Sits to the right of the Built-in video pill, labelled "Test alert". It enqueues one synthetic
Super Chat through the real AlertOverlayLayer, so what plays is exactly what a viewer triggers —
the custom clip when one is set, the built-in clip otherwise — and it goes through the same
output path (preview and encoder) as a live alert. Local only: nothing reaches YouTube and no
chat row is invented.
Two holes in the blur's property panel, both of which also affected other layers:
- X/Y/W/H were gated on IsWebcamSelected, so a blur (or an image, chat box or alert zone)
had no numeric position or size at all — only dragging. The block is generic: it always
edited SelectedElement's own values, so the gate was the only thing excluding them.
- Opacity showed for a blur, but the compositor's blur reads BlurAmount — the slider would
have moved a number that changes nothing in the output. Hidden for blur layers.
Blur panel is now: Box | Circle, strength, and the shared geometry fields.
Creator request 2026-11-01: a layer that hides part of the desktop from viewers. Box or circle,
drag/resize like any layer, one strength slider, several allowed.
**Z-order is policy, not preference.** `AddSource` inserts a blur directly above the background
and therefore below the brand logo, and `OnSceneElementsReordered` pushes any blur that ends up
above the brand back down. Nothing else in the app can move it into the brand's way.
**Overlap = the strongest single blur, never a stack.** `SceneCompositor.ApplyBlurLayers` collects
every visible blur the first time one is reached in the z-order, sorts them strongest-first, and
shares one coverage map: a pixel already covered by a stronger blur is skipped. Blurring
back-to-front would have produced something stronger than any slider setting allows, which is
exactly what was ruled out.
**One blur curve, shared with the webcam.** `RegionBlur` carries the constants the webcam blur
already used — downscale 4, radius 1–6 downscaled px, `RadiusFor(strength)` — and
`BackgroundEffectProcessor` now delegates to it instead of holding its own copy. The layer's
slider is the same 0–1 scale with the same 0.5 default, so "half strength" means the same blur in
both features. Cost is proportional to the blur's own rect, not the frame.
**Live scene only**, enforced in `AddSource` as well as the (+) menu (`CanAddBlur`), so a stale
menu item or a scripted call cannot plant one on BRB. `ElementKind.Dynamic`, so it is never baked
into the static base and every render path gets it — pump, preview, thumbnails, scene dump.
Persistence: `Source.BlurAmount` column + migration; shape rides the existing `ClipShape`.
Properties panel: Box | Circle plus the strength slider, gated on `IsBlurSelected`.
4 tests: outside the mask is byte-identical, inside loses its hard edges, a circle leaves its
box corners alone, and two identical stacked blurs equal one (the coverage map). Release builds
at 0 warnings.
Creator's defaults: white text, Background ON (black, 20%), speed capped at 200 px/s, text size shown
in real pixels, and "plate" dropped from the label.
The important find is underneath those: `ScoreboardStyle` is a record STRUCT, and `new
ScoreboardStyle()` zero-initialises — it does NOT run the constructor's parameter defaults. So the
shipped strip had null text colour, NO background and zero opacity, whatever the source said. That is
why the Background checkbox and the opacity slider looked like they had no code behind them: the
renderer was faithfully drawing the zeros it was handed.
An explicit parameterless constructor now carries the real defaults, and the renderer test asserts
them (white / on / black / 0.20 / max speed 200) so this class of silent-zero cannot return.
Readouts fixed too: the opacity and text-size setters never raised their own property, so the numbers
never moved while the render did. Text size now reads "24 px" instead of a bare slider value, and
snaps to whole pixels.
The marquee now has the dial it needs. `ScoreboardPresenter.PixelsPerSecond` is the single owner of
the range (20-400, default 120), the layer's properties expose it with a live px/s readout, and the
value is persisted as Scoreboard.ScrollSpeed because it is a READABILITY choice — re-dialling it
every launch would be a tax on the creator who needs the strip to be readable.
Advance() still takes an optional override so tests can drive exact distances.
Guard: the presenter test proves speed is real (same elapsed time, twice the speed, different
window) — not a value that is stored and ignored — and the panel test pins the control, its range
and the persistence key.
Reported: the ticker was painting OVER the socials. The coupling rule existed and was tested
(ScoreboardLayout.ResolveEdges) but nothing ever CALLED it — the strip's Y still came from its own
preference, so both bars landed on the south edge.
Now the resolution runs at publish time: the social bar keeps its side, the scoreboard takes the
other one, and its Y follows from that edge. The pair re-sorts whenever the social bar moves (the
preview click-toggle) or shows/hides, because the rule is "resolve from what is visible", not a
stored pairing.
The layer also gains an Edge preference (Top/Bottom) in its properties, labelled "socials take the
other" — a preference, never a claim on an occupied edge.
Also stabilised the opacity assertion: it samples a plate-only pixel 5px from the left edge instead
of the first semi-transparent pixel it finds (which could be anti-aliased text).
Four reports, three causes:
1. "Background plate has no code behind it" — the plate COLOUR and OPACITY controls were disabled
until the plate switch was on, which reads as dead controls. They are always settable now; the
switch is what decides whether a plate is drawn.
2. "Opacity doesn't work" — proven in the renderer test: 20% and 80% produce alphas of ~51 and ~204,
so the slider reaches the pixels. What was actually wrong was the box.
3. The strip's BOX was fixed at 56px while a wrapped strip renders taller — the frame was scaled
into the box and looked squashed/clipped. The source's width/height now follow the rendered
frame, and its Y follows from the edge.
4. "Simulate (dev) should override Show the scoreboard" — it does: the preview gate is
(_scoreboardEnabled || _scoreboardSimulate), so the dev toggle works even with the product
setting off.
Not changed: the strip is STATIC by design (all enabled facts always visible, wrap rather than
scroll). If scrolling is wanted after all, that is a presentation mode to add, not a fix.
"Clicking on everything produces no visible effect" — the cause was upstream of the controls, and it
is the same defect as the invisible alert box (TASK 47):
- `Source.DisplaySource` returned `VideoImageSource` only for ChatBox / WebSource / MediaSource /
AlertBox. The scoreboard's frame was rendered and published, then never displayed.
- the preview's Image has a per-type Visibility trigger for each of those and none for IsScoreboard,
so even a correct DisplaySource would have stayed collapsed.
Both are fixed, and the layer test now pins the frame-source list AND the trigger so the pair cannot
drift apart again.
Dev-only affordances are ORANGE from now on (creator ruling): "Simulate (dev)" is a TextBlock in
Brush.Attention, so a development control can never be mistaken for product. The panel test pins the
colour.
Creator (2026-10-30): in dev mode the "Show the scoreboard" option must always be available.
The block's gate becomes CanUseScoreboard = IsConnected || IsDevelopmentBuild. Signed-out Release
still shows the inert block with its "sign in" line; a DEBUG build is fully usable, so the strip can
be toggled and previewed before there is an account. IsConnected raises it, so signing in flips it
live.
Guard: ScoreboardPropertiesTests now asserts the CanUseScoreboard gate (enabled AND hint), and the
VM surface list includes it.
Creator (2026-10-30): pre-1.0, use DEBUG — add a toggle that simulates the ticker so the strip can
be SEEN and its compositing verified before a real stream.
The sample travels the REAL ScoreboardRecorder a stream uses (one new member, a gift of three, a
milestone, a $12.34 Super Chat), so what the preview shows is exactly what live events produce — a
fake renderer would prove nothing. Toggling off clears to an empty session, so the empty state is
visible too.
Debug-gated at COMPILE time: `DevelopmentBuild` is a const true in DEBUG / false in Release, and the
checkbox binds its Visibility to it. A shipped Release build therefore has no dev control at all,
rather than one hidden by a runtime flag.
Guard: ScoreboardSimulationTests — the sample lands all four tallies through the recorder, off clears,
and the panel + const pair exist in the Dual (DEBUG/Release) form.
The scoreboard is fed by the signed-in channel (chat events + channel stats), so offering its
switches to a signed-out user is a dead control. The whole block now binds IsEnabled to IsConnected
and shows one line — "Sign in to YouTube to use the scoreboard." — instead of looking broken.
Nothing extra is needed to re-enable: IsConnected already raises on sign-in/out, so the block
comes alive the moment the session lands.
Guard: ScoreboardPropertiesTests asserts the IsEnabled binding, the signed-out hint and that the
hint is gated by the inverse converter.
Creator: "even if it's not visible, we want to avoid polling."
ScoreboardEnabled is the layer's master switch in the properties panel. Off means two things:
- the strip frame is null in every scene (per-scene eye toggles stay independent), and
- the cadence policy returns None at every phase — a scoreboard nobody can see must not spend quota.
The switch is persisted (Scoreboard.Enabled), because one that resets would quietly resume polling.
The polling gate is ScoreboardPollsEnabled: master switch AND at least one scene with a visible
scoreboard — so hiding it in all five scenes also stops the polls.
Guards: ScoreboardCadenceTests adds the switch off across live/test/recording; ScoreboardProperties
pins the checkbox and the VM surface.
Creator ruling: the Scoreboard layer exists in every scene rather than being added five times.
ScoreboardHealer.Ensure keeps exactly one per scene — healed on load like the Background and Brand
logo — so a scene can never end up with two, and 'off' is the ordinary per-scene eye toggle rather
than a delete.
It is appended as the TOPMOST layer on purpose: a full-width strip draws over the scene, and
inserting it above the background would break the Brand logo's documented invariant (directly above
the background). That choice is also why the layer-reorder test still passes untouched.
Also: a layer-list icon for the type, so the row is not blank.
Guard: ScoreboardHealerTests — one per scene, topmost, exact strip geometry, idempotent, and a
duplicate healed back to one.
Selecting a Scoreboard layer now shows its settings, using the app's own widgets only:
- eight fact checkboxes (subscribers, watch hours, Super Chats, new members, milestones, last member,
last Super Chat, last gift)
- text colour and plate colour via YtComboBox over BorderColorPalette (the app's one palette —
TASK 63's rule: a picker IS the app's drop-down, never a hand-built menu)
- plate on/off, plate opacity, text size 14-24
Every change pushes through the presenter (SetStyle/SetTiles) and repaints the strip, so the preview
and the output follow immediately. One look per layout, stored on the view-model: the scoreboard is a
single billboard, not eight independent widgets.
Guard: ScoreboardPropertiesTests asserts the panel is gated on SelectedElement.IsScoreboard, binds
all eight facts and the four look controls, uses YtComboBox/YtCheckBox only, and that the VM surface
exists for each name (a typo would otherwise bind to nothing in silence).
1. Stream Info + YPP rails are YouTube surfaces → new `youtube` token #ff0000. It is documented
as brand-only: live state stays `primary`, so the two reds never mean the same thing.
2. EDIT toggle: the checked trigger set the SAME colour as the resting face, so clicking it
changed nothing visible. Resting is now the neutral surface with muted text; armed is blue with
dark text, and the label follows the fill.
3. Active scene outline is orange again (`attention`) — it marks the scene you work on, not a
selection; blue made it read as "selected". Live stays red.
4. Scene names wear the same pill as the hotkey chips (black, 10px radius, 8×2 padding), so both
labels stay readable over any thumbnail.
5. REC / ON-AIR pills and the running stop button read red while armed/live — including test
mode, where the top bar is gold. New `RecordPillToggle` style (its own template, because
SegmentToggle hard-codes a transparent face); the stop button's grey `#333` literal is gone.
6. The properties list had `MaxHeight="420"` inside an Auto row, so it scrolled while the window
had space left. The row is `*` now and the cap is gone.
Guard: UiShellTests pins all six to the markup and names the one that regresses.
The palette had no green for status and no black scrim, so two surfaces were about to either
invent literals or misuse an accent. Creator's call (2026-10-30): give green a token rather
than painting "connected" blue, which already means five other things.
- `positive` #22c55e — STATUS ONLY (connected, applied, healthy). Never an accent, never a
button fill; the doc says so and the guard parses the doc.
- `scrim-dark` #26000000 (15% black) and `scrim-dark-strong` #00000080 (50%) — the preview's dim
strips and heavy overlays. 18 sites justify a token, and they are structural, not decoration.
- LeftPanel's ✓ confirm now wears `positive` instead of the interactive accent, matching the
status rule the new token states.
DesignTokensTests still green: the doc is the contract, so the three new rows demanded three
new brushes and got them.
The layers/properties panel is the surface the creator looks at most, and it carried the
largest hardcoded palette in the app: 50 label greys, 15 icon blues, and a scatter of
one-off hexes. All of it resolves from tokens now.
Three sites needed a decision rather than a mapping, because the same hex meant different things:
- the + add icon and the chat author name were the old brand red → interactive accent (blue)
- the webcam lock alert is an ERROR, not decoration → error surface + error border
- the ✕ revert affordance was red, which the standard reserves for live/record → muted. ✓ stays
a positive confirm, now in the interactive accent rather than a green that exists nowhere else
in the palette. If green checks are missed, a `positive` token is the way back, not a literal.
- Super Chat gold was #eab308; it is the standard's amber now (game audio + Super Chat only).
Guard list extended: Controls/LeftPanel.xaml can no longer regress to a raw colour.
- Socials: the dialog is a right-rail drawer (same pattern as Stream Info/YPP/TEST); the live-screen button is removed
- Right rail: Stream Info (was Stream Settings) and YPP show the YouTube logo; Socials and TEST get icons; tab colors and label colors per spec
- Top bar: EDIT toggle beside REC (orange when on, blue when off); going live on YouTube turns it off
- Stream readouts (bitrate, FPS, dropped n (x.y%), Live hh:mm:ss, health) and the resolution dropdown moved to the top-left cell; bottom bar emptied at the same height
- Webcam border drawing restored (it was removed with the CRT bezel change)
- YouTube chat layer: border color, thickness, and opacity controls
- Vertical 1080p30 tier added
Not related to the fps gate yet; no gating is enforced.
- LeftPanel: SCENE LAYERS header with layer count; per-type blue icons; properties header gear; layers list min/max height; properties placeholder; list and properties indent
- SceneThumbnailStrip: names/hotkeys over the tile; PROGRAM badge with blinking red dot on the selected tile; orange border on selected and live tiles; fixed the element border override that hid the style border; reflag tiles on load
- LiveScene is now set on go-live, record start, and live transitions (it was never assigned, so IsLive never fired)
- Startup stages the scene named Starting
- DESIGN.md: color roles (red = YouTube/on-air/record, orange #ff8c3a = attention, amber = game audio/Super Chat)
- Staged live tile uses background art instead of the green LIVE placeholder
Not pushed.
The creator (2026-10-21), on Live -> Layers -> webcam -> Properties:
"why does the drop down under the web cam properties not show the same style for
the border color drop down list box?"
"one of the first things we did was define branding. We established look-and-feel
standards. And yet you keep ignoring these and I'm wondering why"
WHY IT WAS MISSED - the answer the creator is owed.
The TASK 58 sweep had already walked the whole app for un-themed controls and the
border picker STILL was not themed, because the sweep looked for control TYPES
(ComboBox / CheckBox / TextBox) carrying no theme style - and the border picker was
NONE of those. It was a "..." Button whose Click handler built a ContextMenu of
swatches in code. It was never in the sweep's scope, and PropertiesStyleTests.cs
(a type-based guard) could not see it either. A picker that LOOKS like a drop-down
is invisible to a type-based audit; the guard has to ask "is this the app's widget?".
WHAT CHANGED
- The picker is now the app's YtComboBox - the widget the Display and Font pickers
wear - bound to the palette (SelectedElement.BorderColorOptions) with its selection
bound by VALUE (SelectedItem -> BorderColorEntry), because WPF's SelectedValue
matching is ordinal and the palette mixes #FFFFFF and #e94560.
- The closed box shows the swatch + the colour's NAME; the rows show swatch + hex +
name (the rows the creator approved in TASK 60). A ComboBox cannot do that with one
ItemTemplate - WPF copies it into the read-only SelectionBoxItemTemplate - so
Helpers/ClosedBoxTemplateSelector.cs picks by the ContentPresenter's templated
parent: the ComboBox for the closed box, the ComboBoxItem for a row. Verified
against WPF with a throwaway probe, not assumed.
- The swatch is painted by the THEME (Helpers/HexToBrushConverter.cs, registered as
HexToBrush) - the hand-painted SolidColorBrush(Color.FromRgb(...)) in the old
click handler was the exact anti-pattern the theme forbids. The swatch also carries
a 1px #2a3a5e border now, so black and dark swatches are visible on the dark menu
(the follow-up TASK 60 left open).
- The hex field stays (60px, editable): a colour outside the palette can still be
typed, and the drop-down then carries that hex as a NAMELESS entry ("no name" is the
honest answer). The palette's name moved out of TASK 62's TextBlock into the
drop-down's closed box.
- The hand-built menu, its ContextMenu/MenuItem/SolidColorBrush code and
WebcamBorderColorPicker_Click are gone.
MODEL: SceneElement.BorderColorName -> BorderColorEntry (the palette entry for the
current hex, with its name; setting it writes the hex back) + BorderColorOptions (the
palette plus the custom entry), both raised when BorderColor changes.
BorderColorPalette gains EntryFor(hex) and OptionsFor(hex); NameOf delegates.
Guards: ytLive.Tests/BorderColorPickerTests.cs (the ONE test for this change - the
model, the panel, and the REAL widget: with the app's theme style and the panel's own
templates the closed box names the colour and the rows show the hex AND the name).
The TASK 60 guard (BorderColorPaletteTests) was updated because the mechanism it
asserted - a code-built menu - no longer exists; it now guards the new wiring (the
drop-down is bound to the palette and neither the panel nor the code-behind carries a
second list of colours).
Full suite: 435/435 passed, 0 warnings.
The creator (2026-10-21), on Live -> Layers -> webcam -> Properties:
"the Y and H boxes under webcam properties should be the same width as the X and W
boxes and those widths should be fixed to accomodate the max number of possible
digits providing the geometry values"
"also the border color drop drown box content should also include the name of the
color next to the hex value as they in the drop-down list box"
TASK 61 - one fixed width for the four geometry fields.
The row is a DockPanel, and WPF sizes an element to its CONTENT unless it is told
otherwise, while the LAST child of a DockPanel fills the remaining space. So X/W (not
last) were as wide as their text and Y/H (last) stretched across the row - two widths
for the same kind of field. Now all four carry Width="44" and the rows set
LastChildFill="False" (without it the last field ignores its own Width and fills anyway).
44px is the measured width of "-1920" in the app's font at 11px (the largest geometry
value - the master frame is 1920x1080 - plus a sign) plus the field's padding and
border. Docs: https://learn.microsoft.com/en-us/dotnet/api/system.windows.controls.dockpanel.lastchildfill
The guard MEASURES the widest value with WPF's FormattedText instead of asserting a
magic number, so a font change cannot silently make the field too narrow:
https://learn.microsoft.com/en-us/dotnet/api/system.windows.media.formattedtext
TASK 62 - the border field names its colour.
SceneElement.BorderColorName carries the palette's name for the current colour
("Crimson") and is raised when the colour changes; it is EMPTY for a custom hex the
creator typed, because "no name" is the honest answer. BorderColorPalette.NameOf
changed to match (it used to fall back to the hex itself; the TASK 60 test assertion
was updated with it). The hex field keeps a fixed width (60) so the name sits right
next to the hex instead of drifting to the row's far edge - the same trap as TASK 61.
Guards: ytLive.Tests/GeometryFieldWidthTests.cs, ytLive.Tests/BorderColorNameTests.cs
Full suite: 435/435 passed, 0 warnings.
The creator's ruling on the webcam's properties: a "Shape" title above the
Mirror/Rect row; the drag hint indented under that row "to show parentage";
the same for Geometry, Border Color, Border Thickness; Remove and Blur indented
under Background; Strength indented under Blur.
One rule covers it: a title owns its content and the content is indented one
level (10px) under it - two levels where a title is itself nested (Strength
under Blur under Background). The titles are SemiBold 11px #a0a0b0 like the
ones already there.
The shape also stops being a toggle button that shows the NEXT action ("Round"
when the camera was a rectangle) and becomes a CHOICE: two segment options,
Rect | Circle, with Rect selected by default - which is what the creator asked
for ("Rect should be selected as a default if none exists"). The old
ShapeButtonText is gone.
The mutual exclusion lives in the MODEL: IsRectShape/IsRoundShape on
SceneElement, each setter flipping the shape through ToggleClipShape() (so the
pre-round rect is still stored/restored) and always re-raising, so a refused
write - the vertical Chat layout is rect-only (TASK 57) - makes the UI snap
back instead of sticking. Deliberately NOT a WPF RadioButton group: a group
unchecks its siblings by setting IsChecked in code, which makes WPF drop the
binding (stackoverflow 7376373; the same trap measured in 50980687/57275046).
The Circle option stays hidden where CanToggleClipShape is false, so the circle
is still not offered in the vertical Chat layout.
Guard: ytLive.Tests/PropertiesHierarchyTests.cs - the model half (Rect by
default, pick circle, pick rect, the locked Chat layout refuses the circle) and
the panel half (the Shape title owns both options, the circle is the gated one,
the hint is indented and follows the row, every titled block's content is at
10px, Strength at 20px). 432/432 green, 0 warnings.
The creator asked why the Stream Alerts "Display" drop-down was not in the same
style as the other drop-downs. It was never given the theme's YtComboBox style -
only a hand-painted Background/Foreground. Setting colours changes the paint, not
the template, so WPF still drew the light system ComboBox (grey border, system
arrow, white popup list) on top of the dark panel. The chat Font drop-down was a
copy of the same mistake, and every check box was the light system one.
The sweep of the whole properties panel (then the app):
- ComboBox x2 (chat Font, alert Display) -> YtComboBox, hand-painted colours gone
- TextBox x16 in the properties panel -> YtTextBoxCompact (new, BasedOn YtTextBox:
11px / padding 4,2 - the panel is dense, the full 13px box would double it)
- CheckBox x2 (chat) + x3 (preview broadcast) + x1 (socials dialog) -> YtCheckBox
(new: a 14x14 rounded box + tick path, accent fill when checked)
- the layer-rename field -> YtTextBoxCompact via BasedOn, still borderless
- buttons, pills, sliders: already themed, untouched
The sweep also killed a typo'd foreground (#e0d0e0 for #e0e0e0) and the only
remaining hand-painted colour is the deliberate dimming of the two read-only path
fields.
Guard: ytLive.Tests/PropertiesStyleTests.cs (the ONE Good Dog test) parses the app
XAML and fails if an unstyled drop-down, check box or properties field comes back.
431/431 green, 0 warnings.
The creator's locked table (2026-10-17) gives the Chat scene its own rules in
the 9:16 tier: RECT ONLY - the circle option is not offered - and a size that
is either the normal small tile or ENLARGED to fill one whole 607x540 area
(top or bottom). Unit 1 shipped the per-scene placement; this unit is the Chat
scene's own variant of it.
The rule lives on the MODEL, not on the button:
- Models/SceneElement.cs: ClipShape and ToggleClipShape() are now virtual.
- Models/WebcamSceneConfig.cs: VerticalShapeLocked (set per scene by the VM)
OVERRIDES the ClipShape property, so no writer can slip a circle in - the
toggle, the Shape button and the placement swap all go through it. The lock
is cleared BEFORE the 16:9 layout comes back, so the desktop shape returns.
- ViewModels/MainViewModel.Webcam.cs: CanToggleClipShape (the circle is NOT
offered in the vertical Chat layout - the Shape button disappears, it is not
merely greyed out), IsChatSceneCamera, ResizeWebcamInVerticalTier.
- Models/VerticalCameraLayout.cs: FillsArea (the "enlarged" state, derived -
no new column) and SizeFor - the Chat camera's TWO sizes, the tile or the
whole area, whichever is nearer the requested width, so the resize handle
has two detents instead of any size in between.
- An enlarged camera FILLS the area its centre is in (top or bottom), so
dragging it up/down swaps the areas.
- The Chat camera is auto-tiled like the Live one when the vertical tier is
picked - otherwise the vertical chat stream has no camera at all.
- Controls/PreviewPane.xaml.cs: the resize routes through the VM; the drag
assigns, lets the model clamp into the band, then snaps (so the enlarged
camera can be dragged from the top area to the bottom one).
Test: ytLive.Tests/VerticalChatCameraLayoutTests.cs - the ONE Good Dog test for
the unit: the vertical tier is picked -> the Chat camera comes into the band as
a small RECT tile -> the circle option is not offered and cannot be set even on
the model -> dragged up and enlarged it fills the TOP area exactly -> back to
16:9 the desktop layout is restored exactly and the circle option is offered
again. 430/430 tests pass, 0 warnings.
Research (the creator's table is the established practice, not our invention):
- Streamlabs' vertical guide: "splitting the screen in half horizontally,
placing the camera feed in the top half and the main content on the bottom
(or vice versa) may be the bet", and a just-chatting vertical wants the
camera "to take up most of the screen"
(streamlabs.com/content-hub/post/overlays-vertical-and-horizontal-streaming)
- Bloopbot's vertical overlay docs describe the same per-layer model we use at
scene granularity: "move or resize a layer on Vertical and it stops being
Auto-placed. It keeps the place you gave it, and the horizontal layout is not
touched" (bloopbot.com/docs/vertical-overlays-twitch-dual-format) - our
swap-at-tier-change, stored per scene.
- Landmine recorded: "about the bottom 560 px of the vertical video can be
covered by Twitch's chat and player controls on phones" (Bloopbot) - the Chat
camera's enlarged BOTTOM area may be half-hidden by the platform chrome;
TASK 53's safe-zone work decides that.
Memory in the same commit: ai.md (the Chat rules, the shape lock, the tests),
TASKS.md + TASKS/task-57-vertical-camera-layout.md (TASK 57 complete), HANDOFF.md,
MyMistakes.md (3 recipes: lock the model property not the UI button; two detents
for a discrete size set; the edit tool cannot match an oldString with an em
dash), Models/ViewModels/Controls index rows.
The vertical tier is a centred band crop of the SAME 1920x1080 master (band x
656.25, w 607.5, h 1080), so the 16:9 camera placement (x~1408) falls outside the
cropped window and the creator is simply not in the vertical stream. Each scene
now keeps its own vertical position, size and shape; switching back to 16:9
restores the desktop layout exactly (the creator's locked table, 2026-10-17).
Geometry only - NO render-path change. The preview and the compositor both read
the element's own X/Y/W/H + ClipShape, so the tier switch swaps the LIVE geometry:
- Models/VerticalCameraLayout.cs (new): the pure math - the band, its two 607x540
areas, the six spots (top/bottom x left/centre/right), the 32px inset and the
270x152 default tile (480x270 in the 1080x1920 output) in the bottom-right spot,
the corner the 16:9 default webcam uses.
- Models/WebcamSceneConfig.cs: the vertical placement lives in seven additive
columns (VerticalX/Y/Width/Height/ClipShape/RectWidth/RectHeight; NULL = "this
scene has no vertical placement yet"). The 16:9 layout stays in the old columns
and is parked in the transient Landscape* fields while the vertical tier is
live; Begin/EndVerticalLayout + CommitLiveLayoutToVertical do the swap. The
X/Y/W/H overrides clamp every write into the band - outside it the camera is
invisible in the vertical output.
- Models/SceneElement.cs: X/Y/Width/Height became virtual so the derived element
constrains every writer (drag, resize handle, typed fields) in ONE choke point
instead of three writers each re-implementing the rule.
- ViewModels/MainViewModel.Webcam.cs + .Streaming.cs: IsVerticalTier/VerticalBand,
ResolveVerticalCameraLayout(s) (the tier swap), MoveWebcamInVerticalTier /
SnapWebcamInVerticalTier (the six-spot snap, by the tile's centre),
EnsureVerticalCameraLayout; ApplyStreamQuality is the tier-change hook. The
Live scene gets its small tile automatically on the first vertical switch (the
vertical stream would otherwise have no camera at all); the other scenes keep
their 16:9 placement until the creator drags them into the band.
- Controls/PreviewPane.xaml.cs: the drag/resize route through the VM snap under
the vertical tier instead of free-floating over the master.
Controls/LeftPanel.xaml: the webcam properties gained a Geometry section
(X/Y/W/H) for the manual entry the creator asked for, bound to the LIVE
placement, gated on the VM's IsWebcamSelected (NOT SelectedElement.IsWebcam -
the TASK 51 test finds that panel by binding text and would match twice).
- The store: seven guarded ADD COLUMNs, the INSERT/SELECT extended, and the
X/Y/W/H/ClipShape columns stay the 16:9 layout - while the vertical tier is
live the save writes the parked values there and the live ones into Vertical*.
Test: ytLive.Tests/VerticalWebcamLayoutTests.cs - the ONE Good Dog test. The
vertical tier brings the Live camera INTO the band as a small tile; dragging it to
the top-right lands it top-right inside the band; back to 16:9 the desktop layout
is restored exactly (1408/778/480x270); both placements survive a save + reload.
Full suite 429/429, 0 warnings. LayerReorderPersistenceTests.RealMouseDrag fails
ALONE on a clean tree in this environment - pre-existing flake, proven by stashing
this work, not a regression.
Derivative work: OBS has no built-in per-aspect-ratio layout storage - the
maintainers' answer is "create a second scene and add the scene you want to send
as source" (obsproject.com/forum/threads/161307) - and Streamyard's dual-aspect
guide says the same ("no built-in way to store separate layouts per aspect ratio",
so you rely on manual workarounds and plugins):
https://streamyard.com/blog/dual-aspect-ratio-streaming-tool
Our per-scene, per-tier stored placement, resolved on the tier change, is the
step past both.
Memory in the same commit: ai.md (new section), TASKS.md + TASKS/task-57-*.md
(Unit 1 shipped, Unit 2 = the Chat scene still open), HANDOFF.md, MyMistakes.md
(the per-tier geometry-swap recipe, the binding-text panel trap, the store's
webcam-identity rule) and the Models/Services/ViewModels/Controls index rows.
Right-click now creates a transient ZoomSceneElement at z-index 1 whose pixels are a
genuine 2x magnified sample of the backdrop (live capture under art), composited into
stream/recording like any layer while the output crop stays the tier. All shapes are
centered on the click and clamped to frame; healed on load/scene-create, never persisted,
hidden from the layers list + element template (IsZoomLayer). BuildCompositorOptions is
now internal = always tier crop (test seam). ZoomFrameBuilder is a pure static sampler
(box/2 window centered on the box center, single-slot cache) proven by a banded-backdrop
test; LayerReorderPersistenceTests drag resolves rows by item since the collapsed zoom
row shifts container indices. Also books TASK 51 (webcam background Remove + Blur) and
syncs memory docs (HANDOFF, MyMistakes, indexes). Full suite: 405 passed, 0 warnings.
The creator confirmed the clip fix ("the video plays now"), then asked where the
scrolling text was — it was invisible, for a structural reason. AlertTickerFrame
existed only as a frame-pump callback blitted into the OUTPUT; PreviewPane.xaml had
no element for it, because the strip is master-width and global, not a Source, so it
cannot ride a per-element Image. Nothing was wrong in the renderer: there was no
consumer in the preview. Same class of defect as the missing IsAlertBox trigger, one
layer up (MyMistakes RULE 5/6).
- tickerPreviewSink on AlertOverlayLayer, published from RefreshAlertPreviews() so it
is always the UI thread; MainViewModel.AlertTicker writes it into one reused
WriteableBitmap bound to a new global AlertTickerElement, mirroring SocialBarElement.
- Source.AlertDisplayMethod + panel "Display" selector: TickerScroll / Flash / Solid.
Flash pulses 0.5s on / 0.5s off for the whole alert; Solid is centred and still.
- The marquee was also unreadable: a fixed 140px/s took ~17s per pass, so a 10s alert
showed the text once, entering from the right and never crossing. Paced in reads per
alert instead (TickerReadsPerAlert = 3 inside the alert's own length, speed derived
from it) — never px/s. Research (websearch: how do OBS/Streamlabs/StreamElements
alert boxes present announcement timing?) settled the unit: Streamlabs exposes "Alert
Duration: choose how long your alert stays on your stream" and "Text Delay", never a
scroll-speed slider (https://support.streamlabs.com/hc/en-us/articles/52499995174299-Setting-up-Your-Streamlabs-Alerts).
Run is phase-started half a frame in so the first frame isn't blank.
- Persistence: AlertDisplayMethod INTEGER NOT NULL DEFAULT 0 via the idempotent
table_info migration, appended LAST in the SELECT because the Source reader is
positional (GetInt32(32..34)) — a mid-list insert would silently shift a neighbour.
Tests: 15 new facts (suite 339/339). RealApp STA host: the pane draws the strip and
collapses at alert end; the layer publishes a real 1920x48 frame for all three methods
and nothing when the ticker is off; three passes counted in 10s by the pill's leading
edge resetting (a seamless marquee never blanks, so an empty frame cannot count a pass);
Solid byte-identical at every moment; Flash on for half of each second; the panel shows
and writes back the choice; the DB round-trips all three alert fields together.
Incidental finding: a bound ItemsSource ComboBox in LeftPanel.xaml broke
LayerReorderPersistenceTests.RealMouseDrag (that test injects PHYSICAL mouse input, so a
load-time re-measure moves the rows out from under the cursor). Rewritten as inline
ComboBoxItems, the shape the chat Font selector already uses in that panel. Recorded as
MyMistakes RULE (8).
TASK 43's alert box grows a real video celebration. Per-alert IAlertClipDecoder
(ffmpeg bgra + f32le pipes, real-time paced, disposed at drain) plays the shipped
Assets/alert-default.mp4 (stamped into the Asset table at startup) unless the
creator picks their own file — path reference only, never stored in the DB; the
six AlertRenderer animations stay the fallback. ~0.3s fade rides the alpha
envelope on straight-source copies (EOF freeze-frames then fades out); audio
forwards to a new AudioMixer alert ring (8s, 48k stereo) drained at unity — no
duck, creator ruling — scaled by volume × fade. An auto-composed marquee ticker
('Funder — Super Chat · $10.00', 140px/s) scrolls top-of-frame via a
FramePump._alertTicker seam through Render/CompositeLayers, mixed into the cache
signature (dynamic overlay, never baked). New Stream Alerts section in LeftPanel.
Derivative-work references (how OBS/Streamlabs alert boxes do per-alert video):
- https://support.streamlabs.com/hc/en-us/articles/217741147-Setting-Up-Your-Streamlabs-Alerts (custom image/video per alert type + variations)
- https://obsproject.com/kb/stream-tutorial-2-alerts (alert overlay as an on-screen zone)
- https://streamlabs.com/content-hub/widgets/alert-box (per-event alert playback)
Good Dog: AlertLayerVideoTests drives a fake IAlertClipDecoder through the whole
lifecycle in one pass (custom path wins, decoder spawns/disposes, fade envelope
0→127→255, audio volume×fade, ticker scrolls, EOF fade-drain to idle). It caught
the clip branch of Advance not clearing _current before AdvanceToNext — the layer
stayed IsPlaying after drain (MyMistakes post-mortem).
Full vstest 319/319; clean build 0 warnings; scope check green.
Replace the external StreamElements feed with NATIVE YouTube events per the creator's
2026-09-23 question.
Chat parity half:
- YouTubeChatService decodes ALL SIX liveChat/messages event types into
ChatMessage.Kind (superChat/superSticker/newSponsor/membershipGifting/
giftMembershipReceived/memberMilestoneChat) — the four formerly-empty overlay
rows are real now — and re-arms its one-shot poll on the server's
pollingIntervalMillis (streamList connection semantics; clamp 1000-6000ms,
maxResults=2000). ParsePage internal static seam + ChatPage record for
deterministic tests; optional HttpClient ctor seam kept; InjectSimulatedMessage
(TASK 41) preserved.
Alerts half (creator rulings: one celebration zone, six UNIQUE animations, no
menus/polls, sub mention = chat row only, NO viewer count):
- New SourceType.AlertBox 'Stream Alerts' (one per layout, CanAddAlerts gate
mirroring chat; idle = transparent).
- AlertRenderer: six distinct branded animations (SuperChat slide-up/shine/
count-up, SuperSticker scale-pop, NewMember drop-in/flash, MemberGift slide-left/
chip-fan, GiftReceived confetti, MemberMilestone rise/growth-bar), every card
drawing the 'made with LlamaCasty!' brand line.
- AlertOverlayLayer: true component (Commit-G pattern) — queue (cap 10, drop tail
never stall), 33ms UI ticker, cache-first RenderFrame + UpdatePreview,
Advance(double) as the deterministic test clock; ChatEventKind.None rows never
enqueue.
- Wired: resolver RenderAlertBox, _alertLayer ctor + dispose, LoadLayout previews,
Add menu item (Controls/LeftPanel.xaml), TestSessionViewModel sims tagged
(member->NewMember, superchat->SuperChat).
Good Dog ONE integration test: AlertLayerTests (RealApp STA, real WPF raster) —
ParsePage classifies all six kinds + cadence fields; None rows enqueue nothing;
six events play pairwise-distinct moving frames then drain to null.
Gate: clean build 0 warnings; full suite 316/317, the one failure
(LayerReorderPersistenceTests.RealMouseDrag) repros on the clean tree — the
known environmental class (real-mouse-drag no-ops with a game/fullscreen window
focused).
References (OBS/overlay ecosystem):
- streamList semantics: https://developers.google.com/youtube/v3/live/docs/liveChatMessages/streamList
- OBS alert-box pattern (designated celebration zone, idle transparent): creator-chosen model
Click-to-pin an open window as the full-bleed Live backdrop, via the existing
window:<hwnd> capture path (same render layer as game/desktop). Session-scoped:
window:/picker: keys heal to auto on reload (HWND-recycle hazard); pin wins
while the window enumerates, then auto-fallbacks (game → desktop → static); a
dead window's capture session heals to auto, no dead ends.
Fix: ScreenCaptureSourceFactory.Resolve hex-parsed "window:0x<Hwnd>" WITHOUT
stripping the 0x prefix — NumberStyles.HexNumber rejects it, so every pin
resolved null ("No capture target for this key") and the heal silently rolled
back. Same flaw in IsAliveWindowPin (pin-wins guard was dead). Both fixed.
Commit also carries an out-of-scope prerequisite: PillRadioTests.cs:105 had a
committed stray token ("...PrimaryStartButtonLabel soil;", CS1003) blocking the
whole test-project compile — token removed.
Plus the previous session's uncommitted pricing docs (one-time $29/$49) that
share TASKS.md/ai.md/HANDOFF.md.
Chat's camera no longer greys Web Cam in Live (per-scene max, not app-wide);
TASK 26 superseded by creator directive (app-level resource model). Add Webcam
now places the existing app default without the picker; the picker runs only
for the initial selection. Removing the last placement keeps the identity
(_webcam never nulled) so Add stays offered. Startup pass adopts a solo camera
as the app default, so a clean layout offers the layer in Live/Chat at once.
Dynamic WebcamAddToolTip names the why (incl. the 'graduated to OBS' line).
Good Dog: WebcamMenuGateTests rewritten (real app + temp DB + camera seams) —
identity in Chat does not gray Live; Add in Live places same wc-1 no picker;
scene-with-placement stays gray; identity survives both removals (DB row 1).
WebcamStartupResourceTests single-lock fact asserts CanAddWebcam after adoption.
Docs same commit (ai.md supersession, TASKS.md, HANDOFF.md). 304/304, 0 warnings.
The creator couldn't add a webcam to Live (grayed app-wide) and nothing in the
app explained why. Ground truth from the live DB: one Webcam identity AND one
WebcamSceneConfig in the Chat scene — so the gray was the single-identity rule
working, but the reason was unobservable. This slice makes the webcam a
resource the app validates and locks, mirroring how OBS reserves its devices.
At startup we enumerate the OS once (ValidateWebcamResourceStartupAsync, fired
after LoadLayout, stored as WebcamStartupValidationTask for tests to await):
- 0 webcams -> app runs on, layer inactive, no alarm
- exactly 1 -> attempt CameraManager.AcquireAsync as an app-wide lock; on
failure show a persistent red alert at the bottom of the Layers panel
(WebcamLockAlert + Retry) that re-polls every 5s and clears itself the
moment the camera locks, or on any first real frame
- >=2 -> deliberately no auto-lock; camera selection belongs to the App
Settings dialog (gear) — next slice
CameraManager.IsRunning(deviceId) tells the pass a session already exists
(started OR still starting) so loaded identity configs count as the lock and
the pass never double-acquires. Test seams mirror LayoutPathOverride:
CameraEnumeratorOverride / CameraFrameSourceFactoryOverride so the startup
probe never touches real hardware under test.
Good Dog test: WebcamStartupResourceTests x3 — single-cam locked + app runs on,
zero-cams no-alarm/no-lock, lock-fails -> red alert -> Retry -> clears. Full
suite 304/304, build 0 warnings, scope-check passed.
Docs in-commit: TASKS Open items + ai.md Webcam section + HANDOFF rewrite.
Also corrects the record: 'NVIDIA Broadcast opens the webcam exclusively' was a
suspect-list claim (CameraConflictProbe reads process names only, no handles)
— not restated as fact.
LeftPanel.xaml(.cs) takes the Row 1 Col 0 panel (layers + properties editor and
the live chat list). All property-editor/chat handlers moved into the control
(SourceNameBox edit, AddSourceButton menu, chat autoscroll self-subscribed on
Loaded, mirror/clip-shape, remove-source, background capture change/refresh,
opacity slider label, list drag-to-reorder, snapshot revert/accept family).
Window keeps only shell + routing: ctor wiring (_leftPanel), geometry, hotkeys,
taskbar overlay, BackToAbout, Window_PreviewMouseLeftButtonDown (uses
_leftPanel.IsClickInside), UpdateSelectionOverlay (calls _leftPanel.OnSelectionChanged).
Zero behavior change; build 0 warnings; 246 pass, only the 2 known failures.
Docs: ViewModels/index.md Phase 2 tracker updated in same commit.