Commit Graph
67 Commits
Author SHA1 Message Date
4grayandClaude Opus 5.5 43358e10f2 fix(ui): welcome screens and drop overlay follow the app theme (#1886)
* fix(ui): welcome screens and drop overlay follow the app theme

The welcome dashboard, the empty Sources page and the playlist drop
overlay switched on `prefers-color-scheme`, so with the app set to dark on
a light OS (or light on a dark OS) they painted the other theme's colours.
They now read `--app-*` tokens, which follow the `.dark-theme` class set
from Settings, and the hard-coded blues are derived from the selection
colour (a local "strong" accent mixed toward the heading ink keeps chips
and filled labels at 4.5:1 in both themes).

White rgba() fills that vanished in the light theme (season empty panel,
catalog refinement chips and menu divider, Xtream archive banner and
disabled paginator icons, shell download-activity track, search field)
now use the widget surface, on-surface mixes or the search tokens.

Reads of custom properties that nothing declares are fixed: the release
notes error, the search-layout empty state and the collection reload dim
now use declared tokens; unset hooks are replaced by their fallback value.

New guard `pnpm run styles:theme-references:validate` (CI) rejects
undeclared var() reads and prefers-color-scheme outside the settings
resolver. Electron E2E os-color-scheme.e2e.ts flips the OS scheme under
each explicit app theme and checks colours, contrast and screenshots.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(e2e): retry the rejected-drop comparison when the card dismisses mid-read

The rejection hides itself after 1.8s, and the OS-scheme comparison reads
the overlay twice with an emulateMedia call between. A slow runner could
lose the card between the reads; the comparison now drops again until both
reads see it. A colour that follows the OS still fails every attempt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(e2e): measure welcome text contrast from rendered pixels

The feature and source cards paint gradients, which a backgroundColor
walk ignores, so card titles, descriptions and chips could pass while
falling short on the actual card. Every text on the three surfaces is now
measured against the pixels under it, as the filled button labels were.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(tools): scan only runtime sources in the theme-references guard

The guard read every tracked file under apps/ and libs/ except specs, so
an E2E, mock-server or test-helper declaration could satisfy a runtime
var() read that nothing in the app declares (dashboard-rail-focus.e2e.ts
sets --cover-rail-width), and a test-only read could fail the check. It
now skips spec/test/e2e files, test helpers and stubs, testing projects,
the E2E and mock-server apps and the marketing website.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(xtream): set live paginator tokens through mat.paginator-overrides

The live layout hand-declared --mat-paginator-* tokens, which the UI
guidelines route through the overrides mixin so a mistyped name fails the
build instead of silently doing nothing. Same values, same output.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(tools): skip every test-only naming convention in the theme guard

The runtime scan still read .stub, .harness, .fixtures, .spec-stubs,
.spec-helpers, .spec-fixtures, test-setup and test-double sources, and
test-stubs/ or *fixtures/ directories, so a declaration in one could mask
an undeclared runtime read. A file is now test-only when a dot segment
after its name marks it (spec, stub, fixture, harness, mock, spec-*,
test-*, *-fixtures), its stem is a test bootstrap, or it sits in a test
directory. Runtime names such as xtream-connection-test.service.ts stay
in the scan; no runtime source imports an excluded file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ui): keep welcome card text legible while hovered

At the 16%/10% hover tint the dark theme's body text on a feature card
measured 4.45:1. The hover fill steps up to 10%/6% instead (4.78:1 in
dark, 6.6:1 in light); lift, border and shadow carry the rest. The E2E
now measures feature and source card text while hovered, in both themes;
with the old tint it fails at 4.45.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 12:22:23 +02:00
4grayandClaude Opus 5.5 7abf479de9 fix(ui): one focus ring colour everywhere via the shared mixin (#1882)
* fix(ui): one focus ring colour everywhere via the shared mixin

#1866 gave the app a keyboard focus ring token (`--app-focus-ring`, at
least 3:1 on every surface), but 59 component rings still drew their own
colour: the selection blue under several names (`--app-selection-color`,
`--mat-sys-primary`, `--app-selection-border`, the EPG's `$accent-blue`,
`--embedded-mpv-accent`, `--apd-accent`, the hero's `--accent-color`),
the heading colour, or the overlay's literal text colour. The selection
blue falls to 2.6:1 on the stronger selection tint.

- Every one now includes `focus-ring.focus-ring-declarations`, keeping its
  offset; the projects that newly import `libs/ui/styles` declare
  `ui-styles`.
- The mixin reads the token alone: it is declared on `html` in both
  themes, and the fallback chain cost bytes in every ring.
- `tools/nx/check-focus-ring-colour.mjs` joins `styles:focus-visible:
  validate`: an outline in a focus rule must use the token or, over
  video, the player's `--pc-*` palette. Three deliberate exceptions are
  listed with their reasons, and a stale one is reported. On master it
  reports these 59 rings.
- The keyboard-focus E2E asserts each ring's colour equals the token
  where it is drawn.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ui): search fields and shadow rings take the focus colour too; guard reads them

Local review (Greptile P2): the ring-colour guard read only `outline`, so a
focus indicator drawn as a shadow or a border kept any colour. Search
fields showed focus as a `--mat-sys-primary`/selection border with a 12-16%
halo, the EPG guide's keyboard cell as an inset `$accent-blue` shadow, and
the downloads cards tinted their artwork border with the M3 primary.

- Every one now uses `--app-focus-ring` (the halos and tints keep their
  strength through `color-mix()`); over video the panel's search border
  mixes the overlay's own ring colour.
- The guard reads a focus rule's outline, its unblurred ring or line
  shadows and its border colours. Neutral boundaries (separator and
  widget-border tokens, transparent, currentColor) and blurred lift
  shadows are not indicators. On the previous commit it reports these 21
  declarations.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(tooling): check every focus colour on its own; count exceptions only where they excuse a ring

Greptile on #1882 (2×P2):

- The guard joined a declaration's colours and accepted the lot when the
  token appeared anywhere, so `border-color: var(--app-focus-ring) red`
  or a `color-mix()` of the token and red passed. It now splits each
  declaration into plain colours, every `color-mix()` argument included,
  and checks each one: the token, the player palette or a neutral
  boundary. An outline or shadow ring without a colour is drawn in the
  text colour, which only a border may keep.
- An exception counted as used when its value appeared in any
  declaration (the diagnostic's amber is also a text colour), so a stale
  one was never reported. It now counts only where it excuses an
  off-token focus indicator.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(settings): the row a search result reveals takes the app focus ring

Codex on #1882 (P2): choosing a settings search result with Enter focuses
its row by script, and the keypress keeps `:focus-visible`, so the row's
60%-transparent selection outline was a real keyboard focus ring under
3:1, not the passive marker its guard exception described. The row now
includes the shared mixin (keeping its 12px radius), the exception is
gone, and the keyboard-focus E2E reveals a row with Enter and asserts the
app ring on it; with the old rule it fails on the 60% colour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(tooling): accept focus colour tokens by exact name

Greptile (local, P2): the guard matched `--app-focus-ring` and the neutral
boundary tokens by prefix, so `var(--app-focus-ring-other, red)` or
`var(--app-separator-strong)` passed. It now reads the property a `var()`
names and accepts exactly `--app-focus-ring`, a `--pc-*` palette token or
one of the four neutral boundary tokens; their fallbacks are never drawn,
since the tokens are always declared, so they are not checked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(tooling): check the body of a focus mixin, the shared ring included

Greptile (local, P2): a mixin body has no selector and its includes are
not expanded, so the shared `focus-ring-declarations` itself escaped the
colour check; turning it red would change every ring and pass. The
walker now names the mixin a declaration sits in, and the colour guard
treats the body of a mixin whose name mentions focus as a focus rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(tooling): read logical border sides in the focus colour guard

Greptile on #1882 (P2): only `border`, `border-color` and the physical
sides were read, so `border-inline-start: 2px solid red` in a focus rule
passed. The guard now reads every colour-carrying border property: the
shorthand and the physical and logical sides, with or without `-color`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(tooling): an exception excuses its own colour, not the declaration

Greptile (local, P2): an exception matched the whole declaration, so in
the player stylesheet `box-shadow: 0 0 0 2px #ffffff, 0 0 0 4px red`, or
the white mixed with red, passed. Exceptions now apply to each plain
colour, by exact value, like every other check; an exception counts as
used only where it excuses one of a focus indicator's colours.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(tooling): tell a var() or calc() width from the ring colour

Greptile (local, P2): `outline: var(--ring-width) solid var(--app-focus-
ring)` failed the guard, since any non-length token counted as a colour.
Math functions now count as lengths, and a shorthand with one colour slot
takes its literal colour, else an accepted token (the other `var()`s are
widths), else reports every candidate it cannot prove; `border-color`
still checks a colour per side.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(tooling): read a shadow ring whose spread is computed

Codex (local, P2): with `box-shadow: 0 0 0 calc(1px + 1px) red` or a
`var()` spread, only three literal zeros were left as lengths, so the
shadow looked flat and was skipped. A shadow is now skipped only when it
is provably not a ring: a literal non-zero blur in the third place, or
every length a literal zero with no `var()` that could be a spread. The
test with a variable-width token ring now asserts it is read, not skipped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(tooling): read shadow lengths by type, with the colour first or no blur

Greptile (local, P2): the guard took a shadow's first three tokens as its
lengths, so `0 2px` (a line in the text colour, no blur) was skipped as
blurred and a colour-first lift shadow was read as a ring. Lengths are
now read by type: a colour sits before or after them, never between, and
a missing blur is zero. A `var()` counts as a possible length, so a
shadow is skipped only when the first three possible lengths prove a
blur, or every length and the first four possible ones are zero.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(tooling): a negated focus condition does not make a focus rule

Greptile (local, P2): the colour guard read `:focus-visible` inside
`:not()` as a focus rule, so a hover style such as
`.button:hover:not(:focus-visible) { border-color: red; }` failed. The
selector is now tested without its `:not()` arguments; a focus condition
elsewhere, `:has()` included, still makes a focus rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(tooling): read a width in any CSS length unit

Greptile (local, P2): only px, em and rem were lengths, so `outline: 1pt
solid var(--app-focus-ring)` took `1pt` for the colour and failed. Every
CSS length unit (absolute, font-relative, viewport and container) now
counts as a length.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ui): the open-in-playlist chip rings in the app focus colour

Codex on #1882 (P2): the chip's inline component styles drew its focus
ring in `--app-selection-color`, out of the colour guard's reach (it reads
stylesheets). It was the only inline-style focus ring in the repository;
it now uses `--app-focus-ring` like every other ring.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(ui): move the open-in-playlist chip styles into a stylesheet

Codex on #1882 (P2) suggested this over scanning TS inline styles: the
chip was the only component with a focus ring in inline `styles`, which
the colour guard does not read. Its styles now live in
`open-in-playlist-chip.component.scss` unchanged, the ring through the
shared `focus-ring-declarations` mixin, so the guard covers it (a drifted
colour there is reported).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 10:40:08 +02:00
4grayandClaude Opus 5.5 7d7d691ce4 fix(ui): skeletons that match the M3U header and dashboard rails (#1887)
* fix(ui): skeletons that match the M3U header and dashboard rails

Loading skeletons now take their boxes from the code that draws the
content, so the content lands where the skeleton was.

- M3U channel list: the loading state's headers, divider, groups rail
  and first row share `styles/_channel-list-layout.scss` with the All
  channels, context and groups views. The All channels list moved down
  by 19px when a playlist loaded; the group header was 79px against the
  real 69px (the panel header's 52px minimum plus the padding that
  `.sidebar .sidebar-header` gives it, now one mixin both include).
  Group rows start at the list's top-left like the virtual-scroll rows.
- Dashboard rails: `lib-dashboard-rail-skeleton` replaces six copies of
  the skeleton markup and takes the rail's layout and aspect ratio
  (channel cards for live favorites, 16:9 for sources, posters with a
  title and a meta line otherwise). Its header, track, cards and text
  line boxes come from `_dashboard-rail-geometry.scss`, which the rail
  itself now uses. The skeleton was 328px against the 337.8px poster
  rail and a 2:3 poster in front of the 83.5px channel cards.
- One shimmer in `libs/ui/styles/_skeleton-shimmer.scss`, mixed from
  `--app-on-surface`: the rail sweep between the widget surfaces was
  1.04:1 on the light theme. The dashboard hero and the detail
  skeletons (through a forward) use it too.
- Electron E2E `skeleton-geometry.e2e.ts` holds the IPC channel behind
  each loading state, compares skeleton and content boxes to 0.5px at
  1280x700 and caps the dashboard layout-shift score at 0.01.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(dashboard): keep rail heights when a skeleton stands for a label-less or live rail

Review follow-up (Greptile, local):
- Rails that replace a skeleton (Continue Watching, sources, Xtream
  recently added, TMDB) keep an empty meta line under cards without
  labels (`reserveMetaLine`), as the skeleton always draws one; a
  Continue Watching rail of movies with no known duration came in
  shorter.
- The recent-content skeleton follows the recent rail it waits for:
  posters while Continue Watching is still loading with no cards,
  channel cards otherwise.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 08:16:38 +02:00
4grayandClaude Opus 5.5 ea7efdbd9b fix(workspace): open a source row from the keyboard (#1885)
A Sources row opened only on a pointer click: the row div was not
focusable and had no key handler, so a keyboard user could Tab to its
actions but never open the playlist.

Following app-content-card, the row's title and meta block becomes the
keyboard activation element: role="button", tabindex="0", named by the
source title, aria-current on the active source, aria-disabled while
busy. Enter and Space open the source (Space prevents the page scroll).
The drag handle, health indicator and actions stay its siblings, so no
control is nested inside a role="button" and their keys never bubble
into it. A click anywhere on the row still opens it via the row.

The row draws the ring with the shared focus-ring mixin, inset, because
the list's scroller would cut a ring drawn outside the full-width row.
playlist-shared-ui now declares its ui-styles stylesheet dependency.

Tests: unit spec for Enter/Space, bubbling from an action button, busy
and selected states; Electron E2E tabs to a source row, checks the ring
and opens it with Enter (fails on the previous component).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 08:15:04 +02:00
4grayandClaude Opus 5.5 4281194cb4 fix(a11y): accessible names for icon-only buttons and a guard (#1880)
* fix(a11y): accessible names for icon-only buttons and a guard

Icon-only buttons named only by a matTooltip (or by nothing) had no
accessible name: the icon ligature is hidden from assistive technology
and a tooltip only adds a description. 22 buttons on master, in .html
and inline templates, now carry a translated aria-label bound to their
tooltip key. The channel row's favorite star keeps one label
("Favorite") and reports its state through aria-pressed.

New guard `pnpm run a11y:icon-buttons:validate` (CI step) fails on a
<button> whose only content is mat-icons (through control flow,
ng-container and spinners) without aria-label, an aria-label binding
or aria-labelledby. The inline-template lookup moves to a shared
tools/nx/inline-templates.mjs used by the icon-ligature guard too.

web-e2e icon-button-names.e2e.ts runs axe's button-name rule on a
channel list, the channel details dialog, Sources, the playlist info
dialog and an Xtream search. Five new keys are translated in all 18
locales.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(a11y): text hidden from assistive technology never names an icon button

Greptile: a static aria-hidden="true" child's text counted as the
button's name, so <button><mat-icon/><span aria-hidden="true">Close</span>
passed the guard. Hidden subtrees now add only their icons.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 07:34:04 +02:00
4grayandClaude Opus 5.5 ea515280e3 fix(catalog): short sort chip label and radio sort menus (#1881)
* fix(catalog): short sort chip label and radio sort menus

The catalog sort chip now shows one short label per mode at every width
("Newest", "A-Z", "Top rated") and keeps the full "Sort: ..." text in its
aria-label, so it no longer truncates in long translations. The rating
chip follows the same rule and puts its clear icon after the value. The
1120px container query that swapped full and compact labels is gone.

Single-choice mat-menus get a shared appMenuItemRadio /
appMenuItemRadioCheck pair: rows are menuitemradio with aria-checked, and
every row reserves a leading check slot that is visible only when
checked. Material projects every mat-icon ahead of the label, so the old
check rendered only on the chosen row shifted that row's label. Applied
to the catalog refine menu (sort and rating groups), Xtream live
channels, M3U all channels and groups, unified collection favorites,
workspace categories and workspace sources.

Adds WORKSPACE.SORT_CHIP keys in all locales and drops the unused
WORKSPACE.FILTER_RATING key.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(catalog): read the full sort to screen readers from hidden text

A plain div cannot carry an accessible name, so screen readers could skip
the chip's aria-label and read only the short "Newest". The full
"Sort: ..." text now sits in a visually hidden span and the short label
is aria-hidden; the polite live region announces the full text on change.
The spec checks the text outside aria-hidden, and the E2E checks the
chip's accessibility tree in en, de, ru and hu.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 23:20:42 +02:00
4grayandClaude Opus 5.5 e1d4f00d93 fix(i18n): follow runtime language switches in stored and memoized labels (#1872)
* fix(i18n): follow runtime language switches in stored and memoized labels

A label translated once and kept kept its language after Settings ->
Language until the data reloaded or the app restarted. The Stalker store
baked `translate.instant('PORTALS.ALL_CATEGORIES' | 'PORTALS.ALL_RADIO')`
into its category list, so the every-item genre stayed English in the
rail, the live header, the fullscreen panel title, the catalog title and
the search scope.

Stalker: the every-item genre now carries `labelKey` and an empty
`category_name`; the category views render the key through the translate
pipe. `getSelectedCategoryName` becomes `getSelectedCategoryLabel`
({ name, labelKey }) and every text consumer goes through
`stalkerCategoryLabelText()` inside a computed that reads a language
signal, so no consumer can show the empty name or a stale translation.

Same class elsewhere (computed or stored `instant` text without a language
signal): the Xtream import overlay title and progress (shell-provided),
the Settings About version note (stored in a signal on the page where the
language changes), the remove-all-playlists progress, the context panel's
status/error text and category search, and the movie/series heroes
(`createVodDetailsHeroState`, `createSeriesHeroState`, the Xtream movie
presenter), cast & crew "Director", the M3U sidebar count, the M3U movie
hero and the collection panel title. These read
`toSignal(onLangChange.pipe(startWith(null)))` and also recover when the
translation file lands after the first render.

Documents the rule in the UI guidelines and the Stalker store contract.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(stalker): record getSelectedCategoryLabel in the store API baseline

getSelectedCategoryName is gone without an alias: a name-only selector is
empty for the every-item genre, whose label is a translation key.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(i18n): one translation tick that also follows late dictionaries

Greptile asked for coverage of a dictionary that lands after the first
render. That exposed a gap: every hand-written tick listened to
`onLangChange` only, but a start-up without a saved language never calls
`use()` - the default dictionary landing fires `onDefaultLangChange` alone,
so computeds that ran before it kept raw keys. Dictionary updates
(`onTranslationChange`) were missed the same way.

`injectTranslationTick()` in `@iptvnator/pipes` merges the three events the
translate pipe re-renders on (the embedded MPV player already did). All 37
ticks use it now, including the hero factories' `language` dependency. New
specs cover the delayed default-language load, a late `use()` dictionary
and a dictionary update; the hero specs add the start-up case.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(i18n): stub the Xtream selection the merged search scope reads

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(i18n): series view reads the shared translation tick

#1871 added an onLangChange-only tick for the synthetic episode titles; a
start-up dictionary that lands without use() would leave raw keys.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(i18n): move injectTranslationTick to @iptvnator/services

The tick injects TranslateService and subscribes, so it is injectable
runtime state; nx-workspace-boundaries.md reserves type:util for pure
helpers and contracts. @iptvnator/services is the shared type:data-access
project every consumer domain may depend on, and it imports none of them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 18:31:38 +02:00
4grayandClaude Opus 5.5 17a2b1b3b0 fix(ui): visible keyboard focus everywhere via a global focus-visible fallback (#1866)
* fix(ui): visible keyboard focus everywhere via a global focus-visible fallback

The global stylesheet removed the outline from every input, button,
textarea and `:focus`, so Tab users saw no focus on about 110 raw buttons:
detail actions, season tabs, chips, title results, list rows.

- Remove the outline only under `:focus:not(:focus-visible)` and draw a
  fallback ring on every other `:focus-visible` element. Both rules sit
  in `:where()`, so any component that styles its own focus still wins.
- One recipe: `focus-ring-declarations` moves to
  `libs/ui/styles/_focus-ring.scss` and reads `--app-focus-ring`, declared
  per theme (light #1d63e0, dark #8cbaff) with at least 3:1 on every app
  surface and selection tint; `m3-theme.spec.ts` measures it. The card
  ring, the detail actions, the portal sidebar and both context panels
  use the mixin (the panels' selection-blue ring fell to 2.97:1 on the
  active item).
- Material Tab stops (buttons, switches, button toggles, checkboxes) take
  the ring over their 12% focus state layer; menu items and options keep
  Material's highlight.
- Surfaces over video (player controls, vendor chrome, fullscreen panels,
  Up Next rail) point the ring at the player's text colour.
- The selected season tab drew its border with `outline`, which outranks
  the fallback; it is a spread shadow now.
- Guard: `pnpm run styles:focus-visible:validate` (CI) rejects a global
  `outline: none` on an unscoped selector and a missing fallback.
- Electron E2E `keyboard-focus-ring.e2e.ts` tabs through the detail
  actions and season tabs, a catalog grid with its refinement chips, the
  Sources list and Settings in both themes: one ring per stop, 3:1 where
  measurable, none after a click.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ui): keep the player ring in the fullscreen panel; tighten the focus guard and E2E

Local review round 1 (Codex P2, Greptile 3×P2):

- The fullscreen channel panel carries `dark-theme`, whose context declares
  the theme ring again, so its own controls ringed in #8cbaff. Point the
  token back at the player's text colour on `.fullscreen-channel-panel
  .dark-theme`; the web series-playback E2E checks the close button's ring.
- The guard took a container-scoped rule (`.panel :focus-visible`), a mixin
  body or a media query as the global fallback. The fallback is now the
  whole selector, outside any at-rule.
- The keyboard-focus E2E now fails a ring that an `overflow: hidden`
  ancestor (up to the scroll container) cuts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ui): show focus where a component removes its outline; catch invisible rings in the guard

GitHub review round on #1866 (Codex P2, Greptile P2):

- A component rule that sets `outline: none` outranks the zero-specificity
  fallback. Re-audited every one: two hid a Tab stop with nothing else to
  show. The EPG list row (`role="button"`) now draws an inset ring, and the
  command palette underlines its search row while the field has focus.
  The others already show focus on the field's wrapper, on another
  element, or as a highlight inside an arrow-key composite (the "…" menu,
  the guide's search listbox); the guidelines now state the rule.
- The guard read only a bare zero or `none` as a removal. It now reads a
  zero width, a `none`/`hidden` style or a transparent colour anywhere in
  the shorthand or longhands, so `outline: 0 solid transparent` is
  reported and `outline: 2px solid transparent` is no fallback.
- The keyboard-focus E2E walks the live EPG list and the command palette
  too, and reads an inset or offset shadow line as a ring.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(player): keep --pc-text a literal palette colour

Master's palette spec (#1854) reads `--pc-text` from the controls host as
a literal; this branch had made it `#{palette.$text}`, failing "sets the
timeline label on the dense glass" on the merge ref. The host declares the
literal again, and a spec keeps the palette's `$text` (the focus ring
token for surfaces beside the host) equal to it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* build(epg): declare the ui-styles dependency of the list row's focus ring

The EPG list row now `@use`s `libs/ui/styles/_focus-ring.scss`; Nx cannot
infer a Sass import, so `ui-epg` declares `ui-styles` like the other
consumers of the shared partials.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(tooling): a styleless outline shorthand hides focus too

`outline: 2px` or `outline: red` resets the style to `none`, yet the guard
counted either as a visible fallback. A shorthand without a drawn style
(or a `var()` that may carry one) now counts as removing the outline, as
do `initial` and `unset`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 17:10:24 +02:00
4grayandClaude Fable 5.1 50d45bc7c0 feat(details): redesigned episode list, section rhythm and hero fade (#1859)
* 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>
2026-10-10 10:44:05 +02:00
4grayandClaude Opus 5.5 2dff91c9f2 fix(ui): replace icon ligatures missing from the font and guard them (#1864)
* fix(ui): replace icon ligatures missing from the font and guard them

The download and recording queues showed the literal text "file_off" for a
missing file: the bundled Material Icons font (material-design-icons-iconfont
6.7.0) has no such ligature, so it rendered as text overflowing the icon box.
Use error_outline, which the missing state's tertiary palette keeps distinct
from the filled error glyph of a failed download.

Add `pnpm run styles:icon-ligatures:validate` (CI): it parses templates with
@angular/compiler and TypeScript with the compiler API, collects static
<mat-icon> text, the string results of its bindings, icon/*Icon inputs and
icon-named TypeScript values, and fails on a name missing from the font's
codepoints file. Two listed codepoints the font has no ligature for
(rounded_corner, stairs) are excluded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(tools): check quoted inline templates in the icon guard

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(tools): accept every codepoint and report escaped template lines

The first measurement rendered the names without the package's CSS. With
material-design-icons.css, all 2,193 codepoint names, rounded_corner and
stairs included, draw as one glyph, so the guard accepts the whole file.

Inline templates now map each cooked position back to the source through
escapes, line continuations and CRLF, so a name after `\n` in a quoted
template is reported on the line it is written on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 10:31:21 +02:00
4grayandClaude Opus 5.5 8fabb88106 fix(collections): one confirmed Clear recently viewed action (#1865)
A playlist's own recently viewed page (/workspace/<provider>/<id>/recent,
opened from the dashboard's recently viewed rails) showed two clear buttons:
the page's "Clear recently viewed <type>", which confirms through
createClearCollectionAction, and a header delete_sweep button that cleared
every tab of the playlist at once without asking. Remove the header bulk
action end to end, and the unreachable, unconfirmed clear button of the M3U
channel list's recent view, so every clear goes through
createClearCollectionAction. Drop the two i18n keys only those buttons used.

E2E: a shared helper asserts one clear control per page and one confirmation
per press; the M3U, Xtream and Stalker recent tests cancel first (rows stay),
then clear once on the playlist's own recently viewed page.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:45:21 +02:00
4grayandClaude Fable 5.1 8030ced95a feat(settings): grouped navigation, cards and switches from the settings concept (#1853)
* feat(settings): grouped navigation, cards and switches from the settings concept

Implements the Claude Design "Settings concept" handoff.

Navigation: the sidebar is a 20px "Settings" title over App, Library, Devices
and Data groups, with About pinned to the footer beside the installed version
and an update badge. The active item is a soft fill without a border. Esc
leaves settings like the header Back. Reset is no longer a page: its one
destructive action is the last card of "Backup & data".

Pages: a title and one-line subtitle, then rows grouped into titled cards
with one right-aligned control column. Selects are compact without floating
labels, checkboxes are switches, segmented controls are pills, and
descriptions are capped at 56ch. "Show subtitles" moves to Playback, the
Embedded MPV note becomes a callout under the player select (only while it
is selected), and the TMDB attribution becomes the About footer.

EPG: Add and Refresh all sit in the Sources header, the empty state has its
own Add, the format examples are one line, Clear EPG data is its own row and
the offset is a stepper. About: version, update state and channel share one
card; the nightly warning is a callout shown only while Nightly is selected,
with a shortcut to the backup page; the support buttons are neutral with
coloured icons.

Save model: the save bar stays (design option B) and now floats over the
content column, counts the staged changes, answers Cmd/Ctrl+S, and marks
pages with staged edits in the sidebar. A saved change that waits for a
restart shows a dismissible notice.

Rows stack under 600px of pane width (the page is a size container, so the
persistent sidebar is accounted for). Page-specific styles moved into the
About, EPG, Backup and Remote control section stylesheets to stay within the
component style budget. New SETTINGS keys are translated in all 19 locales
and NAV_RESET is removed; brand names and loanwords that stay English are in
the identical-value baseline.

E2E: Material slide toggles report aria-checked after the next render, so
both suites toggle through a setSwitch helper; the settings nav is a
navigation landmark, so the Dashboard rail link is scoped to the rail.

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

* fix(settings): compare restart notices against the running app, fix CI fallout

The restart notice now lists only the restart controls whose saved value
differs from what the running app uses: the stored values at first load,
or what the embedded engine reports it actually runs for the frame-copy
opt-in. Saving the launch value back withdraws the notice instead of
leaving it up with no chip to explain it. The refresh reads the current
list untracked and writes only a changed one, because the engine probe
effect calls it.

CI: the zoneless checklist dropped the deleted Reset section component, and
the theme-tokens Electron E2E floats the recording folder label on the
Playback page now that the settings selects carry no floating label.

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

* fix(settings): keep a dismissed restart reminder away until the setting changes

"Later" now records the saved value it dismissed, for the rest of the app
run, so an unrelated save (or reopening Settings) does not bring the same
reminder back. It returns once a restart setting is saved with another
value.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-09 20:39:07 +02:00
4grayandClaude Fable 5.1 4c6540679d perf(workspace): paint the content area's rounded corner instead of clipping it (#1857)
* perf(workspace): paint the content area's rounded corner instead of clipping it

The `border-radius: 16px 0 0 0` on the scrolling content area made Blink
clip every composited effect inside it (the rail chevrons' and hero
controls' backdrop filters, running transform animations, isolated
groups) through a mask layer synthesized per effect: its shader path
for rounded clips needs four equal radii on macOS and a translation-only
transform between the clip and the effect, and the fallback mask is the
size of the whole clipped area, about 24 MB of tile memory at 2x on a
16" display. Twelve of them put the dashboard at 537-655 MB of tile
memory against Chromium's 512 MB budget ("tile memory limits exceeded",
blank tiles while scrolling).

The scroller now sits in a frame that carries the radius and the shadow
without clipping; the scroller clips to a rectangle and is a stacking
context; a 16px corner piece on the frame paints the notch in the body
colour. Dashboard tile memory (1728x1000 window at 2x, Xtream mock):
idle 537 -> 242 MB, slide switch 598 -> 303 MB, scrolling 655 -> 165 MB;
movie detail 110 -> 89 MB, series detail 187 -> 143 MB. The new
Electron E2E asserts that no element-less drawing layer spans half the
content area (eight did before).

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

* test(e2e): identify synthesized clip masks by missing owner node and reasons

A content layer's owner node is also missing when its first paint chunk
belongs to an anonymous box (the rail track and the content scroller
flipped between runs), so the dashboard compositing check now requires
both: no owner node and no compositing reason, which only a synthesized
mask has. It also waits for Blink's layer debug info to be filled after
enabling the LayerTree domain, and asserts that the backdrop-filtered
controls really are composited layers, so the mask check cannot pass
vacuously. Verified under `--disable-gpu` as well: the old scroller
radius still produces seven masks there, the fix none.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-09 19:45:21 +02:00
4grayandClaude Opus 5.5 7bd7ff5ac7 fix(ui): start a sidebar drag from its CSS width, not its border box (#1856)
ResizableDirective started a drag from offsetWidth, which includes
padding and border, but writes the result to style.width. The hosts
are content-box, so the first move widened a padded host by its
padding plus border: 23px on the workspace categories panel, 1px on
the Favorites sidebar. Each drag also saved a width that much larger.

A drag now starts from the computed CSS width (the box style.width
sizes), falling back to the directive's own width when that is not a
length. Border-box hosts (live-layout and groups rails) are unchanged.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 22:09:41 +02:00
4grayandClaude Opus 5.5 4477775b4d fix(ui): keep the saved sidebar width when a narrower sidebar clamps it (#1852)
The workspace context panels and Favorites share one stored
`sidebar-width` but clamp it to different limits. Loading wrote the
clamped value back, so visiting Settings (max 400) cut a categories
panel widened to 520 down to 400; the saved width could only shrink.

Load now clamps for rendering only. Legacy alias keys migrate their raw
value, an unparseable value falls back to the default without a write,
and only a drag that changes the width persists — a click on the handle
no longer saves the clamped width either.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 20:04:22 +02:00
6c8f5028c7 perf(epg): render only the timeline programmes near the visible range (J3) (#1817)
* perf(epg): render only the timeline programmes near the visible range (J3)

Selecting a live channel rendered every programme block of its schedule
(about 240 for the Xtream mock) while the stream was starting: about 6,000
of J3's 6,199 renderer.domMutationsToPlaying. The ribbon now renders the
blocks, ticks and day dividers within half a viewport of the visible range;
the track keeps the full schedule's width, so positions, the scrollbar and
scroll-to-now are unchanged.

A resize reported before the scroll-to-now must not re-centre the window
(it once jumped to the schedule's start and back); resizes only widen it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(e2e): sweep the EPG ribbon to see every programme

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(epg): estimate the ribbon window once per channel or mount

The live estimate followed the centred day and the 30 s now tick while the
ribbon was not yet scrolled: a small scroll across midnight re-centred the
window on the next day's noon and left the visible range empty, and the
host width was re-read (a forced layout) on every tick.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(e2e): keyboard Tab reaches programmes beyond the rendered EPG range

Greptile flagged that windowing the ribbon could strand keyboard users at
the last rendered block. Focusing a block scrolls it into view, which
re-windows before the next key press; the new test walks ten unrendered
programmes past the range with Tab and with Shift+Tab, and fails if focus
ever leaves the ribbon.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(epg): re-centre the ribbon window in the same pass as a zoom

Review follow-up (Codex): a zoom (button, Ctrl/⌘ wheel, coalesced wheel
burst) or a group expansion changed the scale before the anchored
scrollLeft landed on a later frame, so the window was the previous
centre at the new scale until the next scroll event re-measured it. The
ribbon could flash empty or show the wrong section. The zoom controller
now hands the window the minute its anchored scroll will centre, and a
group expansion the group's centre, together with the new scale
(TimelineWindowController.centreOnMinute).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(epg): window the scroll position a zoom can actually reach

Review follow-up (Greptile, Codex): a zoom-out anchored right of centre
at the ribbon's start computed a negative scroll position. The window
centred on it, the browser kept scrollLeft at 0, and with the position
unchanged no scroll event re-windowed, so the visible right-hand part
stayed empty. The centre now uses the position clamped at 0, and once
the frame applies scrollLeft the window re-centres on what the browser
kept, which also covers the clamp at the end of the track.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(epg): close the programme popover when the window drops its block

Review follow-up (Codex): the ribbon window can remove a focused narrow
block on scroll, and a removed node fires no focusout, so the fixed
tooltip kept showing a programme no longer on screen. The popover is now
a linkedSignal over the rendered items that keeps its state only while
its block (by key) is still rendered.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(epg): re-measure the ribbon window when the axis origin moves

Review follow-up (Codex): a time offset that carries the first programme
across midnight moves the axis origin and every track position while
scrollLeft and the ribbon stay put, so no scroll event fires and the
window kept its epoch-time centre at a different pixel position. The
window identity now includes the axis start; when only that changes,
the viewport is measured from the ribbon instead of re-estimated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: 4gray <fourgray@proton.me>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 22:07:25 +02:00
acb8cb0318 fix(workspace): lead header Back to a parent route when the page opened the session (#1830)
* fix(workspace): lead header Back to a parent route when the page opened the session

Settings, Discover, actor and in-portal search registered a header Back
that only ran Location.back(). As the first entry of the session (deep
link, reload, restored view) that did nothing in Electron and left the
app in a browser.

WorkspaceBackNavigationService.back(resolveParent) keeps Location.back()
while the previous entry is an in-app one, and while that is unknown
because the Navigation API is missing. Otherwise it opens the page's
parent with replaceUrl, so history Back cannot return to the page:

- Settings: the first workspace view (resolveDashboardPath()).
- Discover: the catalog section it lists (vod for movies, series for TV).
- Actor and search: the portal root, which redirects to its default
  section within the same navigation.

The web E2E opens these pages in a fresh tab: a page.goto in the same
tab leaves the previous document behind, often at the parent's URL, so
history Back passed without the fix. Electron covers settings after a
window reload.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(workspace): lead first-entry Back to the parent without the Navigation API

Review follow-ups (Greptile):

- Without the Navigation API (older Safari and Firefox) back() always
  called Location.back(), so a page that opened the session still left
  the app. The service now tracks the router's in-app history depth there
  (trackRouterHistoryDepth): first navigation 0, push +1, replacement
  keeps it, a traversal restores the depth recorded for its entry. Depth
  0 opens the parent; an unknown depth (an entry from before a reload)
  keeps Location.back().
- Stalker's Discover (movie/tv section), actor and search pages now have
  tests that they hand the service the parent under the portal :id.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(workspace): adopt the router navigation the Back depth tracker missed

Review follow-up (Codex, Greptile): the lazy workspace shell creates the
Back service after the first NavigationStart, so the tracker saw only its
NavigationEnd, left the depth unknown and counted the next push as the
first entry. It now adopts the router's current or last successful
navigation when it starts: a first navigation is depth 0, a later one
leaves the depth unknown (browser history Back), and a late start of the
adopted navigation is not counted again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: 4gray <fourgray@proton.me>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 00:24:00 +02:00
4gray 84aef83a6c feat(workspace): move page Back buttons into the header and add a history fallback (#1814) 2026-10-04 12:16:39 +02:00
4grayandClaude Opus 5.5 040acbd976 feat(tools): land mixins from another Sass module where they are included (#1795)
A mixin included from another Sass module (`@use 'x'; @include x.m`) now
lands where it is included, as a same-file mixin already did: its
top-level weights meet the including rule's family, and its family
becomes that rule's, in the order Sass writes them out. Include sites
resolve through the existing `@use`/`@forward` scope resolution, the
definition Sass resolves is the one that runs, and an include inside a
mixin body resolves where that mixin runs. The header's "Not traced" list
keeps what stays out (positional arguments, content blocks placed by
another module's mixin, a name two `@import`ed files define).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:46:32 +02:00
4grayandClaude Opus 5.5 e8b181fcea fix(ui): Cyrillic/Greek weights, html lang, weight normalisation (#1780)
Load Roboto 600/700 and DM Sans 700 so Cyrillic and Greek headings render
real semibold and bold faces instead of a synthetic bold, keep
<html lang> in step with the UI language, and move every font weight onto
the 400/500/600/700 scale (JetBrains Mono at 500 or lighter; the dashboard
LIVE badge now uses the interface font at 700).

Add the `styles:font-weights:validate` ratchet guard and its CI step. It
reads stylesheets much as Sass and the browser do (cascade, layers,
mixins, content blocks, `@extend`, `@at-root`, `:is()`/`:where()`,
keyframes) and lists what it deliberately does not trace in its header.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 08:55:23 +02:00
4gray ab8460338a feat(ui): cinematic movie and series details pages and dashboard hero (#1792) 2026-10-03 23:08:16 +02:00
4gray 4adc3ba20f fix(ui): one watch-progress colour in app chrome and in the player (#1798) 2026-10-03 15:11:10 +02:00
4grayandClaude Opus 5.5 a8dd1eaa97 fix(import): consistent add-source forms with masked passwords and URL errors (#1796)
* fix(import): consistent add-source forms with masked passwords and URL errors

- Mask the Xtream password in add and edit (and the Stalker one in edit)
  behind a shared PasswordVisibilityToggleDirective: one translated
  "Show password" label, state in aria-pressed, type="button".
- Give the Xtream server URL its own mat-error and a neutral hint instead
  of the EPG file error; give the M3U URL a mat-error.
- Use "Playlist title" in every add form, "MAC address" casing, a single
  ellipsis in "Validating portal…" and one "Add playlist" submit label;
  translate the method radiogroup's aria-label.
- Show Stalker refusals inline under the portal URL (role="status", like
  the Xtream connection test), translated in the template and cleared by
  edits; translate the snackbars for outcomes that close the dialog.
- Translate new strings into all locales; reuse the identical Stalker URL
  error translations; fix MAC casing and ellipses; drop unused keys.
- Unit specs per form, edit-dialog spec, new add-source-forms web E2E;
  update E2E locators; UI guidelines Forms section; Stalker contract.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(import): mask the password again when an add form is cleared

Clear erased the password but left the visibility toggle on, so the next
password typed in the Xtream or Stalker form showed in plain text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 13:13:54 +02:00
4gray cb252940b2 fix(workspace): move detail Back into the header and drop the rail brand (#1789) 2026-10-03 11:17:34 +02:00
4grayandClaude Opus 5.5 23a1860119 fix(ui): destructive confirmations, verb labels and provider icons (#1783)
* fix(ui): destructive confirmations, verb labels and provider icons

Confirmations: ConfirmDialogData.confirmLabel is required, so no dialog can
fall back to "Yes"/"No"; the dismiss defaults to "Cancel" and
`tone: 'destructive'` styles the confirm with .app-destructive-button. Every
caller names its action ("Remove playlist", "Clear", "Refresh playlist",
"Cancel download" with a "Close" dismiss). The confirm button has the
confirm-dialog-confirm test id and drops its no-op color="primary".

The no-op `warn` color input becomes .app-destructive-button on the EPG
mapping, playlist item, error view, EPG/reset settings, delete-all and source
cleanup buttons, and on the unsaved-changes dialog's Discard.

Provider icons come from SOURCE_TYPE_ICONS in shared/interfaces (Xtream
cloud, Stalker cast, M3U playlist_play / link / description / subject) in the
add dialog, auto-import, empty state, playlist switcher, playlist rows,
dashboard source rail, command palette, Sources filters and both reset
summaries. Stalker no longer borrows the Dashboard icon, and Xtream no longer
shares a glyph with M3U URL playlists.

The playlist error view removed a playlist through the stale
PlaylistActions.removePlaylist: it dropped the playlist from state before the
delete ran, swallowed failures, skipped the source activity guard and showed
no toast. It now uses PlaylistDeleteActionService like every other removal,
commits only a completed delete, toasts and goes home. The unused action and
its effect are removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ui): one provider icon per playlist row, imperative Korean remove label

A restored Stalker or Xtream playlist can also carry a URL, and the row's
independent checks then showed the M3U URL icon next to the provider icon.
The row now switches on resolvePlaylistSourceIconKey(), the precedence every
other surface uses, so each source shows exactly one icon.

HOME.PLAYLISTS.REMOVE now names the confirm button and the row's delete
tooltip; in Korean it read "the playlist has been removed". It now says
"remove playlist", like every other locale.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ui): keep the auto-refresh badge on playlist rows with one provider icon

Showing one provider icon per row moved the auto-refresh badge into the M3U
branches only, so a restored Stalker playlist with a URL and auto-refresh
lost it although the URL is still re-fetched. The row now renders one icon
container: the provider icon from the shared precedence, then the badge for
any row with a URL or a local M3U, exactly the rows that showed it before.
The Xtream portal-status dot, used without source health, keeps that corner.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ui): let the playlist row's cancel action render in the error color

The row's action buttons set `color: inherit`, and the selected row does so
again with more specific selectors. Both beat Material's token-driven icon
color, so the .app-destructive-button cancel action kept the row color
(selection blue on the active row). Pin the cancel button to
--mat-sys-error in both row states.

The large-deletion Electron E2E now checks the cancel color in both themes;
without this rule it reads rgb(47, 123, 255) instead of the error red.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(ui): give the dialog service spec the now-required confirm labels

ConfirmDialogData.confirmLabel became required, and the spec still built
confirmations without one. Jest only transpiles, so the suite stayed green,
but the "Typecheck Jest spec programs" CI step rejected it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 20:36:16 +02:00
4gray b6357da6af fix(dashboard): keep the hero skeleton until every hero source has loaded (#1782) 2026-10-01 23:41:21 +02:00
4gray 572034f3be fix(ui): declare Material system tokens and migrate dead --mdc overrides (#1775) 2026-10-01 18:02:50 +02:00
4grayandClaude Opus 5.5 adb4889b0f fix(player): keyboard focus, contrast and ARIA for controls and settings (#1769)
* fix(player): keyboard focus, contrast and ARIA for controls and settings

Dock and settings-panel icon buttons draw a 2px --pc-text ring on
:focus-visible, and Material's theme-coloured focus layer is off, so
keyboard focus shows on video in the light theme too. A focused selected
subtitle swatch now differs from one that is only selected.

Settings headings read --pc-text-secondary on denser glass
(--pc-glass-bg-dense, 0.86): 4.5:1 or more over mid-grey and white
frames. They wrap (overflow-wrap: anywhere, hyphens: auto), so long
German and Russian headings stay inside the sheet's heading column.

The settings panel is now radio groups only (SettingsRadioGroupDirective
over a CDK FocusKeyManager): one Tab stop per group on the checked option,
arrows, Home and End move focus without applying, and Space/Enter checks.
The dialog and its groups are named by real h2/h3/h4 headings, the
load-file action sits outside the subtitle radio group, the subtitle and
speed chips carry their value in their name ("Subtitles: English"), and
tune has aria-haspopup="dialog".

Adds a web E2E for the keyboard path in both themes with an axe check on
the open panel, and de/ru sheet heading wrapping; axe-core is a new dev
dependency for it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(player): arrows check settings radios; subtitle chip reads On

Review follow-up:
- Arrow keys, Home and End now check the settings radio they reach, as a
  native radio group does (the directive clicks it, so the template's
  handler applies the choice); an option the engine already reports as
  checked is not applied again.
- With subtitles on but no track marked selected yet (the engine can
  report the switch before the track list), the subtitle chip reads and
  announces "On" (new SUBTITLES_ON key, 19 locales) instead of "Off".
- The swatch row has 4px padding on every side, so the outer focus ring
  is not clipped at the scroll edge of the panel body.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(player): re-apply a settings radio while a switch is pending

The arrow-key check skipped any option the engine still reported as
checked. Arrowing from audio track A to B and back to A before the engine
confirmed B therefore sent no command for A, and playback ended on B with
focus on A. An arrow move always lands on an option other than the last
one applied, so it now applies unconditionally.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 06:37:02 +02:00
4gray 444ec06e94 fix(detail): keep the Back button from covering detail content (#1763) 2026-09-30 22:51:17 +02:00
4grayandClaude Opus 5.5 cb317bad4c fix(ui): keep dialog action rows on one line (#1762)
* fix(ui): keep dialog action rows on one line

Settings "Unsaved changes" dialog:
- Cancel / Discard / Save replace the phrase labels in all 19 locales, and
  the dismiss now comes first; the shared CANCEL key replaces the unused
  UNSAVED_DIALOG_STAY.
- At the 640px phone breakpoint the actions stack one per row, full width,
  in DOM order.

EPG programme dialog:
- mat-dialog-title gives the dialog an accessible name.
- The footer Close is the only dismiss; it comes first and the primary
  action last.
- The archive copy/download tools move under their notice, so the footer
  stays on one row.
- Channel rows now open it through EpgProgrammeDialogService, which owns
  the 540px config and a panel class scoping the surface overrides.

Adds a web-e2e layout spec (en, de, ru, fr, hu, ar on one row; de and ru
stacked on a phone), extends the Electron EPG spec to all three openers,
and documents the dialog contract in the UI guidelines.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(epg): stack programme dialog actions on phones

Below the 640px phone breakpoint the viewport caps the programme dialog,
and a long translated primary label ("Regarder depuis le début") no
longer fit beside Close. The footer had no wrap, and the dialog hides
overflow, so the label was clipped.

- At the phone breakpoint, the archive tools and the footer now stack one
  full-width button per row, in DOM order.
- Buttons grow to fit their label, so a long label wraps inside its
  button instead of being clipped.
- On desktop the footer can wrap again as a last resort.

The new Electron test opens a past programme in French at a 360px
viewport and measures both rows. It fails against the previous
stylesheet (the footer buttons are 44px narrower than the row).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 21:54:09 +02:00
4gray 03dfafd68b fix(player): pin overlay reds to the --pc-* palette (#1759) 2026-09-30 07:02:57 +02:00
36be6495f2 perf(dashboard): hold rail skeletons back so empty rails stop shifting the page (#1738)
* perf(dashboard): hold rail skeletons back so empty rails stop shifting the page

On every launch with sources the dashboard shifted by about 0.23 (the
"good" CLS threshold is 0.1). The rails render as soon as their own data
arrives, and each loading rail showed a 328 px skeleton immediately. On a
normal profile the live-favorites and recent-content sources resolve empty
15-20 ms later, so their skeletons flashed and collapsed and every rail
below jumped up by about 360 px. J1 never saw it: its layout-shift window
ends at the first card, which is painted just before the collapse.

Rail skeletons now wait out a 300 ms grace period (createRailSkeletonGrace)
and appear only for a rail still loading after it; the hero keeps its
immediate skeleton because it reserves the top of the page. Recorded over
three renderer reloads of a seeded profile, the dashboard's layout shift
drops from 0.219-0.234 to 0.0004, with the real rails painted at the same
time as before. Plan thread C5, journey J1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(dashboard): gate rail skeletons per rail, never above visible rails

Addresses the Codex and Greptile reviews on #1738. The component-wide grace
timer started when the dashboard was created, so a rail that begins loading
later (Xtream recently added, TMDB) showed its skeleton at once and could
still flash and collapse; and after the grace period a slow rail's skeleton
could appear above rails that already showed cards, pushing them down and,
if it resolved empty, back up.

createRailSkeletonGates now keeps one gate per rail, in template order: the
grace period counts from that rail's own loading start, a skeleton is never
inserted above a rail that already has cards (the real rail inserts at most
once instead), and a shown skeleton stays until its own rail finishes so the
first arriving rail does not collapse the others in a cascade. Nine specs
cover the fast path, per-rail start, the no-content-below rule, latching,
reloading, destroy and a zero grace period. The seeded-profile timeline is
unchanged at 0.0004 across three renderer reloads.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: 4gray <fourgray@proton.me>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 14:24:14 +02:00
4gray be217d5cf5 feat(playback): Hybrid redesign of the shared player controls (#1709) 2026-09-28 07:32:55 +02:00
4grayandClaude Opus 5 975b1f7f74 refactor(collections): split the unified collection page and live tab (#1642)
* refactor(collections): split the unified collection page and live tab

Both files sat far above the workspace's 400-line hard maximum and were
only green because `tools/eslint/max-lines-baseline.mjs` exempted them.
No behavior change: every template binding, public signal and handler the
components exposed still resolves the same way, and the two baseline
entries are gone.

`unified-collection-page.component.ts` (966 → 398 lines) keeps the view
surface and delegates:

- `unified-collection-data.service.ts` — component-provided; owns the
  rows, the favorite uid set, the loading/reload indicators and every
  mutation, plus `loadedRequest` (the PR #1636 invariant, now read through
  one `mutationRequest` computed instead of two ad-hoc fallbacks)
- `unified-collection-load.ts` — the reload key and the load effect
- `unified-collection-scope.ts` — the This-playlist/All-playlists toggle
- `unified-collection-content-type.ts` — the Live/Movies/Series tab
- `unified-collection-history.ts` — the `window.history.state` view entry
- `unified-collection-detail-state.ts` / `-detail-navigation.ts` — the
  inline detail and where an item this route cannot render goes instead
- `unified-collection-clear-action.ts`, `-labels.ts`, `-favorites-sort.ts`

`unified-live-tab.component.ts` (1051 → 426 lines) continues the pattern
its siblings already established:

- `unified-live-selection.ts` (+ `-selection-generation.ts`) — activating
  a row while the mounted player stays alive
- `unified-live-selection-view.ts` — what the selection implies about the
  source and the player surface
- `unified-live-epg-view.ts`, `unified-live-epg-map.ts` — the EPG panel
  and the rail's now-playing map
- `unified-live-catchup.ts` — the timeshift override
- `unified-live-recording-metadata.ts`, `unified-live-channel-rows.ts`

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

* refactor(collections): move the collection data service into data-access

Codex review on PR #1642: `UnifiedCollectionDataService` loads and persists
favorites/recent rows, which CLAUDE.md places in
`@iptvnator/portal/shared/data-access`, not in the `type:ui` project that
renders them — and the dialog-scoped `SourceCleanupService` already sets that
precedent for a component-provided stateful collection service. It was also
the only non-root `@Injectable()` in `portal-shared-ui`.

`collection-reload-indicator.ts` moves with it: `type:data-access` may not
depend on `type:ui`, and the indicator is the service's own loading state
machine rather than a view. Both are exported from the data-access collection
barrel, so the boundary is now lint-enforced instead of conventional.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 19:51:34 +02:00
4grayandClaude Fable 5.1 15d964ef88 fix(collections): non-destructive reload feedback for the scope switch (#1636)
Switching the unified Favorites/Recent page between "This playlist" and "All playlists" kept the old items on screen with no feedback until the new query resolved. The skeleton cannot be reused there: it unmounts a playing channel and drops focus from the toggle.

Add a separate reload state that keeps content mounted: after a 180 ms grace period an indeterminate progress bar overlays the header separator, the content region carries aria-busy, and the grid or the live tab's channel rail dims — the player never dims. Fast IndexedDB/SQLite answers show nothing. Only the latest request settles the state.

Also bind Clear and drag reorder to the request that loaded the rows still on screen (`loadedRequest`) instead of `effectiveScope()`, which moves ahead the moment the toggle is clicked.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-19 18:08:41 +02:00
4grayandClaude Fable 5.1 7790e68147 feat(portal): show each season's own poster beside the season tabs (#1628)
Series detail pages now render the selected season's poster as a season
cover next to the season tabs and description, and the fullscreen episode
panel shows the same poster as a season strip above its tabs.

Resolution is TMDB-first, like the show artwork merge: the lazy season
enrichment stores `/tv/{id}/season/{n}` `poster_path` as a w342 URL in
`tmdb_season_posters` (Xtream) or `StalkerSeriesTmdbSeasonsService.posters()`
(Stalker), under the same write-only-if-changed convergence guard as the
season overview. Xtream falls back to the provider's `seasons[].cover_big`/
`cover` when it is an http(s) URL other than the show poster, because panels
repeat the show poster on every season. Stalker is TMDB-only.

The cover column is not rendered for one-season items, seasons without a
poster, or a failed image, so every fallback is today's markup. It is sized
by a new `--season-cover-width` token (96/120/144px per Settings.coverSize).
The hero poster never follows the season.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-19 11:45:54 +02:00
4grayandClaude Fable 5.1 43c1ceac16 test(web-e2e): reach row buttons with Option+Tab on WebKit (#1629)
The two "channel scrolling keeps focus after selection" cases (and the
Xtream "channel focus and separate scrollbar" cases, which press Tab the
same way) failed deterministically on Playwright WebKit while passing on
Chromium and Firefox. Playwright's WebKit emulates Safari's default
keyboard preference, under which plain Tab visits only text fields and
links: from the focused channel pane the key landed on the sidebar search
field instead of the first row button. Option+Tab reaches the button and
then the favorite action in the same DOM order Chromium's Tab follows, so
the app's focus contract (ChannelScrollFocusDirective) is intact and this
is Safari's Tab semantics, not an app bug.

Add a `pressTab` E2E helper that presses Alt+Tab only on webkit and keeps
the literal Tab / Shift+Tab on chromium and firefox, use it at the four
Tab presses toward buttons, and note Safari's behaviour in the keyboard
scrolling contract.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-19 11:44:52 +02:00
4grayandClaude Fable 5.1 ef3f98d026 feat(portals): posters-only cover wall for movie and series grids (#1604)
* feat(portals): posters-only cover wall for movie and series grids

Add `Settings.showCoverTitles` (Settings > General, default on). Turning it
off drops the title row under VOD/series covers in catalog, favorites and
recent grids and reveals the title as a bottom-gradient overlay on hover and
keyboard focus, pinned open for items whose cover is missing or failed.

`CoverTitlesService` is the single resolver: the opt-out AND a hover-capable
pointer, so touch-only devices keep their titles. Live channel grids, search
results, "recently added" rails and dashboard rails always keep labels.

Catalog and collection cards become keyboard buttons (role, tabindex,
aria-label, Enter/Space, focus ring) and poster alt text is the title. The
default-on boolean coercion moves into `settings-opt-out.util.ts` because
the settings store reached the max-lines limit.

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

* fix(portals): keep nested Remove key presses from activating the card

Enter/Space on the content card's nested Remove button bubbled into the
card's own key handlers: Enter opened the item before removing it and
Space opened it while cancelling the removal. Only keys pressed on the
card element itself now activate it. Regression spec added.

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

* fix(portals): keep cover titles while an in-section search filters the grid

The posters-only wall exempts search results because they are identified
by the name the user typed; the category grid's own in-section filter is
the same case, so `app-grid-list` now keeps the title row while its
`searchTerm` is non-blank. Contract docs updated, regression spec added.

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

* fix(portals): keep cover titles while the collection tab search is active

The unified favorites/recent tab filters by its own search term, so its
matches are identified by name like every other search result. The tab
now opts its cards out of the posters-only wall while the term is
non-blank. Contract docs updated, regression spec added.

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

* fix(portals): move the card Remove control out of the button surface

An interactive control nested inside a role="button" is an invalid
accessibility structure. The content card's activation surface is now its
own inner element and the Remove button a sibling positioned over the
poster corner, labelled by its tooltip text. Spec asserts the control is
never a descendant of the button.

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

* fix(portals): draw the collection card focus ring where it is not clipped

The card's overflow: hidden clipped an outline drawn on the inner
activation surface on every edge, so keyboard users saw no focus
indication. The ring now sits on the outer card via
:has(> .content-card__activation:focus-visible).

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

* fix(portals): detect any hover-capable pointer for the posters-only wall

`hover` describes only the primary pointer, so a touch-first tablet with
a mouse or hover-capable stylus attached lost the wall. The resolver now
reads `(any-hover: hover)`.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 18:34:21 +02:00
4gray bd848aaad6 feat(playlist): clean up selected inactive desktop sources (#1593) (#1596) 2026-09-13 10:39:14 +02:00
4gray 62655a8b5d feat(playlist): show desktop health indicators for network sources (#1592) 2026-09-12 23:05:17 +02:00
4grayandClaude Fable 5.1 0a2373f192 feat(portals): fold live TV panels in nested levels with a category dropdown (#1556)
## Summary

Live TV panels now fold from the outside in, in three nested levels, instead of one toggle that hid the categories rail and the channel list together:

1. **Categories + channels + player** (browse, unchanged).
2. **Channels + player** — a new `chevron_left` in the categories rail header hides only that rail. The channels header then turns its title into a **category dropdown** that opens the same shell panel as a popover (search, sort, counts, selection are one implementation), plus a `chevron_right` that brings the rail back.
3. **Player only** — the channels header chevron, as before. The floating restore handle and `Cmd/Ctrl+B` return to the level the user collapsed from, not always to level 1.

Every level is restored as stored, per surface (`live-sidebar-state:<surface>`, from #1555): a hidden rail is discoverable through the workspace header toggle and the hidden-list empty state that #1555 added, so this PR no longer needs its original "player-only never restores" rule. The level `Cmd/Ctrl+B` comes back to is seeded from the restored level and kept for the session.

## Design notes

- Nested levels rather than two independent booleans: "channels hidden, categories visible" makes no sense since a category click has to bring the channels back anyway. The model follows the outside-in collapse of three-pane apps (Mail, Slack, Plex).
- The categories rail folds at level 2 **only while a category is selected**: the live root ("All Items" grid) has no channels header to host the way back, so folding there would strand the user. Level 3 folds it regardless, because the floating restore handle lives in the content area.
- `LIVE_CATEGORIES_POPOVER` (`@iptvnator/portal/shared/util`) is the DI bridge: the workspace shell provides `WorkspaceLiveCategoriesPopoverService` (CDK overlay hosting `WorkspaceContextPanelComponent` in `presentation="popover"`), the Xtream and Stalker live layouts inject it optionally and keep their plain heading without a provider.
- M3U and the unified live tab have no categories rail and treat level 2 like level 1; their code is untouched.

## Merged with #1555 (per-surface rail state)

#1555 landed while this PR was open and reworked the same service: state per surface (`m3u` / `portal` / `collection`), a workspace header toggle, the hidden-list empty state, and the legacy shared key forgotten on startup. This PR keeps that model and layers the three levels onto the `portal` surface (`areCategoriesHiddenFor`, `hideCategories` / `showCategories` / `collapse` / `expand` per surface; `toggle(surface)` returns to the level the surface collapsed from). "Show playing channel" uses `expand('portal')` so it keeps a deliberately hidden categories rail folded, and the category sort preference moved to `PortalCategorySortStateService` so the popover copy of the context panel and the retained rail agree.

## Also fixed along the way

- The channels header showed "Channels" instead of the category name: provider category ids are strings, the selection is numeric. Compared via `String()` now.
- A collapsed context panel left a 22px padding strip beside the channels rail.
- The panel toggle labels said "Hide channels list" while also hiding categories; labels and tooltips are honest now (8 new i18n keys, all 18 locales).



Docs: `docs/architecture/iptvnator-ui-guidelines.md` ("Collapsible Live Sidebar" rewritten), `docs/architecture/workspace-shell.md`. Release note: `.changes/portals-live-panel-collapse-levels.md`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 14:42:59 +02:00
4grayandClaude Fable 5.1 0dcfba7045 fix(live-tv): keep a hidden channel list discoverable and scoped per surface (#1555)
* fix(live-tv): keep a hidden channel list discoverable and scoped per surface

The second report in #1458 ("all channels disappear after clearing the
playback history, reset does not bring them back") was not data loss: the
history write never touches playlist items. The reporter's screenshot shows
a collapsed channel rail, a state persisted under one localStorage key
shared by the M3U player, the Xtream/Stalker live layouts and the
favorites/recent live tab. It survived restart, "Remove all playlists" and
re-import, and the only way back was a 32px chevron or Ctrl/Cmd+B.

- LiveLayoutSidebarStateService keeps the state per surface (m3u / portal /
  collection) under live-sidebar-state:<surface>; the M3U player now goes
  through the service instead of its own signal. The legacy shared key is
  forgotten on startup and never read, so the update itself restores the
  list for everyone who got stuck.
- The workspace header renders a view_sidebar toggle on every route that
  renders its own rail (M3U all/groups, Xtream live, Stalker itv/radio), so
  the control exists in both states instead of disappearing with the rail.
  Collection pages keep their own toggle beside the content switch.
- While the rail is collapsed and nothing plays, every live host shows
  app-channel-list-hidden-state (title, shortcut hint, full-size "Show
  channels list" button) instead of asking to pick from a list that is not
  on screen. app-portal-empty-state gained optional hint/action inputs.
- New LAYOUT.CHANNELS_LIST_HIDDEN(_HINT) strings in en plus 18 locales.

Tests: service, empty-state, hidden-state and header component specs, a
separate video-player-sidebar spec (the main M3U spec sits at the test
line budget), and an Electron E2E covering history clearing, restore via
button/header/shortcut across restart and re-import, per-surface scoping
against an Xtream portal, and legacy-key cleanup.

Refs #1458

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

* test(live-tv): mirror the EPG offset setting in the sidebar spec mock

Master's player reads `resolvedEpgOffsetMinutes` from the settings store; the
new sidebar spec was cloned from the movie-gate harness before that field
landed, so its playing-channel case threw inside the EPG effect.

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

* test(web-e2e): scope the Stalker radio rail toggles to the rail

The workspace header now carries a second "Hide/Show channels list" toggle,
so the role+name locators matched more than one button and tripped
Playwright's strict mode. Target the rail's own chevron and the floating
restore button, and assert the header toggle mirrors the state.

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

* fix(live-tv): honour Cmd/Ctrl+B on collection pages and hide the header rail toggle on phones

Codex review follow-ups on #1555:

- The hidden-list state advertises Cmd/Ctrl+B, but the favorites/recent
  collection page had no handler; only the routed M3U/Xtream/Stalker live
  layouts did. The page now toggles the collection surface while its live
  tab is on screen, with the same typing/inert guards as the other hosts.
- At the phone breakpoint the header already holds the drawer toggle,
  switcher, search and Add; the live rail is a bottom drawer with its own
  toggle there, so the header rail toggle is hidden below 640px.

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

* fix(live-tv): migrate the live navigation helpers to the per-surface sidebar API

master (#1554) added `XtreamLiveChannelNavigationService` and
`stalker-live-navigation.ts`, which expand the rail through
`sidebar.setState('expanded')` on the pre-split signature. Point them at the
`portal` surface and update their specs; drop the now-unused hidden-state
stub from the Xtream layout spec, which master pushed to the max-lines
budget.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 11:48:58 +02:00
4gray 002faafbd7 fix(xtream): improve sync overlay contrast in both themes (#1546) 2026-09-06 00:44:08 +02:00
4gray 0140146716 fix(ui): restore detail surface boundaries in light theme (#1549)
* fix(ui): restore detail surface boundaries in light theme

* test(ui): measure detail action edges over rendered artwork
2026-09-06 00:41:53 +02:00
4gray 8eda5370eb fix(playback): apply themes to player and EPG panels (#1541)
* fix(playback): apply themes to player and EPG panels

* test(playback): verify active recording icon theme

* fix(epg): keep loading shimmer visible in both themes
2026-09-05 15:32:22 +02:00
4gray eb602db5fc fix(ui): restore channel and detail keyboard scrolling (#1542)
* fix(ui): restore channel and detail keyboard scrolling

* test(ui): drag below the Windows scrollbar arrow
2026-09-05 15:02:57 +02:00
4grayandClaude Fable 5 cf74f7e4a0 feat(stalker): append portal pages on scroll and drop pagination everywhere (2/2) (#1395)
* feat(stalker): append portal pages on scroll and drop pagination everywhere

Second and final PR of the pagination removal (plan:
.plans/2026-08-09-infinite-scroll-catalog.md). Stalker VOD/series grids now
feed the shared infinite-scroll contract from server-paged appends: portal
pages (server-side size, typically 14) accumulate into one deduplicated
paginatedContent list, page 1 replaces it for the skeleton, hasMoreContent
derives from accumulated length vs total_items (portals that ignore
requested page sizes still terminate), and a failed page > 1 keeps the
accumulated pages on screen with a tail retry (retryContentPage reloads the
same page; loadMore refuses to skip past an unresolved append error). The
facade splits the resource's loading flag by page — skeleton for page one,
tail spinner for appends — and keeps per-identity scroll offsets for
Stalker's INLINE detail round trips; the shared view re-arms its one-shot
restore when a detail opens in the same component instance.

The transitional supportsInfiniteScroll flag and every paged member are
deleted from PortalCatalogFacade; the shared catalog view loses the
mat-paginator, the ?page= round-trip, and the paged query-param branch. The
ITV all-channels grid becomes a client-side render window over the cached
full list (the app's last paginator), and Stalker search pages past its
first capped request via the layout's nearEnd, with a progress guard for
portals that report no usable total.

Validation: 1600 unit tests across 7 projects green (new: vod/series
append + failed-append retry, facade loading split/loadMore guards/scroll
snapshots, ITV window model, compat selector update); catalog-sorting e2e
5/5 (Stalker spec rewritten to scroll model with p>=2 network asserts and
an inline-detail spot-restore round trip; one unrelated nav-timeout flake
reproduced only under parallel machine load), search e2e 16/16, web
stalker e2e green (all-channels grid asserts the windowed count instead of
a paginator range label); lint clean; release note added and validated;
stalker-portal.md, CLAUDE.md, and ui-guidelines updated.

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

* fix(stalker): reset paging on content-type switch and never skip failed search pages

Round-1 review findings on #1395:

1. Codex P1: switching /vod -> /series with the same category id ('*' on
   both section roots) left page > 1 in place — setSelectedContentType did
   not touch paging and setSelectedCategory('*') no-ops on an unchanged id
   — so the new type's FIRST response was treated as an append onto the
   old type's accumulated list. The type setter now resets the page (and
   no-ops entirely when the type repeats, keeping detail round-trip
   restores intact).

2. Greptile P1 + Codex P2: a failed search append left searchHasMore true,
   so the next near-end advanced to page N+1 and permanently omitted the
   failed page. The search now tracks searchAppendError: a failed append
   keeps the accumulated pages and the next near-end RETRIES the same
   page; a failed fresh search (page 1) clears the previous query's cards
   instead of rendering them under the new term (Codex P2).

The page-merge/failure logic moved into applySearchPageSuccess/Failure
methods: Angular resource() never re-fires on params changes in this
repo's template-less jest harnesses (store-hosted resources do), so the
extracted methods carry the unit coverage — accumulation + dedupe,
no-total progress guard, retry-not-skip, fresh-failure clear — plus a
selection spec for the type-switch page reset. portal-stalker-feature
260, portal-stalker-data-access 464, lint clean; catalog-sorting e2e 5/5
and web stalker e2e green. search.e2e shows machine-load nav-timeout
flakes on unrelated M3U/live specs (a runaway third-party process pegs
the host CPU); CI provides the clean independent run.

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

* fix(stalker): include the portal in the search paging identity

Round-2 Codex P1 on #1395: Angular reuses the search route across
/stalker/A/search -> /stalker/B/search, and the paging identity covered
only term + filter — the page number and accumulator survived the portal
change, so the next near-end fetched portal B at the OLD page number and
appended it onto portal A's results while skipping B's first page.

The active playlist id now joins the page-reset identity, the resource
params, the stale-response guard, and the layout's near-end reset key.
Regression spec: switching the active playlist on a reused route resets
the page to 1 and rotates the scroll reset key.
portal-stalker-feature 261, lint clean.

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

* fix(stalker): end paging on no-progress appends even with a reported total

Round-3 Codex P2s on #1395 (same defect in both accumulators): the
no-progress guard only applied when the portal reported no usable
total_items. After a mid-list portal mutation, deduplication can leave
the unique list permanently shorter than the claimed total — hasMore then
stayed true forever and every scroll crossing kept requesting pages past
the end of the data.

An append that adds no unique items now ends paging in both places: the
catalog clamps totalCount to the accumulated length (hasMoreContent turns
false and the count badge reflects what is actually reachable), and the
search requires append progress in the total-backed branch exactly like
the no-total branch. Regression specs cover a duplicate page under a
larger claimed total for both. portal-stalker-data-access 465,
portal-stalker-feature 262, lint clean.

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

* fix(stalker): explicit search retry control and per-portal scroll identities

Round-4 findings on #1395:

1. Greptile P1: with the results pane parked at the bottom, repeated
   append failures exhausted the scroll auto-fill budget while the
   near-end latch stayed armed — the retry path was reachable only
   through another nearEnd event that could never fire. The search page
   now renders an explicit retry control under the results whenever an
   append has failed (same wording as the catalog grid tail), wired to
   the existing retry-same-page path, so recovery never depends on
   producing another scroll event.

2. Codex P2: the facade's saved-scroll map survives a same-config portal
   switch (the vod/series route provider is reused across /stalker/A ->
   /stalker/B), and its identity lacked the playlist — portal A's offset
   could restore onto portal B's unrelated catalog. The playlist id now
   leads the scroll identity; regression spec covers the cross-portal
   non-restore and the return restore.

portal-stalker-feature 263, lint clean.

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

* fix(stalker): restore the search results scroll after an inline detail

Round-5 Codex P2 on #1395: the search layout destroys the results
container while an inline detail is shown (showDetails) and recreates it
at offset zero — with the new multi-page accumulation a user could load
several pages, open a result far down the list, and land back at the top
on close even though the accumulated results survived.

SearchLayoutComponent now exposes a scroll handoff for hosts whose
details replace the results (getResultsScrollTop /
restoreResultsScrollTop on the container it owns), and the Stalker search
captures the offset when a detail opens and restores it one-shot after
the container is recreated on close. Regression specs cover the layout
handoff methods and the capture/restore round trip.
portal-shared-ui 90, portal-stalker-feature 264, lint clean.

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

* fix(stalker): clear accumulated search results for unsearchable portals

Round-6 Codex P2 on #1395: the loader's early returns (deleted or
malformed playlist on a reused route) predate the accumulator and
returned [] without touching it — the previous portal's cards kept
rendering under the new context once loading settled.

Every no-portal early return now goes through resetSearchAccumulator(),
which empties the accumulated list and both paging flags; the short-term
path uses it too (and now also clears a stale append error). Regression
spec covers the full reset. portal-stalker-feature 265, lint clean.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 13:36:59 +02:00
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
4grayandClaude Opus 5 c741815b97 fix(build): include shared UI stylesheets in Nx cache inputs (#1360)
* fix(build): include shared UI stylesheets in Nx cache inputs

`libs/ui/styles` held shared SCSS partials but had no `project.json`, so its
files belonged to no Nx project and were absent from every task hash. Editing
a partial and running `pnpm nx build web` reported 4 of 4 tasks cached and
shipped the previous CSS — a silent wrong build rather than a failure.

Nx derives its project graph from TypeScript imports only, so a relative Sass
`@use` that crosses a project root creates no edge. Verified directly: after
adding the project but before declaring anything, `ui-styles` still had zero
dependents in the graph.

Make it the `ui-styles` project (no targets — it exists to be hashed) and
declare `implicitDependencies` on the 8 consumers. Chosen over adding the path
to `sharedGlobals`, which would put shared styles into every project's hash and
make a one-line SCSS tweak mark the whole workspace affected. A styles edit now
marks 15 projects affected and leaves electron-backend, website, the mock
servers and the shared libs alone.

`libs/ui/styles` was the only projectless directory holding files under `libs/`
or `apps/`.

Add `pnpm run styles:inputs:validate` to keep it closed: it resolves every
relative stylesheet import against Nx's real project graph and fails when one
escapes the input closure of a build that compiles it, naming the project to
declare. It exits 1 with 21 diagnostics on the pre-fix tree. Imports of
`apps/web/src/nav-list.scss` are deliberately accepted — `web` already hashes
that file, and a lib -> app edge would make the graph cyclic.

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

* fix(build): spawn git without a shell in the stylesheet check

`execSync("git ls-files '*.scss'")` runs through `cmd.exe` on Windows, where
single quotes are literal characters rather than quoting. Git received the
pathspec with the quotes intact, matched nothing and exited 0, so
`styles:inputs:validate` reported success after checking zero stylesheets —
silently disabling the check for Windows developers while staying green.

Spawn with `execFileSync` so no shell is involved and git expands its own
pathspec; verified to return the identical 133 files.

Both this and the eslint glob trap next to it in the docs report success while
covering nothing, so also make an empty scan fail rather than pass: the
workspace always contains SCSS, and a listing that returns none means the scan
broke.

Reported by Codex review on #1360.

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

* refactor(styles): move nav-list partial into ui-styles (#1361)

* fix(build): count every target of a comma-separated Sass @import

`@import` is the only rule that takes a list, and the scan read just its
first target. A later entry crossing an Nx project boundary escaped the
cache key while the check still reported success — the same silent-pass
failure the tool exists to prevent.

Parse every target of an `@import` list. The obvious "read all quoted
strings" fix trades one silent gap for a phantom one, so the rule decides:
`@use`/`@forward` load exactly one module and a quoted string after it is
`with (...)` configuration, and `url(...)` stays a plain CSS import the
browser resolves at runtime. Neither is a module Sass compiles.

The workspace has no relative `@import` at all today, so the scan still
finds the same 42 imports across 133 files; this closes the gap before
someone writes one.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 23:18:37 +02:00
4grayandClaude Fable 5 e86e988e72 feat(ui): turn the phone context panel into an off-canvas drawer (#1332)
* feat(ui): turn the phone context panel into an off-canvas drawer

On ≤640px viewports the workspace context panel (categories, filters,
settings sections, collection filters) no longer stacks above the route
content capped at 30vh — it is a hidden-by-default drawer that slides in
from the left over a backdrop, opened via a new header toggle
(phone-only, CSS-gated) and closed by selection, backdrop tap, Escape,
or any navigation.

State lives in the new WorkspaceShellContextDrawerService provided by
the shell component; panels close it explicitly after selections that
do not navigate (Stalker ITV/radio categories, settings sections,
sources filters, collection filters), since NavigationEnd alone cannot
cover those. Desktop behavior is untouched, including the
ResizableDirective inline width.

Closes the drawer follow-up deferred from #1100 / PR #1326.

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

* fix(ui): make the phone context drawer modal for keyboard users

Addresses Greptile P1 and Codex P2 review feedback on #1332:

- CdkTrapFocus on the sidebar captures focus into the drawer on open and
  contains it while the drawer is modal; the shell restores focus to the
  header toggle on close, since the closed drawer is visibility: hidden
  and focus left inside it would silently drop to <body>.
- The drawer service closes the drawer when the viewport leaves the
  phone breakpoint (matchMedia), so the trap can never hold the in-flow
  desktop sidebar after a resize.
- The toggle's tooltip and aria-label are now variant-aware — categories
  on portal routes, filters on sources/collection routes, settings
  sections on the settings route — instead of a fixed 'Categories &
  filters' that misdescribed two of the three; the two generic i18n keys
  are replaced by six variant keys across all 19 locales.

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

* fix(ui): remove background content from the a11y tree while the drawer is open

Round-2 review feedback on #1332 (Greptile P1, Codex P2):

- The rail, header, route content and playback footer are marked inert
  while the phone drawer is open — CdkTrapFocus constrains Tab focus,
  but a screen reader's virtual cursor could still reach and activate
  the visually obscured controls behind the backdrop.
- The drawer panel itself is the trap's initial focus target
  (tabindex=-1 + cdkFocusInitial), so focus capture still works when a
  category list is loading, empty, or failed and renders no focusable
  rows.
- Focus restore on close is deferred one tick: the toggle lives in the
  inert header, and focus() on a still-inert element is silently
  ignored.

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

* fix(playback): gate global shortcuts and Escape behind the open phone drawer

Round-3 review feedback on #1332 (Codex P2s):

- The shell consumes Escape while the drawer is open: downstream Escape
  consumers (the portal detail shell's inline player close, the shared
  controls shortcuts) check defaultPrevented, so one keypress no longer
  closes both the drawer and the obscured playback surface.
- inert does not silence document-level keydown listeners, so players
  opt out themselves while inside an inert region: ControlsShortcuts
  gains an optional hostElement handler and ignores every shortcut
  (including Escape) when that host has an inert ancestor, and the radio
  audio player applies the same check to its volume/mute keys.

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

* fix(ui): close control, Cmd+F gate, and Embedded MPV inert guard for the drawer

Round-4 review feedback on #1332 (Greptile P1, Codex P2s):

- 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 must
  offer dismissal itself — even when a category list is loading or
  empty and renders no actionable entries.
- Ctrl/Cmd+F no longer opens global search while the drawer is modal;
  the shortcut would have navigated and focused an input inside the
  inert header.
- EmbeddedMpvShortcuts (native-view legacy dock) gains the same
  hostElement/inert-ancestor guard as the shared controls shortcuts, so
  the obscured player cannot react to Space/arrows/M/Escape behind the
  drawer.

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

* fix(ui): round-5 drawer feedback + update phone-layout e2e for the drawer

Merges master (#1333 landed mobile-layout.e2e.ts pinning the #1326
stacked-panel behavior this PR replaces) and updates that spec to pin
the drawer contract instead: panel hidden by default with full-width
content, header toggle opens it over a backdrop, category selection and
backdrop tap close it. Verified locally on Chromium, Firefox and WebKit
(12/12). The spec's getByTestId calls needed plain [data-test-id=...]
locators — the web-e2e Playwright config never mapped testIdAttribute.

Also addresses Codex round-5 P2s:
- Focus restore now reports whether the toggle received focus; when a
  drawer selection navigated to a route without a context panel (toggle
  gone), focus falls back to the route content instead of dropping to
  <body>.
- The Xtream and Stalker live layouts' Ctrl/Cmd+B sidebar shortcut opts
  out while their host sits inside an inert region, matching the other
  document-level listeners.

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

* fix(ui): suppress command palette and shortcuts dialog behind the open drawer

Greptile round-6 finding on #1332: the document-level Ctrl/Cmd+K
handler in WorkspaceShellFacade and the '?' help-key handler in
WorkspaceKeyboardShortcutsService still opened their dialogs while the
phone context drawer was modal, stacking a second focus-trapped surface
on top of it. Both now check the drawer service (injected optionally,
same shell-component providers) and stay quiet while it is open, like
the Ctrl/Cmd+F global-search gate.

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

* fix(ui): round-7 drawer feedback — Cmd+R gate and native MPV surface hiding

Addresses the two Codex round-7 P2s on #1332:

- WorkspaceShellContextDrawerService moves to @iptvnator/workspace/shell/util
  and becomes root-provided, so AppComponent's document-level Ctrl/Cmd+R
  global-recent shortcut can observe the modal drawer without pulling the
  lazy shell chunk into the eager bundle. Cmd+R is now suppressed while
  the drawer is open, like Cmd+F/Cmd+K/'?'.
- The shell registers the open drawer with a new
  EmbeddedMpvOverlayVisibilityService.acquireExternalModalSurface() API:
  the native-view video surface is composited outside DOM stacking and
  would paint straight over the drawer regardless of z-index. The service
  treats registered external modal surfaces exactly like open Material
  dialogs.
- The service's recompute no longer reads overlayActive back before
  setting it: signals already skip notification on equal values, and that
  hidden read registered overlayActive as a dependency of any reactive
  context calling into the service — the shell's acquire/release effect
  looped forever on exactly that (caught by a live browser probe; the
  unit suite mocked the service). The effect also wraps the acquire in
  untracked() for caller-side hygiene.

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

* fix(ui): expose the phone drawer as a named modal dialog

Round-8 review feedback on #1332 (Codex P2s):

- While open, the drawer carries role=dialog, aria-modal=true, and a
  variant-appropriate accessible name (categories / filters / settings
  sections) — assistive technology now hears that a named modal surface
  opened instead of an unnamed complementary landmark. Closed (and the
  always-visible desktop sidebar) stays a plain landmark.
- The UI-guidelines drawer section no longer claims the drawer service
  is component-provided; it is root-provided from workspace/shell/util
  since the round-7 move, and the stale claim could have led a future
  change to re-scope it and silently break the AppComponent shortcut
  gate and the Embedded MPV overlay observer. Matching code comments
  updated everywhere.

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

* fix(ui): round-9 — gate M3U player keys behind the drawer, raise drawer stacking

Greptile round-9 P1 + Codex round-9 P2 on #1332:

- The M3U video player's document-level digit-key channel switching and
  Ctrl/Cmd+B sidebar toggle now apply the same inert-ancestor guard as
  every other routed-content key listener. A codebase sweep confirms
  this closes the class: every document-level key listener on routed
  content is now either gated by the shell (Escape, Cmd+F/K/R, '?') or
  opts out via closest('[inert]'); the guidelines now require the guard
  for any new listener.
- The drawer moves from z-index 99/98 to 951/950: above the settings
  action bar (100) and the root EPG/update panels (900/901), which
  inert removes from interaction but not from paint order — below the
  CDK overlay container (1000), since dialogs opened from inside the
  drawer (Manage categories) must stack on top of it.

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

---------

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