# 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:
  ```bash
  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):**
```bash
# 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`:

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

```bash
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:

```csharp
    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:

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

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

```csharp
    // 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`:

```csharp
    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`):

```csharp
        bool randomStartPoint,
        int? watermarkId) =>
```

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

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

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

```bash
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`:

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

```bash
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:

```csharp
#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:

```csharp
    internal static WatermarkResponseModel ProjectToResponseModel(ChannelWatermark watermark) =>
        new(watermark.Id, watermark.Name, watermark.ImageSource);
```

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

```bash
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`).

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

```bash
cd web && npm run check:api && cd ..
```
Expected: exit 0, no diff.

- [ ] **Step 7: Strip the BOM and commit**

```bash
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:
  ```ts
  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`:

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

```bash
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`:

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

```bash
cd web && npx vitest run src/components/bugPreview.test.tsx
```
Expected: PASS, 10 tests.

- [ ] **Step 5: Commit**

```bash
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`:

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

```bash
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):

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

with:

```tsx
            {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**

```bash
cd web && npx vitest run src/screens/WatermarksScreen.test.tsx
```
Expected: PASS.

- [ ] **Step 5: Commit**

```bash
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.tsx` — `BrandingPane` (`: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:
  ```ts
  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`):

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

```bash
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:

```ts
// 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:

```tsx
  // 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:

```tsx
        <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 channel’s own logo on the stream, using the shared preset’s 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**

```bash
cd web && npx vitest run src/screens/ChannelEditScreen.test.tsx
```
Expected: PASS.

- [ ] **Step 5: Commit**

```bash
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`):

```tsx
  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(<ChannelsScreen />);

    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:

```tsx
      // 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**

```bash
cd web && npx vitest run src/screens/ChannelsScreen.test.tsx
```
Expected: PASS, including the pre-existing tests.

- [ ] **Step 5: Commit**

```bash
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:

```markdown
## 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):

```markdown
## 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:

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

```bash
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 -c` — `mapfile` is bash-only and the default shell here is zsh, which silently yields an empty file list.

- [ ] **Step 5: Commit**

```bash
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.
