Files
ersatztv/docs/superpowers/plans/2026-07-20-unified-logo-bug.md
T
timothy 71bb2c23f3 docs(67): revise plan+spec per independent review — fix toggle blocker, seed marker, real preview geometry
Fable review (cold, review-only) found a blocker in the plan's own code:
Task 5/6 selected the logo-bug preset by searching for the first
imageSource==='ChannelLogo' entry, but getWatermarks() sorts by name
(pickers.ts:14), so with a second logo-driven preset -- which this design
explicitly invites users to create -- the toggle would read OFF for a
logo-driven channel and REPOINT it on tick, from a control documented as a
pure reflection of stored state. Now resolved by id lookup, with the
tick-on target chosen by a shared findLogoBugWatermark() helper and pinned
by a two-preset regression test.

Also adopted:
- ConfigElement seed marker (watermark.channel_bug_seeded): ChannelWatermark
  has no IsSystem flag and Initialize runs every startup, so a name-only
  guard resurrected a deliberately deleted preset forever.
- Channel-editor preview now fetches the referenced preset's REAL geometry
  via the existing GET /api/v1/watermarks/{id} instead of hardcoding the
  seeded defaults -- which would have been wrong for exactly the users who
  tuned theirs.
- The 're-save untouched leaves watermarkId unchanged' test the spec
  promised and the plan had omitted, plus a create-path degrade test.
- Reversed the ChannelBuilder exclusion (operator decision): fresh installs
  stamp the preset onto the templates the seed creates; existing installs
  are untouched.
- External-URL logos never render a bug (File.Exists against a URL,
  WatermarkSelector.cs:269-286) -- verified, filed as #502, preview no
  longer promises it.
- Dropped Task 2's InternalsVisibleTo branch: already present
  (ErsatzTV.Application.csproj:30-32).

Refs #67 #502
2026-07-20 20:04:56 +02:00

52 KiB
Raw Blame History

Unified logo / on-screen bug (ersatztv#67) 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: A single uploaded channel logo drives both the guide listing and the on-screen bug by default, with the geometry visible before saving.

Architecture: No schema change. ChannelWatermarkImageSource.ChannelLogo already resolves each channel's own logo artwork at render time, so we seed ONE shared Channel Bug watermark preset idempotently in DbInitializer, point the SPA's channel-creation paths at it, and add a shared <BugPreview> component that renders the resolved geometry in both the channel editor and the watermarks screen. One additive DTO field (WatermarkResponseModel.imageSource) lets the SPA find logo-driven presets without matching a user-editable name.

Tech Stack: C# / .NET 10, EF Core (dual-provider, but no migration here), MediatR CQRS, NUnit + Shouldly + InMemoryTvContext; React 19 + TypeScript + Vite, vitest + @testing-library/react.

Spec: docs/superpowers/specs/2026-07-20-unified-logo-bug-design.md

Global Constraints

  • Worktree: /Users/timothy/.claude/worktrees/etv67, branch feat/67-unified-logo-bug, branched off origin/main. Never commit in /Users/timothy/ersatztv (shared mutable tree).
  • Test framework is NUnit + Shouldly + NSubstitute. Never xUnit.
  • /api/v1 is frozen-additive (docs/decisions.md, #286): add fields, never remove or reshape.
  • No database migration. If you find yourself running scripts/add-migration.sh, stop — the design explicitly rejected that path.
  • BOM gate (#311, fix-as-you-touch). Two files this plan modifies currently carry a UTF-8 BOM and MUST be written back without one: ErsatzTV.Infrastructure/Data/DbInitializer.cs and ErsatzTV.Application/Watermarks/Mapper.cs. Check before every push:
    for f in $(git diff --name-only origin/main...HEAD -- '*.cs'); do head -c3 "$f" | xxd -p | grep -q "^efbbbf" && echo "BOM: $f"; done
    
  • Never set ETV_UPDATE_GOLDENS.
  • Exact seed values (adopted verbatim from the operator's proven production row — do not "improve" them): Name="Channel Bug", Mode=ChannelWatermarkMode.Permanent, ImageSource=ChannelWatermarkImageSource.ChannelLogo, Image=null, Location=WatermarkLocation.TopLeft, Size=WatermarkSize.Scaled, WidthPercent=5.0, HorizontalMarginPercent=1.0, VerticalMarginPercent=1.0, FrequencyMinutes=0, DurationSeconds=0, Opacity=80, PlaceWithinSourceContent=false, ZIndex=0.
  • SPA field names differ from the C# ones — the wire DTO uses width, horizontalMargin, verticalMargin (NOT widthPercent/*Percent). Use the wire names in all TypeScript.

Verification gate (run before every commit):

# backend, from the worktree root
dotnet build ErsatzTV.sln
dotnet test ErsatzTV.Tests/ErsatzTV.Tests.csproj
# frontend, from web/
npm test && npm run lint && npm run typecheck && npm run build

Task 1: Seed the shared Channel Bug watermark preset

Files:

  • Modify: ErsatzTV.Core/Domain/ConfigElementKey.cs (add one key property)
  • Modify: ErsatzTV.Infrastructure/Data/DbInitializer.cs:134 (call order), SeedChannelTemplates (:140-186, takes the new id), NewSystemTemplate (:233-265, stamps it); append the new seed method
  • Test: ErsatzTV.Tests/Infrastructure/DbInitializerChannelBugWatermarkTests.cs (create)

Interfaces:

  • Consumes: TvContext.ChannelWatermarks (ErsatzTV.Infrastructure/Data/TvContext.cs:44), ChannelWatermark (ErsatzTV.Core/Domain/ChannelWatermark.cs:6-36), enums ChannelWatermarkMode / ChannelWatermarkImageSource (same file, :38-52), WatermarkLocation / WatermarkSize (ErsatzTV.FFmpeg/State/WatermarkState.cs), ChannelTemplate.WatermarkId (ErsatzTV.Core/Domain/ChannelTemplate.cs:14).
  • Produces: private static async Task<int?> SeedChannelBugWatermark(TvContext, CancellationToken) returning the preset's id; a ChannelWatermark row named Channel Bug with ImageSource = ChannelLogo; freshly seeded ChannelTemplate rows carrying its WatermarkId; a ConfigElement row keyed watermark.channel_bug_seeded.

Why the marker and the ordering (do not simplify these away): ChannelWatermark has no IsSystem flag, so a pure name-guard resurrects the row on every restart after a deliberate delete — DbInitializer.Initialize runs at every startup (ErsatzTV/Services/RunOnce/DatabaseMigratorService.cs:89). The watermark must be seeded before the templates so its id can be stamped on them.

  • Step 1: Write the failing tests

Create ErsatzTV.Tests/Infrastructure/DbInitializerChannelBugWatermarkTests.cs:

using ErsatzTV.Core.Domain;
using ErsatzTV.FFmpeg.State;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Tests.Support;
using NUnit.Framework;
using Shouldly;

namespace ErsatzTV.Tests.Infrastructure;

[TestFixture]
public class DbInitializerChannelBugWatermarkTests
{
    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 Initialize_Should_Seed_Channel_Bug_Watermark()
    {
        await using TvContext context = _db.CreateContext();

        await DbInitializer.Initialize(context, CancellationToken.None);

        ChannelWatermark watermark = context.ChannelWatermarks.Single(w => w.Name == "Channel Bug");
        watermark.Mode.ShouldBe(ChannelWatermarkMode.Permanent);
        watermark.ImageSource.ShouldBe(ChannelWatermarkImageSource.ChannelLogo);
        watermark.Image.ShouldBeNull();
        watermark.Location.ShouldBe(WatermarkLocation.TopLeft);
        watermark.Size.ShouldBe(WatermarkSize.Scaled);
        watermark.WidthPercent.ShouldBe(5.0);
        watermark.HorizontalMarginPercent.ShouldBe(1.0);
        watermark.VerticalMarginPercent.ShouldBe(1.0);
        watermark.Opacity.ShouldBe(80);
        watermark.ZIndex.ShouldBe(0);
        watermark.PlaceWithinSourceContent.ShouldBeFalse();
    }

    // The production instance already has a hand-made "Channel Bug" row with tuned geometry.
    // Seeding must adopt it untouched, never overwrite it and never duplicate it.
    [Test]
    public async Task Initialize_Should_Adopt_Existing_Channel_Bug_Watermark_Untouched()
    {
        await using TvContext context = _db.CreateContext();
        await context.ChannelWatermarks.AddAsync(
            new ChannelWatermark
            {
                Name = "Channel Bug",
                Mode = ChannelWatermarkMode.Intermittent,
                ImageSource = ChannelWatermarkImageSource.ChannelLogo,
                Location = WatermarkLocation.BottomRight,
                Size = WatermarkSize.Scaled,
                WidthPercent = 12,
                HorizontalMarginPercent = 3,
                VerticalMarginPercent = 4,
                Opacity = 55,
                FrequencyMinutes = 10,
                DurationSeconds = 20
            });
        await context.SaveChangesAsync();

        await DbInitializer.Initialize(context, CancellationToken.None);

        ChannelWatermark watermark = context.ChannelWatermarks.Single(w => w.Name == "Channel Bug");
        watermark.Mode.ShouldBe(ChannelWatermarkMode.Intermittent);
        watermark.Location.ShouldBe(WatermarkLocation.BottomRight);
        watermark.WidthPercent.ShouldBe(12);
        watermark.Opacity.ShouldBe(55);
    }

    [Test]
    public async Task Initialize_Should_Be_Idempotent()
    {
        await using TvContext context = _db.CreateContext();

        await DbInitializer.Initialize(context, CancellationToken.None);
        await DbInitializer.Initialize(context, CancellationToken.None);

        context.ChannelWatermarks.Count(w => w.Name == "Channel Bug").ShouldBe(1);
    }

    // No IsSystem flag exists on ChannelWatermark, and Initialize runs on every startup, so without
    // a seed marker a deliberate delete would be undone forever.
    [Test]
    public async Task Initialize_Should_Not_Resurrect_A_Deleted_Preset()
    {
        await using TvContext context = _db.CreateContext();
        await DbInitializer.Initialize(context, CancellationToken.None);

        ChannelWatermark seeded = context.ChannelWatermarks.Single(w => w.Name == "Channel Bug");
        context.ChannelWatermarks.Remove(seeded);
        await context.SaveChangesAsync();

        await DbInitializer.Initialize(context, CancellationToken.None);

        context.ChannelWatermarks.Any(w => w.Name == "Channel Bug").ShouldBeFalse();
    }

    [Test]
    public async Task Initialize_Should_Stamp_The_Preset_On_Freshly_Seeded_Templates()
    {
        await using TvContext context = _db.CreateContext();

        await DbInitializer.Initialize(context, CancellationToken.None);

        ChannelWatermark watermark = context.ChannelWatermarks.Single(w => w.Name == "Channel Bug");
        foreach (ChannelTemplate template in context.ChannelTemplates.Where(t => t.IsSystem).ToList())
        {
            template.WatermarkId.ShouldBe(watermark.Id);
        }
    }
}
  • Step 2: Run the tests to verify they fail
dotnet test ErsatzTV.Tests/ErsatzTV.Tests.csproj --filter FullyQualifiedName~DbInitializerChannelBugWatermarkTests

Expected: FAIL — Sequence contains no elements from .Single(...) (no seeded row yet).

  • Step 3: Implement the seed

3a. In ErsatzTV.Core/Domain/ConfigElementKey.cs, add the key next to ChannelTemplatesDefaultTemplateId (:26), matching the file's existing property style:

    public static ConfigElementKey WatermarkChannelBugSeeded => new("watermark.channel_bug_seeded");

3b. In ErsatzTV.Infrastructure/Data/DbInitializer.cs, replace the single call at :134. The watermark seeds first so its id can be stamped on the templates:

        int? channelBugWatermarkId = await SeedChannelBugWatermark(context, cancellationToken);
        await SeedChannelTemplates(context, cancellationToken, channelBugWatermarkId);

3c. Add this private method directly after SeedChannelTemplates (which ends at :186):

    // A single shared preset is all that's needed: ImageSource.ChannelLogo resolves each channel's
    // own logo artwork at render time (WatermarkSelector), so one row makes every channel use its
    // own logo as its on-screen bug.
    //
    // Guarded by a ConfigElement marker rather than by name alone: ChannelWatermark has no IsSystem
    // flag, and Initialize runs on every startup, so a name-only guard would resurrect the row
    // forever after a deliberate delete. Adopting an existing same-name row (an operator's tuned
    // one) also sets the marker — adopt, never overwrite.
    private static async Task<int?> SeedChannelBugWatermark(
        TvContext context,
        CancellationToken cancellationToken)
    {
        string seededKey = ConfigElementKey.WatermarkChannelBugSeeded.Key;
        bool alreadySeeded = await context.ConfigElements
            .AnyAsync(c => c.Key == seededKey, cancellationToken);

        ChannelWatermark existing = await context.ChannelWatermarks
            .FirstOrDefaultAsync(w => w.Name == "Channel Bug", cancellationToken);

        if (alreadySeeded)
        {
            return existing?.Id;
        }

        if (existing is null)
        {
            existing = new ChannelWatermark
            {
                Name = "Channel Bug",
                Mode = ChannelWatermarkMode.Permanent,
                ImageSource = ChannelWatermarkImageSource.ChannelLogo,
                Image = null,
                Location = WatermarkLocation.TopLeft,
                Size = WatermarkSize.Scaled,
                WidthPercent = 5.0,
                HorizontalMarginPercent = 1.0,
                VerticalMarginPercent = 1.0,
                FrequencyMinutes = 0,
                DurationSeconds = 0,
                Opacity = 80,
                PlaceWithinSourceContent = false,
                ZIndex = 0
            };
            await context.ChannelWatermarks.AddAsync(existing, cancellationToken);
        }

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

        return existing.Id;
    }

3d. Thread the id through the template seed. Change the signature at :140:

    private static async Task SeedChannelTemplates(
        TvContext context,
        CancellationToken cancellationToken,
        int? channelBugWatermarkId)

Pass channelBugWatermarkId as a new final argument to both NewSystemTemplate(...) calls (:157-166 and :172-181), add the matching parameter to NewSystemTemplate (:233-240):

        bool randomStartPoint,
        int? watermarkId) =>

and add one line to its object initializer (:241-264):

            WatermarkId = watermarkId,

This only ever touches templates the seed is creating for the first time — the existing name-guards at :142-147 and :155/:170 mean an install that already has these templates is untouched.

Add using ErsatzTV.FFmpeg.State; to the file's usings if WatermarkLocation/WatermarkSize do not already resolve.

  • Step 4: Run the tests to verify they pass
dotnet test ErsatzTV.Tests/ErsatzTV.Tests.csproj --filter FullyQualifiedName~DbInitializerChannelBugWatermarkTests
dotnet test ErsatzTV.Tests/ErsatzTV.Tests.csproj --filter FullyQualifiedName~DbInitializerChannelTemplateTests

Expected: PASS, 5 tests in the first fixture; the pre-existing template fixture must still pass (you changed its seed signature). Confirm the build output shows Build succeeded / 0 Error(s) — a failed build with --no-build elsewhere can silently run a stale dll.

  • Step 5: Strip the BOM and commit
python3 - <<'EOF'
p = 'ErsatzTV.Infrastructure/Data/DbInitializer.cs'
b = open(p, 'rb').read()
if b.startswith(b'\xef\xbb\xbf'):
    open(p, 'wb').write(b[3:])
    print('stripped', p)
EOF
head -c3 ErsatzTV.Infrastructure/Data/DbInitializer.cs | xxd -p   # must NOT be efbbbf
git add ErsatzTV.Core/Domain/ConfigElementKey.cs ErsatzTV.Infrastructure/Data/DbInitializer.cs ErsatzTV.Tests/Infrastructure/DbInitializerChannelBugWatermarkTests.cs
git commit -m "feat(67): seed shared Channel Bug watermark preset, once per database"

Task 2: Expose imageSource on the watermark picker DTO

Files:

  • Modify: ErsatzTV.Core/Api/Watermarks/WatermarkResponseModel.cs (whole file, 4 lines)
  • Modify: ErsatzTV.Application/Watermarks/Mapper.cs:9-10 (ProjectToResponseModel)
  • Modify (generated, do not hand-edit): ErsatzTV/wwwroot/openapi/v1.json, docs/endpoint-index.md, web/src/api/generated/v1.d.ts
  • Test: ErsatzTV.Tests/Application/Watermarks/WatermarkMapperTests.cs (create)

Interfaces:

  • Consumes: ChannelWatermark.ImageSource.

  • Produces: WatermarkResponseModel(int Id, string Name, ChannelWatermarkImageSource ImageSource), surfacing as Watermark.imageSource ("Custom" | "ChannelLogo" | "Resource") in web/src/api/pickers.ts. Tasks 5 and 6 consume this.

  • Step 1: Write the failing test

Create ErsatzTV.Tests/Application/Watermarks/WatermarkMapperTests.cs:

using ErsatzTV.Core.Api.Watermarks;
using ErsatzTV.Core.Domain;
using NUnit.Framework;
using Shouldly;
using static ErsatzTV.Application.Watermarks.Mapper;

namespace ErsatzTV.Tests.Application.Watermarks;

[TestFixture]
public class WatermarkMapperTests
{
    [Test]
    public void ProjectToResponseModel_Should_Carry_ImageSource()
    {
        var watermark = new ChannelWatermark
        {
            Id = 7,
            Name = "Channel Bug",
            ImageSource = ChannelWatermarkImageSource.ChannelLogo
        };

        WatermarkResponseModel result = ProjectToResponseModel(watermark);

        result.Id.ShouldBe(7);
        result.Name.ShouldBe("Channel Bug");
        result.ImageSource.ShouldBe(ChannelWatermarkImageSource.ChannelLogo);
    }
}

Mapper is internal static, but ErsatzTV.Application.csproj:30-32 already exposes internals to ErsatzTV.Tests via an InternalsVisibleTo assembly attribute — verified, so this test compiles as written. Do not add another InternalsVisibleTo.

  • Step 2: Run the test to verify it fails
dotnet test ErsatzTV.Tests/ErsatzTV.Tests.csproj --filter FullyQualifiedName~WatermarkMapperTests

Expected: FAIL to compile — WatermarkResponseModel does not contain a definition for ImageSource.

  • Step 3: Add the field

ErsatzTV.Core/Api/Watermarks/WatermarkResponseModel.cs becomes:

#nullable enable
using ErsatzTV.Core.Domain;

namespace ErsatzTV.Core.Api.Watermarks;

// ImageSource lets a client identify logo-driven presets (the seeded "Channel Bug") without
// matching a user-editable name. Additive under the frozen /api/v1 contract (#286).
public record WatermarkResponseModel(int Id, string Name, ChannelWatermarkImageSource ImageSource);

ErsatzTV.Application/Watermarks/Mapper.cs:9-10 becomes:

    internal static WatermarkResponseModel ProjectToResponseModel(ChannelWatermark watermark) =>
        new(watermark.Id, watermark.Name, watermark.ImageSource);
  • Step 4: Run the test to verify it passes
dotnet test ErsatzTV.Tests/ErsatzTV.Tests.csproj --filter FullyQualifiedName~WatermarkMapperTests

Expected: PASS.

  • Step 5: Regenerate the OpenAPI artifacts — build FIRST

Order matters: update-openapi.sh runs dotnet-getdocument against the already-built ErsatzTV.dll, so a stale assembly silently serializes the old schema (docs/api-conventions.md:308-312).

dotnet build ErsatzTV.sln
./scripts/update-openapi.sh
cd web && npm run generate:api && cd ..
git diff --stat -- ErsatzTV/wwwroot/openapi/v1.json docs/endpoint-index.md web/src/api/generated/v1.d.ts

Expected: v1.d.ts now shows "WatermarkResponseModel": { "id": number; "name": string; "imageSource": components["schemas"]["ChannelWatermarkImageSource"]; }.

  • Step 6: Verify the generated artifacts are in sync
cd web && npm run check:api && cd ..

Expected: exit 0, no diff.

  • Step 7: Strip the BOM and commit
python3 - <<'EOF'
p = 'ErsatzTV.Application/Watermarks/Mapper.cs'
b = open(p, 'rb').read()
if b.startswith(b'\xef\xbb\xbf'):
    open(p, 'wb').write(b[3:])
    print('stripped', p)
EOF
head -c3 ErsatzTV.Application/Watermarks/Mapper.cs | xxd -p   # must NOT be efbbbf
git add -A
git commit -m "feat(67): add imageSource to the watermark picker DTO (additive)"

Task 3: The shared <BugPreview> component

Files:

  • Create: web/src/components/bugPreview.tsx
  • Create: web/src/components/bugPreview.test.tsx
  • Modify: web/src/components/index.ts (add the re-export — check the file's existing export style first and match it)

Interfaces:

  • Produces, consumed by Tasks 4 and 5:

    export interface BugPreviewGeometry {
      location: components['schemas']['WatermarkLocation'];
      size: components['schemas']['WatermarkSize'];
      width: number;              // percent of frame width, used when size === 'Scaled'
      horizontalMargin: number;   // percent
      verticalMargin: number;     // percent
      opacity: number;            // 0-100
    }
    export function bugPreviewStyle(geometry: BugPreviewGeometry): CSSProperties;
    export function BugPreview(props: BugPreviewGeometry & { src: string | null; alt?: string }): JSX.Element;
    

    The geometry math lives in the pure bugPreviewStyle so it is unit-testable without the DOM.

  • Step 1: Write the failing test

Create web/src/components/bugPreview.test.tsx:

import { cleanup, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it } from 'vitest';
import { BugPreview, bugPreviewStyle, type BugPreviewGeometry } from './bugPreview';

const base: BugPreviewGeometry = {
  horizontalMargin: 1,
  location: 'TopLeft',
  opacity: 80,
  size: 'Scaled',
  verticalMargin: 1,
  width: 5
};

describe('bugPreviewStyle', () => {
  it('anchors TopLeft using the margins', () => {
    const style = bugPreviewStyle(base);
    expect(style.top).toBe('1%');
    expect(style.left).toBe('1%');
    expect(style.bottom).toBeUndefined();
    expect(style.right).toBeUndefined();
  });

  it('anchors BottomRight using the margins', () => {
    const style = bugPreviewStyle({ ...base, location: 'BottomRight' });
    expect(style.bottom).toBe('1%');
    expect(style.right).toBe('1%');
    expect(style.top).toBeUndefined();
    expect(style.left).toBeUndefined();
  });

  it('centers horizontally for TopMiddle', () => {
    const style = bugPreviewStyle({ ...base, location: 'TopMiddle' });
    expect(style.top).toBe('1%');
    expect(style.left).toBe('50%');
    expect(style.transform).toBe('translateX(-50%)');
  });

  it('centers vertically for LeftMiddle', () => {
    const style = bugPreviewStyle({ ...base, location: 'LeftMiddle' });
    expect(style.left).toBe('1%');
    expect(style.top).toBe('50%');
    expect(style.transform).toBe('translateY(-50%)');
  });

  it('centers both axes for MiddleCenter', () => {
    const style = bugPreviewStyle({ ...base, location: 'MiddleCenter' });
    expect(style.transform).toBe('translate(-50%, -50%)');
  });

  it('scales width by percent when size is Scaled', () => {
    expect(bugPreviewStyle({ ...base, width: 12 }).width).toBe('12%');
  });

  it('leaves width unset at ActualSize', () => {
    expect(bugPreviewStyle({ ...base, size: 'ActualSize' }).width).toBeUndefined();
  });

  it('maps opacity percent to a 0-1 fraction', () => {
    expect(bugPreviewStyle({ ...base, opacity: 80 }).opacity).toBeCloseTo(0.8);
  });
});

describe('BugPreview', () => {
  afterEach(cleanup);

  it('renders the image when a src is supplied', () => {
    render(<BugPreview {...base} alt="Bug preview" src="/iptv/logos/abc" />);
    expect(screen.getByAltText('Bug preview')).toHaveAttribute('src', '/iptv/logos/abc');
  });

  it('renders an empty frame without a src', () => {
    render(<BugPreview {...base} src={null} />);
    expect(screen.queryByRole('img')).toBeNull();
  });
});
  • Step 2: Run the test to verify it fails
cd web && npx vitest run src/components/bugPreview.test.tsx

Expected: FAIL — cannot resolve ./bugPreview.

  • Step 3: Implement the component

Create web/src/components/bugPreview.tsx:

import type { CSSProperties } from 'react';
import type { components } from '../api/generated/v1';

// Renders where the on-screen bug lands, using the same percent-of-frame geometry the FFmpeg
// watermark filters use (WatermarkScaleFilter scales to widthPercent of output width; margins are
// percentages). This is an approximation for editing feedback, not a frame-accurate render.
export interface BugPreviewGeometry {
  horizontalMargin: number;
  location: components['schemas']['WatermarkLocation'];
  opacity: number;
  size: components['schemas']['WatermarkSize'];
  verticalMargin: number;
  width: number;
}

export function bugPreviewStyle(geometry: BugPreviewGeometry): CSSProperties {
  const horizontal = `${geometry.horizontalMargin}%`;
  const vertical = `${geometry.verticalMargin}%`;

  const placement: CSSProperties = (() => {
    switch (geometry.location) {
      case 'BottomLeft':
        return { bottom: vertical, left: horizontal };
      case 'BottomMiddle':
        return { bottom: vertical, left: '50%', transform: 'translateX(-50%)' };
      case 'BottomRight':
        return { bottom: vertical, right: horizontal };
      case 'LeftMiddle':
        return { left: horizontal, top: '50%', transform: 'translateY(-50%)' };
      case 'MiddleCenter':
        return { left: '50%', top: '50%', transform: 'translate(-50%, -50%)' };
      case 'RightMiddle':
        return { right: horizontal, top: '50%', transform: 'translateY(-50%)' };
      case 'TopLeft':
        return { left: horizontal, top: vertical };
      case 'TopMiddle':
        return { left: '50%', top: vertical, transform: 'translateX(-50%)' };
      case 'TopRight':
        return { right: horizontal, top: vertical };
      default:
        return { left: horizontal, top: vertical };
    }
  })();

  return {
    ...placement,
    objectFit: 'contain',
    opacity: geometry.opacity / 100,
    position: 'absolute',
    width: geometry.size === 'Scaled' ? `${geometry.width}%` : undefined
  };
}

export function BugPreview({
  alt = 'On-screen bug preview',
  src,
  ...geometry
}: BugPreviewGeometry & { alt?: string; src: null | string }) {
  return (
    <div
      style={{
        aspectRatio: '16 / 9',
        background: 'var(--ctv-surface-sunken, #1b1b1f)',
        border: '1px solid var(--ctv-border, #33343a)',
        borderRadius: 6,
        maxWidth: 320,
        overflow: 'hidden',
        position: 'relative',
        width: '100%'
      }}
    >
      {src && <img alt={alt} src={src} style={bugPreviewStyle(geometry)} />}
    </div>
  );
}

Then re-export it from web/src/components/index.ts, matching that file's existing export style (inspect it first — do not guess between export * and named re-exports).

  • Step 4: Run the test to verify it passes
cd web && npx vitest run src/components/bugPreview.test.tsx

Expected: PASS, 10 tests.

  • Step 5: Commit
git add web/src/components/bugPreview.tsx web/src/components/bugPreview.test.tsx web/src/components/index.ts
git commit -m "feat(67): add shared BugPreview component with pure geometry helper"

Task 4: Use <BugPreview> on the Watermarks screen

Files:

  • Modify: web/src/screens/WatermarksScreen.tsx:511-548 (the "Image" Row's raw <img> block)
  • Test: web/src/screens/WatermarksScreen.test.tsx (extend)

Interfaces:

  • Consumes: BugPreview from Task 3; the existing Draft (= CreateWatermarkRequest) fields image, imageSource, location, size, width, horizontalMargin, verticalMargin, opacity; watermarkImageUrl (already imported).

  • Step 1: Write the failing test

Append to the describe('WatermarksScreen', ...) block in web/src/screens/WatermarksScreen.test.tsx:

  it('renders a geometry preview of the watermark image in the editor', async () => {
    window.history.pushState({}, '', '/app/watermarks/3');
    mockApi();
    render(<WatermarksScreen />);

    const preview = await screen.findByAltText('On-screen bug preview');
    expect(preview).toHaveAttribute('src', '/artwork/watermarks/logo.png');
    // BottomRight at 5% margins, 15% width, 100% opacity (see watermarkDetail fixture)
    expect(preview).toHaveStyle({ bottom: '5%', right: '5%', width: '15%' });
  });

If /app/watermarks/3 is not the edit route, read the screen's routing block and use the real edit path — do not invent one.

  • Step 2: Run the test to verify it fails
cd web && npx vitest run src/screens/WatermarksScreen.test.tsx

Expected: FAIL — Unable to find an element with the alt text: On-screen bug preview.

  • Step 3: Replace the raw <img>

Add BugPreview to the existing import from '../components' (line 5). Then replace exactly this block (web/src/screens/WatermarksScreen.tsx, inside the "Image" Row):

            {custom && draft.image && (
              <img
                alt="Watermark preview"
                src={watermarkImageUrl(draft.image)}
                style={{ maxHeight: 60, maxWidth: 200, objectFit: 'contain' }}
              />
            )}

with:

            {custom && draft.image && (
              <BugPreview
                horizontalMargin={draft.horizontalMargin}
                location={draft.location}
                opacity={draft.opacity}
                size={draft.size}
                src={watermarkImageUrl(draft.image)}
                verticalMargin={draft.verticalMargin}
                width={draft.width}
              />
            )}
  • Step 4: Run the test to verify it passes
cd web && npx vitest run src/screens/WatermarksScreen.test.tsx

Expected: PASS.

  • Step 5: Commit
git add web/src/screens/WatermarksScreen.tsx web/src/screens/WatermarksScreen.test.tsx
git commit -m "feat(67): show resolved bug geometry on the watermarks screen"

Task 5: The "Use logo as on-screen bug" toggle in the channel editor

Files:

  • Modify: web/src/screens/ChannelEditScreen.tsxBrandingPane (:646-760)
  • Test: web/src/screens/ChannelEditScreen.test.tsx (extend)

Interfaces:

  • Consumes: BugPreview (Task 3); Watermark.imageSource (Task 2); getWatermark(id) from web/src/api/watermarks.ts; existing data.watermarks, draft.watermarkId, previewSrc, set().
  • Produces, also used by Task 6 — add to web/src/api/watermarks.ts and export from the '../api' barrel:
    export function findLogoBugWatermark<T extends { id: number; imageSource: string; name: null | string }>(
      watermarks: T[]
    ): T | null;
    

Behavior contract (read carefully — the first draft of this plan got this wrong):

  • Ticked iff the watermark that draft.watermarkId points at has imageSource === 'ChannelLogo'. Resolve it by id lookup, never by searching the list for the first ChannelLogo entry: getWatermarks() sorts by name (web/src/api/pickers.ts:14), so a search silently binds to the alphabetically-first logo-driven preset. Since the design explicitly invites users to create a second preset for per-channel geometry, that search would (a) show the toggle OFF for a channel that is logo-driven and (b) repoint the channel on tick — a data-changing action from a control documented as a pure reflection.

  • Tick-on target is findLogoBugWatermark(...): prefer the preset named exactly Channel Bug, else the first ChannelLogo preset, else null. With several logo-driven presets imageSource alone cannot identify the default, so the name is a deterministic tiebreak — not the identification mechanism.

  • Loading and re-saving a channel without touching the toggle must leave watermarkId byte-identical, including when it points at a non-ChannelLogo watermark or at a ChannelLogo preset that is not the seeded one.

  • The existing watermark <Select> stays — the toggle is a shortcut, not a replacement.

  • Step 1: Write the failing test

Append to web/src/screens/ChannelEditScreen.test.tsx (match the file's existing mock/fixture helpers — read them first; the watermark list fixture must now include imageSource):

  it('ticks the logo-bug toggle when the channel uses a ChannelLogo watermark', async () => {
    // watermark list fixture must include { id: 9, name: 'Channel Bug', imageSource: 'ChannelLogo' }
    // and the channel fixture must have watermarkId: 9
    mockApi();
    render(<ChannelEditScreen />);

    fireEvent.click(await screen.findByRole('tab', { name: /Branding/ }));

    expect(await screen.findByRole('switch', { name: /Use logo as on-screen bug/ })).toHaveAttribute(
      'aria-checked',
      'true'
    );
  });

  it('clears watermarkId when the logo-bug toggle is switched off', async () => {
    const bodies: unknown[] = [];
    mockApi({
      onRequest: (url, method, body) => {
        if (url.startsWith('/api/v1/channels/') && method === 'PUT') {
          bodies.push(body);
        }
        return null;
      }
    });
    render(<ChannelEditScreen />);

    fireEvent.click(await screen.findByRole('tab', { name: /Branding/ }));
    fireEvent.click(screen.getByRole('switch', { name: /Use logo as on-screen bug/ }));
    fireEvent.click(screen.getByRole('button', { name: /Save/ }));

    await waitFor(() => expect(bodies).toHaveLength(1));
    expect((bodies[0] as { watermarkId: null | number }).watermarkId).toBeNull();
  });

  // Regression: a search-by-imageSource implementation binds to the alphabetically-first
  // ChannelLogo preset (getWatermarks sorts by name), silently repointing this channel.
  it('reflects a non-first ChannelLogo preset without repointing the channel', async () => {
    const bodies: unknown[] = [];
    mockApi({
      onRequest: (url, method, body) => {
        if (url === '/api/v1/watermarks' && method === 'GET') {
          return jsonResponse([
            { id: 4, imageSource: 'ChannelLogo', name: 'Alpha Bug' },
            { id: 9, imageSource: 'ChannelLogo', name: 'Channel Bug' }
          ]);
        }
        if (url.startsWith('/api/v1/channels/') && method === 'PUT') {
          bodies.push(body);
        }
        return null;
      }
    });
    // channel fixture must have watermarkId: 9 (the NON-first preset by name order)
    render(<ChannelEditScreen />);

    fireEvent.click(await screen.findByRole('tab', { name: /Branding/ }));
    expect(screen.getByRole('switch', { name: /Use logo as on-screen bug/ })).toHaveAttribute(
      'aria-checked',
      'true'
    );

    fireEvent.click(screen.getByRole('button', { name: /Save/ }));
    await waitFor(() => expect(bodies).toHaveLength(1));
    expect((bodies[0] as { watermarkId: null | number }).watermarkId).toBe(9);
  });

  // The guarantee protecting the existing production channels.
  it('leaves watermarkId unchanged when an untouched channel is re-saved', async () => {
    const bodies: unknown[] = [];
    mockApi({
      onRequest: (url, method, body) => {
        if (url === '/api/v1/watermarks' && method === 'GET') {
          return jsonResponse([
            { id: 2, imageSource: 'Custom', name: 'Sponsor bug' },
            { id: 9, imageSource: 'ChannelLogo', name: 'Channel Bug' }
          ]);
        }
        if (url.startsWith('/api/v1/channels/') && method === 'PUT') {
          bodies.push(body);
        }
        return null;
      }
    });
    // channel fixture must have watermarkId: 2 (a NON-ChannelLogo watermark)
    render(<ChannelEditScreen />);

    fireEvent.click(await screen.findByRole('tab', { name: /Branding/ }));
    expect(screen.getByRole('switch', { name: /Use logo as on-screen bug/ })).toHaveAttribute(
      'aria-checked',
      'false'
    );

    // change something unrelated so the form is dirty and Save is enabled
    fireEvent.click(screen.getByRole('tab', { name: /General/ }));
    fireEvent.change(screen.getByDisplayValue(/./), { target: { value: 'Renamed channel' } });
    fireEvent.click(screen.getByRole('button', { name: /Save/ }));

    await waitFor(() => expect(bodies).toHaveLength(1));
    expect((bodies[0] as { watermarkId: null | number }).watermarkId).toBe(2);
  });

If the Branding section is selected by something other than role="tab", read SECTIONS/the nav markup and use the real selector. For the last test, read the General pane and pick a concrete, unambiguous field to dirty (the placeholder getByDisplayValue(/./) above will match several inputs — replace it with the channel-name input's real selector).

  • Step 2: Run the tests to verify they fail
cd web && npx vitest run src/screens/ChannelEditScreen.test.tsx

Expected: FAIL — no element with role switch named "Use logo as on-screen bug".

  • Step 3: Implement the toggle

5a. Add the shared selector to web/src/api/watermarks.ts and export it from the '../api' barrel:

// Identifies THE default logo-driven preset. imageSource alone is not enough once a second
// ChannelLogo preset exists (the design invites creating one for per-channel geometry), and
// getWatermarks() sorts by name — so prefer the seeded name, then fall back deterministically.
export function findLogoBugWatermark<T extends { id: number; imageSource: string; name: null | string }>(
  watermarks: T[]
): T | null {
  const logoDriven = watermarks.filter((watermark) => watermark.imageSource === 'ChannelLogo');
  return logoDriven.find((watermark) => watermark.name === 'Channel Bug') ?? logoDriven[0] ?? null;
}

5b. Add BugPreview to the existing '../components' import (line 15 — Switch is already imported) and findLogoBugWatermark, getWatermark to the '../api' import. Inside BrandingPane, after the previewSrc line (:673), add:

  // Reflect the referenced row — never a search — so a channel bound to a second ChannelLogo
  // preset reads correctly and is never silently repointed.
  const referenced = data.watermarks.find((watermark) => watermark.id === draft.watermarkId) ?? null;
  const logoBugEnabled = referenced?.imageSource === 'ChannelLogo';
  const logoBugTarget = findLogoBugWatermark(data.watermarks);
  // An external-URL logo is resolved by WatermarkSelector to the URL itself and then File.Exists-ed,
  // which is never true, so no bug renders (#502). Don't promise one in the preview.
  const externalUrlLogo = trimmedUrl.length > 0;

  const [bugGeometry, setBugGeometry] = useState<null | {
    horizontalMargin: number;
    location: BugPreviewGeometry['location'];
    opacity: number;
    size: BugPreviewGeometry['size'];
    verticalMargin: number;
    width: number;
  }>(null);

  useEffect(() => {
    if (!logoBugEnabled || draft.watermarkId == null) {
      setBugGeometry(null);
      return;
    }

    let cancelled = false;
    void getWatermark(draft.watermarkId)
      .then((watermark) => {
        if (!cancelled) {
          setBugGeometry({
            horizontalMargin: watermark.horizontalMargin,
            location: watermark.location,
            opacity: watermark.opacity,
            size: watermark.size,
            verticalMargin: watermark.verticalMargin,
            width: watermark.width
          });
        }
      })
      .catch(() => {
        if (!cancelled) {
          setBugGeometry(null);
        }
      });

    return () => {
      cancelled = true;
    };
  }, [draft.watermarkId, logoBugEnabled]);

Import useEffect/useState (already imported at :1) and type BugPreviewGeometry from '../components'.

5c. Insert this Row immediately after the "External logo URL" Row and before the "Watermark" Row:

        <Row
          control={340}
          help={
            logoBugTarget == null
              ? 'No logo-driven watermark preset exists yet.'
              : externalUrlLogo
                ? 'An external logo URL cannot be used as the on-screen bug — upload an image instead (see #502).'
                : 'Overlays this channels own logo on the stream, using the shared presets position and size.'
          }
          label="Use logo as on-screen bug"
        >
          <div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
            <Switch
              checked={logoBugEnabled}
              disabled={hlsDirect || logoBugTarget == null}
              label="Use logo as on-screen bug"
              onChange={(next) => set({ watermarkId: next ? (logoBugTarget?.id ?? null) : null })}
              size="sm"
            />
            {logoBugEnabled && bugGeometry && previewSrc && !externalUrlLogo && (
              <BugPreview {...bugGeometry} src={previewSrc} />
            )}
          </div>
        </Row>

Check the rendered result for a duplicated caption — the Row supplies a visible label and Switch also renders its label prop when set. If both show, keep Switch's for accessibility but render it visually hidden, or pass the accessible name another way; do not simply delete it, as the tests select the switch by accessible name.

  • Step 4: Run the tests to verify they pass
cd web && npx vitest run src/screens/ChannelEditScreen.test.tsx

Expected: PASS.

  • Step 5: Commit
git add web/src/screens/ChannelEditScreen.tsx web/src/screens/ChannelEditScreen.test.tsx
git commit -m "feat(67): add Use logo as on-screen bug toggle with preview to the channel editor"

Task 6: Default new channels to the logo-bug preset

Files:

  • Modify: web/src/screens/ChannelsScreen.tsx:332-387 (createBlankChannel)
  • Test: web/src/screens/ChannelsScreen.test.tsx (extend)

Interfaces:

  • Consumes: getWatermarks() (web/src/api/pickers.ts) returning Watermark[] with imageSource (Task 2), and findLogoBugWatermark() from Task 5a. Use that shared helper — do not re-implement the lookup here, or the two paths will drift apart (a plain .find(imageSource === 'ChannelLogo') binds to the alphabetically-first preset, since getWatermarks() sorts by name).

  • Behavior: the create body's watermarkId becomes the logo-driven preset's id when one exists, else stays null. Never fail channel creation because the lookup failed — a watermark-list error must degrade to null, not abort.

  • Scope note — do NOT also change web/src/builder/ChannelBuilder.tsx. That flow inherits watermarkId from the selected ChannelTemplate (:1025 eff('watermarkId'), :2046), so "defaulting" it would mean stamping the preset onto the seeded Standard / Music videos template rows. Those are user-editable and the seed's name-guard leaves them alone on existing installs, so the change would be a no-op on production while mutating fresh installs' data. Template-driven inheritance is the intended mechanism there. See the spec's §4 exclusion.

  • Step 1: Write the failing test

Append to web/src/screens/ChannelsScreen.test.tsx (read its existing mockApi helper and extend the fetch mock to serve /api/v1/watermarks):

  it('defaults a newly created channel to the logo-bug watermark preset', async () => {
    const bodies: unknown[] = [];
    mockApi({
      onRequest: (url, method, body) => {
        if (url === '/api/v1/watermarks' && method === 'GET') {
          return jsonResponse([{ id: 9, imageSource: 'ChannelLogo', name: 'Channel Bug' }]);
        }
        if (url === '/api/v1/channels' && method === 'POST') {
          bodies.push(body);
          return jsonResponse({ id: 42 }, 201);
        }
        return null;
      }
    });
    render(<ChannelsScreen />);

    fireEvent.click(await screen.findByRole('button', { name: /New Channel/ }));

    await waitFor(() => expect(bodies).toHaveLength(1));
    expect((bodies[0] as { watermarkId: null | number }).watermarkId).toBe(9);
  });

it('still creates the channel when the watermark lookup fails', async () => { const bodies: unknown[] = []; mockApi({ onRequest: (url, method, body) => { if (url === '/api/v1/watermarks' && method === 'GET') { return new Response(null, { status: 500 }); } if (url === '/api/v1/channels' && method === 'POST') { bodies.push(body); return jsonResponse({ id: 42 }, 201); } return null; } }); render();

fireEvent.click(await screen.findByRole('button', { name: /New Channel/ }));

await waitFor(() => expect(bodies).toHaveLength(1));
expect((bodies[0] as { watermarkId: null | number }).watermarkId).toBeNull();

});


Use the real button label from the screen — read it, do not assume `/New Channel/`.

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

```bash
cd web && npx vitest run src/screens/ChannelsScreen.test.tsx

Expected: FAIL — expected null to be 9.

  • Step 3: Implement the default

Add findLogoBugWatermark and getWatermarks to the existing import from '../api'. In createBlankChannel, after the getFfmpegSettings() call, add:

      // New channels default to the seeded logo-driven preset so one uploaded logo drives both the
      // guide listing and the on-screen bug (#67). A lookup failure must not block channel creation.
      let watermarkId: null | number = null;
      try {
        watermarkId = findLogoBugWatermark(await getWatermarks())?.id ?? null;
      } catch {
        watermarkId = null;
      }

and change the body's last field from watermarkId: null to watermarkId.

  • Step 4: Run the tests to verify they pass
cd web && npx vitest run src/screens/ChannelsScreen.test.tsx

Expected: PASS, including the pre-existing tests.

  • Step 5: Commit
git add web/src/screens/ChannelsScreen.tsx web/src/screens/ChannelsScreen.test.tsx
git commit -m "feat(67): default new channels to the logo-bug watermark preset"

Task 7: Documentation

Files:

  • Modify: docs/channels.md:140-144 (Watermarks section)

  • Modify: docs/decisions.md (append a dated entry — append-only)

  • Modify: docs/api-conventions.md (note the additive imageSource field)

  • Step 1: Update docs/channels.md

Replace the Watermarks section body with:

## Watermarks

`ChannelWatermark` supports modes: Permanent, Intermittent, OpacityExpression. Image sources: custom
upload, channel logo, or built-in resource. Positioned with percentage-based margins and z-index.

A watermark is a **shared, named entity** (unique `Name`), referenced by playout items, schedule
items, block items and decos; a channel points at one via `Channel.WatermarkId`. It is not
per-channel state.

`DbInitializer` seeds one shared preset named **`Channel Bug`** (`ImageSource = ChannelLogo`,
Permanent, TopLeft, Scaled 5% width, 1%/1% margins, 80% opacity). Because `ChannelLogo` resolves
each channel's own `ArtworkKind.Logo` artwork at render time, this single row makes every channel
that points at it use its own logo as its on-screen bug — one uploaded image drives both the guide
listing and the bug (#67). An existing `Channel Bug` row is adopted untouched, never overwritten, and
a `ConfigElement` marker (`watermark.channel_bug_seeded`) makes the seed run once per database, so a
deliberately deleted preset is not resurrected on the next restart.

Quick-add channel creation defaults to the preset, and the channel editor's Branding tab exposes it
as a "Use logo as on-screen bug" toggle. On a **fresh** install the seed also stamps the preset onto
the system channel templates it creates, so the library-to-lineup builder (which inherits
`WatermarkId` from the selected template) gets the default too. On an existing install the templates
are left alone, so builder-created and auto-tuned channels there inherit whatever the template
already specifies.

**Limitation:** a logo set via **External logo URL** cannot drive the bug. `WatermarkSelector`
resolves it to the URL and then `File.Exists`-checks it, which is never true, so the watermark is
silently dropped — the URL wins for the guide listing but disables the on-screen bug. Tracked as
**#502**; the editor does not offer a bug preview in that case.

Note: `ChannelLogoGenerator.GenerateChannelLogoUrl()` hardcodes `localhost` for watermark logo
fetching — see issue #1 for details.
  • Step 2: Append to docs/decisions.md

Append at the end of the file (append-only; if it conflicts on rebase, re-append — never hand-merge):

## 2026-07-20 — One logo drives the bug via a shared ChannelLogo preset, not new schema (#67)

#67 asked that one uploaded image drive both the listing logo and the on-screen bug, separably
overridable, with preview. Most of it already existed: `ChannelWatermarkImageSource.ChannelLogo`
resolves the channel's own logo artwork at render time at all three watermark precedence levels, and
`Custom` already provides the independent override. Production had already been running exactly this
pattern by hand — 43 channels pointing at one hand-made `Channel Bug` preset.

**Decision: seed that preset rather than add per-channel bug columns.** A watermark is a shared named
entity, so per-channel geometry would need either a dual-provider migration or one watermark row per
channel (under a unique-name index). Since `ChannelLogo` resolves per channel at render time, a single
shared row already delivers the user-visible behavior with no schema change.

- The seed **adopts** an existing `Channel Bug` row untouched, so an operator's tuned geometry is never
  overwritten, and a `ConfigElement` marker (`watermark.channel_bug_seeded`) makes it run once per
  database rather than once per name-absence — `ChannelWatermark` has no `IsSystem` flag and
  `DbInitializer.Initialize` runs at every startup, so a name-only guard would resurrect a deliberately
  deleted preset forever. Covered by `DbInitializerChannelBugWatermarkTests`.
- `WatermarkResponseModel` gained `imageSource` (additive under the frozen `/api/v1`, #286) so clients
  identify logo-driven presets generically instead of matching a user-editable name. Note the limit:
  once a *second* logo-driven preset exists, `imageSource` identifies the class but not *the* default,
  so `findLogoBugWatermark` prefers the seeded name as a deterministic tiebreak.
- The default is applied by the SPA's quick-add **creation** path, not by inferring "newness" in the
  editor — quick-add creates through the API and then navigates to the editor, so the editor only ever
  loads an existing row. The editor toggle reflects the **referenced** watermark's `imageSource`; an
  earlier draft searched the list instead, which (because `getWatermarks()` sorts by name) would have
  silently repointed channels bound to a non-first logo-driven preset. Caught in independent review
  and pinned by a regression test.
- The builder flow is covered on **fresh installs only**, by stamping the preset onto the system
  channel templates the seed itself creates; existing installs' templates are never mutated.
- Server-side create defaulting was rejected: making an omitted `watermarkId` mean "give me a
  watermark" would surprise machine clients of the frozen API. `POST /api/v1/channels/auto-tune` is
  therefore unchanged.
- **Not fixed here:** external-URL logos never render a bug (`WatermarkSelector` `File.Exists`-checks a
  URL). Pre-existing, lands in the FFmpeg render path, tracked as **#502**. This change only stops the
  preview from promising it.

**Accepted trade-off:** every channel on the shared preset shares one geometry; per-channel tweaks mean
creating a second preset on the Watermarks screen.
  • Step 3: Note the DTO change in docs/api-conventions.md

In the artwork/watermark area (near §4, :284-306), add:

`GET /api/v1/watermarks` returns picker-grade rows that carry `imageSource` alongside `id`/`name`
(#67), so a client can find the seeded logo-driven `Channel Bug` preset without matching its
user-editable name. The full geometry still requires `GET /api/v1/watermarks/{id}`.
  • Step 4: Full verification gate
dotnet build ErsatzTV.sln
dotnet test ErsatzTV.Tests/ErsatzTV.Tests.csproj
cd web && npm test -- --run && npm run lint && npm run typecheck && npm run build && npm run check:api && cd ..
for f in $(git diff --name-only origin/main...HEAD -- '*.cs'); do head -c3 "$f" | xxd -p | grep -q "^efbbbf" && echo "BOM: $f"; done
bash -c 'mapfile -t files < <(git diff --name-only --diff-filter=ACM origin/main...HEAD -- "*.cs")
dotnet format ErsatzTV.sln --no-restore --verify-no-changes --include "${files[@]}"'

Expected: all green; the BOM loop prints nothing; dotnet format exits 0. Note dotnet format MUST run under bash -cmapfile is bash-only and the default shell here is zsh, which silently yields an empty file list.

  • Step 5: Commit
git add docs/
git commit -m "docs(67): record the shared-preset decision, watermark seeding, and the additive DTO field"

Post-implementation (orchestrator, not the task worker)

  1. Push and open the PR against main; arm a CI monitor on the head sha at PR-open (commit-status endpoint), not at the end.
  2. Run a cold-context adversarial review scoped "review only" over the full diff. The diff touches a DTO and a seeding path but no locks/auth/migrations and is well under ~150 changed C# lines — an independent pass is still worth it for the seed-adoption logic.
  3. Live-E2E via scripts/e2e-local.sh: create a channel, upload a logo, confirm the toggle is on and the bug renders; confirm an existing channel's watermarkId is unchanged after a no-op save.
  4. Post Review-verdict: <MERGEABLE|APPROVED|BLOCKED> @ <head-sha> on the PR (H10 convention).
  5. Tick the ## Done-when boxes on #67 as evidence lands, then let the derived-consent gate decide the merge.