* 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>
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