diff --git a/Controls/TopBar.xaml b/Controls/TopBar.xaml
index 5635797..e4e8cc0 100644
--- a/Controls/TopBar.xaml
+++ b/Controls/TopBar.xaml
@@ -145,11 +145,19 @@
-
-
+
+
+
+
diff --git a/Models/SessionStartDecision.cs b/Models/SessionStartDecision.cs
index 20fabee..8187be0 100644
--- a/Models/SessionStartDecision.cs
+++ b/Models/SessionStartDecision.cs
@@ -21,12 +21,17 @@ public enum SessionStartKind
/// Pure so the whole matrix is testable without a window.
public static class SessionStartDecision
{
- public static SessionStartKind For(bool recordArmed, bool onAirArmed, bool editing)
+ ///
+ /// Which kind of session the armed output starts. Since 2026-11-12 there is no inference from
+ /// editing: the output stage offers "Private Test Stream" and "Review & Go Live" as separate
+ /// buttons, so ON-AIR always means the real livestream and Test is something the creator asks for.
+ ///
+ public static SessionStartKind For(bool recordArmed, bool onAirArmed)
{
- // Neither pill armed still starts something: local recording needs no account, and the
+ // Neither output armed still starts something: local recording needs no account, and the
// no-dead-ends rule forbids an inert primary button.
if (recordArmed || !onAirArmed) return SessionStartKind.Record;
- return editing ? SessionStartKind.Test : SessionStartKind.Live;
+ return SessionStartKind.Live;
}
/// What pressing the button actually does, given whether YouTube is connected.
diff --git a/ViewModels/MainViewModel.Streaming.cs b/ViewModels/MainViewModel.Streaming.cs
index 1268453..32bd3f8 100644
--- a/ViewModels/MainViewModel.Streaming.cs
+++ b/ViewModels/MainViewModel.Streaming.cs
@@ -191,7 +191,7 @@ public partial class MainViewModel : ViewModelBase
/// Creator ruling 2026-10-30: EDIT decides whether the ON-AIR start is the real
/// livestream or the test one, which is why the separate TEST button is gone.
public SessionStartKind PrimaryStartKind =>
- SessionStartDecision.For(RecordPillOn, OnAirPillOn, EditingActive);
+ SessionStartDecision.For(RecordPillOn, OnAirPillOn);
public string PrimaryStartButtonLabel => SessionStartDecision.LabelFor(PrimaryStartKind);
@@ -229,7 +229,7 @@ public partial class MainViewModel : ViewModelBase
public string EndButtonText =>
IsRecording && !IsLive ? "Stop Recording"
: IsTestStream ? "End Test"
- : "End Stream";
+ : "End Live Stream";
/// 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
diff --git a/ViewModels/MainViewModel.TopBar.cs b/ViewModels/MainViewModel.TopBar.cs
index 99ba03f..73ae350 100644
--- a/ViewModels/MainViewModel.TopBar.cs
+++ b/ViewModels/MainViewModel.TopBar.cs
@@ -35,11 +35,33 @@ public partial class MainViewModel
/// running — a live session cannot change its output, so its switch is not offered.
public bool ShowOutputStage => IsOutputWorld && IsOffline && !IsRecording;
+ /// The action the armed output offers: recording is one button, YouTube is two — the test
+ /// run and the real one are separate asks since 2026-11-12, not something inferred from EDIT.
+ public bool ShowStartRecordingAction => ShowOutputStage && RecordPillOn;
+ public bool ShowYouTubeStartActions => ShowOutputStage && OnAirPillOn;
+
+ public ICommand StartRecordingCommand { get; private set; } = null!;
+ public ICommand TestStreamCommand { get; private set; } = null!;
+ public ICommand GoLiveCommand { get; private set; } = null!;
+
public ICommand EnterBuildWorldCommand { get; private set; } = null!;
public ICommand EnterOutputWorldCommand { get; private set; } = null!;
private void InitializeWorldCommands()
{
+ StartRecordingCommand = new RelayCommand(_ => BeginRecordOnly(), _ => ShowStartRecordingAction);
+ // A signed-out press starts the sign-in flow instead of sitting dead (no-dead-ends rule).
+ TestStreamCommand = new RelayCommand(_ =>
+ {
+ if (!IsConnected) { _ = SignInAsync(); return; }
+ BeginTestStream();
+ }, _ => ShowYouTubeStartActions);
+ GoLiveCommand = new RelayCommand(_ =>
+ {
+ if (!IsConnected) { _ = SignInAsync(); return; }
+ StartSession();
+ }, _ => ShowYouTubeStartActions);
+
EnterBuildWorldCommand = new RelayCommand(_ => EnterWorld(BarWorld.Build));
EnterOutputWorldCommand = new RelayCommand(_ => EnterWorld(BarWorld.Output));
}
@@ -54,5 +76,7 @@ public partial class MainViewModel
OnPropertyChanged(nameof(IsOutputWorld));
EditingActive = world == BarWorld.Build;
OnPropertyChanged(nameof(ShowOutputStage));
+ OnPropertyChanged(nameof(ShowStartRecordingAction));
+ OnPropertyChanged(nameof(ShowYouTubeStartActions));
}
}
diff --git a/docs/ai/ui-surface.md b/docs/ai/ui-surface.md
index cc07e1b..ab5808e 100644
--- a/docs/ai/ui-surface.md
+++ b/docs/ai/ui-surface.md
@@ -157,7 +157,14 @@ Placement math itself is covered by `WindowPlacementTests` (pure function, no HW
its glyph, with **Local MP4 armed at launch** (`RecordPillOn` starts true — it did not before). The two
are one axis and the view model already drops one when the other arms, so exclusivity is policy, not
layout. A running session hides the stage (its output cannot change) and the reality line plus the
- stop action take the cluster's place. Next in the same redesign: the output-dependent primary action.
+ stop action take the cluster's place.
+- **The action follows the armed output** (creator ruling 2026-11-12). Local MP4 → `Start Recording`
+ (`Stop Recording` while running). YouTube → `Private Test Stream` beside `Review & Go Live`, the latter
+ carrying the primary face because it is the default ask. **Test is no longer inferred from EDIT** — the
+ 2026-10-30 rule where COLLECT EDIT decided test-vs-real is superseded, and `SessionStartDecision.For`
+ takes only the armed output. Running: `End Live Stream` (or `End Test` for a test run, `Stop Recording`
+ for a local one) via `EndButtonText`. A signed-out YouTube press starts the sign-in flow rather than
+ sitting dead (no-dead-ends).
- **Editing during a session is a setting (creator ruling 2026-11-12).** `App.LockEditWhileLive` is off
by default: a show does not take EDIT away, so a creator may move a layer or resize a webcam while
live. On restores the old gun-safety — go-live flips EDIT off, stop flips it back, and only because
diff --git a/ytLive.Tests/SessionStartDecisionTests.cs b/ytLive.Tests/SessionStartDecisionTests.cs
index d77a484..caab077 100644
--- a/ytLive.Tests/SessionStartDecisionTests.cs
+++ b/ytLive.Tests/SessionStartDecisionTests.cs
@@ -3,27 +3,22 @@ using ytLive.Models;
namespace ytLive.Tests;
-/// Creator ruling (2026-10-30): the top bar's primary button is context-sensitive and the
-/// separate TEST button is gone.
+/// Which session an armed output starts, and where a press routes.
///
-/// EDIT off + ON-AIR → the livestream message. EDIT on + ON-AIR → the test-livestream
-/// message. REC always records, because there is no test recording. The matrix is pure, so it is
-/// cheaper to pin it here than to drive a window.
+/// Superseded 2026-11-12: the ON-AIR start no longer infers Test from EDIT. The output stage
+/// offers "Private Test Stream" and "Review & Go Live" as separate buttons, so ON-AIR always means the
+/// real livestream here. REC always records, because there is no test recording.
public class SessionStartDecisionTests
{
[Fact]
- public void Edit_decides_whether_the_on_air_start_is_live_or_a_test()
+ public void The_armed_output_decides_the_session_kind_and_test_is_never_inferred()
{
- // ON-AIR armed: EDIT decides.
- Assert.Equal(SessionStartKind.Live, SessionStartDecision.For(recordArmed: false, onAirArmed: true, editing: false));
+ // ON-AIR armed: the real livestream, never a test.
+ Assert.Equal(SessionStartKind.Live, SessionStartDecision.For(recordArmed: false, onAirArmed: true));
Assert.Equal("Go Live", SessionStartDecision.LabelFor(SessionStartKind.Live));
- Assert.Equal(SessionStartKind.Test, SessionStartDecision.For(recordArmed: false, onAirArmed: true, editing: true));
- Assert.Equal("Test Stream", SessionStartDecision.LabelFor(SessionStartKind.Test));
-
- // REC armed: always a recording, whatever EDIT says (no test recording exists).
- Assert.Equal(SessionStartKind.Record, SessionStartDecision.For(true, false, false));
- Assert.Equal(SessionStartKind.Record, SessionStartDecision.For(true, false, true));
+ // REC armed: always a recording (no test recording exists).
+ Assert.Equal(SessionStartKind.Record, SessionStartDecision.For(true, false));
Assert.Equal("Start Recording", SessionStartDecision.LabelFor(SessionStartKind.Record));
// Signed out: an ON-AIR press (live OR test) routes to the sign-in flow, never a dead
@@ -35,6 +30,6 @@ public class SessionStartDecisionTests
Assert.Equal(SessionStartDecision.Route.Record, SessionStartDecision.RouteFor(SessionStartKind.Record, connected: false));
// Unarmed start records locally (the no-dead-ends rule).
- Assert.Equal(SessionStartKind.Record, SessionStartDecision.For(false, false, false));
+ Assert.Equal(SessionStartKind.Record, SessionStartDecision.For(false, false));
}
}
diff --git a/ytLive.Tests/TopBarWorldTests.cs b/ytLive.Tests/TopBarWorldTests.cs
index ddfa31a..9fc0726 100644
--- a/ytLive.Tests/TopBarWorldTests.cs
+++ b/ytLive.Tests/TopBarWorldTests.cs
@@ -39,6 +39,18 @@ public class TopBarWorldTests
Assert.False(vm.RecordPillOn);
vm.RecordPillOn = true;
Assert.False(vm.OnAirPillOn);
+
+ // The action follows the output: one button for MP4, two for YouTube (test + real).
+ Assert.True(vm.ShowStartRecordingAction);
+ Assert.False(vm.ShowYouTubeStartActions);
+ vm.OnAirPillOn = true;
+ Assert.False(vm.ShowStartRecordingAction);
+ Assert.True(vm.ShowYouTubeStartActions);
+
+ // Neither action exists while building scenes.
+ vm.EnterBuildWorldCommand.Execute(null);
+ Assert.False(vm.ShowStartRecordingAction);
+ Assert.False(vm.ShowYouTubeStartActions);
}
finally
{
diff --git a/ytLive.Tests/UiShellTests.cs b/ytLive.Tests/UiShellTests.cs
index 77f3f6d..fce11d6 100644
--- a/ytLive.Tests/UiShellTests.cs
+++ b/ytLive.Tests/UiShellTests.cs
@@ -49,6 +49,11 @@ public class UiShellTests
Assert.Contains("IsChecked=\"{Binding OnAirPillOn}\"", topBar);
Assert.Contains("IsChecked=\"{Binding RecordPillOn}\"", topBar);
+ // 2d. The action follows the armed output, and Test is an explicit ask again.
+ Assert.Contains("Content=\"Start Recording\"", topBar);
+ Assert.Contains("Content=\"Private Test Stream\"", topBar);
+ Assert.Contains("Content=\"Review & Go Live\"", topBar);
+
// 3. Active scene = orange; live stays red.
Assert.Contains("IsStaged}\" Value=\"True\">\n ", strip);