Merges the 2026-10-29 Stitch export into the existing 2026-10-26 token sheet and promotes the file from "redesign exploration" to the normative standard: - type scale (32→10px), spacing, radii and elevation levels — the three scales the old DESIGN.md listed as missing - meter thresholds: cyan ≤ -12 dB, amber -12..-3, red > -3 - interaction rules: focus ring, hover step, 150ms/90ms easing, no springs Four export tokens rejected as Material-3 warm-seed artifacts (recorded with reasons): tertiary #4cd7f6 (kept #38bdf8), outline #ab8986, outline-variant #5b403e, on-surface-variant #e4beba (kept slate #94a3b8). Product rulings recorded: #e94560 retired as the app's general accent (blue takes every interactive role, red is live/record only, wordmark artwork untouched); error gets its own surface (#93000a) so it cannot be mistaken for on-air red. The export's dock, four bays, page-nav sidebar, CPU/GPU/NVENC readouts and acrylic blur are explicitly marked exploratory, not spec — the design principle is a simpler surface, not hardware cosplay. On-stream output styling stays outside this theme. Falsified facts fixed in the same commit: product.md brand section now points at DESIGN.md for UI tokens (MARCOM.md stays the marketing palette), and ui-surface.md no longer calls DESIGN.md an unreconciled exploration. Docs-only unit: scope-check run, no build.
32 KiB
UI surface: control room, patterns & transitions
The control-surface layout, scene strip, preview rail, hotkeys, broadcast drawer, toasts, transitions, brand logo, launch geometry, and the theme/properties-panel conventions. Read before changing anything the creator looks at.
Launch POSITION is derived, never restored (2026-10-04)
Creator ruling: the window must open in the upper-left of its screen with ~75px padding, because
the restore path was putting it below the fold. MainWindow.RestoreWindowGeometry now ignores the
saved position entirely and calls the pure TopLeftPlacement(workArea, w, h, padding), which takes
the work-area origin plus LaunchPadding = 75 and clamps so the window is never pushed off-screen:
a window that fits stays wholly visible, one too big to fit falls back to the work-area origin.
LaunchPadding is in device-independent units, so it scales with DPI instead of being a fixed
physical pixel count.
Size and maximized state are still persisted and restored — only position was dropped. The
clamp still applies to size: saved values are Math.Clamped to [MinWidth, work.Width], which is why
a saved 800×500 comes back as 1366×768 (the XAML MinWidth/MinHeight). Reverting this is
reverting the one block in MainWindow.xaml.cs; the saved values are still written, so nothing is lost.
Why the old clamp was wrong (do not "fix" it back): it tolerated Left as low as
work.Left - width + 120 and Top up to work.Bottom - 80 — ranges wide enough to leave the window
almost entirely off-screen while still looking like a successful clamp. A clamp that permits the
window to be ~unreachable is worse than no clamp, because it reads as a guarantee it does not keep.
BroadcastPullOutTests.Metadata_Persists_Window_Launches_TopLeft_And_UpdatePatchesRemote (renamed
from ..._WindowRestores_Clamped_...) is the integration guard — it asserts the constructor applies
the derived placement for both an on-screen and a wildly off-screen (-99999) saved position.
Placement math itself is covered by WindowPlacementTests (pure function, no HWND needed).
Key patterns
ViewModelBase.SetProperty<T>()for property change notificationsRelayCommandfor all button actions; commands gate on state (e.g. Start only when Offline). TypedCommandParameters — no stringly-typed command tokens: menu items that pick a source type pass the enum value itself (CommandParameter="{x:Static models:SourceType.DisplayCapture}"), so a typo breaks the build instead of silently adding an Image;AddSourcestill falls back toEnum.TryParse<SourceType>(..., true)for safety. The webcam item is its ownAddWebcamCommand(it greys out viaCanAddWebcamToActiveSceneand isn't aSourceType— webcams areWebcamSceneConfig, notSourcerows)- Audio is KISS by rule — the whole of audio is one knob: desktop/game audio is automatic (WASAPI loopback from the default output at unity, zero UI — "it just is"); the mic is the creator's only audio control — sound meter + mute button + volume slider (
MicVolume, defaults to 0.8) all sit together on the second line of the preview bottom row, BELOW the TRAX row, CENTERED beneath the preview panel. The TRAX button + Desktop Audio meter sit on the first line of that same row; Socials is on the preview rail. Meter: 288px, muted slate track (#3a3b52) with ruler graduations and muted yellow/red zone tints at 60%/80%; fill = green → yellow → red viaMeterFillWidth/MeterBrush; the meter is a READ-ONLY realtime level display — it shows the live input level scaled by the volume (raising the volume moves ambient noise up the bar), NOT the volume setting: the fill isMath.Min(1, AudioLevelMeter.ToDisplay(AudioLevel) * MicVolume)—ToDisplaymaps the raw linear RMS onto a −60..0 dBFS display scale, because real speech sits around −40..−20 dBFS (0.01..0.1 linear) which would leave a flat scale dead (AudioLevelis fed by the audio mixer once capture lands, 0 with no input) and 0 while muted. While the volume slider is being dragged the bar previews the slider position (SetVolumeAdjusting, fromPreviewMouseLeftButtonDown/Up+LostMouseCapturehandlers) so the creator sees where they're setting it; on release it returns to the live level — with no input it bounces back to 0, exactly as it does today. Clicking the meter does nothing; clicking the MIC label opens the mic picker (OpenMicPickerCommand), and the picked voice source name (MicSourceName) is shown left-justified INSIDE the meter bar (FontSize 10, ellipsized to the bar) — the fill runs at 75% opacity so the text and the ruler markings stay visible through it. Mute (ToggleMicMuteCommand/MicMuted) is a plain clickable speaker icon (MicSpeaker_MouseLeftButtonUpcode-behind handler — not a Button,Stretch="Uniform"so the glyph is never clipped) that swaps to a red do-not-symbol (slashed speaker) when muted. The slider and the speaker can never disagree:MicMutedis read-only, derived fromMicVolume == 0— sliding the volume off flips the speaker to muted (storing the prior level in_volumeBeforeMute), sliding it up from 0 clears the mute indicator (and the stored level); the speaker button just runs the volume to 0 or restores it (default 0.8 if unknown). Muting zeroes the meter; unmuting flashes the meter to the restored position for ~300ms (BeginVolumeFlash/EndVolumeFlashon a DispatcherTimer, cancelled if the slider is grabbed) before it returns to the live level. Line 2 of the footer holds everything else: stream stats (bitrate/fps/dropped/duration/health) on the left, quality dropdown + gear on the right. The slider is a slim dimensional style inThemes/Controls.xaml(gradient track, beveled green fill on a 5px pill, gloss-sphere thumb with drop shadow — deliberately NOT flat). No device pickers (never show device names — no "install a device you didn't know existed"), no filter stacks, no monitoring, no routing — OBS's confusion (dynamic mixer, unintuitive names, four required filters) is deliberately absent. A production-ready mic chain (high-pass → noise gate → compressor) will be applied invisibly in the mixer, unconfigurable. Capture runs only while live (privacy indicator stays off otherwise). Capture pipeline =IAudioSourceseam + NAudioWasapiCapture/WasapiLoopbackCapture+AudioMixer(pending — the UI is in place now). Mic mute icon (2026-08-13): a second 16px clickable glyph — a microphone, red + slash when muted — sits between the meter and the speaker (both mutes adjacent, spacing between the icons) and reuses the sameMicSpeaker_MouseLeftButtonUp→ToggleMicMuteCommandhandler. REC / ON-AIR pills + signs (TASK 18, 2026-08-29): the top-center area is now two sliding pill toggles (intent) next to two status signs (reality). REC pill (RecordPillOn) is the local-recording intent — works signed-out; ON-AIR pill (OnAirPillOn) is the streaming intent — greyed/disabled untilIsConnected. The REC sign (RecDotBrush/RecTextBrush/RecDotOpacity) is dark-gray + dim offline and green-tinged, pulsing only while actually recording (IsRecording), not merely pill-on. The ON-AIR sign (OnAirBrush=#555offline,#22c55elive) is green while actually streaming (IsLive); thePRIVATEbadge (IsLivePrivate) still shows when the broadcast is private. Both sign labels share theStatusSignTextstyle (Themes/Controls.xaml) so REC and ON-AIR can't drift apart — status color lives on the dot only, never the label. The elapsed timer shows whileIsLive || IsRecording. The connected account's avatar/name shows in the top bar next to the primary button (AccountAvatarUrl/AccountDisplayNameviaSyncConnectedAccount), so the creator always sees WHICH account will go live. - ViewModels are constructed in XAML (
<vm:MainViewModel/>as DataContext) - True decomposition beats partial-shuffling (2026-08-31, Commit G):
MainViewModelis a ~4100-line god-object split into 17 partials — but partials are a myth of decomposition: every partial shares the same class, sameSetPropertystate, same collaborators viaScenes/StagedScene/IsLive/Source.VideoImageSource, so an AI fetching one partial still reconstructs the whole class. The only thing that actually improves retrieval is a boundary where a feature owns its own state + collaborators.Services/ChatOverlayLayer.csis the model (extracted fromMainViewModel.Chat.cs194→44): the layer owns the message buffer,ChatBoxRenderer, fade/mock timers, preview renders, and the liveRenderFrame; the VM keeps only the binding surface (ChatMessagesdelegates to_chatLayer.Messagesso XAML + LeftPanelCollectionChangedhold; the two computed gate props stay on the VM because theirOnPropertyChangedis raised from VM setters).Services/AlertOverlayLayer.cs(TASK 43, 2026-09-24) is a second true component built fresh on the same contract — it owns the event queue, the 33ms playback ticker, the per-source preview renders and the liveRenderFrame, withAdvance(double)exposed as the deterministic test clock; the VM again keeps only the facade (RenderAlertBox,UpdateAlertBoxPreview,CanAddAlerts). Two COMPONENT layers, one facade partial. Diagnostic — glue vs. component: before extracting, countOnPropertyChanged/SetPropertytouches (glue) and bound-property reads per partial. Zero/low-glue + a cohesive state blob (renderer/timers/buffer) = extract (chat). High glue over an already-extracted service (Audio, Webcam, Background, Scenes —AudioMixer/CameraManager/ScreenCaptureManager/ChatBoxRendereralready exist) = leave as VM glue; forcing it adds coupling, doesn't remove it. Don't manufacture seams to hit a line count — the line count is a guideline for context, not a design goal. - SceneGraph component + baked-crust compositor (TASK 31):
Services/SceneGraph.csowns the scene collection (Scenes— the ViewModel'sScenesproperty delegates to it) and the element mutation surface (AddElement/InsertElement/RemoveElement/MoveElement, each invalidating the bake cache) plus queries that were scattered LINQ (GetBackground/GetWebcam/GetChatBoxes/GetSplitPoint/IsStatic). Elements exposeElementKind Kind(Static= images/background art,Dynamic= webcam/live capture/chat/web). The split point is the index of the first dynamic element;SceneGraph.GetBakedBasebakes/caches all static layers below it (keyed by scene id + static element identities), andSceneCompositor.BakeStaticBase/CompositeLayers/theRender(.., staticBase, split)overload composite the dynamic/above-split layers per frame.FramePump.RenderSceneuses the optimized path when aSceneGraphis wired in (falls back to full render without one). Invariant: dynamic-only pixel changes never invalidate; static layout/opacity/visibility/asset changes do (viaInvalidateBakefrom the VM's element-property and background-heal paths). Defensive deviation from the TASK 31 spec:ChatOverlayLayerkeeps takingIEnumerable<Scene>instead of depending onGetChatBoxes()— it is deliberately decoupled from the graph (its doc comment says "without owning the scene graph"); and the static background helpers (EnsureBackground/NormalizeBackgrounds) stay on the ViewModel becauseBackgroundTests.csunit-testsMainViewModel.EnsureBackgrounddirectly. Queries + invalidation moved; helpers stayed. - Services are currently instantiated in MainViewModel's constructor — no DI container yet
- Layout persists to SQLite (
Microsoft.Data.Sqlite); scenes/sources/asset bytes stored in the DB, asset identity is a SHA-256 content hash (1:M reuse, no file paths — assets are always available). Loaded sources always deriveIsBackgroundfromType(OR'd with the persisted column, so legacy DisplayCapture backdrops keep their flag) — pre-derivation rows withIsBackground=0heal on load - Element z-order is a unified space, not per-table (2026-09-20):
SourceandWebcamSceneConfiglive in different tables, and a webcam can be interleaved between sources in a scene. Both tables'SortOrderis stamped from the SAME counter — the element's index withinscene.Elements— andLoadmerges the two tables' rows per scene by that shared z (tie-break sources-first, so legacy per-table rows read back stacked the way they were saved). Never re-introduce per-type sort counters + "append all Sources then all configs", or a webcam slotted mid-stack reverts on reload (creator-reproduced bug). - Five-scene catalog (
Models/SceneCatalog.cs): the product is exactly Starting/Live/BRB/Chat/Ending — work with less, never more (the escape hatch for "more" is OBS). Scenes are matched by name (SceneCatalog.Is, case-insensitive trim). Empty DBs seed all five; the scenes-header "+" (ShowAddScene/MissingScenesonMainViewModel) only appears while ≥1 canonical scene is missing and its menu lists only the missing ones, re-adding them by name (AddSceneCommand). Renaming a canonical scene makes it missing again;AddScenerejects non-canonical names. - Theming: all custom styles live in
Themes/Controls.xaml, merged inApp.xaml— never duplicate styles per-window (dialog duplicates were consolidated into this dictionary). Every control in the app carries a theme style (TASK 58, 2026-10-21): an unstyledComboBox/CheckBoxfalls back to the light WPF system template (grey border, system arrow, white popup list) and sticks out of the dark panels — the properties panel's drop-downs had been hand-painted with aBackground/Foregroundinstead of a style (the chat Font drop-down was a copy of that), and every check box was default. The rule now:ComboBox→YtComboBox,CheckBox→YtCheckBox, the dense properties fields →YtTextBoxCompact(BasedOn YtTextBox: 11px, padding 4,2) — no hand-paintedBackground/BorderBrushon a themed control.ytLive.Tests/PropertiesStyleTests.csis the guard: it fails if an unstyled drop-down, check box or properties field comes back — but that guard sees only those three control types, so a picker built as something else (aButton+ a code-builtContextMenu) slipped through it and the creator had to ask twice (TASK 63) - The properties panel is a HIERARCHY (TASK 59, 2026-10-21): a title owns its content, and the content is indented one level (10px) under it — the creator's ruling: "the 'drag to move' help text needs to be indented under 'Mirror Rect' to show parentage … same with Geometry, Border Color, Border Thickness … Remove should be indented under Background as should Blur … Strength should be indented under Blur." So the webcam's block reads
Shape(the mirror switch + the shape choice) →Geometry→Border Color→Border Thickness→Background(Remove, Blur, and Strength one level deeper under Blur), each title SemiBold at 11px#a0a0b0like the existing ones. The shape is a CHOICE, not a toggle button:IsRectShape/IsRoundShapeonSceneElementare two settable bools and the panel shows them as twoSegmentToggleoptions (Rect | Circle) with Rect selected by default (an element that never picked a shape is a rect) — the old single button showed the next action ("Round"), which is why the creator could not tell what the camera was. The mutual exclusion lives in the MODEL (each setter flips throughToggleClipShape()and both props are re-raised), not in a WPFRadioButtongroup — a RadioButton group unchecks its siblings by settingIsCheckedin code, which silently kills the binding (stackoverflow 7376373). Each setter always re-raises, so a refused write (the vertical Chat layout is rect-only) makes the UI snap back. Guard:ytLive.Tests/PropertiesHierarchyTests.cs - One list for the offered colours (TASK 60): the border-colour drop-down builds its menu from
Models/BorderColorPalette.cs(NamedColor(Hex, Name)+NameOf(hex)— empty for a colour outside the palette, "no name" being the honest answer), NOT from a list inside the click handler — the creator asked for a colour NAME next to each hex (#e94560 Crimson), and a hard-codedstring[]cannot carry one. A palette the creator may want to extend belongs in a testable catalogue, with the UI as a consumer. TASK 62: the field that shows the current colour shows the name beside it too (SceneElement.BorderColorName), and the hex field keeps a fixed width so the name stays next to the hex instead of drifting to the row's far edge - A picker IS the app's drop-down (TASK 63, 2026-10-21): the border colour is now a
YtComboBox— the same widget the Display and Font pickers wear — not a...button that built aContextMenuin code. The creator's complaint ("one of the first things we did was define branding. We established look-and-feel standards. And yet you keep ignoring these") had a precise cause: the TASK 58 sweep looked forComboBox/CheckBox/TextBoxtypes, and the border picker was neither — it was a hand-rolledButton+ code-built menu, so it was never in scope, and the guard could not see it. Lesson: a style sweep must ask "is this the app's widget?", not "is this control type themed?" — anything hand-rolled that looks like a picker (a button that opens a menu) is invisible to a type-based guard. The hex field stays (a custom colour can still be typed); the drop-down carries the swatch and the colour's NAME, the rows carry swatch + hex + name. Guard:ytLive.Tests/BorderColorPickerTests.cs - A ComboBox's CLOSED box and its ROWS can differ — only through a template selector (TASK 63):
WPF copies
ItemTemplateintoSelectionBoxItemTemplate, and that property is read-only in C# ("Property or indexer 'ComboBox.SelectionBoxItemTemplate' cannot be assigned"), so a plainItemTemplaterenders the SAME content in the closed box and in every row. The one mechanism that tells the two apart is anItemTemplateSelector: WPF calls it with theContentPresenter's templated parent — theComboBoxwhen the closed box renders, theComboBoxItemwhen a row renders.Helpers/ClosedBoxTemplateSelector.csdoes exactly that (ClosedTemplatefor the ComboBox,RowTemplatefor the ComboBoxItem). AComboBoxItem.ContentTemplateset through anItemContainerStyledoes not work: WPF overwrites the container'sContentTemplatewith the ComboBox'sItemTemplate(null) — so use the selector. - The theme's drop-down popup sizes to its CONTENT, not to the ComboBox (measured 2026-10-21):
the
YtComboBoxpopup carries no width binding, so the pop-up is as wide as its widest row (measured 142.7px for the border-colour rows against a 100px ComboBox) — a row is never clipped, and the popup may be wider than the closed box. Measure (RenderTargetBitmap/ActualWidthin a throwaway test), don't guess. And aPopup's visual root (PopupRoot) has anullvisual parent (VisualTreeHelper.GetParent(popupRoot) == null) even thoughPopupRootIS aVisual: its only link back to thePopupand the owningComboBoxisLogicalTreeHelper.GetParent. Any ancestor walk used by a window-level click-outside guard (LeftPanel.IsDescendantOf) MUST fall back toLogicalTreeHelper.GetParent(child)whenVisualTreeHelper.GetParent(child)returnsnull, or clicking aComboBoxIteminsidePART_Popuplooks like an outside click and deselectsSelectedElementbefore the item click registers (fixed 2026-10-22). - A field in a dense panel carries a FIXED width (TASK 61): a WPF element sizes itself to its
content, and in a
DockPanelthe last child fills the row — so a row of same-kind fields looks broken unless every field has an explicitWidthAND the row setsLastChildFill="False". The geometry fields are 44px: the measured width of-1920in the app's font at 11px (via WPF'sFormattedText— measure, don't guess) plus the field's padding and border. Guard:ytLive.Tests/GeometryFieldWidthTests.cs - Resolution tiers: 1080p60@8 (default) → 1080p30@8 → 720p60@6 → 720p30@6 → Vertical 1080p30@8 and 1080p60@8 (9:16, 1080×1920). The composition master frame is always 1920×1080 — a tier is an output rect + target resolution over that master, so source geometry is never rewritten (no rounding drift). 16:9 tiers use the full frame; vertical tiers use a centered 607×1080 window and the preview dims the cropped side strips at 55% black with an accent outline (semi-crop — the cut area stays visible). A resolution badge in the preview corner shows the active tier. The resolution picker and live readouts (bitrate/FPS/dropped/duration) are in the top-left cell above the layers panel; the bottom bar is an empty height-preserving spacer. A tooltip explains finding upload bandwidth — an in-app speed test was deliberately dropped (unreliable). The future encoder crops the master to the rect and scales to the tier's Width×Height
- Crash diagnosis:
AppLogwrites startup checkpoints to%APPDATA%\ytLlive\startup.log;App.xaml.cslogsDispatcherUnhandledException/AppDomain.UnhandledException. When WPF won't run from WSL, this log is how you find the failure (it caught theMenuItemRole.SeparatorXAML crash and the ComboBox SelectionBoxItem bug). The log is SIZE-CAPPED (TASK 55, 2026-10-22): at 5 MBstartup.logrolls tostartup.1.log(one rolled copy kept) — the policy lives inHelpers/LogRotation.csas a pure function over a path, soAppLog's 156-call-site surface never changes and the rule is testable on its own. Guard:ytLive.Tests/AppLogRotationTests.cs
⚠️ Known issue: screens/layers settings audit — RETIRED INTO THE GOLD PASS (2026-08-22, dispositioned 2026-09-01)
The AI hallucinated through multiple commits that night on background/scene property placement, repeatedly misreading the spec and introducing bugs. Specifically: moved properties to the wrong superclass level, used new instead of override breaking WPF bindings, removed working code, and added UI elements that weren't spec'd. The audit was never run since. The creator ruled (2026-09-01): it is a going-gold requirement, not session debt — the fine-tooth-comb pass (every screen's Background layer properties, pill toggle persistence, context menu visibility, "Add Layer"/"(+)" menu items) is a named checklist item under TASK 36 (gold pass) and this landmine is closed here.
Control Surface UX (TASK 19/23 foundation; surface redesign started 2026-10-26)
The app is a director's control surface, not an editor. This replaces the OBS-style scene list with a TV-control-room metaphor.
Current layout (2026-10-26 redesign commits): MainWindow has a top bar and health banner, then a two-column work area with a fixed 360px left column and preview column. Its top row holds stream readouts/resolution on the left and the five-scene strip above the preview; the lower row holds the left panel and central preview. The bottom bar is now only a height-preserving spacer.
- Left panel:
EditingActiveis an explicitEDITtoggle, not a mode inferred fromStreamStatus. On = scene layers + properties; off = YouTube chat. Go Live turns editing off and stopping restores it; recording does not automatically switch it off. The staged scene is the preview/edit target;LiveSceneis the program output. - Top bar: the
EDITtoggle sits beside the REC/ON-AIR session controls; the brand/gear and account surfaces remain at the sides. The gear's left-click menu remains; right-click opens App Settings directly. - Scene strip: five fixed-size cards above the preview show the scene name and hotkey; the staged scene carries a
PROGRAMbadge, and staged/live borders use orange. Click stages a scene while idle; while streaming or recording it changes program output and starts the selected transition. - Preview rail: stream info, YPP, Socials, and TEST are icon/color tabs on the preview's right edge. Socials opens an animated in-preview drawer; it is no longer a standalone modal window. The bottom live-controls row retains TRAX and the game/mic meters and controls.
- Central monitor: shows the staged scene while editing and the program scene when editing is off. Preview transitions snapshot and animate the outgoing picture in every mode; output transitions run while streaming or recording. Go Live itself remains a Cut.
- Edit safety: edit availability follows
EditingActive; it is no longer a hard lock derived from "offline and not recording." Keep the explicit toggle and output/edit behavior distinct. - Selected-layer preview access: a draggable layer selected from the list temporarily gets the highest preview
Panel.ZIndexand first priority in the preview's custom hit test, so it can be grabbed even underneath a higher scene layer. Clearing/replacing selection restores the preview stack;Scene.Elementsorder is untouched, so output and saved z-order do not change. Guard:ytLive.Tests/SelectedLayerPreviewHitTestingTests.cs.
Scene thumbnails: Scene.Snapshot is a 320×180 compositor render. The strip uses per-scene ThumbnailAssetId, then background art, then a placeholder; existing saved OnAirImageAssetId can still replace the staged tile. The old hardcoded CRT-thumbnail and on-air seed paths are empty, so a fresh layout no longer imports that artwork. Minis never render live captures. DESIGN.md is the visual standard (tokens — Themes/Tokens.xaml implements them); it is not a layout spec.
Global hotkeys (TASK 20 — step 1 shipped 2026-08-24)
Services/GlobalHotkeys.cs:HotkeyIdenum (9 actions),IHotkeyRegistrarseam,Win32HotkeyRegistrar(RegisterHotKey, no modifiers),GlobalHotkeyManager. Fixed defaults: F1-F5 canonical scenes, F6 start/end stream, F7 mic mute, F8 desktop-audio mute, F9 TRAX play/pause.MainWindow.OnSourceInitializedcreates the manager and forwardsActivated→MainViewModel.HandleHotkey; windowCloseddetaches (unregister all).MainViewModel.HandleHotkeymaps ids toTransitionToScene/start-end commands (honoringCanExecute)/mute toggles/TraxLeftClick.- Tests:
RegistrarOverridestatic seam mirrorsLayoutPathOverride. Landmine: forcing a WPF window's handle withWindowInteropHelper.Handledoes NOT raiseSourceInitializedand returns Zero pre-show — useEnsureHandle()in tests; it both creates the HWND and fires the event.
Broadcast metadata pull-out + launch geometry (2026-08-24)
The "Text" tab (always visible, 2026-08-24 revision): a white 30px tab with rotated YouTube-red "Text" sits on the right edge of the preview, vertically centered — shown regardless of login/stream state (creator decision; original IsLive gating removed same day). Click toggles the drawer (ToggleDrawerCommand); any left-click outside the form also collapses it (Window_PreviewMouseLeftButtonDown checks TextPullOutHost ancestry), as does the ✕. The drawer is a 320px dark panel over the preview's right edge (200ms CubicEase on Width, BroadcastForm.IsDrawerOpen drives it). The Update button still greys until CurrentBroadcastId exists.
Fields — the always-editable snippet/status set (⚠️ creator premise inverted: contentDetails fields like latency/DVR/embed are editable ONLY in created/ready, NOT while live; they are deliberately absent):
- Title / Description / Tags (comma-separated string →
snippet.tags[]) / Visibility ComboBox (private/unlisted/public) / Made-for-Kids CheckBox — all live-editable pre- and post-launch - Scheduled Start is read-only ("set at Go Live")
Plumbing: Models/BroadcastMetadata POCO; Services/LiveBroadcastFormViewModel (owns field state, persists every edit to Settings keys Broadcast.* immediately via Func<LayoutStore> seam — survives layout re-pointing; owns drawer state; UpdateBroadcastCommand gated by CurrentBroadcastId != null && !IsUpdating). YouTubeStreamService.UpdateBroadcast PUTs liveBroadcasts?part=snippet,status and must echo scheduledStartTime — update replaces the whole snippet part, omitting it clears the schedule. Go Live prefills the dialog from the form (BroadcastForm.Title/Description) and calls CaptureGoLive(title, description, now) on confirm. MainViewModel.CurrentBroadcastId exposes the private _currentBroadcastId; notified on create/end.
The old Default Stream Title/Description (un-persisted, App Settings overlay) are DELETED — the form owns those fields end-to-end; Go Live prefill routes through it.
Launch geometry: default 1920×1040, MinWidth 1366, MinHeight 768, WindowStartupLocation=Manual. Window size/position persist on close via RestoreBounds (+ WindowState Normal/Maximized; Minimized treated as Normal) into Settings keys Window.*; restored in the MainWindow ctor BEFORE show, clamped to the minimums and the primary SystemParameters.WorkArea (a position saved on a since-disconnected secondary monitor falls back on-screen). Multi-monitor restore is not supported yet.
Test seam note: YouTubeAuthService.SetSession() is public — tests inject a channel with future TokenExpiry so EnsureToken() passes offline; pair with an HttpClient(new StubHandler()).
Toast notifications (TASK 24 — shipped 2026-08-23, plan in TASKS.md)
Non-blocking user-facing messages replace blocking MessageBoxes. Seam: INotificationService.Show(title, message, AppNotificationSeverity) (Services/INotificationService.cs) + Info/Success/Warning/Error
extension methods; MainViewModel news up the real NotificationService at its field initializer
(no DI container — the VM is built from XAML). Library: Notification.Wpf 11.0.0 (Platonenkov fork,
MIT — notice #10 in THIRD-PARTY-NOTICES.txt). Facts that cost a hunt:
- The
NotificationAreaXAML control registers itself with the manager onLoaded; routing matches the area'sNameagainst the request'sAreaName— our host isx:Name="ToastArea"in MainWindow's root grid (last child = topmost z-order,Grid.RowSpan=4, bottom-right above the footer viaMargin="0,0,12,96", outside the preview Viewbox). An unknown AreaName silently drops the toast. NeverExpires()setsExpirationTime = TimeSpan.MaxValue(NOT null);NotificationColor.ToHex()returns#AARRGGBB(alpha prefix).- Lifetimes: Info/Success ~4s, Warning ~8s auto-dismiss, Error sticky until dismissed (the pure mapping
lives in
NotificationService.BuildRequest+ theCardstint table: slate info#3a3b52, green success#1d5c38, amber warning#b8860b, dark-red error#8f1f1f— matching the health-banner language). - Calls marshal to the captured Dispatcher (
_dispatcher.Invoke) — encoder/pump events fire off-thread.
Migrated MessageBoxes: both webcam-acquire warnings, image-read warning, sign-in unsuccessful/failed. Promoted from log-only (each keeps its AppLog line): go-live prep failures ×3 (reusable stream, broadcast insert, prep exception), frame-pump death while live, mic-missing at startup, premium lapse, saved-session refresh failure (sign-out notice). Deliberately left log-only: offline license re-validation skip (would spam every launch). Still modal by design: SocialsDialog's slot-delete YesNo confirm.
Transitions (2026-10-27)
- Fade starts from the outgoing picture.
TransitionService.Startwith no "from" frame setsCaptureRequested; the frame pump copies its last output at the top of its next tick and callsSupplyFromFrame. Before that fix the blend had no starting frame and a scene swap was instant. Guard:ytLive.Tests/TransitionFadeTests.cs. - Type and duration are selected in App Settings and persisted with the layout. Cut, Fade, Move,
and Wipe are offered; Wipe is a CPU left-to-right reveal. Guard:
ytLive.Tests/TransitionPickerTests.csandytLive.Tests/WipeTransitionTests.cs. - A scene change animates the outgoing preview in every mode. Fade and Move fade it out; Wipe
shrinks it away; Cut is immediate. Program output transitions when streaming or recording, while
offline scene changes affect only the preview. Guard:
ytLive.Tests/PreviewTransitionTests.cs. - Still planned (see TASKS.md): persistent layers for Move and a capability-gated GPU Cube.
Brand logo (2026-10-27)
- One locked image layer per scene,
Source.IsBrandLogo(persisted column, additive migration), named "Brand logo", directly above the background. Healed on load byEnsureBrandLogos, which also copies the App Settings image into every scene's layer. Removal is blocked like the background. - The image lives in the asset table; the settings table keeps its asset id and file name
(
BrandLogoAssetId/BrandLogoFileName). Eye visibility is the layer's ordinary per-sceneIsVisible. - Default placement: top-left (32,32), 240x120 master px.