Commit Graph
120 Commits
Author SHA1 Message Date
gramps 005b459fe0 feat(alerts): a Test alert button in the alert properties
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.
2026-11-01 14:04:20 -08:00
gramps 754377c5db ui(audio): every header label in the sound area uses one colour
MIC's device name was the only header label at full-strength OnSurface (it was brightened
because it shows a choice), and the lip-sync reading was Tertiary — three different values
across four bays in the same row. All four header labels are now Brush.OnSurfaceVariant, the
standard secondary-text token. No other colours touched.
2026-11-01 13:59:57 -08:00
gramps b75c2b3001 ui(audio): one row grid across the dock — ladders on a line, volume bars on a line
Every bay is now header / ladder / controls. Lip-sync has no ladder, so it holds an empty 20px
row (10px meter + the two margins) that keeps its slider exactly level with the other three,
and MIC's ladder gained the missing 4px bottom margin that had its slider sitting 4px high.

Presets stay under the slider in the narrow lip-sync bay.
2026-11-01 13:57:36 -08:00
gramps 2fedc7cb64 ui(audio): the lip-sync slider moves up one line — onto the header
Not under it: the offset slider now rides the header line beside the LIP-SYNC badge and the
reading, and the preset row is what stays below. The box is narrow, so a slider on its own line
was the only thing making it two-and-a-bit rows tall.
2026-11-01 13:56:53 -08:00
gramps 789272142d fix(build): the wordmark number counts commits again, not compiles
GenerateBuildStamp read BuildCounter.txt, added 1 and wrote it back on every CoreCompile, so
the number tracked BUILDS: a day of iteration reached #1122 against 563 commits. It now uses
git rev-list count of HEAD, which is what the comment always claimed (and why the file existed
in .gitignore). No counter file, no drift; a missing git still falls back to the start.

Also moves the MIC and lip-sync volume bars up one line: both were pinned to the floor of their
bay by a star row plus VerticalAlignment=Bottom. They now sit directly under their headers,
with the auto rows sized to content.
2026-11-01 13:56:17 -08:00
gramps 35c7809c28 fix(audio): bay ladders fill their bay again — the 320px cap was a mistake
Capping the three bay meters at 320px and left-aligning them made each ladder look cut off
mid-bay, which is what the creator saw as deleted bars. The caps are gone; the master ladder
keeps its own 420-900px range so it stays the longest without shortening anything else.
2026-11-01 13:52:31 -08:00
gramps 4c4f22e6bb ui(audio): the master ladder is always the longest one
The master meter was a fixed 420px, but each sound bay's ladder fills its column — on a wide
window a bay could be longer than the master, which is the opposite of what was asked.

Master now stretches between 420 and 760 px, and the three bay ladders cap at 320 with the same
cap for each, so they stay the same relative size to one another and stay shorter than the
master at every window width.
2026-11-01 13:49:05 -08:00
gramps f8e4f2cf29 ui(audio): master meter centred and biggest; TRAX gets its own bay and meter
Creator rulings, 2026-11-01.

- Master strip is 44px (from 36) and its meter is now the largest on screen: 420x14 with 32
  segments, centred under the preview, with a 12px MASTER label — bigger than the 9px bay
  labels. L/R imbalance is a flag on the left, MONO and the session clock on the right.
- The kbps / fps / dropped counters are gone from that line: the live readouts already sit
  above the preview, and the duplicate was noise rather than telemetry.
- TRAX is its own bay (MIC | GAME | TRAX | LIP-SYNC) with its own meter bound to
  TraxMeterFraction. It lived inside the game bay with no ladder, which meant whether the bed
  was actually playing could not be answered from the dock — and the player already measures
  its own output.
- Lip-sync is narrow (0.45 star) since it is one slider and three presets; the three sound bays
  are equal width, so their meters keep the same relative size.

AudioDockLayout moves with it (156 = 112 + 44; collapsed 44) and the layout test is updated.
TraxBayTests now asserts the opposite of the old ruling: the bay HAS a meter, and the game bay
no longer carries it.
2026-11-01 13:48:28 -08:00
gramps 9b337ad91b feat(blur): the brand logo cannot be blurred
The blur is universal, so it would swallow the logo along with everything else. The compositor
now defers every visible brand logo: skipped in both element loops, blitted immediately AFTER
the blur group, before the flash/social/ticker overlays. One logo per scene, so it is cheap.

Preview mirrors it: the staged scene's brand logo is drawn above the blur host (StagedBrandLogo),
so what you see in the editor is what the encoder gets.

Test: a blur covering the logo's area leaves the logo's pixels exactly white while the coloured
background beneath it blends.
2026-11-01 13:32:23 -08:00
gramps bb51164f2f feat(blur): universal — the blur covers every layer, webcam included
The creator found the use case that reframes the feature: a moment of privacy while the webcam is
on. A blur that can only cover the desktop cannot do that.

- SceneCompositor applies the blur group AFTER every element is composited (still before the
  flash / social bar / ticker / zoom overlays), so it covers webcam, chat, alerts — anything. Its
  position in the layer list no longer affects the output, so it is added at the top and the
  reorder clamp is gone.
- Supersedes the earlier "blur sits below the brand" rule: a universal blur does obscure the logo
  when it covers it. Called out in frame-pipeline.md so it is not re-litigated.
- Preview mirrors it exactly: the blur host moved OUT of CanvasGrid to a sibling above it, so the
  VisualBrush samples every layer (and cannot sample itself). It used to sample only the backdrop,
  which is why a circle over the webcam would have shown sharp pixels in the editor and blurred
  ones in the stream.
2026-11-01 13:29:15 -08:00
gramps bb80ec8370 ui(properties): geometry for every placeable layer, and no fake opacity on a blur
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.
2026-11-01 13:20:13 -08:00
gramps 1416548f24 feat(blur): the editor shows the blur — whatever is beneath it is blurred, in the preview
The preview canvas is WPF visuals; a blur layer contributes no visual of its own, so until now
the creator saw nothing where the blur was and could not tell what it was hiding.

Each visible blur now draws a rectangle (or ellipse) filled with a VisualBrush of the whole
BackdropLayer — the desktop capture plus the background art — sampled at the blur's own rect and
carrying a BlurEffect at the same strength the compositor uses. The host canvas sits after the
backdrop and before the element layers, which is the model's own z-rule, so the preview shows
exactly what the output will: desktop blurred, brand and everything above it untouched.

Rebuilt on scene change, on add/remove, and on any blur property change (position, size, shape,
strength) — the brush samples the live backdrop, so the picture keeps moving underneath.
2026-11-01 13:12:27 -08:00
gramps 71d0e87cac feat(blur): a privacy blur layer for the Live scene — boxes and circles over the desktop
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.
2026-11-01 09:55:04 -08:00
gramps 2e12c5651e ui(drawers): 20px of content padding on the right of every pull-out
The five drawers each sized their content Grid to Width=320 inside a panel that itself
animates to 320, with 14px margins — so the content box was 348 wide in a 320 window and its
right edge was clipped. That is why every drawer's content jammed into the right margin
regardless of the margin value.

The Grid now fills the panel (no fixed width) with Margin 14,10,20,12: 14 on the left, 20
between the content and the right edge of the div, which is the requested gutter.
2026-11-01 09:35:37 -08:00
gramps a543d3629b ui(settings): 20px right gutter on the drawer, rail tabs centred on the preview
Two build-1047 notes. The settings content gets a 20px right margin (plus 4 left) inside the
scroll viewer, so text and controls stop running into the panel edge and the scrollbar has
somewhere to live. The rail tab stack is VerticalAlignment=Center instead of Stretch, so the
whole group of tabs sits centred against the preview rather than hanging from the top.
2026-11-01 09:22:02 -08:00
gramps a35ec0c5f8 refactor(settings): a pull-out drawer, with Zoom as a category — and the menu moves to the wordmark
Creator directive, 2026-11-01. Settings stopped being a centred modal.

- **Drawer** (Controls/SettingsDrawer): hosted in PreviewPane's pull-out stack, 320px, the same
  animated-width pattern as Stream Settings / YPP / Socials / TEST, opened by the gear rail tab
  on a single left click. SettingsDrawerOpen is exclusive with the other four, both directions.
  This also removes the modal's MaxHeight-700 scroll problem for good: the drawer is the height
  of the preview.
- **Categories**: headings with controls indented beneath them. Zoom now owns *Zoom view shape*
  (Circle / Phone / Tablet) and *Zoom box follows*; Recordings, Brand logo and Scene transition
  are their own categories.
- **The app menu moved to the LlamaCasty wordmark** (right-click → Report Bug / Feature Request /
  About). Left-click still opens About. The gear tab no longer carries a menu at all, so clicking
  it does exactly one thing.

OverlayHost keeps only the Bug / Feature / About panels; its brand-logo handlers and the dev-panel
injection moved with the settings block. BrandLogoPreviewTests follows the control (same assertions,
new path). Release 0 warnings; 15 tests pass across brand logo, zoom, entitlement and preview
history.
2026-11-01 09:16:27 -08:00
gramps c418288215 fix(settings): the App Settings panel scrolls — the lower options were clipped off-screen
The shell is MaxHeight 700 and the settings panel had grown past it with no ScrollViewer,
so everything below roughly the transition section was unreachable. That is why 'Tablet
(Horizontal Rectangle)' existed in the XAML and in the model but never appeared in the app:
the shape control itself was cut off, not the option.

Panel row is now * with an Auto ScrollViewer, so every section is reachable and the next
one added cannot silently fall off the bottom.
2026-11-01 09:08:08 -08:00
gramps 517e60d593 fix(zoom): the magnifier freezes the layers — no picking up objects while it is open
While zoom is active a left drag moves the box and never selects or moves a layer beneath
it (creator ruling 2026-11-01). MouseMove already owned the zoom drag; the missing guard was
in MouseLeftButtonDown, which happily hit-tested a layer first and left a stray selection
plus a half-started drag behind the moving box.
2026-11-01 09:06:05 -08:00
gramps 15734a5477 feat(zoom): Ctrl closes the box, and a Follow pill that re-anchors the capture
Two creator directives, 2026-11-01.

**Ctrl-down dismisses an open zoom box.** Window-level PreviewKeyDown, so the same hand that
opened it can put it away. It cannot cancel itself at creation: Ctrl is already held before
the right-click that makes the box, so the keydown fires while nothing is open.

**Follow mode, default Anchor.** A PillToggle in App Settings ("Follow the box"), persisted
as Zoom.FollowBox. Off — the default — keeps the 2026-10-08 model: the capture stays on the
clicked point and dragging only moves the box over it. On, UpdateZoomMove re-anchors the
capture to the box centre (half the box's size, clamped per axis), so the magnifier shows
whatever it hovers. Flipping to Follow while the box is open snaps it immediately.

Three tests: anchor drags leave CaptureX alone, follow moves it to the expected quarter-offset
centre, and the setting round-trips through LayoutStore (tested against the store, not via two
view models — two live VMs fight over the temp DB, which is a fixture problem, not a bug).

Both new test classes share a collection tag: MainViewModel.LayoutPathOverride is static, and
xUnit runs classes in parallel, so without it the two view models collided (one failure in a
combined run, clean when run apart).

Release build 0 warnings; 14 tests pass across zoom, entitlement and preview history. Debug
build is blocked by the running app (PID 47388) — close it and the exe updates.
2026-11-01 08:51:28 -08:00
gramps 1d2d58f1d7 fix(zoom): the gesture now creates the box instead of only closing it
Creator report: Ctrl+right in edit mode and left-click in preview did nothing when
neither streaming nor recording. The bindings were correct; the action was not.

Two causes, both in the model rather than the handler:
- ActivateZoom opened with `if (!(IsLive || IsRecording)) return;` — the box could not be
  created outside a session at all.
- ToggleZoom only ever called DismissZoom, so "toggle" could turn it off and never on.

Now ToggleZoom(x, y) activates at the cursor or dismisses if already open, and the live-only
guard is gone — the magnifier is an editing aid as much as a live one. Both call sites pass
the canvas point, so the box still anchors on the click rather than the frame centre.

Debug 0 warnings; ZoomBox + ZoomMagnifier tests pass (3).
2026-11-01 08:43:17 -08:00
gramps 4b91760f92 fix(preview): Ctrl+right no longer opens the menu, and the social bar keeps its click
Two event-pairing bugs found by validating every handler instead of trusting the table.

1. Ctrl+right-click opened the context menu in edit mode. Cause: WPF's PopupControlService
   opens menus on right-button-UP, and only the DOWN event was being handled — so the zoom
   toggled and the menu appeared under it. Both events are now handled when Ctrl is down.
   (The creator saw this in edit mode; preview mode had the same latent bug.)

2. The social bar's left-click was dead in preview mode and dragged the layer beneath it in
   edit mode. Cause: PreviewGrid handles the TUNNELLING PreviewMouseLeftButtonDown, which
   fires before the bar's bubbling handler — and since preview-mode left-click now toggles
   zoom, that handler swallowed the bar's click too. The preview handler now steps aside when
   the point is inside the bar's bounds (SocialBarElement.RenderSize, translated to canvas).

Audited and left alone because they are correct: left-down → resize handle / element drag /
deselect; move → zoom drag before edit drag (by design); left-up → EndPreviewDrag; right-up
→ zoom-drag release then the Ctrl swallow.

Build Debug 0 warnings, 22 tests pass across ZoomBox, PreviewHistory and SocialBar.
2026-11-01 08:19:17 -08:00
gramps 279eb08dea feat(preview): right-click is the menu, zoom is the single-handed gesture
Creator ruling 2026-11-01, and the reason is live-stream ergonomics: no two-handed
chords while a broadcast or recording is running.

- Edit mode: left selects / drags / resizes, right opens the context menu, Ctrl+right
  toggles the zoom box. Chords are acceptable here because editing is a stopped activity.
- Preview mode: left toggles the zoom box, right opens the context menu, and Ctrl+right
  is swallowed outright — handled rather than left to fall through, so Ctrl+right cannot
  open a menu by accident while one hand is on the mouse.

This also removes the old live behaviour where right-click activated the magnifier at the
cursor point (and dismissed it). Zoom in preview is now a plain toggle; if point-zoom is
wanted back it needs a one-button gesture, not a chord.

Build: Release 0 warnings. Debug is currently blocked by the running app holding the exe
(PID 47252) — close it and the Debug build copies normally.
2026-11-01 08:10:04 -08:00
gramps fdb27f0758 feat(ui): gear to the rail, EDIT on the panel edge, preview undo/redo
Three creator directives (2026-11-01), done ahead of the surge.

**Gear moved to the preview rail** — first tab, orange (Brush.Attention), gear icon
only. Left-click opens the same app menu the top bar gear owned (Settings / Bug /
Feature / About), right-click goes straight to App Settings; both behaviours moved
with it, so nothing lost an entry point. TopBar keeps the wordmark and EDIT.

**EDIT right edge aligned to the layers panel.** The work area is margin 8 + a 360px
column, the top bar is padding 16, so the shared edge is 352 inside the bar. TopBar
column 0 is now a fixed 352 grid with EDIT right-aligned in it — alignment by
arithmetic instead of by eye.

**Preview undo/redo: Ctrl+Z / Ctrl+Shift+Z.** Models/PreviewHistory snapshots element
REFERENCES plus geometry, visibility, order and membership — a deep clone would mirror
every property of a dozen element subclasses and quietly miss the next one added.
One snapshot per gesture (pushed at drag/resize start, never in the move loop), bounded
at 50, redo survives multiple undos, cleared on layout load. Gated on EditingActive:
undo while the preview shows program output would rewrite a scene nobody is looking at.

Honest scope, in the type's doc comment: layout edits only. Colour/opacity/border/font
edits are not captured yet, so Ctrl+Z after one of those restores the last layout change.

6 tests in PreviewHistoryTests; 19 pass across it and the neighbouring UI classes; Debug
and Release both build at 0 warnings.
2026-11-01 07:46:10 -08:00
gramps 27fc6fb507 feat(profile): the free budget is pixels and fps, enforced once — with a dev-only release hatch
The paid delta is output quality, so the rule needs to be measurable rather than a word:
921,600 pixels and 30 fps, fail-closed. Models/OutputProfile.cs is that rule as pure
policy — a permitted tier passes through untouched (Pro, or the free tier's own 720p30),
a disallowed one resolves to the best tier the budget allows and carries ITS bitrate, so
a capped user is never offered an 8 Mbps budget for a 720p frame.

Applied once, in BuildEncoderOptions. That is genuinely one place: the stream and the
local recording are two output blocks of ONE ffmpeg process fed by one EncoderOptions,
so there is no second encoder to forget. (commercial.md claimed "both encoders — never
two checks"; the code is stronger than the doc, and the doc is corrected.)

Developer override, as asked: a "Developer mode — all resolutions" checkbox in App
Settings, bound to MainViewModel.DeveloperMode, which feeds HasProOutput. It is not
hidden in a release build — Controls/DeveloperPanel's Page and Compile items are removed
when Configuration != Debug, and OverlayHost adds it at runtime inside #if DEBUG, so a
shipped exe has no BAML for it at all. The setter also refuses the value when
DevBuild.IsDevelopment is false, which is now the single source for that question (the
scoreboard partial had its own duplicate const).

Two traps worth their lines: a `const bool` dev flag makes `if (!Flag) return;` an
unreachable-code warning in one configuration or the other (CS0162), so DevBuild uses
static readonly; and a Condition on an ItemGroup is invisible to a test reading
item.Attribute("Condition") — read the parent.

Tests: 14 pass across OutputProfileTests, AssemblyIdentityTests and StorePackagingTests.
The invariant test is the one that matters: iterate every shipped tier under a free
entitlement and assert none emits more than the budget, so a future 4K tier fails here
instead of shipping to everyone. App builds Debug and Release at 0 warnings.
2026-11-01 07:11:49 -08:00
gramps abfab25685 build: the exe is LlamaCasty.exe, and the publish script proves the artifact
Renames AssemblyName to LlamaCasty before the first Steam depot — Steamworks is explicit
that renaming a live exe is painful, so this had to land first, not later.

Four assembly-qualified pack URIs carried the old name, not three: MainWindow (window
icon), OverlayHost (logo), RenameRecordingDialog and KeepBroadcastDialog. Pack URIs
resolve by assembly name at runtime, so a stale one compiles, passes every test and then
silently renders nothing the first time that view opens. AssemblyIdentityTests guards
both halves — the assembly name, and no source XAML still naming the legacy assembly.
Packaging/Package.appxmanifest and package-msix.sh carry the exe name too.

scripts/publish.sh replaces the by-hand publish: single-file win-x64, ReadyToRun,
ffmpeg beside the exe, and it refuses to finish if ffmpeg, LICENSE, PRIVACY.md or
THIRD-PARTY-NOTICES.txt are missing, or if a test assembly leaked in.

That last guard exists because the first publish was wrong in a way no test could see:
PublishSingleFile + IncludeNativeLibrariesForSelfExtract bundled the libav*.dll family
into the exe — a 9-file output with no ffmpeg folder and a 182 MB binary, so the locator
would have found nothing at runtime. ExcludeFromSingleFile="true" on the content keeps
the folder a folder. Diffing against a control publish without the single-file flags
found it; guessing at item globs wasted two attempts first (recorded in MyMistakes.md).

Measured: 127 MB exe + 129 MB ffmpeg = 257 MB depot. Tests: AssemblyIdentity,
StorePackaging, BuildStamp, BundledFonts all pass; app builds with 0 warnings.
2026-11-01 07:01:50 -08:00
gramps c03acd0aa0 fix(scoreboard): the defaults were never applied — a record struct zero-initialises
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.
2026-10-30 15:24:14 -07:00
gramps 28525c90aa feat(scoreboard): scroll speed is a property (20-400 px/s, persisted)
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.
2026-10-30 15:19:28 -07:00
gramps 882b8551d9 fix(scoreboard): the strip and the social bar now really share the frame
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).
2026-10-30 15:11:35 -07:00
gramps e27e79962c fix(scoreboard): box follows the rendered strip, opacity proven, plate always settable, dev override
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.
2026-10-30 15:09:19 -07:00
gramps 8caee21b9a fix(scoreboard): the strip finally reaches the preview, and dev labels are orange
"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.
2026-10-30 15:05:33 -07:00
gramps 9405c54750 feat(scoreboard): dev mode can configure the board without signing in
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.
2026-10-30 15:01:32 -07:00
gramps e6abc13461 feat(scoreboard): a DEBUG-only "Simulate" switch, so the strip is provable in the preview
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.
2026-10-30 14:58:56 -07:00
gramps ea1017776d feat(scoreboard): the properties are inert until the creator is signed in
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.
2026-10-30 14:54:52 -07:00
gramps 38c2ad6555 feat(scoreboard): a master on/off switch that also stops the polling
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.
2026-10-30 14:54:05 -07:00
gramps 85a4a8a7d3 feat(scoreboard): the layer is available to ALL scenes (YT_ACTIVITY_TICKER)
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.
2026-10-30 14:50:20 -07:00
gramps 534f56a7c4 feat(scoreboard): units 7+8 — the layer's properties panel (facts, colours, plate, size)
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).
2026-10-30 14:41:31 -07:00
gramps ec293e54b0 feat(dock): TRAX becomes a background level, not a channel strip
Creator ruling (2026-10-30): TRAX is background music and is controlled separately from desktop and
mic — so KISS it:

- TRAX loses its own ladder and slider. It is one number plus ±5 dB buttons, disabled at the ends
  (−60 dB floor, +6 dB ceiling).
- It STARTS at −50 dB, taken from the creator's own meter readings, and moves in 5 dB clicks. If that
  proves inaudible on their setup, six clicks lands at −20 — the buttons exist for exactly that, and
  `TraxDefaultDb` is one constant.
- Layout follows: Desktop's volume now sits directly beneath its ladder, and the TRAX row (name,
  play/pause, level) sits beneath that, inside the GAME bay. The bays are MIC | GAME+TRAX | LIP-SYNC.
- The metering tap on the player stays (it is how "is the music producing sound" is answered) but has
  no ladder of its own; the desktop bed already shows the music.

Guards updated: TraxBayTests now asserts the stepper wiring and that the row has NO SegmentMeter;
GameBayTests asserts the desktop fader sits below the desktop ladder.
2026-10-30 13:46:05 -07:00
gramps 93dc6592ac docs(dock): the desktop ladder says it includes TRAX
TRAX is not a separate bus: it plays through the default output device and WASAPI loopback taps
that device, so the music genuinely is in the desktop bed — the Desktop ladder moving with a track
is correct, not a leak. The bay now says so on the label and in a tooltip, because two moving
ladders for one sound invites exactly this question.

No behaviour change: routing stays right (the stream hears the music once, via loopback).
2026-10-30 13:34:27 -07:00
gramps a54149bd7c feat(dock): TRAX gets its own ladder — measured at the player's output
Creator: "why is there no meter for when trax is playing?"

Routing answer: TRAX rides the desktop loopback, so it already moved the GAME ladder — there was no
separate TRAX signal in the mix. But that ladder cannot answer the question a deaf creator actually
has: "is the music producing sound at all?" The play glyph cannot either — an open file with a dead
decoder still reports Playing.

So the player now measures its own output: \`MusicPlayer\` wraps the volume stage in an NAudio
\`MeteringSampleProvider\` and exposes \`Level\`; \`ITraxPlayer.Level\` keeps the fake honest. The VM maps
it through the same display scale (\`TraxMeterFraction\`), zeroed when not playing, and the TRAX bay
carries a 24-segment ladder above its fader.

This reverses my earlier "no ladder, nothing honest to show" — the honest thing arrived with a real
measurement. TraxBayTests flipped accordingly (it now asserts the ladder AND its source).
2026-10-30 13:22:13 -07:00
gramps 16689c0a80 feat(dock): the dock is now the only audio surface (unit 10, final)
Deletes the preview's live-controls strip — mic meter + speaker + volume, the game-audio chip,
the TRAX button and the lip-sync slider — and the three code-behind handlers that served it.

One sound surface instead of two: the same gains in two places was the original duplication, and
two places to set a gain is how a creator ends up with a meter that disagrees with what they hear.
~16.7 KB of XAML and 60 lines of event code gone.

Verified: the preview still loads and its interactions still work (selected-layer hit testing, the
socials preview, the brand-logo preview), plus the theme's resource guard.
2026-10-30 13:19:58 -07:00
gramps 5889d71db6 feat(dock): master strip + MONO output (the accessibility switch)
Unit 9, the one that matters most for this creator.

MASTER STRIP (top, nearest the preview):
- one ladder fed by the MEASURED mix — `AudioMixer` now measures the buffer the encoder receives
  (`MasterLevel`, `MasterImbalanceDb`), so the master cannot show a source instead of the output
- `-12.4 dB` readout, and ONE L/R flag that appears above a 6 dB gap instead of a second ladder
- bitrate / FPS / dropped / duration from the existing health poll

MONO OUTPUT:
- `AudioFold.ToMono` averages each stereo pair into both channels, applied after the limiter and
  the sync delay, so the stream AND the recording get what was folded
- `MonoOutput` is persisted (`Audio.MonoOutput`) and restored at startup: an accessibility switch
  that resets each session is an accessibility failure
- one click in the strip, remembered forever

Guard: AudioFoldTests (hard-left reaches the right at half amplitude, hard-right — the inaudible
case — reaches the left, centred material keeps its level, mono buffers untouched) and
MasterStripTests (the strip binds the measured mix + the switch, the store round-trips it, and the
fold is the tested helper rather than new arithmetic). 33 audio tests green.
2026-10-30 13:18:23 -07:00
gramps a6f63f56f3 feat(dock): the LIP-SYNC bay — offset, readout, zero detent
Unit 8. Re-houses the shipped TASK 22 offset in the dock:
- slider −200…+200 ms, snapping to 5 ms ticks (the existing property still clamps and persists)
- readable readout: "0 ms (aligned)" when right, signed otherwise ("+50 ms", "−120 ms")
- presets 0 / +50 / +100; 0 IS the detent — a button is easier to hit than a snap, and "the lip-sync
  is right" deserves one click.

Guard: SyncBayTests pins the binding, the readout, the presets and the snapping, and that the
offset stays the persisted property (no second source of truth).
2026-10-30 13:16:19 -07:00
gramps 7339df5fcd feat(dock): the TRAX bay — track, play/pause, level, duck badge
Unit 7. The bay shows the track name (left-click toggles playback, right-click opens the picker —
the label stays the control), a play/pause button, the duck badge, and a dB level.

No ladder here, deliberately: nothing measures the music bed, and a decorative ladder would break
the measured-not-painted rule. A test asserts the bay has no SegmentMeter.

The level is real: `ITraxPlayer.UserGain` is a new creator gain multiplied onto the fixed design
volume, separate from `LocalGain` (which follows the desktop mute). The VM's `TraxVolumeDb` is the
same dB scale as the other faders, so unity means the bed's design level.
2026-10-30 13:15:17 -07:00
gramps a1d8e761ea feat(dock): the GAME bay — dB fader, measured ladder, mute, real duck badge
Unit 6. Mirrors the mic bay for the desktop/game bed, plus the badge that matters to a creator who
cannot hear the duck: it shows while the ducker is actually attenuating, and it prints the gain the
mixer applied (−12.0 DUCK by design).

- `AudioMixer.DuckGain` exposes `AutoDucker.CurrentGain` instead of leaving it internal.
- VM: `GameVolumeDb` / `GameVolumeDbLabel` (fader face), `GameMeterFraction` (measured level),
  `IsGameDucking` + `GameDuckLabel` refreshed on the level tick — the same moment the ducker runs,
  so the badge cannot lag behind the mix.
- The bay: header chip + source, ladder, mute dot (uses the meter band, so mute reads at a glance),
  dB readout, fader.

Guard: GameBayTests — the dock binds all four, `AutoDucker.DuckGain` is −12.04 dB and the label is
produced through the same dB scale as the faders, and the mixer property exists so the badge cannot
become a hardcoded number.
2026-10-30 13:12:10 -07:00
gramps 3524ede480 feat(dock): the MIC bay — dB fader, measured ladder, mute, picker
Unit 5. The dock's first working bay:
- 24-segment ladder fed by `MicMeterFraction` — the MEASURED display level (already scaled by the
  volume and zeroed when muted), never the gain setting.
- Fader in dB via `MicVolumeDb` (−60 … +6, 0 = unity) with a mono-weight readout
  (`MicVolumeDbLabel`), so "−6.0 dB" is what the creator reads instead of "0.50".
- Mute button whose dot uses the existing MicStatusBrush; clicking the device name opens the mic
  picker, so the label stays the control (the KISS rule: one place to change the mic).

The preview's own mic row stays for now — it is removed in the final unit, after the dock bays are
complete, so nothing is lost if a bay misbehaves.

Guard: MicBayTests — the bay binds the dB face and the measured level (not the gain), the fader
range IS the scale's range, the VM surface exists, and mute still zeroes the ladder.
2026-10-30 12:46:57 -07:00
gramps 7d8021ca09 fix(dock): master strip on top, against the preview
During a live session attention sits on the preview, so the master sound bar belongs as close to it
as possible — master row first, the four bays below. Collapsed mode is unchanged: 36px is the
master strip on its own, so it stays exactly where the eye already is.
2026-10-30 12:45:12 -07:00
gramps 1a9e8b8d36 feat(audio dock): the shell — 168px, auto-collapsing, replacing the empty bottom bar
Unit 4 of the sound-bar refactor. `Controls/AudioDock` takes the bottom row that held the empty
`BottomBar` spacer (deleted): four bay containers (MIC / GAME / TRAX / LIP-SYNC) above a 36px
master strip, all token-styled.

Height comes from `Models/AudioDockLayout`, not from a setting: 168px normally, 36px below a 900px
window height. At the 1366×768 minimum a full dock leaves ~398px for the preview, so the collapse
is mandatory, not cosmetic. The dock listens to its window's SizeChanged and re-applies.

Nothing here pretends to be a meter yet — the bays and the master readouts fill in over the next
units, each with its own test.

Guard: AudioDockLayoutTests pins both sides of the threshold (768 collapsed, 1040 expanded), the
boundary itself, and the two heights as the design's numbers.
2026-10-30 12:37:17 -07:00
gramps 0f62e55474 feat(audio): the segment meter control (unit 3 of the dock)
`Controls/SegmentMeter` draws a 24-segment ladder in ONE OnRender pass — no per-segment elements
updating on the UI thread, which is the difference between a meter and a UI thread tax. Level and
peak are display fractions (what was MEASURED), never gain settings; vertical for the bay ladders,
horizontal for the master strip.

Bands come from `Helpers/MeterScale` (nominal/caution/peak, DESIGN.md thresholds) and live as
dependencies of the control, so the painting logic is pure and testable: the band a segment paints
is the level it measured. Brushes resolve from the theme tokens via SetResourceReference, so the
ladder cannot drift from DESIGN.md.

Peak-hold is the reason the header exists: a bar that follows the moment hides clipping, and
clipping is the one audio fault that cannot be fixed after the stream.

Guard: MeterScaleTests — band edges (inclusive at −12 and −3, as the standard states the ranges),
the −60…0 display scale, and peak-hold rising instantly then decaying at 0.35/s.
2026-10-30 12:36:05 -07:00
gramps 17249229c8 feat(top bar): EDIT moves beside the gear; the gear wears the app's orange
- EDIT leaves the centre session cluster and sits immediately right of the gear, in the brand
  column — so mode lives with the controls that configure the app, not with the session buttons.
- The gear is a LlamaCasty control, so its icon is `attention` orange instead of muted grey.

Guard: UiShellTests asserts the gear fill and that EDIT appears before the centre (session)
cluster, so a later reformat cannot quietly put it back.
2026-10-30 11:18:36 -07:00
gramps fbeb04385b feat(top bar): EDIT decides live vs test on the primary button — the TEST button is gone
Creator ruling (2026-10-30):
- EDIT off + ON-AIR armed → the primary button reads "Go Live" and runs the livestream.
- EDIT on + ON-AIR armed → the same button reads "Test Stream" and runs the test broadcast.
- REC armed (or nothing armed) → "Start Recording", whatever EDIT says; there is no test
  recording, and an unarmed start records locally rather than doing nothing.

So the fifth button was redundant: `ShowTestButton` no longer feeds any control. The decision is a
pure function (`Models/SessionStartDecision`) so the whole matrix is testable without a window;
`PrimaryStartCommand` follows the same kind, so label and action cannot disagree.

Guard: SessionStartDecisionTests pins the four cases, including the no-dead-ends fallback that my
first version got wrong (unarmed returned Live instead of Record — the test caught it).

Note for the v1 gate: the "hide TEST at v1" item is now satisfied by construction — the test path
is a mode of the primary button, not a separate control.
2026-10-30 11:14:33 -07:00