diff --git a/Controls/TopBar.xaml b/Controls/TopBar.xaml
index 3699f6f..3c21218 100644
--- a/Controls/TopBar.xaml
+++ b/Controls/TopBar.xaml
@@ -100,15 +100,10 @@
-
-
+
diff --git a/Models/SessionStartDecision.cs b/Models/SessionStartDecision.cs
new file mode 100644
index 0000000..9498b39
--- /dev/null
+++ b/Models/SessionStartDecision.cs
@@ -0,0 +1,38 @@
+namespace ytLive.Models;
+
+/// What the top bar's primary action button will do.
+public enum SessionStartKind
+{
+ Record,
+ Live,
+ Test,
+}
+
+/// Creator ruling (2026-10-30): the primary button is context-sensitive, so the separate
+/// TEST button is gone.
+///
+///
+/// EDIT off + ON-AIR armed → Go Live (the livestream message)
+/// EDIT on + ON-AIR armed → Test Stream (the test-livestream message) — a dry run you
+/// cannot leak, because every broadcast is private pre-v1 anyway
+/// REC armed → Start Recording, whatever EDIT says (there is no test recording)
+///
+///
+/// Pure so the whole matrix is testable without a window.
+public static class SessionStartDecision
+{
+ public static SessionStartKind For(bool recordArmed, bool onAirArmed, bool editing)
+ {
+ // Neither pill 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;
+ }
+
+ public static string LabelFor(SessionStartKind kind) => kind switch
+ {
+ SessionStartKind.Record => "Start Recording",
+ SessionStartKind.Test => "Test Stream",
+ _ => "Go Live",
+ };
+}
diff --git a/ViewModels/MainViewModel.Scenes.cs b/ViewModels/MainViewModel.Scenes.cs
index d95d8c2..b597701 100644
--- a/ViewModels/MainViewModel.Scenes.cs
+++ b/ViewModels/MainViewModel.Scenes.cs
@@ -85,8 +85,12 @@ public partial class MainViewModel
get => _editingActive;
set
{
- if (SetProperty(ref _editingActive, value))
- OnPropertyChanged(nameof(IsEditMode));
+ if (!SetProperty(ref _editingActive, value)) return;
+ OnPropertyChanged(nameof(IsEditMode));
+ // EDIT decides whether the ON-AIR start is live or a test (creator ruling 2026-10-30).
+ OnPropertyChanged(nameof(PrimaryStartKind));
+ OnPropertyChanged(nameof(PrimaryStartButtonLabel));
+ OnPropertyChanged(nameof(PrimaryStartCommand));
}
}
diff --git a/ViewModels/MainViewModel.Streaming.cs b/ViewModels/MainViewModel.Streaming.cs
index 4693e4a..4f4b5ae 100644
--- a/ViewModels/MainViewModel.Streaming.cs
+++ b/ViewModels/MainViewModel.Streaming.cs
@@ -72,6 +72,9 @@ public partial class MainViewModel : ViewModelBase
OnPropertyChanged(nameof(OnAirBrush));
OnPropertyChanged(nameof(IsLivePrivate));
OnPropertyChanged(nameof(ShowPrimaryStartButton));
+ OnPropertyChanged(nameof(PrimaryStartKind));
+ OnPropertyChanged(nameof(PrimaryStartButtonLabel));
+ OnPropertyChanged(nameof(PrimaryStartCommand));
OnPropertyChanged(nameof(ShowEndStreamButton));
OnPropertyChanged(nameof(EndButtonText));
OnPropertyChanged(nameof(ShowTestButton));
@@ -109,6 +112,9 @@ public partial class MainViewModel : ViewModelBase
if (!SetProperty(ref _recordPillOn, value)) return;
if (value && _onAirPillOn) OnAirPillOn = false; // record-OR-live — arming REC drops ON-AIR
OnPropertyChanged(nameof(ShowPrimaryStartButton));
+ OnPropertyChanged(nameof(PrimaryStartKind));
+ OnPropertyChanged(nameof(PrimaryStartButtonLabel));
+ OnPropertyChanged(nameof(PrimaryStartCommand));
OnPropertyChanged(nameof(CanStartSession));
RaiseTopBarModes();
}
@@ -125,6 +131,9 @@ public partial class MainViewModel : ViewModelBase
if (!SetProperty(ref _onAirPillOn, value)) return;
if (value && _recordPillOn) RecordPillOn = false; // record-OR-live — arming ON-AIR drops REC
OnPropertyChanged(nameof(ShowPrimaryStartButton));
+ OnPropertyChanged(nameof(PrimaryStartKind));
+ OnPropertyChanged(nameof(PrimaryStartButtonLabel));
+ OnPropertyChanged(nameof(PrimaryStartCommand));
OnPropertyChanged(nameof(CanStartSession));
RaiseTopBarModes();
}
@@ -146,6 +155,9 @@ public partial class MainViewModel : ViewModelBase
OnPropertyChanged(nameof(IsEditMode));
OnPropertyChanged(nameof(ShowEndStreamButton));
OnPropertyChanged(nameof(ShowPrimaryStartButton));
+ OnPropertyChanged(nameof(PrimaryStartKind));
+ OnPropertyChanged(nameof(PrimaryStartButtonLabel));
+ OnPropertyChanged(nameof(PrimaryStartCommand));
OnPropertyChanged(nameof(EndButtonText));
OnPropertyChanged(nameof(ShowTestButton));
RaiseTopBarModes();
@@ -169,8 +181,18 @@ public partial class MainViewModel : ViewModelBase
/// 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 =>
- OnAirPillOn ? "Go Live" : "Start Recording";
+ /// 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);
+
+ public string PrimaryStartButtonLabel => SessionStartDecision.LabelFor(PrimaryStartKind);
+
+ /// The button's action follows its label: Test runs the same go-live pipeline with
+ /// `IsTestStream` set, Go Live runs it for real.
+ public ICommand PrimaryStartCommand => PrimaryStartKind == SessionStartKind.Test
+ ? StartTestCommand
+ : StartStreamCommand;
/// The End button shows while recording OR live.
public bool ShowEndStreamButton => IsLive || IsRecording;
diff --git a/ytLive.Tests/SessionStartDecisionTests.cs b/ytLive.Tests/SessionStartDecisionTests.cs
new file mode 100644
index 0000000..2860f46
--- /dev/null
+++ b/ytLive.Tests/SessionStartDecisionTests.cs
@@ -0,0 +1,32 @@
+using Xunit;
+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.
+///
+/// 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.
+public class SessionStartDecisionTests
+{
+ [Fact]
+ public void Edit_decides_whether_the_on_air_start_is_live_or_a_test()
+ {
+ // ON-AIR armed: EDIT decides.
+ Assert.Equal(SessionStartKind.Live, SessionStartDecision.For(recordArmed: false, onAirArmed: true, editing: false));
+ 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));
+ Assert.Equal("Start Recording", SessionStartDecision.LabelFor(SessionStartKind.Record));
+
+ // Unarmed start records locally (the no-dead-ends rule).
+ Assert.Equal(SessionStartKind.Record, SessionStartDecision.For(false, false, false));
+ }
+}