Files
ersatztv/docs/graphics-elements.md
T
timothyandClaude Fable 5.1 fcdc381246
Build ErsatzTV Image / CI toolchain image resolves (pull_request) Successful in 7s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 13s
PR Gates / Docs update reminder (pull_request) Successful in 13s
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 20s
PR Gates / decisions lifecycle (pull_request) Successful in 18s
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 12s
Review verdict / Set review-verdict status (pull_request_target) Successful in 26s
review-verdict/h10 Review-verdict: MERGEABLE @ fcdc381 (base: main)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 9m23s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m23s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Skipped
PR Gates / Script lint and tests (ruff + pytest) (pull_request) Successful in 17m33s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 6m7s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 9s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 7s
docs(568): the rule the branch establishes gets a record, and every row the fix moved is re-measured
api-conventions.md now says which half of an id-list validator a sibling field may
gate (existence, never the raw-count cap) and that a lost-race recovery re-asks the
whole validator set rather than the fields whoever wrote the catch remembered.
Those, with the bound and the field-named 422, are one convention with residuals, so
they get a record -- api.top-level-id-list-validation -- and a task-signal row.
The record states what #568 does NOT settle: three validators on two DTOs is a
per-field constant, not the repo-wide rule #917 owns, and it says to expect #917 to
replace the mechanism.

graphics-elements.md: rows 35-44 re-measured against the whole ErsatzTV.Tests
project on this tree, because the fix moved five of their red sets -- Validate is
now also what the recovery path re-runs, so removing a validator from it reddens
that handler's race test too. Rows 45-47 are new and measured the same way. The
"redden more than one test" figure is recounted from the table (21 -> 24); the
cross-fixture set is unchanged at five.

The negative discriminator rows now carry a stated seeding rule: vary one half of
the identity and hold the other at the seeded value. Varying both leaves the row
rejected by the pre-#568 predicate as well, so a composite revert to it would pass
every test at that site.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
2026-09-05 21:35:15 +02:00

284 lines
28 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Graphics elements — schema reference
Graphics elements are YAML-authored overlays composited onto a channel's **transcoded** frames by the
SkiaSharp graphics engine (`ErsatzTV.Infrastructure/Streaming/Graphics/`). They appear in the FFmpeg
filter graph as the `[vge]` stage.
`[vge]` is **not** proof that a graphics element is bound. Watermarks are a separate, older system that
falls back onto the graphics engine whenever it cannot use ffmpeg's native `overlay` shortcut — an
`Intermittent`/`OpacityExpression` mode, more than one active watermark, or an external-URL image all
route a *watermark* through `[vge]` with zero graphics-element rows. The native shortcut is only taken
for a single `Permanent` local-file watermark
(`ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs`, `CanUseFFmpegNativeWatermark`).
`StreamingMode.HttpLiveStreamingDirect` renders nothing: ErsatzTV is not transcoding, so there is no
frame pipeline to draw into and `GraphicsElementSelector` returns empty.
## Discovery and attachment
- Files live under `FileSystemLayout.GraphicsElements*TemplatesFolder` (`text/`, `image/`, `motion/`,
`subtitle/`, `script/`). `RefreshGraphicsElementsHandler` scans them and maintains one
`GraphicsElement` row per file, removing rows whose file vanished.
- Five join tables attach an element: `ChannelGraphicsElement`, `PlayoutItemGraphicsElement`,
`ProgramScheduleItemGraphicsElement`, `BlockItemGraphicsElement`, `DecoGraphicsElement`.
Resolution order and the deco `Merge`/`Override`/`Disable`/`Inherit` semantics are in
`graphics.channel-level-attachment`; the channel level is the **base layer**.
## Parsing rules that bite
- Deserialization is YamlDotNet **without** `IgnoreUnmatchedProperties`, so **an unknown key throws**
and the whole element is silently disabled for that content with only a logged warning
(`GraphicsElementLoader.FromYaml<T>`). A typo is not a partial render — it is no render.
- There is no schema validation (`GraphicsElementLoader` carries an explicit `TODO: validate schema`).
- Colour strings are parsed with SkiaSharp's `SKColor.TryParse` (`#RRGGBB`, `#AARRGGBB`, named
colours). Failure handling differs by field and by whether the style is the base one. An unparseable
**background/border** colour is warned about and that part of the box is skipped. Everywhere else it
is silent: on the **base** style an unparseable `text_color` falls back to white and an unparseable
`halo_color` leaves the halo unset; on a **non-base** style an unparseable `text_color` OR
`halo_color` is simply not applied, so the value inherited from the base style stands.
- **`font_family` is required on the base style.** Non-base styles inherit it; a null one reaching
the mapper makes `CustomFontMapper` throw on a null
dictionary key, which the renderer swallows into "disable for this content" (#570).
- `{{ … }}` template expressions are rendered by Scriban before the YAML is parsed.
## Text element fields
Element level (`TextGraphicsElement`):
| Field | Type | Notes |
| --- | --- | --- |
| `name` | string | Display name; **never** used for identity. |
| `text` | string | Scriban-templated. `[styleName]…[/styleName]` selects a style for a span. |
| `base_style` | string | Must name an entry in `styles`, or initialization throws. |
| `styles` | list | See below. |
| `epg_entries` | int | How many EPG entries to expose as `Epg[…]`. |
| `location` | enum | `TopLeft`, `BottomLeft`, … |
| `horizontal_margin_percent` / `vertical_margin_percent` | double | Percent of frame width/height. |
| `width_percent` | double | Percent of frame width. A wrapping/scaling **budget** for the whole element, box included — not a hard clamp; see the geometry notes. |
| `text_fit` | `None` \| `Wrap` \| `Scale` | Only meaningful with `width_percent`. |
| `text_align` | `Left` \| `Center` \| `Right` | |
| `z_index` | int | |
| `opacity_percent` | int | Ignored when `opacity_expression` is set. |
| `opacity_expression` | string | NCalc expression, e.g. `LinearFadeDuration(content_seconds, 4, 1, 6)`. |
| `include_fonts_from` | string | Directory of `.ttf`/`.otf` to load. |
Background box (#732) — element level, because the engine lays the whole element out as one text block
and rasterises it into one bitmap, so there is exactly one box per element:
| Field | Type | Default | Notes |
| --- | --- | --- | --- |
| `background_color` | string | unset | Unset means no **fill**. A `border_color` alone still draws an outlined box; with neither, there is no box and no insets. |
| `background_opacity_percent` | int | 100 | Clamped to 0100; multiplies the colour's own alpha, so `#AARRGGBB` and this field compose. |
| `background_padding` | double px | 0 | Space between the text and the box edge, on all four sides. |
| `background_corner_radius` | double px | 0 | Clamped to half the shorter side. |
| `border_color` | string | unset | |
| `border_width` | double px | 1 when `border_color` is set | A border colour with no width draws a hairline rather than nothing. |
Geometry notes:
- Padding and border grow the element's bitmap and are subtracted from the text's wrap/scale budget, so
`width_percent` applies to the visible box rather than to the text inside it. The inset is rounded up
to a whole pixel once and the same integer is used on both sides, so the box cannot exceed the budget
by a rounding remainder.
- **`width_percent` is a budget, not a guarantee.** Wrapping cannot break below a single glyph, so a
narrow `width_percent` overflows with or without a box — that is pre-existing behaviour, not
something the box introduced. `text_fit: Scale` shrinks the font instead, down to a 5px floor.
- If the inset does not fit inside the budget at all, the **inset is clamped** (with a logged warning)
rather than the text being squeezed to nothing, so an oversized `background_padding` stops growing
its box at the budget instead of producing one several times the requested width.
- The inset is **also capped at `min(frameWidth, frameHeight) / 2`**, with a logged warning, whether or
not `width_percent` is set. Each field is bounded on its own, but padding and border add up, so
without this a two-field mistake could allocate a bitmap far larger than the frame it is drawn onto.
- With no box fields set the wrap/scale budget is passed through untouched, so existing overlays keep
their exact pre-#732 geometry.
- The box is drawn behind the text and fades with the element, so `opacity_expression` dims box and
text together.
- Sizes are in **pixels at frame scale**, matching `font_size`. They do not scale with resolution.
Per-style (`StyleDefinition`): `name`, `font_family` (required), `font_size`, `font_weight`,
`font_italic`, `text_color`, `letter_spacing`, `line_height`, `halo_color`, `halo_width`, `halo_blur`.
Every one of these overrides the `base_style`'s value when the style declares it — the halo fields
did not until #732, so before that a non-base style silently rendered the base style's halo. The
seeded `on-now-next.yml` is unaffected because all three of its styles declare the same halo: its
rendered bitmap is byte-identical either way. That invariant holds on any host; a specific hash would
not, because the render depends on the host's fonts (see `docs/testing.md`). Re-derive it by rendering
`OnNowNextYaml` through `TextElement` at 1920x1080 with and without the per-style halo merge and
comparing the encoded PNGs — they must be equal, whatever their hash is.
**This is an operator-visible change and belongs in the release notes.** A custom template whose
non-base style declares its own `halo_color` / `halo_width` / `halo_blur` renders differently after
this version — correctly, but differently. So does one using `line_height` with `text_fit: Scale`:
the Scale path now carries line height through, which changes measured height and therefore where the
element anchors.
Halo is drawn by RichTextKit as part of the text, not by the box code — a wide halo **on top of** a
background box over-darkens the glyphs, which is why the seeded template cuts it to 1px.
## The built-in On Now / Next element
`GraphicsElementSeeder` writes `on-now-next.yml` once per database (`graphics.on_now_next_seeded`) and
never overwrites an operator's file. Two rules govern it after that:
- Updating the shipped default → `graphics.seeded-template-upgrade-by-fingerprint`.
- It is attached to channels by default → `graphics.on-now-next-on-by-default`.
Identity is the **full seeded path plus `Kind == Text`**, never the editable `Name` and never the
bare filename (`OnNowNextFileName`) alone — a filename-only, folder-agnostic comparison let a user
element named exactly `on-now-next.yml` in a different template folder also report `builtIn:true`
(#568). Both halves live in one predicate, `GraphicsElementDefaults.IsOnNowNext(path, kind)`, and
both `GraphicsElementResponseModel.BuiltIn` and `GraphicsElementSeeder.GetBuiltInElementId` resolve
through it whole, so the SPA never name-matches and the two sites cannot answer differently about
the same row. `Kind` was a `Where` clause on the seeder's lookup alone until #568, and that split is
exactly what let the API report an Image row at the seeded path as `builtIn:true` while the lookup
refused to treat it as the built-in element.
`Kind` is load-bearing rather than decorative, which is why it is inside the identity rather than
a filter any one caller may add: the seeder's own "does this row exist yet?" test —
`EnsureBuiltInElementRow`, which decides whether to create the row at startup — calls
`GetBuiltInElementId`, so a wrong-kind row at the seeded path answering yes would suppress the Text
row every consumer resolves. Asking that question a second way, as its own SQL `Path == target`
comparison, could answer differently in two ways, each leaving the built-in element undiscoverable
for the life of the install (#568): the collation one below, and `Kind`, which the SQL check ignored
(rows 18 and 34). Creating the row stays idempotent because the path it writes is the path the
lookup matches — held by `Repeated_Seeding_Does_Not_Accumulate_Element_Rows` (row 21), which reddens
if the two drift.
Each half of the identity rejects a row on its own, so a row that is BOTH of another kind AND
outside the seeded folder is still refused when either half is dropped — the survivor rejects it. A
test asserting such a row is ignored therefore cannot appear in row 18's red set; it is a test that
cannot fail, and the combination is deliberately not shipped. Row 10 drops the identity call
entirely, which is a different mutation from dropping either half.
The same arithmetic constrains how each negative case is SEEDED, at both discriminator sites: a
row varies exactly one half of the identity and holds the other at the seeded value. The
outside-folder rows carry `Kind == Text`, the wrong-kind rows sit at the seeded path. A negative row
that varies both is refused by the surviving half of the *pre-#568* predicate too — filename plus
`Kind == Text` — so a composite revert to it would pass every discriminator test at that site while
restoring the folder-agnostic bug the issue reports (#568).
`IsOnNowNext` is **ordinal**, and every caller applies it **in memory** rather than in a `Where`
clause. That is not incidental: `GraphicsElement.Path` takes no explicit collation (`TvContext`
pins one only on the listed name/title columns), so a SQL `Path ==` comparison is case-sensitive
under SQLite and normally case-INsensitive under MySQL. Evaluating one discriminator site in SQL and
another in memory is what would let them disagree, on MySQL only. The SQLite test suite cannot
distinguish the two — BINARY collation and an ordinal comparison agree on every input —
so this is held by keeping the comparison out of SQL, not by a test; what the case-variant tests in
the table below pin is the ordinal rule itself, against a loosening to `OrdinalIgnoreCase`.
## Tests
`ErsatzTV.Tests/Infrastructure/Graphics/TextElementBackgroundBoxTests.cs` renders real bitmaps and
asserts pixels — the box's failure mode is a silent no-op, which a model-level test cannot see.
Geometry assertions are relative to a no-box baseline so they do not depend on the host's typeface,
and `Baseline_Renders_A_Non_Empty_Bitmap` turns a fontless host into a loud red instead of a suite
that passes vacuously on a 0x0 bitmap (see `docs/testing.md` → font dependency).
Attachment behaviour is covered by `GraphicsElementDefaultAttachTests`,
`GraphicsElementSeederUpgradeTests` and `CreateChannelDefaultGraphicsElementTests`.
### Mutation record (#732)
Every clause below was mutated in turn against this tree and **the whole `ErsatzTV.Tests` project**
re-run; the "Reddens" column is the measured failure set. The working tree was confirmed clean after
each run. Reproduce one by making the edit and running
`dotnet test ErsatzTV.Tests/ErsatzTV.Tests.csproj`**not** a `--filter`ed run.
Three traps this table is built to avoid:
- **Measure against the whole project, never a per-fixture filter.** A filtered run structurally
cannot observe a red in another fixture, so it under-reports the failure set while looking precise.
Twenty-four rows below redden more than one test, and five of them (13, 18, 22, 33, 41) span two
fixture classes — those five are exactly what a per-fixture filter cannot see. Both figures are
counted from the table itself, so a row added without recounting them makes this bullet quietly
false — recount both whenever a row is added or its red set changes.
- **A mutation that fails to COMPILE is not a result.** Warnings-as-error turn the obvious mutation
shapes into build failures — `CS0162` for `if (true) { return; }`, `CS1717` for self-assignment,
Sonar `S3981` for a constant-folded condition — and a build failure emits no test summary at all,
which reads exactly like a pass in a scrolled log. Require a summary line, and detect build failure
with `: error `, not `error CS`.
- **Re-measure the red set; never carry it over.** Test renames and added cases both change which
tests a clause reddens, so a row written from anything but a fresh run describes a tree that no
longer exists.
**This table is not an enumeration of the change.** It lists the clauses that have a measured proof;
it makes no claim about the ones that do not appear. A clause absent from it has no proof — which is a
reason to go and check, never evidence that none is possible. A "the only uncovered clause is X"
sentence was tried here and abandoned: it was falsified three times, each time by a different clause,
because it silently becomes false the moment a guard is added without its row.
Known clauses with no red, as examples rather than a closed set. Each was measured, and each is
recorded with why a test could not reach it — an unreachable clause is a reason to say so, not to ship
a test that cannot fail:
- the `float.IsFinite` guard on the `width_percent` budget — .NET saturates float→int conversion, so
removing it changes nothing observable;
- the `insetPixels == 0 ? maxWidth : …` compatibility branch — a `width_percent` that rounds to zero
renders identically whether RichTextKit is given a `MaxWidth` of 0 or 1;
- the absent-target early return and the temp-file existence check in `UpgradeUnmodifiedTemplate`
behaviourally redundant; the first re-raises as a caught `IOException`, the second guards a delete
that is already a no-op;
- the inner `catch` around that cleanup delete — it stops a failing delete from replacing the
exception being unwound (which C# otherwise does, and which would downgrade a real cancellation to a
swallowed warning). `MockFileSystem` does not propagate an interceptor throw raised during the
delete, so the clause is correct by language semantics but not reachable from a test here.
Rows are written per clause, not per block: a row naming a whole `if` or a whole style-merge block
hides which individual fields inside it are actually reachable from a test.
A row number is an identity, not a position: prose elsewhere cites rows by number (the built-in
element section above cites rows 18 and 21), so a new row takes the next unused number and the rest
are never renumbered. A clause that loses its red keeps its number vacant rather than freeing it for
some other clause — reusing it would silently re-point every existing citation. Reinstating the SAME
clause under its OWN number is the sole exception, because that is the same identity and not a new
one: row 18 is the `Kind == Text` filter, and stays that clause whether or not it currently has a
red.
| # | Clause mutated | Reddens (measured) |
|---|---|---|
| 1 | DrawBackgroundBox returns immediately | `An_Oversized_Border_Is_Clamped_And_Does_Not_Flood_The_Element`<br>`Background_Color_Fills_The_Box`<br>`Background_Opacity_Percent_Is_Clamped_To_Its_Documented_Range`<br>`Background_Opacity_Percent_Scales_The_Alpha`<br>`Background_Padding_Actually_Insets_The_Text`<br>`Border_Color_Draws_A_Border_Distinct_From_The_Fill`<br>`Border_Color_Without_An_Explicit_Width_Draws_A_Hairline`<br>`Corner_Radius_Rounds_The_Corner_Away` |
| 2 | inset not subtracted from the wrap budget | `Fractional_Padding_Still_Respects_Width_Percent`<br>`Width_Percent_Bounds_The_Whole_Box_Including_Padding` |
| 3 | text painted at (0,0) instead of the inset | `Background_Padding_Actually_Insets_The_Text` |
| 4 | ApplyOpacityPercent returns the colour unchanged | `Background_Opacity_Percent_Is_Clamped_To_Its_Documented_Range`<br>`Background_Opacity_Percent_Scales_The_Alpha` |
| 5 | UpgradeUnmodifiedTemplate call removed | `A_Failed_Replace_After_A_Complete_Temp_Write_Leaves_The_Original_Intact`<br>`A_Failed_Write_Leaves_The_Original_Template_Intact`<br>`An_Unwritable_Template_Does_Not_Fail_Startup`<br>`The_Upgraded_Template_Still_Deserializes_With_A_Resolvable_Base_Style`<br>`Upgrades_An_Untouched_Previous_Default`<br>`Upgrades_An_Untouched_Previous_Default_With_Windows_Line_Endings` |
| 6 | fingerprint check ignored (upgrade unconditionally) | `Leaves_An_Operator_Modified_File_Alone` |
| 7 | line-ending normalisation dropped from Normalize | `Upgrades_An_Untouched_Previous_Default_With_Windows_Line_Endings` |
| 8 | HLS-Direct exclusion removed from the backfill | `Skips_Hls_Direct_Channels_Where_The_Overlay_Cannot_Render` |
| 9 | already-attached filter removed | `Does_Not_Duplicate_An_Existing_Attachment`<br>`While_Armed_A_Restored_Element_Is_Attached_To_Every_Eligible_Channel` |
| 10 | the whole `IsOnNowNext(c.Path, c.Kind)` filter removed from the built-in lookup (#568) — both halves of the identity at once, so every candidate row matches | `A_Row_Of_Another_Kind_At_The_Seeded_Path_Does_Not_Suppress_The_Built_In_Row`<br>`Ignores_A_Case_Variant_Of_The_Seeded_Path`<br>`Ignores_A_Non_Built_In_Element_With_A_Different_Filename`<br>`Ignores_A_Same_Named_Same_Kind_Element_Outside_The_Seeded_Folder` |
| 11 | graphics.on_now_next_default_attached guard never fires | `Does_Not_Re_Attach_After_An_Operator_Clears_It` |
| 12 | create-time `ChannelGraphicsDefaults.Attach` call removed from `CreateChannelHandler` | `Attaches_The_Built_In_Element_To_A_New_Channel` |
| 13 | HLS-Direct skip removed from the create path | `Leaves_An_Hls_Direct_Channel_Alone_Because_Nothing_Can_Render_There`<br>`Should_Not_Attach_The_Overlay_To_An_Hls_Direct_Channel` |
| 14 | upgrade no longer catches filesystem exceptions | `A_Failed_Replace_After_A_Complete_Temp_Write_Leaves_The_Original_Intact`<br>`A_Failed_Write_Leaves_The_Original_Template_Intact`<br>`A_Read_Failure_On_The_Template_Does_Not_Fail_Startup`<br>`An_Unwritable_Template_Does_Not_Fail_Startup` |
| 15 | FitTextBlock drops HaloColor again | `The_Scale_Path_Preserves_Halo_Blur`<br>`The_Scale_Path_Preserves_The_Halo` |
| 16 | unrounded inset subtracted from the budget | `Fractional_Padding_Still_Respects_Width_Percent` |
| 17 | inset clamp removed | `An_Oversized_Padding_Is_Clamped_To_The_Largest_That_Fits` |
| 18 | `kind == GraphicsElementKind.Text` conjunct dropped from `GraphicsElementDefaults.IsOnNowNext` (#568) — one row for both discriminator sites since they share the predicate; the lookup is what `EnsureBuiltInElementRow` asks, so a wrong-kind row at the seeded path would answer for the Text row that never then gets created, and the API would report it as `builtIn:true` | `A_Row_Of_Another_Kind_At_The_Seeded_Path_Does_Not_Suppress_The_Built_In_Row`<br>`GetAllGraphicsElementsForApi_Should_Not_Mark_A_Wrong_Kind_Row_At_The_Seeded_Path_As_BuiltIn` |
| 19 | Sanitize passes values through unchecked | `Non_Finite_And_Absurd_Box_Values_Do_Not_Corrupt_The_Geometry` |
| 20 | the BOX itself is no longer clamped (only insetPixels) | `An_Oversized_Border_Is_Clamped_And_Does_Not_Flood_The_Element` |
| 21 | duplicate guard removed from EnsureBuiltInElementRow | `Repeated_Seeding_Does_Not_Accumulate_Element_Rows` |
| 22 | EnsureBuiltInElementRow removed from the ALREADY-SEEDED branch | `A_Row_Of_Another_Kind_At_The_Seeded_Path_Does_Not_Suppress_The_Built_In_Row`<br>`An_Already_Seeded_Install_Missing_Its_Element_Row_Gets_One`<br>`Repeated_Seeding_Does_Not_Accumulate_Element_Rows` |
| 23 | non-atomic in-place write restored | `A_Failed_Replace_After_A_Complete_Temp_Write_Leaves_The_Original_Intact`<br>`A_Failed_Write_Leaves_The_Original_Template_Intact` |
| 24 | marker written even with nothing resolved | `Stays_Armed_When_There_Is_No_Built_In_Element_To_Attach`<br>`While_Armed_A_Restored_Element_Is_Attached_To_Every_Eligible_Channel` |
| 25 | the atomic replace becomes a non-atomic copy | `A_Failed_Replace_After_A_Complete_Temp_Write_Leaves_The_Original_Intact` |
| 26 | the frame cap on the inset is disarmed | `Non_Finite_And_Absurd_Box_Values_Do_Not_Corrupt_The_Geometry`<br>`The_Inset_Is_Capped_Against_The_Frame_Even_With_No_Width_Percent` |
| 27 | per-style halo overrides dropped from the style merge | `A_Non_Base_Style_Uses_Its_Own_Halo_Blur`<br>`A_Non_Base_Style_Uses_Its_Own_Halo_Not_The_Base_Styles` |
| 28 | FitTextBlock drops HaloBlur from the rebuilt style | `The_Scale_Path_Preserves_Halo_Blur` |
| 29 | FitTextBlock drops LineHeight from the rebuilt style | `The_Scale_Path_Preserves_Line_Height` |
| 30 | per-style halo_blur override dropped from the style merge | `A_Non_Base_Style_Uses_Its_Own_Halo_Blur` |
| 31 | the 0-100 opacity clamp is removed | `Background_Opacity_Percent_Is_Clamped_To_Its_Documented_Range` |
| 32 | the lineup create path stops attaching the built-in element | `Should_Attach_The_Built_In_On_Now_Next_Element` |
| 33 | `IsOnNowNext` loosened from `Ordinal` to `OrdinalIgnoreCase` (#568) — one row for both discriminator sites, since they share the predicate | `GetAllGraphicsElementsForApi_Should_Not_Mark_A_Case_Variant_Of_The_Seeded_Path_As_BuiltIn`<br>`Ignores_A_Case_Variant_Of_The_Seeded_Path` |
| 34 | `EnsureBuiltInElementRow`'s existence check re-derived as a SQL `AnyAsync(e => e.Path == target)` instead of asking `GetBuiltInElementId` (#568) | `A_Row_Of_Another_Kind_At_The_Seeded_Path_Does_Not_Suppress_The_Built_In_Row` |
| 35 | `GraphicsElementIdsMustExist` removed from `UpdateChannelHandler.Validate` (#568) — the unknown-id 422 the issue asks for, and with it the count cap and the diagnostic cap that validator carries, and the lost-race recheck, which re-runs `Validate` whole | `Should_Cap_The_Ids_Echoed_Back_In_The_Unknown_Id_422`<br>`Should_Reject_A_Duplicate_Heavy_List_On_Its_Raw_Count`<br>`Should_Reject_One_More_Than_The_Maximum_Number_Of_GraphicsElementIds`<br>`Should_Reject_Unknown_GraphicsElementId_With_422_Not_500`<br>`Should_Reject_When_One_Of_Several_GraphicsElementIds_Is_Unknown`<br>`Should_Translate_An_Element_Deleted_Between_Validation_And_Save_Into_The_Same_422` |
| 36 | `GraphicsElementIdsMustExist` removed from `UpdateDecoHandler.Validate` (#568) — and so from the lost-race recheck too, which re-runs `Validate` whole | `Should_Reject_More_Than_The_Maximum_Number_Of_GraphicsElementIds`<br>`Should_Reject_Too_Many_GraphicsElementIds_Even_Under_A_Mode_That_Does_Not_Consume_Them`<br>`Should_Reject_Unknown_GraphicsElementId_With_A_Validation_Error_Not_A_Throw`<br>`Should_Translate_A_Deco_Element_Deleted_Between_Validation_And_Save_Into_The_Same_422` |
| 37 | `WatermarkIdsMustExist` removed from `UpdateDecoHandler.Validate` (#568) — and so from the lost-race recheck too | `Should_Reject_More_Than_The_Maximum_Number_Of_WatermarkIds`<br>`Should_Reject_Too_Many_WatermarkIds_Even_Under_A_Mode_That_Does_Not_Consume_Them`<br>`Should_Reject_Unknown_WatermarkId_With_A_Validation_Error_Not_A_Throw`<br>`Should_Translate_A_Deco_Watermark_Deleted_Between_Validation_And_Save_Into_The_Same_422` |
| 38 | `idsAreConsumed: ConsumesGraphicsElementIds(request)` replaced by `idsAreConsumed: true` at `UpdateDecoHandler.GraphicsElementIdsMustExist`'s call site (#568) — a validator may only reject ids the apply path would consume | `Should_Ignore_An_Unknown_GraphicsElementId_When_The_Mode_Does_Not_Consume_It` |
| 39 | the same replacement at `UpdateDecoHandler.WatermarkIdsMustExist`'s call site (#568) | `Should_Ignore_An_Unknown_WatermarkId_When_The_Mode_Does_Not_Consume_It` |
| 40 | `GetAllGraphicsElementsForApiHandler`'s `BuiltIn` reverted from `GraphicsElementDefaults.IsOnNowNext(e.Path, e.Kind)` to `Path.GetFileName(e.Path) == GraphicsElementDefaults.OnNowNextFileName` (#568) — the API-side call, the folder-agnostic and kind-agnostic shape the issue reports | `GetAllGraphicsElementsForApi_Should_Not_Mark_A_Wrong_Kind_Row_At_The_Seeded_Path_As_BuiltIn`<br>`GetAllGraphicsElementsForApi_Should_Not_Mark_Same_Filename_Outside_Seeded_Folder_As_BuiltIn` |
| 41 | the raw-count cap removed from `Validators.IdsMustExist` (#568) — one clause, three call sites, so its red set is what shows the cap is shared rather than restated | `Should_Reject_A_Duplicate_Heavy_List_On_Its_Raw_Count`<br>`Should_Reject_More_Than_The_Maximum_Number_Of_GraphicsElementIds`<br>`Should_Reject_More_Than_The_Maximum_Number_Of_WatermarkIds`<br>`Should_Reject_One_More_Than_The_Maximum_Number_Of_GraphicsElementIds`<br>`Should_Reject_Too_Many_GraphicsElementIds_Even_Under_A_Mode_That_Does_Not_Consume_Them`<br>`Should_Reject_Too_Many_WatermarkIds_Even_Under_A_Mode_That_Does_Not_Consume_Them` |
| 42 | `Validators.DescribeIds` stops truncating and joins every missing id (#568) | `Should_Cap_The_Ids_Echoed_Back_In_The_Unknown_Id_422` |
| 43 | the `DbUpdateException` catch disarmed in `UpdateChannelHandler.ApplyUpdateRequestTranslatingLostRace` (#568) — retyped to an exception the save cannot raise, so the FK failure propagates as it did before the fix | `Should_Translate_A_Watermark_Deleted_Between_Validation_And_Save_Into_The_Same_422`<br>`Should_Translate_An_Element_Deleted_Between_Validation_And_Save_Into_The_Same_422` |
| 44 | the same catch disarmed in `UpdateDecoHandler.ApplyUpdateRequestTranslatingLostRace` (#568) | `Should_Translate_A_Deco_Element_Deleted_Between_Validation_And_Save_Into_The_Same_422`<br>`Should_Translate_A_Deco_Watermark_Deleted_Between_Validation_And_Save_Into_The_Same_422` |
| 45 | the lost-race recheck in `UpdateChannelHandler.ApplyUpdateRequestTranslatingLostRace` narrowed from `Validate` back to `GraphicsElementIdsMustExist` alone (#568) — the recovery enumerating its own fields instead of re-asking the validator set, which omits every other FK the same save writes | `Should_Translate_A_Watermark_Deleted_Between_Validation_And_Save_Into_The_Same_422` |
| 46 | the same narrowing in `UpdateDecoHandler.ApplyUpdateRequestTranslatingLostRace` (#568) | `Should_Translate_A_Deco_Watermark_Deleted_Between_Validation_And_Save_Into_The_Same_422` |
| 47 | the `idsAreConsumed` gate hoisted ABOVE the raw-count cap in `Validators.IdsMustExist` (#568) — the shape the deco mode gate had while it short-circuited the whole call, which took the cap with it | `Should_Reject_Too_Many_GraphicsElementIds_Even_Under_A_Mode_That_Does_Not_Consume_Them`<br>`Should_Reject_Too_Many_WatermarkIds_Even_Under_A_Mode_That_Does_Not_Consume_Them` |