feat(ui): TASK 42 top bar redesign — one Record-or-Stream surface (2026-09-22)

The bar now renders ONE surface from the FIRST decision the creator makes —
Record or Stream — with each world being the whole bar. Creator directive:
"I wrote the fucking thing and I still can't figure-out how to do stuff",
"forget this one dog plan bullshit". OBS/streaming-tool reference for the
one-surface paradigm: https://obsproject.com (single mode toggle + context
actions) — cited per spin-guard habit; the go-live/test pattern follows
CEV (https://cev-desktop.aSean.xyz) precedent.

- Mode: single segmented REC|ON-AIR switch (SegmentToggle/SegmentLabel in
  Themes/Controls.xaml); radio-exclusive world selection, one tap to flip.
- Record world: switch + Start Recording, zero YouTube identity.
- Stream world: switch + Go Live + Test + account zone right (Sign In until
  connected, then avatar with Change Account/Logout context menu).
- Test is a child of Stream: procs only stream-armed AND signed-in.
- Running: one reality line "dot word elapsed" (REC/LIVE/TEST; green/red/
  gold) + End; worlds + switch retire.
- Gear moved up from bottom bar, ~3 wordmark letters past the brand, single
  click opens Settings/Bug/Feature/About menu (TASK 40 Unit B shipped early).
- Fix folded in: BeginTestStream now arms OnAirPillOn explicitly (unarmed
  pump booted with zero encoder outputs -> "At least one output is required"
  forced-stop cascade from TASK 41's pipeline).
- VM: world/reality props + RaiseTopBarModes() wired into StreamStatus,
  pill, IsRecording, IsTestStream, IsConnected setters.

Doc: ai.md top-bar model, ViewModels/index.md, Controls/index.md, TASKS.md,
HANDOFF.md, new TASKS/task-42-top-bar-redesign.md; task-40 note.

Tests: TopBarModeTests.cs (new Good Dog), PillRadioTests + TestStreamTests
gates updated. Build 0 warnings; full suite 314/315 — single abort is the
pre-existing env-dependent RealMouseDrag test (Path of Exile 2 running)
This commit is contained in:
2026-09-22 17:57:06 -07:00
parent bb5dcb4ba2
commit e69db4d26c
18 changed files with 538 additions and 236 deletions
+74 -13
View File
@@ -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
/// <summary>Either output selected makes the button actionable.</summary>
public bool CanStartSession => RecordPillOn || OnAirPillOn;
/// <summary>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.</summary>
/// <summary>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.</summary>
public string PrimaryStartButtonLabel =>
IsConnected || !OnAirPillOn ? "Start" : "Sign In";
OnAirPillOn ? "Go Live" : "Start Recording";
/// <summary>The End button shows while recording OR live.</summary>
public bool ShowEndStreamButton => IsLive || IsRecording;
@@ -170,13 +172,71 @@ public partial class MainViewModel : ViewModelBase
: IsTestStream ? "End Test"
: "End Stream";
/// <summary>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.</summary>
public bool ShowTestButton => IsOffline && !IsRecording && IsConnected;
/// <summary>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.</summary>
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.
/// <summary>Idle — the mode switch is present and actionable.</summary>
public bool ShowIdleWorld => IsOffline && !IsRecording;
/// <summary>Running — the reality line (dot + word + elapsed) + End replace the switch.</summary>
public bool RunningVisible => IsLive || IsRecording;
/// <summary>Record world: the switch sits on REC and Go says "Start Recording".
/// No account zone, no Test — YouTube doesn't exist here.</summary>
public bool ShowRecordAction => ShowIdleWorld && !OnAirPillOn;
/// <summary>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.</summary>
public bool ShowStreamActions => ShowIdleWorld && OnAirPillOn;
/// <summary>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.</summary>
public bool ShowSignInButton => ShowStreamActions && !IsConnected;
/// <summary>The avatar confirms WHICH account is about to stream — only in the
/// Stream world, signed-in. Right-click for Change Account / Logout.</summary>
public bool ShowAvatarButton => ShowStreamActions && IsConnected;
/// <summary>The reality word: TEST while a test broadcast runs, LIVE while on the
/// air, REC during a local-record session.</summary>
public string RunningText => IsTestStream ? "TEST" : IsLive ? "LIVE" : "REC";
/// <summary>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.</summary>
public string RunningDotBrush => IsTestStream ? "#9c6f1c" : IsLive ? "#e94560" : "#22c55e";
/// <summary>Raises every flag the top bar renders, so any state change re-renders
/// exactly the world that is now true.</summary>
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));
}
/// <summary>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: <see cref="BeginTestStream"/> flips it before <see cref="StreamStatus"/>
@@ -189,6 +249,7 @@ public partial class MainViewModel : ViewModelBase
{
if (!SetProperty(ref _isTestStream, value)) return;
OnPropertyChanged(nameof(EndButtonText));
RaiseTopBarModes();
UpdateLiveVisuals();
}
}