Files
LlamaCasty/docs/ai/ui-surface.md
T
gramps 9fa06c67c2 feat(ui): add current-launch logs tab
Add Live Chat and Logs tabs to the non-edit left panel. Expose a bounded process-local AppLog feed so the newest messages appear first without importing earlier startup.log contents or changing file logging.

Ref: https://obsproject.com/forum/resources/understanding-obs-studio-log.473
2026-11-02 11:06:43 -08:00

39 KiB
Raw Blame History

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 notifications
  • RelayCommand for all button actions; commands gate on state (e.g. Start only when Offline). Typed CommandParameters — 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; AddSource still falls back to Enum.TryParse<SourceType>(..., true) for safety. The webcam item is its own AddWebcamCommand (it greys out via CanAddWebcamToActiveScene and isn't a SourceType — webcams are WebcamSceneConfig, not Source rows)
  • 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 via MeterFillWidth/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 is Math.Min(1, AudioLevelMeter.ToDisplay(AudioLevel) * MicVolume) — ToDisplay maps 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 (AudioLevel is 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, from PreviewMouseLeftButtonDown/Up + LostMouseCapture handlers) 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_MouseLeftButtonUp code-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: MicMuted is read-only, derived from MicVolume == 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/EndVolumeFlash on 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. Dock layout (2026-11-01): four bays — MIC | GAME/PC | TRAX | LIP-SYNC — where the three sound bays share equal width so their meters stay the same relative size, and lip-sync is narrow (0.45*) because its function is one slider and three presets. TRAX is its own bay with its own meter (TraxMeterFraction), which it did not have while it lived inside the game bay. The master strip is 44px tall, its meter is the largest on screen (420×14, 32 segments) and is centred under the preview with a 12px MASTER label — bigger than the 9px bay labels. The kbps / fps / dropped counters were removed from that line: the same readouts already sit above the preview, and duplication on a telemetry strip is noise. The slider is a slim dimensional style in Themes/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 = IAudioSource seam + NAudio WasapiCapture/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 same MicSpeaker_MouseLeftButtonUp → ToggleMicMuteCommand handler. 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 until IsConnected. 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 = #555 offline, #22c55e live) is green while actually streaming (IsLive); the PRIVATE badge (IsLivePrivate) still shows when the broadcast is private. Both sign labels share the StatusSignText style (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 while IsLive || IsRecording. The connected account's avatar/name shows in the top bar next to the primary button (AccountAvatarUrl/AccountDisplayName via SyncConnectedAccount), 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): MainViewModel is a ~4100-line god-object split into 17 partials — but partials are a myth of decomposition: every partial shares the same class, same SetProperty state, same collaborators via Scenes/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.cs is the model (extracted from MainViewModel.Chat.cs 194→44): the layer owns the message buffer, ChatBoxRenderer, fade/mock timers, preview renders, and the live RenderFrame; the VM keeps only the binding surface (ChatMessages delegates to _chatLayer.Messages so XAML + LeftPanel CollectionChanged hold; the two computed gate props stay on the VM because their OnPropertyChanged is 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 live RenderFrame, with Advance(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, count OnPropertyChanged/SetProperty touches (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/ChatBoxRenderer already 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.cs owns the scene collection (Scenes — the ViewModel's Scenes property 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 expose ElementKind Kind (Static = images/background art, Dynamic = webcam/live capture/chat/web). The split point is the index of the first dynamic element; SceneGraph.GetBakedBase bakes/caches all static layers below it (keyed by scene id + static element identities), and SceneCompositor.BakeStaticBase/CompositeLayers/the Render(.., staticBase, split) overload composite the dynamic/above-split layers per frame. FramePump.RenderScene uses the optimized path when a SceneGraph is wired in (falls back to full render without one). Invariant: dynamic-only pixel changes never invalidate; static layout/opacity/visibility/asset changes do (via InvalidateBake from the VM's element-property and background-heal paths). Defensive deviation from the TASK 31 spec: ChatOverlayLayer keeps taking IEnumerable<Scene> instead of depending on GetChatBoxes() — 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 because BackgroundTests.cs unit-tests MainViewModel.EnsureBackground directly. 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 derive IsBackground from Type (OR'd with the persisted column, so legacy DisplayCapture backdrops keep their flag) — pre-derivation rows with IsBackground=0 heal on load
  • Element z-order is a unified space, not per-table (2026-09-20): Source and WebcamSceneConfig live in different tables, and a webcam can be interleaved between sources in a scene. Both tables' SortOrder is stamped from the SAME counter — the element's index within scene.Elements — and Load merges 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/MissingScenes on MainViewModel) 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; AddScene rejects non-canonical names.
  • Theming: all custom styles live in Themes/Controls.xaml, merged in App.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 unstyled ComboBox/CheckBox falls 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 a Background/Foreground instead 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-painted Background/BorderBrush on a themed control. ytLive.Tests/PropertiesStyleTests.cs is 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 (a Button + a code-built ContextMenu) 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 #a0a0b0 like the existing ones. The shape is a CHOICE, not a toggle button: IsRectShape/IsRoundShape on SceneElement are two settable bools and the panel shows them as two SegmentToggle options (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 through ToggleClipShape() and both props are re-raised), not in a WPF RadioButton group — a RadioButton group unchecks its siblings by setting IsChecked in 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-coded string[] 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 a ContextMenu in 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 for ComboBox/CheckBox/TextBox types, and the border picker was neither — it was a hand-rolled Button + 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 ItemTemplate into SelectionBoxItemTemplate, and that property is read-only in C# ("Property or indexer 'ComboBox.SelectionBoxItemTemplate' cannot be assigned"), so a plain ItemTemplate renders the SAME content in the closed box and in every row. The one mechanism that tells the two apart is an ItemTemplateSelector: WPF calls it with the ContentPresenter's templated parent — the ComboBox when the closed box renders, the ComboBoxItem when a row renders. Helpers/ClosedBoxTemplateSelector.cs does exactly that (ClosedTemplate for the ComboBox, RowTemplate for the ComboBoxItem). A ComboBoxItem.ContentTemplate set through an ItemContainerStyle does not work: WPF overwrites the container's ContentTemplate with the ComboBox's ItemTemplate (null) — so use the selector.
  • The theme's drop-down popup sizes to its CONTENT, not to the ComboBox (measured 2026-10-21): the YtComboBox popup 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/ActualWidth in a throwaway test), don't guess. And a Popup's visual root (PopupRoot) has a null visual parent (VisualTreeHelper.GetParent(popupRoot) == null) even though PopupRoot IS a Visual: its only link back to the Popup and the owning ComboBox is LogicalTreeHelper.GetParent. Any ancestor walk used by a window-level click-outside guard (LeftPanel.IsDescendantOf) MUST fall back to LogicalTreeHelper.GetParent(child) when VisualTreeHelper.GetParent(child) returns null, or clicking a ComboBoxItem inside PART_Popup looks like an outside click and deselects SelectedElement before 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 DockPanel the last child fills the row — so a row of same-kind fields looks broken unless every field has an explicit Width AND the row sets LastChildFill="False". The geometry fields are 44px: the measured width of -1920 in the app's font at 11px (via WPF's FormattedText — 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
  • Locked output tiers stay visible and clickable. They are dimmed for non-owners. Choosing one keeps the free tier selected and opens About in-app; only the explicit llamacasty.com link opens a browser.
  • Crash diagnosis: AppLog writes startup checkpoints to %APPDATA%\ytLlive\startup.log; App.xaml.cs logs DispatcherUnhandledException/AppDomain.UnhandledException. When WPF won't run from WSL, this log is how you find the failure (it caught the MenuItemRole.Separator XAML crash and the ComboBox SelectionBoxItem bug). The log is SIZE-CAPPED (TASK 55, 2026-10-22): at 5 MB startup.log rolls to startup.1.log (one rolled copy kept) — the policy lives in Helpers/LogRotation.cs as a pure function over a path, so AppLog'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: EditingActive is an explicit EDIT toggle, not a mode inferred from StreamStatus. On = scene layers + properties; off = a two-tab panel with Live Chat first/default and Logs second. Logs is a read-only, newest-first view of up to 500 AppLog entries captured in this process; it does not load older startup.log contents or change file logging/rotation. Go Live turns editing off and stopping restores it; recording does not automatically switch it off. The staged scene is the preview/edit target; LiveScene is the program output.
  • Settings is a right-edge drawer, not a modal (2026-11-01): the gear rail tab opens Controls/SettingsDrawer in the same animated-width stack as Stream Settings / YPP / Socials / TEST (SettingsDrawerOpen, exclusive with the other four). Categories are section headings with their controls indented under them — Zoom holds Zoom view shape and Zoom box follows. The app menu (Report Bug / Feature Request / About) hangs off a right-click on the LlamaCasty wordmark; left-click still opens About. The modal shell keeps only Bug / Feature / About, which is why it no longer needs a scroll viewer.
  • Top bar: the EDIT toggle sits beside the REC/ON-AIR session controls; the brand and account surfaces remain at the sides. Its right edge aligns with the layers panel below (TopBar column 0 is a fixed 352 = work-area margin 8 + 360px column − the bar's own 16px padding), so the button and the panel share an edge. The settings gear moved to the preview rail (2026-11-01): first tab, orange (Brush.Attention), gear icon only — left-click opens the app menu (Settings / Bug / Feature / About), right-click goes straight to App Settings.
  • Preview gestures (2026-11-01): edit mode — left selects/drags/resizes, right opens the context menu, Ctrl+right toggles the zoom box. Preview mode — left toggles the zoom box, right opens the context menu; Ctrl+right does nothing at all (no chord while live or recording — one hand, always). ⚠️ Both the Down and the Up event must be handled for a Ctrl+right-click: WPF's PopupControlService opens a context menu on right-button-up, so suppressing only the down event leaves the menu appearing under the gesture (found 2026-11-01 in edit mode). Menus exist on the webcam layer and the backdrop; every other layer right-clicks through to the backdrop menu. Left-dragging with the zoom box open moves it, in both modes. Ctrl-down closes it: pressing Ctrl while the box is open dismisses it, so the same hand that opened it can put it away (MainWindow_PreviewKeyDown; the box cannot cancel itself on creation because Ctrl is already down before it exists). Follow mode is a Settings pill, default off = Anchor: with Follow on, dragging re-anchors the capture to the box centre (ZoomFollowsBox, persisted as the Zoom.FollowBox setting). The gesture activates the box at the cursor, not merely dismisses it: ActivateZoom no longer refuses when idle (it used to return unless IsLive || IsRecording, and ToggleZoom only ever dismissed — so the gesture did nothing at all outside a session until 2026-11-01). The social bar consumes its own click (toggle top ⇄ bottom), and the preview's tunnelling handler steps aside for it — otherwise the bar is unclickable in preview mode and drags the layer beneath it in edit mode.
  • Preview undo/redo (2026-11-01): Ctrl+Z / Ctrl+Shift+Z, window-level bindings, live only while EditingActive — undo with the preview showing program output would rewrite a scene nobody is looking at. Models/PreviewHistory snapshots element references plus geometry, visibility, order and membership, so move / resize / reorder / add / remove / hide all undo in one step per gesture (PushPreviewUndo is called once at drag or resize start, never inside the move loop). Property edits (colour, opacity, border, font) are not captured yet — stated in the type's doc comment so nobody assumes otherwise. The stack clears on layout load. Guard: ytLive.Tests/PreviewHistoryTests.cs.
  • Developer affordances are compiled out of release, not hidden. Any control that must never ship (today: the all-resolutions override) lives in its own UserControl whose Page and Compile items are removed by ytLive.csproj when $(Configuration) != 'Debug', and is added to its host at runtime inside #if DEBUG (OverlayHost). A collapsed row still exists in the visual tree of every shipped copy; "not compiled" is the only claim worth making. Label such rows with Brush.Attention (orange) while they are visible in Debug. Guard: ytLive.Tests/OutputProfileTests.cs asserts the build rule, and Helpers/DevBuild.cs is the single source for "is this a dev build".
  • Scene strip: five fixed-size cards above the preview show the scene name and hotkey; the staged scene carries a PROGRAM badge, 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.ZIndex and 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.Elements order 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: HotkeyId enum (9 actions), IHotkeyRegistrar seam, 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.OnSourceInitialized creates the manager and forwards Activated → MainViewModel.HandleHotkey; window Closed detaches (unregister all).
  • MainViewModel.HandleHotkey maps ids to TransitionToScene/start-end commands (honoring CanExecute)/mute toggles/TraxLeftClick.
  • Tests: RegistrarOverride static seam mirrors LayoutPathOverride. Landmine: forcing a WPF window's handle with WindowInteropHelper.Handle does NOT raise SourceInitialized and returns Zero pre-show — use EnsureHandle() 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 NotificationArea XAML control registers itself with the manager on Loaded; routing matches the area's Name against the request's AreaName — our host is x:Name="ToastArea" in MainWindow's root grid (last child = topmost z-order, Grid.RowSpan=4, bottom-right above the footer via Margin="0,0,12,96", outside the preview Viewbox). An unknown AreaName silently drops the toast.
  • NeverExpires() sets ExpirationTime = 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 + the Cards tint 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.Start with no "from" frame sets CaptureRequested; the frame pump copies its last output at the top of its next tick and calls SupplyFromFrame. 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.cs and ytLive.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 by EnsureBrandLogos, 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-scene IsVisible.
  • Default placement: top-left (32,32), 240x120 master px.
  • Properties panel typography (2026-11-01): one hierarchy for every section, defined in Themes/Controls.xaml — PropertyHeader (category: 11px semibold, Brush.OnSurface, the brightest text), PropertySubHeader (a field label inside it: 10px semibold, indented 10, OnSurfaceVariant), PropertyFieldLabel (a label sharing a row with its control: same typography, vertically centred, no margin), PropertyHint (the explanatory sentence: 10px, wrapped, dim). Colour carries the grouping — bright for the thing being edited, dim for its labels and help — and no new palette entries were needed. ⛔ No literal hex in the panel: the four #888/#666/#555 values that had crept in are now OnSurfaceVariant / OnSurfaceDisabled. ⛔ Every checkbox in the properties panel is a pill switch (PropertyPillCheck, 32×16 track with an animated thumb) — a 14px box reads as a form, a switch reads as a setting; IsChecked semantics are untouched so all bindings still work.