- 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>
- 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>
- 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>
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>
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>
- 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>
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>
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>
- 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>
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>
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>
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>
- 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>
- 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>
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>
- 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>
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>
* 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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
- 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>
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>
- 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>
- 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>
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>
* 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>
* 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>
A Sources row opened only on a pointer click: the row div was not
focusable and had no key handler, so a keyboard user could Tab to its
actions but never open the playlist.
Following app-content-card, the row's title and meta block becomes the
keyboard activation element: role="button", tabindex="0", named by the
source title, aria-current on the active source, aria-disabled while
busy. Enter and Space open the source (Space prevents the page scroll).
The drag handle, health indicator and actions stay its siblings, so no
control is nested inside a role="button" and their keys never bubble
into it. A click anywhere on the row still opens it via the row.
The row draws the ring with the shared focus-ring mixin, inset, because
the list's scroller would cut a ring drawn outside the full-width row.
playlist-shared-ui now declares its ui-styles stylesheet dependency.
Tests: unit spec for Enter/Space, bubbling from an action button, busy
and selected states; Electron E2E tabs to a source row, checks the ring
and opens it with Enter (fails on the previous component).
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* 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>
* 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>
* 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>
* 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>
* perf(playlist): make the playlist import and shared UI components OnPush
Plan item C6 step 3 for libs/playlist (import/feature and shared/ui): the
twelve Eager components switch to OnPush. Two of them rendered plain fields
written after an await, outside any template event, which only an Eager
check on the next zone tick picked up:
- playlist-item's portal status dot (PWA, after the async portal check)
now reads a signal;
- playlist-info's playlist is backed by a signal behind its existing
getter/setter name, so the EPG source list follows removals and file
picks that land after awaited cleanup and dialogs.
A regression test for each fails on OnPush with the plain field and passes
with the signal. The Stalker import's post-await patchValue needs no change
(see the zoneless checklist). The m3u feature-player components stay Eager
for the playback PR.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(playlist): check the OnPush dialogs without forcing a render
Review follow-ups (Greptile, Codex):
- The portal-status and EPG-row tests forced detectChanges() after their
await, so they passed with plain fields. They now let the fixture render
on its own; with portalStatus back on a plain field the status test fails.
- New: the playlist info dialog enables Save and shows the path after a
native EPG file pick, without a forced render. pristine and valid read
the form's state signals, so the OnPush dialog follows on its own.
- New render spec for the add-playlist dialog with the real URL form: Add
enables after typing and after a patch from outside the child (as an
auto-detect prefill does).
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>
* 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>
* 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>
* 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>
Tier A coverage runs projects a few at a time (largest first, bounded Jest workers, buffered output, fail-fast kept) and ts-jest transpiles with isolatedModules instead of type-checking per process; five type re-exports become export type, two decorated inputs use import type. Unit Tests and Typechecks job: 26 min -> 9 min (Tier A step 23 min -> 6.5 min).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Turkish upper and lower case queries now return the same results everywhere a
title can be searched. Lower-casing the dotted capital "İ" (U+0130) leaves a
combining dot behind, so "İnş" and "inş" reached different search arms and
different results.
- Case folding is locale-invariant: `toLowerCase()`, never `toLocaleLowerCase()`,
which under a Turkish or Azeri OS locale maps ASCII "I" to the dotless "ı".
- The Electron content search composes to NFC and drops the leftover combining
marks before tokenizing, and its LIKE/GLOB pattern builders additionally spell
the `'tr'`-locale İ forms, since SQLite LIKE folds only ASCII.
- A shared `foldSearchText` covers every in-memory filter: channel lists, the
Xtream and Stalker catalogs, category filters, collections, the EPG guide, the
command palette, sources, the playlist switcher and the download lists.
- Composing before the strip keeps canonically equivalent spellings equal while
the fold stays accent-sensitive; the Turkish I/ı pair is deliberately left
alone, as the FTS index does not fold it either.
Covered by a SQLite-backed spec over the real trigram index plus regression
cases in the affected renderer specs.
Closes#609.
Co-Authored-By: Justin Willhite <5132924+thejdubb02@users.noreply.github.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>