* perf(web): take the Material snack-bar off the startup path The seven services and the app shell created with the app injected MatSnackBar, and snack-bar.mjs statically imports Material's button, the icon button chunk, ripple and the CDK a11y module, so about 110 KB sat in the initial bundle although a toast shows only after an event. A root LazySnackBar (@iptvnator/services/lazy-snack-bar) resolves the snack-bar from a dynamic import the way LazyMatDialog does, retrying after a failed chunk load, and opens text toasts with MatSnackBar.open's arguments; a failed load is logged without the message. The eager importers open their toasts through it with unchanged messages, actions, durations and panel classes; lazy code keeps injecting MatSnackBar. The startup status defers its Retry button, the other eager importer of the button chunk, with a native button as the error fallback. Local production build: renderer.initialBytes 1,349,048 -> 1,236,918 (-112,130, -8.3%); no Material module is in the initial bundle any more. The CDK overlay, scrolling and portal chunks (about 78 KB) stay through the FullscreenOverlayContainer provider in app.config.ts. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(web): keep a native Retry while the startup button chunk loads Greptile (local round 1): the failure screen had no Retry button until the deferred Material button chunk arrived. One native button, drawn in the flat button's shape with theme colours, is now the placeholder and the error view of the defer block, so a retry is possible while the chunk downloads and after a failed load. Baseline and docs follow the re-measured value, 1,237,440 bytes. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * docs(perf): point the initialBytes evidence at the opened pull request The baseline and the performance doc named the predicted number; the pull request opened as #1939. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test(web): cover the toast actions behind the lazy snack-bar Greptile (PR round 1): the Refresh and Trust-host actions now run after LazySnackBar.open resolves, and no test exercised them. The PWA update toast test holds the toast promise, then resolves it and picks Refresh; the page reload is a field the spec substitutes, as jsdom's location cannot be spied on. The Electron test does the same for the TLS trust toast: the confirmation opens only once the toast is up, approving it records the host and retries the fetch. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test(web): type the stubbed toast ref as Observable<undefined> CI's spec typecheck rejected `typeof EMPTY` (Observable<never>) for a stub that returns `of(undefined)`. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * perf(web): lower the initialBytes baseline to the merged measurement Merged with #1938 the branch measures 1,231,455 bytes on a local production build (the radio play control's fab tokens left the stylesheet); the baseline follows it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * perf(web): lower the initialBytes baseline to the zoneless merged measurement Merged with #1929, #1936 and #1938 the branch measures 1,178,109 bytes on a local production build; the baseline follows it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * perf(web): re-measure initialBytes after merging #1903 and #1942 Merged with #1903 (self-hosted server storage, which raised master's baseline by 7,245 bytes on purpose) and #1942 the branch measures 1,187,983 bytes on a local production build, below master's 1,285,523; no Material module is in the initial bundle. The new favorites effect spec provides the LazySnackBar stub the merged effect injects. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * perf(xtream): open favorites toasts through LazySnackBar #1934 gave the Xtream store's favorites feature a static MatSnackBar injection. The store exists from startup, so that one import put Material's snack-bar, button, icon-button and ripple chunks (about 95 KB) back on the renderer's initial path. The feature now opens its two toasts through LazySnackBar with the same messages and durations. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(perf): count statically imported chunks in renderer.initialBytes Angular's builder emits at most ten modulepreload hints. Once the startup graph passed ten chunks, the rest were fetched through main.js's own imports but never counted, so the counter stopped growing and an eager MatSnackBar import on master went unnoticed. The script now follows the static import graph of every counted script (dynamic import() targets stay lazy; a missing imported file is an error), which matches the bundler's metafile closure exactly: 1,196,205 bytes for this branch, where the old counter read 1,098,535. The baseline follows the new measurement and its note now explains the current limit (Greptile). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(perf): parse startup imports and count them per request URL Codex (PR round): the regexes also matched import-shaped text in strings, templates and comments, which could count a lazy chunk or fail on a file that does not exist. Static declarations are now read from TypeScript's syntax tree (top-level import/export-from statements only). Imports are also deduplicated by request URL, like the index.html references, so a preload of chunk.js?v=1 no longer hides an import of ./chunk.js. The real build still measures 1,196,205 bytes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <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, a blog-post scaffold for
the website, and Telegram/Reddit announcement drafts — 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
highlight: 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 |
highlight |
no | short headline (max 60 chars) marking a release highlight |
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"
highlight marks the change as one of the release's headline features and
gives it a short, poster-worthy name. The 60-character cap keeps it roughly to
one line on the hero card; card text wraps by estimated width, so a headline of
unusually wide glyphs may still wrap or ellipsize rather than overflow. Highlights lead the Telegram/Reddit
announcements (everything else collapses into a "+N more" counter) and open the
blog scaffold: a row in its "What changed" table and a ## section ahead of
the themed sections, while fixes without a highlight collapse under a spoiler
there. Set it on the two or three
changes worth announcing — a release where everything is a highlight has none.
Not allowed on type: internal.
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
pnpm --silent run release:notes:telegram
pnpm --silent run release:notes:reddit
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, --format github, --format telegram, and --format reddit
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 announcement formats print paste-ready posts to stdout, each guaranteed
to fit its platform's limit: Telegram plain text within 4096 characters,
Reddit markdown within 40,000, with a suggested post title on the first line.
Whatever does not fit collapses into a counter; a breaking change is never
collapsed, and if the highlights alone will not fit, the render fails with an
actionable error rather than shipping a post that cannot be submitted. Use pnpm --silent run for these two —
plain pnpm run prints its lifecycle banner to the same stdout, so a
redirected post starts with two lines of build noise. Render and save them
before
--consume — the changelog keeps the entries, but the highlight: metadata
lives only in the note files. Publishing is manual; nothing posts anywhere.
An internal-only release has nothing to announce: both formats then print an
explanation on stderr, leave stdout empty, and exit 0 — the same shape
extract-changelog-section.mjs --public uses for its empty public body.
The release sequence is: bump the version → release:notes:changelog →
release:notes:blog → release:screenshots → release:notes:telegram /
release:notes:reddit → release:cards:generate → --consume → commit → tag
→ push → release:verify:draft. Everything reading highlight: comes before
--consume, because that step deletes the only copy of it. Full contract:
docs/architecture/release-pipeline.md. 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).
Highlight cards
pnpm run release:cards:generate renders one branded 1200×630 card per
highlight: note (headline, body, and a framed screenshot strip when the note
names one) plus a release hero card — for Telegram/Reddit previews and the
blog hero.jpg. It reads screenshots from the published blog directory, so it
runs after release:screenshots and, like the announcement formats, before
--consume. Output goes to dist/release-highlight-cards/v<version>/, and a
rerun replaces the cards it previously wrote there; copying a card into the
website tree is a deliberate manual act. release:cards:dry-run lists what
would be rendered. A release without highlights still gets its hero card, and
an internal-only release writes nothing — neither is an error.
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