feat(732): On Now / Next gets a background box, and is on by default #843

Merged
timothy merged 16 commits from feat/732-graphics-text-background into main 2026-08-26 21:28:26 +02:00
Owner

fixes #732

What shipped

Part 1 — a background box for text graphics elements. New element-level background_color,
background_opacity_percent, background_padding, background_corner_radius, border_color and
border_width, drawn as a rounded rect behind the text block. Element level rather than per-style
because the engine lays the whole element out as one RichTextKit TextBlock and rasterises it into
one bitmap — one box per element is the only shape the renderer can express.

The seeded on-now-next.yml adopts it. The halo drops 2px→1px rather than to zero, and the box
carries a low-alpha white border. That was a visual call made against real rendered frames: a 65%
black fill is legible over bright and midtone content but vanishes entirely into dark content, which
is why the issue asked for an outlined box.

Because #74's seeder writes the template only when absent, an already-seeded install would never have
seen any of this. The seeder now keeps every default it has shipped as a verbatim fingerprint and
rewrites the file only when it still matches one — write-then-move, never in place.

Part 2 — on by default. New channels get the built-in element from a shared
ChannelGraphicsDefaults.Attach called by both create paths; pre-existing channels get a
one-time backfill.

The issue's open question, resolved — and not as it guessed

[vge] in a filter graph does not imply a graphics element is bound. Watermarks fall back onto
the graphics engine whenever they cannot use ffmpeg's native overlay shortcut, so a watermark alone
produces that stage. Documented in docs/graphics-elements.md.

Separately, the issue's premise that all five binding tables were empty is stale: measured 2026-08-26,
all 43 prod channels already carried the element by operator action.

Verification

  • 4,529 tests green across all seven projects.
  • 32 mutation rows, each measured against the whole ErsatzTV.Tests project (a per-fixture
    filter cannot see a cross-fixture red); table in docs/graphics-elements.md.
  • Live E2E: fresh install · pre-#74 upgrade backfill · read-only template boots · 3-boot row
    idempotence · V1→current template upgrade · both create paths attaching through the real API ·
    and a captured HLS stream frame showing the box rendering over colour bars mid-fade.
  • Seven adversarial review rounds, two independent reviewers per round (round 7 single-reviewer —
    Codex hit its usage limit). Rounds 1–6 all BLOCKED; round 7 MERGEABLE.

Behaviour changes worth release notes

  • A custom template whose non-base style declares its own halo_* now renders with that halo.
    It was silently inheriting the base style's — a pre-existing bug this fixes. The seeded template is
    unaffected (its three styles declare identical halos).
  • text_fit: Scale now carries line_height through, which changes measured height and therefore
    where the element anchors.
  • The template upgrade is a one-way door: the loader rejects unknown YAML keys, so rolling back to
    a build without these fields drops the overlay on every channel still carrying it.

Docs

New docs/graphics-elements.md (schema reference + parsing traps + mutation record), two decision
records under docs/decisions/records/graphics/, plus docs/README.md, the catalog and
docs/testing.md (the CI font dependency the new pixel tests rely on is now declared rather than
incidental).

fixes #732 ## What shipped **Part 1 — a background box for text graphics elements.** New element-level `background_color`, `background_opacity_percent`, `background_padding`, `background_corner_radius`, `border_color` and `border_width`, drawn as a rounded rect behind the text block. Element level rather than per-style because the engine lays the whole element out as one RichTextKit `TextBlock` and rasterises it into one bitmap — one box per element is the only shape the renderer can express. The seeded `on-now-next.yml` adopts it. The halo drops 2px→1px rather than to zero, and the box carries a low-alpha white border. That was a visual call made against **real rendered frames**: a 65% black fill is legible over bright and midtone content but vanishes entirely into dark content, which is why the issue asked for an *outlined* box. Because #74's seeder writes the template only when absent, an already-seeded install would never have seen any of this. The seeder now keeps every default it has shipped as a verbatim fingerprint and rewrites the file only when it still matches one — write-then-move, never in place. **Part 2 — on by default.** New channels get the built-in element from a shared `ChannelGraphicsDefaults.Attach` called by **both** create paths; pre-existing channels get a one-time backfill. ## The issue's open question, resolved — and not as it guessed `[vge]` in a filter graph does **not** imply a graphics element is bound. Watermarks fall back onto the graphics engine whenever they cannot use ffmpeg's native overlay shortcut, so a watermark alone produces that stage. Documented in `docs/graphics-elements.md`. Separately, the issue's premise that all five binding tables were empty is stale: measured 2026-08-26, all 43 prod channels already carried the element by operator action. ## Verification - **4,529 tests green** across all seven projects. - **32 mutation rows**, each measured against the **whole** `ErsatzTV.Tests` project (a per-fixture filter cannot see a cross-fixture red); table in `docs/graphics-elements.md`. - **Live E2E**: fresh install · pre-#74 upgrade backfill · read-only template boots · 3-boot row idempotence · V1→current template upgrade · both create paths attaching through the real API · and a captured **HLS stream frame** showing the box rendering over colour bars mid-fade. - **Seven adversarial review rounds**, two independent reviewers per round (round 7 single-reviewer — Codex hit its usage limit). Rounds 1–6 all BLOCKED; round 7 MERGEABLE. ## Behaviour changes worth release notes - A custom template whose **non-base** style declares its own `halo_*` now renders with that halo. It was silently inheriting the base style's — a pre-existing bug this fixes. The seeded template is unaffected (its three styles declare identical halos). - `text_fit: Scale` now carries `line_height` through, which changes measured height and therefore where the element anchors. - The template upgrade is a **one-way door**: the loader rejects unknown YAML keys, so rolling back to a build without these fields drops the overlay on every channel still carrying it. ## Docs New `docs/graphics-elements.md` (schema reference + parsing traps + mutation record), two decision records under `docs/decisions/records/graphics/`, plus `docs/README.md`, the catalog and `docs/testing.md` (the CI font dependency the new pixel tests rely on is now declared rather than incidental).
timothy added 16 commits 2026-08-26 20:40:34 +02:00
Part 1 - background box. TextGraphicsElement gains element-level background_color,
background_opacity_percent, background_padding, background_corner_radius, border_color and
border_width; TextElement draws a rounded rect behind the text block and insets the text into it.
Element level rather than per-style because the engine lays the whole element out as one
RichTextKit TextBlock and rasterises it into one bitmap, so one box per element is the only shape
the renderer can express. width_percent keeps bounding the visible element, so the insets are
subtracted from the text's wrap/scale budget.

The seeded on-now-next.yml adopts it. The halo drops from 2px to 1px rather than to zero, and the
box carries a low-alpha white border: rendered against real frames, a 65% black fill is legible on
bright and midtone content but vanishes entirely into dark content, which is why the issue asked
for an *outlined* box. The halo still matters because the fill is translucent.

Because #74's seeder writes the template only when absent, an already-seeded install would never
have seen any of this. GraphicsElementSeeder now keeps every default it has shipped as a verbatim
fingerprint and rewrites the file only when it still matches one (line-endings normalised), so an
untouched install upgrades and an operator-edited one is left alone. The V1 fingerprint was checked
byte-for-byte against the live install before being trusted - a fingerprint that matches nothing in
the field is a permanent silent no-op.

Part 2 - on by default. New channels get the built-in element from CreateChannelHandler (CreateChannel
carries no graphics field and the SPA's channel editor is update-only, so the server is the only
place a create-time default can live). Pre-existing channels get a one-time AttachOnNowNextByDefault
backfill. The marker is written on the first run even when nothing was attached: the tempting
alternative - stay armed until the element row exists - would fire on a later boot and re-attach the
overlay to a channel the operator had cleared in between. HLS Direct is excluded at both sites,
where the overlay cannot render at all.

Resolves the issue's open question: [vge] in a filter graph does not imply a graphics element is
bound. Watermarks fall back onto the graphics engine whenever they cannot use ffmpeg's native
overlay shortcut, so a watermark alone produces that stage.

Tests assert rendered pixels, because every failure mode here is a silent no-op that a model-level
test cannot see. 13 clause mutations were executed against them; each reddened only its own clause.

Decisions-Edit: yes
Refs: #732
Two independent cold reviews (Codex GPT-5.6 and a worktree-isolated Opus agent) converged on the
same three serious problems; both returned BLOCKED.

Boot safety. The already-seeded branch of SeedOnNowNext used to touch the filesystem not at all,
so a template the app cannot read - one edited as root via `docker exec` while the app runs under
PUID/PGID, or a read-only /config - was harmless. Reading and rewriting it there put unguarded IO
ahead of DatabaseIsReady() in a BackgroundService whose default behavior is StopHost, i.e. a
restart loop triggered by a file permission an operator can set by hand. The upgrade is now
best-effort (broad catch, cancellation still propagates) and CreateDirectory moved back below the
already-seeded check.

Stranding. The backfill wrote its marker even when the built-in GraphicsElement row was merely
undiscovered - the row is normally created by RefreshGraphicsElements, which runs long after
startup. An install upgrading from before #74 seeds the template and backfills on the same boot,
so every pre-existing channel would have been left unattached forever, with no second chance. The
seeder now creates the row itself, so the marker is only ever written against a resolved element.
The test that asserted the old behavior is replaced by one that pins the upgrade case.

Overclaimed records. The never-re-attach guarantee holds from the marker onwards, not across the
upgrade boundary: an operator who cleared the overlay before this landed left no state that
survives, and the one-time backfill re-adds it. That is inherent to "on by default" rather than a
defect, but the record asserted otherwise. Also recorded: the upgrade is a one-way door, because
the loader rejects unknown YAML keys, so a rollback drops the overlay on every channel at once.

Smaller, all with tests: the wrap budget now subtracts the same integer inset the bitmap grows by
(a fractional padding overflowed width_percent by a pixel); an inset that cannot fit is clamped
instead of squeezing the text to 1px and letting the box grow unbounded; FitTextBlock carries halo
and line height across, which only became reachable once a box's insets could push an element into
the Scale path; the built-in lookup filters on Kind so a same-named element in another template
folder cannot win; CreateChannelHandler adds to the collection rather than assigning over it.

width_percent is documented as a budget rather than a guarantee - wrapping cannot break below one
glyph, so it overflows with or without a box. The doc also no longer claims an unparseable colour
is never substituted (true for the box, false for text_color) or that an unset background_color
means no box (a border alone draws one).

The mutation claim is now reproducible: all 19 clause mutations are listed in
docs/graphics-elements.md with the test each one reddens. Two candidate mutations produced NO test
summary because they failed to compile under warnings-as-error, which reads exactly like a pass in
a scrolled log - that is called out there too.

The new pixel tests are the first in the repo to rasterise text, so the CI image's font dependency
is now declared in docs/testing.md rather than incidental (351 fonts present in the pinned image,
measured; they arrive via playwright install --with-deps).

Decisions-Edit: yes
Refs: #732
Both round-2 reviewers returned BLOCKED, and the sharpest findings were defects the previous fix
commit introduced rather than ones it missed.

The mechanism that fix added was untested. Deleting EnsureBuiltInElementRow from the ALREADY-SEEDED
branch left the whole 2054-test suite green - and that is the branch every install carrying #74's
marker takes on its first #732 boot, i.e. exactly the population the fix exists for. The previous
commit message claimed the marker "is only ever written against a resolved element"; that was proven
only for the fresh-seed minority. Same shape for the duplicate guard inside it: without the guard the
already-seeded branch inserts a row on every boot, RefreshGraphicsElements neither reaps nor dedupes
them, and the SPA list fills with copies. Both now have tests, and both were confirmed to redden.

That claim was also still false. With the template file absent, EnsureBuiltInElementRow returns
without creating a row and the marker was written anyway - a narrower version of the stranding bug
round 1 found. The backfill now stays armed when nothing resolves. This is safe in a way it was not
in round 1: EnsureBuiltInElementRow closed the "row appears later" window that argued against it.

The clamp only shrank insetPixels, not the box. DrawBackgroundBox kept stroking at the original
border width, centred on a rect with no room for it, so an oversized border flooded the element and
painted over its interior. The box's own fields are clamped now, border first.

The catch was over-corrected. Round 1 asked for it to be broader; everything-but-cancellation
swallows genuinely fatal failures and boots a compromised process. It is a named filesystem set
again, plus a carve-out for an OperationCanceledException raised while the token is NOT cancelled -
that one is just a faulty read, and letting it escape is the restart loop the catch exists to stop.

The template rewrite was not atomic: WriteAllTextAsync truncates first, so an interrupted write left
a partial file matching no fingerprint, which the upgrade could then never repair, on a template now
attached to every channel. Write-then-move, with the temp file cleaned up on failure.

Also: YAML doubles like 1e100 and NaN reach float/int casts, so box dimensions are sanitized at the
boundary; the built-in row is created with a Name so it does not sort into the unnamed bucket in the
SPA until the first refresh; CreateChannelHandler threads the real cancellation token.

Decisions-Edit: yes
Refs: #732
The table in docs/graphics-elements.md asserted 19 clause-level results carried over from notes. Two
were false: one named a test that had since been renamed, and one claimed a red for a clause whose
mutation reddened nothing. Every row is now generated from a fresh run against this tree, and the
"Reddens" column is the measured failure set rather than a prediction.

Re-measuring found two tests passing for the wrong reason.

The atomicity test could not see what it claimed. Testably raises its interception BEFORE truncating,
so a plain in-place WriteAllTextAsync leaves the original intact too and "the file survived" is true
either way - on a real filesystem it would not be. The test now captures which PATH the write targets
and requires it to be the temp file, which is what actually distinguishes the two, and reverting the
atomic write reddens it.

The non-finite width_percent test set no background fields, so the budget was never converted to an
int and the guard it was named for could be deleted with the test still green. It now carries a box.

The float.IsFinite guard on the width budget is recorded as NOT covered by any mutation: .NET
saturates float-to-int conversion, so removing it changes no observable behaviour. It stays as
explicit intent, said out loud rather than implied by omission.

Also recorded: warnings-as-error turn the obvious mutation shapes (if (true) return, self-assignment,
a constant-folded condition) into build failures that emit no test summary and read like a pass, and
detecting that needs `: error `, not `error CS`.

Decisions-Edit: yes
Refs: #732
The record was the blocker. Round 2 inverted the marker behaviour - it is now written only once the
built-in element RESOLVES - but graphics.on-now-next-on-by-default still stated the old rule in its
frontmatter and catalog row, and its rationale paragraph argued AGAINST the shipped design as "the
tempting alternative". A future session resolving that key would have been told the opposite of the
code. The XML remarks on AttachOnNowNextByDefault said the same thing. Both rewritten, catalog
regenerated.

The trade is now stated instead of asserted away. A single global marker cannot express both "never
re-add to a channel the operator cleared" and "never strand a channel that had nothing to attach".
While the backfill is armed - the built-in template is absent, so refresh has reaped its row - a
cleared channel is indistinguishable from an untouched one, and restoring the element attaches it
everywhere. That window is narrow, visible and reversible; stranding is silent and permanent, which
is why it is resolved this way. Closing it properly needs per-channel state, which is a schema change
and is not in this PR. A test now pins the accepted semantics rather than leaving them undiscovered.

The upgrade write hardened twice more: the temp name carries the process id (a fixed one is shared by
two containers on the same config volume, where one can rename it over the live template while the
other holds it open), and there is deliberately NO in-place fallback when the replace fails. A
bind-mounted single-file template cannot be renamed onto (EBUSY) and so will not be upgraded - which
needs a pinned file that is ALSO byte-identical to a shipped default, against reintroducing the
truncation on every IO fault. Recorded in the fingerprint record.

The inset is now clamped against the FRAME as well as against width_percent. Sanitize caps each field
alone, but padding and border add up, and with no width_percent nothing else bounded them - two
fat-fingered fields could allocate a bitmap far larger than the frame.

Two tests strengthened where they could not see what they claimed: the already-seeded row test
asserted only a count and a successful lookup, so dropping Name still satisfied it; and the
failed-write test faulted on the FIRST write, never reaching File.Move or the cleanup, so a
non-atomic Copy would have kept it green. A second test now faults the replace after a complete temp
write.

Decisions-Edit: yes
Refs: #732
The table asserted its "Reddens" column was measured, and it was - through
`--filter FullyQualifiedName~<fixture>`, which structurally cannot observe a red in another fixture.
A measurement with a blind spot presented as a measurement is worse than none, and it was the table's
own documented reproduce command that carried the blind spot.

Re-measured by running the whole ErsatzTV.Tests project per mutation. Eight of the twenty-four rows
redden more than one fixture and were previously under-reported - including the row a cold review
caught by hand, which reddens `An_Already_Seeded_Install_Missing_Its_Element_Row_Gets_One` AND
`Repeated_Seeding_Does_Not_Accumulate_Element_Rows`, not one of them.

The reproduce instruction now says to run the project unfiltered, and the reason is stated so the
next person does not reintroduce the filter for speed.

The mutation harness also grew a guard: it restores with `git checkout --`, so an uncommitted change
to a file it mutates was silently reverted. It now refuses to run when a mutated file is dirty.

Decisions-Edit: yes
Refs: #732
Both found by cold review, both the same shape: an assertion that is satisfied by the behaviour it
was written to rule out.

The armed/re-add test never performed the operator clear. It created the second channel with no
attachment at all, so it proved an UNTOUCHED channel gets backfilled - not that a deliberately
cleared one is re-added, which is the accepted trade the test exists to pin. It now attaches to both
channels, removes one join as the operator action, and then runs the still-armed backfill.

The replace-failure test could not tell an atomic replace from a truncating one. Testably raises its
interception before applying the change, so File.Copy(temp, target, true) also leaves the original
intact and also touches the temp path before the target - path ordering cannot distinguish them. The
target's change TYPE can: a rename reports Renamed, a copy reports Changed. Asserting that, and
substituting Copy for Move now reddens the test.

Mutation row 14 also overstated itself: it swapped IOException for FileLoadException while leaving
UnauthorizedAccessException in the filter, so it did not mean "no longer catches filesystem
exceptions" as labelled. It now replaces the whole filter. A cold review reasoned from the label that
the row's red set was missing a test; the label was the thing that was wrong.

Refs: #732
Round-4 review, both reviewers BLOCKED. The blocker was the same shape this branch keeps producing:
a completeness claim stated in words rather than derived.

The frame cap on the inset, added one round earlier, had NO test and NO mutation row. Disarming the
clause left the whole 2064-test suite green. The nearest candidate could not see it: its bound was
`baseline + 25_000` while the clause prevents growth of 20_000 on a 1080p frame, i.e. the assertion
was looser than the defect. Worse, the mutation record written the following commit asserted that
exactly one clause was uncovered, naming a different one - a false enumeration in the doc that exists
to prevent false enumerations. Now: a test pinning the cap, a tightened bound on the sibling, two
mutation rows, the cap documented in the geometry notes, and the "not covered" paragraph rewritten as
a claim that decays when a guard is added without its row.

`Environment.ProcessId` is 1 in every container. The image's ENTRYPOINT is exec form, so each
container is PID 1 in its own namespace and they all compute the same temp name - the mitigation was
inert in exactly the deployment the record cited for it. Two containers on one config volume could
still truncate each other's temp file and rename a partial one over the live template, which is the
outcome write-then-move exists to prevent. Random name now, record corrected.

Per-style halo was documented as working and silently discarded. The merge loop rebuilt each style
from base and overrode only font and colour, so a non-base style's halo_* inherited the base style's.
The seeded template hid it by declaring identical halos on all three styles. Three lines, symmetric
with the existing overrides, plus a differential test.

Also: an assertion on `_target + ".upgrade.tmp"` was unfalsifiable once the temp name gained a
suffix - a path the code can never create, passing with the cleanup gutted; a test named
An_Unreadable_Template actually simulates an unwritable one, renamed; a comment described the wrong
channel as cleared.

Decisions-Edit: yes
Refs: #732
Adding the frame-cap and per-style-halo clauses, renaming a test and tightening one assertion can all
move a red set, so the whole table was re-measured against the full project rather than patched.
Twenty-seven rows, all red, nine of them reddening more than one fixture. Only rows 5 and 14 actually
moved, and only by the test rename - the rest reproduced exactly, which is the first time the table
has been re-derived and found already correct.

Refs: #732
The halo fix changes a renderer that has already shipped, so the question is what it does to the one
template we ship. Measured rather than reasoned: the seeded on-now-next.yml renders to the same
bitmap with and without the fix (md5 0663AF1E13C558F4DB99F887318E275D, 1920x1080), because all three
of its styles declare the same halo - which is also why the bug was invisible. A third-party template
that varies halo per style will change, which is the point.

Refs: #732
The completeness sentence was wrong a third time - a different clause each round - so it is gone
rather than corrected again. "The only uncovered clause is X" silently becomes false the moment a
guard is added without its row, which is a property of the sentence, not of the reviewers who kept
falsifying it. The table now says what it IS (clauses with a measured proof) and explicitly makes no
claim about what is absent, with the known no-red clauses listed as examples rather than a closed set.

Cleanup could replace the exception that triggered it. A delete throwing while unwinding a
cancellation swapped the OperationCanceledException for an IOException, which the outer filter then
swallowed - downgrading a real shutdown to a warning, the one case that filter deliberately lets
through. The cleanup now catches its own failures and logs them.

The frame-cap test hardcoded 540 - one 1080p result rather than the documented rule - so changing the
test frame size would have failed correct production code. Derived from Math.Min(w, h) / 2 now.

Also removed a chronology clause from the doc that the narrative checker does not catch but the rule
forbids.

Decisions-Edit: yes
Refs: #732
Round 5 enumerated the guards independently and ran the full suite against each: `HaloBlur` and
`LineHeight` in FitTextBlock's rebuilt style, and the per-style `HaloBlur` override in
BuildTextStyles, could all be deleted with 2062 tests green. All three were added by the round-4 fix
commit - the same commit that rewrote the mutation-coverage paragraph and warned that adding a guard
without its row makes that paragraph false. It did exactly that, three times, in itself.

The blanket claim is already gone. These three now have differential tests and measured rows, so they
are covered rather than merely unclaimed. The existing halo rows were written at whole-block
granularity, which is what hid the per-field gap: HaloColor and HaloWidth were reachable, HaloBlur was
not.

Also disclosed: the per-style halo fix and the Scale path now carrying line_height are
operator-visible changes for custom templates, which belongs in the release notes rather than only in
a commit message. And a comment on the schema still said an unset background_color means no box at
all, which the border-only path and its own test contradict.

Refs: #732
Thirty rows, all red, eleven reddening more than one fixture. One row moved: dropping HaloColor from
FitTextBlock now also reddens the new blur test, because that test declares a halo colour too.

Added a note that rows are written per clause rather than per block. Block-level rows are what hid
the per-field gap a cold review found - a row naming the whole style-merge block read as covering it,
while HaloBlur inside that block had no reachable test.

Refs: #732
A functional gap, found by cold review and confirmed against the source: there are THREE places that
persist a Channel, and only one of them attached the built-in element.

CreateChannelFromLineupHandler is the SPA's primary "Add Channel" flow and the command Auto-Tune
bulk-creates through, and it attached nothing. Because the backfill writes its marker on the first
boot after upgrade, channels created there would never get the overlay - no log line, no UI
difference, no way back except toggling each one by hand. So the secondary "New blank channel" button
got the feature and the primary one did not.

The third site, DbInitializer's default channel, needs no call: it runs before
AttachOnNowNextByDefault in the same startup, so the backfill covers it. That one came from sweeping
`Channels.Add` rather than from the review, which is the point - the population is derivable from the
source and was not derived when the create-time default was first written.

Both create paths now call ChannelGraphicsDefaults.Attach, so they cannot drift apart again, and the
record says three sites with the grep that finds them rather than an enumeration that reads as
closed.

Also closes the round-6 follow-ups: the 0-100 opacity clamp now has a test (mutating Math.Clamp away
reddens it); the schema reference no longer claims an unparseable non-base halo_color leaves the halo
unset, when it inherits the base style's; and the rendered-bitmap md5 carries the command that
re-derives it.

Two tests were REMOVED rather than shipped: one for the cleanup-exception guard and one for the
no-inset legacy budget branch. Both were green with their clause disarmed - MockFileSystem does not
propagate an interceptor throw raised during a delete, and a MaxWidth of 0 and 1 render identically.
They are recorded as known-unreachable clauses with those reasons instead.

Decisions-Edit: yes
Refs: #732
Three rows moved, each a correct consequence of the round-6 fixes. Rows 1 and 4 gain the new
opacity-clamp test. Row 13 is the useful one: disarming the HLS-Direct exclusion now reddens the
tests for BOTH create paths, which is the evidence that the shared helper actually covers them rather
than just looking like it does.

Thirteen rows redden more than one test and two span two fixture classes - both figures taken from
the run, not from the previous version of the sentence.

Refs: #732
docs(732): sweep the shared create-time helper through the prose it did not reach
Build ErsatzTV Image / CI toolchain image resolves (pull_request) Successful in 6s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 10s
PR Gates / Docs update reminder (pull_request) Successful in 12s
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 21s
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 13s
Review verdict / Set review-verdict status (pull_request_target) Successful in 15s
PR Gates / decisions lifecycle (pull_request) Successful in 22s
PR Gates / Script lint and tests (ruff + pytest) (pull_request) Successful in 5m51s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m40s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m59s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Skipped
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 6m6s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 8s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 6s
review-verdict/h10 Review-verdict: MERGEABLE @ 4804aa9 (base: main)
4804aa90f2
The round-6 extraction changed the code and left three written references pointing at the old shape.
The record BODY was correct; the parts that get read in isolation were not.

The catalog `rule:` and the record frontmatter still said the default is attached "by
CreateChannelHandler" - and that line is the retrieval abstract, so a maintainer resolving this key,
or reading the MemPalace drawer mirrored from it, would have learned exactly the misconception that
caused the round-6 blocker: that the blank-channel path is where the default lives. `signals:` named
only that file too. Both now name ChannelGraphicsDefaults.Attach and both create paths.

The XML remarks on AttachOnNowNextByDefault had the same omission, in the file a reader lands on when
tracing the backfill. And mutation row 12 named AttachDefaultGraphicsElements, a method deleted in the
extraction - the table tells readers to reproduce a row by making the edit, which they cannot do by
grepping for a symbol that no longer exists.

Also dropped the pinned render md5. The invariant it was defending - the seeded template renders
identically with and without the per-style halo merge - holds on any host; the hash does not, because
the render depends on the host's fonts. A future reader checking the number on a different font set
would get a mismatch with no way to tell whether the invariant had actually broken. The doc now states
the invariant and how to re-derive it.

Decisions-Edit: yes
Refs: #732
Author
Owner

Review-verdict: MERGEABLE @ 4804aa9

Seven adversarial rounds, two independent reviewers per round except round 7 (Codex hit its usage limit). Rounds 1-6 BLOCKED; round 7 MERGEABLE after an independent source-derived sweep confirmed the channel-creation population is closed. 4529 tests green, 32 measured mutation rows, live E2E incl. a captured HLS frame.

Review-verdict: MERGEABLE @ 4804aa9 Seven adversarial rounds, two independent reviewers per round except round 7 (Codex hit its usage limit). Rounds 1-6 BLOCKED; round 7 MERGEABLE after an independent source-derived sweep confirmed the channel-creation population is closed. 4529 tests green, 32 measured mutation rows, live E2E incl. a captured HLS frame.
timothy merged commit ba6a4b08aa into main 2026-08-26 21:28:26 +02:00
timothy deleted branch feat/732-graphics-text-background 2026-08-26 21:28:27 +02:00
Sign in to join this conversation.