feat(topbar): three fixed slots — every state changes inside its own slot

Creator correction (2026-11-13): "OpenCode literally inserted Live and Private as two extra flat siblings
inside the outer pill bar, shoving Record to the right and making it look like five ungrouped toolbar
items". Correct — completion states were being added to the BAR instead of to a SLOT.

Now the bar is exactly three slots, and the bar's shape never changes:

  [ Build Scenes ]   [ YouTube ]   [ Record ]

  YouTube:  ▶ YouTube -> [Live | Private | ✕] -> [● Start Live | ✕] (amber: next click goes on air)
            -> [■ Stop Live]
  Record:   ◉ Record  -> [● Start Recording | ✕] -> ● REC hh:mm:ss [■ Stop]
  Build:    enabled only while BOTH action slots are Idle; otherwise disabled at 40% with the tooltip
            "Scene editing is locked while streaming or recording"

State lives in YouTubeSlotState / RecordSlotState, with one RaiseSlotStates() announcing every flag so no
caller can forget one. Esc and the ✕ cancel the slot you are in and nothing else.

This retires the BarWorld model from earlier today, along with TopBarActivityTests (superseded by
TopBarSlotTests, which pins all three flows, the lock, and cancel-to-Idle).

679 pass, 0 failed (the host-hang fallback in f4caea0 counted the per-test lines).
Scope justification: TopBarActivityTests.cs is deleted as part of this unit — it tested the model being
replaced; TopBarSlotTests is its successor.
This commit is contained in:
2026-11-13 12:20:11 -08:00
parent ba60f9ab3d
commit 69f08a478e
10 changed files with 373 additions and 273 deletions
+139 -92
View File
@@ -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. -->
<!-- THREE ACTIVITIES, ALWAYS VISIBLE (creator, 2026-11-13): build, stream, record. The
active one is lit; the others stay put and dim, so the creator can see where they are
and step back without hunting. Each active button runs its own flow in place:
Build nothing to start
YouTube [Live][Private] -> Start -> Stop (arming and starting are separate presses:
Start is the last chance to check you are ready)
Record Start Recording -> Stop Recording
-->
<!-- THREE FIXED SLOTS (creator, 2026-11-13): [Build Scenes] [YouTube] [Record]. The bar's
shape NEVER changes — every transition happens inside one slot, so the neighbouring
buttons cannot move. Each slot is its own little flow. -->
<Border Background="{StaticResource Brush.SegmentTrack}" CornerRadius="12" Padding="4"
VerticalAlignment="Center" Margin="0,0,12,0"
KeyboardNavigation.TabNavigation="Cycle" KeyboardNavigation.DirectionalNavigation="Cycle">
<StackPanel Orientation="Horizontal">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<!-- 1. BUILD SCENES -->
<Button x:Name="BuildSegmentButton" Style="{StaticResource SegmentHalf}"
Tag="{Binding IsBuildActivity}" Command="{Binding SelectBuildActivityCommand}"
ToolTip="Edit layers, scenes and webcam placement">
<!-- ══ SLOT 1: BUILD SCENES ══ -->
<Button x:Name="BuildSegmentButton" Grid.Column="0" Style="{StaticResource SegmentHalf}"
Tag="True" Command="{Binding SelectBuildCommand}"
IsEnabled="{Binding IsBuildScenesEnabled}"
ToolTip="{Binding BuildScenesTooltip}" FocusVisualStyle="{x:Null}">
<StackPanel Orientation="Horizontal">
<Path Style="{StaticResource SegmentIcon}" Tag="{Binding IsBuildActivity}" Data="M3,4h8v6H3V4zM13,4h8v6h-8V4zM3,12h8v8H3V12zM13,12h8v8h-8V12z"/>
<TextBlock Text="Build Scenes" FontSize="12" Margin="6,0,0,0" VerticalAlignment="Center"
<Path Style="{StaticResource SegmentIcon}" Tag="True" Data="M3,4h8v6H3V4zM13,4h8v6h-8V4zM3,12h8v8H3V12zM13,12h8v8h-8V12z"/>
<TextBlock Text="Build Scenes" FontSize="12" FontWeight="Bold" Margin="6,0,0,0"
VerticalAlignment="Center"
Foreground="{Binding Foreground, RelativeSource={RelativeSource AncestorType=Button}}"/>
</StackPanel>
</Button>
<!-- 2. YOUTUBE -->
<Button x:Name="YouTubeActivityButton" Style="{StaticResource SegmentHalf}"
Tag="{Binding IsYouTubeActivity}" Command="{Binding SelectYouTubeActivityCommand}"
ToolTip="Stream to YouTube">
<StackPanel Orientation="Horizontal">
<Path Style="{StaticResource SegmentIcon}" Tag="{Binding IsYouTubeActivity}" Data="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z"/>
<TextBlock Text="YouTube" FontSize="12" Margin="6,0,0,0" VerticalAlignment="Center"
Foreground="{Binding Foreground, RelativeSource={RelativeSource AncestorType=Button}}"/>
</StackPanel>
</Button>
<!-- YouTube's own flow, in place. -->
<StackPanel Orientation="Horizontal" VerticalAlignment="Center"
Visibility="{Binding ShowYouTubeModeChoices, Converter={StaticResource BoolToVis}}">
<Button Style="{StaticResource OutputChip}" Tag="{Binding IsLiveModeChosen}"
Command="{Binding ChooseLiveModeCommand}" ToolTip="Public livestream">
<!-- ══ SLOT 2: YOUTUBE ══ -->
<Grid Grid.Column="1" Margin="4,0">
<!-- State 0: idle -->
<Button Style="{StaticResource SegmentHalf}" Tag="False"
Command="{Binding OpenYouTubeCommand}" FocusVisualStyle="{x:Null}"
Visibility="{Binding IsYouTubeIdle, Converter={StaticResource BoolToVis}}"
ToolTip="Stream to YouTube">
<StackPanel Orientation="Horizontal">
<Path Style="{StaticResource OutputChipIcon}" Tag="{Binding IsLiveModeChosen}" Data="M8,5v14l11,-7z"/>
<TextBlock Text="Live" FontSize="12" Margin="5,0,0,0" VerticalAlignment="Center"
<Path Style="{StaticResource SegmentIcon}" Tag="False" Data="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z"/>
<TextBlock Text="YouTube" FontSize="12" Margin="6,0,0,0" VerticalAlignment="Center"
Foreground="{Binding Foreground, RelativeSource={RelativeSource AncestorType=Button}}"/>
</StackPanel>
</Button>
<Button Style="{StaticResource OutputChip}" Tag="{Binding IsPrivateModeChosen}"
Command="{Binding ChoosePrivateModeCommand}" ToolTip="Private stream — nothing public is published">
<!-- State 1: choose privacy — grouped inside a bordered sub-pill -->
<Border Background="{StaticResource Brush.SegmentActive}" CornerRadius="8" Padding="3"
Visibility="{Binding IsYouTubeChoosingPrivacy, Converter={StaticResource BoolToVis}}">
<StackPanel Orientation="Horizontal">
<Path Style="{StaticResource OutputChipIcon}" Tag="{Binding IsPrivateModeChosen}" Data="M7,10V7a5,5 0 0,1 10,0v3h1a1,1 0 0,1 1,1v9a1,1 0 0,1 -1,1H6a1,1 0 0,1 -1,-1v-9a1,1 0 0,1 1,-1h1zM9,10h6V7a3,3 0 0,0 -6,0v3z"/>
<TextBlock Text="Private" FontSize="12" Margin="5,0,0,0" VerticalAlignment="Center"
<Button Style="{StaticResource OutputChip}" Command="{Binding ChooseLiveCommand}"
FocusVisualStyle="{x:Null}" ToolTip="Public livestream">
<StackPanel Orientation="Horizontal">
<Path Style="{StaticResource OutputChipIcon}" Data="M8,5v14l11,-7z"/>
<TextBlock Text="Live" FontSize="12" Margin="5,0,0,0" VerticalAlignment="Center"
Foreground="{StaticResource Brush.SegmentTextActive}"/>
</StackPanel>
</Button>
<Button Style="{StaticResource OutputChip}" Command="{Binding ChoosePrivateCommand}"
FocusVisualStyle="{x:Null}" ToolTip="Private stream — nothing public is published">
<StackPanel Orientation="Horizontal">
<Path Style="{StaticResource OutputChipIcon}" Data="M7,10V7a5,5 0 0,1 10,0v3h1a1,1 0 0,1 1,1v9a1,1 0 0,1 -1,1H6a1,1 0 0,1 -1,-1v-9a1,1 0 0,1 1,-1h1zM9,10h6V7a3,3 0 0,0 -6,0v3z"/>
<TextBlock Text="Private" FontSize="12" Margin="5,0,0,0" VerticalAlignment="Center"
Foreground="{StaticResource Brush.SegmentTextActive}"/>
</StackPanel>
</Button>
<Button Style="{StaticResource OutputChip}" Command="{Binding CancelYouTubeCommand}"
FocusVisualStyle="{x:Null}" ToolTip="Cancel">
<Path Style="{StaticResource OutputChipIcon}" Data="M18.3,5.71L12,12l6.3,6.29 -1.41,1.42L10.59,13.41 4.29,19.71 2.88,18.29 9.17,12 2.88,5.71 4.29,4.29 10.59,10.59 16.89,4.29z" Width="11" Height="11"/>
</Button>
</StackPanel>
</Border>
<!-- State 2: armed — amber border, because the next click goes on air -->
<Border Background="{StaticResource Brush.SegmentActive}" CornerRadius="8" Padding="3"
BorderBrush="{StaticResource Brush.Attention}" BorderThickness="1"
Visibility="{Binding IsYouTubeArmed, Converter={StaticResource BoolToVis}}">
<StackPanel Orientation="Horizontal">
<Button Style="{StaticResource OutputChip}" Command="{Binding StartYouTubeCommand}"
FocusVisualStyle="{x:Null}" ToolTip="Start broadcasting now">
<StackPanel Orientation="Horizontal">
<Ellipse Width="9" Height="9" VerticalAlignment="Center"
Fill="{StaticResource Brush.Attention}"/>
<TextBlock Text="{Binding YouTubeArmLabel}" FontSize="12" FontWeight="Bold"
Margin="5,0,0,0" VerticalAlignment="Center"
Foreground="{StaticResource Brush.SegmentTextActive}"/>
</StackPanel>
</Button>
<Button Style="{StaticResource OutputChip}" Command="{Binding CancelYouTubeCommand}"
FocusVisualStyle="{x:Null}" ToolTip="Cancel">
<Path Style="{StaticResource OutputChipIcon}" Data="M18.3,5.71L12,12l6.3,6.29 -1.41,1.42L10.59,13.41 4.29,19.71 2.88,18.29 9.17,12 2.88,5.71 4.29,4.29 10.59,10.59 16.89,4.29z" Width="11" Height="11"/>
</Button>
</StackPanel>
</Border>
<!-- State 3: streaming -->
<Button Style="{StaticResource SegmentHalf}" Tag="True"
Command="{Binding StopYouTubeCommand}" FocusVisualStyle="{x:Null}"
Visibility="{Binding IsYouTubeStreaming, Converter={StaticResource BoolToVis}}"
ToolTip="End the broadcast">
<StackPanel Orientation="Horizontal">
<Path Style="{StaticResource SegmentIcon}" Tag="True" Data="M6,6h12v12H6z"/>
<TextBlock Text="{Binding YouTubeStopLabel}" FontSize="12" FontWeight="Bold"
Margin="6,0,0,0" VerticalAlignment="Center"
Foreground="{Binding Foreground, RelativeSource={RelativeSource AncestorType=Button}}"/>
</StackPanel>
</Button>
</StackPanel>
<Button Style="{StaticResource OutputChip}" Command="{Binding StartChosenStreamCommand}"
Visibility="{Binding ShowYouTubeStart, Converter={StaticResource BoolToVis}}"
ToolTip="Start the stream you armed">
<StackPanel Orientation="Horizontal">
<Path Style="{StaticResource OutputChipIcon}" Data="M8,5v14l11,-7z"/>
<TextBlock Text="Start" FontSize="12" FontWeight="Bold" Margin="5,0,0,0" VerticalAlignment="Center"
Foreground="{StaticResource Brush.SegmentTextActive}"/>
</StackPanel>
</Button>
<Button Style="{StaticResource OutputChip}" Command="{Binding EndStreamCommand}"
Visibility="{Binding ShowYouTubeStop, Converter={StaticResource BoolToVis}}"
ToolTip="End the livestream">
<StackPanel Orientation="Horizontal">
<Path Style="{StaticResource OutputChipIcon}" Data="M6,6h12v12H6z"/>
<TextBlock Text="Stop" FontSize="12" FontWeight="Bold" Margin="5,0,0,0" VerticalAlignment="Center"
Foreground="{StaticResource Brush.SegmentTextActive}"/>
</StackPanel>
</Button>
</Grid>
<!-- 3. RECORD -->
<Button x:Name="RecordActivityButton" Style="{StaticResource SegmentHalf}"
Tag="{Binding IsRecordActivity}" Command="{Binding SelectRecordActivityCommand}"
ToolTip="Record to a local MP4">
<StackPanel Orientation="Horizontal">
<Path Style="{StaticResource SegmentIcon}" Tag="{Binding IsRecordActivity}" Data="M12,2a10,10 0 1,0 0,20a10,10 0 1,0 0,-20zM12,4a8,8 0 1,1 0,16a8,8 0 1,1 0,-16zM12,7.5a4.5,4.5 0 1,1 0,9a4.5,4.5 0 1,1 0,-9z"/>
<TextBlock Text="Record" FontSize="12" Margin="6,0,0,0" VerticalAlignment="Center"
Foreground="{Binding Foreground, RelativeSource={RelativeSource AncestorType=Button}}"/>
</StackPanel>
</Button>
<!-- Record's own flow: the clock while it runs, and one action that swaps. -->
<StackPanel Orientation="Horizontal" VerticalAlignment="Center"
Visibility="{Binding IsRecordActivity, Converter={StaticResource BoolToVis}}">
<StackPanel Orientation="Horizontal" Margin="2,0,6,0" VerticalAlignment="Center"
Visibility="{Binding IsRecording, Converter={StaticResource BoolToVis}}">
<Ellipse Width="10" Height="10" VerticalAlignment="Center"
Fill="{StaticResource Brush.RecordLive}"
Opacity="{Binding RecDotOpacity, Mode=OneWay}"/>
<TextBlock Text="{Binding RecordElapsedLabel}" FontSize="12" FontWeight="Bold"
FontFamily="Consolas" Margin="5,0,0,0" VerticalAlignment="Center"
Foreground="{StaticResource Brush.SegmentTextActive}"/>
</StackPanel>
<Button Style="{StaticResource OutputChip}" Command="{Binding StartRecordingCommand}"
Visibility="{Binding ShowRecordStart, Converter={StaticResource BoolToVis}}">
<!-- ══ SLOT 3: RECORD ══ -->
<Grid Grid.Column="2" Margin="4,0,0,0">
<!-- State 0: idle -->
<Button Style="{StaticResource SegmentHalf}" Tag="False"
Command="{Binding OpenRecordCommand}" FocusVisualStyle="{x:Null}"
Visibility="{Binding IsRecordIdle, Converter={StaticResource BoolToVis}}"
ToolTip="Record to a local MP4">
<StackPanel Orientation="Horizontal">
<Path Style="{StaticResource OutputChipIcon}" Data="M12,2a10,10 0 1,0 0,20a10,10 0 1,0 0,-20zM12,4a8,8 0 1,1 0,16a8,8 0 1,1 0,-16zM12,7.5a4.5,4.5 0 1,1 0,9a4.5,4.5 0 1,1 0,-9z"/>
<TextBlock Text="Start Recording" FontSize="12" FontWeight="Bold" Margin="5,0,0,0"
VerticalAlignment="Center" Foreground="{StaticResource Brush.SegmentTextActive}"/>
<Path Style="{StaticResource SegmentIcon}" Tag="False" Data="M12,2a10,10 0 1,0 0,20a10,10 0 1,0 0,-20zM12,4a8,8 0 1,1 0,16a8,8 0 1,1 0,-16zM12,7.5a4.5,4.5 0 1,1 0,9a4.5,4.5 0 1,1 0,-9z"/>
<TextBlock Text="Record" FontSize="12" Margin="6,0,0,0" VerticalAlignment="Center"
Foreground="{Binding Foreground, RelativeSource={RelativeSource AncestorType=Button}}"/>
</StackPanel>
</Button>
<Button Style="{StaticResource OutputChip}" Command="{Binding EndStreamCommand}"
Visibility="{Binding ShowRecordStop, Converter={StaticResource BoolToVis}}">
<!-- State 1: armed -->
<Border Background="{StaticResource Brush.SegmentActive}" CornerRadius="8" Padding="3"
BorderBrush="{StaticResource Brush.Attention}" BorderThickness="1"
Visibility="{Binding IsRecordArmed, Converter={StaticResource BoolToVis}}">
<StackPanel Orientation="Horizontal">
<Path Style="{StaticResource OutputChipIcon}" Data="M6,6h12v12H6z"/>
<TextBlock Text="Stop Recording" FontSize="12" FontWeight="Bold" Margin="5,0,0,0"
VerticalAlignment="Center" Foreground="{StaticResource Brush.SegmentTextActive}"/>
<Button Style="{StaticResource OutputChip}" Command="{Binding StartRecordingCommand}"
FocusVisualStyle="{x:Null}" ToolTip="Start recording now">
<StackPanel Orientation="Horizontal">
<Ellipse Width="9" Height="9" VerticalAlignment="Center"
Fill="{StaticResource Brush.Attention}"/>
<TextBlock Text="Start Recording" FontSize="12" FontWeight="Bold" Margin="5,0,0,0"
VerticalAlignment="Center"
Foreground="{StaticResource Brush.SegmentTextActive}"/>
</StackPanel>
</Button>
<Button Style="{StaticResource OutputChip}" Command="{Binding CancelRecordCommand}"
FocusVisualStyle="{x:Null}" ToolTip="Cancel">
<Path Style="{StaticResource OutputChipIcon}" Data="M18.3,5.71L12,12l6.3,6.29 -1.41,1.42L10.59,13.41 4.29,19.71 2.88,18.29 9.17,12 2.88,5.71 4.29,4.29 10.59,10.59 16.89,4.29z" Width="11" Height="11"/>
</Button>
</StackPanel>
</Button>
</StackPanel>
</StackPanel>
</Border>
<!-- State 2: recording — green pulse, clock, stop -->
<Border Background="{StaticResource Brush.SegmentActive}" CornerRadius="8" Padding="3"
Visibility="{Binding IsRecordRecording, Converter={StaticResource BoolToVis}}">
<StackPanel Orientation="Horizontal">
<Ellipse Width="9" Height="9" VerticalAlignment="Center"
Fill="{StaticResource Brush.RecordLive}"
Opacity="{Binding RecDotOpacity, Mode=OneWay}"/>
<TextBlock Text="{Binding RecordElapsedLabel}" FontSize="11" FontWeight="Bold"
FontFamily="Consolas" Margin="5,0,8,0" VerticalAlignment="Center"
Foreground="{StaticResource Brush.SegmentTextActive}"/>
<Button Style="{StaticResource OutputChip}" Command="{Binding StopRecordingCommand}"
FocusVisualStyle="{x:Null}" ToolTip="Stop recording and save">
<StackPanel Orientation="Horizontal">
<Path Style="{StaticResource OutputChipIcon}" Data="M6,6h12v12H6z"/>
<TextBlock Text="Stop" FontSize="12" FontWeight="Bold" Margin="5,0,0,0"
VerticalAlignment="Center"
Foreground="{StaticResource Brush.SegmentTextActive}"/>
</StackPanel>
</Button>
</StackPanel>
</Border>
</Grid>
</Grid>
</Border>
<!-- RUNNING: one reality line (dot + word + elapsed) and the stop -->