feat(topbar): one decision — [ Build Scenes ][ Record / Go Live ]

Unit A of the top-bar redesign (creator ruling 2026-11-12). The standalone EDIT toggle is gone;
Build Scenes IS edit mode, so "which world am I in?" and "can I edit?" cannot disagree any more.

BarWorld (ViewModels/MainViewModel.TopBar.cs) has two values, Build and Output. Build ships
highlighted at launch and sets EditingActive on; choosing Record / Go Live turns it off. Clicking the
already-highlighted half is deliberately not a no-op: it re-enters that world, which is the only way
back to the layers during a session now that the EDIT button is gone (unit 1 made that legal).

WorldSegmentButton (Themes/Controls.xaml) is a Button, not a ToggleButton, and each half passes its
own flag through Tag to one shared DataTrigger — a ToggleButton would fight the view model and, with a
one-way IsChecked, clear its own binding on first click.

UiShellTests updated where it guarded the old EDIT markup (it now guards the segment). TopBarWorldTests
pins launch state, both transitions, and the re-enter case.

679 pass, 0 warnings. Next: unit B (output switch), unit C (output-dependent action + End labels).
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:05:29 -08:00
parent d46e84562f
commit a5dc2f494c
7 changed files with 174 additions and 30 deletions
+18 -20
View File
@@ -53,26 +53,6 @@
</TextBlock> </TextBlock>
</Button> </Button>
<!-- EDIT: explicit toggle. Neutral = off (chat), blue = on (layers/properties). The
trigger used to set the SAME colour as the resting state, so the button never
looked pressed. Right edge aligns with the layers panel below. -->
<ToggleButton Grid.Column="1" IsChecked="{Binding EditingActive, Mode=TwoWay}" Content="EDIT"
Cursor="Hand" Height="32" Padding="14,0" Margin="18,0,0,2"
HorizontalAlignment="Right" VerticalAlignment="Center"
FontWeight="Bold" FontSize="12" Foreground="{StaticResource Brush.OnAccent}" BorderThickness="0">
<ToggleButton.Template>
<ControlTemplate TargetType="ToggleButton">
<Border x:Name="EditBody" Background="{StaticResource Brush.SurfaceContainerHigh}" CornerRadius="4" Padding="{TemplateBinding Padding}">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsChecked" Value="True">
<Setter TargetName="EditBody" Property="Background" Value="{StaticResource Brush.Tertiary}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</ToggleButton.Template>
</ToggleButton>
</Grid> </Grid>
<!-- Session surface (top bar redesign): the bar is ONE surface rendered by <!-- Session surface (top bar redesign): the bar is ONE surface rendered by
@@ -81,6 +61,24 @@
reality line + End so nothing mid-session can be re-armed. --> reality line + End so nothing mid-session can be re-armed. -->
<StackPanel Grid.Column="1" Orientation="Horizontal" <StackPanel Grid.Column="1" Orientation="Horizontal"
HorizontalAlignment="Center" VerticalAlignment="Center"> HorizontalAlignment="Center" VerticalAlignment="Center">
<!-- THE decision (creator ruling 2026-11-12): [ Build Scenes ][ Record / Go Live ].
Build Scenes is the new edit mode — there is no separate EDIT button, so the
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. -->
<Border Background="{StaticResource Brush.SurfaceContainerLowest}" CornerRadius="4"
Padding="2" Height="32" VerticalAlignment="Center" Margin="0,0,12,0">
<StackPanel Orientation="Horizontal">
<Button Style="{StaticResource WorldSegmentButton}" Tag="{Binding IsBuildWorld}"
Command="{Binding EnterBuildWorldCommand}" Padding="12,0"
Content="Build Scenes" FontSize="12" FontWeight="Bold"
ToolTip="Edit layers, scenes and webcam placement"/>
<Button Style="{StaticResource WorldSegmentButton}" Tag="{Binding IsOutputWorld}"
Command="{Binding EnterOutputWorldCommand}" Padding="12,0"
Content="Record / Go Live" FontSize="12" FontWeight="Bold"
ToolTip="Choose the output and start the session"/>
</StackPanel>
</Border>
<!-- IDLE: the mode switch + the action(s) of the armed world. The three <!-- IDLE: the mode switch + the action(s) of the armed world. The three
controls share one height (build 77 feedback) so they read as one controls share one height (build 77 feedback) so they read as one
family — segments host, Start/Go Live, and Test all sit at 32. --> family — segments host, Start/Go Live, and Test all sit at 32. -->
+27
View File
@@ -1002,6 +1002,33 @@
<!-- REC / ON-AIR segment pills: an armed session control reads red, because red is what <!-- REC / ON-AIR segment pills: an armed session control reads red, because red is what
"live or recording" means (DESIGN.md). Kept separate from SegmentToggle so the Rect|Circle "live or recording" means (DESIGN.md). Kept separate from SegmentToggle so the Rect|Circle
choice in the properties panel stays the interactive blue. --> choice in the properties panel stays the interactive blue. -->
<!-- The top bar's world segment (creator ruling 2026-11-12): [ Build Scenes ][ Record / Go Live ].
A Button, not a ToggleButton — the highlight follows the view model, so a click must never be
what sets it. Each button passes its own world flag in Tag and one DataTrigger paints it. -->
<Style x:Key="WorldSegmentButton" TargetType="Button">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="Foreground" Value="{StaticResource Brush.OnAccent}"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Height" Value="28"/>
<Setter Property="VerticalAlignment" Value="Center"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="Face" Background="{TemplateBinding Background}"
CornerRadius="3" Padding="{TemplateBinding Padding}">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
<Style.Triggers>
<DataTrigger Binding="{Binding Tag, RelativeSource={RelativeSource Self}}" Value="True">
<Setter Property="Background" Value="{StaticResource Brush.Primary}"/>
</DataTrigger>
</Style.Triggers>
</Style>
<Style x:Key="RecordPillToggle" TargetType="ToggleButton" BasedOn="{StaticResource SegmentToggle}"> <Style x:Key="RecordPillToggle" TargetType="ToggleButton" BasedOn="{StaticResource SegmentToggle}">
<!-- SegmentToggle's template hard-codes a Transparent face and paints the checked state <!-- SegmentToggle's template hard-codes a Transparent face and paints the checked state
itself, so an armed pill needs its own template that honours Background. --> itself, so an armed pill needs its own template that honours Background. -->
+53
View File
@@ -0,0 +1,53 @@
using System.Windows.Input;
using ytLive.Helpers;
namespace ytLive.ViewModels;
/// <summary>Which half of the top bar's dual segment is in force.</summary>
public enum BarWorld
{
/// <summary>Build Scenes — the edit surface. The default at launch (creator ruling 2026-11-12).</summary>
Build,
/// <summary>Record / Go Live — the output surface: which output, and the one action that starts it.</summary>
Output,
}
/// <summary>
/// The top bar's one decision (creator ruling 2026-11-12): <c>[ Build Scenes ][ Record / Go Live ]</c>.
/// Build Scenes <b>is</b> edit mode — there is no separate EDIT toggle any more, so "may I edit?" and
/// "which world am I in?" cannot disagree.
///
/// <para>Clicking the highlighted segment is not a no-op: it re-enters that world, which matters while a
/// session runs because Build Scenes is the only way back to the layers (see
/// <see cref="LockEditWhileLive"/>).</para>
/// </summary>
public partial class MainViewModel
{
private BarWorld _barWorld = BarWorld.Build;
public BarWorld BarWorld => _barWorld;
public bool IsBuildWorld => _barWorld == BarWorld.Build;
public bool IsOutputWorld => _barWorld == BarWorld.Output;
public ICommand EnterBuildWorldCommand { get; private set; } = null!;
public ICommand EnterOutputWorldCommand { get; private set; } = null!;
private void InitializeWorldCommands()
{
EnterBuildWorldCommand = new RelayCommand(_ => EnterWorld(BarWorld.Build));
EnterOutputWorldCommand = new RelayCommand(_ => EnterWorld(BarWorld.Output));
}
/// <summary>Switches worlds. Re-entering the current one still re-asserts its edit state, which is
/// what lets a creator take EDIT back mid-show by clicking Build Scenes.</summary>
internal void EnterWorld(BarWorld world)
{
_barWorld = world;
OnPropertyChanged(nameof(BarWorld));
OnPropertyChanged(nameof(IsBuildWorld));
OnPropertyChanged(nameof(IsOutputWorld));
EditingActive = world == BarWorld.Build;
}
}
+1
View File
@@ -342,6 +342,7 @@ public partial class MainViewModel : ViewModelBase
OpenHotkeyConfigCommand = new RelayCommand(_ => OpenHotkeyConfig()); OpenHotkeyConfigCommand = new RelayCommand(_ => OpenHotkeyConfig());
ChangeAccountCommand = new RelayCommand(_ => _ = ChangeAccountAsync()); ChangeAccountCommand = new RelayCommand(_ => _ = ChangeAccountAsync());
LogoutCommand = new RelayCommand(_ => _ = SignOutYouTubeAsync()); LogoutCommand = new RelayCommand(_ => _ = SignOutYouTubeAsync());
InitializeWorldCommands();
_layoutStore = new LayoutStore(LayoutPathOverride ?? DefaultLayoutPath); _layoutStore = new LayoutStore(LayoutPathOverride ?? DefaultLayoutPath);
_activeLayoutPath = _layoutStore.ActivePath; _activeLayoutPath = _layoutStore.ActivePath;
+8
View File
@@ -145,6 +145,14 @@ Placement math itself is covered by `WindowPlacementTests` (pure function, no HW
`FormattedText` — **measure, don't guess**) plus the field's padding and border. Guard: `FormattedText` — **measure, don't guess**) plus the field's padding and border. Guard:
`ytLive.Tests/GeometryFieldWidthTests.cs` `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.<tier>`): 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 - 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.<tier>`): 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 ]`.**
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,
which is how a creator gets back to the layers during a session. `WorldSegmentButton`
(`Themes/Controls.xaml`) is a Button, not a ToggleButton — the highlight follows the view model, and a
click must never be what sets it. Next in the same redesign: the output switch (`Output [YouTube]
[Local MP4] One output at a time`, MP4 default) and an output-dependent primary action.
- **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
+55
View File
@@ -0,0 +1,55 @@
using System;
using System.IO;
using Xunit;
using ytLive.ViewModels;
namespace ytLive.Tests;
/// <summary>
/// The top bar's one decision: <c>[ Build Scenes ][ Record / Go Live ]</c>. Build Scenes ships
/// highlighted, and it <b>is</b> edit mode — the old EDIT toggle is gone, so "can I edit?" and "which
/// world am I in?" cannot disagree.
/// </summary>
[Collection("MainViewModelLayoutPath")]
public class TopBarWorldTests
{
[Fact]
public void Build_scenes_is_the_default_world_and_is_the_only_place_editing_lives()
{
var db = Path.Combine(Path.GetTempPath(), $"llamacasty-world-{Guid.NewGuid():N}.db");
MainViewModel.LayoutPathOverride = db;
try
{
var vm = new MainViewModel();
// Launch state: Build Scenes highlighted, editing on.
Assert.Equal(BarWorld.Build, vm.BarWorld);
Assert.True(vm.IsBuildWorld);
Assert.False(vm.IsOutputWorld);
Assert.True(vm.IsEditMode);
// Record / Go Live leaves edit mode — the output surface has nothing to drag.
vm.EnterOutputWorldCommand.Execute(null);
Assert.Equal(BarWorld.Output, vm.BarWorld);
Assert.True(vm.IsOutputWorld);
Assert.False(vm.IsBuildWorld);
Assert.False(vm.IsEditMode);
// Clicking the highlighted half is not a no-op: it re-enters that world, which is how a
// creator takes editing back during a session.
vm.EnterBuildWorldCommand.Execute(null);
Assert.True(vm.IsBuildWorld);
Assert.True(vm.IsEditMode);
// …and re-entering the world already in force still re-asserts its edit state.
vm.EditingActive = false;
vm.EnterBuildWorldCommand.Execute(null);
Assert.True(vm.IsEditMode);
}
finally
{
MainViewModel.LayoutPathOverride = null;
try { File.Delete(db); } catch (IOException) { }
}
}
}
+12 -10
View File
@@ -27,12 +27,13 @@ public class UiShellTests
Assert.Contains("Brush.Youtube", tokens); Assert.Contains("Brush.Youtube", tokens);
Assert.Equal(2, Count(preview, "Background=\"{StaticResource Brush.Youtube}\"")); Assert.Equal(2, Count(preview, "Background=\"{StaticResource Brush.Youtube}\""));
// 2. EDIT: resting face differs from the armed face, and the label follows it. // 2. The world segment replaced the EDIT toggle (creator ruling 2026-11-12): both halves are
Assert.Contains("x:Name=\"EditBody\" Background=\"{StaticResource Brush.SurfaceContainerHigh}\"", topBar); // present, each lit by its own world flag through the shared style.
Assert.Contains("<Setter TargetName=\"EditBody\" Property=\"Background\" Value=\"{StaticResource Brush.Tertiary}\"/>", topBar); Assert.Contains("Content=\"Build Scenes\"", topBar);
// The label is white in BOTH states — only the fill changes. A muted resting colour is Assert.Contains("Content=\"Record / Go Live\"", topBar);
// what made it illegible on the dark bar (creator, 2026-10-30). Assert.Contains("Tag=\"{Binding IsBuildWorld}\"", topBar);
Assert.Contains("FontSize=\"12\" Foreground=\"{StaticResource Brush.OnAccent}\"", topBar); Assert.Contains("Tag=\"{Binding IsOutputWorld}\"", topBar);
Assert.DoesNotContain("x:Name=\"EditBody\"", 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);
@@ -91,11 +92,12 @@ public class UiShellTests
Assert.Contains("MouseLeftButtonUp=\"SettingsTab_Click\"", gear); Assert.Contains("MouseLeftButtonUp=\"SettingsTab_Click\"", gear);
Assert.DoesNotContain("ToolTip=\"Settings\"", topBar); Assert.DoesNotContain("ToolTip=\"Settings\"", topBar);
// EDIT stays in the left brand cluster, before the centre session cluster. // The world segment leads the centre cluster; the brand column carries no edit control.
var edit = topBar.IndexOf("EditingActive", System.StringComparison.Ordinal);
var centre = topBar.IndexOf("<StackPanel Grid.Column=\"1\"", System.StringComparison.Ordinal); var centre = topBar.IndexOf("<StackPanel Grid.Column=\"1\"", System.StringComparison.Ordinal);
Assert.True(edit > 0 && centre > edit, var segment = topBar.IndexOf("Content=\"Build Scenes\"", System.StringComparison.Ordinal);
"the EDIT toggle must appear before the centre (session) cluster"); Assert.True(centre > 0 && segment > centre,
"the [ Build Scenes ][ Record / Go Live ] segment must lead the centre cluster");
Assert.DoesNotContain("x:Name=\"EditBody\"", topBar);
} }
private static int Count(string haystack, string needle) private static int Count(string haystack, string needle)