diff --git a/Controls/TopBar.xaml b/Controls/TopBar.xaml
index 8a35538..bde997f 100644
--- a/Controls/TopBar.xaml
+++ b/Controls/TopBar.xaml
@@ -66,121 +66,168 @@
question "which world am I in?" and "can I edit?" cannot disagree. Clicking the
highlighted half re-enters it, which is how a creator gets back to the layers
during a session. -->
-
+
-
+
+
+
+
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
-
-
+
+
-
+
+
+
-
-
+
+
+
+
+
+
+
+
+
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/MainWindow.xaml.cs b/MainWindow.xaml.cs
index dfd9645..c2055de 100644
--- a/MainWindow.xaml.cs
+++ b/MainWindow.xaml.cs
@@ -204,10 +204,20 @@ public partial class MainWindow : Window
// Esc leaves a sub-menu (creator ruling 2026-11-12): the Output world is a sub-menu, and its
// back chevron is gone in favour of this. The bar says so under the switches.
- if (e.Key == System.Windows.Input.Key.Escape && _viewModel.Activity != ViewModels.TopActivity.Build)
+ // Esc backs out of whichever slot is mid-flow (creator, 2026-11-13): the slots cancel in place
+ // rather than moving the bar around.
+ if (e.Key == System.Windows.Input.Key.Escape)
{
- _viewModel.SelectBuildActivityCommand.Execute(null);
- e.Handled = true;
+ if (_viewModel.IsYouTubeChoosingPrivacy || _viewModel.IsYouTubeArmed)
+ {
+ _viewModel.CancelYouTubeCommand.Execute(null);
+ e.Handled = true;
+ }
+ else if (_viewModel.IsRecordArmed)
+ {
+ _viewModel.CancelRecordCommand.Execute(null);
+ e.Handled = true;
+ }
}
}
diff --git a/ViewModels/MainViewModel.Streaming.Operations.cs b/ViewModels/MainViewModel.Streaming.Operations.cs
index 66df8ef..6efe07b 100644
--- a/ViewModels/MainViewModel.Streaming.Operations.cs
+++ b/ViewModels/MainViewModel.Streaming.Operations.cs
@@ -656,7 +656,7 @@ public partial class MainViewModel : ViewModelBase
}
OnPropertyChanged(nameof(RecDotOpacity));
OnPropertyChanged(nameof(LiveElapsedText));
- OnPropertyChanged(nameof(RecordActionLabel));
+ OnPropertyChanged(nameof(RecordElapsedLabel));
}
private void OnLiveTimerTick(object? sender, EventArgs e)
@@ -666,7 +666,7 @@ public partial class MainViewModel : ViewModelBase
_recordLength = _liveElapsed;
_recDotPulse = _recDotPulse > 0.5 ? 0.2 : 1.0;
OnPropertyChanged(nameof(RecDotOpacity));
- OnPropertyChanged(nameof(RecordActionLabel));
+ OnPropertyChanged(nameof(RecordElapsedLabel));
// TASK 55·3: the last useful warning before the encoder fails for a reason it won't explain.
if (IsRecording && !_lowDiskWarned && _recordFolder is { } folder
diff --git a/ViewModels/MainViewModel.Streaming.cs b/ViewModels/MainViewModel.Streaming.cs
index 37c22cc..2b021d2 100644
--- a/ViewModels/MainViewModel.Streaming.cs
+++ b/ViewModels/MainViewModel.Streaming.cs
@@ -87,7 +87,7 @@ public partial class MainViewModel : ViewModelBase
OnPropertyChanged(nameof(ShowChatInactiveMessage));
OnPropertyChanged(nameof(ShowPreviewPlaceholder));
RaiseTopBarModes();
- RaiseActivityStage(); // IsLive drives ShowOutputStage
+ RaiseSlotStates(); // IsLive drives ShowOutputStage
UpdateLiveVisuals();
}
}
@@ -118,7 +118,7 @@ public partial class MainViewModel : ViewModelBase
{
if (!SetProperty(ref _recordPillOn, value)) return;
if (value && _onAirPillOn) OnAirPillOn = false; // record-OR-live — arming REC drops ON-AIR
- RaiseActivityStage();
+ RaiseSlotStates();
OnPropertyChanged(nameof(ShowPrimaryStartButton));
OnPropertyChanged(nameof(PrimaryStartKind));
OnPropertyChanged(nameof(PrimaryStartButtonLabel));
@@ -138,7 +138,7 @@ public partial class MainViewModel : ViewModelBase
// intent, not reality; the Start face turns this into a sign-in.
if (!SetProperty(ref _onAirPillOn, value)) return;
if (value && _recordPillOn) RecordPillOn = false; // record-OR-live — arming ON-AIR drops REC
- RaiseActivityStage();
+ RaiseSlotStates();
OnPropertyChanged(nameof(ShowPrimaryStartButton));
OnPropertyChanged(nameof(PrimaryStartKind));
OnPropertyChanged(nameof(PrimaryStartButtonLabel));
@@ -171,7 +171,7 @@ public partial class MainViewModel : ViewModelBase
OnPropertyChanged(nameof(EndButtonText));
OnPropertyChanged(nameof(ShowTestButton));
RaiseTopBarModes();
- RaiseActivityStage(); // Start Recording <-> Stop Recording, and the REC clock
+ RaiseSlotStates(); // Start Recording <-> Stop Recording, and the REC clock
UpdateSessionTimer();
}
}
diff --git a/ViewModels/MainViewModel.TopBar.cs b/ViewModels/MainViewModel.TopBar.cs
index e82d9b7..ab49232 100644
--- a/ViewModels/MainViewModel.TopBar.cs
+++ b/ViewModels/MainViewModel.TopBar.cs
@@ -3,115 +3,161 @@ using ytLive.Helpers;
namespace ytLive.ViewModels;
-/// What the app is doing (creator, 2026-11-13): build, record, or stream. Three activities,
-/// three buttons, always visible.
-/// Which YouTube stream is armed. Choosing does not start it — Start does.
-public enum StreamMode
+/// Where the YouTube slot is in its own five-step flow.
+public enum YouTubeSlotState
{
- None,
- Live,
- Private,
+ /// [ ▶ YouTube ]
+ Idle,
+
+ /// [ ▶ Live | 🔒 Private | ✕ ]
+ ChoosePrivacy,
+
+ /// [ ● Start Live | ✕ ] or [ ● Start Private | ✕ ]
+ Armed,
+
+ /// [ ■ Stop Live ] or [ ■ Stop Private ]
+ Streaming,
}
-public enum TopActivity
+/// Where the Record slot is in its own three-step flow.
+public enum RecordSlotState
{
- Build,
- YouTube,
- Record,
+ /// [ ◉ Record ]
+ Idle,
+
+ /// [ ● Start Recording | ✕ ]
+ Armed,
+
+ /// [ ■ Stop Recording ]
+ Recording,
}
+///
+/// The top bar is THREE FIXED SLOTS (creator, 2026-11-13): Build Scenes, YouTube, Record. A slot never
+/// changes the bar's shape — every state transition happens INSIDE its own slot, and the neighbouring
+/// slots never move. Earlier attempts added completion states as siblings in the bar, which read as five
+/// ungrouped toolbar items and pushed Record around.
+///
public partial class MainViewModel
{
- private TopActivity _activity = TopActivity.Build;
+ private YouTubeSlotState _youTubeStep = YouTubeSlotState.Idle;
+ private RecordSlotState _recordStep = RecordSlotState.Idle;
+ private bool _podcastLive; // true = Private chosen (the private-visibility test stream)
- public TopActivity Activity => _activity;
+ // ─── Build Scenes slot ───
+ // Enabled only while BOTH other slots are idle: editing a scene under a live stream or a running
+ // recording is what the lock is for, and the tooltip says so out loud.
+ public bool IsBuildScenesEnabled =>
+ _youTubeStep == YouTubeSlotState.Idle && _recordStep == RecordSlotState.Idle;
- public bool IsBuildActivity => _activity == TopActivity.Build;
- public bool IsYouTubeActivity => _activity == TopActivity.YouTube;
- public bool IsRecordActivity => _activity == TopActivity.Record;
+ public string BuildScenesTooltip => IsBuildScenesEnabled
+ ? "Edit layers, scenes and webcam placement"
+ : "Scene editing is locked while streaming or recording";
- // ─── Each activity's own flow ───
- // Build: nothing to start. YouTube: Live or Private, then Stop. Record: Start, then Stop.
+ // ─── YouTube slot ───
+ public YouTubeSlotState YouTubeStep => _youTubeStep;
+ public bool IsYouTubeIdle => _youTubeStep == YouTubeSlotState.Idle;
+ public bool IsYouTubeChoosingPrivacy => _youTubeStep == YouTubeSlotState.ChoosePrivacy;
+ public bool IsYouTubeArmed => _youTubeStep == YouTubeSlotState.Armed;
+ public bool IsYouTubeStreaming => _youTubeStep == YouTubeSlotState.Streaming;
- // YouTube is two steps ON PURPOSE (creator, 2026-11-13): choosing Live or Private only ARMS it,
- // because Start is the last moment to tell someone "check you are ready". Collapsing the two would
- // trade that prompt for one fewer click.
- private StreamMode _streamMode = StreamMode.None;
+ public bool IsLiveChosen => !_podcastLive;
+ public bool IsPrivateChosen => _podcastLive;
+ public string YouTubeArmLabel => _podcastLive ? "Start Private" : "Start Live";
+ public string YouTubeStopLabel => _podcastLive ? "Stop Private" : "Stop Live";
- public bool IsLiveModeChosen => _streamMode == StreamMode.Live;
- public bool IsPrivateModeChosen => _streamMode == StreamMode.Private;
-
- public bool ShowYouTubeModeChoices => IsYouTubeActivity && !IsLive && _streamMode == StreamMode.None;
- public bool ShowYouTubeStart => IsYouTubeActivity && !IsLive && _streamMode != StreamMode.None;
- public bool ShowYouTubeStop => IsYouTubeActivity && IsLive;
- public bool ShowRecordStart => IsRecordActivity && !IsRecording;
- public bool ShowRecordStop => IsRecordActivity && IsRecording;
-
- /// The Record activity's own words: its name while idle, its action while chosen.
- public string RecordActionLabel => IsRecording ? "Stop Recording" : "Start Recording";
+ // ─── Record slot ───
+ public RecordSlotState RecordStep => _recordStep;
+ public bool IsRecordIdle => _recordStep == RecordSlotState.Idle;
+ public bool IsRecordArmed => _recordStep == RecordSlotState.Armed;
+ public bool IsRecordRecording => _recordStep == RecordSlotState.Recording;
public string RecordElapsedLabel => $"REC: {LiveElapsedText}";
- public ICommand SelectBuildActivityCommand { get; private set; } = null!;
- public ICommand SelectYouTubeActivityCommand { get; private set; } = null!;
- public ICommand SelectRecordActivityCommand { get; private set; } = null!;
- public ICommand ChooseLiveModeCommand { get; private set; } = null!;
- public ICommand ChoosePrivateModeCommand { get; private set; } = null!;
- public ICommand StartChosenStreamCommand { get; private set; } = null!;
+ public ICommand SelectBuildCommand { get; private set; } = null!;
+ public ICommand OpenYouTubeCommand { get; private set; } = null!;
+ public ICommand ChooseLiveCommand { get; private set; } = null!;
+ public ICommand ChoosePrivateCommand { get; private set; } = null!;
+ public ICommand CancelYouTubeCommand { get; private set; } = null!;
+ public ICommand StartYouTubeCommand { get; private set; } = null!;
+ public ICommand StopYouTubeCommand { get; private set; } = null!;
+
+ public ICommand OpenRecordCommand { get; private set; } = null!;
+ public ICommand StartRecordingCommand { get; private set; } = null!;
+ public ICommand CancelRecordCommand { get; private set; } = null!;
+ public ICommand StopRecordingCommand { get; private set; } = null!;
private void InitializeActivityCommands()
{
- SelectBuildActivityCommand = new RelayCommand(_ => SelectActivity(TopActivity.Build));
- SelectYouTubeActivityCommand = new RelayCommand(_ => SelectActivity(TopActivity.YouTube));
- SelectRecordActivityCommand = new RelayCommand(_ => SelectActivity(TopActivity.Record));
+ SelectBuildCommand = new RelayCommand(_ => RaiseSlotStates(), _ => IsBuildScenesEnabled);
+ OpenYouTubeCommand = new RelayCommand(_ => SetYouTubeStep(YouTubeSlotState.ChoosePrivacy));
+ ChooseLiveCommand = new RelayCommand(_ => { _podcastLive = false; SetYouTubeStep(YouTubeSlotState.Armed); });
+ ChoosePrivateCommand = new RelayCommand(_ => { _podcastLive = true; SetYouTubeStep(YouTubeSlotState.Armed); });
+ CancelYouTubeCommand = new RelayCommand(_ => SetYouTubeStep(YouTubeSlotState.Idle));
- ChooseLiveModeCommand = new RelayCommand(_ => SetStreamMode(StreamMode.Live), _ => ShowYouTubeModeChoices);
- ChoosePrivateModeCommand = new RelayCommand(_ => SetStreamMode(StreamMode.Private), _ => ShowYouTubeModeChoices);
-
- // Start is its own press: the last chance to say "you're about to be live".
// Signed out still routes to sign-in rather than sitting dead (no-dead-ends rule).
- StartChosenStreamCommand = new RelayCommand(_ =>
+ StartYouTubeCommand = new RelayCommand(_ =>
{
if (!IsConnected) { _ = SignInAsync(); return; }
- if (_streamMode == StreamMode.Private) BeginTestStream();
- else StartSession();
- }, _ => ShowYouTubeStart);
+ if (_podcastLive) BeginTestStream(); else StartSession();
+ SetYouTubeStep(YouTubeSlotState.Streaming);
+ });
+
+ StopYouTubeCommand = new RelayCommand(_ =>
+ {
+ StopStream();
+ SetYouTubeStep(YouTubeSlotState.Idle);
+ });
+
+ OpenRecordCommand = new RelayCommand(_ => SetRecordStep(RecordSlotState.Armed));
+ StartRecordingCommand = new RelayCommand(_ =>
+ {
+ BeginRecordOnly();
+ SetRecordStep(RecordSlotState.Recording);
+ });
+ CancelRecordCommand = new RelayCommand(_ => SetRecordStep(RecordSlotState.Idle));
+ StopRecordingCommand = new RelayCommand(_ =>
+ {
+ StopStream();
+ SetRecordStep(RecordSlotState.Idle);
+ });
}
- /// Switches activity. Build is the only one that edits; the other two take the layers away
- /// but leave the button lit, so the creator can see where they are and step back.
- internal void SelectActivity(TopActivity activity)
+ private void SetYouTubeStep(YouTubeSlotState step)
{
- _activity = activity;
- OnPropertyChanged(nameof(Activity));
- OnPropertyChanged(nameof(IsBuildActivity));
- OnPropertyChanged(nameof(IsYouTubeActivity));
- OnPropertyChanged(nameof(IsRecordActivity));
-
- EditingActive = activity == TopActivity.Build;
-
- // The underlying outputs follow the activity, so the existing start commands and the
- // record/live paths agree with what the bar is showing.
- RecordPillOn = activity == TopActivity.Record;
- OnAirPillOn = activity == TopActivity.YouTube;
- _streamMode = StreamMode.None; // the YouTube flow always restarts at the choice
-
- RaiseActivityStage();
+ _youTubeStep = step;
+ RaiseSlotStates();
}
- private void SetStreamMode(StreamMode mode)
+ private void SetRecordStep(RecordSlotState step)
{
- _streamMode = mode;
- OnPropertyChanged(nameof(IsLiveModeChosen));
- OnPropertyChanged(nameof(IsPrivateModeChosen));
- RaiseActivityStage();
+ _recordStep = step;
+ RaiseSlotStates();
}
- internal void RaiseActivityStage()
+ /// One place that announces everything the three slots show, so no caller can forget a flag.
+ ///
+ internal void RaiseSlotStates()
{
- OnPropertyChanged(nameof(ShowYouTubeModeChoices));
- OnPropertyChanged(nameof(ShowYouTubeStart));
- OnPropertyChanged(nameof(ShowYouTubeStop));
- OnPropertyChanged(nameof(ShowRecordStart));
- OnPropertyChanged(nameof(ShowRecordStop));
+ OnPropertyChanged(nameof(IsBuildScenesEnabled));
+ OnPropertyChanged(nameof(BuildScenesTooltip));
+
+ OnPropertyChanged(nameof(YouTubeStep));
+ OnPropertyChanged(nameof(IsYouTubeIdle));
+ OnPropertyChanged(nameof(IsYouTubeChoosingPrivacy));
+ OnPropertyChanged(nameof(IsYouTubeArmed));
+ OnPropertyChanged(nameof(IsYouTubeStreaming));
+ OnPropertyChanged(nameof(IsLiveChosen));
+ OnPropertyChanged(nameof(IsPrivateChosen));
+ OnPropertyChanged(nameof(YouTubeArmLabel));
+ OnPropertyChanged(nameof(YouTubeStopLabel));
+
+ OnPropertyChanged(nameof(RecordStep));
+ OnPropertyChanged(nameof(IsRecordIdle));
+ OnPropertyChanged(nameof(IsRecordArmed));
+ OnPropertyChanged(nameof(IsRecordRecording));
+ OnPropertyChanged(nameof(RecordElapsedLabel));
+
+ // Build Scenes is the edit surface, so the lock has to reach editing too.
+ EditingActive = IsBuildScenesEnabled;
}
}
diff --git a/docs/ai/ui-surface.md b/docs/ai/ui-surface.md
index 4c1440d..037f29d 100644
--- a/docs/ai/ui-surface.md
+++ b/docs/ai/ui-surface.md
@@ -145,7 +145,18 @@ Placement math itself is covered by `WindowPlacementTests` (pure function, no HW
`FormattedText` — **measure, don't guess**) plus the field's padding and border. Guard:
`ytLive.Tests/GeometryFieldWidthTests.cs`
- Resolution tiers: 1080p60@12 (default) → 1080p30@10 → 720p60@6 → 720p30@6 → **Vertical 1080p30@10 and 1080p60@12 (9:16, 1080×1920)** — the `@` is the CBR target the encoder is asked to hold, between YouTube's H.264 recommendation and the practitioner sweet spot (its 14/17/8 recommendations are the max, the tier minimums 5/6/3 the step-down floor). Vertical mirrors its landscape pair: same pixel count, same rate. The old flat 8 Mbps was an inherited round number, not a measurement. The composition master frame is **always 1920×1080** — a tier is an output rect + target resolution over that master, so source geometry is never rewritten (no rounding drift). 16:9 tiers use the full frame; vertical tiers use a centered **607×1080** window and the preview dims the cropped side strips at 55% black with an accent outline (semi-crop — the cut area stays visible). A resolution badge in the preview corner shows the active tier. The resolution picker and live readouts (bitrate/FPS/dropped/duration) are in the top-left cell above the layers panel; the bottom bar is an empty height-preserving spacer. Under the picker sits the creator's own **rate box** (Mbps, per tier, persisted as `Video.Rate.`): the tier chooses geometry, the box chooses bits, clamped at the tier's YouTube minimum and otherwise unbounded because live step-down is the thing that catches a wrong guess. Its tooltip carries YouTube's recommendation for the tier (`Models/RateLadder`). Every consumer — encoder options, step-down policy, clip buffer — reads `MainViewModel.EffectiveQuality`, never the raw tier. A **tooltip** explains finding upload bandwidth — an in-app speed test was deliberately dropped (unreliable). The future encoder crops the master to the rect and scales to the tier's Width×Height
-- **The top bar has ONE decision (creator ruling 2026-11-12): `[ Build Scenes ][ Record / Go Live ]`.**
+- **The top bar is THREE FIXED SLOTS (creator ruling 2026-11-13): `[Build Scenes] [YouTube] [Record]`.**
+ The bar's shape never changes — every transition happens INSIDE one slot, so neighbouring buttons cannot
+ move. Attempts that added completion states as siblings (`Live`, `Private`, `Stop`) read as five
+ ungrouped toolbar items and pushed Record around; that is the mistake this replaces.
+ Slot flows (`MainViewModel.TopBar.cs`, `YouTubeSlotState` / `RecordSlotState`):
+ - **YouTube:** Idle → ChoosePrivacy (`Live | Private | ✕`) → Armed (`Start Live|Start Private | ✕`, amber
+ border because the next click goes on air) → Streaming (`Stop Live|Stop Private`).
+ - **Record:** Idle → Armed (`Start Recording | ✕`) → Recording (`● REC hh:mm:ss` + `Stop`).
+ - **Build Scenes** is enabled only while BOTH action slots are Idle; otherwise it is disabled at 40%
+ opacity with the tooltip "Scene editing is locked while streaming or recording".
+ Arming and starting stay separate presses: Start is the last "are you ready?" moment.
+- **Legacy note — superseded by the three-slot bar above:**
Build Scenes ships highlighted and **is** edit mode — the standalone EDIT toggle is gone, so "which
world am I in?" and "can I edit?" cannot disagree (`MainViewModel.TopBar.cs`, `BarWorld`). The segment
leads the centre cluster and stays present in every state; clicking the highlighted half re-enters it,
diff --git a/ytLive.Tests/TopBarActivityTests.cs b/ytLive.Tests/TopBarActivityTests.cs
deleted file mode 100644
index cad59d9..0000000
--- a/ytLive.Tests/TopBarActivityTests.cs
+++ /dev/null
@@ -1,80 +0,0 @@
-using System;
-using System.IO;
-using Xunit;
-using ytLive.ViewModels;
-
-namespace ytLive.Tests;
-
-///
-/// Three activities, always visible (creator, 2026-11-13): build, stream, record. The active one is lit
-/// and runs its own flow in place; the other two stay on the bar and dim, so there is no state where the
-/// creator cannot see where they are or how to step back.
-///
-[Collection("MainViewModelLayoutPath")]
-public class TopBarActivityTests
-{
- private static MainViewModel NewVm(string db)
- {
- MainViewModel.LayoutPathOverride = db;
- return new MainViewModel();
- }
-
- [Fact]
- public void Three_activities_stay_on_the_bar_and_the_active_one_owns_its_flow()
- {
- var db = Path.Combine(Path.GetTempPath(), $"llamacasty-activity-{Guid.NewGuid():N}.db");
- try
- {
- var vm = NewVm(db);
-
- // Launch: Build is the activity and the only one editing.
- Assert.Equal(TopActivity.Build, vm.Activity);
- Assert.True(vm.IsBuildActivity);
- Assert.True(vm.IsEditMode);
- Assert.False(vm.IsRecording);
-
- // Record: its own flow becomes Start, and editing steps aside.
- vm.SelectRecordActivityCommand.Execute(null);
- Assert.True(vm.IsRecordActivity);
- Assert.False(vm.IsEditMode);
- Assert.True(vm.ShowRecordStart);
- Assert.False(vm.ShowRecordStop);
- Assert.Equal("Start Recording", vm.RecordActionLabel);
-
- // Recording swaps the flow to Stop, and the elapsed label carries REC.
- vm.IsRecording = true;
- Assert.False(vm.ShowRecordStart);
- Assert.True(vm.ShowRecordStop);
- Assert.Equal("Stop Recording", vm.RecordActionLabel);
- Assert.StartsWith("REC: ", vm.RecordElapsedLabel);
- vm.IsRecording = false;
-
- // YouTube: choosing Live only ARMS it. Start is a second press — the "are you ready?" moment.
- vm.SelectYouTubeActivityCommand.Execute(null);
- Assert.True(vm.IsYouTubeActivity);
- Assert.True(vm.ShowYouTubeModeChoices);
- Assert.False(vm.ShowYouTubeStart);
-
- vm.ChooseLiveModeCommand.Execute(null);
- Assert.True(vm.IsLiveModeChosen);
- Assert.False(vm.ShowYouTubeModeChoices);
- Assert.True(vm.ShowYouTubeStart);
-
- vm.ChoosePrivateModeCommand.Execute(null);
- Assert.True(vm.IsPrivateModeChosen);
- Assert.False(vm.IsLiveModeChosen);
-
- // Back to Build: editing returns, and the YouTube choice is forgotten, not carried.
- vm.SelectBuildActivityCommand.Execute(null);
- Assert.True(vm.IsBuildActivity);
- Assert.True(vm.IsEditMode);
- Assert.False(vm.ShowYouTubeModeChoices);
- Assert.False(vm.ShowYouTubeStart);
- }
- finally
- {
- MainViewModel.LayoutPathOverride = null;
- try { File.Delete(db); } catch (IOException) { }
- }
- }
-}
diff --git a/ytLive.Tests/TopBarModeTests.cs b/ytLive.Tests/TopBarModeTests.cs
index d8d04d6..d16d91e 100644
--- a/ytLive.Tests/TopBarModeTests.cs
+++ b/ytLive.Tests/TopBarModeTests.cs
@@ -87,18 +87,16 @@ public sealed class TopBarModeTests
// A local recording lives in the Record activity (2026-11-13): the bar keeps its three
// buttons and the Record one carries REC + the clock + Stop, so the separate reality line
// stays away — it would be a second set of the same facts.
- vm.SelectRecordActivityCommand.Execute(null);
- vm.IsRecording = true;
- Assert.True(vm.IsRecordActivity);
- Assert.True(vm.ShowRecordStop);
+ vm.OpenRecordCommand.Execute(null);
+ vm.StartRecordingCommand.Execute(null);
+ Assert.True(vm.IsRecordRecording);
Assert.False(vm.RunningVisible);
Assert.StartsWith("REC: ", vm.RecordElapsedLabel);
- vm.IsRecording = false;
+ vm.StopRecordingCommand.Execute(null);
- // A live stream is the YouTube activity's flow; the reality line pins the mode.
- vm.SelectYouTubeActivityCommand.Execute(null);
+ // A live stream is the YouTube slot's flow; the reality line pins the mode.
vm.StreamStatus = StreamStatus.Streaming;
- Assert.True(vm.ShowYouTubeStop);
+ Assert.True(vm.RunningVisible);
Assert.Equal("LIVE", vm.RunningText);
Assert.Equal("#ff5451", vm.RunningDotBrush);
vm.IsTestStream = true;
diff --git a/ytLive.Tests/TopBarSlotTests.cs b/ytLive.Tests/TopBarSlotTests.cs
new file mode 100644
index 0000000..6e16061
--- /dev/null
+++ b/ytLive.Tests/TopBarSlotTests.cs
@@ -0,0 +1,68 @@
+using System;
+using System.IO;
+using Xunit;
+using ytLive.ViewModels;
+
+namespace ytLive.Tests;
+
+///
+/// The bar is three FIXED SLOTS (creator, 2026-11-13). Each slot owns its own flow and mutates only its
+/// own interior, so neighbouring buttons never move; and while either action slot is past Idle, Build
+/// Scenes is locked.
+///
+[Collection("MainViewModelLayoutPath")]
+public class TopBarSlotTests
+{
+ [Fact]
+ public void Each_slot_runs_its_own_flow_in_place_and_build_locks_while_they_do()
+ {
+ var db = Path.Combine(Path.GetTempPath(), $"llamacasty-slots-{Guid.NewGuid():N}.db");
+ MainViewModel.LayoutPathOverride = db;
+ try
+ {
+ var vm = new MainViewModel();
+
+ // All three idle: YouTube and Record are single buttons, Build is live.
+ Assert.True(vm.IsYouTubeIdle);
+ Assert.True(vm.IsRecordIdle);
+ Assert.True(vm.IsBuildScenesEnabled);
+ Assert.Contains("Edit layers", vm.BuildScenesTooltip);
+
+ // YouTube: idle -> choose privacy -> armed -> (start) streaming.
+ vm.OpenYouTubeCommand.Execute(null);
+ Assert.True(vm.IsYouTubeChoosingPrivacy);
+ Assert.False(vm.IsBuildScenesEnabled);
+ Assert.Contains("locked", vm.BuildScenesTooltip);
+
+ vm.ChoosePrivateCommand.Execute(null);
+ Assert.True(vm.IsYouTubeArmed);
+ Assert.Equal("Start Private", vm.YouTubeArmLabel);
+
+ vm.ChooseLiveCommand.Execute(null); // switching choice while armed is allowed
+ Assert.Equal("Start Live", vm.YouTubeArmLabel);
+
+ // Cancel returns that slot to Idle — and Build unlocks because both slots are idle again.
+ vm.CancelYouTubeCommand.Execute(null);
+ Assert.True(vm.IsYouTubeIdle);
+ Assert.True(vm.IsBuildScenesEnabled);
+
+ // Record: idle -> armed -> (start) recording, with the clock carrying REC.
+ vm.OpenRecordCommand.Execute(null);
+ Assert.True(vm.IsRecordArmed);
+ Assert.False(vm.IsBuildScenesEnabled);
+
+ vm.StartRecordingCommand.Execute(null);
+ Assert.True(vm.IsRecordRecording);
+ Assert.StartsWith("REC: ", vm.RecordElapsedLabel);
+
+ vm.StopRecordingCommand.Execute(null);
+ Assert.True(vm.IsRecordIdle);
+ Assert.True(vm.IsBuildScenesEnabled);
+ }
+ finally
+ {
+ MainViewModel.LayoutPathOverride = null;
+ try { File.Delete(db); } catch (IOException) { }
+ }
+ }
+}
diff --git a/ytLive.Tests/UiShellTests.cs b/ytLive.Tests/UiShellTests.cs
index ff9662f..9614eda 100644
--- a/ytLive.Tests/UiShellTests.cs
+++ b/ytLive.Tests/UiShellTests.cs
@@ -32,8 +32,8 @@ public class UiShellTests
Assert.Contains("Text=\"Build Scenes\"", topBar);
Assert.Contains("Text=\"YouTube\"", topBar);
Assert.Contains("Text=\"Record\"", topBar);
- Assert.Contains("Tag=\"{Binding IsBuildActivity}\"", topBar);
- Assert.Contains("Tag=\"{Binding IsYouTubeActivity}\"", topBar);
+ Assert.Contains("IsEnabled=\"{Binding IsBuildScenesEnabled}\"", topBar);
+ Assert.Contains("Command=\"{Binding OpenYouTubeCommand}\"", topBar);
Assert.DoesNotContain("x:Name=\"EditBody\"", topBar);
// 2b. The segment is Build-world only: choosing Record / Go Live REPLACES it with the output
@@ -51,8 +51,8 @@ public class UiShellTests
// 2c. The output stage: two labelled switches and the exclusivity promise beside them.
Assert.Contains("Text=\"YouTube\"", topBar);
- Assert.Contains("Command=\"{Binding SelectYouTubeActivityCommand}\"", topBar);
- Assert.Contains("Command=\"{Binding SelectRecordActivityCommand}\"", topBar);
+ Assert.Contains("Command=\"{Binding OpenYouTubeCommand}\"", topBar);
+ Assert.Contains("Command=\"{Binding OpenRecordCommand}\"", topBar);
// 2d. The bar carries NO action button: the output line is the bar's whole middle
// (creator ruling 2026-11-12 — the action is placed elsewhere, on instruction).