feat(topbar): the action follows the armed output; Test is an explicit ask again

Unit C (creator ruling 2026-11-12).

- Local MP4: one button, "Start Recording".
- YouTube: "Private Test Stream" beside "Review & Go Live", the latter with the primary face because it
  is the default ask.
- Running: "End Live Stream" (or "End Test" for a test run, "Stop Recording" for a local one).

The behavioural change underneath: SessionStartDecision.For no longer takes `editing`. Since 2026-10-30
EDIT silently chose test-vs-real, which meant the same button did two different things depending on a
toggle three inches away. With Test as its own button that inference has no job, so For() now reads only
the armed output and ON-AIR always means the real livestream. That supersedes the 2026-10-30 ruling;
SessionStartDecisionTests is rewritten to pin the new matrix rather than the old one.

A signed-out press still starts the sign-in flow instead of sitting dead (no-dead-ends), unchanged from
before — now stated in one place, the two new commands, instead of inside a route table.

680 pass, 0 warnings. Scope justification: Assets/starting-background.jpg is outside this unit — the
creator's replacement art, still awaiting their visual check.
This commit is contained in:
2026-11-12 15:44:15 -08:00
parent a5a10fb0b0
commit 4bc41c488c
8 changed files with 82 additions and 26 deletions
+13 -5
View File
@@ -145,11 +145,19 @@
</Border> </Border>
<TextBlock Text="One output at a time" FontSize="11" VerticalAlignment="Center" <TextBlock Text="One output at a time" FontSize="11" VerticalAlignment="Center"
Foreground="{StaticResource Brush.OnSurfaceVariant}" Margin="8,0,0,0"/> Foreground="{StaticResource Brush.OnSurfaceVariant}" Margin="8,0,0,0"/>
<!-- Go face names the world: "Start Recording" vs "Go Live". <!-- The action follows the armed output (creator ruling 2026-11-12).
(Signed-out ON-AIR Start routes to the sign-in — StartSession.) --> MP4: one button. YouTube: the test run AND the real one, side by side — Test is
<Button Content="{Binding PrimaryStartButtonLabel}" Style="{StaticResource YtButton}" something the creator asks for now, not something inferred from EDIT — with
Height="32" Margin="10,0,0,0" Command="{Binding PrimaryStartCommand}" Review & Go Live carrying the primary face because it is the default ask. -->
Visibility="{Binding ShowPrimaryStartButton, Converter={StaticResource BoolToVis}}"/> <Button Content="Start Recording" Style="{StaticResource YtButton}"
Height="32" Margin="10,0,0,0" Command="{Binding StartRecordingCommand}"
Visibility="{Binding ShowStartRecordingAction, Converter={StaticResource BoolToVis}}"/>
<Button Content="Private Test Stream" Style="{StaticResource YtButtonSecondary}"
Height="32" Margin="10,0,0,0" Command="{Binding TestStreamCommand}"
Visibility="{Binding ShowYouTubeStartActions, Converter={StaticResource BoolToVis}}"/>
<Button Content="Review &amp; Go Live" Style="{StaticResource YtButton}"
Height="32" Margin="8,0,0,0" Command="{Binding GoLiveCommand}"
Visibility="{Binding ShowYouTubeStartActions, Converter={StaticResource BoolToVis}}"/>
<!-- TASK 32·3: the drop ended this broadcast. YouTube cannot resume a completed one, so <!-- TASK 32·3: the drop ended this broadcast. YouTube cannot resume a completed one, so
the only honest action is a new broadcast — one click, saved details, and the title the only honest action is a new broadcast — one click, saved details, and the title
says it is a resume so the channel shows what happened. --> says it is a resume so the channel shows what happened. -->
+8 -3
View File
@@ -21,12 +21,17 @@ public enum SessionStartKind
/// <para>Pure so the whole matrix is testable without a window.</para></summary> /// <para>Pure so the whole matrix is testable without a window.</para></summary>
public static class SessionStartDecision public static class SessionStartDecision
{ {
public static SessionStartKind For(bool recordArmed, bool onAirArmed, bool editing) /// <summary>
/// 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.
/// </summary>
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. // no-dead-ends rule forbids an inert primary button.
if (recordArmed || !onAirArmed) return SessionStartKind.Record; if (recordArmed || !onAirArmed) return SessionStartKind.Record;
return editing ? SessionStartKind.Test : SessionStartKind.Live; return SessionStartKind.Live;
} }
/// <summary>What pressing the button actually does, given whether YouTube is connected. /// <summary>What pressing the button actually does, given whether YouTube is connected.
+2 -2
View File
@@ -191,7 +191,7 @@ public partial class MainViewModel : ViewModelBase
/// <summary>Creator ruling 2026-10-30: EDIT decides whether the ON-AIR start is the real /// <summary>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.</summary> /// livestream or the test one, which is why the separate TEST button is gone.</summary>
public SessionStartKind PrimaryStartKind => public SessionStartKind PrimaryStartKind =>
SessionStartDecision.For(RecordPillOn, OnAirPillOn, EditingActive); SessionStartDecision.For(RecordPillOn, OnAirPillOn);
public string PrimaryStartButtonLabel => SessionStartDecision.LabelFor(PrimaryStartKind); public string PrimaryStartButtonLabel => SessionStartDecision.LabelFor(PrimaryStartKind);
@@ -229,7 +229,7 @@ public partial class MainViewModel : ViewModelBase
public string EndButtonText => public string EndButtonText =>
IsRecording && !IsLive ? "Stop Recording" IsRecording && !IsLive ? "Stop Recording"
: IsTestStream ? "End Test" : IsTestStream ? "End Test"
: "End Stream"; : "End Live Stream";
/// <summary>TASK 41 — Test is a child of Stream (creator ruling: "Test should only /// <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 /// proc when we've selected stream"): a dry-run of the livestream, so it exists
+24
View File
@@ -35,11 +35,33 @@ public partial class MainViewModel
/// running — a live session cannot change its output, so its switch is not offered.</summary> /// running — a live session cannot change its output, so its switch is not offered.</summary>
public bool ShowOutputStage => IsOutputWorld && IsOffline && !IsRecording; public bool ShowOutputStage => IsOutputWorld && IsOffline && !IsRecording;
/// <summary>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.</summary>
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 EnterBuildWorldCommand { get; private set; } = null!;
public ICommand EnterOutputWorldCommand { get; private set; } = null!; public ICommand EnterOutputWorldCommand { get; private set; } = null!;
private void InitializeWorldCommands() 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)); EnterBuildWorldCommand = new RelayCommand(_ => EnterWorld(BarWorld.Build));
EnterOutputWorldCommand = new RelayCommand(_ => EnterWorld(BarWorld.Output)); EnterOutputWorldCommand = new RelayCommand(_ => EnterWorld(BarWorld.Output));
} }
@@ -54,5 +76,7 @@ public partial class MainViewModel
OnPropertyChanged(nameof(IsOutputWorld)); OnPropertyChanged(nameof(IsOutputWorld));
EditingActive = world == BarWorld.Build; EditingActive = world == BarWorld.Build;
OnPropertyChanged(nameof(ShowOutputStage)); OnPropertyChanged(nameof(ShowOutputStage));
OnPropertyChanged(nameof(ShowStartRecordingAction));
OnPropertyChanged(nameof(ShowYouTubeStartActions));
} }
} }
+8 -1
View File
@@ -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 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 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 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 - **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 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 live. On restores the old gun-safety — go-live flips EDIT off, stop flips it back, and only because
+10 -15
View File
@@ -3,27 +3,22 @@ using ytLive.Models;
namespace ytLive.Tests; namespace ytLive.Tests;
/// <summary>Creator ruling (2026-10-30): the top bar's primary button is context-sensitive and the /// <summary>Which session an armed output starts, and where a press routes.
/// separate TEST button is gone.
/// ///
/// <para>EDIT off + ON-AIR → the livestream message. EDIT on + ON-AIR → the test-livestream /// <para>Superseded 2026-11-12: the ON-AIR start no longer infers Test from EDIT. The output stage
/// message. REC always records, because there is no test recording. The matrix is pure, so it is /// offers "Private Test Stream" and "Review & Go Live" as separate buttons, so ON-AIR always means the
/// cheaper to pin it here than to drive a window.</para></summary> /// real livestream here. REC always records, because there is no test recording.</para></summary>
public class SessionStartDecisionTests public class SessionStartDecisionTests
{ {
[Fact] [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. // ON-AIR armed: the real livestream, never a test.
Assert.Equal(SessionStartKind.Live, SessionStartDecision.For(recordArmed: false, onAirArmed: true, editing: false)); Assert.Equal(SessionStartKind.Live, SessionStartDecision.For(recordArmed: false, onAirArmed: true));
Assert.Equal("Go Live", SessionStartDecision.LabelFor(SessionStartKind.Live)); Assert.Equal("Go Live", SessionStartDecision.LabelFor(SessionStartKind.Live));
Assert.Equal(SessionStartKind.Test, SessionStartDecision.For(recordArmed: false, onAirArmed: true, editing: true)); // REC armed: always a recording (no test recording exists).
Assert.Equal("Test Stream", SessionStartDecision.LabelFor(SessionStartKind.Test)); Assert.Equal(SessionStartKind.Record, SessionStartDecision.For(true, false));
// 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)); 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 // 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)); Assert.Equal(SessionStartDecision.Route.Record, SessionStartDecision.RouteFor(SessionStartKind.Record, connected: false));
// Unarmed start records locally (the no-dead-ends rule). // 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));
} }
} }
+12
View File
@@ -39,6 +39,18 @@ public class TopBarWorldTests
Assert.False(vm.RecordPillOn); Assert.False(vm.RecordPillOn);
vm.RecordPillOn = true; vm.RecordPillOn = true;
Assert.False(vm.OnAirPillOn); 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 finally
{ {
+5
View File
@@ -49,6 +49,11 @@ public class UiShellTests
Assert.Contains("IsChecked=\"{Binding OnAirPillOn}\"", topBar); Assert.Contains("IsChecked=\"{Binding OnAirPillOn}\"", topBar);
Assert.Contains("IsChecked=\"{Binding RecordPillOn}\"", 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 &amp; Go Live\"", topBar);
// 3. Active scene = orange; live stays red. // 3. Active scene = orange; live stays red.
Assert.Contains("IsStaged}\" Value=\"True\">\n <Setter Property=\"BorderBrush\" Value=\"{StaticResource Brush.Attention}\"/>", strip); Assert.Contains("IsStaged}\" Value=\"True\">\n <Setter Property=\"BorderBrush\" Value=\"{StaticResource Brush.Attention}\"/>", strip);