On Windows with a light OS theme, scrollbars rendered light even when the
app was switched to dark. Two combined causes:
- The page never declared `color-scheme`, so Chromium colored native
scrollbars from the OS preference. Declare `color-scheme: light` on html
and flip it to `dark` via `html:has(> body.dark-theme)` plus the
`.dark-theme` block itself.
- Scrollbar styling referenced `--mat-sys-*` tokens, which are never
emitted by the current Material theme setup (mat.define-theme +
all-component-themes does not produce system tokens). Those
`scrollbar-color` declarations computed to `auto`, falling back to the
native (light) scrollbar. Switch scrollbar styling to the `--app-muted-color`
design token (defined for both themes), replace hardcoded white
`rgba(255,255,255,.08)` thumbs, and add an explicit `scrollbar-color`
where only `scrollbar-width: thin` was set.
Verified live in Electron via CDP in both themes: scrollbar-color resolves
and scrollbars render dark in dark theme regardless of the OS setting.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(favorites): persist custom drag-and-drop order for Xtream favorites
Prepared-statement writes dispatched via drizzle's `.execute()` on the
better-sqlite3 driver return a promise and defer the write to a microtask.
Inside a synchronous `db.transaction(() => ...)` callback (which cannot
await), the transaction commits before that promise settles, so the write
is a silent no-op — no error, no rows changed.
This bit `reorderGlobalFavorites`: the custom favorites order never
persisted for the per-playlist ("This playlist") Xtream scope, which relies
solely on the `favorites.position` column. The global ("All playlists")
scope masked the bug because it also persists an order to the `appState`
`global-favorites-channel-order-v1` key and re-applies it on read.
`removeRecentItemsBatch` had the same latent bug — batch "clear recent
items" silently did nothing.
Switch both writers to synchronous `.run()`. Add regression coverage that
asserts `.run()` (not `.execute()`) is used and would fail on the old
behavior, and document the gotcha in the DB worker architecture doc.
Verified over CDP against a live Electron instance: reorder writes
positions 0..N, and the order survives navigation and a full reload.
Fixes#1137
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(favorites): scope reorder position writes by playlist
The global favorites reorder wrote the new position filtering only by
content_id, so two Xtream playlists holding a favorite with the same
content_id would clobber each other's persisted order (greptile P1).
Thread playlist_id through the whole reorder path — the renderer builder
(UnifiedCollectionItem already carries playlistId), the IPC contract
(ElectronBridgeFavoriteReorderUpdate + inline payload types), the worker
op — and scope the prepared UPDATE by (contentId, playlistId), matching
the favorites composite unique index.
Tests: favorites.operations.spec asserts the playlistId placeholder and
per-row playlistId payload; preload contract fixture updated.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(favorites): include playlist_id in workspace global favorites reorder payload
The workspace global-favorites reorder path still sent updates with only
content_id and position. Since the backend UPDATE is now scoped by
(contentId, playlistId), that payload binds an undefined playlist id and
matches no rows — the DB write silently no-ops (flagged by Greptile P1).
Also scope the prepared-statement example in the sqlite-db-worker gotcha
doc by (contentId, playlistId) so it no longer documents the
cross-playlist rewrite this PR fixes (flagged by Codex P3).
Regression spec asserts the reorder payload carries playlist_id per item
(fails on the old payload shape) and that the appState uid order is
still persisted for non-Xtream items.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* fix(lint): resolve module-boundary and prefer-inject errors
Retag workspace-shell-util as type:data-access to match its injectable
services that depend on @iptvnator/services, and convert
RemoteControlService to inject(HttpClient).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(lint): enforce max-lines 400 with generated baseline
Add a max-lines ESLint error (hard cap 400 raw lines per TypeScript
file) per the repo file-size rule. The 134 pre-existing offenders are
baselined in tools/eslint/max-lines-baseline.mjs, regenerable via
generate-max-lines-baseline.mjs; the list should only shrink.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(ci): enforce lint on PRs and guard coverage policy drift
- Add a Lint job to ci.yml running nx run-many -t lint --all, so
module-boundary tags, legacy-alias bans, and max-lines gate merges.
- Fix the root lint script (was linting only electron-backend).
- Add tools/coverage/check-coverage-policy.mjs: fails CI when a project
with a test target is missing from coverage-policy.json; wired into
coverage:ci as coverage:policy:check.
- Run Tier B/C unit tests in CI without coverage (list derived from the
policy), so website/packaging/remote-control tests run on PRs.
- Replace the hand-picked 16-project test:unit:ci list with --all.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs: document CI lint enforcement and coverage policy guard
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(ci): address bot review feedback on policy guard and baseline generator
- Drive Tier B/C validation from each policy entry's validationCommand
(falling back to nx test), skipping projects with an e2e target since
the E2E workflow already runs them (Codex).
- Fail when a Tier A entry has no test target (Greptile, adapted:
checking all entries against test targets would false-positive on the
intentionally spec-less e2e/mock-server tiers).
- Guard against missing JSON array in nx show projects output (Greptile).
- Scan .tsx files in the max-lines baseline generator to match the
ESLint rule's file patterns (Greptile).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Register an "Switch player to Embedded MPV" command in the Cmd+K command
palette so the embedded MPV player can be activated like the other players.
Visibility is gated on an async getEmbeddedMpvSupport() check, mirroring the
Settings dropdown so the command only appears when embedded MPV is usable.
Generalizes the per-command visibility flag from desktopOnly to a `requires`
discriminator ('none' | 'managed-external' | 'embedded-mpv').
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two Electron E2E regressions from this branch's redesigns:
1. Hiding the playlist switcher on /settings (a playlist-scoped control)
also hid the global "Add playlist" button, which lives in the same
header-actions block. A user configuring Settings before importing
their first source had no way to add a playlist — and the settings /
playlist-switcher E2E suites, which add a portal right after saving a
setting, timed out waiting for the button. The Add Playlist button is
a global action, so it now stays visible on Settings; only the
per-playlist context shortcut and bulk actions are hidden there.
2. dashboard-activation.e2e.ts asserted on `dashboard-global-favorites-
rail`, the mixed Favorites rail removed earlier in this branch. The
test now targets the v0.22 rails: live favorites resolve through the
favorites-first `dashboard-live-recent-rail`, and the played movie /
series resolve through `dashboard-continue-watching-rail` (renamed
from `dashboard-recently-watched-rail`). The movie-from-favorites
assertion was dropped — that dashboard surface no longer exists; the
favorites collection still has its own page-level coverage.
Verified locally: dashboard-activation, settings, and playlist-switcher
E2E specs all pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
After switching from mono to sans, the SETTINGS label still used
uppercase + 0.1em letter-spacing — an eyebrow treatment that doesn't
appear anywhere else in the workspace rail. The neighbouring items
("Dashboard", "Sources", "Global favorites") are all sentence case,
so the uppercase header stood out as a one-off.
Use sentence case "Settings" with the same typography family as the
rail items: same font, same case, small + muted so it still reads as
a quiet caption rather than competing with the clickable items below.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The v0.22 mockup uses JetBrains Mono for eyebrow labels as part of its
broader editorial design language. The previous commit ported that one
element verbatim, but the rest of the workspace shell never uses mono
for chrome — only for code-adjacent data (EPG time codes, kbd glyphs,
port numbers). So the SETTINGS rail title became a one-off mono label
in an otherwise all-sans shell.
Drop the `font-family: 'JetBrains Mono'…` declaration so the eyebrow
inherits DM Sans. Bump the weight from 500 → 600 to compensate for
mono's higher visual density. Uppercase + 0.1em letter-spacing + small
size are kept — those carry the eyebrow effect regardless of typeface.
If we ever want to port the broader mono-eyebrow system across rails
and chips, that's a separate (bigger) design decision.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The earlier Settings polish (kill triple header, compact theme picker,
sticky footer, hide playlist switcher) addressed structure. This round
addresses typography and chrome to bring the look in line with the
v0.22 mockup spec captured in redesign-screens-2.jsx.
Three changes:
1. Flat sections — no card chrome
- Each .settings-group was a rounded panel (border-radius:22px,
filled background, box-shadow, .settings-group--active ring +
glow). Combined with the per-section icon-circle next to the
title, every section looked like a feature card, not a settings
region anchor. Strip all of it: the rail's active state on the
left already announces "you are here".
- Hide .settings-group__header-icon via display:none so the icon-
circle markup in each section template stays intact while the
visual chrome goes away — saves touching 6 templates for a
CSS-only change.
2. Promote section titles to a large flat heading
- Was h3 1.08rem 700 weight nested in the card chrome. Now h3
1.5rem (24px) 600 weight with a 1px bottom-border separator,
matching the mockup's 28px h1 + sub anchor pattern (24px is a
reasonable density compromise for the denser app layout). Active-
section variant keeps the blue title accent so users scrolling
the right pane don't lose the anchor.
- .setting-item drops the `margin: 0 18px` card inset so rows
align flush with the section title — flat list rhythm.
3. Quiet mono eyebrow for the left nav title
- .panel-title was a 20px 500 weight h2 ("Settings") competing for
"biggest text on screen" against the section titles on the right.
Switch to mono 11px uppercase 0.1em letter-spaced text-secondary
— quiet rail header per the mockup. Now the section titles on
the right are unambiguously the dominant heading.
Visual confirmation via agent-browser shows a much closer match to the
mockup: subtle SETTINGS eyebrow above the rail list, large flat
"General" anchor with bottom border, compact pickers, full-width sticky
footer.
50/50 settings tests still pass; build clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Four UX-audit fixes for the Settings page:
1. Header dedupe
- The page used to stack THREE headers vertically: the workspace rail's
"Settings" entry, a page-level <h1>Settings</h1> + subtitle, and the
General section's <h3>General</h3> + subtitle. The page-level and
section-level subtitles both used SETTINGS.GENERAL_SUBTITLE
("Change the configuration of the application"), word-for-word.
- Drop the visible page-level header entirely. Keep an a11y-only
<span class="visually-hidden"> for the data-test-id hook so the
existing settings.component.spec selector still resolves. Left rail
announces "Settings"; section header is now the page anchor.
- Add a global .visually-hidden helper to styles.scss for re-use.
2. Theme + Cover-size pickers shrink to a real segmented control
- .theme-switcher was a 3-column grid of ~78px-tall buttons making
"Choose theme" look like the most important action in Settings.
Replaced with a 30px-tall segmented control (track + thumb with
box-shadow on the selected option), matching the standard Material
"compact row" cadence everywhere else.
- Shortened all 18 locales' THEMES.* labels from "Light theme / Dark
theme / System theme" to "Light / Dark / System". The "theme" word
duplicated the section's own h3 ("Visual theme") and forced labels
to wrap into two lines in narrow columns.
3. Save bar becomes a real sticky footer
- The action bar was a `border-radius:18px; margin-left:auto;
position:sticky` chip floating bottom-right that clipped the last
form rows underneath. Now spans the full content column with a
top border separator — sits flush with the bottom edge while
still sticky on scroll. The save-button gradient also drops the
hand-rolled #3b82f6 in favour of var(--app-selection-color) so it
matches the unified blue primary used everywhere else.
4. Playlist switcher hidden on /settings
- Workspace shell header gains an isSettingsRoute input that hides
the playlist switcher block AND the leading actions group
(+ Add source / header shortcut / bulk action). Settings is a
global page — those controls were implying that switching
playlists scopes settings, which it doesn't. Wired through from
the shell facade's existing isSettingsRoute computed.
- Mock header in workspace-shell.component.spec gains the matching
input() declaration so the binding resolves under tests.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Introduced new localization keys for Xtream refresh actions in multiple languages (ar, ary, by, de, el, en, es, fr, it, ja, ko, nl, pl, pt, ru, tr, zh, zhtw).
- Implemented refresh preparation state management in PlaylistRefreshActionService.
- Enhanced WorkspaceShellXtreamImportService to handle refresh preparation states and display appropriate labels.
- Updated tests to cover new refresh preparation scenarios and ensure correct overlay display during refresh operations.
Entire-Checkpoint: f957cd9849e0
Two small focused changes from an Angular perf audit.
content-card lazy loading
ContentCard is the building block for catalog grids and rails — Xtream
recently-added (3 rails × 20 = 60 cards on first paint), Xtream/Stalker
search results, unified favorites/recent grids. The poster <img> had
no loading attribute, so every off-screen card eagerly fetched its
poster from the Xtream/Stalker server on initial render. Same fix
already in place on the dashboard rail's separate <img>; mirror it on
the shared ContentCard so every consumer benefits.
Add loading="lazy" decoding="async" to both <img> tags (real poster
and the default-poster fallback). Browser defers off-screen requests;
no JS changes.
OnPush on workspace-sources-filters-panel
The sources filters panel renders one row per filter option and
displays {{ getTypeCount(option.id) }} per row — that method is a
plain Map lookup but fires every CD cycle under default change
detection. The component is signal-only (computed + selectSignal, no
subscribes) so the OnPush conversion is purely additive.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: f957cd9849e0
When a search filter inside a category produces zero results, the grid
@empty fallback used to render the EMPTY_CATEGORY view ("No content in
this category" with the empty-tv illustration). That message was
misleading — the category itself wasn't empty, only the search filter
excluded everything.
grid-list now takes a searchTerm input and branches the @empty fallback:
when a search is active it renders the NO_SEARCH_RESULTS view with the
parameterised "No results found for {term}" title, otherwise it falls
back to EMPTY_CATEGORY as before. category-content-view derives the
searchTerm signal from the ?q= query param and passes it through.
Also: bump the NO_SEARCH_RESULTS icon from a 64px mat-icon to a clamp()
sized icon (120-180px) at 0.5 opacity so it visually balances with the
SVG illustrations used by the other viewTypes, swap the icon to
search_off (struck-through magnifier) so it reads as "no results"
rather than "search now", and fix the EN i18n typo
("change you search request" -> "change your search request").
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: 2e1a36e2f0f6
The two component specs in this lib had been carrying 7 long-standing
failures rooted in three stale-mock issues, all unrelated to public
behaviour but enough to make the suite red on every run.
Header spec (5 failures, NG0303 on app-playlist-switcher):
- MockPlaylistSwitcherComponent was missing canRefreshActivePlaylist /
isRefreshingActivePlaylist inputs and refreshPlaylistRequested
output. The real PlaylistSwitcherComponent grew them when refresh
was added to the header; the mock never caught up. Re-aligned.
Workspace-shell component spec (2 failures):
- NG0301 on #dropZone="playlistDropZone" because overrideComponent's
set.imports list dropped PlaylistDropZoneDirective and the drop
overlay component. Added MockPlaylistDropZoneDirective and
MockPlaylistDropOverlayComponent.
- After the recent option-D refactor that lifted the import overlay
into its own child component, the spec also needed
MockWorkspaceShellImportOverlayComponent so the parent template
could resolve <app-workspace-shell-import-overlay />.
- MockWorkspaceShellHeaderComponent gained hasNoPlaylists input and
MockWorkspaceShellContextSidebarComponent gained hasPlaylists input
to match the real components.
- The "renders type-aware xtream import progress copy" test asserted
on inline overlay markup that no longer lives in the parent
template. Replaced with a structural test that the overlay child
renders/unmounts based on facade.showXtreamImportOverlay().
New focused spec for WorkspaceShellImportOverlayComponent (6 tests)
covers the moved progress-copy assertion at the right level by mocking
the WorkspaceShellXtreamImportService directly: title, progress copy,
determinate vs indeterminate progress bar, badge tone, and cancel
forwarding.
Final state: full feature lib suite is 66/66 (was 53/60 with 7
chronic failures). pnpm nx test web, pnpm nx lint
workspace-shell-feature, pnpm nx build web --configuration=production,
and the basic + settings web-e2e suites all pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: c2cea9c530e6
Continues the workspace-shell facade decomposition by relocating two
clusters from the parent template into the components that own them.
Xtream import overlay:
- New WorkspaceShellImportOverlayComponent injects
WorkspaceShellXtreamImportService directly. The 70-line inline
overlay block in workspace-shell.component.html collapses to a
single <app-workspace-shell-import-overlay /> element, gated by
facade.showXtreamImportOverlay().
- Overlay-specific styles move from workspace-shell.component.scss
into the child component (~125 lines relocated).
- Facade drops 14 import re-export lines plus the cancelXtreamImport
delegation. The two facade tests that exercised label generation
now resolve the service via TestBed.inject and assert against it
directly.
Header bulk action:
- WorkspaceShellHeaderComponent gains headerBulkAction input and
headerBulkActionRequested output, plus a button that renders the
action when present (next to the existing headerShortcut button).
- The parent template wires facade.headerBulkAction() →
facade.runHeaderBulkAction(). The facade keeps the cross-cutting
computed and the multi-store dispatch method since both span
multiple stores.
Verification:
- workspace-shell.facade.spec.ts: 20/20 passing (two assertions
retargeted at WorkspaceShellXtreamImportService).
- Full feature lib: 53/60 passing — same 7 pre-existing failures.
- pnpm nx build web --configuration=production: success.
- pnpm nx lint workspace-shell-feature: clean.
- pnpm nx run web-e2e:e2e-ci--src/basic.e2e.ts: 3/3 across browsers.
- pnpm nx run web-e2e:e2e-ci--src/settings.e2e.ts: 12/12 across
browsers.
Facade: 917 → 897 lines. The bulk-action button now actually renders
in the header — closing a latent gap where the computed existed on
the facade but had no template binding.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: c2cea9c530e6
Production build (stricter than test/dev) flagged the helper signature
as requiring a mutable array while WorkspaceViewCommandService.commands
returns readonly. The body uses spread, so widening the parameter to
readonly is the correct fix and unblocks `nx build web` plus E2E.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: c2cea9c530e6
The WorkspaceShellFacade had grown to 1666 lines, well past the 300/400
LOC target in CLAUDE.md. The component template binds to 54 facade
members directly, so the split preserves the public API by reference
re-export and keeps the template untouched.
Phase 1 — extract pure helpers (no DI, no behavior change):
- helpers/workspace-shell-constants.ts: i18n keys, RAIL_TOOLTIP_KEYS,
XtreamImportPhaseTone, WorkspaceHeaderBulkAction
- helpers/workspace-shell-route-utils.ts: toQueryString,
getRouteQueryParam, syncSearchQueryParam, bumpRefreshQueryParam,
getProviderFromPlaylist
- helpers/workspace-shell-search-labels.ts: resolveSearchPlaceholderKey,
resolveSearchScopeLabel, resolveActiveCategoryLabel, getRailTooltipKey,
translateRailLinks (each takes TranslateFn + snapshots as params)
- helpers/workspace-shell-import-labels.ts: build* helpers for the 14
Xtream import overlay labels and formatLocalizedNumber
- helpers/workspace-shell-command-builders.ts: buildCommandPaletteItems
plus the three command-definition builders, navigation command
factory, resolveCommand, comparePaletteCommands, getCommandGroupOrder
Phase 2 — extract the two component-scoped sub-services with the
cleanest boundaries:
- WorkspaceShellXtreamImportService: read-only labels, cancel action,
isImportRunning. Reads only XtreamStore + TranslateService.
- WorkspaceShellCommandPaletteService: MatDialog lifecycle, recent
commands integration, eager WorkspacePlayerCommandsContributor
bootstrap. Receives a typed CommandBuilderContext via function
argument from the parent — no circular DI.
The parent facade re-exposes every member by reference assignment so
signal identity is preserved for change detection. workspace-shell.
component.html is unchanged. workspace-shell.component.ts gains only
two provider entries for the new sub-services.
Verification:
- workspace-shell.facade.spec.ts: 20/20 passing
- pnpm nx lint workspace-shell-feature: clean
- workspace-shell-feature suite: 53/60 passing — same 7 pre-existing
failures (header & shell component specs hit unrelated NG0301
on playlistDropZone export); no regressions introduced
- CDP smoke: command palette opens with Recently Used + Global
Actions groups, settings nav disables search bar correctly
Facade reduces from 1666 to 917 lines. Each new helper/service is
under 250 lines except command-builders.ts (462) which is mostly
declarative command definitions. The parent facade is still over the
400-line hard max because cross-cutting orchestration
(searchCapability, runHeaderBulkAction, rail link computeds) reads
from multiple clusters; further reduction would require moving logic
into child components, which is deferred per the plan.
Plan: ~/.claude/plans/workspace-shell-facade-ts-is-now-1668-crispy-planet.md
(also copied locally to .plans/2026-05-01-workspace-shell-facade-split.md;
the working-tree .gitignore change excludes .plans from tracking).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: c2cea9c530e6
Adds a "Recently used" section at the top of the command palette (capped at 5,
persisted via StorageMap) and five "Switch player to ..." commands for one-step
player switching from anywhere. MPV/VLC are gated to Electron; the active player
entry is shown disabled. Switching applies to the next playback session.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: c2cea9c530e6
- Added `XtreamCachedContentScope` type to manage content loading states.
- Updated live stream layout to show a loading skeleton when content is being fetched.
- Introduced styles for loading content in the live stream layout.
- Enhanced unit tests to cover loading states and caching behavior.
- Modified `XtreamWorkspaceRouteSession` to handle cached content loading and state management.
- Improved handling of optional connection values in playlist data.
- Updated workspace context panel to reflect import states and loading conditions.
Entire-Checkpoint: c2cea9c530e6
Two issues kept the scrollbar invisible on the global Favorites and
Recently viewed live-tv lists:
1. The wrong files. The visible Favorites/Recent pages render through
<app-global-favorites-list> from portal/shared/ui (and the
workspace-shell variant), not the channel-list-container's tabs.
Apply .app-scrollbar to both of those components.
2. Specificity. Component CSS gets an Angular [_ngcontent-xxx]
attribute selector, so `.nav-list { overflow-y: auto }` resolves to
(0,2,0) — same as the doubled `.app-scrollbar.app-scrollbar` and
wins on source order (component CSS loads after globals). Mark
overflow-y: scroll !important on the utility so the contract holds:
applying .app-scrollbar always reserves the gutter.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: 8ae011808369