Commit Graph
564 Commits
Author SHA1 Message Date
4gray 8e807242db Merge remote-tracking branch 'origin/master' into perf/zoneless-flip
# Conflicts:
#	tools/performance/journey-baselines.json
2026-10-14 19:58:21 +02:00
4grayandClaude Fable 5.1 ced7386dfa perf(web): render the radio play control as an icon button, not a fab (#1938)
The audio player's play/pause control was the app's only mat-fab. It is now
a mat-icon-button drawn as the same 56px primary circle (icon-button
overrides: 56px state layer, 32px white icon, 50% shape, selection-colour
fill, hover scale), so the eager button chunk no longer carries the fab
directives and the theme no longer emits the fab tokens:
renderer.initialBytes 1,349,048 -> 1,329,249 bytes (-19,799: -13,799 in
button.mjs, -6,028 in the stylesheet), measured locally against the merge
base. The fab also reset the keyboard focus ring with `outline: none`; the
icon button keeps the app ring.

- tools/nx/check-material-theme-scope.mjs: `button` brings button and
  icon-button; `fab` is required only while a runtime template renders
  mat-fab / mat-mini-fab (matFab / matMiniFab), found through the component
  templates the way check-form-field-defaults.mjs resolves them. That
  resolution moves to tools/nx/component-templates.mjs, shared by both.
  Templates know HTML comments only, so the scan strips just those: a `//`
  in text or an attribute value no longer hides a fab on the same line.
- apps/web/src/m3-theme.scss drops `fab` from $material-components.
- apps/web-e2e: the Stalker radio test checks the control's size, shape,
  fill, icon and keyboard focus ring.
- journey-baselines.json: renderer.initialBytes 1,345,807 -> 1,329,249.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-14 19:51:34 +02:00
7550a22a41 test(workspace): cover the atomic route swap and document it (#1922)
* fix(workspace): swap routed pages in one task when returning to the dashboard

Leaving an Xtream portal section for the dashboard inserted the dashboard
below the still-present portal page: the @angular/animations renderer
queues every DOM removal until the end of the next change detection, while
the router inserts the new page at once. With event coalescing that tick
runs in the next requestAnimationFrame, so Chromium laid the document out
with both pages (the dashboard at y=720 in a 720 px window) and scored the
swap as one layout shift: 0.337 at 1280 px, 0.394 at 900 px, 0.723 at
420 px, sources lib-workspace-dashboard-rails.

Nothing uses @angular/animations triggers (Material animates with CSS), so
the app no longer provides provideAnimations(). The old page now leaves in
the same microtask, before the new one is inserted, and the swap records no
shift. The router itself was already atomic: NavigationEnd and the shell's
currentUrl follow activation synchronously.

app.config.spec.ts keeps the animations renderer out;
workspace-route-swap.e2e.ts asserts that no mutation checkpoint holds both
pages and that neither the dashboard nor the content pane shifts. Both fail
with provideAnimations() restored.

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

* fix(workspace): read scroll and focus state before a view's DOM detaches

Without the @angular/animations renderer a destroyed view's DOM leaves
before its destroy hooks run, which the deferred removal used to hide. Two
hooks read state from the detached DOM:

- CategoryContentView.ngOnDestroy saved the grid's scrollTop, which reads 0
  once detached, over the offset saved when a card was clicked: back from a
  VOD detail landed at the top of the list (catalog-sorting E2E on every
  OS). The view now keeps the offset of the grid that scrolled last; a grid
  rendered since (a new category's) saves the top.
- Chromium blurs a focused element synchronously while removing it, so
  removing the focused player settings panel fired a focusout without a
  relatedTarget before the panel's destroy hook, which cleared focusInside:
  Escape left focus on the page instead of the tune button
  (player-controls-keyboard web E2E). The panel now judges a focusout a
  microtask later: a removed panel is detached then, while a pointer
  release blurs a panel that stays attached and still drops focus.

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-14 13:41:10 +02:00
4grayandClaude Opus 5.5 90a31d9be9 feat(sources): redesign the Sources page and the playlist switcher (#1932)
* feat(sources): add the shared source row and credential-free formatting

One app-source-row (regular for the Sources page, compact for the
playlist switcher) with an app-source-tile, built on pure helpers:

- resolvePlaylistSourceKind: one provider rule for icons, filters and
  counts (the list filter and the panel counts used to disagree).
- sourceDisplayName / sourceDisplayUrl: a name or URL never shows a
  password, token or MAC. URL imports that stored
  "get.php?username=…&password=…" as the title now show the host.
- stripUrlCredentials (shared/logging): drops what the log redactor
  would mask, plus short user/pass keys.
- formatSourceDate / sourceMetaParts / sourceStatusTag: one meta line
  order and Intl dates and durations in the UI language.
- SourceStatusService + SourceHealthHistoryService: one status per
  source (desktop health check, browser Xtream portal status) and the
  last conclusive result kept across restarts, so "not responding for
  3 days" survives a relaunch. No URL or credential is stored.

Also fixes playlistNameHasCredentials answering differently on repeated
calls (global regex lastIndex), and adds the UI-kit button, menu and
segmented-control mixins (libs/ui/styles/_app-controls.scss); menus opt
in with class="app-menu", styled by the components that use them.

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

* feat(sources): rebuild the Sources page on the shared source row

- Side list (UI-kit side list): title with the playlist count and an Add
  button, a Type group, and on desktop a Status group (Any / Responding /
  Not responding). The two filters combine and persist; each group counts
  within the other's choice. The chosen row uses one selected-row token
  (soft tint, outlined in light theme).
- Page: a 28px title with the visible count instead of the bordered
  header bar; Clean up and the sort menu are secondary buttons. Clean up
  shows how many sources do not respond. Sort adds Last updated, Most
  channels and (desktop) Not responding first, and keeps Name Z-A and
  Custom order.
- List: app-source-row with hover actions (Refresh, Edit, ⋯); a failing
  row keeps Retry in view. "No matches" offers Reset filters. Dragging in
  custom order no longer reuses positions of rows a filter hides.
- Delete with Undo: no confirmation; the row leaves at once and the delete
  runs when the toast closes (its close button commits at once). Deleting
  the active source switches to the next row. A large Xtream delete shows
  its row as "Removing…" with progress and Cancel, as before.
- The list's refresh flows moved into SourceListRefreshService; E2E and
  performance drivers follow the new markup and delete flow.

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

* feat(switcher): restyle the toolbar trigger and open it with Cmd/Ctrl+P

- The trigger is a 40px secondary button: the active source's tile with
  its status dot, its name and "type · channels" (or the source count),
  and a chevron that turns while open. The name comes from
  sourceDisplayName, so a URL-named playlist no longer shows its URL or
  credentials in the toolbar.
- Cmd+P / Ctrl+P opens the switcher wherever it is shown (Cmd+K stays
  the command palette) and is listed in the keyboard shortcuts dialog.
- The trigger's inline refresh button is gone; refresh stays in the
  playlist's ⋯ menu and the command palette.

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

* feat(switcher): rebuild the playlist switcher as an overlay popover

The switcher's mat-menu becomes a CDK connected overlay
(app-playlist-switcher-panel), 440px wide, with a dimmed, blurred
backdrop; only its list scrolls and row menus open above it.

- Header with the source count, Add playlist and All sources.
- Search by name or host, autofocused; the type filter is one segmented
  control (All / M3U / Xtream / Stalker with counts) instead of three
  checkboxes, remembered between openings (the old checkbox setting is
  migrated). The search is no longer kept between openings.
- "Recent" (the three most recently opened, kept across restarts) above
  "All" in the Sources page order, as compact source rows: the current
  one tinted with a check. Each row's ⋯ menu holds Refresh, Playlist
  info, Account info, Check again and Remove (with Undo); the footer's
  info, account and add actions moved there and to the header.
- Keyboard: a listbox driven by aria-activedescendant from the search
  field. Arrow keys move and wrap, Enter opens, Shift+F10 opens the
  row's menu, Escape closes and returns focus to the trigger. Hints in
  the footer.
- Deleting from a row menu moves focus to the toast's Undo, since the
  row (and its focus) left the page.

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

* refactor(sources): remove the old source row, filter state and dead styles

- Delete app-playlist-item and SourceHealthIndicatorComponent, replaced by
  app-source-row and its tile dot.
- Delete the ngrx type-filter slice (FilterActions, selectedFilters,
  selectActiveTypeFilters); the Sources filters live in
  SourcesViewStateService.
- Drop the empty state's unused "no results" variant and its styles.
- The switcher and Sources list components are no longer in the max-lines
  baseline.
- Docs: UI guidelines (source row, Sources page and switcher, status dot,
  selected-row tokens, UI-kit mixins), the source-health contract and the
  zoneless inventory. Release notes for the redesign and the credentials fix.

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

* fix(catalog): keep the refine menu whole in short windows

The refine menu held 12 rows at 48px plus two section titles (657px).
Angular Material 22 sets no max-height on a menu panel, so below ~800px
the overlay was pushed up over its own Refine button, and at 600px the
last thresholds were clipped behind an invisible scrollbar.

The minimum-rating thresholds now open in a submenu from one row that
names the active threshold. The menu is about 390px and opens below its
button at 600px. The submenu trigger sits in a role="none" wrapper:
Material offsets a submenu by the first direct mat-menu-item's
offsetTop, and with the sort rows inside a role="group" that item was
the trigger itself, which opened the submenu 335px off screen.

The Electron E2E opens both menus by keyboard in a 600px-tall window
and checks that each lies whole on screen without scrolling.

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

* style(workspace): drop the side panel's divider line

The context panel beside Sources, categories, Settings and collections
drew a 1px outline-variant border against the content surface, whose
background, shadow and rounded top-left corner already mark the edge;
the line clashed with that corner. The border stays 1px but transparent,
so the panel width, the persisted sidebar widths and the resize handle's
position do not move. The handle keeps its hover and drag highlight.

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

* fix(switcher): keep the search field and type filter whole with many sources

The switcher popover is a height-capped flex column. With a long list
(hundreds of sources) the browser shrank every part in proportion to its
size, not only the list: the type filter, which clips its overflow,
collapsed to 2px and showed no labels, and the search field dropped from
36px to its 21px text line. The header, search, type filter, empty state
and footer are now flex: none, so only the list shrinks and scrolls.

The search field now matches the header and side-panel search fields:
the shared --app-search-* tokens, a 10px radius, an 18px icon, the same
text size and focus halo.

The Electron E2E opens the switcher in a 320px-tall window and checks the
search height, the type toggles and the scrolling list; it fails without
the fix (search 21px).

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

* fix(sources): checks behind filters, keyboard Undo in the switcher, check causes

Local review round 1 (Codex + Greptile) on the redesign:

- A saved Responding / Not responding filter hid every source without a
  result, and the hidden rows were what started the checks, so the list
  stayed empty. keepSourcesChecked (portal shared data-access) now keeps
  every library source checked while the Sources page lives, on desktop
  where a check is one shared job that rows join.
- Removing from the switcher left its focus trap open around the search,
  outside of which the Undo toast sits: Tab could not reach Undo. The
  popover now closes first; Undo takes the focus and returns it to the
  trigger (SourceRemovalOptions.returnFocusTo). Every switcher action
  closes through one path, so a closing row menu no longer pulls the focus
  back into the popover.
- A running desktop Xtream delete brought its source back into the
  switcher as an ordinary option that could be opened or removed again; the
  switcher now leaves it out until the delete ends (the Sources page shows
  its progress and Cancel).
- Undo re-activated the removed source over a source the user had chosen
  since, when nothing had replaced it (it was the only row a filter
  showed). Undo now restores only while no other source is active.
- The switcher highlight stays on its option, or in range, when the list
  changes under the same search, so Enter still opens something.
- The status dot named every failure "Could not connect". SourceStatus
  now carries the check's cause (access denied, account disabled, timeout…,
  kept with the health history and validated on read), and M3U dots again
  say the check covers the playlist URL, not its channels.

Tests: new specs for keepSourcesChecked, the saved-filter check, the Undo
guard and focus return, the switcher close/highlight/deleting cases and
the dot's cause; the filter and Undo specs fail without their fixes. The
switcher keyboard E2E now deletes and undoes from the keyboard.

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

* fix(sources): own Undo timer, per-connection health history, race-free perf waits

Local review round 2 (Codex + Greptile):

- The delete committed whenever the shared snack bar was dismissed, so
  another message (a refresh finishing elsewhere) cut the four-second Undo
  window short and deleted the source early. The window is now the
  service's own timer; only the toast's close button (commitNow) or a
  second delete commits sooner.
- The health history was keyed by playlist ID only, so an edited address
  or account inherited the old connection's result, failing run included.
  Entries now keep a 32-bit FNV-1a fingerprint of the source health key
  (no URL or credential); a result for another connection starts over and
  a stale entry no longer shows.
- The M3U refresh-cancellation probe still queried the removed row's
  .cancel-btn, .busy-state__message and .action-spinner, so its cancel
  never fired; it now uses the source row's selectors (smoke run passes,
  cancelEffect=true).
- The Xtream delete benchmark waited to catch the "Removing…" row coming
  back before it left, which a fast delete could finish before the wait
  began. The driver now arms a delete-playlist operation window before the
  commit click and ends on its completed event and the absent row.

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

* fix(sources): sort by the shown name; start the benchmark's Undo window in its trigger

Local review round 3 (Greptile; Codex was clean):

- Name order compared the stored title, while the rows show a URL-named
  or credential-bearing source by its host: a source stored as
  get.php?username=… sorted by its hidden account details. sortPlaylists
  takes the list's name function, and Sources and the switcher pass
  sourceSortName, the name they display.
- The Xtream delete benchmark clicked Remove while preparing, which starts
  the four-second Undo window; capture setup before the trigger could
  outlast it, so the delete committed on its own before the measurement
  and the close button was gone. Preparation now stops at the open menu,
  and the trigger clicks Remove and then the toast's close button, with
  the trigger time taken right before the commit click as before.

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

* fix(sources): never hand over to a leaving source; browser checks per connection

Local review round 4 (Codex + Greptile):

- Removing the active source handed over to its neighbour even when that
  neighbour's own delete was still running ("Removing…"), activating a
  source about to disappear. replacementAfterRemoval (playlist shared
  util) picks the nearest row that is not leaving, on the Sources page and
  in the switcher; SourceRemovalService also refuses a leaving replacement.
- In the browser build a "Check again" for an account the user then
  edited could finish last and overwrite the new account's status under
  the same playlist ID. Portal statuses are now keyed by playlist and
  connection fingerprint, so the old check lands under the old key.
- The switcher scrolled the highlighted option into view through a
  selector built from the playlist ID, which a restored ID with "." or
  ":" broke (or made throw). It now finds the row by its exact DOM ID.

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

* fix(sources): keep ⌘P out of open dialogs; redact refresh errors

Local review round 5 (Greptile; Codex was clean):

- Ctrl/Cmd+P opened the playlist switcher over an open dialog (an
  editor, a confirmation), taking the focus out of it and handing it back
  to the toolbar behind the dialog on close. The shortcut now leaves the
  key alone while MatDialog has open dialogs.
- SourceListRefreshService logged a failed refresh's raw error, which can
  carry the playlist URL and its credentials. It now logs through the
  redacting portal logger (createLogger).

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

* fix(sources): show a new search's first match; keep inconclusive check causes

Local review round 6 (Codex; Greptile was clean at 5/5):

- Typing a search or picking a type in the switcher highlighted the first
  match but kept the list scrolled where the previous result left it, so
  the highlighted row could sit above the view. The list returns to the
  top whenever the search or type changes.
- A source with no kept history whose check was inconclusive (connection
  checks paused by the connectivity guard, a cancelled check) lost the
  live cause and time, and its dot said only "Not verified". The status
  now keeps them; a running check still reads as checking.

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

* fix(switcher): fall back to All when the remembered type has no sources left

Local review round 7 (Greptile 5/5 with one P2; Codex was clean): when the
last source of the remembered switcher type was removed while two other
types remained, the list kept filtering by the missing type. Its segment
was gone, no segment was selected, and the list said "Nothing found". The
list now filters by All until a source of that type returns; the remembered
choice is kept.

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

* fix(sources): clean credentials nested in kept URL parts

Codex bot (PR review, P1): stripUrlCredentials copied every non-sensitive
parameter value unchanged, so a URL nested in one (a proxy's
?url=http%3A%2F%2Fjoe%3Ahunter2%40origin.test%2F…) kept its userinfo and
password and reached the Sources row through sourceDisplayUrl.

Kept query and fragment values and path segments are now cleaned
recursively, up to four levels: a nested URL loses its credentials the
same way, a nested parameter list loses its sensitive keys, and a part
that still names a secret (joe:hunter2@host, token=…) is dropped. Deeper
nesting is dropped rather than shown.

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

* fix(sources): keep fragment and nested URL secrets out of source names

Greptile bot (PR review, P1 security): an unnamed import of
https://host.test/list.m3u#api_key=secret123 stored list.m3u#api_key=…
as its title. isUrlDerivedName only knew names ending in a query, and
sourceSecrets only read the top-level query, so the row and the toolbar
showed the secret while the displayed URL hid it.

A name that ends the URL with a query or a fragment is now URL-derived,
and shows the host. The secrets a typed name is checked against now
include sensitive fragment parameters and, as stripUrlCredentials cleans
them, the credentials of URLs and parameter lists nested in parameters
(nested account names included).

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

* fix(sources): keep deletes busy until settled and on screen; refresh E2E via the switcher

PR #1932 round 1 (CI + local Codex/Greptile on the bot fixes):

- CI: category-management.e2e refreshed the active playlist through the
  old switcher trigger's inline refresh button, which the redesign
  replaced with the switcher row menu (⋯ → Refresh playlist). The helper
  now goes through that menu; it failed on all three OS shards.
- A desktop Xtream delete's completed event cleared its progress while
  PlaylistsService still cleaned up (parental lock, stored state), so the
  already-deleted source was actionable again on Sources and back in the
  switcher until the store dropped it. A terminal event now only ends
  Cancel; the row stays busy until the delete settles and the removal
  service clears it.
- A running delete (from the switcher, say) disappeared from Sources when
  the page's saved filters excluded it, hiding its only progress and
  Cancel. Running deletes stay listed whatever the filters.

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

* fix(sources): keep counting a failure's duration while the list stays open

Local Greptile (5/5, one P2) on the PR round: the row's status tag
formatted "Not responding · 3 days" against the time the row mounted (or
the language last changed), so a new failure never gained its duration
and an existing one froze while Sources stayed open. A shared root
SourceClock now ticks once a minute and only the status tag reads it, so
dates and the meta line do not recompute every minute.

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

* fix(sources): keep the page header in view; mark the active source's row

Local Greptile (two P2s) on the PR round, both regressions against master:

- The redesigned Sources page scrolled as one, so the title, Clean up and
  Sort left the view in a long library (master kept a sticky header, and
  the UI guidelines say headers stay and lists scroll). The header now
  stays and only the list below it scrolls, which also keeps a row reached
  by keyboard from scrolling under the header.
- Master marked the active playlist on the Sources page; the new row only
  tinted the current row in the compact (switcher) size. The active
  source's row is now tinted with the same --app-list-selected-* token on
  the page too; the switcher keeps its check.

The design handoff called the header non-sticky and the current tint
switcher-only; both followed master and the guidelines instead.

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

* fix(sources): name the Sources sort group; refit the switcher on resize

Local review on the PR round:

- Greptile: the Sources sort menu's radio group had no accessible name,
  unlike every other menu group (UI guidelines). It is named by its
  section, "Sort by".
- Codex: the switcher popover capped its height once, when it opened, so
  shrinking the window while it was open left the footer and the lower
  rows out of reach. A window resize refits the open popover.

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

* wip: linear URL-cleaning regexes

* fix(sources): linear URL-cleaning patterns; keep the stripper off the startup path

PR #1932 CI:

- CodeQL (polynomial regular expression on uncontrolled data): the
  embedded-URL and keyed-pair patterns in stripUrlCredentials could retry
  from every position of a long run of letters. Matches now start only
  where a name can start (lookbehinds) and every name part is bounded, as
  is the bare-userinfo pattern; a spec times 50,000-character runs. The
  preceding "wip: linear URL-cleaning regexes" commit holds that change.
- Initial bytes ratchet (+135 B over after master's #1928 lowered the
  baseline): stripUrlCredentials sat behind the shared logging barrel,
  which startup code imports, so esbuild placed it in an initial chunk
  although only the lazy Sources page and switcher use it. It now lives
  beside its only consumer in playlist/shared/util source-display, and the
  logging barrel exports the two redaction helpers it needs. Measured
  locally on the merge with master: 1,537,497 bytes, within 1,538,700.

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

* fix(sources): case-insensitive Xtream path markers; no removal during a refresh

- Strip and redact the account segments after `/LIVE/`, `/Movie/` or an
  encoded marker such as `/%6Cive/`, as after `/live/`. The display-name
  check also treats an Xtream path password as a URL secret.
- A source cannot be removed while a switcher refresh clears and refills
  it: the removal service ignores the request, the switcher disables
  Remove, and the Sources page shows the refresh as busy.

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

* fix(sources): search URLs nested in path segments for name secrets

A proxy URL can carry a whole source URL, or a token pair, in one path
segment. The display stripper already cleans such segments; the name
check now collects their secrets too, so a name holding them collapses
to the host.

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

* fix(sources): decode nested URLs before classifying; narrow Xtream paths

- A URL nested in a parameter or path segment is percent-decoded one
  round at a time until it reads as a URL or parameter list, so one
  encoded twice is cleaned too; a value still encoded after the last
  round is dropped. The name check unwraps nested values the same way.
- The two segments after `/live/` and its siblings count as an account
  only when a stream follows them, so a playlist path such as
  `/live/news/list.m3u` keeps its file name in the URL and the title.
- Adding a playlist from the phone drawer closes the drawer first, so
  two focus-trapped modals never stack.

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

* fix(sources): clean URL-shaped fragments; one registry for busy sources

- A fragment is cleaned whatever its shape: `#https://joe:pw@…` loses its
  userinfo, an unclassifiable secret is dropped, and the name check reads
  a URL in the fragment as well as a parameter list.
- Every refresh holds its source in the root SourceActivityService, which
  deletes and the startup auto-refresh already use: Sources-row refreshes,
  the switcher's M3U refresh and the shared Xtream flow. The switcher no
  longer offers Refresh or Remove for a source the page is refreshing, the
  removal service ignores a held source, a second refresh from either
  entry point is skipped, and the page disables Remove while it is held.
- The long-input timing spec allows 3 s: the inputs take about 0.25 s,
  a backtracking pattern takes minutes, and a loaded runner took 1.6 s.

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

* fix(sources): keep a browser refresh busy until the backend answers

The browser build's PLAYLIST_UPDATE returned at once while its request
ran on, so the row stayed refreshable and removable and the shared
registry saw the source as idle. The event now settles when the refresh
does (the PWA service resolves after its success or error report; the
Electron fallback awaits its handler), and both the Sources row and the
switcher keep the source busy and held until then.

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

* fix(sources): name check hides values under short account keys

The name check used the log redactor's key list, which leaves out the
short keys `pass`, `pas`, `user` and `usr` that the URL stripper drops.
Both now share `isDisplaySensitiveKey`, so a name holding `?pass=…`
collapses to the host; `user`/`usr` are account names like `username`
and stay shown at the top level, as by design.

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

* fix(sources): read keys, markers and secrets through every encoding layer

Each check decoded its input once, so one more encoding layer slipped a
secret past it: `?%2570assword=…` kept its value, and `/%256Cive/…`
kept its account segments, both on screen and in logs.

`decodeFully` (shared logging) undoes up to four percent-encoding layers
and is now the one form every name check reads: the redactor's key
normaliser and Xtream marker check, the display key predicate and path
check, and the name check's secret values. Logs and the Sources lists
gain the fix together.

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

* fix(sources): read Xtream paths decoded; keep short decoded forms out

- An Xtream account could hide behind encoded separators
  (`live%2Fjoe%2Fhunter2%2F1.ts` is one raw segment). `findXtreamAccount`
  reads the path as decoded parts, maps them back to raw segments, and is
  the one scanner for the log redactor, the on-screen URL and the name
  check. Account segments go whole, joined ones included.
- The name check required a minimum length only of the raw secret, so a
  decoded form as short as one letter (`%2542` is `B`) hid ordinary
  names. Each form must now be long enough on its own.
- The decoding helpers move to `url-credential-parts.ts`, which keeps the
  redactor under the line limit.

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

* fix(sources): read an Xtream path both whole-segment and split

Reading the path only as decoded parts misplaced the account when the
user name itself held an encoded slash (`/live/joe%2Fx/hunter2/1.ts`):
the password segment stayed on screen and in logs. `findXtreamAccount`
now scans both readings, each raw segment decoded whole and the decoded
path split on every separator, and drops what either finds.

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

* fix(sources): drop JSON configs from shown URLs; read their secrets

A nested value such as `?config={"password":"…"}` was neither a URL nor
a parameter list, and the quoted key slipped past the free-text check.

- A nested value that parses as a JSON object or array is dropped from
  the shown URL (JSON can spell a key so no pattern reads it, such as
  `w`); brackets that are not JSON, like `[1080p]`, stay.
- The free-text check accepts a quoted key (`"token": …`).
- The name check parses JSON values and collects what sits under
  sensitive keys, and follows URLs in its other strings.

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

* fix(sources): hold Xtream sources through re-import; match secrets in any case

- An Xtream refresh released its hold when navigation ended, while the
  re-import ran on from the route session, so Remove came back during
  the import's writes. The route session now takes the hold synchronously
  on NavigationEnd and keeps it until its sync, and the import it awaits,
  settle; the store also holds the source for every content
  initialization, retries included.
- Secret matching was case-sensitive against encodeURIComponent's
  upper-case escapes, so `%3a` or a lower-case MAC slipped through. Text
  and secrets are now compared lower-cased, the text also fully decoded.
- The Xtream `· username` label is left out when it is, or holds, the
  password.

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

* fix(sources): read a bracketed key as parameters unless it is JSON

The name check returned early for any value starting with `{` or `[`,
so `[password]=…` (not JSON) skipped the parameter check and its
password stayed in the name. It now returns early only when the value
parses as a JSON object or array.

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

* fix(sources): no opening a source from the switcher mid-refresh

While an Xtream refresh was still deleting the catalog, ⌘P could reopen
the switcher and selecting that source navigated at once, starting its
import before the user data to restore was parked. The refresh flow now
exposes `isRunning` (its set of runs is a signal), and the switcher marks
a refreshing source aria-disabled and ignores its selection by pointer
and Enter alike. Imports alone do not block opening a source.

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

* fix(sources): hand over only to an idle source; hide names that are a short secret

- Removing the active source could hand over to a neighbour a refresh
  was still clearing, opening it and starting its import early.
  `SourceRemovalService.canTakeOver` (not leaving, not deleting, not held
  in SourceActivityService) now picks the replacement in both lists and
  gates it in the service.
- Secrets under three characters were dropped from the check entirely,
  so a name that was just a two-letter password showed it. Short secrets
  now count as the whole text (trimmed, any case); longer ones still
  match inside a text.

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

* fix(sources): offer Refresh only when it can run; drag handle never opens

- PlaylistRefreshActionService runs one refresh at a time, but
  canRefresh checked only the source, so the switcher, the command
  palette and the dashboard offered a Refresh that refresh() then
  ignored. canRefresh now applies the same global guard.
- In Custom order a click on a row's drag handle bubbled to the row and
  opened the source; the handle now stops it.

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

* fix(sources): no idle Refresh on held rows; Recent counts the filtered list

- The Sources page offered Refresh on a source the startup auto-refresh,
  an import or a delete held, and refresh() then refused it. The row now
  offers Refresh only while it can run, keeping it (with its spinner)
  while that source refreshes.
- The switcher showed Recent whenever the library had six sources, so a
  type filter down to one or two listed them twice and the keyboard
  visited each twice. Recent now depends on the filtered count.

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

* fix(sources): compare passwords in their own case; only a MAC caselessly

Matching every secret lower-cased hid any name holding the password word
in another case: the Electron E2E source "Stress Xtream Portal" (password
`stress`) showed its host, and the deletion test could not find its row.
A password is case-sensitive, so it is compared as written; the text is
still read fully decoded, so `%3a` escapes count as `:`. A MAC is the
same address in either letter case and is compared lower-cased.

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

* fix(sources): match a MAC from the URL in any letter case

Only the source's own `macAddress` was compared caselessly, so an M3U
URL carrying `?mac=…` (nested ones too) left the MAC visible in a name
written in another case. Any MAC-shaped secret, and every form of the
source's own MAC, is now compared lower-cased; passwords keep their case.

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

* perf(sources): keep source-health helpers off the startup path

Master's #1931 lowered the initial-bytes baseline, and this branch then
sat 535 bytes over its slack. Most of its growth was runtime helpers in
`@iptvnator/shared/interfaces` (reachability, health history, reason and
fingerprint helpers) that only the lazily loaded source services call:
the bundler put them in a chunk the app preloads. They now live in
`@iptvnator/portal/shared/data-access` beside those services (types stay
in interfaces); initialBytes 1,350,438 → 1,349,048, 855 under the limit.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 08:06:23 +02:00
4gray 7b00a57138 Merge remote-tracking branch 'origin/master' into perf/zoneless-flip
# Conflicts:
#	apps/web/src/app/app.config.ts
#	tools/performance/journey-baselines.json
2026-10-14 00:15:33 +02:00
4grayandClaude Fable 5.1 e6ef8cdd54 perf(web): take Angular Material off the startup path (#1931)
Takes Angular Material off the renderer's startup path: renderer.initialBytes
1,596,201 -> 1,344,067 bytes (-252,134, 15.8%) at the branch point; merged with
master, where #1928 lands the form-field defaults per component, the branch
measures 1,345,807 (-188,797, 12.3% against master's baseline). The bundler
keeps each module in one chunk, so one eager import of any export carries the
module's whole code; each item removes the last eager importer of one Material
module.

- EPG progress and update panels: eager host + `@defer (when visible)` view
  with an `@error` notice; the startup spinner is static markup copied from
  mat-spinner.
- LazyMatDialog: the confirm dialog service, player service, parental lock
  prompt and shell actions resolve MatDialog on first use.
- The theme emits tokens for the 21 imported components only, through a
  `$material-components` list and meta.apply; tools/nx/check-material-theme-scope.mjs
  (CI) keeps it in step with the imports in both directions.
- No provideAnimations(): Material 22 animates with CSS and reads
  prefers-reduced-motion itself; apps/electron-backend-e2e/src/material-motion.e2e.ts
  proves dialogs, selects, menus, snack-bars and tooltips animate in and out and
  stop under reduced motion. Its renderer had deferred every DOM removal to the
  end of the tick, so the catalog list now tracks the grid offset from scroll
  events and the player settings panel decides "focus was inside" in a
  microtask, both with specs that detach the view first.
- The snack-bar stays, and with it button, the icon-button chunk and the CDK
  overlay (about 190 KB): root services inject it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-14 00:08:58 +02:00
4gray 0622794253 Merge remote-tracking branch 'origin/master' into perf/zoneless-flip
# Conflicts:
#	apps/web/src/app/app.config.ts
#	tools/performance/journey-baselines.json
2026-10-13 21:57:56 +02:00
ec4b9286a7 perf(web): provide form-field defaults per component, not at the root (#1928)
* perf(web): provide form-field defaults per component, not at the root

app.config.ts provided MAT_FORM_FIELD_DEFAULT_OPTIONS in the root
injector. Importing the token kept @angular/material/form-field in
main.js, and Material 22.2's form field imports @angular/forms/signals,
which would add @angular/forms to startup as well (the reason the
Angular 22.2 dependency update exceeds the initial-bytes ratchet). Every
form field renders in a lazy chunk, so the outline + dynamic defaults
now come from provideFormFieldDefaults()
(@iptvnator/ui/components/form-field-defaults) on each component that
renders <mat-form-field>.

tools/nx/check-form-field-defaults.mjs (ui:form-field-defaults:validate,
run in CI) reports a component that renders a form field without the
provider, and any use of the token outside the shared provider. The
Xtream import form test asserts outlined, dynamically sized fields with
no root provider; without the component provider they render as fill.

renderer.initialBytes: 1,596,201 -> 1,534,604 bytes (-61,597) on a local
production build; the baseline is lowered to the measured value.

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

* perf(web): cite #1928 as the initial-bytes baseline evidence

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

* test(ui): check form-field providers per component on the TypeScript AST

The guard matched templates and providers with regular expressions over
the whole file: a quoted inline template went unread, one component's
provider covered another in the same file, an earlier array-valued
provider hid the call, and provideFormFieldDefaults() in app.config or a
route passed although it loads Material's form field on startup again.
It now reads each @Component decorator's own template and providers
array, and reports any provideFormFieldDefaults() call outside one.

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-13 21:53:08 +02:00
4grayandClaude Opus 5.5 d88d005062 perf(web): run the renderer zoneless and drop the zone.js polyfill
Plan item C6 step 5. app.config.ts provides
provideZonelessChangeDetection() directly and apps/web builds without the
zone.js polyfill, so a tick runs only when a signal, markForCheck, an
Angular event or a render hook asks for one, not after every macrotask.
The flag from #1824 (change-detection.providers*.ts and the *-zoneless
configurations) goes away; a build-config test fails if the polyfill or a
zone provider comes back.

NgZone.run/runOutsideAngular calls that only mattered with zone.js are
removed from the dashboard data services, the settings unload guard and
the embedded MPV position poll (with the spec case asserting it), and
the unused polyfills.ts, polyfills-test.ts and setup-jest.ts are deleted.
Unit tests keep zone.js/testing for fakeAsync and waitForAsync;
apps/remote-control-web still bootstraps with zone.js.

renderer.initialBytes: 1,596,201 -> 1,545,931 bytes (-50,270) on a local
production build of this base; the baseline is lowered to the measured
value.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 08:44:52 +02:00
4grayandClaude Opus 5.5 4e9795ac54 fix(playback): wait for mpv's reply to every reused-instance IPC command (#1876)
Every reused-instance MPV IPC command now carries a request_id and waits, within the existing 2 s bound, for mpv's matching reply; an error or missing reply falls back to a fresh launch like a socket failure, and the racing post-loadfile seek is gone. MPV launches run one at a time through a launch queue so overlapping launches never act on the same child; Stop on a queued launch delegates to the in-flight launch's own closer, a failed exit stays retryable, and the dock keeps the newest launch. Position polling and output-error reporting moved out of mpv-session.service.ts, which leaves the max-lines baseline.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 22:42:43 +02:00
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 197fbee781 fix(i18n): retranslate EPG source strings for local XMLTV files (#1890)
Retranslate SETTINGS.EPG_URL_LABEL, EPG_URL_PLACEHOLDER, EPG_URL_ERROR,
EPG_SOURCES_DESCRIPTION and EPG_EMPTY_HINT in 16 locales that still kept
the URL-only meaning after local XMLTV files became EPG sources (#1600).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 12:01:56 +02:00
4grayandClaude Opus 5.5 c76cf357b7 refactor(workspace): remove standalone-layout branches no route can reach (#1889)
* refactor(workspace): remove standalone-layout branches no route can reach

Every routed view sits under the /workspace route (the other top-level
paths are redirects), so isWorkspaceLayoutRoute() was true for every
caller. Global search became a route on 2026-01-12 and the last route
outside the workspace went with the home component on 2026-03-02;
nothing opens SearchResultsComponent in a MatDialog.

Removed as unreachable:
- isWorkspaceLayoutRoute() and the route's data.layout marker; the
  channel list, Xtream live layout and unified collection read the q
  query param directly, the clear-view command no longer checks a flag
  that was always true, and the M3U EPG guide header action keeps only
  its supportsEpg gate.
- The playback sidebar's playlist header (Home button and playlist
  switcher, showPlaylistHeader, its stylesheet and the macOS padding
  rule). The switcher itself stays: the workspace header uses it.
- Xtream search's dialog mode (MAT_DIALOG_DATA initialQuery, dialogRef,
  close buttons) and inline search input; Back is offered whenever the
  search is not the global one, as before.
- Stalker search's inline-input flag (Back is always offered, as before).
- SearchLayoutComponent's showSearchInput, showCloseButton, closeClick,
  searchTermChange and focusSearchInput, and SearchFormComponent, which
  only it rendered.
- SETTINGS.BACK_TO_HOME and PORTALS.SEARCH in all 19 locales, the
  zoneless checklist entry for the deleted form, the specs that only
  covered these paths, and two Electron E2E assertions that the deleted
  form is absent.

Behaviour of every reachable path is unchanged.

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

* refactor(ui): drop the macOS host class and switcher rule nothing uses

The app root's `macos-platform` host class only fed the removed sidebar
header's padding rule, so no stylesheet reads it any more. The shared
portal-sidebar partial's `.sidebar-header .switcher` rule matched no
element: the only `switcher` class lived in that removed header, which
used `.current-playlist`, not `.sidebar-header`.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 11:07:57 +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 afd7a5f331 fix(i18n): translate source cleanup, source health and diagnostics strings (#1884)
* fix(i18n): translate source cleanup, source health and diagnostics strings

Translate every English-identical SOURCE_CLEANUP, SOURCE_HEALTH and
PLAYBACK_DIAGNOSTICS value in all 18 locales, plus the external playback
dock statuses (the same strings as the diagnostic ones), the EPG source
list strings, and leftover English in hu, ko and el. The identical-English
baseline drops 973 entries (2106 -> 1133) and gains none.

Add tools/i18n/check-duplicates.mjs (pnpm run i18n:duplicates), a report
of English strings defined under several keys whose translations differ
per locale, and align drifted wordings: 101 diverging groups -> 65.

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

* fix(i18n): call cleaned-up zh/zhtw sources invalid, not unactivated

The cleanup dialog lists expired and disabled accounts too, so 未激活 /
未啟用 ("not activated") misexplained why a source is offered for deletion.
Use 失效 ("no longer valid") in the title and the confirmed group heading.

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

* fix(i18n): one zh/zhtw wording for copying the stream URL

The playback diagnostics button said 复制 URL / 複製 URL while the
channel details dialog (new on master) says 复制流 URL / 複製串流網址
for the same action; use the details dialog wording in both places.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 09:59:50 +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 039238b7bc fix(settings): show the installed version without the GitHub release check (#1879)
The settings facade filled its version signal only inside the GitHub
releases callback, so offline or under an api.github.com rate limit the
installed version was missing from the sidebar's About item and the About
page. The version is known locally from DataService, so the signal starts
with it. The release check also gets an error handler: SettingsService
already logs the failure, and without it the error resurfaced uncaught.

The sidebar layout E2E no longer needs to stub the releases request; a new
E2E aborts it and expects the version in both places.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 22:49:22 +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 Opus 5.5 2b400cb81d fix(i18n): translate the remaining hard-coded labels (#1871)
* fix(i18n): translate hard-coded labels, snackbars and missing keys

Catalog screens, snackbars and external player messages showed English
in every locale, and seven keys used in code were missing from en.json,
so ngx-translate rendered them raw.

- Catalog: "All items", item and channel counts, channel sort menus and
  tooltips, unnamed-category and empty-category labels, LIVE/PAUSED
  badges and the Xtream global search summary are translated. The Xtream
  and Stalker stores no longer bake an English name into the every-item
  sentinel; the facades return a null title and the view translates it.
- Snackbars: Xtream/Stalker request failures, the 413 upload error,
  backup export/import results and the category visibility failure use
  keys; every "Close" action uses CLOSE.
- External player: the main process sends an error code instead of an
  English sentence (player-error event, session errorCode, and a tag in
  rejected launch errors); the renderer, dock and VOD primary button
  translate it. The external player info dialog is translated.
- Adds the seven missing keys and 43 new ones, translated in all 18
  locales; seven legitimately identical values are baselined.
- tools/i18n/check-usage.mjs fails on keys used in code but missing from
  en.json; it runs in i18n:check (and so in CI through i18n:validate).

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

* fix(i18n): translate catalog counts in the template and skip inline template comments

- The category subtitle hands a key to the translate pipe instead of
  caching translate.instant(), so a cold start re-renders it once the
  language file loads.
- The Xtream live root count uses the singular/plural item keys, so one
  result no longer reads "1 channels".
- check-usage.mjs strips HTML comments inside inline templates of
  TypeScript files, so a commented-out key no longer fails the check.

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

* fix(i18n): check keys in parenthesised translate pipe operands

`(expanded() ? 'SHOW_LESS' : 'SHOW_MORE') | translate` yields keys that
neither precede the pipe directly nor contain a dot, so the usage check
missed them. It now reads the ternary and fallback branches of a
parenthesised operand; a literal compared in the condition is not read
as a key.

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

* test(e2e): expect the translated external player failure in the dock

A launch failure without an error code now shows the translated generic
status in the playback dock, with the raw main-process detail as its
tooltip. The ClearKey DASH flow asserted the raw English text.

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

* fix(playback): keep the IPC error tag out of the stored session detail

A tagged launch failure reached ExternalPlayerSession.error unchanged, so
the dock tooltip showed "[iptvnator:external-player:start-failed]". The
registry now strips the tag when it stores the detail; the rejected IPC
error keeps it for the renderer to read the code.

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

* test(web): expect translated portal request failure toasts

#1861's new resolved-failure specs asserted the English toast text; the
toasts now go through PORTALS.REQUEST_ERRORS keys, so the specs check
the key and its message/status params.

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

* fix(i18n): read only returned branches of a grouped translate operand

A literal at the start of a condition, as in
`('ERROR' === status() ? 'CLOSE' : 'CLOSE') | translate`, was taken for
a translation key, so a valid template could fail the usage check. A
grouped literal now counts only when it ends its operand (end of group,
`:`, `||` or `??`).

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

* fix(i18n): translate the remaining hard-coded labels

Follow-up to the UI-26 pass. The remaining English UI literals now come
from translation keys, translated in all 18 locales:

- Windows/Linux window controls, playlist switcher title and actions
  menu, the portal status tooltip, dashboard carousel roles and rail
  scroll buttons, the search placeholder, the card remove tooltip, the
  EPG offset unit, the REC chip, the Stalker EPG source label and the
  export file type.
- Embedded MPV failures raised in the renderer and the release-notes
  dialog without a bridge. Settings never showed its English reasons,
  so they are dropped.
- Unnamed Stalker episodes: data access leaves the title empty and the
  series view labels it after the TMDB overlay. Synthetic season names
  stay, because they key persisted episode progress.
- The phone remote-control page, which follows the first browser
  language with a translation and sets <html lang>.

Removes the dead getStatusMessage(), the unused favorites layout and the
translateWithFallback() helpers whose keys now exist.

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

* fix(remote-control): keep the remote build off shared-interfaces

The language resolver imported the Language enum, which made
remote-control-web depend on shared-interfaces. Its build-performance
chain then hit Nx's recursive-invocation guard through the existing
shared-interfaces/shared-logging build loop, failing the Electron E2E
and performance journey builds. The resolver now keeps its own list of
translation codes, and a spec checks it against the locale files the page
loads.

Also drops the deleted favorites layout from the zoneless checklist,
whose guard spec requires ticked entries to exist.

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

* perf(journeys): raise the launch DOM mutation baseline for translated attributes

Launch now sets 569 DOM mutations before the first dashboard card instead
of 557 (identical in all three CI iterations). The extra twelve come from
attributes this PR moved from static English to translated bindings on
the launch path: the window-control labels and tooltips on Linux, the
hero carousel and slide roles, and the rail scroll-button labels. A
translated attribute is a binding set after the element is attached, so
each costs a mutation. Accepted as a deliberate trade-off; it needs the
perf-baseline-increase label.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 16:22:31 +02:00
4grayandClaude Opus 5.5 6e18983400 fix(i18n): translate hard-coded labels, snackbars and missing keys (#1867)
* fix(i18n): translate hard-coded labels, snackbars and missing keys

Catalog screens, snackbars and external player messages showed English
in every locale, and seven keys used in code were missing from en.json,
so ngx-translate rendered them raw.

- Catalog: "All items", item and channel counts, channel sort menus and
  tooltips, unnamed-category and empty-category labels, LIVE/PAUSED
  badges and the Xtream global search summary are translated. The Xtream
  and Stalker stores no longer bake an English name into the every-item
  sentinel; the facades return a null title and the view translates it.
- Snackbars: Xtream/Stalker request failures, the 413 upload error,
  backup export/import results and the category visibility failure use
  keys; every "Close" action uses CLOSE.
- External player: the main process sends an error code instead of an
  English sentence (player-error event, session errorCode, and a tag in
  rejected launch errors); the renderer, dock and VOD primary button
  translate it. The external player info dialog is translated.
- Adds the seven missing keys and 43 new ones, translated in all 18
  locales; seven legitimately identical values are baselined.
- tools/i18n/check-usage.mjs fails on keys used in code but missing from
  en.json; it runs in i18n:check (and so in CI through i18n:validate).

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

* fix(i18n): translate catalog counts in the template and skip inline template comments

- The category subtitle hands a key to the translate pipe instead of
  caching translate.instant(), so a cold start re-renders it once the
  language file loads.
- The Xtream live root count uses the singular/plural item keys, so one
  result no longer reads "1 channels".
- check-usage.mjs strips HTML comments inside inline templates of
  TypeScript files, so a commented-out key no longer fails the check.

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

* fix(i18n): check keys in parenthesised translate pipe operands

`(expanded() ? 'SHOW_LESS' : 'SHOW_MORE') | translate` yields keys that
neither precede the pipe directly nor contain a dot, so the usage check
missed them. It now reads the ternary and fallback branches of a
parenthesised operand; a literal compared in the condition is not read
as a key.

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

* test(e2e): expect the translated external player failure in the dock

A launch failure without an error code now shows the translated generic
status in the playback dock, with the raw main-process detail as its
tooltip. The ClearKey DASH flow asserted the raw English text.

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

* fix(playback): keep the IPC error tag out of the stored session detail

A tagged launch failure reached ExternalPlayerSession.error unchanged, so
the dock tooltip showed "[iptvnator:external-player:start-failed]". The
registry now strips the tag when it stores the detail; the rejected IPC
error keeps it for the renderer to read the code.

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

* test(web): expect translated portal request failure toasts

#1861's new resolved-failure specs asserted the English toast text; the
toasts now go through PORTALS.REQUEST_ERRORS keys, so the specs check
the key and its message/status params.

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

* fix(i18n): read only returned branches of a grouped translate operand

A literal at the start of a condition, as in
`('ERROR' === status() ? 'CLOSE' : 'CLOSE') | translate`, was taken for
a translation key, so a valid template could fail the usage check. A
grouped literal now counts only when it ends its operand (end of group,
`:`, `||` or `??`).

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 13:02:21 +02:00
4grayandClaude Fable 5.1 0060330b5e fix(portals): resolve cancelled and 401/403 portal requests instead of rejecting through IPC (#1861)
* fix(portals): resolve cancelled and 401/403 portal requests instead of rejecting through IPC

Electron logs every rejected ipcMain.handle promise as
"Error occurred in handler for '<channel>'" with a stack trace, and
ipcRenderer.invoke keeps nothing of the rejection but its message. A
request the renderer cancelled and an HTTP 401/403 are routine outcomes,
not errors, so STALKER_REQUEST and XTREAM_REQUEST now resolve them as a
structured { portalRequestFailure } envelope. ElectronService, the only
reader of the raw bridge result, rethrows it as an AbortError or as an
"HTTP Error <code>" error that carries the numeric status, so a
cancellation is never read as an empty answer.

- cancelled requests: silent in the main process unless
  IPTVNATOR_TRACE_IPC is on; the renderer logs at debug level, no snackbar
- 401/403: one credential-free console.warn (host and pathname only)
- 404, 5xx, network errors and the guard fast-fail keep rejecting with
  their existing message contracts and error log

Regression coverage in both handler specs, the shared contract and
classifier specs, the renderer data-service spec, and an Electron E2E
that reads the real main-process output.

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

* fix(portals): type the Xtream bridge result as a union with the failure envelope

A resolved cancellation or 401/403 carries neither `payload` nor
`action`, so `xtreamRequest` now promises
`ElectronBridgeXtreamResponse | PortalRequestFailureEnvelope` and
`ElectronService` narrows it with `isPortalRequestFailureEnvelope`
before reading success fields. Review finding from the local Greptile
pass.

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

* fix(portals): reject malformed envelopes in the guard; harden the logging E2E

Greptile findings on the pushed head:

- `isPortalRequestFailureEnvelope` vouched for an `http` failure whose
  `statusText` was not a string, so a malformed envelope would have
  reached `statusText.trim()` as a TypeError; the reader and the guard
  now reject it, with regression cases.
- The logging E2E inherited `IPTVNATOR_TRACE_IPC`/`IPTVNATOR_TRACE_STARTUP`
  from a developer shell, which makes the handlers log cancellations on
  purpose and fail the silence assertion; both flags are omitted at launch.
- The refusing portal's listener is now closed in an outer `finally`, so
  a failed Electron launch or close no longer leaks it.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-10 11:28:59 +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 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 Opus 5.5 015ea203b7 fix(player): keyboard-reachable, bounded labels for timeline segments (#1854)
Seek-bar segments (catch-up programmes, file chapters) now reach keyboard,
touch and screen-reader users through the shared timelineSegments path:

- aria-valuetext reads the translated "<title> · <time>" inside a titled
  segment, the plain time otherwise.
- ControlsTimelineLabel anchors the label on keyboard focus and drag
  previews as well as pointer hover.
- Two-part label (ellipsized title, whole time), clamped by its measured
  width and re-placed on resize to stay 8px inside the player.
- Opaque --pc-timeline-track with white separators clamped to the track,
  so boundaries hold 3:1 over any frame.
- Translated LIVE badge; LIVE and --:-- are named role="img" elements in
  both docks.
- epglong Xtream mock scenario and an Electron E2E in both themes at
  1280/800px and in de/ru.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 08:47:38 +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
8581bddcaf perf(web): keep the NgRx store devtools out of production bundles (#1810)
* perf(web): keep the NgRx store devtools out of production bundles

app.config.ts imported @ngrx/store-devtools statically and gated it on
AppConfig.production at runtime, so the optimizer kept the module in
main.js for the production, PWA and performance builds. The providers now
come from environments/store-devtools.providers.ts, an empty list in every
build; only the development and electron-e2e configurations swap in the
devtools through fileReplacements. A build-config test keeps it that way.

renderer.initialBytes: 1,608,610 -> 1,596,045 bytes (-12,565) on a local
production build; the baseline is lowered to the measured value.

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

* perf(web): cite #1810 as the initial-bytes baseline evidence

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 21:19:34 +02:00
22a9c1f1e5 perf(web): add an opt-in zoneless change-detection build flag (#1824)
* perf(web): add an opt-in zoneless change-detection build flag

Plan item C6 step 4. app.config.ts takes its change-detection providers
from environments/change-detection.providers.ts, which keeps
provideZoneChangeDetection({ eventCoalescing: true }) for every existing
build. The new electron-performance-zoneless and electron-e2e-zoneless
web configurations are their base configuration plus one fileReplacements
swap to provideZonelessChangeDetection(), so the journeys and the Electron
E2E suite can run zoneless while nothing ships it. zone.js stays in the
polyfills until the flip.

A build-config test pins each *-zoneless configuration to its base plus
the swap and refuses the swap anywhere else.

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

* fix(epg): schedule the guide's post-render scroll without zone.js

The programme guide jumps to now once the virtual list first renders rows,
and focuses cells after keyboard scrolls, from afterNextRender hooks
registered in CDK and RxJS callbacks. zone.js followed those callbacks
with a tick; under zoneless change detection a render hook schedules no
render, so the guide opened at midnight (epg-guide.e2e.ts on the zoneless
build). The guide now marks itself when it registers one, which is
harmless with zone.js.

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

* docs(performance): record the zoneless flag measurements and E2E run

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

* docs(performance): say the zoneless flag ran with the three implemented journeys

Review follow-up (Greptile): J4 search is still planned, so the flag was
validated with J1-J3 and the Electron E2E suite, not all four journeys.

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-06 17:52:47 +02:00
77458b3931 perf(web): make the app root and settings components OnPush (#1823)
* perf(web): make the app root and settings components OnPush

Plan item C6 step 3 for apps/web: the fifteen Eager components switch to
OnPush, among them the app root and the update notification panel that
the idle audit found re-rendering on every idle tick. Their template
state is signals from the settings facades, signal inputs and the shared
reactive settings form.

The checklist flagged the backup import, which patches the form from a
detached file input with no template event. A new spec patches only a
value, which changes no form status, and confirms the OnPush general
section still shows the new theme; it guards that path for the zoneless
flag.

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

* fix(settings): re-render OnPush sections when the form changes outside them

Review follow-ups (Greptile, Codex):

- The settings sections read form values in their templates (selected
  theme and cover size, epgField.value, form().value.player), and the
  parent changes the form outside their events: Discard and backup import
  patch it, the store hydrates it, the EPG file picker sets a control
  after an await. Under OnPush the section kept the old selection or EPG
  status. Each section now marks itself on its form's events
  (markSectionForCheckOnFormEvents).
- The value-only patch test no longer forces detectChanges(); with the
  fixture rendering on its own it fails without the marking, and so does
  a new test for a control set outside the EPG section.
- The zoneless guard counts only changeDetection metadata outside
  comments, so a comment naming the strategy is not an Eager component.

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

* test(settings): guard the unsaved-changes bar after a save off the sections

Review follow-up (Codex): Save marks the form pristine after an async
store write, also on Backup, Reset or search, where no form section is
rendered. The OnPush page re-renders anyway because pristine and valid
read the form's state signals; the new test checks that on the Backup
page without forcing a render (it waits for the scheduled one).

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-06 15:58:28 +02:00
4grayandClaude Opus 5.5 7a629f5fe5 fix(dashboard): hero legibility in the light theme and stable page heading (#1811)
UI-24 from the UI consistency audit.

- No-artwork slides paint their gradient in CSS from the slide hue: a light
  tint in the light theme, unchanged near-black in the dark one. The dark
  gradient under the light page-coloured scrim read as a grey slab.
- The side scrim holds 88% of the page colour up to the slide's right edge
  (inset + min(560px, 55%)), so the end of a full slide no longer sits on
  about 45%.
- Narrow layout (container <= 720px): a full-bleed 90% scrim behind the text
  block, a scrim-coloured text shadow, and an entrance without a fade so
  that scrim never flashes the art on a rotation.
- --hero-body is 85% of the heading colour (was 72%).
- Light --app-rating-color #a16207 -> #7a4a00: measured 3.36:1 on the chip
  over artwork, now 5.10:1. The details pages share the chip and token.
- Buttons cap at the slide width and end long labels in an ellipsis.
- The page gets one visually hidden h1 ("Dashboard"); slide titles are h2.
- One live region outside the re-created slide announces slide changes;
  progress bars are named and VOD ones read "N% watched"; dots are 24px.

dashboard-hero-legibility.e2e.ts replaces every image with a checkerboard
and measures each piece of slide text from the screen in both themes, wide
and narrow, for backdrop, poster, no-artwork and live slides. On master the
worst cases were 2.35:1 (body text) and 2.65:1 (pills).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 16:51:58 +02:00
4grayandClaude Fable 5.1 bc5a7fcbf9 fix(playback): keep the saved Embedded MPV player when the mpv check is inconclusive (#1803)
* fix(playback): keep the saved Embedded MPV player when the mpv check is inconclusive

On Linux native-view the support check runs `mpv --version` by bare name
and waits for the login shell PATH first. Since #1784 that lookup is
asynchronous with a 10 s budget; when it ran out, the check ran on the
inherited PATH and answered a plain `supported: false`. The settings store
took that as a verdict and persisted the default player over a saved
Embedded MPV selection. The main process probed again once the shell
answered, but nothing restored the setting.

`EmbeddedMpvSupport` now carries `inconclusive`. The native service sets it
on a missing mpv while its probe has only seen the inherited PATH; the IPC
handler declares that state before probing and registers the re-probe
before the check, so a throwing check cannot leave it stuck. Every other
answer stays final.

Consumers no longer settle on an inconclusive answer: the settings store
keeps the saved player, and the command palette and the settings search
probe again on their next use instead of caching it for the session.

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

* fix(playback): keep asking for Embedded MPV support while the answer is inconclusive

Keeping the saved player on an inconclusive answer left a mounted player
stuck on it: the session controller asked for support once, in its
constructor, and the session effect never starts while unsupported, so the
player did not recover after the login shell answered. The settings page
held its one answer the same way.

`watchEmbeddedMpvSupport()` asks again every 3 s until the answer is final
or the surface is destroyed. The player controller and the settings page
facade load support through it, so playback starts by itself and the
Embedded MPV option appears without reopening the page.

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

* fix(settings): follow an inconclusive Embedded MPV answer to a final decision

The settings store checked a saved Embedded MPV selection once. After an
inconclusive answer it kept the selection and never looked again, so when
mpv turned out to be really missing the player stayed on Embedded MPV
instead of falling back to the default one.

The store now follows the answer with `watchEmbeddedMpvSupport()` until it
is final and only then decides. It acts on an answer only while Embedded
MPV is still the saved player, so a player picked meanwhile, also while
the first answer was pending, is never overwritten.

The watch backs off from 3 s to 30 s between rechecks, so a login shell
that never answers does not keep the app polling at the first rate, and it
no longer schedules a recheck after its answer handler stopped it.

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

* fix(settings): keep the settings search following an inconclusive Embedded MPV answer

The settings page asks the search service for Embedded MPV support once,
when its search facade is created. After an inconclusive answer the service
only probed again on its next call, so with the page left open the
Embedded MPV rows stayed unsearchable after the login shell answered,
while the player option on the same page already updated.

The service now follows the answer with `watchEmbeddedMpvSupport()` until
it is final, which updates the open page and the command palette alike. A
call made while the answer is still inconclusive restarts the watch, so it
asks at once as before.

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

* fix(settings): follow Embedded MPV support for search only while the settings page is open

The settings search service is provided in the root injector, so the
watch it started on its first use had no owner: with a login shell that
never answers it kept asking every 30 s until the app quit, long after
the settings page or the command palette that needed the answer was
closed. A failed recheck also ended the watch as if it were a final
answer, hiding the Embedded MPV rows for the rest of the session.

The service now separates the two uses. `ensureEmbeddedMpvSupportLoaded()`
is a single request again, for the command palette. The settings page
calls `followEmbeddedMpvSupport()` and ends it when the page is destroyed.
Only a final answer is kept: after an inconclusive one or a failed
request the next use asks again, for the palette's player commands too.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-04 10:54:19 +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 572034f3be fix(ui): declare Material system tokens and migrate dead --mdc overrides (#1775) 2026-10-01 18:02:50 +02:00
e4cf48fdc2 test(perf): count change-detection ticks in the electron-performance build (#1776)
* test(perf): count change-detection ticks in the electron-performance build

J1 renderer.cdTicksToFirstCard, J2 renderer.cdTicksToFirstPage and the
J1 idle baseline renderer.cdTicksIdle30s. Angular's ɵsetProfiler is only
reachable through the dev-mode window.ng global, so the electron-performance
configuration alone swaps environment.ts for environment.performance.ts,
which re-exports the production AppConfig and wraps ApplicationRef._tick.
Production and PWA sources and output are unchanged.

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

* test(perf): refuse a J1 idle window that opened late after the settle point

Addresses review: the idle window opens in the settle timer's callback while
the settle point is that timer's deadline, so a late callback left ticks
uncounted between the two.

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-01 14:23:15 +02:00
4gray d1e79bdc3e fix(parental-lock): per-flow PIN dialog labels and visible mismatch error (#1777) 2026-10-01 11:21:27 +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
4grayandClaude Opus 5.5 525ca7bc44 fix(playback): show the Up next card near the real end of an episode (#1768)
* fix(playback): show the Up next card near the real end of an episode

The card appeared a fixed 8 minutes before the end: most of a short
episode, and far into the story of a long one, with no way to hide it.

- Adaptive lead: 4% of the episode, clamped to 40 s … 3 min.
- A closing-credits chapter in the last third, when timeline segments
  carry one, brings the card forward to its start (capped at 5 min).
- Close button and Escape dismiss the card for the current next episode.
- After 10 s (not while hovered) the card collapses into a one-line pill.
- Seconds countdown in the last minute.
- New playback setting "Up next card" (default on) turns it off.

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

* fix(playback): address Up next card review feedback

- Offer the Up next card setting for Embedded MPV only under the
  frame-copy engine; native view never mounts the shared controls.
- Hovering pauses the collapse delay instead of restarting it.
- Document that the card stays visible when the controls auto-hide.

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

* fix(playback): restart the Up next collapse delay for a new episode

- A card that stays mounted while its next episode changes gives the new
  item the full delay instead of the previous item's leftover.
- The setting description no longer promises credit-based timing: no
  current series path supplies chapters yet.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 21:56:48 +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
b7e0a59ea2 fix(i18n): translate parental lock strings in 16 locales (#1755)
* fix(i18n): translate parental lock strings in 16 locales

The parental lock feature (#1601) added 57 keys that only de and ru
translated; ar, ary, by, el, es, fr, hu, it, ja, ko, nl, pl, pt, tr, zh
and zhtw still showed the English text. Translate them using each
locale's existing terms for categories, groups, sources and settings,
and quote each locale's own "Manage categories" / "Manage groups"
labels in the how-to text.

nl WORKSPACE.DASHBOARD.HERO_DETAILS stays "Details": it is correct
Dutch and belongs to the dashboard hero, not the parental lock.

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

* fix(i18n): use gender-neutral locked counts

LOCKED_COUNT and LOCKED_CATEGORIES_ROW count both categories and M3U
groups. The generic masculine plural in es, fr, it and pt read wrong for
(feminine) categories, so count the locks with a noun instead. el now
uses the feminine plural that fits both nouns.

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-30 11:28:14 +02:00
4gray be217d5cf5 feat(playback): Hybrid redesign of the shared player controls (#1709) 2026-09-28 07:32:55 +02:00
4gray 363411542f fix(workspace): describe settings in the command palette search label and empty state (#1726) 2026-09-28 00:00:52 +02:00
4gray 887ac64d18 feat(workspace): cinematic rotating dashboard hero (#1725) 2026-09-27 21:27:47 +02:00
650da4a1d3 ci(test): type-check Jest spec programs and gate it in CI (#1705)
* build(test): make spec tsconfigs resolve what Jest resolves

Lib spec tsconfigs used module: commonjs with node10 resolution, which cannot
see Angular's exports-only secondary entry points, and dropped global.d.ts, so
tsc reported thousands of resolution errors and no window.electron typing.
Switch them to module: preserve with bundler resolution (ts-jest still forces
CommonJS emit outside ESM mode), add global.d.ts to every spec program, type
jest.unstable_mockModule for the ESM workspace, include the ui-epg and
ui-playback specs that jest.web-esm.workspace.ts runs under the web spec
config, and drop the snack-bar stub that shadowed the real Material types.

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

* ci(test): gate spec type-checking with typecheck:spec

Add tools/typecheck/spec-typecheck.mjs, which runs tsc --noEmit over every
tsconfig.spec.json with a small pool and fails on any diagnostic, wire it into
the unit-and-typecheck job after typecheck:ci, and document the gate and the
spec tsconfig conventions in the validation map. Also bring the non-Tier-A
spec configs (remote-control-web, ui-remote-control, stalker-mock-server) to
the same conventions so the gate covers the whole workspace.

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

* test: fix the spec type errors surfaced by typecheck:spec

With the spec programs resolving modules and ambient typings correctly,
tsc reported 432 genuine errors across the Tier A projects: read-only
capability flags assigned on Partial<> doubles, signal-store values used as
types, fixtures missing required fields, index-signature property access,
partial bridge doubles cast through incompatible shapes, and deferred
resolvers narrowed to never. Type the doubles instead of casting to any:
writable mapped types for capability flags, InstanceType<typeof StalkerStore>,
typed jest.fn signatures, protectedState: false on test signal stores, and
completed fixtures. Production changes are limited to bracket access for
index-signature properties under the libs' noPropertyAccessFromIndexSignature
setting and two narrowing guards in the global favorites loader.

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

* test(playback): use the ESM setup's jest global in the controls fixtures

The fixture imported jest from @jest/globals, which is not a direct
dependency. Jest provides that module at runtime, so tests passed, but on a
clean pnpm install tsc cannot resolve it and typecheck:spec failed in CI.
The ESM test setup already installs import.meta.jest as the global, typed
by @types/jest, as the other ESM specs use it.

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

* test: type the parental lock doubles merged since the gate was written

The parental lock feature (#1601) and the Stalker actor route landed on master
with spec doubles declared as zero-argument jest.fn()s that the tests then
drive with the real arguments, plus a copy of the ResizableDirective override
imported from a library that does not export it. Give the doubles the lock
service's real signatures, drop the dead override as in the sibling layout
specs, use bracket access for the actor route's personId param, and keep the
Stalker layout spec within the 1200-line limit.

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

---------

Co-authored-by: 4gray <fourgray@proton.me>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-27 20:54:27 +02:00
9d02f90dfe perf(web): keep backup/restore and portal helpers off the initial path (#1734)
* perf(web): keep backup/restore and portal helpers off the initial path

#1601 (parental lock) put about 35 KB onto the renderer's initial path by
design (the lock service, lock store and enforcement gate the workspace
resolver and the catalog data sources) and was merged with the ratchet red:
renderer.initialBytes 1,655,428 against the 1,619,993 baseline.

Offset it without touching the lock gate. Code splitting puts a module in the
chunk shared by every entry that reaches it, so helpers only lazy routes use
landed in initial chunks because eager files reach them through barrels:

- PlaylistBackupService (only the lazy settings page) moves to
  @iptvnator/services/playlist-backup and out of the services barrel.
- The eager Xtream data layer and root shell import the portal logger and DI
  tokens through @iptvnator/portal/shared/util/logger and /tokens instead of
  the barrel, whose navigation, keyboard-shortcut and download helpers
  (about 45 KB) belong to the lazy portal routes.

renderer.initialBytes 1,655,428 -> 1,598,232 bytes (-57,196).

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

* chore(performance): lower the initial-bytes baseline to 1,598,232 bytes

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-27 17:57:44 +02:00