* feat(xtream): replace catalog pagination with infinite scroll Xtream movie/series/live catalogs now load continuously while scrolling instead of paging. The selection store keeps a growing visibleCount render window over the in-memory catalog (initial 50, +50 per load) plus a saved scroll state, so opening a title and going back restores the exact spot. A shared InfiniteScrollDirective (portal/shared/ui) fires loadMore near the bottom (edge-triggered, mirroring search-layout) and auto-fills viewports taller than the initial window by measuring container overflow — capped at 10 self-initiated loads per list identity, with a ResizeObserver re-check. The shared CategoryContentViewComponent branches on the transitional PortalCatalogFacade.supportsInfiniteScroll flag: Xtream scrolls, Stalker keeps its server-driven paginator and ?page= round-trip untouched until its append lands (PR 2), after which the paged facade members and the flag are deleted. grid-list loses its dead built-in paginator and gains tail states (append spinner, retry-on-error) plus content-visibility on cards. The in-portal search results reuse the search layout's nearEnd hook to window their full result set instead of rendering it unbounded. Validation: portal-xtream-data-access (234), portal-xtream-feature (357), portal-catalog-feature (22), portal-shared-ui (77, incl. new directive spec), portal-stalker-* (253) unit tests green; catalog-sorting e2e 5/5 (new scroll-growth + spot-restore test against the large 200-item mock scenario, Stalker paged spec unchanged); search e2e 16/16; lint green; release note added and validated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(xtream): auto-fill search results and refresh the near-end latch Review findings from #1392: the in-portal search window could stall at its first 60-item chunk when the rendered cards did not overflow the container — nearEnd only fired on real scroll events (Greptile P1), the search layout's edge latch survived a result-set replacement (Codex), and the shared directive's latch went stale after appended content moved the bottom out of the threshold (Codex). The search layout now drives its results container through the shared InfiniteScrollDirective instead of a bespoke scroll handler: the measured auto-fill reveals further chunks on tall viewports without any scroll, the reset key (search term) and item-count changes refresh the latch, and new nearEndHasMore/nearEndAppending inputs let consumers gate emissions. Xtream search wires them for both modes — this also fixes the same latent tall-viewport stall in the global search's 100-item pages — and the Stalker search page (single capped request until PR 2) sets hasMore=false. The directive's fill check now refreshes the latch from the measured state, so an End-key jump straight to the new bottom is a genuine crossing again. New coverage: directive stale-latch regression, search-layout auto-fill + hasMore gating, in-portal window reveal/reset in search-results. Reruns: portal-shared-ui 80, portal-xtream-feature 357, portal-stalker-feature green; search e2e 16/16 (fresh Playwright report verified); lint clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(xtream): re-measure search auto-fill on the rendered window, not the total Round-2 review finding on #1392 (Greptile P1 + Codex P2, same defect): the search layout bound the constant result-set total to the infinite-scroll directive's item count, so once the in-portal window grew 60 -> 120 no tracked input changed, no further overflow check was scheduled, and results beyond 120 stayed unreachable on tall viewports. The layout now takes an explicit nearEndRenderedCount (falling back to resultsCount for consumers that render everything they report) and feeds THAT to the directive. Xtream search passes the windowed slice length for in-portal mode and the loaded-set length for global mode. Regression specs: layout re-measures when the rendered window grows while the total stays constant; the component exposes the rendered count following the window. portal-shared-ui 81, portal-xtream-feature 357, lint clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(xtream): include filter state in the search reset identity Round-3 Codex P2 on #1392: the near-end latch and auto-fill budget were keyed on the search term alone, so a filter-only transition (type filters or the hidden-categories toggle) replaced the result set without resetting them — a jump straight back into the threshold could be swallowed. The search layout now accepts an explicit nearEndResetKey (defaulting to the term); Xtream search supplies term + type filters + excludeHidden. Regression specs: layout latch resets on an identity change without a new term; the component identity changes on filter-only and hidden-toggle transitions. portal-shared-ui 82, portal-xtream-feature 358, lint clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(xtream): refuse global-search appends while an edited query debounces Round-4 Codex P2 on #1392: after the reset-identity change, the layout's auto-fill can request more results inside the 300ms search debounce. The append then ran with the freshly edited term but the old result count as offset, interleaving a page of the new query into the old query's visible results until the offset-zero search landed. An append now only continues the LAST EXECUTED search: the append guard additionally requires the effective term to equal lastGlobalSearchTerm, so pagination stays suppressed from the first keystroke until the fresh search replaces the result set. Regression spec covers the mid-debounce refusal; the two existing append specs state their precondition explicitly. portal-xtream-feature 359, lint clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(xtream): per-selection scroll snapshots and progress-based auto-fill stop Round-5 Codex P2s on #1392: 1. The single saved-scroll slot lost the first tab's position on a VOD -> Series -> VOD round trip — the series view's destroy hook overwrote it with series coordinates. Snapshots are now kept per selection identity (bounded to the 8 most recent), so a detour's save can never destroy another list's spot. Store API is unchanged. 2. The fixed 10-load auto-fill budget could strand items on a viewport large enough that ten chunks still do not overflow — with no scrollbar, no real scroll event can ever fire. The auto-fill now terminates on lack of progress instead: loads continue while they grow scrollHeight (until genuine overflow hands off to scroll events) and stop after three consecutive loads without growth, which only a source that reports more but renders nothing can produce. Regression specs: VOD/Series round trip keeps both snapshots; growth keeps filling past the old cap and stops at overflow; no-growth stalls stop at three; reset key clears the stall guard. portal-shared-ui 83, portal-xtream-data-access 235, catalog-sorting e2e 5/5 re-run, lint clean. CLAUDE.md wording updated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Release notes (.changes/)
Every PR with a user-visible change drops one file here describing that change
in plain language. At release time
tools/release/build-release-notes.mjs turns the accumulated files into the
GitHub release body, the CHANGELOG.md section, and a blog-post scaffold for
the website — then deletes them.
The point is to write the note while the context is still fresh, instead of reconstructing three months of work from commit titles at release time.
File
Name it <area>-<short-slug>.md, e.g. .changes/playback-up-next-rail.md.
---
type: feature
area: playback
issues: [1187]
screenshot: up-next-rail
---
Series now show an "Up Next" rail beside the player on wide windows: the rest
of the current season, watch progress, and click-to-play inline.
| Field | Required | Value |
|---|---|---|
type |
yes | breaking, feature, fix, perf, or internal |
area |
yes | lowercase slug, same as the conventional-commit scope |
issues |
no | issue numbers this closes — [1187] or 1187 |
screenshot |
no | slug from tools/release/screenshots.manifest.json |
There is no version field. The release version is chosen deliberately at release time, not derived from these files.
You never write a PR number: the generator resolves it from the commit that added the file.
Writing the body
One to three sentences, present tense, written for a user, not a reviewer. The body is capped at 400 characters — depth belongs in the blog post.
-
❌ "Refactor
WebVideoControlsAdapterto hoist volume state into the session" -
✅ "The player now remembers volume between episodes"
-
❌ "Fix off-by-one in
resolveEnrichmentSeasonNumber" -
✅ "Series whose title carries a season marker no longer show the wrong season"
type: internal records invisible maintenance. Internal notes stay collapsed in
CHANGELOG.md, are omitted from the blog scaffold, and are removed from the
authored public GitHub body by
extract-changelog-section.mjs --public. GitHub's generated commit list remains
separate. An internal-only release can therefore have an empty authored body.
When a note is not needed
The gate auto-exempts website, E2E and mock-server apps, *.spec.{js,ts},
*.e2e.{js,ts}, snapshots, any /testing/ path, and Markdown. For other
test-only, documentation, CI/workflow, or pure-refactor changes under
apps//libs/, apply no-release-note when no user-visible note is warranted.
Commands
pnpm run release:notes:validate
pnpm run release:notes:github
pnpm run release:notes:changelog
pnpm run release:notes:blog
node tools/release/build-release-notes.mjs --consume
The release version comes from the root package.json — bump it first, then
generate. --version 0.24.0 overrides it to preview a release before the bump:
pnpm run release:notes:github --version 0.24.0
A bare -- separator is accepted and ignored, so the npm habit of
pnpm run release:notes:github -- --version 0.24.0 works too: pnpm forwards
that separator to the script rather than consuming it the way npm does.
--validate and --format github only read and print. --format changelog
and --format blog write their target file (rerunning changelog for the same
version replaces that section rather than duplicating it). Only --consume
deletes anything.
The release sequence is: bump the version → release:notes:changelog →
release:notes:blog → --consume → commit → tag → push. The tag build then
extracts the new CHANGELOG.md section into the GitHub release body
(tools/release/extract-changelog-section.mjs) and fails the release if
the section is missing — a tag cut without the changelog step cannot silently
ship PR-title-only notes.
The website publishes one post per minor version (v0-18 … v0-22), and
release screenshots live under the matching blog/v0-24/ directory. A patch
release therefore edits the existing post rather than generating a new one, so
--format blog refuses to overwrite unless you pass --force.
Screenshots
pnpm run release:screenshots captures every manifest shot in dark and light
against the built app plus the Xtream mock server — never a real account.
The run is fail-closed: it proves the real ~/.iptvnator/databases directory
(including the SQLite WAL sidecars, checked after Electron exits) was not
touched, launches the app with an allowlisted environment, records and blocks
all non-localhost traffic, scans every frame for external resources and
credential-shaped text, and asserts TMDB enrichment stays disabled. Frames are
staged outside the repository and published only once every shot and every
guard has passed.
Adding a shot for a new feature = one entry in
tools/release/screenshots.manifest.json (plus, if navigation is new, one
named action in tools/release/capture-navigation.ts).
pnpm nx run electron-backend:build-e2e # once, before capturing
pnpm run release:screenshots # all shots, both themes
pnpm run release:screenshots -- --only dashboard --theme dark
pnpm run release:screenshots -- --release v0-24