feat(topbar): the segmented mode switcher, to the mock

Implements the creator's mock (2026-11-12) for the world segment: pill track (#1E222B, radius 12,
padding 4) holding two equal halves; the active half sits on a lighter pill (#2E3440, radius 8) with
#E2E8F0 bold text and a coral (#F26D6D) icon; the inactive half is #8892A4 on a one-alpha black face so
the whole area hit-tests; hover tints #252A34 on the inactive half only, so the active one never looks
clickable-but-dim. Labels are "Build scenes" / "Record / go live".

Two deliberate deviations from the pasted recipe, both to honour this repo:
- State is the existing BarWorld binding plus a command, not a code-behind SetActiveMode — one source of
  truth, and the change still raises the same property-changed notification a mode event would.
- Styles live in Themes/Controls.xaml (SegmentHalf, SegmentIcon) with colours as tokens in Tokens.xaml,
  rather than inline properties on the elements: AGENTS requires shared styles, and one DataTrigger on
  each element's own Tag serves both halves.

DesignTokensTests caught the one raw literal (#01000000) and it is now Brush.SegmentHitArea, which is the
rule working as intended.

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 16:20:25 -08:00
parent b92569a8c7
commit 6b4aa99863
4 changed files with 86 additions and 25 deletions
+19 -22
View File
@@ -66,43 +66,40 @@
question "which world am I in?" and "can I edit?" cannot disagree. Clicking 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 highlighted half re-enters it, which is how a creator gets back to the layers
during a session. --> during a session. -->
<Border Background="{StaticResource Brush.SurfaceContainerLowest}" CornerRadius="4" <!-- The segmented mode switcher (creator mock 2026-11-12): pill track, two equal halves,
Padding="2" Height="32" VerticalAlignment="Center" Margin="0,0,12,0" active half on a lighter pill with off-white bold text and a coral icon. Hover tints
only the inactive half. -->
<Border Background="{StaticResource Brush.SegmentTrack}" CornerRadius="12" Padding="4"
VerticalAlignment="Center" Margin="0,0,12,0"
Visibility="{Binding IsBuildWorld, Converter={StaticResource BoolToVis}}"> Visibility="{Binding IsBuildWorld, Converter={StaticResource BoolToVis}}">
<StackPanel Orientation="Horizontal"> <Grid>
<!-- Build Scenes carries a LAYOUT glyph while it is the active world and a PADLOCK <Grid.ColumnDefinitions>
once Record / Go Live is: the door is still there, it is just behind the <ColumnDefinition Width="*"/>
session. Clicking it swaps the glyph back and takes the creator into the <ColumnDefinition Width="*"/>
layers (creator ruling 2026-11-12). --> </Grid.ColumnDefinitions>
<Button Style="{StaticResource WorldSegmentButton}" Tag="{Binding IsBuildWorld}" <Button Grid.Column="0" Style="{StaticResource SegmentHalf}"
Command="{Binding EnterBuildWorldCommand}" Padding="12,0" Tag="{Binding IsBuildWorld}" Command="{Binding EnterBuildWorldCommand}"
ToolTip="Edit layers, scenes and webcam placement"> ToolTip="Edit layers, scenes and webcam placement">
<StackPanel Orientation="Horizontal"> <StackPanel Orientation="Horizontal">
<Path Width="14" Height="14" Stretch="Uniform" VerticalAlignment="Center" <Path Style="{StaticResource SegmentIcon}" Tag="{Binding IsBuildWorld}"
Fill="{Binding Foreground, RelativeSource={RelativeSource AncestorType=Button}}"
Visibility="{Binding IsBuildWorld, Converter={StaticResource BoolToVis}}"
Data="M3,4h8v6H3V4zM13,4h8v6h-8V4zM3,12h8v8H3V12zM13,12h8v8h-8V12z"/> Data="M3,4h8v6H3V4zM13,4h8v6h-8V4zM3,12h8v8H3V12zM13,12h8v8h-8V12z"/>
<TextBlock Text="Build scenes" FontSize="12" Margin="6,0,0,0"
<TextBlock Text="Build Scenes" FontSize="12" FontWeight="Bold" Margin="6,0,0,0"
VerticalAlignment="Center" VerticalAlignment="Center"
Foreground="{Binding Foreground, RelativeSource={RelativeSource AncestorType=Button}}"/> Foreground="{Binding Foreground, RelativeSource={RelativeSource AncestorType=Button}}"/>
</StackPanel> </StackPanel>
</Button> </Button>
<!-- Record / Go Live: a record dot in a ring, the same "armed" language the REC sign <Button Grid.Column="1" Style="{StaticResource SegmentHalf}"
uses, so the two halves are read apart at a glance. --> Tag="{Binding IsOutputWorld}" Command="{Binding EnterOutputWorldCommand}"
<Button Style="{StaticResource WorldSegmentButton}" Tag="{Binding IsOutputWorld}"
Command="{Binding EnterOutputWorldCommand}" Padding="12,0"
ToolTip="Choose the output and start the session"> ToolTip="Choose the output and start the session">
<StackPanel Orientation="Horizontal"> <StackPanel Orientation="Horizontal">
<Path Width="14" Height="14" Stretch="Uniform" VerticalAlignment="Center" <Path Style="{StaticResource SegmentIcon}" Tag="{Binding IsOutputWorld}"
Fill="{Binding Foreground, RelativeSource={RelativeSource AncestorType=Button}}"
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"/> 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 / Go Live" FontSize="12" FontWeight="Bold" Margin="6,0,0,0" <TextBlock Text="Record / go live" FontSize="12" Margin="6,0,0,0"
VerticalAlignment="Center" VerticalAlignment="Center"
Foreground="{Binding Foreground, RelativeSource={RelativeSource AncestorType=Button}}"/> Foreground="{Binding Foreground, RelativeSource={RelativeSource AncestorType=Button}}"/>
</StackPanel> </StackPanel>
</Button> </Button>
</StackPanel> </Grid>
</Border> </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
+50
View File
@@ -1002,6 +1002,56 @@
<!-- 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 segmented mode switcher's icon: muted when its segment is inactive, coral when active.
Tag carries the segment's own flag so one style serves both halves (creator mock 2026-11-12). -->
<Style x:Key="SegmentIcon" TargetType="Path">
<Setter Property="Fill" Value="{StaticResource Brush.SegmentTextInactive}"/>
<Setter Property="Width" Value="15"/>
<Setter Property="Height" Value="15"/>
<Setter Property="Stretch" Value="Uniform"/>
<Setter Property="VerticalAlignment" Value="Center"/>
<Style.Triggers>
<DataTrigger Binding="{Binding Tag, RelativeSource={RelativeSource Self}}" Value="True">
<Setter Property="Fill" Value="{StaticResource Brush.SegmentIconActive}"/>
</DataTrigger>
</Style.Triggers>
</Style>
<!-- One segment of the switcher: pill container is the host Border, this is a half of it. -->
<Style x:Key="SegmentHalf" TargetType="Button">
<Setter Property="Background" Value="{StaticResource Brush.SegmentHitArea}"/>
<Setter Property="Foreground" Value="{StaticResource Brush.SegmentTextInactive}"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="HorizontalContentAlignment" Value="Center"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="Face" Background="{TemplateBinding Background}"
CornerRadius="8" Padding="14,6">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
<Style.Triggers>
<!-- Active: lighter inner pill, off-white bold text. -->
<DataTrigger Binding="{Binding Tag, RelativeSource={RelativeSource Self}}" Value="True">
<Setter Property="Background" Value="{StaticResource Brush.SegmentActive}"/>
<Setter Property="Foreground" Value="{StaticResource Brush.SegmentTextActive}"/>
<Setter Property="FontWeight" Value="Bold"/>
</DataTrigger>
<!-- Hover tints ONLY the inactive half, so the active one never looks clickable-but-dim. -->
<MultiDataTrigger>
<MultiDataTrigger.Conditions>
<Condition Binding="{Binding IsMouseOver, RelativeSource={RelativeSource Self}}" Value="True"/>
<Condition Binding="{Binding Tag, RelativeSource={RelativeSource Self}}" Value="False"/>
</MultiDataTrigger.Conditions>
<Setter Property="Background" Value="{StaticResource Brush.SegmentHover}"/>
</MultiDataTrigger>
</Style.Triggers>
</Style>
<!-- The top bar's world segment (creator ruling 2026-11-12): [ Build Scenes ][ Record / Go Live ]. <!-- 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 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. --> what sets it. Each button passes its own world flag in Tag and one DataTrigger paints it. -->
+10
View File
@@ -35,6 +35,16 @@
<SolidColorBrush x:Key="Brush.Tertiary" Color="#38bdf8"/> <SolidColorBrush x:Key="Brush.Tertiary" Color="#38bdf8"/>
<SolidColorBrush x:Key="Brush.OnTertiary" Color="#0b1326"/> <SolidColorBrush x:Key="Brush.OnTertiary" Color="#0b1326"/>
<SolidColorBrush x:Key="Brush.OnAccent" Color="#ffffff"/> <SolidColorBrush x:Key="Brush.OnAccent" Color="#ffffff"/>
<!-- Segmented mode switcher (creator mock, 2026-11-12) -->
<SolidColorBrush x:Key="Brush.SegmentTrack" Color="#1E222B"/>
<SolidColorBrush x:Key="Brush.SegmentActive" Color="#2E3440"/>
<SolidColorBrush x:Key="Brush.SegmentHover" Color="#252A34"/>
<SolidColorBrush x:Key="Brush.SegmentTextActive" Color="#E2E8F0"/>
<SolidColorBrush x:Key="Brush.SegmentTextInactive" Color="#8892A4"/>
<SolidColorBrush x:Key="Brush.SegmentIconActive" Color="#F26D6D"/>
<!-- One-alpha black: transparent to the eye, opaque to hit-testing, so the whole segment is clickable. -->
<SolidColorBrush x:Key="Brush.SegmentHitArea" Color="#01000000"/>
<SolidColorBrush x:Key="Brush.Primary" Color="#ff5451"/> <SolidColorBrush x:Key="Brush.Primary" Color="#ff5451"/>
<SolidColorBrush x:Key="Brush.PrimaryContainer" Color="#b91a24"/> <SolidColorBrush x:Key="Brush.PrimaryContainer" Color="#b91a24"/>
<SolidColorBrush x:Key="Brush.Secondary" Color="#ffb95f"/> <SolidColorBrush x:Key="Brush.Secondary" Color="#ffb95f"/>
+7 -3
View File
@@ -29,8 +29,8 @@ public class UiShellTests
// 2. The world segment replaced the EDIT toggle (creator ruling 2026-11-12): both halves are // 2. The world segment replaced the EDIT toggle (creator ruling 2026-11-12): both halves are
// present, each lit by its own world flag through the shared style. // present, each lit by its own world flag through the shared style.
Assert.Contains("Text=\"Build Scenes\"", topBar); Assert.Contains("Text=\"Build scenes\"", topBar);
Assert.Contains("Text=\"Record / Go Live\"", topBar); Assert.Contains("Text=\"Record / go live\"", topBar);
Assert.Contains("Tag=\"{Binding IsBuildWorld}\"", topBar); Assert.Contains("Tag=\"{Binding IsBuildWorld}\"", topBar);
Assert.Contains("Tag=\"{Binding IsOutputWorld}\"", topBar); Assert.Contains("Tag=\"{Binding IsOutputWorld}\"", topBar);
Assert.DoesNotContain("x:Name=\"EditBody\"", topBar); Assert.DoesNotContain("x:Name=\"EditBody\"", topBar);
@@ -38,6 +38,10 @@ public class UiShellTests
// 2b. The segment is Build-world only: choosing Record / Go Live REPLACES it with the output // 2b. The segment is Build-world only: choosing Record / Go Live REPLACES it with the output
// line (creator ruling 2026-11-12), and a chevron is the way back. // line (creator ruling 2026-11-12), and a chevron is the way back.
Assert.Contains("Data=\"M3,4h8v6H3V4zM13,4h8v6h-8V4zM3,12h8v8H3V12zM13,12h8v8h-8V12z\"", topBar); Assert.Contains("Data=\"M3,4h8v6H3V4zM13,4h8v6h-8V4zM3,12h8v8H3V12zM13,12h8v8h-8V12z\"", topBar);
// Segmented switcher (creator mock 2026-11-12): pill track, two equal halves, coral active icon.
Assert.Contains("Brush.SegmentTrack", topBar);
Assert.Contains("Style=\"{StaticResource SegmentHalf}\"", topBar);
Assert.Contains("Style=\"{StaticResource SegmentIcon}\"", topBar);
Assert.Contains("Visibility=\"{Binding IsBuildWorld, Converter={StaticResource BoolToVis}}\"", topBar); Assert.Contains("Visibility=\"{Binding IsBuildWorld, Converter={StaticResource BoolToVis}}\"", topBar);
Assert.Contains("Text=\"Output:\"", topBar); Assert.Contains("Text=\"Output:\"", topBar);
Assert.Contains("Text=\"press &lt;esc&gt; to return\"", topBar); Assert.Contains("Text=\"press &lt;esc&gt; to return\"", topBar);
@@ -116,7 +120,7 @@ public class UiShellTests
// The world segment leads the centre cluster; the brand column carries no edit control. // The world segment leads the centre cluster; the brand column carries no edit control.
var centre = topBar.IndexOf("<StackPanel Grid.Column=\"1\"", System.StringComparison.Ordinal); var centre = topBar.IndexOf("<StackPanel Grid.Column=\"1\"", System.StringComparison.Ordinal);
var segment = topBar.IndexOf("Text=\"Build Scenes\"", System.StringComparison.Ordinal); var segment = topBar.IndexOf("Text=\"Build scenes\"", System.StringComparison.Ordinal);
Assert.True(centre > 0 && segment > centre, Assert.True(centre > 0 && segment > centre,
"the [ Build Scenes ][ Record / Go Live ] segment must lead the centre cluster"); "the [ Build Scenes ][ Record / Go Live ] segment must lead the centre cluster");
Assert.DoesNotContain("x:Name=\"EditBody\"", topBar); Assert.DoesNotContain("x:Name=\"EditBody\"", topBar);