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
52 KiB
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, branchfeat/67-unified-logo-bug, branched offorigin/main. Never commit in/Users/timothy/ersatztv(shared mutable tree). - Test framework is NUnit + Shouldly + NSubstitute. Never xUnit.
/api/v1is 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.csandErsatzTV.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(NOTwidthPercent/*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), enumsChannelWatermarkMode/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; aChannelWatermarkrow namedChannel BugwithImageSource = ChannelLogo; freshly seededChannelTemplaterows carrying itsWatermarkId; aConfigElementrow keyedwatermark.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 asWatermark.imageSource("Custom" | "ChannelLogo" | "Resource") inweb/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
bugPreviewStyleso 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:
BugPreviewfrom Task 3; the existingDraft(=CreateWatermarkRequest) fieldsimage,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.tsx—BrandingPane(:646-760) - Test:
web/src/screens/ChannelEditScreen.test.tsx(extend)
Interfaces:
- Consumes:
BugPreview(Task 3);Watermark.imageSource(Task 2);getWatermark(id)fromweb/src/api/watermarks.ts; existingdata.watermarks,draft.watermarkId,previewSrc,set(). - Produces, also used by Task 6 — add to
web/src/api/watermarks.tsand 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.watermarkIdpoints at hasimageSource === 'ChannelLogo'. Resolve it by id lookup, never by searching the list for the firstChannelLogoentry: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 exactlyChannel Bug, else the firstChannelLogopreset, elsenull. With several logo-driven presetsimageSourcealone 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
watermarkIdbyte-identical, including when it points at a non-ChannelLogowatermark or at aChannelLogopreset 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 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
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) returningWatermark[]withimageSource(Task 2), andfindLogoBugWatermark()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, sincegetWatermarks()sorts by name). -
Behavior: the create body's
watermarkIdbecomes the logo-driven preset's id when one exists, else staysnull. Never fail channel creation because the lookup failed — a watermark-list error must degrade tonull, not abort. -
Scope note — do NOT also change
web/src/builder/ChannelBuilder.tsx. That flow inheritswatermarkIdfrom the selectedChannelTemplate(:1025eff('watermarkId'),:2046), so "defaulting" it would mean stamping the preset onto the seededStandard/Music videostemplate 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 additiveimageSourcefield) -
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 -c — mapfile 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)
- 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. - 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.
- 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'swatermarkIdis unchanged after a no-op save. - Post
Review-verdict: <MERGEABLE|APPROVED|BLOCKED> @ <head-sha>on the PR (H10 convention). - Tick the
## Done-whenboxes on #67 as evidence lands, then let the derived-consent gate decide the merge.