feat(ui): TASK 42 top bar redesign — one Record-or-Stream surface (2026-09-22)

The bar now renders ONE surface from the FIRST decision the creator makes —
Record or Stream — with each world being the whole bar. Creator directive:
"I wrote the fucking thing and I still can't figure-out how to do stuff",
"forget this one dog plan bullshit". OBS/streaming-tool reference for the
one-surface paradigm: https://obsproject.com (single mode toggle + context
actions) — cited per spin-guard habit; the go-live/test pattern follows
CEV (https://cev-desktop.aSean.xyz) precedent.

- Mode: single segmented REC|ON-AIR switch (SegmentToggle/SegmentLabel in
  Themes/Controls.xaml); radio-exclusive world selection, one tap to flip.
- Record world: switch + Start Recording, zero YouTube identity.
- Stream world: switch + Go Live + Test + account zone right (Sign In until
  connected, then avatar with Change Account/Logout context menu).
- Test is a child of Stream: procs only stream-armed AND signed-in.
- Running: one reality line "dot word elapsed" (REC/LIVE/TEST; green/red/
  gold) + End; worlds + switch retire.
- Gear moved up from bottom bar, ~3 wordmark letters past the brand, single
  click opens Settings/Bug/Feature/About menu (TASK 40 Unit B shipped early).
- Fix folded in: BeginTestStream now arms OnAirPillOn explicitly (unarmed
  pump booted with zero encoder outputs -> "At least one output is required"
  forced-stop cascade from TASK 41's pipeline).
- VM: world/reality props + RaiseTopBarModes() wired into StreamStatus,
  pill, IsRecording, IsTestStream, IsConnected setters.

Doc: ai.md top-bar model, ViewModels/index.md, Controls/index.md, TASKS.md,
HANDOFF.md, new TASKS/task-42-top-bar-redesign.md; task-40 note.

Tests: TopBarModeTests.cs (new Good Dog), PillRadioTests + TestStreamTests
gates updated. Build 0 warnings; full suite 314/315 — single abort is the
pre-existing env-dependent RealMouseDrag test (Path of Exile 2 running)
This commit is contained in:
2026-09-22 17:57:06 -07:00
parent bb5dcb4ba2
commit e69db4d26c
18 changed files with 538 additions and 236 deletions
+102 -106
View File
@@ -3,7 +3,6 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:helpers="clr-namespace:ytLive.Helpers"
x:Name="TopBarRoot">
<!-- LOGO / BRAND — clicking opens the in-app About overlay -->
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
@@ -11,123 +10,120 @@
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<!-- Logo / Brand — clicking opens the in-app About overlay -->
<Button Grid.Column="0" Background="Transparent" BorderThickness="0" Cursor="Hand"
VerticalAlignment="Center" Margin="0,0,24,0" ToolTip="About LlamaCasty"
Command="{Binding OpenAboutCommand}">
<Button.Template>
<ControlTemplate TargetType="Button">
<ContentPresenter/>
</ControlTemplate>
</Button.Template>
<TextBlock FontSize="22" FontWeight="Bold">
<Run Text="LlamaCas" Foreground="#e0e0e0"/><Run Text="ty" Foreground="#e94560"/><Run
x:Name="BuildIdStamp" Text="{x:Static helpers:BuildStamp.Label}" FontSize="9"
BaselineAlignment="Superscript" Foreground="#8888aa"
ToolTipService.ToolTip="{x:Static helpers:BuildStamp.Label}"/><Run Text=" " FontSize="4"/>
</TextBlock>
</Button>
<!-- Session controls (creator order 2026-09-01): [sign light] LABEL [pill] per
output — each reality lamp sits in front of its own intent switch. -->
<StackPanel Grid.Column="1" Orientation="Horizontal"
HorizontalAlignment="Center" VerticalAlignment="Center">
<!-- REC group: local recording needs no sign-in -->
<Ellipse Width="12" Height="12" VerticalAlignment="Center"
Fill="{Binding RecDotBrush}" Opacity="{Binding RecDotOpacity}"/>
<TextBlock Text="REC" Style="{StaticResource StatusSignText}"/>
<ToggleButton Style="{StaticResource PillToggle}" VerticalAlignment="Center"
Margin="6,0,16,0"
IsChecked="{Binding RecordPillOn}"/>
<!-- ON-AIR group: armable signed-out (creator ruling 2026-09-20: a
greyed pill was a dead end — the pill is intent and intent must
be able to light while offline). The Start face turns the armed
pill signed-out into a sign-in. -->
<Ellipse Width="12" Height="12" VerticalAlignment="Center"
Fill="{Binding OnAirBrush}"/>
<TextBlock Text="ON-AIR" Style="{StaticResource StatusSignText}"/>
<ToggleButton Style="{StaticResource PillToggle}" VerticalAlignment="Center"
Margin="6,0,0,0"
IsChecked="{Binding OnAirPillOn}"
ToolTip="{Binding AccountStatusLightToolTip}"/>
<Border Background="#8f1f1f" CornerRadius="3" Padding="6,1" Margin="8,0,0,0"
VerticalAlignment="Center"
Visibility="{Binding IsLivePrivate, Converter={StaticResource BoolToVis}}">
<TextBlock Text="PRIVATE" FontSize="11" FontWeight="Bold" Foreground="White"/>
</Border>
<TextBlock Text="{Binding LiveElapsedText}" Foreground="White" FontSize="14"
FontFamily="Consolas" VerticalAlignment="Center" Margin="14,0,0,0"
Visibility="{Binding IsLive, Converter={StaticResource BoolToVis}}"/>
<Border Background="#9c6f1c" CornerRadius="3" Padding="6,1" Margin="10,0,0,0"
VerticalAlignment="Center"
Visibility="{Binding IsTestStream, Converter={StaticResource BoolToVis}}">
<TextBlock Text="TEST" FontSize="11" FontWeight="Bold" Foreground="White"/>
</Border>
<!-- LOGO / BRAND — clicking opens the in-app About overlay; the gear sits
a couple characters past the build stamp and opens App Settings. -->
<StackPanel Grid.Column="0" Orientation="Horizontal" VerticalAlignment="Center"
Margin="0,0,24,0">
<Button Background="Transparent" BorderThickness="0" Cursor="Hand"
VerticalAlignment="Center" ToolTip="About LlamaCasty"
Command="{Binding OpenAboutCommand}">
<Button.Template>
<ControlTemplate TargetType="Button">
<ContentPresenter/>
</ControlTemplate>
</Button.Template>
<TextBlock FontSize="22" FontWeight="Bold" VerticalAlignment="Center">
<Run Text="LlamaCas" Foreground="#e0e0e0"/><Run Text="ty" Foreground="#e94560"/><Run
x:Name="BuildIdStamp" Text="{x:Static helpers:BuildStamp.Label}" FontSize="9"
BaselineAlignment="Superscript" Foreground="#8888aa"
ToolTipService.ToolTip="{x:Static helpers:BuildStamp.Label}"/><Run Text=" " FontSize="4"/>
</TextBlock>
</Button>
<!-- Settings gear (user directive): a couple characters beyond the brand.
Moved up from the bottom bar — one menu: App Settings / Bug / Feature / About. -->
<Button Style="{StaticResource IconButton}" Margin="6,0,0,2" VerticalAlignment="Center"
ToolTip="Menu" Click="GearButton_Click">
<Button.ContextMenu>
<ContextMenu DataContext="{Binding PlacementTarget.DataContext, RelativeSource={RelativeSource Self}}">
<MenuItem Header="App Settings" Command="{Binding OpenSettingsCommand}"/>
<Separator/>
<MenuItem Header="Report Bug" Command="{Binding OpenBugCommand}"/>
<MenuItem Header="Feature Request" Command="{Binding OpenFeatureCommand}"/>
<MenuItem Header="About" Command="{Binding OpenAboutCommand}"/>
</ContextMenu>
</Button.ContextMenu>
<Path Data="M19.43,12.98c0.04,-0.32 0.07,-0.65 0.07,-0.98c0,-0.33 -0.03,-0.66 -0.07,-0.98l2.11,-1.65c0.19,-0.15 0.24,-0.42 0.12,-0.64l-2,-3.46c-0.12,-0.22 -0.38,-0.31 -0.61,-0.22l-2.49,1c-0.52,-0.4 -1.08,-0.73 -1.69,-0.98l-0.38,-2.65C14.46,2.18 14.25,2 14,2h-4c-0.25,0 -0.46,0.18 -0.49,0.42l-0.38,2.65c-0.61,0.25 -1.17,0.59 -1.69,0.98l-2.49,-1c-0.23,-0.09 -0.49,0 -0.61,0.22l-2,3.46c-0.13,0.22 -0.07,0.49 0.12,0.64l2.11,1.65c-0.04,0.32 -0.07,0.65 -0.07,0.98c0,0.33 0.03,0.66 0.07,0.98l-2.11,1.65c-0.19,0.15 -0.24,0.42 -0.12,0.64l2,3.46c0.12,0.22 0.38,0.31 0.61,0.22l2.49,-1c0.52,0.4 1.08,0.73 1.69,0.98l0.38,2.65c0.03,0.24 0.24,0.42 0.49,0.42h4c0.25,0 0.46,-0.18 0.49,-0.42l0.38,-2.65c0.61,-0.25 1.17,-0.59 1.69,-0.98l2.49,1c0.23,0.09 0.49,0 0.61,-0.22l2,-3.46c0.12,-0.22 0.07,-0.49 -0.12,-0.64L19.43,12.98zM12,15.5c-1.93,0 -3.5,-1.57 -3.5,-3.5c0,-1.93 1.57,-3.5 3.5,-3.5c1.93,0 3.5,1.57 3.5,3.5C15.5,13.93 13.93,15.5 12,15.5z"
Fill="#a0a0b0" Width="18" Height="18" Stretch="Uniform"/>
</Button>
</StackPanel>
<!-- Three-state top bar: logged-out / idle / live -->
<!-- Session surface (top bar redesign): the bar is ONE surface rendered by
the FIRST decision — Record or Stream. Each world is the whole bar; the
other world never exists on screen. While running, the bar drops to a
reality line + End so nothing mid-session can be re-armed. -->
<StackPanel Grid.Column="1" Orientation="Horizontal"
HorizontalAlignment="Center" VerticalAlignment="Center">
<!-- IDLE: the mode switch + the action(s) of the armed world -->
<StackPanel Orientation="Horizontal"
Visibility="{Binding ShowIdleWorld, Converter={StaticResource BoolToVis}}">
<Border Background="#0f1622" CornerRadius="4" Padding="2" VerticalAlignment="Center">
<StackPanel Orientation="Horizontal">
<ToggleButton Style="{StaticResource SegmentToggle}"
IsChecked="{Binding RecordPillOn}">
<TextBlock Text="REC" Style="{StaticResource SegmentLabel}"/>
</ToggleButton>
<ToggleButton Style="{StaticResource SegmentToggle}"
IsChecked="{Binding OnAirPillOn}">
<TextBlock Text="ON-AIR" Style="{StaticResource SegmentLabel}"/>
</ToggleButton>
</StackPanel>
</Border>
<!-- Go face names the world: "Start Recording" vs "Go Live".
(Signed-out ON-AIR Start routes to the sign-in — StartSession.) -->
<Button Content="{Binding PrimaryStartButtonLabel}" Style="{StaticResource YtButton}"
Margin="10,0,0,0" Command="{Binding StartStreamCommand}"
Visibility="{Binding ShowPrimaryStartButton, Converter={StaticResource BoolToVis}}"/>
<!-- Test is a child of Stream — a dry-run of the livestream, so it
exists only inside the Stream world (stream armed + signed in). -->
<Button Content="Test" Style="{StaticResource YtButtonSecondary}" Padding="12,4" FontSize="11"
Margin="8,0,0,0" VerticalAlignment="Center"
Command="{Binding StartTestCommand}"
ToolTip="Run a private test broadcast to check your chat overlay."
Visibility="{Binding ShowTestButton, Converter={StaticResource BoolToVis}}"/>
</StackPanel>
<!-- RUNNING: one reality line (dot + word + elapsed) and the stop -->
<StackPanel Orientation="Horizontal"
Visibility="{Binding RunningVisible, Converter={StaticResource BoolToVis}}">
<Ellipse Width="12" Height="12" VerticalAlignment="Center"
Fill="{Binding RunningDotBrush}"/>
<TextBlock Text="{Binding RunningText}" FontSize="16" FontWeight="Bold"
Foreground="White" Margin="8,0,0,0" VerticalAlignment="Center"/>
<TextBlock Text="{Binding LiveElapsedText}" Foreground="White" FontSize="14"
FontFamily="Consolas" VerticalAlignment="Center" Margin="8,0,0,0"/>
<Button Content="{Binding EndButtonText}" Style="{StaticResource YtButton}"
Background="#333" Margin="14,0,0,0" Command="{Binding EndStreamCommand}"/>
</StackPanel>
</StackPanel>
<!-- Account zone: lives only in the Stream world. Signed out → Sign In;
signed in → the avatar (which account is about to stream). Record world
has no YouTube identity at all. -->
<StackPanel Grid.Column="2" Orientation="Horizontal" VerticalAlignment="Center">
<!-- Status light -->
<Ellipse Width="8" Height="8" Margin="0,0,8,0" VerticalAlignment="Center"
ToolTip="{Binding AccountStatusLightToolTip}">
<Ellipse.Style>
<Style TargetType="Ellipse">
<Setter Property="Fill" Value="#ef4444"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsConnected}" Value="True">
<Setter Property="Fill" Value="#22c55e"/>
</DataTrigger>
<DataTrigger Binding="{Binding IsLive}" Value="True">
<Setter Property="Fill" Value="#e94560"/>
</DataTrigger>
</Style.Triggers>
</Style>
</Ellipse.Style>
</Ellipse>
<!-- Avatar (connected only); shows the channel initial when
the profile image is missing or fails to download -->
<Border Width="26" Height="26" CornerRadius="13" Background="#16213e" ClipToBounds="True"
Margin="0,0,8,0" VerticalAlignment="Center"
<Button Content="Sign In" Style="{StaticResource YtButtonSecondary}"
Margin="0,0,8,0" Command="{Binding SignInCommand}"
ToolTip="Sign in to YouTube to go live."
Visibility="{Binding ShowSignInButton, Converter={StaticResource BoolToVis}}"/>
<Button Style="{StaticResource IconButton}" Margin="0,0,0,0"
ToolTip="{Binding AccountDisplayName}"
Visibility="{Binding IsConnected, Converter={StaticResource BoolToVis}}">
<Grid>
<Image x:Name="AvatarImage" Stretch="UniformToFill"/>
<TextBlock x:Name="AvatarInitial" Foreground="#e0e0e0" FontSize="13"
FontWeight="Bold" HorizontalAlignment="Center" VerticalAlignment="Center"
Visibility="Collapsed"/>
</Grid>
</Border>
<!-- Test button (TASK 41): one-click private test broadcast — the Test
drawer (right rail, TEST tab) hosts the mock chat input + simulated
events. Visible only signed-in and idle. -->
<Button Content="Test" Style="{StaticResource YtButtonSecondary}" Padding="12,4" FontSize="11"
Margin="0,0,8,0" VerticalAlignment="Center"
Command="{Binding StartTestCommand}"
ToolTip="Start a private test broadcast to check your chat overlay and chat tools."
Visibility="{Binding ShowTestButton, Converter={StaticResource BoolToVis}}"/>
<!-- Primary Start button: the always-present idle action (sign-in moved
into its context menu — a bare bar after stopping was a dead end) -->
<Button Content="{Binding PrimaryStartButtonLabel}" Style="{StaticResource YtButton}"
Command="{Binding StartStreamCommand}"
Visibility="{Binding ShowPrimaryStartButton, Converter={StaticResource BoolToVis}}">
Visibility="{Binding ShowAvatarButton, Converter={StaticResource BoolToVis}}">
<Button.ContextMenu>
<ContextMenu>
<MenuItem Header="Sign in to YouTube" Command="{Binding SignInCommand}"
Visibility="{Binding IsConnected, Converter={StaticResource InverseBoolToVis}}"/>
<Separator/>
<MenuItem Header="Choose Record Folder" Command="{Binding ChooseRecordFolderCommand}"/>
<Separator/>
<MenuItem Header="Change Account" Command="{Binding ChangeAccountCommand}"/>
<Separator/>
<MenuItem Header="Logout" Command="{Binding LogoutCommand}"/>
</ContextMenu>
</Button.ContextMenu>
<Border Width="26" Height="26" CornerRadius="13" Background="#16213e"
ClipToBounds="True" BorderBrush="#3a4a66" BorderThickness="1">
<Grid>
<Image x:Name="AvatarImage" Stretch="UniformToFill"/>
<TextBlock x:Name="AvatarInitial" Foreground="#e0e0e0" FontSize="13"
FontWeight="Bold" HorizontalAlignment="Center" VerticalAlignment="Center"
Visibility="Collapsed"/>
</Grid>
</Border>
</Button>
<!-- End button (live OR recording) — the only button whose
label changes; "Stop Recording" while recording -->
<Button Content="{Binding EndButtonText}" Style="{StaticResource YtButton}"
Background="#333" Command="{Binding EndStreamCommand}"
Visibility="{Binding ShowEndStreamButton, Converter={StaticResource BoolToVis}}"/>
</StackPanel>
</Grid>
</UserControl>