Compare commits

..
Author SHA1 Message Date
timothyandClaude Fable 5 439272b405 feat(web): SPA cutover — root route + legacy redirects (#91)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m11s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m31s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Make the ChicoryTV SPA the default UI: GET/HEAD requests to / and to
legacy Blazor routes with SPA equivalents (channels, schedules,
playouts, libraries, all settings pages, channel add) now 302 to their
/app counterparts, preserving query strings and the ETV_BASE_URL path
base. 302 not 301: the map will grow as parity lands and permanent-
redirect caching would make rollback painful.

Blazor-only functionality (collections, media browse/search, trakt,
filler presets, watermarks, ffmpeg profiles, blocks/decos/templates,
playout detail editors, logs, troubleshooting, channel edit) keeps
serving Blazor; the Blazor home stays reachable at /system/health.
Parity gaps are tracked in #140-#146; Blazor removal is phase (b).

Also adds an /app smoke assertion to the docker build workflow.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 11:07:01 +02:00
timothyandClaude Fable 5 b564545ff7 docs: advance ChicoryTV issue queue past #90 (PR #139, tagged v26.4.0); next prompt = #91 cutover
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 3m55s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 4m36s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 3m54s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 10:20:36 +02:00
5 changed files with 291 additions and 67 deletions
+2 -2
View File
@@ -274,8 +274,8 @@ jobs:
done
echo " FAIL $path (no 2xx containing '$needle' within timeout)"; return 1
}
if check "/iptv/channels.m3u" "#EXTM3U" && check "/iptv/xmltv.xml" "<tv"; then
echo "Smoke + IPTV E2E passed: channels.m3u + xmltv.xml serve a valid playlist + guide"
if check "/iptv/channels.m3u" "#EXTM3U" && check "/iptv/xmltv.xml" "<tv" && check "/app/" "ChicoryTV"; then
echo "Smoke + IPTV E2E passed: channels.m3u + xmltv.xml serve a valid playlist + guide; /app/ serves the ChicoryTV SPA"
else
echo "===== container logs (tail) ====="; docker logs "$NAME" 2>&1 | tail -n 40 || true
exit 1
+112
View File
@@ -0,0 +1,112 @@
using Microsoft.AspNetCore.Http;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests;
[TestFixture]
public class LegacyUiRedirectsTests
{
private static readonly string StartupSource = File.ReadAllText(FindStartupPath());
[Test]
public void Every_Mapping_Should_Resolve()
{
foreach ((string from, string to) in LegacyUiRedirects.Map)
{
LegacyUiRedirects.TryGetRedirect(new PathString(from), out string target).ShouldBeTrue();
target.ShouldBe(to);
}
}
[TestCase("/", "/app")]
[TestCase("/channels", "/app/channels")]
[TestCase("/channels/add", "/app/new-channel")]
[TestCase("/schedules", "/app/schedules")]
[TestCase("/playouts", "/app/playouts")]
[TestCase("/media/libraries", "/app/libraries")]
[TestCase("/settings/ffmpeg", "/app/settings/streaming")]
[TestCase("/settings/hdhr", "/app/settings/system")]
[TestCase("/settings/logging", "/app/settings/logging")]
[TestCase("/settings/playout", "/app/settings/playout")]
[TestCase("/settings/scanner", "/app/settings/scanner")]
[TestCase("/settings/ui", "/app/settings/general")]
[TestCase("/settings/xmltv", "/app/settings/xmltv")]
public void Known_Route_Should_Redirect(string path, string expected)
{
LegacyUiRedirects.TryGetRedirect(new PathString(path), out string target).ShouldBeTrue();
target.ShouldBe(expected);
}
[TestCase("/channels/", "/app/channels")]
[TestCase("/schedules/", "/app/schedules")]
[TestCase("/settings/ffmpeg/", "/app/settings/streaming")]
public void Trailing_Slash_Should_Match(string path, string expected)
{
LegacyUiRedirects.TryGetRedirect(new PathString(path), out string target).ShouldBeTrue();
target.ShouldBe(expected);
}
[Test]
public void Lookup_Should_Be_Case_Insensitive()
{
LegacyUiRedirects.TryGetRedirect(new PathString("/Channels"), out string target).ShouldBeTrue();
target.ShouldBe("/app/channels");
}
[TestCase("/channels/5")] // channel edit (Blazor-only)
[TestCase("/channels/numbers")] // Blazor-only
[TestCase("/system/health")] // Blazor home escape hatch
[TestCase("/media/collections")] // Blazor-only media page
[TestCase("/ffmpeg")] // Blazor-only
[TestCase("/watermarks")] // Blazor-only
[TestCase("/app")] // already the SPA
[TestCase("/app/channels")] // already the SPA
[TestCase("/iptv/channels.m3u")] // IPTV surface
[TestCase("/api/health")] // API surface
[TestCase("")] // empty
[TestCase("//")] // all-slash path must not collapse to root "/"
[TestCase("/channels//")] // double trailing slash is not normalized to a match
public void Non_Migrated_Route_Should_Not_Redirect(string path)
{
LegacyUiRedirects.TryGetRedirect(new PathString(path), out string target).ShouldBeFalse();
target.ShouldBe(string.Empty);
}
[Test]
public void Startup_Should_Redirect_Legacy_Routes_In_Blazor_Branch_Before_Routing()
{
// The redirect middleware must be wired inside the blazor branch and run
// before UseRouting so migrated routes never reach the Blazor fallback.
int redirectIndex = StartupSource.IndexOf("LegacyUiRedirects.TryGetRedirect", StringComparison.Ordinal);
redirectIndex.ShouldBeGreaterThan(-1);
// The Blazor branch's UseRouting call that follows the redirect middleware.
int routingIndex = StartupSource.IndexOf("blazor.UseRouting()", StringComparison.Ordinal);
routingIndex.ShouldBeGreaterThan(-1);
redirectIndex.ShouldBeLessThan(routingIndex);
// 302 (temporary), not a permanent redirect.
StartupSource.ShouldContain("context.Request.PathBase + target");
StartupSource.ShouldNotContain("RedirectPermanent(target");
}
private static string FindStartupPath()
{
DirectoryInfo? directory = new(TestContext.CurrentContext.TestDirectory);
while (directory is not null)
{
string candidate = Path.Combine(directory.FullName, "ErsatzTV", "Startup.cs");
if (File.Exists(candidate))
{
return candidate;
}
directory = directory.Parent;
}
throw new FileNotFoundException("Could not find ErsatzTV/Startup.cs");
}
}
+74
View File
@@ -0,0 +1,74 @@
using Microsoft.AspNetCore.Http;
namespace ErsatzTV;
// Phase (a) of the Blazor -> ChicoryTV SPA cutover (ersatztv#91).
//
// The React SPA (served under /app) is now the default UI: the legacy Blazor
// routes below 302-redirect to their SPA equivalents. Only routes that already
// have SPA parity are listed here. Blazor pages WITHOUT a SPA equivalent are
// deliberately left reachable (no redirect) so their functionality stays
// available while the SPA catches up:
// /system/health (Blazor home escape hatch; Index.razor also lives here),
// /channels/{id} edit, /channels/numbers, /media/* (collections etc.),
// /ffmpeg, /watermarks, /blocks, /decos, /templates, /deco-templates,
// schedule/playout detail editors, /system/logs, /system/troubleshooting.
//
// Phase (b) removes the redirected Blazor pages entirely, but that is GATED on
// full SPA parity for every route in this map. Until then this map is the
// single source of truth for "what has migrated" and is expected to grow.
public static class LegacyUiRedirects
{
// Blazor route -> SPA route. EXACT paths only (no prefix matching); a single
// trailing slash on the request is normalized away before lookup.
public static readonly IReadOnlyDictionary<string, string> Map =
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["/"] = "/app",
["/channels"] = "/app/channels",
["/channels/add"] = "/app/new-channel",
["/schedules"] = "/app/schedules",
["/playouts"] = "/app/playouts",
["/media/libraries"] = "/app/libraries",
["/settings/ffmpeg"] = "/app/settings/streaming",
["/settings/hdhr"] = "/app/settings/system",
["/settings/logging"] = "/app/settings/logging",
["/settings/playout"] = "/app/settings/playout",
["/settings/scanner"] = "/app/settings/scanner",
["/settings/ui"] = "/app/settings/general",
["/settings/xmltv"] = "/app/settings/xmltv"
};
public static bool TryGetRedirect(PathString path, out string target)
{
target = string.Empty;
string value = path.Value;
if (string.IsNullOrEmpty(value))
{
return false;
}
// Normalize a single trailing slash so "/channels/" matches "/channels"
// (but keep root "/" intact). Guard against a path of all slashes (e.g.
// "//") collapsing down to "/" and falsely matching the root entry.
if (value.Length > 1 && value.EndsWith('/'))
{
string trimmed = value[..^1];
if (trimmed == "/")
{
return false;
}
value = trimmed;
}
if (Map.TryGetValue(value, out string mapped))
{
target = mapped;
return true;
}
return false;
}
}
+22
View File
@@ -726,6 +726,28 @@ public class Startup
ctx => !IsIptvPath(ctx.Request.Path) && !IsSpaPath(ctx.Request.Path),
blazor =>
{
// ersatztv#91 phase (a): make the ChicoryTV SPA the default UI by
// redirecting migrated legacy Blazor routes to their /app equivalents.
// 302 (not 301): this map grows as pages migrate, and permanent-redirect
// browser caching would make rollback painful. UsePathBase (ETV_BASE_URL)
// only rewrites the request side (Request.Path/PathBase); it never touches
// redirect Location headers, so the PathBase prefix must be re-applied here.
blazor.Use(async (context, next) =>
{
if (HttpMethods.IsGet(context.Request.Method) ||
HttpMethods.IsHead(context.Request.Method))
{
if (LegacyUiRedirects.TryGetRedirect(context.Request.Path, out string target))
{
context.Response.Redirect(
context.Request.PathBase + target + context.Request.QueryString);
return;
}
}
await next(context);
});
blazor.UseRouting();
if (OidcHelper.IsEnabled)
+81 -65
View File
@@ -8,9 +8,10 @@ builds are limited to 23 concurrent, never wide fan-outs. Backend gaps all la
2026-07-04 (PRs #113#119); merge pass PR #120; live-data screens: #109 Dashboard (PR #123),
#84 Channels (PR #124), #86 Schedule editor (PR #125), #87 Playouts (PR #127), #88 Libraries
(PR #128), #85 Guide/EPG (PR #129); #62 prerequisites: #65 (PR #130), #64 (PR #133), #63
(PR #134) — epic #62 COMPLETE; #89 Channel Builder (PR #136); **#93 Settings (PR #138) —
(PR #134) — epic #62 COMPLETE; #89 Channel Builder (PR #136); #93 Settings (PR #138) —
first screen through the full design-first workflow; #92 design-sync round-trip verified and
documented (`docs/design-sync.md`)**.
documented (`docs/design-sync.md`); **#90 rebrand (PR #139) — SPA fully presents as
ChicoryTV; v26.4.0 tagged at this merge (first app-change release → prod)**.
**PROCESS (2026-07-06, binding — supersedes 07-05)**: Claude Code ONLY — Codex is retired
(usage exhausted). Fable is the orchestrator in the main session and is EXPENSIVE — use it
@@ -31,30 +32,27 @@ can't see (see Lessons: local-run recipe).
**RELEASE CHECKPOINT (standing, added 2026-07-06)**: prod cutover to the fork is DONE —
prod container `ersatztv` on bumblebee runs `192.168.1.95:3000/timothy/ersatztv:prod`
(= v26.3.1, app-identical to upstream 26.3.0); `ersatztv-test` tracks `:latest` (main).
Prod only advances on `v*` tags. At every milestone merge, FLAG THE USER: is this slice
worth tagging **v26.4.0** (reserved for the first app-change release)? Latest sensible tag
point is #91 (cutover); earlier if a stable API slice should reach prod sooner. Tagging
needs explicit user consent; NEVER `[skip ci]` a commit you'll tag. (Flagged again at the
#93 merge, 2026-07-07 — builder + settings + full API is a very plausible v26.4.0 slice;
#90 rebrand would make it present as ChicoryTV.)
Prod only advances on `v*` tags. **v26.4.0 TAGGED 2026-07-07** on 65b1a5e3 (the #90 merge,
user-consented) — first app-change release; prod image = full API + all SPA screens +
ChicoryTV branding. At future milestone merges, flag the user for the NEXT tag
(v26.4.1/v26.5.0 — #91 cutover is the obvious next tag point). Tagging needs explicit user
consent; NEVER `[skip ci]` a commit you'll tag.
**Session state (2026-07-07, post-#93)**: main = 17f49304 (PR #138 merged): **Settings** is
live at `/app/settings/<section>` — backend `GET/PUT /api/settings/{ffmpeg,playout,xmltv,
scanner,logging,ui,hdhr}` + `/api/settings/resolutions` CRUD (`SettingsController`, DTOs in
`ErsatzTV.Core/Api/Settings`), frontend `web/src/screens/SettingsScreen.tsx` +
`web/src/api/settings.ts` (tiered loader: settings groups gate the screen, reference data
via allSettled with per-resource failure notes). App.tsx `ScreenRoute` gained an opt-in
`allowSubPaths` flag (settings only); the screen parses its own URL suffix. Design-first
workflow proven end-to-end: prototype `design-system/templates/chicorytv-admin/Settings.jsx`
+ handoff bundle `design-system/design_handoff_settings/` synced both directions with the
DesignSync MCP (main session only) — workflow documented in `docs/design-sync.md`, #92
CLOSED. Also this session: **PR #137** — Scriban.Signed 6.5.2→7.2.5 (GHSA-5wr9-m6jw-xx44
sandbox escape; the fresh advisory made NuGetAudit fail EVERY CI restore, merged first to
unblock); media-sources fresh-DB 500 fixed (Dapper→EF, see Lessons). Review: 3 lenses →
11 findings (2 backend contract clusters + frontend silent-failure/partial-save cluster),
fixed by 2 parallel subagents, fork-verified SHIP. Baselines: ErsatzTV.Tests **495**,
Core.Tests **493** (+1 skip), **web tests 145** (was 112). All #93 worktrees removed;
main checkout still sits on docs/59-ui-redesign-brief — do NOT touch it.
**Session state (2026-07-07, post-#90)**: main = **65b1a5e3** (PR #139 merged, **tagged
v26.4.0**). #90 rebrand landed: inventory showed most SPA naming was ALREADY ChicoryTV from
earlier screen work; the actual gaps were (a) NO favicon at all → `web/public/favicon.svg`
(byte-copy of `design-system/assets/chicorytv-icon.svg`; Vite rebases the absolute
`/favicon.svg` href to `/app/favicon.svg` at build — verified in built output, served by
Startup.cs static files), (b) no description/theme-color meta (theme-color = `#12100E` =
`--ctv-bg`, NOT the icon's `#171A21`), (c) sidebar used the dark-square `chicorytv-icon.svg`
→ switched to the canonical transparent `chicory-mark.svg` per the design-system lockup
(`brand-logo.card.html` / admin template Shell.jsx), (d) Channel Builder default group
`'ErsatzTV'``'ChicoryTV'` (+2 test assertions). Backend/Blazor strings untouched. Remaining
intentional "Ersatz" in web/: vite.config outDir path + one backend-file code comment
(App.tsx). Review: single fable fork (ship; it live-verified the favicon path by building).
Baselines unchanged: ErsatzTV.Tests **495**, Core.Tests **493** (+1 skip), **web tests 145**.
Worktree .worktrees/issue-90-rebrand now sits on main (used for the doc commit) — remove it
next session. Main checkout still sits on docs/59-ui-redesign-brief — do NOT touch it.
**Lessons for all remaining prompts** (accumulated):
- NEW (#93) — Local live-E2E recipe: `npm run build` (outputs to gitignored
@@ -193,49 +191,63 @@ main checkout still sits on docs/59-ui-redesign-brief — do NOT touch it.
---
# PROMPT — #90: ChicoryTV rebrand (assets + naming)
# PROMPT — #91: Blazor → SPA cutover (retire old UI)
You are Fable, the ORCHESTRATOR in the main Claude Code session (Codex is retired — Claude
Code only). Fable is EXPENSIVE: delegate implementation to fitting subagents (this issue is
mostly mechanical — haiku/sonnet territory; fable only for the review fork). Read CLAUDE.md
and the PROCESS + Lessons sections of this file first.
Code only). Fable is EXPENSIVE: delegate to fitting subagents (recon → Explore/haiku;
mechanical deletions → sonnet; routing/Startup surgery → opus; fable for the hardest design
calls + review forks). Read CLAUDE.md and the PROCESS + Lessons sections of this file first.
HARD CONSTRAINTS:
- `git worktree add .worktrees/issue-90-rebrand -b feat/90-rebrand origin/main`; never touch
the main checkout (it sits on docs/59-ui-redesign-brief). `cd web && npm ci` first.
- Max 23 concurrent builds; ONE dotnet build at a time. Frontend-only expected — if backend
strings turn out to be in scope, they're NOT (deeper product rebrand is epic #59).
- NEVER set ETV_UPDATE_GOLDENS.
- Review before PR: this is a small mechanical issue — a single fable correctness/design fork
over the diff suffices (skip the 3-lens panel unless the diff grows); fixes via subagents.
Merge needs an in-conversation consent question.
- `git worktree add .worktrees/issue-91-cutover -b feat/91-cutover origin/main`; never touch
the main checkout (docs/59-ui-redesign-brief). Also REMOVE the stale merged worktree
.worktrees/issue-90-rebrand first (`git worktree remove`). `cd web && npm ci`.
- Max 23 concurrent builds; ONE dotnet build at a time. NEVER set ETV_UPDATE_GOLDENS.
- This issue is BIG (deleting the Blazor UI + MudBlazor + root-route change). STAGE IT:
recon may conclude it should split into 2 PRs — (a) root-route flip + legacy redirects,
(b) Blazor/MudBlazor removal. Prefer the split; land (a) first — it's small, reversible,
and immediately user-visible.
- Full review before each PR (3 lenses: fable correctness fork + contract/tests lens +
design/UX lens for the routing UX) — this is NOT a mechanical issue. Live-E2E per the
#93 recipe (build SPA → symlink wwwroot/app into bin → run fresh server) and manually hit
`/`, `/app/...`, a legacy Blazor route, `/iptv/*`, `/api/*`, swagger. Merge consent
in-conversation per PR.
## Task
Issue #90: apply the ChicoryTV rebrand across the SPA surface. Naming (ErsatzTV → ChicoryTV
in user-facing UI copy), favicon/app icon/wordmark from `design-system/assets/`
(chicory-mark.svg, chicorytv-icon.svg, chicorytv-wordmark.svg), page title/meta.
Acceptance: the SPA presents consistently as ChicoryTV. (Product-wide rebrand = epic #59.)
Issue #91: complete the Blazor→SPA transition. Make the SPA the default UI (root route);
redirect legacy Blazor page routes; remove Blazor pages/components + MudBlazor deps once
nothing user-facing depends on them; update CLAUDE.md/docs (infra → server-management).
Acceptance: no user-facing MudBlazor screens remain; ChicoryTV SPA is THE UI.
## Approach notes
1. Inventory first (delegate to Explore): every user-facing "ErsatzTV" in web/ (index.html
title/meta/favicon, shell header, empty states, aria-labels, App.test.tsx copy
assertions), what web/index.html currently ships as favicon, how the shell renders the
brand mark today (the admin template uses chicory-mark.svg + "Chicory<accent>TV</accent>"
— mirror that), and whether vite needs assets in web/public/ vs imported.
2. Do NOT rename API strings, C# namespaces, Docker images, or docs — SPA surface only.
The legacy Blazor UI stays ErsatzTV.
3. Favicon: derive from chicorytv-icon.svg (SVG favicon is fine for modern browsers; add a
PNG fallback only if trivial).
4. Tests: update copy assertions; baseline 145 web tests must stay green (some assert brand
strings). dotnet suites should be untouched (frontend-only): ErsatzTV.Tests 495,
Core.Tests 493 (+1 skip).
5. PR → main: "feat(web): ChicoryTV rebrand (#90)", `closes #90`; poll CI by head SHA; ask
"merge?"; verify main post-merge run.
6. RELEASE CHECKPOINT: after #90 merges, the SPA is feature-complete-enough AND branded —
this is the strongest v26.4.0 tag point before #91. Ask the user explicitly whether to
tag v26.4.0 now (never `[skip ci]` the tagged commit).
7. Update THIS handoff: pop #90, next = #91 cutover; record PR + main SHA + baselines.
Commit to main. Print the next prompt in a fenced block.
1. RECON [Explore]: how Startup.cs/Program.cs wire Blazor (MapBlazorHub, fallback page,
`/app` static files + SPA fallback); every Blazor @page route and whether an SPA
equivalent exists (screens shipped: dashboard/channels/schedule/playouts/libraries/EPG/
builder/settings); what NON-page infrastructure lives in ErsatzTV/ that must SURVIVE
(controllers, SignalR?, background services, OIDC/auth wiring); MudBlazor/BlazorSortable
package references; what `/iptv`, `/api`, swagger, and the v1.json generator depend on.
Decide: is anything still Blazor-ONLY (no SPA equivalent)? If yes → those routes keep a
legacy escape hatch (e.g. `/legacy/...`) or the gap gets filed as a blocking sub-issue.
2. Phase (a): root `/` serves/redirects to the SPA; legacy Blazor routes 301 to SPA
equivalents (map table from recon); keep Blazor reachable under a legacy prefix ONLY if
recon found Blazor-only functionality. Mind deep links + the SPA's own client routing.
3. Phase (b): delete Pages/Shared/component dirs, drop MudBlazor+BlazorSortable from
Directory.Packages.props + csproj, prune Startup DI, _Imports, CSS/static assets.
Expect dotnet test fallout in ErsatzTV.Tests (495 baseline) — anything referencing
Blazor bits. Goldens must NOT change.
4. Docs: CLAUDE.md says "Blazor Server UI (MudBlazor)" + "Keep Blazor pages thin" — update
architecture/conventions inline with the PR; docs/contributing.md Blazor sections too.
~/homelab-docs/Docker/ErsatzTV.md mentions the UI → update after merge.
5. PRs → main: "feat(web): SPA cutover — root route (#91)" then "feat!: remove Blazor UI
(#91)" (closes #91 on the second). Poll CI by head SHA; consent per merge; verify main
post-merge runs.
6. RELEASE CHECKPOINT: v26.4.0 shipped at #90 (prod now runs the branded SPA at /app, root
still Blazor). After #91 lands, flag the user: tag v26.5.0 (next release-seq — the
breaking UI swap deserves its own release, not a patch).
7. Update THIS handoff: pop #91; next = post-cutover housekeeping (queue item 2). Record
PRs + main SHA + new baselines (ErsatzTV.Tests count will DROP with Blazor tests gone —
record the new number as baseline). Commit to main. Print the next prompt in a fenced
block.
---
@@ -245,9 +257,13 @@ Acceptance: the SPA presents consistently as ChicoryTV. (Product-wide rebrand =
Renovate/dep PRs (#21, #48, #49, #61, #131 security, #132) — cheap batch-merge pass when
convenient (note: Scriban already bumped to 7.2.5 by PR #137). MCP PR #76 (#58) still
needs its rebase/refresh pass — good parallel track.
1. #90 rebrand ← PROMPT above (small, mechanical; strongest v26.4.0 tag point at its merge).
2. #91 cutover (+ tag v26.4.0 at the latest here — see RELEASE CHECKPOINT).
1. #91 cutover ← PROMPT above (BIG; stage into root-flip PR + Blazor-removal PR; flag
v26.5.0 after it lands).
2. Post-cutover housekeeping: verify prod/test containers on the tagged image (v26.4.0 built
by run on 65b1a5e3 → `:prod`; watchtower or manual pull on bumblebee — infra side =
server-management); dep-PR batch pass; MCP PR #76 refresh; then triage epic #59 remainder
(backend rebrand scope, #66/#67 image pipeline, #68).
Cross-refs: #66/#67 remain open image-pipeline nice-to-haves; #68 unblocked-independent.
Done recently: PR #136 (#89 Channel Builder), **PR #138 (#93 Settings — closed 2026-07-07,
main 17f49304; web tests 112 → 145, ErsatzTV.Tests 447 → 495), PR #137 (Scriban GHSA
CI-unblock), #92 design-sync round-trip closed (docs/design-sync.md)**.
Done recently: **PR #139 (#90 rebrand — closed 2026-07-07, main 65b1a5e3, TAGGED v26.4.0)**,
PR #138 (#93 Settings, first design-first screen), PR #137 (Scriban GHSA CI-unblock),
#92 design-sync closed (docs/design-sync.md), PR #136 (#89 Channel Builder).