# On Now / Next Overlay (#74) Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Add a per-channel toggle that burns a transient "On Now / Next" EPG text bug onto the transcoded stream at each program transition, reusing the existing SkiaSharp graphics engine.

**Architecture:** The graphics engine already renders dynamic EPG text per playout item. We add (1) a `ChannelGraphicsElement` join so a channel can carry graphics elements (the missing channel-level hook), (2) a seeded built-in `on-now-next.yml` text element, (3) a render-path change so the selector emits channel-level elements, (4) a channel REST field + a `builtIn` discriminator on graphics elements, and (5) a SPA Branding-tab toggle mirroring the existing logo-bug switch.

**Tech Stack:** C# / .NET 10, EF Core (dual-provider Sqlite + MySql), MediatR CQRS, LanguageExt, SkiaSharp + RichTextKit graphics engine, Scriban + NCalc templating, React + TypeScript SPA, NUnit + Shouldly + NSubstitute, generated OpenAPI (`v1.json` / `v1.d.ts`).

## Global Constraints

- Work only in the worktree `.claude/worktrees/issue-74-on-now-next-overlay` (branch `feat/74-on-now-next-overlay`, off `origin/main`). Never commit in `/Users/timothy/ersatztv`.
- Any `TvContext` model change requires a dual-provider migration via `scripts/add-migration.sh <Name>` (generates BOTH Sqlite and MySql). Never hand-edit migrations. Never set `ETV_UPDATE_GOLDENS`.
- Tests: NUnit + Shouldly + NSubstitute only (never xUnit). C# test DB harness is `InMemoryTvContext` (real SQLite `:memory:`, FKs OFF), constructed `_db = await InMemoryTvContext.CreateAsync();` then `await using TvContext context = _db.CreateContext();`.
- Any `/api/*` change: build the app project FIRST, then `./scripts/update-openapi.sh`, then `cd web && npm run generate:api`. Commit regenerated `ErsatzTV/wwwroot/api/v1.json`, `web/src/api/generated/v1.d.ts`, and `docs/endpoint-index.md` in the same task.
- Before any push touching `.cs`: BOM-check the touched set (`git diff --cached --name-only -- '*.cs' | while read f; do xxd -p -l3 "$f" | grep -q '^efbbbf' && echo "BOM: $f"; done`) and run the format gate under `bash -c`: `bash -c 'dotnet format whitespace ErsatzTV.sln --folder --include $(git diff --name-only origin/main -- "*.cs" | tr "\n" " ")'`.
- Central Package Management: never add `Version=` to a `<PackageReference>`.
- Text-element inline style markup uses SQUARE brackets with a backreferenced close: `[styleName]text[/styleName]`. `base_style` MUST name a style present in `styles:` or the renderer throws.
- EPG template variable is `Epg` (a list); members are PascalCase: `Epg[0].Title`, `Epg[0].SubTitle`, `Epg[1].Title`, `Epg[1].Start` (`DateTimeOffset`). Now = `Epg[0]`, Next = `Epg[1]`.
- `opacity_expression` is NCalc; time params (seconds, doubles): `content_seconds`, `content_total_seconds`, `channel_seconds`, `time_of_day_seconds`. Helper functions: `LinearFadeDuration(time, start, fadeSeconds, peakSeconds)` and `LinearFadePoints(time, start, peakStart, peakEnd, end)`. When `opacity_expression` is set, `opacity_percent` is ignored.
- Built-in element identity is by filename constant `on-now-next.yml` (see Task 3 `GraphicsElementDefaults`), never by user-editable Name (the #67 lesson).

---

### Task 1: `ChannelGraphicsElement` join entity + EF config + dual-provider migration

**Files:**
- Create: `ErsatzTV.Core/Domain/ChannelGraphicsElement.cs`
- Modify: `ErsatzTV.Core/Domain/Channel.cs` (add navs)
- Modify: `ErsatzTV.Core/Domain/GraphicsElement.cs` (add navs)
- Modify: `ErsatzTV.Infrastructure/Data/Configurations/ChannelConfiguration.cs` (add M2M)
- Create (generated): `ErsatzTV.Infrastructure.Sqlite/Migrations/*_Add_ChannelGraphicsElement.cs` + `ErsatzTV.Infrastructure.MySql/Migrations/*_Add_ChannelGraphicsElement.cs`
- Test: `ErsatzTV.Tests/Infrastructure/ChannelGraphicsElementPersistenceTests.cs`

**Interfaces:**
- Produces: `ChannelGraphicsElement { int ChannelId; Channel Channel; int GraphicsElementId; GraphicsElement GraphicsElement }`; `Channel.ChannelGraphicsElements : List<ChannelGraphicsElement>`; `Channel.GraphicsElements : List<GraphicsElement>`; `GraphicsElement.ChannelGraphicsElements : List<ChannelGraphicsElement>`; `GraphicsElement.Channels : List<Channel>`.

- [ ] **Step 1: Write the failing persistence test**

Create `ErsatzTV.Tests/Infrastructure/ChannelGraphicsElementPersistenceTests.cs`:

```csharp
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Tests.Support;
using Microsoft.EntityFrameworkCore;
using NUnit.Framework;
using Shouldly;

namespace ErsatzTV.Tests.Infrastructure;

[TestFixture]
public class ChannelGraphicsElementPersistenceTests
{
    private InMemoryTvContext _db = null!;

    [SetUp]
    public async Task SetUp() => _db = await InMemoryTvContext.CreateAsync();

    [TearDown]
    public async Task TearDown() => await _db.DisposeAsync();

    [Test]
    public async Task Channel_Can_Attach_And_Read_Back_A_Graphics_Element()
    {
        int channelId;
        int elementId;

        await using (TvContext context = _db.CreateContext())
        {
            var profile = new FFmpegProfile { Name = "p" };
            await context.FFmpegProfiles.AddAsync(profile);
            var element = new GraphicsElement { Path = "/x/on-now-next.yml", Name = "On Now / Next", Kind = GraphicsElementKind.Text };
            await context.GraphicsElements.AddAsync(element);
            await context.SaveChangesAsync();

            var channel = new Channel(Guid.NewGuid())
            {
                Number = "1", Name = "c", Group = "g", FFmpegProfileId = profile.Id,
                ChannelGraphicsElements = [new ChannelGraphicsElement { GraphicsElementId = element.Id }]
            };
            await context.Channels.AddAsync(channel);
            await context.SaveChangesAsync();
            channelId = channel.Id;
            elementId = element.Id;
        }

        await using (TvContext context = _db.CreateContext())
        {
            Channel loaded = await context.Channels
                .Include(c => c.ChannelGraphicsElements)
                .ThenInclude(x => x.GraphicsElement)
                .SingleAsync(c => c.Id == channelId);

            loaded.ChannelGraphicsElements.Count.ShouldBe(1);
            loaded.ChannelGraphicsElements[0].GraphicsElementId.ShouldBe(elementId);
            loaded.ChannelGraphicsElements[0].GraphicsElement.Name.ShouldBe("On Now / Next");
        }
    }
}
```

- [ ] **Step 2: Run it to verify it fails to compile**

Run: `dotnet build ErsatzTV.Tests`
Expected: FAIL — `ChannelGraphicsElement` / `Channel.ChannelGraphicsElements` do not exist.

- [ ] **Step 3: Create the join entity**

`ErsatzTV.Core/Domain/ChannelGraphicsElement.cs`:

```csharp
namespace ErsatzTV.Core.Domain;

public class ChannelGraphicsElement
{
    public int ChannelId { get; set; }
    public Channel Channel { get; set; }
    public int GraphicsElementId { get; set; }
    public GraphicsElement GraphicsElement { get; set; }
}
```

- [ ] **Step 4: Add navs to `Channel` and `GraphicsElement`**

In `ErsatzTV.Core/Domain/Channel.cs`, alongside `public List<Artwork> Artwork { get; set; }`:

```csharp
    public List<GraphicsElement> GraphicsElements { get; set; }
    public List<ChannelGraphicsElement> ChannelGraphicsElements { get; set; }
```

In `ErsatzTV.Core/Domain/GraphicsElement.cs`, alongside the `Decos` / `DecoGraphicsElements` navs:

```csharp
    public List<Channel> Channels { get; set; }
    public List<ChannelGraphicsElement> ChannelGraphicsElements { get; set; }
```

- [ ] **Step 5: Declare the M2M in `ChannelConfiguration`**

In `ErsatzTV.Infrastructure/Data/Configurations/ChannelConfiguration.cs`, inside `Configure`, after the `MirrorSourceChannel` block, add (mirrors `DecoConfiguration`'s `.UsingEntity<DecoGraphicsElement>`):

```csharp
        builder.HasMany(c => c.GraphicsElements)
            .WithMany(m => m.Channels)
            .UsingEntity<ChannelGraphicsElement>(
                j => j.HasOne(ci => ci.GraphicsElement)
                    .WithMany(mi => mi.ChannelGraphicsElements)
                    .HasForeignKey(ci => ci.GraphicsElementId)
                    .OnDelete(DeleteBehavior.Cascade),
                j => j.HasOne(ci => ci.Channel)
                    .WithMany(c => c.ChannelGraphicsElements)
                    .HasForeignKey(ci => ci.ChannelId)
                    .OnDelete(DeleteBehavior.Cascade),
                j => j.HasKey(ci => new { ci.ChannelId, ci.GraphicsElementId }));
```

Note: `ChannelConfiguration.cs` starts with a UTF-8 BOM already; preserve it (do not strip), and BOM-check per Global Constraints before pushing.

- [ ] **Step 6: Generate the dual-provider migration**

Run: `scripts/add-migration.sh Add_ChannelGraphicsElement`
Expected: two new migration files (Sqlite + MySql) creating a `ChannelGraphicsElement` table with composite PK `(ChannelId, GraphicsElementId)` and two cascade FKs. Inspect both to confirm the table + FKs; no other model drift.

- [ ] **Step 7: Run the test to verify it passes**

Run: `dotnet test ErsatzTV.Tests --filter FullyQualifiedName~ChannelGraphicsElementPersistenceTests`
Expected: PASS.

- [ ] **Step 8: Commit**

```bash
git add ErsatzTV.Core/Domain/ChannelGraphicsElement.cs ErsatzTV.Core/Domain/Channel.cs ErsatzTV.Core/Domain/GraphicsElement.cs \
        ErsatzTV.Infrastructure/Data/Configurations/ChannelConfiguration.cs \
        ErsatzTV.Infrastructure.Sqlite/Migrations ErsatzTV.Infrastructure.MySql/Migrations \
        ErsatzTV.Tests/Infrastructure/ChannelGraphicsElementPersistenceTests.cs
git -c core.hooksPath=/dev/null commit -m "feat(74): add ChannelGraphicsElement join + dual-provider migration"
```

---

### Task 2: Render-path — selector emits channel-level graphics elements

**Files:**
- Modify: `ErsatzTV.Core/FFmpeg/GraphicsElementSelector.cs` (append channel elements at the fall-through)
- Modify: `ErsatzTV.Application/Streaming/Queries/FFmpegProcessHandler.cs` (eager-load the join on the streaming channel)
- Test: `ErsatzTV.Tests/Core/FFmpeg/GraphicsElementSelectorTests.cs`

**Interfaces:**
- Consumes: `Channel.ChannelGraphicsElements` (Task 1); `IGraphicsElementSelector.SelectGraphicsElements(Channel, PlayoutItem, DateTimeOffset)`.
- Produces: channel-level `PlayoutItemGraphicsElement`s appended as a base layer (below deco/playout precedence).

- [ ] **Step 1: Write the failing selector tests**

Create `ErsatzTV.Tests/Core/FFmpeg/GraphicsElementSelectorTests.cs`. `DecoEntries` is `record(Option<Deco> TemplateDeco, Option<Deco> PlayoutDeco)`; the selector iterates each Option. Use `NSubstitute` for `IDecoSelector` and `NullLogger`.

```csharp
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Domain.Scheduling;
using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Interfaces.FFmpeg;
using LanguageExt;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using NUnit.Framework;
using Shouldly;

namespace ErsatzTV.Tests.Core.FFmpeg;

[TestFixture]
public class GraphicsElementSelectorTests
{
    private static (GraphicsElementSelector sel, IDecoSelector deco) Build(DecoEntries entries)
    {
        var deco = Substitute.For<IDecoSelector>();
        deco.GetDecoEntries(Arg.Any<Playout>(), Arg.Any<DateTimeOffset>()).Returns(entries);
        return (new GraphicsElementSelector(deco, NullLogger<GraphicsElementSelector>.Instance), deco);
    }

    private static Channel ChannelWithElement(int elementId, StreamingMode mode = StreamingMode.HttpLiveStreaming)
    {
        var element = new GraphicsElement { Id = elementId, Path = "/x/on-now-next.yml", Kind = GraphicsElementKind.Text };
        return new Channel(Guid.NewGuid())
        {
            StreamingMode = mode,
            ChannelGraphicsElements = [new ChannelGraphicsElement { GraphicsElementId = elementId, GraphicsElement = element }]
        };
    }

    private static PlayoutItem Item() => new()
    {
        Playout = new Playout(),
        FillerKind = FillerKind.None,
        PlayoutItemGraphicsElements = []
    };

    [Test]
    public void Channel_Element_Is_Emitted_When_No_Deco()
    {
        (GraphicsElementSelector sel, _) = Build(new DecoEntries(Option<Deco>.None, Option<Deco>.None));

        List<PlayoutItemGraphicsElement> result = sel.SelectGraphicsElements(ChannelWithElement(7), Item(), DateTimeOffset.Now);

        result.Count.ShouldBe(1);
        result[0].GraphicsElement.Id.ShouldBe(7);
    }

    [Test]
    public void Channel_Element_Is_Suppressed_On_HlsDirect()
    {
        (GraphicsElementSelector sel, _) = Build(new DecoEntries(Option<Deco>.None, Option<Deco>.None));

        List<PlayoutItemGraphicsElement> result = sel.SelectGraphicsElements(
            ChannelWithElement(7, StreamingMode.HttpLiveStreamingDirect), Item(), DateTimeOffset.Now);

        result.ShouldBeEmpty();
    }

    [Test]
    public void Channel_Element_Is_Suppressed_By_Disable_Deco()
    {
        var deco = new Deco { GraphicsElementsMode = DecoMode.Disable, DecoGraphicsElements = [] };
        (GraphicsElementSelector sel, _) = Build(new DecoEntries(deco, Option<Deco>.None));

        List<PlayoutItemGraphicsElement> result = sel.SelectGraphicsElements(ChannelWithElement(7), Item(), DateTimeOffset.Now);

        result.ShouldBeEmpty();
    }
}
```

- [ ] **Step 2: Run to verify it fails**

Run: `dotnet test ErsatzTV.Tests --filter FullyQualifiedName~GraphicsElementSelectorTests`
Expected: FAIL — `Channel_Element_Is_Emitted_When_No_Deco` returns 0 (selector never reads `channel`).

- [ ] **Step 3: Append channel elements in the selector**

In `ErsatzTV.Core/FFmpeg/GraphicsElementSelector.cs`, replace the final two lines:

```csharp
        result.AddRange(playoutItem.PlayoutItemGraphicsElements);

        return result;
```

with:

```csharp
        result.AddRange(playoutItem.PlayoutItemGraphicsElements);

        // channel-level overlays are a base layer: merged with playout-item / Merge-deco elements,
        // but suppressed by a deco in Override/Disable mode (which returns before reaching here).
        if (channel.ChannelGraphicsElements is not null)
        {
            result.AddRange(
                channel.ChannelGraphicsElements.Map(cge =>
                    new PlayoutItemGraphicsElement { PlayoutItem = playoutItem, GraphicsElement = cge.GraphicsElement }));
        }

        return result;
```

- [ ] **Step 4: Run to verify it passes**

Run: `dotnet test ErsatzTV.Tests --filter FullyQualifiedName~GraphicsElementSelectorTests`
Expected: PASS (all three).

- [ ] **Step 5: Eager-load the join on the streaming channel path**

In `ErsatzTV.Application/Streaming/Queries/FFmpegProcessHandler.cs`, in `ChannelMustExist`, add after `.Include(c => c.Watermark)`:

```csharp
            .Include(c => c.ChannelGraphicsElements)
            .ThenInclude(x => x.GraphicsElement)
```

(No other Channel-load site needs it: `GetHlsPlaylistByChannelNumberHandler` doesn't build the graphics graph, and troubleshooting playback re-enters through `FFmpegProcessHandler`.)

- [ ] **Step 6: Build to confirm the include compiles**

Run: `dotnet build ErsatzTV.Application`
Expected: PASS.

- [ ] **Step 7: Commit**

```bash
git add ErsatzTV.Core/FFmpeg/GraphicsElementSelector.cs \
        ErsatzTV.Application/Streaming/Queries/FFmpegProcessHandler.cs \
        ErsatzTV.Tests/Core/FFmpeg/GraphicsElementSelectorTests.cs
git -c core.hooksPath=/dev/null commit -m "feat(74): selector emits channel-level graphics elements as a base layer"
```

---

### Task 3: Seed the built-in `on-now-next.yml` template (file + marker)

**Files:**
- Create: `ErsatzTV.Core/Graphics/GraphicsElementDefaults.cs` (shared filename const)
- Modify: `ErsatzTV.Core/Domain/ConfigElementKey.cs` (new marker key)
- Create: `ErsatzTV.Infrastructure/Streaming/Graphics/GraphicsElementSeeder.cs` (the YAML const + seed logic, `IFileSystem`-based)
- Modify: `ErsatzTV/Services/RunOnce/DatabaseMigratorService.cs` (invoke the seeder before `DatabaseIsReady`)
- Test: `ErsatzTV.Tests/Infrastructure/GraphicsElementSeederTests.cs`

**Interfaces:**
- Produces: `GraphicsElementDefaults.OnNowNextFileName = "on-now-next.yml"`; `ConfigElementKey.GraphicsOnNowNextSeeded` (`"graphics.on_now_next_seeded"`); `GraphicsElementSeeder.SeedOnNowNext(TvContext, IFileSystem, CancellationToken)`.
- Row creation is intentionally NOT done here — the existing `RefreshGraphicsElementsHandler` (already tested, single owner of `GraphicsElement` rows) creates the row from the on-disk file at startup. The seeder only materializes the file + marker (marker-gated, adopt-not-clobber, no resurrection after delete), following #67.

- [ ] **Step 1: Write the failing seeder tests**

Create `ErsatzTV.Tests/Infrastructure/GraphicsElementSeederTests.cs`. Uses the repo's `Testably.Abstractions.Testing.MockFileSystem` (implements `System.IO.Abstractions.IFileSystem` — standard `fs.File.*` / `fs.Directory.*` surface; NOT the `AddFile`/`MockFileData`/`FileExists` API of `System.IO.Abstractions.TestingHelpers`) and `InMemoryTvContext`. The seeded path is `FileSystemLayout.GraphicsElementsTextTemplatesFolder + "/on-now-next.yml"`.

```csharp
using System.IO.Abstractions;
using Testably.Abstractions.Testing;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Streaming.Graphics;
using ErsatzTV.Tests.Support;
using Microsoft.EntityFrameworkCore;
using NUnit.Framework;
using Shouldly;

namespace ErsatzTV.Tests.Infrastructure;

[TestFixture]
public class GraphicsElementSeederTests
{
    private InMemoryTvContext _db = null!;
    private string _seededPath = null!;

    [SetUp]
    public async Task SetUp()
    {
        _db = await InMemoryTvContext.CreateAsync();
        _seededPath = Path.Combine(FileSystemLayout.GraphicsElementsTextTemplatesFolder, "on-now-next.yml");
    }

    [TearDown]
    public async Task TearDown() => await _db.DisposeAsync();

    [Test]
    public async Task Seeds_File_And_Marker_When_Absent()
    {
        var fs = new MockFileSystem();
        await using TvContext context = _db.CreateContext();

        await GraphicsElementSeeder.SeedOnNowNext(context, fs, CancellationToken.None);

        fs.File.Exists(_seededPath).ShouldBeTrue();
        fs.File.ReadAllText(_seededPath).ShouldContain("epg_entries: 2");
        (await context.ConfigElements.AnyAsync(c => c.Key == ConfigElementKey.GraphicsOnNowNextSeeded.Key)).ShouldBeTrue();
    }

    [Test]
    public async Task Adopts_Existing_File_Untouched()
    {
        var fs = new MockFileSystem();
        fs.Directory.CreateDirectory(FileSystemLayout.GraphicsElementsTextTemplatesFolder);
        await fs.File.WriteAllTextAsync(_seededPath, "name: Operator Custom\nepg_entries: 2\n");
        await using TvContext context = _db.CreateContext();

        await GraphicsElementSeeder.SeedOnNowNext(context, fs, CancellationToken.None);

        fs.File.ReadAllText(_seededPath).ShouldContain("Operator Custom");
    }

    [Test]
    public async Task Does_Not_Resurrect_After_Delete()
    {
        var fs = new MockFileSystem();
        await using TvContext context = _db.CreateContext();

        await GraphicsElementSeeder.SeedOnNowNext(context, fs, CancellationToken.None);
        fs.File.Delete(_seededPath);
        await GraphicsElementSeeder.SeedOnNowNext(context, fs, CancellationToken.None);

        fs.File.Exists(_seededPath).ShouldBeFalse();
    }
}
```

- [ ] **Step 2: Run to verify it fails to compile**

Run: `dotnet build ErsatzTV.Tests`
Expected: FAIL — `GraphicsElementSeeder` / `ConfigElementKey.GraphicsOnNowNextSeeded` do not exist.

- [ ] **Step 3: Add the shared filename const**

`ErsatzTV.Core/Graphics/GraphicsElementDefaults.cs`:

```csharp
namespace ErsatzTV.Core.Graphics;

public static class GraphicsElementDefaults
{
    // Built-in "On Now / Next" text element; identity is by filename, never by user-editable Name.
    public const string OnNowNextFileName = "on-now-next.yml";
}
```

- [ ] **Step 4: Add the ConfigElement marker key**

In `ErsatzTV.Core/Domain/ConfigElementKey.cs`, next to `WatermarkChannelBugSeeded`:

```csharp
    public static ConfigElementKey GraphicsOnNowNextSeeded => new("graphics.on_now_next_seeded");
```

- [ ] **Step 5: Create the seeder (YAML const + logic)**

`ErsatzTV.Infrastructure/Streaming/Graphics/GraphicsElementSeeder.cs`. The YAML is authored from the field grammar (Global Constraints): square-bracket style spans, `Epg[0]`/`Epg[1]`, `LinearFadeDuration`, and a Scriban guard so a missing NEXT entry renders nothing. `base_style: now` names a real style.

```csharp
using System.IO.Abstractions;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Graphics;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;

namespace ErsatzTV.Infrastructure.Streaming.Graphics;

public static class GraphicsElementSeeder
{
    private const string OnNowNextYaml =
        """
        name: On Now / Next
        epg_entries: 2
        location: BottomLeft
        horizontal_margin_percent: 4
        vertical_margin_percent: 8
        width_percent: 42
        text_fit: Wrap
        text_align: Left
        z_index: 100
        # transparent until 4s in, fade in 1s, hold 6s, fade out 1s
        opacity_expression: "LinearFadeDuration(content_seconds, 4, 1, 6)"
        base_style: now
        styles:
          - name: now
            font_size: 30
            font_weight: 700
            text_color: "#FFFFFF"
            halo_color: "#000000"
            halo_width: 2
          - name: sub
            font_size: 22
            font_weight: 400
            text_color: "#DDDDDD"
            halo_color: "#000000"
            halo_width: 2
          - name: next
            font_size: 22
            font_weight: 400
            text_color: "#DDDDDD"
            halo_color: "#000000"
            halo_width: 2
        text: |
          [now]NOW  {{ Epg[0].Title }}[/now]
          {{ if Epg[0].SubTitle }}[sub]{{ Epg[0].SubTitle }}[/sub]{{ end }}
          {{ if Epg.size > 1 }}[next]NEXT  {{ Epg[1].Title }} · {{ format_datetime (convert_timezone Epg[1].Start) "h:mm tt" }}[/next]{{ end }}
        """;

    public static async Task SeedOnNowNext(TvContext context, IFileSystem fileSystem, CancellationToken cancellationToken)
    {
        string seededKey = ConfigElementKey.GraphicsOnNowNextSeeded.Key;
        bool alreadySeeded = await context.ConfigElements.AnyAsync(c => c.Key == seededKey, cancellationToken);
        if (alreadySeeded)
        {
            return;
        }

        string folder = FileSystemLayout.GraphicsElementsTextTemplatesFolder;
        string target = fileSystem.Path.Combine(folder, GraphicsElementDefaults.OnNowNextFileName);

        if (!fileSystem.Directory.Exists(folder))
        {
            fileSystem.Directory.CreateDirectory(folder);
        }

        // Adopt an operator's existing file untouched; only write when absent.
        if (!fileSystem.File.Exists(target))
        {
            await fileSystem.File.WriteAllTextAsync(target, OnNowNextYaml, cancellationToken);
        }

        await context.ConfigElements.AddAsync(
            new ConfigElement { Key = seededKey, Value = "true" },
            cancellationToken);
        await context.SaveChangesAsync(cancellationToken);
    }
}
```

- [ ] **Step 6: Run the seeder tests to verify they pass**

Run: `dotnet test ErsatzTV.Tests --filter FullyQualifiedName~GraphicsElementSeederTests`
Expected: PASS (all three).

- [ ] **Step 7: Invoke the seeder at startup**

In `ErsatzTV/Services/RunOnce/DatabaseMigratorService.cs`, resolve `IFileSystem` from the scope and call the seeder between `DbInitializer.Initialize` and `DatabaseIsReady`:

```csharp
        _logger.LogInformation("Initializing database");
        await DbInitializer.Initialize(dbContext, stoppingToken);

        var fileSystem = scope.ServiceProvider.GetRequiredService<System.IO.Abstractions.IFileSystem>();
        await GraphicsElementSeeder.SeedOnNowNext(dbContext, fileSystem, stoppingToken);

        _systemStartup.DatabaseIsReady();
```

Add `using ErsatzTV.Infrastructure.Streaming.Graphics;` at the top. (`IFileSystem` is already DI-registered — `RefreshGraphicsElementsHandler` consumes it. The seeder runs before `DatabaseIsReady`, hence before `SchedulerService` queues `RefreshGraphicsElements`, so the file is present when the refresh creates the row.)

- [ ] **Step 8: Build to confirm startup wiring compiles**

Run: `dotnet build ErsatzTV`
Expected: PASS.

- [ ] **Step 9: Commit**

```bash
git add ErsatzTV.Core/Graphics/GraphicsElementDefaults.cs ErsatzTV.Core/Domain/ConfigElementKey.cs \
        ErsatzTV.Infrastructure/Streaming/Graphics/GraphicsElementSeeder.cs \
        ErsatzTV/Services/RunOnce/DatabaseMigratorService.cs \
        ErsatzTV.Tests/Infrastructure/GraphicsElementSeederTests.cs
git -c core.hooksPath=/dev/null commit -m "feat(74): seed built-in on-now-next.yml text element (file + marker)"
```

---

### Task 4: API — channel `graphicsElementIds` + graphics `builtIn` + OpenAPI regen

**Files:**
- Modify: `ErsatzTV.Core/Api/Channels/ChannelDetailResponseModel.cs` (add `int[] GraphicsElementIds`)
- Modify: `ErsatzTV/Controllers/Api/Requests/UpdateChannelRequest.cs` (add `List<int> GraphicsElementIds` + thread through `ToCommand`)
- Modify: `ErsatzTV.Application/Channels/Commands/UpdateChannel.cs` (add `List<int> GraphicsElementIds`)
- Modify: `ErsatzTV.Application/Channels/Commands/UpdateChannelHandler.cs` (include + reconcile)
- Modify: `ErsatzTV.Infrastructure/Data/Repositories/ChannelRepository.cs` (include on `GetChannel`)
- Modify: `ErsatzTV.Application/Channels/Mapper.cs` (project ids)
- Modify: `ErsatzTV.Core/Api/Graphics/GraphicsElementResponseModel.cs` (add `bool BuiltIn`)
- Modify: `ErsatzTV.Application/Graphics/Queries/GetAllGraphicsElementsForApiHandler.cs` (derive `builtIn`)
- Generated: `ErsatzTV/wwwroot/api/v1.json`, `web/src/api/generated/v1.d.ts`, `docs/endpoint-index.md`
- Test: `ErsatzTV.Tests/Application/Channels/UpdateChannelGraphicsElementsTests.cs`

**Interfaces:**
- Consumes: `Channel.ChannelGraphicsElements` (Task 1); `GraphicsElementDefaults.OnNowNextFileName` (Task 3).
- Produces: DTO field `GraphicsElementIds` on channel detail + update; `BuiltIn` on `GraphicsElementResponseModel`.

- [ ] **Step 1: Write the failing reconcile test**

Create `ErsatzTV.Tests/Application/Channels/UpdateChannelGraphicsElementsTests.cs`. Drive `UpdateChannelHandler.Handle` against `InMemoryTvContext` with a channel + two graphics elements; assert the join set is reconciled (add then remove). Construct the handler with the same collaborators the existing `UpdateChannelHandler` tests use — read `ErsatzTV.Tests/Application/Channels/` for the existing setup (an `IDbContextFactory<TvContext>` over `_db`, `ISearchTargets` substitute, channel-data refresh substitutes). Assertion core:

```csharp
    // after sending UpdateChannel with GraphicsElementIds = [elementA.Id]
    Channel reloaded = await context.Channels.Include(c => c.ChannelGraphicsElements)
        .SingleAsync(c => c.Id == channelId);
    reloaded.ChannelGraphicsElements.Select(x => x.GraphicsElementId).ShouldBe(new[] { elementAId });

    // after sending UpdateChannel with GraphicsElementIds = [] (remove)
    reloaded.ChannelGraphicsElements.ShouldBeEmpty();
```

If no `UpdateChannelHandler` test exists to copy the harness from, construct the factory as `new PooledDbContextFactory<TvContext>(_db.Options)`-equivalent used elsewhere in `ErsatzTV.Tests/Application/` (grep for `IDbContextFactory<TvContext>` test usages) and substitute the other ctor deps with `Substitute.For<...>()`.

- [ ] **Step 2: Run to verify it fails**

Run: `dotnet build ErsatzTV.Tests`
Expected: FAIL — `UpdateChannel.GraphicsElementIds` does not exist.

- [ ] **Step 3: Add `GraphicsElementIds` to the command + request DTO**

In `ErsatzTV.Application/Channels/Commands/UpdateChannel.cs`, add a final positional parameter:

```csharp
    bool ShowInEpg,
    List<int> GraphicsElementIds) : IRequest<Either<BaseError, ChannelViewModel>>;
```

In `ErsatzTV/Controllers/Api/Requests/UpdateChannelRequest.cs`, add the matching final record parameter `List<int> GraphicsElementIds` and pass it as the final `ToCommand` argument (`GraphicsElementIds ?? []`).

- [ ] **Step 4: Reconcile the join in `UpdateChannelHandler`**

In `ErsatzTV.Application/Channels/Commands/UpdateChannelHandler.cs`, add to the channel load in `Handle`:

```csharp
            .Include(c => c.ChannelGraphicsElements)
```

In `ApplyUpdateRequest`, immediately before `await dbContext.SaveChangesAsync(cancellationToken);`, reconcile (mirrors the artwork add/remove block):

```csharp
        c.ChannelGraphicsElements ??= [];
        var desired = update.GraphicsElementIds?.Distinct().ToList() ?? [];
        c.ChannelGraphicsElements.RemoveAll(cge => !desired.Contains(cge.GraphicsElementId));
        foreach (int id in desired.Where(id => c.ChannelGraphicsElements.All(cge => cge.GraphicsElementId != id)))
        {
            c.ChannelGraphicsElements.Add(new ChannelGraphicsElement { ChannelId = c.Id, GraphicsElementId = id });
        }
```

- [ ] **Step 5: Include the join on the detail read + project ids**

In `ErsatzTV.Infrastructure/Data/Repositories/ChannelRepository.cs` `GetChannel`, add before `.OrderBy(c => c.Id)`:

```csharp
            .Include(c => c.ChannelGraphicsElements)
            .ThenInclude(x => x.GraphicsElement)
```

In `ErsatzTV.Application/Channels/Mapper.cs` `ProjectToDetailResponseModel`, add the final constructor argument:

```csharp
            channel.ShowInEpg,
            channel.ChannelGraphicsElements?.Map(x => x.GraphicsElementId).ToArray() ?? []);
```

In `ErsatzTV.Core/Api/Channels/ChannelDetailResponseModel.cs`, add the final record parameter:

```csharp
    bool ShowInEpg,
    int[] GraphicsElementIds);
```

- [ ] **Step 6: Add `BuiltIn` to the graphics-element DTO + handler**

In `ErsatzTV.Core/Api/Graphics/GraphicsElementResponseModel.cs`:

```csharp
public record GraphicsElementResponseModel(int Id, string Name, bool BuiltIn);
```

In `ErsatzTV.Application/Graphics/Queries/GetAllGraphicsElementsForApiHandler.cs`, derive `builtIn` from the entity's filename (keep the existing VM ordering; carry the entity alongside the VM):

```csharp
        return graphicsElements
            .Select(e => new
            {
                Vm = ProjectToViewModel(e),
                BuiltIn = Path.GetFileName(e.Path) == GraphicsElementDefaults.OnNowNextFileName
            })
            .OrderBy(x => x.Vm.Name == x.Vm.FileName)
            .ThenBy(x => x.Vm.Name)
            .Select(x => new GraphicsElementResponseModel(x.Vm.Id, x.Vm.Name, x.BuiltIn))
            .ToList();
```

Add `using ErsatzTV.Core.Graphics;`.

- [ ] **Step 7: Run the reconcile test to verify it passes**

Run: `dotnet test ErsatzTV.Tests --filter FullyQualifiedName~UpdateChannelGraphicsElementsTests`
Expected: PASS.

- [ ] **Step 8: Regenerate OpenAPI artifacts**

Run:
```bash
dotnet build ErsatzTV
./scripts/update-openapi.sh
cd web && npm run generate:api && cd ..
```
Expected: `v1.json`, `web/src/api/generated/v1.d.ts`, `docs/endpoint-index.md` now show `graphicsElementIds` on the channel models and `builtIn` on `GraphicsElementResponseModel`. Confirm `git diff --stat` lists exactly those generated files.

- [ ] **Step 9: Update `docs/api-conventions.md` checklist note**

Add a one-line entry noting the channel now carries `graphicsElementIds` (attached graphics elements) and graphics elements expose `builtIn`. Keep it consistent with the existing checklist style.

- [ ] **Step 10: Commit**

```bash
git add ErsatzTV.Core/Api ErsatzTV/Controllers/Api/Requests/UpdateChannelRequest.cs \
        ErsatzTV.Application/Channels ErsatzTV.Application/Graphics \
        ErsatzTV.Infrastructure/Data/Repositories/ChannelRepository.cs \
        ErsatzTV/wwwroot/api/v1.json web/src/api/generated/v1.d.ts docs/endpoint-index.md docs/api-conventions.md \
        ErsatzTV.Tests/Application/Channels/UpdateChannelGraphicsElementsTests.cs
git -c core.hooksPath=/dev/null commit -m "feat(74): channel graphicsElementIds + graphics builtIn; regen OpenAPI"
```

---

### Task 5: SPA — Branding-tab "Show On Now / Next overlay" toggle

**Files:**
- Create: `web/src/api/graphicsElements.ts` (`findBuiltInOnNowNext` helper)
- Modify: `web/src/screens/ChannelEditScreen.tsx` (load elements into ReferenceData; draft field; the Switch)
- Test: `web/src/screens/ChannelEditScreen.test.tsx` (or a focused component test if the harness has one; else document manual verification)

**Interfaces:**
- Consumes: generated types now carry `graphicsElementIds` and `GraphicsElementResponseModel.builtIn` (Task 4); `getGraphicsElements()` in `web/src/api/pickers.ts`.

- [ ] **Step 1: Add the built-in finder helper**

`web/src/api/graphicsElements.ts` (mirrors `findLogoBugWatermark`):

```ts
import type { components } from './generated/v1';

export type GraphicsElement = components['schemas']['GraphicsElementResponseModel'];

// Prefer the server-declared built-in flag; never match on the user-editable name.
export function findBuiltInOnNowNext(elements: GraphicsElement[]): GraphicsElement | null {
  return elements.find((e) => e.builtIn) ?? null;
}
```

- [ ] **Step 2: Load graphics elements into `ReferenceData`**

In `web/src/screens/ChannelEditScreen.tsx`: add `getGraphicsElements` (from `../api/pickers`) to the reference-data `Promise.all`, add `graphicsElements` to the `ReferenceData` type, and pass it into `BrandingPane` via the existing `data` prop.

- [ ] **Step 3: Thread the draft field**

In `draftFromChannel`, add `graphicsElementIds: channel.graphicsElementIds ?? []`. (The generated `UpdateChannelRequest`/`Channel` types now include the field.) `dirty` tracking (JSON.stringify) picks it up automatically.

- [ ] **Step 4: Add the toggle in `BrandingPane`**

After the logo-bug `Row`, add (uses `findBuiltInOnNowNext`; no live preview, static caption):

```tsx
        {(() => {
          const onNowNext = findBuiltInOnNowNext(data.graphicsElements);
          const ids = draft.graphicsElementIds ?? [];
          const enabled = onNowNext != null && ids.includes(onNowNext.id);
          return (
            <Row
              control={340}
              help={
                onNowNext == null
                  ? 'The built-in On Now / Next element is not available yet.'
                  : 'Briefly shows the current and next program at each program change. Not shown in HLS Direct mode.'
              }
              label="Show On Now / Next overlay"
            >
              <Switch
                checked={enabled}
                disabled={hlsDirect || onNowNext == null}
                hideLabel
                label="Show On Now / Next overlay"
                onChange={(next) => {
                  if (onNowNext == null) return;
                  const without = ids.filter((id) => id !== onNowNext.id);
                  set({ graphicsElementIds: next ? [...without, onNowNext.id] : without });
                }}
                size="sm"
              />
            </Row>
          );
        })()}
```

- [ ] **Step 5: Typecheck + build the SPA**

Run: `cd web && npm run build`
Expected: PASS (no TS errors; `graphicsElementIds` and `builtIn` resolve against the regenerated types).

- [ ] **Step 6: Web test (if harness present)**

If `web/src/screens/ChannelEditScreen.test.tsx` exists, add a test that renders the Branding pane with a built-in element in `data.graphicsElements` and asserts toggling the switch adds/removes the id from the draft. Give heavy-render tests an explicit per-test timeout (e.g. `{ timeout: 15000 }`). Run: `cd web && npm run test -- ChannelEditScreen`. If no component-test harness exists for this screen, note that verification is covered by the live-E2E in Task 6 and skip.

- [ ] **Step 7: Commit**

```bash
git add web/src/api/graphicsElements.ts web/src/screens/ChannelEditScreen.tsx web/src/screens/ChannelEditScreen.test.tsx
git -c core.hooksPath=/dev/null commit -m "feat(74): channel Branding-tab On Now/Next overlay toggle"
```

---

### Task 6: Live-E2E verification + docs + decision records

**Files:**
- Modify: `docs/domain-model.md`, `docs/channels.md`
- Modify: `docs/decisions.md` (+ `docs/blazor-route-parity.md` only if a route changed — it did not)
- No code (verification + docs)

- [ ] **Step 1: Boot a fresh local instance**

Run: `scripts/e2e-local.sh` (fresh config dir — do NOT reuse an existing one). Confirm startup logs show the graphics-element refresh created the built-in row (grep the log for `on-now-next.yml`).

- [ ] **Step 2: Confirm the built-in element is served**

Run: `curl -s localhost:<port>/api/v1/graphics-elements | jq '.[] | select(.builtIn==true)'`
Expected: one element with `builtIn: true` named "On Now / Next".

- [ ] **Step 3: Enable the overlay on a channel with real playout**

`GET /api/v1/channels/{id}`, then `PUT` the same body with `graphicsElementIds` set to `[<builtInId>]`. Re-`GET` and confirm `graphicsElementIds` round-trips.

- [ ] **Step 4: Stream and visually confirm the burned-in bug**

Curl the channel's HLS (never a browser tab), pull a `.ts`/`.m4s` segment a few seconds after a program boundary, and extract a frame:
```bash
ffmpeg -i <segment> -frames:v 1 /tmp/on-now-next-frame.png
```
Open the PNG and confirm the NOW/NEXT text bug is rendered in the corner. Repeat across a program boundary to confirm the text updates. (This is a visual/operator check — a curl assertion cannot verify pixels.)

- [ ] **Step 5: Note the guide-cache caveat outcome**

If NOW/NEXT is wrong, check guide-cache freshness (`ChannelGuideCacheFolder/{number}.xml`). For a normal channel it should match; if on-demand/time-shift channels drift, file a scoped follow-up issue rather than expanding this PR.

- [ ] **Step 6: Update docs**

- `docs/domain-model.md`: document the channel-level graphics-element attachment (`ChannelGraphicsElement`) and where it's edited (Channel editor → Branding → "Show On Now / Next overlay").
- `docs/channels.md`: add the overlay toggle + its transcode-only limitation (no HLS-Direct).
- `docs/decisions.md`: add a decision record (with `key:`, `Signals:`, `status: active`, `since: 2026-07-22`) for **channel-level graphics-element attachment + the built-in seeded text-element pattern**, cross-referencing `iptv.logo-drives-bug-preset`. Suggested key: `graphics.channel-level-attachment`. Then run `python3 scripts/build_decisions_catalog.py` (or the documented regen) so `docs/decisions/README.md` updates, and validate with `python3 scripts/decisions_validate.py`.

- [ ] **Step 7: Commit docs**

```bash
git add docs/domain-model.md docs/channels.md docs/decisions.md docs/decisions/README.md
git -c core.hooksPath=/dev/null commit -m "docs(74): channel-level graphics attachment + On Now/Next overlay"
```

---

### Task 7: Local gate, independent review, push, PR

- [ ] **Step 1: Full local gate**

```bash
dotnet build ErsatzTV.sln
dotnet test ErsatzTV.Tests
cd web && npm run build && npm run test && cd ..
```
Expected: all green. Investigate any failure before proceeding.

- [ ] **Step 2: BOM + format gate on the touched set**

Run the BOM check and `dotnet format whitespace ... --folder --include <changed .cs>` under `bash -c` (Global Constraints). Fix any reported file.

- [ ] **Step 3: Independent review (MANDATORY)**

This diff touches a DB migration, an API write-path handler, and the render path, and exceeds ~150 C# lines — independent review is required (`process.independent-review-rubric`). Run a cold-context, cross-model review over the full branch diff. Fold fixes as follow-up commits; re-review the fix commit; loop to a clean `Review-verdict: MERGEABLE @ <head-sha>` (or an explicit acceptable-defer with a filed follow-up).

- [ ] **Step 4: Push + open PR + arm CI monitor**

Push the branch once (batch all commits — CI runs can't be cancelled), open a PR (`fixes #74`), and arm the CI monitor on the head sha at PR-open. Ensure #74's issue body carries a `## Done-when` checklist (adversarial-review-passed; tests-green; live-E2E; docs-updated) so the merge-consent gate can derive consent.

- [ ] **Step 5: Session close**

Run the H12 audit (`scripts/issue-qualification-audit.sh`), post a `## Closing record` on #74, remove the `in-progress` label after merge, and run `scripts/refresh-shared-checkout.sh`.

---

## Self-Review

**Spec coverage:** §3.1 join → Task 1; §3.2 selector hook + eager-load → Task 2; §3.3 seed → Task 3; §3.4 API + builtIn → Task 4; §3.5 SPA toggle → Task 5; §5 tests → Tasks 1/2/3/4 (+ E2E Task 6); §6 risks (guide-cache) → Task 6 Step 5; §7 docs → Tasks 4/6; §8 out-of-scope (no live preview) → Task 5 honored. All spec sections map to tasks.

**Deviations from the spec, made explicit:** (a) Row creation is delegated to the existing `RefreshGraphicsElementsHandler` (single owner) rather than done in the seeder — the seeder only materializes the file + marker; this is cleaner ownership and keeps the seeder unit-testable with `MockFileSystem`. (b) The style set uses three named styles (`now`/`sub`/`next`) so NOW title, subtitle, and NEXT line differ visually.

**Placeholder scan:** the only intentionally-open value is the two file-harness lookups called out in Task 4 Step 1 and Task 5 Step 6 (copy the existing test harness / confirm a web test harness exists) — these are "read the neighbor and mirror it" instructions, not code TODOs. The `opacity_expression` is concrete (`LinearFadeDuration(content_seconds, 4, 1, 6)`), verified against `OpacityExpressionHelper`.

**Type consistency:** `GraphicsElementIds` is `List<int>` on the command/request and `int[]` on the response DTO (matches existing DTO array style); `ChannelGraphicsElement` navs and FKs are named identically across entity, config, includes, and reconcile. `builtIn` derives from `GraphicsElementDefaults.OnNowNextFileName` in both the seeder-identity and the API handler.
