diff --git a/Controls/BottomBar.xaml b/Controls/BottomBar.xaml
index 18686fb..9736d2a 100644
--- a/Controls/BottomBar.xaml
+++ b/Controls/BottomBar.xaml
@@ -9,22 +9,6 @@
-
-
-
diff --git a/Controls/BottomBar.xaml.cs b/Controls/BottomBar.xaml.cs
index df355db..36e5aa6 100644
--- a/Controls/BottomBar.xaml.cs
+++ b/Controls/BottomBar.xaml.cs
@@ -1,4 +1,3 @@
-using System.Windows;
using System.Windows.Controls;
namespace ytLive.Controls;
@@ -9,14 +8,4 @@ public partial class BottomBar : UserControl
{
InitializeComponent();
}
-
- private void GearButton_Click(object sender, RoutedEventArgs e)
- {
- if (sender is Button { ContextMenu: { } menu } button)
- {
- menu.PlacementTarget = button;
- menu.Placement = System.Windows.Controls.Primitives.PlacementMode.Bottom;
- menu.IsOpen = true;
- }
- }
}
\ No newline at end of file
diff --git a/Controls/TopBar.xaml b/Controls/TopBar.xaml
index 7baab2d..973d5c4 100644
--- a/Controls/TopBar.xaml
+++ b/Controls/TopBar.xaml
@@ -3,7 +3,6 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:helpers="clr-namespace:ytLive.Helpers"
x:Name="TopBarRoot">
-
@@ -11,123 +10,120 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
+
\ No newline at end of file
diff --git a/Controls/TopBar.xaml.cs b/Controls/TopBar.xaml.cs
index 2e1ef25..446f990 100644
--- a/Controls/TopBar.xaml.cs
+++ b/Controls/TopBar.xaml.cs
@@ -1,6 +1,7 @@
using System;
using System.IO;
using System.Net.Http;
+using System.Windows;
using System.Windows.Controls;
using System.Windows.Media.Imaging;
using ytLive.Helpers;
@@ -25,6 +26,18 @@ public partial class TopBar : UserControl
_ = LoadAvatarImageAsync();
}
+ private void GearButton_Click(object sender, RoutedEventArgs e)
+ {
+ // One menu (Settings / Bug / Feature / About), opened on click — moved up
+ // from the bottom bar so app-level actions live next to the brand.
+ if (sender is Button { ContextMenu: { } menu } button)
+ {
+ menu.PlacementTarget = button;
+ menu.Placement = System.Windows.Controls.Primitives.PlacementMode.Bottom;
+ menu.IsOpen = true;
+ }
+ }
+
private async System.Threading.Tasks.Task LoadAvatarImageAsync()
{
var vm = DataContext as MainViewModel;
diff --git a/Controls/index.md b/Controls/index.md
index 1158553..6f1de28 100644
--- a/Controls/index.md
+++ b/Controls/index.md
@@ -7,9 +7,9 @@ See [`schema.md`](../schema.md) for the memory-map conventions.
| File | Purpose |
|------|---------|
-| `BottomBar.xaml(.cs)` | Footer (Row 3): gear menu (stop/mic/game-mute/quality dropdown), stream stats (bitrate/fps/dropped/duration/health), resolution dropdown |
+| `BottomBar.xaml(.cs)` | Footer (Row 3): stream stats (bitrate/fps/dropped/duration/health), resolution dropdown (gear menu moved up to TopBar 2026-09-22) |
| `HealthBanner.xaml(.cs)` | Row 1 status banner + the 7 value→visibility converters live in the app theme, not here |
-| `TopBar.xaml(.cs)` | Row 0 stream controls: REC/ON-AIR pills+signs, elapsed timer, start/stop, account avatar/name (`RefreshAvatar()`) |
+| `TopBar.xaml(.cs)` | Row 0 session surface (2026-09-22 redesign): brand + gear menu (left), one segmented REC|ON-AIR mode switch + world actions (Record: Start Recording · Stream: Go Live + Test) + reality line while running (dot/word/elapsed) + End (center), account zone — Sign In or avatar w/ Change Account/Logout context menu (right). `RefreshAvatar()`, `GearButton_Click` |
| `PreviewPane.xaml(.cs)` | Row 1 Col 1 center: the 1920×1080 `OverlayCanvas`/`SelectionOverlay`/`BrandFlashLayer`/`SocialBarElement`, text pull-out drawer, and the live-controls row (TRAX / game / mic meters + sliders + speakers + socials) |
| `SceneThumbnailStrip.xaml(.cs)` | Row 0 scene-picker strip (`ItemsControl` over `Scenes`), staged-red / live-green border highlight |
| `LeftPanel.xaml(.cs)` | Row 1 Col 0 left panel: edit mode → layers+properties; live → YouTube chat. Source add/edit/remove, list drag-to-reorder, snapshot revert-accept |
diff --git a/HANDOFF.md b/HANDOFF.md
index 0fd76b7..1951833 100644
--- a/HANDOFF.md
+++ b/HANDOFF.md
@@ -1,63 +1,75 @@
-# HANDOFF — 2026-09-22 (TASK 41 Test Stream mode shipped; TASK 40 plan queued)
+# HANDOFF — 2026-09-22 (TASK 42 top bar redesign implemented; commit + full-suite rerun owed)
## Branch / Commit State
-`main` HEAD = **`4d9188a` — docs(plan): TASK 40 App Settings round — saved plan**. Pushed state
-at `197ee81`. **Working tree DIRTY — TASK 41 implementation in progress** (code + docs staged for
-a single scope-checked commit; HANDOFF rewrite rides in a final docs commit):
+`main` HEAD = **`bb5dcb4` — feat(stream): TASK 41 Test Stream mode …** (TASK 41 committed).
+Pushed state at `197ee81`. **Working tree DIRTY — TASK 42 implementation done, not yet committed**
+(docs updated in this same pass; next step is scope-check + build + full tests + one commit):
```
-?? HANDOFF.md (this rewrite)
- M Models/YouTube.cs
- M Services/YouTubeStreamService.cs
- M Services/YouTubeChatService.cs
- ?? Services/TestSessionViewModel.cs
- M ViewModels/MainViewModel.cs
- M ViewModels/MainViewModel.Ypp.cs
- M ViewModels/MainViewModel.Account.cs
+ M Controls/BottomBar.xaml(.cs) (gear removed)
+ M Controls/TopBar.xaml(.cs) (rewrite + GearButton_Click)
+ M Themes/Controls.xaml (SegmentToggle/SegmentLabel)
M ViewModels/MainViewModel.Streaming.cs
+ M ViewModels/MainViewModel.Account.cs
M ViewModels/MainViewModel.Streaming.Operations.cs
- M Controls/TopBar.xaml
- M Controls/PreviewPane.xaml
- ?? ytLive.Tests/TestStreamTests.cs
- M TASKS.md
- ?? TASKS/task-41-test-stream-mode.md
- M ai.md
+ M ytLive.Tests/PillRadioTests.cs / TestStreamTests.cs
+ ?? ytLive.Tests/TopBarModeTests.cs (new Good Dog)
+ M TASKS.md + TASKS/task-42-top-bar-redesign.md (new)
+ M ai.md, ViewModels/index.md, Controls/index.md, TASKS/task-40-app-settings-round.md
+ M HANDOFF.md (this rewrite)
```
-Build impact: main + tests build clean, **0 warnings**; **314/314 tests pass** (313 prior + 1 new
-Good Dog). A flaky real-camera startup test aborted one earlier full-suite run (hardware-dependent,
-unrelated to this change); the clean rerun passes fully.
+Build impact: main + tests build clean, **0 warnings**; targeted suite **6/6 pass**; full-suite run
+**314/315 pass** — the single abort is the known environment-dependent `RealMouseDrag` test (Path of
+Exile 2 running, physical-mouse injection no-ops; see ⚠️ below and rerun with the game closed for a
+clean 315/315).
-## What shipped — TASK 41: Test Stream mode (2026-09-22)
+## What shipped — TASK 42: top bar redesign (2026-09-22)
-Full record: **`TASKS/task-41-test-stream-mode.md`**. Decisions locked with the creator:
-chat-overlay-events-only (no stinger/TTS — stays TASK 3 16/17/20), TEST drawer in the right rail,
-hybrid simulation (real text insert + local synthetic events). Key design: since every broadcast is
-already private-only (`CreateBroadcast` hardcodes `privacyStatus=private`), "Test" is a **session
-variant**: `BeginTestStream` → extracted-shared `StartStreamingSession(alsoRecord:false)` (no
-dialog, no "last live" stamp, no recording). `IsTestStream` = gold top bar (`#9c6f1c`) + TEST badge
-+ "End Test" button face. `TestSession.BeginTest(broadcastId, liveChatId)` opens the TEST drawer
-once the liveChatId resolves. Mock Chat Input = real `liveChat/messages.insert` round-trip;
-simulated events inject through the poller's `MessageReceived` seam marked `IsSimulated` (hard API
-fact: insert only creates text — non-text events are local-only by design). Three-way drawer
-exclusivity (Stream Settings / YPP / TEST) in `MainViewModel.Ypp.cs`.
+Full record: **`TASKS/task-42-top-bar-redesign.md`**. Creator directive: "forget this one dog plan
+bullshit. Our one plan is replacing the top menu bar with something usable." The old bar's six
+widget grammars (sliding pills, dots, badges, buttons, hidden sign-in context menu, live-window
+status) collapsed into **ONE surface rendered by the FIRST decision — Record or Stream**:
-**Good Dog** — `ytLive.Tests/TestStreamTests.cs` (one `[Fact]`): coordinator protocol w/ fake HTTP
-(insert POST body asserted; simulated shapes verified) + real-window exclusivity/gate. Note: the
-coordinator section runs on the test thread directly (pure services, no WPF), the window section via
-`RealAppHost.Run` — deviates from YppPullOutTests' all-in-one-Run shape for debuggable stacks.
+- **Mode switch:** one segmented **REC|ON-AIR** toggle (`SegmentToggle`/`SegmentLabel` styles).
+ Going back = one tap on the other segment. Runs idle-only; retires while running.
+- **Record world:** switch + **Start Recording** — no YouTube identity at all.
+- **Stream world:** switch + **Go Live** + Test + account zone (Sign In until connected; then the
+ avatar, right-click Change Account/Logout). **Test is a child of Stream** — procs only ON-AIR-armed
+ AND signed-in (`ShowTestButton`/`CanStartTest`).
+- **Running:** one reality line `● REC|LIVE|TEST 00:12:34` (green/red/gold `RunningDotBrush`) + End.
+- **Gear** moved up from bottom bar, ~3 wordmark letters past the brand, one click = Settings / Bug /
+ Feature / About menu (`GearButton_Click`). Bottom-bar gear removed. Task 40 Unit B is DONE by this.
+- **Sign-in = the account-confirm step:** no Google API enumerates machine accounts; OAuth is
+ single-account, so Switch Account re-runs the chooser (`login_hint`+`select_account`) — login and
+ account-confirmation are one surface. Existing `ChangeAccountCommand`/`LogoutCommand` resurface via
+ the avatar.
+- **PRIVATE/TEST chips gone from the bar** (dev-phase forced-private becomes an OPTION at ship).
+
+**Good Dog** — `ytLive.Tests/TopBarModeTests.cs` (one `[Fact]`, `RealAppHost.Run`): default record
+world → arm ON-AIR → sign-in/avatar/Test gating flips → one-tap flip back → direct stream status
+changing + running reality values. Also folded in: TASK 41's latent **Test pipeline bug fix** —
+`BeginTestStream` now arms `OnAirPillOn` explicitly (unarmed → encoder booted with zero outputs →
+"At least one output is required" → forced stop cascade). The TestStreamTests gate updated to match.
## ⚠️ Around the task
-- **Manual verification still owed** (needs a real run, Windows): Test button → gold face + badge +
- auto-open TEST drawer; send a message → appears on a ChatBox overlay after ~2s; simulated rows
- render text/member/SuperChat styled; End Test closes it all out cleanly (transition complete).
-- The app may be **RUNNING** — in-place build fails MSB3021/MSB3027 until closed (temp-OutDir
+- **Full-suite run today: 314/315** — the one abort is the pre-existing
+ `LayerReorderPersistenceTests.RealMouseDrag_OnTheLayerList_PersistsTheReorder`, which injects
+ REAL physical mouse input (`SetCursorPos` + `mouse_event`, see its own docstring) and no-ops while
+ another window interferes. Cause observed 2026-09-22: **Path of Exile 2 + NVIDIA Overlay were
+ running** (fullscreen-game window hides the test window's rows; the drag never lands → reorder
+ assert fails). Close the game before running that test; it is unrelated to TASK 42 and has passed
+ on clean reruns previously.
+- **Manual verification owed** (needs a real run on Windows): the app is a new build number when
+ next launched — check the new one-surface bar (switch ↔ worlds, gear menu, reality line).
+- The app may be **RUNNING** — an in-place build fails MSB3021/MSB3027 until closed (temp-OutDir
workaround in ai.md). Close it before the manual pass.
-- The full-suite flake earlier: real-camera startup test under the test host (hardware, pre-existing
- behavior; unrelated to TASK 41).
-- LayerReorderPersistenceTests.RealMouseDrag flaked once pre-TASK-38 (fullscreen game); unrelated.
+- Full-suite note: the pre-existing real-camera startup flake did **not** occur in the 315/315 run.
+- Test-env trap (recorded in the TASK): a dev machine's saved OAuth session loads synchronously and
+ makes `IsConnected` come back true in the VM ctor — signed-in-world tests must force
+ `vm.IsConnected = false/true` explicitly.
- `subscriberCount` rounded to 3 sig figs (YPP); carried. YPP slice 2 needs re-consent — do NOT
merge with other units.
- Polar product `d105dfa1…` still `$99/yr` — must become one-time before launch (carried).
@@ -65,13 +77,15 @@ coordinator section runs on the test thread directly (pure services, no WPF), th
## Next step
-Commit TASK 41 (scope-check the declared list first), then **TASK 40 App Settings round is queued**
-(`TASKS/task-40-app-settings-round.md`, units A → B → C → D, one Good Dog each). Unit A's
-SharedReadOnly control-write question is the one genuinely uncertain technical point — run its
-mitigation ladder, spin-guard research before a third guess. Push only on the user's say-so.
+Scope-check the declared list → build (0 warnings) → full test suite rerun → **one commit** for
+TASK 42 (docs + code same change). Push only on the user's say-so. Then **TASK 40 App Settings
+round** is queued (`TASKS/task-40-app-settings-round.md`) — units **A (camera) → C (defaults) → D
+(accent)** remain (B/gear shipped early); Unit A's SharedReadOnly control-write question is the one
+genuinely uncertain technical point — run its mitigation ladder before a third guess.
## Critical working rules (unchanged, still binding)
- **Good Dog = ONE integration test per change.**
- **Scope lock:** declare the file list before editing; `./scripts/scope-check.sh` before commit.
-- WSL builds use the Windows dotnet host (`/mnt/c/Program Files/dotnet/dotnet.exe`, quoted paths).
\ No newline at end of file
+- WSL builds use the Windows dotnet host (`/mnt/c/Program Files/dotnet/dotnet.exe`, quoted paths).
+- One runtime model, no model switching, no compaction summaries (user directive).
\ No newline at end of file
diff --git a/TASKS.md b/TASKS.md
index 24dec3d..e81c69b 100644
--- a/TASKS.md
+++ b/TASKS.md
@@ -57,6 +57,7 @@
| 39 | YPP journey tracker (slice 1: current-scope data) | 🔶 Slice 1 ✅ — 2026-09-22; slice 2 (Analytics + ETA) queued | [`TASKS/task-39-ypp-journey-tracker.md`](TASKS/task-39-ypp-journey-tracker.md) |
| 40 | App Settings round: active-camera picker + config dialog, gear move, defaults, accent | ☐ Queued (2026-09-22) — plan saved | [`TASKS/task-40-app-settings-round.md`](TASKS/task-40-app-settings-round.md) |
| 41 | Test Stream mode: private test broadcast + TEST drawer (mock chat input + simulated events) | ✅ Done (2026-09-22) | [`TASKS/task-41-test-stream-mode.md`](TASKS/task-41-test-stream-mode.md) |
+| 42 | Top bar redesign: one REC-or-Stream surface, gear up top, sign-in/account zone world-gated | ✅ Done (2026-09-22) | [`TASKS/task-42-top-bar-redesign.md`](TASKS/task-42-top-bar-redesign.md) |
---
@@ -84,7 +85,9 @@
tweaks), **B** gear moved top-left right of the wordmark (left-click App Settings, right-click
Bug/Feature/About), **C** persist output resolution default + surface default scene
transition/duration + confirm-before-End, **D** accent/theme color (consolidate 69 hardcoded
- accent sites into a DynamicResource brush + picker).
+ accent sites into a DynamicResource brush + picker). **Unit B (gear) is DONE early** — shipped 2026-09-22
+ as part of TASK 42 (top bar redesign): gear top-right of the brand, single click = the Settings/Bug/
+ Feature/About menu, removed from BottomBar. A + C + D remain queued.
- **TASK 37** — queued (2026-09-20): split the layout data into a `default` set (the established DB)
and a `current` set carrying the build-id; a saved `current` is honored only by the same build-id,
otherwise defaults load — enables one-click revert to defaults too. Capture needed work OUT of
diff --git a/TASKS/task-40-app-settings-round.md b/TASKS/task-40-app-settings-round.md
index 92fbac8..049da58 100644
--- a/TASKS/task-40-app-settings-round.md
+++ b/TASKS/task-40-app-settings-round.md
@@ -91,12 +91,21 @@ drives `CameraConfigViewModel` — Auto + slider changes forward to the probe.
## Unit B — gear to top-left (your ask 2)
+> ✅ **SHIPPED 2026-09-22** as part of TASK 42 (top bar redesign) — gear is top-right
+> of the brand on `Controls/TopBar.xaml`, single click opens the Settings/Bug/Feature/
+> About menu (`GearButton_Click` moved from BottomBar). See
+> [`task-42-top-bar-redesign.md`](task-42-top-bar-redesign.md).
+
- `Controls/TopBar.xaml`: `IconButton` gear **right of the brand Button**, gap ≈ 3 wordmark
letters; left-click `OpenSettingsCommand`; ContextMenu keeps Report Bug / Feature
Request / About. Remove the gear (+ its click handler) from `Controls/BottomBar.xaml(.cs)`.
- Bonus: TopBar sits inside the overlay scrim span (`OverlayHost Grid.RowSpan=3` covers rows
0–2), so the gear is correctly covered while an overlay is open — today it sits below the scrim.
+> **Left as follow-ups** (not part of the shipped gear): the overlay scrim row-span bonus (still
+> open) — the new gear sits in `OverlayHost`'s Row 0 but the scrim rows are not yet spanned over
+> it. Revisit when rows are next touched.
+
## Unit C — the small defaults (your ask 3, rest)
- **Default output resolution** — persist `SelectedQuality` (e.g. Settings key
diff --git a/TASKS/task-42-top-bar-redesign.md b/TASKS/task-42-top-bar-redesign.md
new file mode 100644
index 0000000..6183818
--- /dev/null
+++ b/TASKS/task-42-top-bar-redesign.md
@@ -0,0 +1,71 @@
+# TASK 42 — Top bar redesign (shipped 2026-09-22)
+
+> One work unit, no sub-tasks (creator directive: "forget this one dog plan bullshit.
+> Our one plan is replacing the top menu bar with something usable"). Good Dog =
+> ONE integration test: `ytLive.Tests/TopBarModeTests.cs`.
+
+## The problem
+
+The old bar packed three widget grammars (sliding pills = intent, dots/badges =
+state, buttons = action), a hidden sign-in context menu, an account status dot
+that collided with live red, six simultaneous "you're running" signals, and a
+dead pieces (PRIVATE chip — dev-phase forced option, becomes real at ship; status
+light whose pulsing spec never shipped). The creator: "I wrote the fucking thing
+and I still can't figure-out how to do stuff."
+
+## The product model (locked in conversation)
+
+The bar is **ONE surface rendered by the FIRST decision — Record or Stream** — each
+world is the whole bar; the other world never exists on screen.
+
+- **Mode switch:** one segmented **REC|ON-AIR** toggle (former two pills are a
+ binary, radio-exclusive decision). Going back = one tap on the other segment.
+ Idle-only; while running the switch retires.
+- **Record world:** `switch + Start Recording`. **No YouTube identity at all.**
+- **Stream world:** `switch + Go Live + Test` and the **account zone** on the right
+ (Sign In while signed-out — the pending sequence step rendered exactly when
+ needed; the avatar once connected, right-click = Change Account/Logout).
+- **Test is a child of Stream** (creator ruling): it procs only when ON-AIR is
+ armed AND signed in — you can't dry-run a recording.
+- **Running:** the worlds retire; one reality line `● REC|LIVE|TEST 00:12:34`
+ (green/red/gold) + End. Full-bar color flip stays.
+- **Gear** moved up from the bottom bar to ~3 wordmark-letters past the brand
+ (task 40 Unit B spec): one menu — App Settings / Report Bug / Feature Request /
+ About. Removed from `BottomBar`.
+- **Sign-in flow:** Stream → (Sign In appears, or avatar confirms the account) →
+ Go Live → Stop. Account switch = one re-auth → Google's own chooser pre-selected
+ (no accounts-list API exists; `login_hint`+`select_account` is the source of truth).
+
+## Files (declared scope)
+
+- `Themes/Controls.xaml` — `SegmentToggle` / `SegmentLabel` (segmented switch)
+- `Controls/TopBar.xaml(.cs)` — full rewrite; `GearButton_Click` moved in
+- `Controls/BottomBar.xaml(.cs)` — gear removed
+- `ViewModels/MainViewModel.Streaming.cs` — world/reality props +
+ `RaiseTopBarModes()` (called from StreamStatus/IsRecording/IsTestStream/pill
+ setters); `PrimaryStartButtonLabel` = "Start Recording"/"Go Live";
+ `ShowTestButton`/`CanStartTest` = stream world AND connected
+- `ViewModels/MainViewModel.Streaming.Operations.cs` — `BeginTestStream` arms
+ `OnAirPillOn` explicitly (the TASK 41 pipeline bug: a test that never armed the
+ pill booted a pump with zero encoder outputs → "At least one output is required")
+- `ViewModels/MainViewModel.Account.cs` — `IsConnected` raises `RaiseTopBarModes()`
+- `ytLive.Tests/PillRadioTests.cs` / `TestStreamTests.cs` — updated to the new
+ gates (label + middle-mover + Test-on-Stream)
+- `ytLive.Tests/TopBarModeTests.cs` — NEW Good Dog
+- Docs: ai.md, ViewModels/index.md, Controls/index.md, TASKS.md, HANDOFF.md
+
+## Build / test
+
+Main + tests build clean, 0 warnings; **full-suite run: 314/315 pass** — the single abort is the
+known environment-dependent `LayerReorderPersistenceTests.RealMouseDrag` (Path of Exile 2 was open;
+its injected physical mouse events no-op when a game window interferes — documented in the test's own
+docstring). 314 prior tests + the new Good Dog, all green; the drag test passes on a clean desktop.
+Test environment note: a dev machine may hold a real saved OAuth
+session that loads synchronously in the ctor — signed-in-world assertions must
+force `IsConnected` first (the record of that trap lives here and in the tests).
+
+## Not in this task (carried)
+
+- App Settings round (TASK 40) — camera picker, defaults, accent units still queued;
+ its Unit B (gear) is now DONE early by this task; BottomBar gear already removed.
+- Account-confirm via Google chooser is behavior folded into Sign In (no new code).
\ No newline at end of file
diff --git a/Themes/Controls.xaml b/Themes/Controls.xaml
index 6f35c14..cb42e34 100644
--- a/Themes/Controls.xaml
+++ b/Themes/Controls.xaml
@@ -655,6 +655,42 @@
+
+
+
+
+
diff --git a/ViewModels/MainViewModel.Account.cs b/ViewModels/MainViewModel.Account.cs
index 536e65f..f400fac 100644
--- a/ViewModels/MainViewModel.Account.cs
+++ b/ViewModels/MainViewModel.Account.cs
@@ -20,9 +20,7 @@ public partial class MainViewModel
// ON-AIR requires a sign-in; dropping the session turns the pill
// off so the state can never offer a stream without an account.
if (!value) OnAirPillOn = false;
- OnPropertyChanged(nameof(PrimaryStartButtonLabel));
- OnPropertyChanged(nameof(ShowPrimaryStartButton));
- OnPropertyChanged(nameof(ShowTestButton));
+ RaiseTopBarModes();
OnPropertyChanged(nameof(AccountStatusLightToolTip));
}
}
diff --git a/ViewModels/MainViewModel.Streaming.Operations.cs b/ViewModels/MainViewModel.Streaming.Operations.cs
index 9b81cef..f874f4a 100644
--- a/ViewModels/MainViewModel.Streaming.Operations.cs
+++ b/ViewModels/MainViewModel.Streaming.Operations.cs
@@ -94,6 +94,11 @@ public partial class MainViewModel : ViewModelBase
private void BeginTestStream()
{
if (!CanStartTest) return;
+ // Test is stream-world-only, so the ON-AIR intent is armed by the time we
+ // get here — but arm it explicitly anyway: the encoder output gate reads the
+ // pill (StreamEnabled = OnAirPillOn), and a test that never armed the pill
+ // boots a pump with zero outputs and dies on "At least one output required".
+ OnAirPillOn = true;
IsTestStream = true; // before StreamStatus so the visuals initialize gold
StreamTitle = BroadcastForm.Title;
StreamDescription = BroadcastForm.Description;
diff --git a/ViewModels/MainViewModel.Streaming.cs b/ViewModels/MainViewModel.Streaming.cs
index 23c3745..3184acb 100644
--- a/ViewModels/MainViewModel.Streaming.cs
+++ b/ViewModels/MainViewModel.Streaming.cs
@@ -75,6 +75,7 @@ public partial class MainViewModel : ViewModelBase
OnPropertyChanged(nameof(AccountStatusLightToolTip));
OnPropertyChanged(nameof(ShowChatInactiveMessage));
OnPropertyChanged(nameof(ShowPreviewPlaceholder));
+ RaiseTopBarModes();
UpdateLiveVisuals();
}
}
@@ -98,6 +99,7 @@ public partial class MainViewModel : ViewModelBase
if (value && _onAirPillOn) OnAirPillOn = false; // record-OR-live — arming REC drops ON-AIR
OnPropertyChanged(nameof(ShowPrimaryStartButton));
OnPropertyChanged(nameof(CanStartSession));
+ RaiseTopBarModes();
}
}
@@ -113,7 +115,7 @@ public partial class MainViewModel : ViewModelBase
if (value && _recordPillOn) RecordPillOn = false; // record-OR-live — arming ON-AIR drops REC
OnPropertyChanged(nameof(ShowPrimaryStartButton));
OnPropertyChanged(nameof(CanStartSession));
- OnPropertyChanged(nameof(PrimaryStartButtonLabel));
+ RaiseTopBarModes();
}
}
@@ -135,6 +137,7 @@ public partial class MainViewModel : ViewModelBase
OnPropertyChanged(nameof(ShowPrimaryStartButton));
OnPropertyChanged(nameof(EndButtonText));
OnPropertyChanged(nameof(ShowTestButton));
+ RaiseTopBarModes();
UpdateSessionTimer();
}
}
@@ -149,15 +152,14 @@ public partial class MainViewModel : ViewModelBase
/// Either output selected makes the button actionable.
public bool CanStartSession => RecordPillOn || OnAirPillOn;
- /// Face of the primary Start button. The idle face is reality-aware:
- /// while the ON-AIR pill is armed signed-out it reads "Sign In" so intent-lit
- /// intent is never a dead-end Start (— the pill is intent, the account is
- /// reality, and a greyed face would be the dead end that failed to ship, creator
- /// ruling 2026-09-20-02). The face flips back to "Start" the moment a session
- /// connects; the armable signed-out pill gets to keep its intent through the
- /// sign-in.
+ /// Face of the Go button names the armed world (top bar redesign): the
+ /// bar renders only ONE world at a time, so the button never has to explain
+ /// itself — it says exactly what pressing it will do. A signed-out ON-AIR Start
+ /// routes to the sign-in inside StartSession (Start — the account step shows up
+ /// beside it as the Sign In button); a signed-out go-live stays the dead-end the
+ /// creator ruling 2026-09-20-02 forbids.
public string PrimaryStartButtonLabel =>
- IsConnected || !OnAirPillOn ? "Start" : "Sign In";
+ OnAirPillOn ? "Go Live" : "Start Recording";
/// The End button shows while recording OR live.
public bool ShowEndStreamButton => IsLive || IsRecording;
@@ -170,13 +172,71 @@ public partial class MainViewModel : ViewModelBase
: IsTestStream ? "End Test"
: "End Stream";
- /// TASK 41 — the Test button (top bar, left of Start): a one-click
- /// private test broadcast. Only meaningful signed-in and idle — it disappears
- /// the moment a session (live OR recording) starts.
- public bool ShowTestButton => IsOffline && !IsRecording && IsConnected;
+ /// TASK 41 — Test is a child of Stream (creator ruling: "Test should only
+ /// proc when we've selected stream"): a dry-run of the livestream, so it exists
+ /// ONLY inside the armed ON-AIR world, signed-in and idle. You can't dry-run a
+ /// recording, so the Record world never offers it.
+ public bool ShowTestButton => ShowStreamActions && IsConnected;
public bool CanStartTest => ShowTestButton;
+ // ─── Top bar worlds / reality line (top bar redesign) ───
+ //
+ // The bar is ONE surface rendered by the FIRST decision — Record or Stream —
+ // and each world is the whole bar. Idle shows the mode switch + the armed
+ // world's actions; the account zone exists only inside the Stream world. While
+ // running, the switch and the worlds retire and a single reality line
+ // (● word elapsed) + End replace them, so nothing mid-session can be re-armed.
+
+ /// Idle — the mode switch is present and actionable.
+ public bool ShowIdleWorld => IsOffline && !IsRecording;
+
+ /// Running — the reality line (dot + word + elapsed) + End replace the switch.
+ public bool RunningVisible => IsLive || IsRecording;
+
+ /// Record world: the switch sits on REC and Go says "Start Recording".
+ /// No account zone, no Test — YouTube doesn't exist here.
+ public bool ShowRecordAction => ShowIdleWorld && !OnAirPillOn;
+
+ /// Stream world: the switch sits on ON-AIR; Go says "Go Live" and Test
+ /// appears. The account zone belongs to this world because login is a step in
+ /// THIS sequence.
+ public bool ShowStreamActions => ShowIdleWorld && OnAirPillOn;
+
+ /// Sign In appears on the right only inside the Stream world, signed-out
+ /// — the pending step of the stream sequence, made visible exactly when it's next.
+ public bool ShowSignInButton => ShowStreamActions && !IsConnected;
+
+ /// The avatar confirms WHICH account is about to stream — only in the
+ /// Stream world, signed-in. Right-click for Change Account / Logout.
+ public bool ShowAvatarButton => ShowStreamActions && IsConnected;
+
+ /// The reality word: TEST while a test broadcast runs, LIVE while on the
+ /// air, REC during a local-record session.
+ public string RunningText => IsTestStream ? "TEST" : IsLive ? "LIVE" : "REC";
+
+ /// Reality dot color, one meaning per mode: gold = test, red = live,
+ /// green = recording. The pre-spec pulsing lamp never shipped; the word + bar
+ /// color carry the state and the dot just pins it.
+ public string RunningDotBrush => IsTestStream ? "#9c6f1c" : IsLive ? "#e94560" : "#22c55e";
+
+ /// Raises every flag the top bar renders, so any state change re-renders
+ /// exactly the world that is now true.
+ private void RaiseTopBarModes()
+ {
+ OnPropertyChanged(nameof(ShowIdleWorld));
+ OnPropertyChanged(nameof(RunningVisible));
+ OnPropertyChanged(nameof(ShowRecordAction));
+ OnPropertyChanged(nameof(ShowStreamActions));
+ OnPropertyChanged(nameof(ShowSignInButton));
+ OnPropertyChanged(nameof(ShowAvatarButton));
+ OnPropertyChanged(nameof(ShowTestButton));
+ OnPropertyChanged(nameof(CanStartTest));
+ OnPropertyChanged(nameof(RunningText));
+ OnPropertyChanged(nameof(RunningDotBrush));
+ OnPropertyChanged(nameof(PrimaryStartButtonLabel));
+ }
+
/// TASK 41 — the current session is a test broadcast (drives the TEST
/// badge + gold top bar + "End Test" face). Internal setter is a test seam like
/// IsRecording: flips it before
@@ -189,6 +249,7 @@ public partial class MainViewModel : ViewModelBase
{
if (!SetProperty(ref _isTestStream, value)) return;
OnPropertyChanged(nameof(EndButtonText));
+ RaiseTopBarModes();
UpdateLiveVisuals();
}
}
diff --git a/ViewModels/index.md b/ViewModels/index.md
index 2653efd..de71e3c 100644
--- a/ViewModels/index.md
+++ b/ViewModels/index.md
@@ -17,7 +17,7 @@ areas' docs in their own rows keeps this index small to read.
| `MainViewModel.Audio.cs` | **Mic bar:** read-only realtime meter `AudioLevel`/`MeterFillWidth`/`MeterBrush` (fill = `Math.Min(1, AudioLevelMeter.ToDisplay((float)AudioLevel) * MicVolume)`, green→yellow→red at 60%/80% zones), `MicVolume` (0.8 default; drives `MicMuted` ⇔ volume 0; unmute flash `BeginVolumeFlash`/`EndVolumeFlash`/`CancelVolumeFlash`), `MicSourceName` (left-justified inside the bar, persists via `LayoutStore.Settings`), `ToggleMicMute`/`PickMicrophone` (`MicPickerDialog`; picked device swaps the live source via `_audioMixer.RestartMic()`, loopback keeps running), status dot `MicStatus`/`MicStatusBrush`/`MicStatusToolTip` (`Models.MicStatus`: green Connected / yellow Problem / red no-device), capture starts once at startup (`StartMicCaptureAsync`) — NOT go-live. **Game audio bar:** overlaid bottom-center, always visible (show/hide gating removed 2026-08-15); `GameAudioLevel` (loopback via `OnLoopbackLevelChanged`), `GameAudioVolume`/`GameMuted`/`GameMuteText`/`ToggleGameMute`/`PushSystemVolume`, mirror volume flash `SetGameVolumeAdjusting`/`BeginGameVolumeFlash`/`EndGameVolumeFlash`/`CancelGameVolumeFlash`. Mixer handlers `OnMicLevelChanged`/`OnLoopbackLevelChanged`/`OnMicConnected`/`OnMicFailed`. |
| `MainViewModel.Trax.cs` | Background music (TASK 9, rides the loopback into the stream): `_musicPlayer` (`MusicPlayer`), `IsMusicPlaying`/`TraxError`/`TraxDotBrush`/`TraxToolTip`, interaction `TraxLeftClick`/`TraxRightClick`/`PickTraxTrack`, playback `LoadTrax`/`PlayTrax`/`PauseTrax`/`OnTraxPlaybackEnded`/`RefreshTraxUi`, `UpdateTraxLocalGain` (mirrors the game bar's mute onto `MusicPlayer.LocalGain` so the track ducks/restores with desktop audio). |
| `MainViewModel.Socials.cs` | Social bar (global resource, overlaid on every scene): `_socials`/`_socialBarFrame`/`_socialValidator` (`ISocialValidator`/`HttpSocialValidator`), `HasSocials`/`SocialBarVisible`/`SocialBarDotBrush`/`SocialBarGlowBrush`/`Socials`/`SocialBarTop`, `OpenSocialDialogCommand`/`OpenSocialDialog` (spawns `SocialsDialogViewModel` + window; saved copy replaces `_socials`), `NotifySocialsChanged`/`RenderSocialBarFrame` (`SocialBarRenderer` → immutable `_socialBarFrame` the frame pump reads), fediverse heal `HealFediverseSoftwareAsync` (static, test-facing) + `HealFediverseSoftwareInBackground`, bar edge toggle `SetSocialBarPosition`/`ToggleSocialBarPosition`. `BarOnBrush`/`BarOffBrush`/`CreateBrush` stay in core (shared tokens). |
-| `MainViewModel.Streaming.cs` | Go-live/record + health + quality tiers + session UI. **Stream state:** `StreamStatus`/`StreamVisibility` setters + `_streamStatus` field, `IsOffline`/`IsLive`/`IsLivePrivate`. **Go-live flow:** `StartSession` (routes by pill state) → `BeginRecordOnly` (signed-out local recording) or `BeginGoLive` (GoLive dialog → captures title/description/visibility + `BroadcastForm.CaptureGoLive` → `PrepareAndStartLiveAsync`: reusable stream → `CreateBroadcast` (stores `_currentBroadcastId`) → empty-metadata warn → chat poll start (liveChatId) → `_framePump.StartAsync()` + health poll; failures → `StreamStatus.Error`, never a crash); `StartRecordFile` (auto-named `ty-…-0000.mp4`) + `FinalizeRecordingAsync` (rename modal, `UniquePath`); `StopStream` (posts Offline, stops chat/audio pipe/pump, clears `_currentBroadcastId`; session/token deliberately NOT cleared — sign-out stays explicit). **Health:** `CurrentHealth`/`ResetHealth`/`ApplyHealth`/`OnFramePumpHealthUpdated` (marshal `FramePump.HealthUpdated` off the stderr thread), `PollHealthAsync`/`OnHealthPollTick`/`ApplyHealthIssue` (report-by-exception banner `HealthIssueBanner`/`HealthIssueBackground` via `StreamHealthReporter`). **Session timer:** `_liveTimer` (ctor-wired) `UpdateSessionTimer`/`OnLiveTimerTick`/`LiveElapsedText`/`_recDotPulse`. **Start/End UI:** REC/ON-AIR pills (`RecordPillOn`/`OnAirPillOn`/`CanToggleOnAir`), `ShowPrimaryStartButton`/`CanStartSession`/`ShowEndStreamButton`/`EndButtonText`/`ShowLogInButton`, REC sign `RecDotBrush`/`RecTextBrush`/`RecDotOpacity`/`OnAirBrush`, live window tokens `WindowTitle`/`TopBarBackground`/`PreviewGlow*` via `UpdateLiveVisuals`; `ChooseRecordFolder`/`ResetRecordFolder`/`_recordFolder`/`RecordFolderDisplay`/`DefaultRecordFolder`. **Resolution dropdown:** `QualityOptions`/`SelectedQuality`/`ResolutionHelp`/`ApplyStreamQuality` + `OutputRect*`/`DimRects`/`IsOutputCropped`/`ResolutionBadgeText` over the 1920×1080 master. **Pipeline bits:** `_framePump`/`_liveTimer`/`_healthPollTimer` fields, `BuildEncoderOptions`. Commands `StartStreamCommand`/`EndStreamCommand`/`ChooseRecordFolderCommand`/`ResetRecordFolderCommand`. Broadcast pull-out: `CurrentBroadcastId`/`BroadcastForm`. |
+| `MainViewModel.Streaming.cs` | Go-live/record + health + quality tiers + session UI. **Stream state:** `StreamStatus`/`StreamVisibility` setters + `_streamStatus` field, `IsOffline`/`IsLive`/`IsLivePrivate`. **Go-live flow:** `StartSession` (routes by pill state) → `BeginRecordOnly` (signed-out local recording) or `BeginGoLive` (GoLive dialog → captures title/description/visibility + `BroadcastForm.CaptureGoLive` → `PrepareAndStartLiveAsync`: reusable stream → `CreateBroadcast` (stores `_currentBroadcastId`) → empty-metadata warn → chat poll start (liveChatId) → `_framePump.StartAsync()` + health poll; failures → `StreamStatus.Error`, never a crash); `StartRecordFile` (auto-named `ty-…-0000.mp4`) + `FinalizeRecordingAsync` (rename modal, `UniquePath`); `StopStream` (posts Offline, stops chat/audio pipe/pump, clears `_currentBroadcastId`; session/token deliberately NOT cleared — sign-out stays explicit). **Health:** `CurrentHealth`/`ResetHealth`/`ApplyHealth`/`OnFramePumpHealthUpdated` (marshal `FramePump.HealthUpdated` off the stderr thread), `PollHealthAsync`/`OnHealthPollTick`/`ApplyHealthIssue` (report-by-exception banner `HealthIssueBanner`/`HealthIssueBackground` via `StreamHealthReporter`). **Session timer:** `_liveTimer` (ctor-wired) `UpdateSessionTimer`/`OnLiveTimerTick`/`LiveElapsedText`/`_recDotPulse`. **Start/End UI:** mode switch `RecordPillOn`/`OnAirPillOn`, top-bar worlds (`ShowIdleWorld`/`ShowRecordAction`/`ShowStreamActions`/`ShowSignInButton`/`ShowAvatarButton`, reality line `RunningVisible`/`RunningText`/`RunningDotBrush`, Test gate `ShowTestButton`/`CanStartTest` — `RaiseTopBarModes()`), `ShowPrimaryStartButton`/`CanStartSession`/`ShowEndStreamButton`/`EndButtonText`, REC sign `RecDotBrush`/`RecTextBrush`/`RecDotOpacity`/`OnAirBrush`, live window tokens `WindowTitle`/`TopBarBackground`/`PreviewGlow*` via `UpdateLiveVisuals`; `ChooseRecordFolder`/`ResetRecordFolder`/`_recordFolder`/`RecordFolderDisplay`/`DefaultRecordFolder`. **Resolution dropdown:** `QualityOptions`/`SelectedQuality`/`ResolutionHelp`/`ApplyStreamQuality` + `OutputRect*`/`DimRects`/`IsOutputCropped`/`ResolutionBadgeText` over the 1920×1080 master. **Pipeline bits:** `_framePump`/`_liveTimer`/`_healthPollTimer` fields, `BuildEncoderOptions`. Commands `StartStreamCommand`/`EndStreamCommand`/`ChooseRecordFolderCommand`/`ResetRecordFolderCommand`. Broadcast pull-out: `CurrentBroadcastId`/`BroadcastForm`. |
| `MainViewModel.Overlays.cs` | Settings/bug/feature/about panels + creator-hub links: panel state `IsSettingsOpen`/`IsBugOpen`/`IsFeatureOpen`/`IsAboutOpen` (via `SetPanel` → `IsAnyOverlayOpen`), `IsLicensesOpen` (flips `IsHubVisible`), `OverlayTitle`, `ShowOverlay` (one-panel exclusivity) + `ShowLicenses` (loads shipped `THIRD-PARTY-NOTICES.txt` into `LicensesText` on first open, never the OS viewer); bug/feature report `BugReportText`/`BugReportEmail`/`FeatureRequestText`/`FeatureRequestEmail` + `SubmitBug`/`SubmitFeature`/`ComposeEmail`/`OpenUrl`; About hub consts `ChannelUrl`/`MastodonUrl`/`CoffeeUrl` (public, integration-test live) + `PremiumUrl` (tabled billing seam — TASK 12 → greyed "Unlock Premium" lights up when filled) + `IsPremiumAvailable` + `SupportEmail` + `AppVersionLabel`; commands `OpenSettingsCommand`/`OpenBugCommand`/`OpenFeatureCommand`/`OpenAboutCommand`/`CloseOverlayCommand`/`SubmitBugCommand`/`SubmitFeatureCommand`/`OpenChannelCommand`/`OpenMastodonCommand`/`OpenCoffeeCommand`/`OpenPremiumCommand`/`OpenLicensesCommand`. Ctor command assignments stay in core. |
| `MainViewModel.Chat.cs` | Chat overlay + preview pipeline: `ChatMessages` (500-message cap; clear-on-fade), `OnChatMessageReceived` (marshal `_youtubeChat.MessageReceived` onto the dispatcher, switch previews mock→real), fade `_chatFadeTimer`/`ResetChatFadeTimer` (shortest `ChatFadeSeconds` across chat layers); live render `RenderChatBox` (called from `ResolveOutputFrame`, stays in core) + preview render `UpdateChatBoxPreview` (bitmap onto `chatBox.VideoImageSource`); mock preview `MockChatMessages`/`StartMockChatPreview`/`StopMockChatPreview`/`RunMockChatPreviewAsync`/`_mockChatCts`; `UpdateActiveChatBoxPreviews` (wires Chat* property-change subscriptions + first render); `CanAddYouTubeChat` (one-per-layout gate), `ShowChatInactiveMessage` (`!IsLive`). `_chatBoxRenderer` field. Ctor wiring (`MessageReceived` hookup, `new ChatBoxRenderer()`, `LoadLayout` preview start, `_youtubeChat.Dispose`) stays in core. |
| `GoLiveViewModel.cs` | Start Stream dialog: **account row** (saved channel shown with Change Account, or Sign in to YouTube; Start gated on `IsSignedIn`/`IsBusy`) + title/description + **locked Visibility (always "Private" — ship step 7, no dropdown)**, start/cancel requests |
diff --git a/ai.md b/ai.md
index 622003d..fd8584c 100644
--- a/ai.md
+++ b/ai.md
@@ -624,25 +624,29 @@ driven by the `FramePump` below.
### Local recording (TASK 18 — shipped 2026-08-29, VM + top-bar UX; plan in TASKS.md)
-Recording is **independent from streaming** and needs no YouTube sign-in. Two pill toggles in the top bar
-declare intent: **REC pill** (local file, works signed-out) and **ON-AIR pill** (streaming; **armable
-signed-out** — intent may light while offline, 2026-09-20: a greyed pill was the dead end the guard
-shipped to forbid; the ON-AIR setter's signed-out light IS what the pill is for). The pill is intent;
-`IsRecording`/`IsLive` are reality — the REC status dot only turns
-green when a session is actually recording, the ON-AIR dot when actually live.
+Recording is **independent from streaming** and needs no YouTube sign-in. A single segmented
+**REC|ON-AIR switch** in the top bar picks the mode (redesigned 2026-09-22 from the old two pills):
+**REC** (local file, works signed-out) / **ON-AIR** (streaming; **armable signed-out** — intent may
+light while offline, 2026-09-20: a greyed pill was the dead end the guard shipped to forbid). The switch
+selection is intent; `IsRecording`/`IsLive` are reality — the reality line (dot + word + elapsed) only
+lights when a session is actually running.
-- **State model (`MainViewModel`):** pills `RecordPillOn`/`OnAirPillOn` (radio-exclusive — arming one
- clears the other, record-OR-live 2026-09-01; the ON-AIR setter stays armable signed-out (2026-09-20: intent must be able to light while offline — a pill that could not light signed-out was the dead end the guard shipped to forbid)), `ShowPrimaryStartButton` = **always the idle face** (`IsOffline && !IsRecording`,
- 2026-09-01 — the old connected/record-only gate blanked the bar after stopping signed-out),
- `ShowEndStreamButton`, `CanStartSession`, `AccountStatusLightToolTip`. Sign-in is a **context-menu
- item on Start** ("Sign in to YouTube" → `SignInCommand`, visible while disconnected) — the standalone
- Sign In button is gone (TASK 30's single-button rule, completed). `StartSession()` routes: ON-AIR on
- → GoLive dialog then stream; REC on or **nothing armed** → `BeginRecordOnly()` (unarmed
- Start lights the REC pill and records — no dead-end no-ops; local recording needs no account).
- `IsEditMode` also requires `!IsRecording` (lock scrubbing while recording).
- **Top bar order (creator spec 2026-09-01):** `[sign light] REC [pill] [sign light] ON-AIR [pill]`
- — each reality lamp sits before its own intent switch. **Stop ends everything:** `StopStream`
- clears both pills (see "Stop ends everything; failures roll back" below).
+- **State model (`MainViewModel`):** pills `RecordPillOn`/`OnAirPillOn` stay the **routing model**
+ (radio-exclusive — arming one clears the other, record-OR-live 2026-09-01; the ON-AIR setter stays
+ armable signed-out (2026-09-20: intent must be able to light while offline)). **Top bar redesign
+ (2026-09-22):** the bar is ONE surface rendered by the FIRST decision — Record or Stream — and each
+ world is the whole bar (`ShowIdleWorld`/`ShowRecordAction`/`ShowStreamActions`, wired by
+ `RaiseTopBarModes()`). The old sign-lights + two pill toggles + status dot + PRIVATE/TEST chips are
+ gone; the mode is one **segmented REC|ON-AIR switch** (`SegmentToggle`/`SegmentLabel` styles), and
+ "going back" is one tap on the other segment. Idle worlds: Record = switch + "Start Recording"
+ (`PrimaryStartButtonLabel` names the armed world) and **no YouTube identity at all**; Stream = switch
+ + "Go Live" + Test (Test is a **child of Stream** — visible only stream-armed AND signed-in) plus the
+ account zone on the right (`ShowSignInButton` = stream world signed-out; `ShowAvatarButton` = the
+ avatar, right-click Change Account/Logout). Running retires the switch and worlds; one reality line
+ `● word elapsed` + End replace them (`RunningVisible`/`RunningText`/`RunningDotBrush` — TEST/LIVE/REC,
+ gold/red/green). The account **status dot is gone** — the avatar/Sign In pair carries connectedness.
+ `ShowPrimaryStartButton` remains the always-idle face. `StopStream` clears both pills (see "Stop ends
+ everything; failures roll back" below).
- **Filenames** (pure `Services/RecordingFile`): auto-name `ty---0000.mp4` at start;
**rename-on-stop** to `ty-…-.mp4` (`FinalizeRecordingAsync` after the pump stops &
the file closes), numeric `-2`/`-3` suffix on collision (`UniquePath`). Length comes from `_liveElapsed`,
diff --git a/ytLive.Tests/PillRadioTests.cs b/ytLive.Tests/PillRadioTests.cs
index 0f310c3..0ef73a9 100644
--- a/ytLive.Tests/PillRadioTests.cs
+++ b/ytLive.Tests/PillRadioTests.cs
@@ -65,13 +65,13 @@ public sealed class PillRadioTests
/// Good Dog (one integration test, creator ruling 2026-09-20): the ON-AIR
/// pill is armable signed-out — intent may light while the account is offline (a
/// greyed pill was the dead end the guard shipped to forbid). A front-branch on
- /// the ON-AIR setter that dropped the pill headlessly is what the pill is FOR. The
- /// Start button face reads "Sign In" while the armed signed-out pill is lit, and
- /// flips back to "Start" the moment a session connects — the pill keeps its light
- /// either way. Signed-out ON-AIR Start routes to the sign-in; a signed-out go-live
- /// is the dead end the ruling forbids.
+ /// the ON-AIR setter that dropped the pill headlessly is what the pill is FOR.
+ /// Since the top bar redesign, the Go face names the armed WORLD ("Go Live")
+ /// rather than the account race — a signed-out Stream press routes into the
+ /// sign-in inside StartSession, and the dedicated Sign In button appears on the
+ /// right exactly when the stream sequence needs the account step.
[Fact]
- public void SignedOut_OnAir_Pill_Stays_Lit_And_Start_Face_Is_SignIn()
+ public void SignedOut_OnAir_Pill_Stays_Lit_And_Stream_World_Is_GoLive()
{
_app.Run(RunSignedOutOnAir);
}
@@ -86,6 +86,9 @@ public sealed class PillRadioTests
SqliteConnection.ClearAllPools();
var vm = new MainViewModel();
+ // The dev machine may hold a real saved OAuth session (loads synchronously
+ // during the ctor), so the signed-out face is forced deterministically.
+ vm.IsConnected = false;
// No sign-in: the ON-AIR pill MUST still be able to light — intent before
// reality, armable signed-out (creator ruling 2026-09-20).
@@ -93,16 +96,21 @@ public sealed class PillRadioTests
Assert.True(vm.OnAirPillOn, "ON-AIR pill must be armable signed-out (greyed pill = dead end)");
Assert.True(vm.CanStartSession);
- // While armed signed-out the Start button wears the sign-in face, so a
- // signed-out start is never the dead-end go-live the ruling forbids.
- Assert.Equal("Sign In", vm.PrimaryStartButtonLabel);
- Assert.False(vm.ShowPrimaryStartButton && vm.CanStartSession && false,
- "sanity: face follows the pill, not the account race");
+ // While armed signed-out the Go face names the Stream world ("Go Live");
+ // the account step surfaces as the Sign In button on the right instead,
+ // and a signed-out start routes into the sign-in (never a dead-end go-live).
+ Assert.Equal("Go Live", vm.PrimaryStartButtonLabel);
+ Assert.True(vm.ShowSignInButton);
+ Assert.False(vm.ShowRecordAction);
+ Assert.True(vm.ShowStreamActions);
- // A session connects: the pill KEEPS its light and the face flips to Start.
+ // A session connects: the pill KEEPS its light; the stream world's Sign In
+ // retires and the avatar confirms the account.
vm.IsConnected = true;
Assert.True(vm.OnAirPillOn, "the armed signed-out pill must survive the sign-in");
- Assert.Equal("Start", vm.PrimaryStartButtonLabel);
+ Assert.Equal("Go Live", vm.PrimaryStartButtonLabel);
+ Assert.False(vm.ShowSignInButton);
+ Assert.True(vm.ShowAvatarButton);
}
finally
{
diff --git a/ytLive.Tests/TestStreamTests.cs b/ytLive.Tests/TestStreamTests.cs
index 1db7594..ac7e8ec 100644
--- a/ytLive.Tests/TestStreamTests.cs
+++ b/ytLive.Tests/TestStreamTests.cs
@@ -149,15 +149,20 @@ public sealed class TestStreamTests
Assert.False(model.Ypp.IsDrawerOpen);
Assert.False(model.TestSession.IsDrawerOpen);
- // Test-button gate follows the sign-in state deterministically (the
- // initial state is environment-dependent — a dev machine may hold a real
- // saved OAuth session).
+ // Test-button gate: Test is a child of Stream — it procs ONLY when the
+ // ON-AIR world is armed AND signed in (title-gating ruling). The initial
+ // sign-in state is environment-dependent (a dev machine may hold a real
+ // saved OAuth session), so it is forced deterministically.
model.IsConnected = false;
Assert.False(model.CanStartTest);
Assert.False(model.ShowTestButton);
+ model.OnAirPillOn = true; // stream world
model.IsConnected = true;
Assert.True(model.CanStartTest);
Assert.True(model.ShowTestButton);
+ model.RecordPillOn = true; // back to the record world → Test retires
+ Assert.False(model.CanStartTest);
+ Assert.False(model.ShowTestButton);
model.TestSession.ToggleDrawerCommand.Execute(null);
Assert.True(model.TestSession.IsDrawerOpen);
diff --git a/ytLive.Tests/TopBarModeTests.cs b/ytLive.Tests/TopBarModeTests.cs
new file mode 100644
index 0000000..f74a4bf
--- /dev/null
+++ b/ytLive.Tests/TopBarModeTests.cs
@@ -0,0 +1,106 @@
+using System;
+using System.IO;
+using Microsoft.Data.Sqlite;
+using Xunit;
+using ytLive.Models;
+using ytLive.Services;
+using ytLive.ViewModels;
+
+namespace ytLive.Tests;
+
+///
+/// Top bar redesign — the bar is ONE surface rendered by the FIRST decision
+/// (Record or Stream). Each world is the whole bar: the Record world has no
+/// YouTube identity at all, the Stream world owns the account zone (Sign In or
+/// avatar) plus Test. The switch is a two-position gate, so "going back" is one
+/// tap on the other segment. While running, the switch retires and a single
+/// reality line (dot + word + elapsed) + End replace it.
+///
+[Collection("RealApp")]
+public sealed class TopBarModeTests
+{
+ private readonly RealAppHost _app;
+
+ public TopBarModeTests(RealAppHost app) => _app = app;
+
+ [Fact]
+ public void TopBar_Renders_Only_The_Armed_World()
+ {
+ _app.Run(Run);
+ }
+
+ private void Run()
+ {
+ var tempDb = Path.Combine(Path.GetTempPath(), $"ytLlive-topbar-{Guid.NewGuid():N}.db");
+ MainViewModel.LayoutPathOverride = tempDb;
+ try
+ {
+ using (var schema = new LayoutStore(tempDb)) { }
+ SqliteConnection.ClearAllPools();
+
+ var vm = new MainViewModel();
+
+ // The dev machine may hold a real saved OAuth session (loads synchronously
+ // during the ctor), so the sign-in state is forced deterministically.
+ vm.IsConnected = false;
+
+ // Default (nothing armed) = the Record world: Go says "Start Recording",
+ // no account zone, no Test — YouTube doesn't exist in this world.
+ Assert.True(vm.ShowIdleWorld);
+ Assert.True(vm.ShowRecordAction);
+ Assert.False(vm.ShowStreamActions);
+ Assert.False(vm.ShowSignInButton);
+ Assert.False(vm.ShowAvatarButton);
+ Assert.False(vm.ShowTestButton);
+ Assert.Equal("Start Recording", vm.PrimaryStartButtonLabel);
+
+ // Arming ON-AIR switches the whole bar to the Stream world.
+ vm.OnAirPillOn = true;
+ Assert.False(vm.ShowRecordAction);
+ Assert.True(vm.ShowStreamActions);
+
+ // Signed out: the account zone shows Sign In; no avatar; Test is gated
+ // behind the sign-in (Test rides on Stream AND a connected account).
+ Assert.True(vm.ShowSignInButton);
+ Assert.False(vm.ShowAvatarButton);
+ Assert.False(vm.ShowTestButton);
+ Assert.Equal("Go Live", vm.PrimaryStartButtonLabel);
+
+ // Connected: avatar + Test join the Stream world; Sign In retires.
+ vm.IsConnected = true;
+ Assert.False(vm.ShowSignInButton);
+ Assert.True(vm.ShowAvatarButton);
+ Assert.True(vm.ShowTestButton);
+
+ // Going back is one tap: flipping REC returns the bar to the Record world.
+ vm.RecordPillOn = true;
+ Assert.False(vm.ShowStreamActions);
+ Assert.True(vm.ShowRecordAction);
+ Assert.False(vm.ShowAvatarButton);
+ Assert.False(vm.ShowTestButton);
+ Assert.Equal("Start Recording", vm.PrimaryStartButtonLabel);
+
+ // Running retires the switch and worlds; the reality line pins the mode.
+ vm.RecordPillOn = false;
+ vm.OnAirPillOn = true;
+ vm.IsRecording = true;
+ Assert.False(vm.ShowIdleWorld);
+ Assert.True(vm.RunningVisible);
+ Assert.Equal("REC", vm.RunningText);
+ Assert.Equal("#22c55e", vm.RunningDotBrush);
+ vm.IsRecording = false;
+ vm.StreamStatus = StreamStatus.Streaming;
+ Assert.Equal("LIVE", vm.RunningText);
+ Assert.Equal("#e94560", vm.RunningDotBrush);
+ vm.IsTestStream = true;
+ Assert.Equal("TEST", vm.RunningText);
+ Assert.Equal("#9c6f1c", vm.RunningDotBrush);
+ }
+ finally
+ {
+ MainViewModel.LayoutPathOverride = null;
+ SqliteConnection.ClearAllPools();
+ try { File.Delete(tempDb); } catch { /* best-effort cleanup */ }
+ }
+ }
+}
\ No newline at end of file