Compare commits

..
7 Commits
Author SHA1 Message Date
gramps 8be6a83dab Docs: the properties-panel batch (TASK 58-63) is pushed to origin/main 2026-10-22 06:39:25 -07:00
gramps 987fddac24 TASK 63: the border-colour picker IS the app's own drop-down
The creator (2026-10-21), on Live -> Layers -> webcam -> Properties:

  "why does the drop down under the web cam properties not show the same style for
   the border color drop down list box?"

  "one of the first things we did was define branding. We established look-and-feel
   standards. And yet you keep ignoring these and I'm wondering why"

WHY IT WAS MISSED - the answer the creator is owed.
The TASK 58 sweep had already walked the whole app for un-themed controls and the
border picker STILL was not themed, because the sweep looked for control TYPES
(ComboBox / CheckBox / TextBox) carrying no theme style - and the border picker was
NONE of those. It was a "..." Button whose Click handler built a ContextMenu of
swatches in code. It was never in the sweep's scope, and PropertiesStyleTests.cs
(a type-based guard) could not see it either. A picker that LOOKS like a drop-down
is invisible to a type-based audit; the guard has to ask "is this the app's widget?".

WHAT CHANGED
- The picker is now the app's YtComboBox - the widget the Display and Font pickers
  wear - bound to the palette (SelectedElement.BorderColorOptions) with its selection
  bound by VALUE (SelectedItem -> BorderColorEntry), because WPF's SelectedValue
  matching is ordinal and the palette mixes #FFFFFF and #e94560.
- The closed box shows the swatch + the colour's NAME; the rows show swatch + hex +
  name (the rows the creator approved in TASK 60). A ComboBox cannot do that with one
  ItemTemplate - WPF copies it into the read-only SelectionBoxItemTemplate - so
  Helpers/ClosedBoxTemplateSelector.cs picks by the ContentPresenter's templated
  parent: the ComboBox for the closed box, the ComboBoxItem for a row. Verified
  against WPF with a throwaway probe, not assumed.
- The swatch is painted by the THEME (Helpers/HexToBrushConverter.cs, registered as
  HexToBrush) - the hand-painted SolidColorBrush(Color.FromRgb(...)) in the old
  click handler was the exact anti-pattern the theme forbids. The swatch also carries
  a 1px #2a3a5e border now, so black and dark swatches are visible on the dark menu
  (the follow-up TASK 60 left open).
- The hex field stays (60px, editable): a colour outside the palette can still be
  typed, and the drop-down then carries that hex as a NAMELESS entry ("no name" is the
  honest answer). The palette's name moved out of TASK 62's TextBlock into the
  drop-down's closed box.
- The hand-built menu, its ContextMenu/MenuItem/SolidColorBrush code and
  WebcamBorderColorPicker_Click are gone.

MODEL: SceneElement.BorderColorName -> BorderColorEntry (the palette entry for the
current hex, with its name; setting it writes the hex back) + BorderColorOptions (the
palette plus the custom entry), both raised when BorderColor changes.
BorderColorPalette gains EntryFor(hex) and OptionsFor(hex); NameOf delegates.

Guards: ytLive.Tests/BorderColorPickerTests.cs (the ONE test for this change - the
model, the panel, and the REAL widget: with the app's theme style and the panel's own
templates the closed box names the colour and the rows show the hex AND the name).
The TASK 60 guard (BorderColorPaletteTests) was updated because the mechanism it
asserted - a code-built menu - no longer exists; it now guards the new wiring (the
drop-down is bound to the palette and neither the panel nor the code-behind carries a
second list of colours).

Full suite: 435/435 passed, 0 warnings.
2026-10-21 23:09:11 -07:00
gramps ef52508b1e TASK 61 + 62: the geometry fields share one width, and the border field names its colour
The creator (2026-10-21), on Live -> Layers -> webcam -> Properties:

  "the Y and H boxes under webcam properties should be the same width as the X and W
   boxes and those widths should be fixed to accomodate the max number of possible
   digits providing the geometry values"

  "also the border color drop drown box content should also include the name of the
   color next to the hex value as they in the drop-down list box"

TASK 61 - one fixed width for the four geometry fields.
The row is a DockPanel, and WPF sizes an element to its CONTENT unless it is told
otherwise, while the LAST child of a DockPanel fills the remaining space. So X/W (not
last) were as wide as their text and Y/H (last) stretched across the row - two widths
for the same kind of field. Now all four carry Width="44" and the rows set
LastChildFill="False" (without it the last field ignores its own Width and fills anyway).
44px is the measured width of "-1920" in the app's font at 11px (the largest geometry
value - the master frame is 1920x1080 - plus a sign) plus the field's padding and
border. Docs: https://learn.microsoft.com/en-us/dotnet/api/system.windows.controls.dockpanel.lastchildfill
The guard MEASURES the widest value with WPF's FormattedText instead of asserting a
magic number, so a font change cannot silently make the field too narrow:
https://learn.microsoft.com/en-us/dotnet/api/system.windows.media.formattedtext

TASK 62 - the border field names its colour.
SceneElement.BorderColorName carries the palette's name for the current colour
("Crimson") and is raised when the colour changes; it is EMPTY for a custom hex the
creator typed, because "no name" is the honest answer. BorderColorPalette.NameOf
changed to match (it used to fall back to the hex itself; the TASK 60 test assertion
was updated with it). The hex field keeps a fixed width (60) so the name sits right
next to the hex instead of drifting to the row's far edge - the same trap as TASK 61.

Guards: ytLive.Tests/GeometryFieldWidthTests.cs, ytLive.Tests/BorderColorNameTests.cs
Full suite: 435/435 passed, 0 warnings.
2026-10-21 22:19:13 -07:00
gramps 60b0218fa3 Docs: the HANDOFF's push state (three properties-panel commits are waiting; origin/main is still 3a77662) 2026-10-21 20:04:01 -07:00
gramps 6271bf34c9 TASK 60: the border-colour drop-down names every colour
The creator: "under Live->Layers->webcam->Properties->border color: in the drop
down box, the #hex values are good, but add a corresponding color name/label next
to the value."

Each row now reads "#e94560  Crimson" - the hex stays exactly as it was, the
colour's name goes next to it (dimmed #a0a0b0 so the hex reads first), and the
tooltip becomes "Crimson (#e94560)".

The 14 colours were a hard-coded string[] inside the click handler - a list of
bare strings cannot carry a name, and a list hidden in UI code cannot be asserted
on. They now live in Models/BorderColorPalette.cs: NamedColor(Hex, Name) +
BorderColorPalette.All (the single source of truth) + NameOf(hex), which answers
case-insensitively and falls back to the hex for a colour outside the palette.
The picker builds its menu from the catalogue and no longer knows any colour by
heart.

The names are chosen not to collide - the palette has two greens, two blues and
two warm colours: #00FF00 is Lime and #22c55e Emerald, #0000FF is Blue and
#3b82f6 Azure, #FF8800 is Orange and #f59e0b Amber.

Guard: ytLive.Tests/BorderColorPaletteTests.cs - every entry valid + named, no
hex or name repeated, the 14 legacy colours all still offered, NameOf answers,
and the old list is gone from the handler. 433/433 green, 0 warnings.
2026-10-21 20:03:19 -07:00
gramps 89f6aa3a67 TASK 59: the properties panel is a hierarchy, and the shape is a choice
The creator's ruling on the webcam's properties: a "Shape" title above the
Mirror/Rect row; the drag hint indented under that row "to show parentage";
the same for Geometry, Border Color, Border Thickness; Remove and Blur indented
under Background; Strength indented under Blur.

One rule covers it: a title owns its content and the content is indented one
level (10px) under it - two levels where a title is itself nested (Strength
under Blur under Background). The titles are SemiBold 11px #a0a0b0 like the
ones already there.

The shape also stops being a toggle button that shows the NEXT action ("Round"
when the camera was a rectangle) and becomes a CHOICE: two segment options,
Rect | Circle, with Rect selected by default - which is what the creator asked
for ("Rect should be selected as a default if none exists"). The old
ShapeButtonText is gone.

The mutual exclusion lives in the MODEL: IsRectShape/IsRoundShape on
SceneElement, each setter flipping the shape through ToggleClipShape() (so the
pre-round rect is still stored/restored) and always re-raising, so a refused
write - the vertical Chat layout is rect-only (TASK 57) - makes the UI snap
back instead of sticking. Deliberately NOT a WPF RadioButton group: a group
unchecks its siblings by setting IsChecked in code, which makes WPF drop the
binding (stackoverflow 7376373; the same trap measured in 50980687/57275046).

The Circle option stays hidden where CanToggleClipShape is false, so the circle
is still not offered in the vertical Chat layout.

Guard: ytLive.Tests/PropertiesHierarchyTests.cs - the model half (Rect by
default, pick circle, pick rect, the locked Chat layout refuses the circle) and
the panel half (the Shape title owns both options, the circle is the gated one,
the hint is indented and follows the row, every titled block's content is at
10px, Strength at 20px). 432/432 green, 0 warnings.
2026-10-21 19:58:28 -07:00
gramps 2273d20160 TASK 58: the properties panel wears the theme (no more light-system chrome)
The creator asked why the Stream Alerts "Display" drop-down was not in the same
style as the other drop-downs. It was never given the theme's YtComboBox style -
only a hand-painted Background/Foreground. Setting colours changes the paint, not
the template, so WPF still drew the light system ComboBox (grey border, system
arrow, white popup list) on top of the dark panel. The chat Font drop-down was a
copy of the same mistake, and every check box was the light system one.

The sweep of the whole properties panel (then the app):
  - ComboBox x2 (chat Font, alert Display) -> YtComboBox, hand-painted colours gone
  - TextBox x16 in the properties panel -> YtTextBoxCompact (new, BasedOn YtTextBox:
    11px / padding 4,2 - the panel is dense, the full 13px box would double it)
  - CheckBox x2 (chat) + x3 (preview broadcast) + x1 (socials dialog) -> YtCheckBox
    (new: a 14x14 rounded box + tick path, accent fill when checked)
  - the layer-rename field -> YtTextBoxCompact via BasedOn, still borderless
  - buttons, pills, sliders: already themed, untouched

The sweep also killed a typo'd foreground (#e0d0e0 for #e0e0e0) and the only
remaining hand-painted colour is the deliberate dimming of the two read-only path
fields.

Guard: ytLive.Tests/PropertiesStyleTests.cs (the ONE Good Dog test) parses the app
XAML and fails if an unstyled drop-down, check box or properties field comes back.
431/431 green, 0 warnings.
2026-10-21 19:46:25 -07:00
28 changed files with 1537 additions and 125 deletions
+110 -66
View File
@@ -3,6 +3,37 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:Helpers="clr-namespace:ytLive.Helpers"
xmlns:models="clr-namespace:ytLive.Models">
<UserControl.Resources>
<!-- TASK 63: the border-colour drop-down is the app's own YtComboBox, so the
colour entries are drawn by TEMPLATES, in the theme's colours and with the
theme's HexToBrush converter — no hand-built menu, no hand-painted brush.
The closed box shows the swatch + the colour's NAME; the rows show the
swatch + the hex + the name. -->
<DataTemplate x:Key="BorderColorClosedTemplate">
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<Border Width="12" Height="12" CornerRadius="2" VerticalAlignment="Center"
Background="{Binding Hex, Converter={StaticResource HexToBrush}}"
BorderBrush="#2a3a5e" BorderThickness="1"/>
<TextBlock Text="{Binding Name}" Margin="6,0,0,0" FontSize="12"
Foreground="#e0e0e0" VerticalAlignment="Center"/>
</StackPanel>
</DataTemplate>
<DataTemplate x:Key="BorderColorRowTemplate">
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<Border Width="12" Height="12" CornerRadius="2" VerticalAlignment="Center"
Background="{Binding Hex, Converter={StaticResource HexToBrush}}"
BorderBrush="#2a3a5e" BorderThickness="1"/>
<TextBlock Text="{Binding Hex}" Margin="6,0,0,0" FontSize="12"
Foreground="#e0e0e0" VerticalAlignment="Center"/>
<TextBlock Text="{Binding Name}" Margin="8,0,0,0" FontSize="12"
Foreground="#a0a0b0" VerticalAlignment="Center"/>
</StackPanel>
</DataTemplate>
<Helpers:ClosedBoxTemplateSelector x:Key="BorderColorTemplates"
ClosedTemplate="{StaticResource BorderColorClosedTemplate}"
RowTemplate="{StaticResource BorderColorRowTemplate}"/>
</UserControl.Resources>
<Border Background="#16213e" CornerRadius="6" Padding="12">
<Grid>
<Grid.RowDefinitions>
@@ -155,12 +186,11 @@
</TextBlock>
<TextBox Grid.Column="0" Text="{Binding Name, UpdateSourceTrigger=PropertyChanged}"
Background="#0f3460" Foreground="#e0e0e0" BorderThickness="0"
Padding="4,2" VerticalContentAlignment="Center"
BorderThickness="0" FontSize="12"
IsVisibleChanged="SourceNameBox_IsVisibleChanged"
KeyDown="SourceNameBox_KeyDown" LostFocus="SourceNameBox_LostFocus">
<TextBox.Style>
<Style TargetType="TextBox">
<Style TargetType="TextBox" BasedOn="{StaticResource YtTextBoxCompact}">
<Setter Property="Visibility" Value="Collapsed"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsEditing}" Value="True">
@@ -239,18 +269,31 @@
ValueChanged="OpacitySlider_ValueChanged" VerticalAlignment="Center"/>
</DockPanel>
</StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,0,0,4"
Visibility="{Binding IsWebcamSelected, Converter={StaticResource BoolToVis}}">
<Button Content="{Binding SelectedElement.MirrorButtonText}"
Style="{StaticResource YtButtonSecondary}" Padding="10,4"
Click="MirrorButton_Click"/>
<Button Content="{Binding SelectedElement.ShapeButtonText}"
Style="{StaticResource YtButtonSecondary}" Padding="10,4"
Margin="8,0,0,0" Click="ShapeButton_Click"
Visibility="{Binding CanToggleClipShape, Converter={StaticResource BoolToVis}}"/>
<!-- TASK 59: the shape is a CHOICE (Rect | Circle) under a
"Shape" title, with Rect selected by default — the circle
is not offered in the vertical Chat layout, so that option
is not even shown there (CanToggleClipShape). -->
<StackPanel Visibility="{Binding IsWebcamSelected, Converter={StaticResource BoolToVis}}">
<TextBlock Text="Shape" Foreground="#a0a0b0" FontSize="11" FontWeight="SemiBold" Margin="0,0,0,4"/>
<StackPanel Orientation="Horizontal" Margin="10,0,0,4">
<Button Content="{Binding SelectedElement.MirrorButtonText}"
Style="{StaticResource YtButtonSecondary}" Padding="10,4"
Click="MirrorButton_Click"/>
<ToggleButton Style="{StaticResource SegmentToggle}" Padding="10,4" Margin="8,0,0,0"
IsChecked="{Binding SelectedElement.IsRectShape, Mode=TwoWay}">
<TextBlock Text="Rect" Style="{StaticResource SegmentLabel}"/>
</ToggleButton>
<ToggleButton Style="{StaticResource SegmentToggle}" Padding="10,4" Margin="4,0,0,0"
IsChecked="{Binding SelectedElement.IsRoundShape, Mode=TwoWay}"
Visibility="{Binding CanToggleClipShape, Converter={StaticResource BoolToVis}}">
<TextBlock Text="Circle" Style="{StaticResource SegmentLabel}"/>
</ToggleButton>
</StackPanel>
</StackPanel>
<!-- The drag hint sits under the shape row — indented, so the
parentage shows. -->
<TextBlock Text="Drag to move · drag the corner dot to resize"
Foreground="#a0a0b0" FontSize="10" Margin="0,2,0,0"/>
Foreground="#a0a0b0" FontSize="10" Margin="10,2,0,0"/>
</StackPanel>
<!-- Webcam geometry (TASK 57): under the vertical tier the camera
@@ -260,43 +303,51 @@
above) so the webcam's border/background section stays the
one panel bound to SelectedElement.IsWebcam. -->
<StackPanel Visibility="{Binding IsWebcamSelected, Converter={StaticResource BoolToVis}}" Margin="0,0,0,8">
<TextBlock Text="Geometry" Foreground="#a0a0b0" FontSize="11" FontWeight="SemiBold" Margin="0,0,0,4"/>
<DockPanel Margin="0,0,0,4">
<TextBlock Text="Geometry" Foreground="#a0a0b0" FontSize="11" FontWeight="SemiBold" Margin="0,8,0,4"/>
<!-- TASK 61: the four fields share ONE fixed width — wide enough
for the largest geometry value (1920, plus a typed sign) and
no wider, so Y/H no longer stretch to fill the row. -->
<DockPanel Margin="10,0,0,4" LastChildFill="False">
<TextBlock DockPanel.Dock="Left" Text="X" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="16"/>
<TextBox Text="{Binding SelectedElement.X, UpdateSourceTrigger=PropertyChanged, StringFormat=F0}"
Background="#0f3460" Foreground="#e0d0e0" BorderThickness="1"
BorderBrush="#2c3a5c" Padding="4,2" FontSize="12"/>
Style="{StaticResource YtTextBoxCompact}" Width="44"/>
<TextBlock DockPanel.Dock="Left" Text=" Y" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="20" Margin="8,0,0,0"/>
<TextBox Text="{Binding SelectedElement.Y, UpdateSourceTrigger=PropertyChanged, StringFormat=F0}"
Background="#0f3460" Foreground="#e0d0e0" BorderThickness="1"
BorderBrush="#2c3a5c" Padding="4,2" FontSize="12"/>
Style="{StaticResource YtTextBoxCompact}" Width="44"/>
</DockPanel>
<DockPanel Margin="0,0,0,4">
<DockPanel Margin="10,0,0,4" LastChildFill="False">
<TextBlock DockPanel.Dock="Left" Text="W" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="16"/>
<TextBox Text="{Binding SelectedElement.Width, UpdateSourceTrigger=PropertyChanged, StringFormat=F0}"
Background="#0f3460" Foreground="#e0d0e0" BorderThickness="1"
BorderBrush="#2c3a5c" Padding="4,2" FontSize="12"/>
Style="{StaticResource YtTextBoxCompact}" Width="44"/>
<TextBlock DockPanel.Dock="Left" Text=" H" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="20" Margin="8,0,0,0"/>
<TextBox Text="{Binding SelectedElement.Height, UpdateSourceTrigger=PropertyChanged, StringFormat=F0}"
Background="#0f3460" Foreground="#e0d0e0" BorderThickness="1"
BorderBrush="#2c3a5c" Padding="4,2" FontSize="12"/>
Style="{StaticResource YtTextBoxCompact}" Width="44"/>
</DockPanel>
</StackPanel>
<!-- Webcam border config -->
<StackPanel Visibility="{Binding SelectedElement.IsWebcam, Converter={StaticResource BoolToVis}}" Margin="0,0,0,8">
<TextBlock Text="Border Color" Foreground="#a0a0b0" FontSize="11" Margin="0,0,0,2"/>
<DockPanel Margin="0,0,0,6">
<Button DockPanel.Dock="Right" Content="..." Width="24" Height="24"
Click="WebcamBorderColorPicker_Click" Margin="4,0,0,0"
ToolTip="Pick color"/>
<TextBox Text="{Binding SelectedElement.BorderColor, UpdateSourceTrigger=PropertyChanged}"
Background="#0f3460" Foreground="#e0d0e0" BorderThickness="1"
BorderBrush="#2c3a5c" Padding="4,2" FontSize="12"
MaxLength="7" ToolTip="#RRGGBB"/>
<TextBlock Text="Border Color" Foreground="#a0a0b0" FontSize="11" FontWeight="SemiBold" Margin="0,8,0,2"/>
<!-- TASK 63: the border colour is picked with the app's OWN
drop-down (YtComboBox) — the same widget the Display
and Font pickers use — not a hand-rolled menu. The hex
field stays for a colour the creator types; the
drop-down carries the colour's name. -->
<DockPanel Margin="10,0,0,6">
<TextBox DockPanel.Dock="Left"
Text="{Binding SelectedElement.BorderColor, UpdateSourceTrigger=PropertyChanged}"
Style="{StaticResource YtTextBoxCompact}"
MaxLength="7" ToolTip="#RRGGBB" Width="60"
VerticalAlignment="Center"/>
<ComboBox ItemsSource="{Binding SelectedElement.BorderColorOptions}"
SelectedItem="{Binding SelectedElement.BorderColorEntry, Mode=TwoWay}"
ItemTemplateSelector="{StaticResource BorderColorTemplates}"
Style="{StaticResource YtComboBox}"
ToolTip="Border colour" MinWidth="100"
Margin="8,0,0,0" VerticalAlignment="Center"/>
</DockPanel>
<TextBlock Text="Border Thickness" Foreground="#a0a0b0" FontSize="11" Margin="0,0,0,2"/>
<DockPanel Margin="0,0,0,6">
<TextBlock Text="Border Thickness" Foreground="#a0a0b0" FontSize="11" FontWeight="SemiBold" Margin="0,8,0,2"/>
<DockPanel Margin="10,0,0,6">
<Button DockPanel.Dock="Right" Style="{StaticResource IconButton}"
Width="22" Height="22" Margin="4,0,0,0"
Click="WebcamBorderAccept_Click">
@@ -317,9 +368,11 @@
</DockPanel>
<!-- TASK 51: background effects — two pills acting as radio buttons
(one on turns the other off; both off = no effect). -->
<TextBlock Text="Background" Foreground="#a0a0b0" FontSize="11" FontWeight="SemiBold" Margin="0,6,0,6"/>
<DockPanel Margin="0,0,0,4">
(one on turns the other off; both off = no effect). TASK 59:
the pills are indented under the Background title, and the
strength row one level deeper, under Blur. -->
<TextBlock Text="Background" Foreground="#a0a0b0" FontSize="11" FontWeight="SemiBold" Margin="0,8,0,6"/>
<DockPanel Margin="10,0,0,4">
<TextBlock DockPanel.Dock="Left" Text="Remove" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="84"/>
<ToggleButton x:Name="RemoveBackgroundPill" Style="{StaticResource PillToggle}"
IsChecked="{Binding SelectedElement.RemoveBackground, Mode=TwoWay}"
@@ -327,7 +380,7 @@
ToolTip="Remove the background behind you — the layers below show through"
HorizontalAlignment="Left"/>
</DockPanel>
<DockPanel Margin="0,0,0,4">
<DockPanel Margin="10,0,0,4">
<TextBlock DockPanel.Dock="Left" Text="Blur" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="84"/>
<ToggleButton x:Name="BlurBackgroundPill" Style="{StaticResource PillToggle}"
IsChecked="{Binding SelectedElement.BlurBackground, Mode=TwoWay}"
@@ -335,7 +388,7 @@
ToolTip="Blur the background behind you"
HorizontalAlignment="Left"/>
</DockPanel>
<DockPanel Margin="0,0,0,6"
<DockPanel Margin="20,0,0,6"
Visibility="{Binding SelectedElement.HasBackgroundEffect, Converter={StaticResource BoolToVis}}">
<TextBlock DockPanel.Dock="Left" Text="Strength" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="84"/>
<TextBlock DockPanel.Dock="Right" Text="{Binding SelectedElement.BackgroundStrength, StringFormat={}{0:P0}}"
@@ -378,22 +431,18 @@
<DockPanel Margin="0,0,0,4">
<TextBlock DockPanel.Dock="Left" Text="X" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="16"/>
<TextBox Text="{Binding SelectedElement.X, UpdateSourceTrigger=PropertyChanged, StringFormat=F0}"
Background="#0f3460" Foreground="#e0d0e0" BorderThickness="1"
BorderBrush="#2c3a5c" Padding="4,2" FontSize="12"/>
Style="{StaticResource YtTextBoxCompact}"/>
<TextBlock DockPanel.Dock="Left" Text=" Y" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="20" Margin="8,0,0,0"/>
<TextBox Text="{Binding SelectedElement.Y, UpdateSourceTrigger=PropertyChanged, StringFormat=F0}"
Background="#0f3460" Foreground="#e0d0e0" BorderThickness="1"
BorderBrush="#2c3a5c" Padding="4,2" FontSize="12"/>
Style="{StaticResource YtTextBoxCompact}"/>
</DockPanel>
<DockPanel Margin="0,0,0,4">
<TextBlock DockPanel.Dock="Left" Text="W" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="16"/>
<TextBox Text="{Binding SelectedElement.Width, UpdateSourceTrigger=PropertyChanged, StringFormat=F0}"
Background="#0f3460" Foreground="#e0d0e0" BorderThickness="1"
BorderBrush="#2c3a5c" Padding="4,2" FontSize="12"/>
Style="{StaticResource YtTextBoxCompact}"/>
<TextBlock DockPanel.Dock="Left" Text=" H" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="20" Margin="8,0,0,0"/>
<TextBox Text="{Binding SelectedElement.Height, UpdateSourceTrigger=PropertyChanged, StringFormat=F0}"
Background="#0f3460" Foreground="#e0d0e0" BorderThickness="1"
BorderBrush="#2c3a5c" Padding="4,2" FontSize="12"/>
Style="{StaticResource YtTextBoxCompact}"/>
</DockPanel>
<TextBlock Text="URI" Foreground="#a0a0b0" FontSize="11" Margin="0,0,0,2"/>
<DockPanel>
@@ -410,8 +459,7 @@
Width="14" Height="14" Stretch="Uniform"/>
</Button>
<TextBox Text="{Binding SelectedElement.WebUri, UpdateSourceTrigger=PropertyChanged}"
Background="#0f3460" Foreground="#e0d0e0" BorderThickness="1"
BorderBrush="#2c3a5c" Padding="4,2" FontSize="12"
Style="{StaticResource YtTextBoxCompact}"
ToolTip="https://example.com"/>
</DockPanel>
</StackPanel>
@@ -434,7 +482,7 @@
<TextBlock DockPanel.Dock="Left" Text="Font" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="70"/>
<ComboBox SelectedValue="{Binding SelectedElement.ChatFontFamily, Mode=TwoWay}"
SelectedValuePath="Content"
Background="#0f3460" Foreground="#e0e0e0" FontSize="11"
Style="{StaticResource YtComboBox}"
Width="140" VerticalAlignment="Center">
<ComboBoxItem Content="Segoe UI"/>
<ComboBoxItem Content="Arial"/>
@@ -449,24 +497,21 @@
<DockPanel Margin="0,0,0,4">
<TextBlock DockPanel.Dock="Left" Text="Font Color" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="70"/>
<TextBox Text="{Binding SelectedElement.ChatFontColor, UpdateSourceTrigger=PropertyChanged}"
Background="#0f3460" Foreground="#e0e0e0" BorderThickness="1"
BorderBrush="#2c3a5c" Padding="4,2" FontSize="11" Width="80"/>
Style="{StaticResource YtTextBoxCompact}" Width="80"/>
</DockPanel>
<!-- Member Color -->
<DockPanel Margin="0,0,0,4">
<TextBlock DockPanel.Dock="Left" Text="Member" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="70"/>
<TextBox Text="{Binding SelectedElement.ChatMemberColor, UpdateSourceTrigger=PropertyChanged}"
Background="#0f3460" Foreground="#e0e0e0" BorderThickness="1"
BorderBrush="#2c3a5c" Padding="4,2" FontSize="11" Width="80"/>
Style="{StaticResource YtTextBoxCompact}" Width="80"/>
</DockPanel>
<!-- Super Chat Color -->
<DockPanel Margin="0,0,0,4">
<TextBlock DockPanel.Dock="Left" Text="Super Chat" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="70"/>
<TextBox Text="{Binding SelectedElement.ChatSuperChatColor, UpdateSourceTrigger=PropertyChanged}"
Background="#0f3460" Foreground="#e0e0e0" BorderThickness="1"
BorderBrush="#2c3a5c" Padding="4,2" FontSize="11" Width="80"/>
Style="{StaticResource YtTextBoxCompact}" Width="80"/>
</DockPanel>
<!-- Max Messages -->
@@ -482,8 +527,7 @@
<DockPanel Margin="0,0,0,4">
<TextBlock DockPanel.Dock="Left" Text="Background" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="70"/>
<TextBox Text="{Binding SelectedElement.ChatBackgroundColor, UpdateSourceTrigger=PropertyChanged}"
Background="#0f3460" Foreground="#e0e0e0" BorderThickness="1"
BorderBrush="#2c3a5c" Padding="4,2" FontSize="11" Width="80"/>
Style="{StaticResource YtTextBoxCompact}" Width="80"/>
</DockPanel>
<!-- Background Opacity -->
@@ -505,9 +549,9 @@
</DockPanel>
<!-- Toggles -->
<CheckBox Content="Show timestamps" Foreground="#a0a0b0" FontSize="11" Margin="0,4,0,2"
<CheckBox Content="Show timestamps" Style="{StaticResource YtCheckBox}" Margin="0,4,0,2"
IsChecked="{Binding SelectedElement.ChatShowTimestamps, Mode=TwoWay}"/>
<CheckBox Content="Show badges" Foreground="#a0a0b0" FontSize="11" Margin="0,0,0,2"
<CheckBox Content="Show badges" Style="{StaticResource YtCheckBox}" Margin="0,0,0,2"
IsChecked="{Binding SelectedElement.ChatShowBadges, Mode=TwoWay}"/>
</StackPanel>
@@ -531,8 +575,8 @@
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBox Grid.Column="0" Text="{Binding SelectedElement.AlertVideoPath, Mode=OneWay}"
IsReadOnly="True" Background="#0f3460" Foreground="#a0a0b0"
FontSize="11" VerticalAlignment="Center"/>
Style="{StaticResource YtTextBoxCompact}"
IsReadOnly="True" Foreground="#a0a0b0" VerticalAlignment="Center"/>
<Button Grid.Column="1" Content="Browse…" Command="{Binding BrowseAlertVideoCommand}"
CommandParameter="{Binding SelectedElement}" Style="{StaticResource YtButtonSecondary}"
Margin="4,0,0,0" Padding="8,2"/>
@@ -558,7 +602,7 @@
<TextBlock DockPanel.Dock="Left" Text="Display" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="84"/>
<ComboBox SelectedValue="{Binding SelectedElement.AlertDisplayMethod, Mode=TwoWay}"
SelectedValuePath="Tag"
Background="#0f3460" Foreground="#e0e0e0" FontSize="11"
Style="{StaticResource YtComboBox}"
Width="140" VerticalAlignment="Center">
<ComboBoxItem Content="Scroll" Tag="TickerScroll"/>
<ComboBoxItem Content="Flash" Tag="Flash"/>
@@ -593,8 +637,8 @@
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBox Grid.Column="0" Text="{Binding SelectedElement.CustomBackgroundPath, Mode=OneWay}"
IsReadOnly="True" Background="#0f3460" Foreground="#a0a0b0"
FontSize="11" VerticalAlignment="Center"/>
Style="{StaticResource YtTextBoxCompact}"
IsReadOnly="True" Foreground="#a0a0b0" VerticalAlignment="Center"/>
<Button Grid.Column="1" Content="Browse…" Command="{Binding BrowseBackgroundCommand}"
Style="{StaticResource YtButtonSecondary}" Margin="4,0,0,0" Padding="8,2"/>
</Grid>
-36
View File
@@ -94,12 +94,6 @@ public partial class LeftPanel : UserControl
webcam.IsMirrored = !webcam.IsMirrored;
}
private void ShapeButton_Click(object sender, RoutedEventArgs e)
{
if ((DataContext as MainViewModel)?.SelectedElement is WebcamSceneConfig webcam)
webcam.ToggleClipShape();
}
private void SourceMenu_Remove(object sender, RoutedEventArgs e)
{
if (sender is MenuItem { DataContext: SceneElement element } && DataContext is MainViewModel vm)
@@ -247,36 +241,6 @@ public partial class LeftPanel : UserControl
}
}
private void WebcamBorderColorPicker_Click(object sender, RoutedEventArgs e)
{
if ((DataContext as MainViewModel)?.SelectedElement is not SceneElement src) return;
if (sender is FrameworkElement fe && fe.ContextMenu == null)
{
var menu = new System.Windows.Controls.ContextMenu();
string[] colors = ["#FFFFFF", "#000000", "#FF0000", "#00FF00", "#0000FF",
"#FFFF00", "#FF00FF", "#00FFFF", "#FF8800", "#8800FF",
"#e94560", "#22c55e", "#3b82f6", "#f59e0b"];
foreach (var c in colors)
{
var item = new System.Windows.Controls.MenuItem { Header = c, ToolTip = c };
var brush = new System.Windows.Media.SolidColorBrush(
(System.Windows.Media.Color)System.Windows.Media.ColorConverter.ConvertFromString(c));
item.Icon = new System.Windows.Controls.Border
{
Width = 16, Height = 16,
Background = brush,
CornerRadius = new System.Windows.CornerRadius(2)
};
var color = c;
item.Click += (_, _) => src.BorderColor = color;
menu.Items.Add(item);
}
fe.ContextMenu = menu;
}
if (sender is FrameworkElement fe2)
fe2.ContextMenu.IsOpen = true;
}
private void WebcamBorderRevert_Click(object sender, RoutedEventArgs e)
{
if (_snapshotElement is Models.Source src)
+6 -6
View File
@@ -542,8 +542,8 @@
<ComboBox Style="{StaticResource YtComboBox}" Margin="0,0,0,10"
ItemsSource="{Binding BroadcastForm.VisibilityOptions}"
SelectedItem="{Binding BroadcastForm.Visibility}"/>
<CheckBox Content="Made for kids" Foreground="#e0e0e0" FontSize="12"
Margin="0,2,0,10"
<CheckBox Content="Made for kids" Style="{StaticResource YtCheckBox}"
FontSize="12" Margin="0,2,0,10"
IsChecked="{Binding BroadcastForm.MadeForKids}"/>
<TextBlock Text="SCHEDULED START" Style="{StaticResource YtLabel}" Margin="0,0,0,3"/>
<TextBlock Text="{Binding BroadcastForm.ScheduledStartText}"
@@ -655,8 +655,8 @@
<TextBlock Text="CHANNEL STANDING" Style="{StaticResource YtLabel}" Margin="0,0,0,2"/>
<TextBlock Text="{Binding Ypp.StandingDetailText}" Foreground="#888899" FontSize="11"
TextWrapping="Wrap" Margin="0,0,0,8"/>
<CheckBox Content="Two-Step Verification switched on" Foreground="#e0e0e0" FontSize="12"
Margin="0,2,0,0"
<CheckBox Content="Two-Step Verification switched on" Style="{StaticResource YtCheckBox}"
FontSize="12" Margin="0,2,0,0"
IsChecked="{Binding Ypp.TwoFactorEnabled}"/>
<Button Background="Transparent" BorderThickness="0" Cursor="Hand" HorizontalAlignment="Left"
Command="{Binding Ypp.OpenTwoStepCommand}" ToolTip="Open Google's 2-Step Verification page">
@@ -667,8 +667,8 @@
</Button.Template>
<TextBlock Text="Set up on Google →" Foreground="#5aa7ff" FontSize="12" Margin="0,0,0,6"/>
</Button>
<CheckBox Content="AdSense account linked" Foreground="#e0e0e0" FontSize="12"
Margin="0,2,0,0"
<CheckBox Content="AdSense account linked" Style="{StaticResource YtCheckBox}"
FontSize="12" Margin="0,2,0,0"
IsChecked="{Binding Ypp.AdSenseLinked}"/>
<Button Background="Transparent" BorderThickness="0" Cursor="Hand" HorizontalAlignment="Left"
Command="{Binding Ypp.OpenEarnCommand}" ToolTip="Open the YouTube Earn page">
+1 -1
View File
@@ -12,7 +12,7 @@ See [`schema.md`](../schema.md) for the memory-map conventions.
| `TopBar.xaml(.cs)` | Row 0 session surface (2026-09-22 redesign): brand + gear menu (left), one segmented REC|ON-AIR mode switch + world actions (Record: Start Recording · Stream: Go Live + Test) + reality line while running (dot/word/elapsed) + End (center), account zone — Sign In or avatar w/ Change Account/Logout context menu (right). `RefreshAvatar()`, `GearButton_Click` |
| `PreviewPane.xaml(.cs)` | Row 1 Col 1 center: the 1920×1080 `OverlayCanvas`/`SelectionOverlay`/`BrandFlashLayer`/`SocialBarElement`, text pull-out drawer, and the live-controls row (TRAX / game / mic meters + sliders + speakers + socials). **TASK 57:** dragging a webcam under the vertical tier routes through `MainViewModel.MoveWebcamInVerticalTier` (the six-spot snap inside the cropped band) instead of free-floating over the master, and a resize re-snaps the tile onto its spot (TASK 57 Unit 2: the resize routes through `MainViewModel.ResizeWebcamInVerticalTier` — the Chat camera's two sizes, then the snap) |
| `SceneThumbnailStrip.xaml(.cs)` | Row 0 scene-picker strip (`ItemsControl` over `Scenes`), staged-red / live-green border highlight |
| `LeftPanel.xaml(.cs)` | Row 1 Col 0 left panel: edit mode → layers+properties; live → YouTube chat. Source add/edit/remove, list drag-to-reorder, snapshot revert-accept. **TASK 51:** the webcam properties carry the Background section — `RemoveBackgroundPill` / `BlurBackgroundPill` (`PillToggle`, radio-exclusive via `WebcamSceneConfig.RemoveBackground`/`BlurBackground`) + `BackgroundStrengthSlider` (0..1, 5% snaps, shown only while an effect is on). **TASK 57:** the webcam properties carry a Geometry section (X/Y/W/H) — manual placement of the camera, bound to the **live** placement (the one the current tier shows), gated on `IsWebcamSelected` (not `SelectedElement.IsWebcam` — the TASK 51 test finds that panel by binding text and would match twice); the Shape button's `Visibility` is bound to `CanToggleClipShape`, so the circle option is NOT OFFERED in the vertical Chat layout |
| `LeftPanel.xaml(.cs)` | Row 1 Col 0 left panel: edit mode → layers+properties; live → YouTube chat. Source add/edit/remove, list drag-to-reorder, snapshot revert-accept. **TASK 51:** the webcam properties carry the Background section — `RemoveBackgroundPill` / `BlurBackgroundPill` (`PillToggle`, radio-exclusive via `WebcamSceneConfig.RemoveBackground`/`BlurBackground`) + `BackgroundStrengthSlider` (0..1, 5% snaps, shown only while an effect is on). **TASK 57:** the webcam properties carry a Geometry section (X/Y/W/H) — manual placement of the camera, bound to the **live** placement (the one the current tier shows), gated on `IsWebcamSelected` (not `SelectedElement.IsWebcam` — the TASK 51 test finds that panel by binding text and would match twice); the Shape button's `Visibility` is bound to `CanToggleClipShape`, so the circle option is NOT OFFERED in the vertical Chat layout. **TASK 58:** every drop-down, check box and field in the properties panel carries the theme's style (`YtComboBox`/`YtCheckBox`/`YtTextBoxCompact`) and paints no colour of its own — the guard is `ytLive.Tests/PropertiesStyleTests.cs`. **TASK 59:** the webcam's properties are a HIERARCHY — a `Shape` title owns the mirror switch and the shape choice (`Rect` | `Circle` as two `SegmentToggle` options, Rect selected by default, the circle option hidden where `CanToggleClipShape` is false), and every titled block (Geometry, Border Color, Border Thickness, Background) owns its content one indent deeper (Strength sits under Blur). **TASK 60:** the border-colour drop-down builds its menu from `Models/BorderColorPalette.cs` — each row is the hex plus the colour's NAME (`#e94560 Crimson`, tooltip "Crimson (#e94560)"); the swatch icon is unchanged. **TASK 61:** the four geometry fields (X/Y/W/H) share one fixed width (44px — the largest value, 1920, plus a sign) and their rows set `LastChildFill="False"`, so Y/H no longer stretch to fill the row. **TASK 62:** the border-colour field shows the palette's name beside the hex (`SelectedElement.BorderColorName`, empty for a custom hex) and keeps a fixed width so the name stays next to the hex. **TASK 63:** the border colour is picked with the **app's own drop-down** (`YtComboBox`) — the `...` button and its code-built `ContextMenu` are gone; the closed box shows the swatch + the colour's NAME, the rows show swatch + hex + name (the panel's own `DataTemplate`s + `Helpers/ClosedBoxTemplateSelector.cs`), and the hex field stays for a custom colour |
| `OverlayHost.xaml(.cs)` | Root-grid overlay dialogs: Settings / Report Bug / Feature Request / About (+ licenses + license-key entry). `BackToAbout_Click` lives here |
| `SceneThumbnailStrip.xaml.cs` | Code-behind for the strip above |
| `LeftPanel.xaml.cs` | Code-behind for the panel above |
+81 -9
View File
@@ -1,10 +1,13 @@
# HANDOFF — 2026-10-21 (vv webcam Units 1+2 shipped; the previous HA-lights session's state below)
# HANDOFF — 2026-10-22 (the creator's properties-panel asks shipped, incl. TASK 63, and pushed; the vv webcam Units 1+2 state below)
## At a glance
- **Branch** main, tracking `origin/main`. **origin/main = `3a77662` = HEAD — up to date, nothing
waiting.** The vv feature (Units 1+2) was **pushed at the feature close** (2026-10-21 — the
creator approved the Unit 1 push after testing it, and the completed feature is a milestone).
Push only at sub-milestones/milestones, at the creator's word.
- **Branch** main, tracking `origin/main`. **The six-commit properties-panel batch is PUSHED**
(2026-10-22, as a sub-milestone): TASK 58 the theme sweep, TASK 59 the properties hierarchy,
TASK 60 the colour names, a docs commit, TASK 61+62 the geometry field widths and the colour
name beside the hex, and TASK 63 the border-colour picker as the app's own drop-down — the
creator's properties-panel asks, done in one batch. `origin/main` is now `987fdda`.
- The vv feature (Units 1+2) **was** pushed at its close (2026-10-21 — the creator approved
the Unit 1 push after testing it, and the completed feature is a milestone).
- **The push path changed:** `origin` is now
`https://llgit.llamachile.tube/gramps/LlamaCasty.git` (HTTPS + the credential helper
`/home/gramps/bin/gitea-credential`, which reads the password from the gitignored
@@ -18,6 +21,55 @@
are `ytLive.Tests/VerticalWebcamLayoutTests.cs` (Unit 1) and
`ytLive.Tests/VerticalChatCameraLayoutTests.cs` (Unit 2). **Only the creator's hands-on check is
left** — see the vv section.
- **TASK 58 — the properties panel now wears the theme** (shipped 2026-10-21). The creator's
"why is the drop-down not in the same style as the others?" was a real defect: those drop-downs
(and every check box, and every properties field) had been hand-painted with a `Background`/
`Foreground` instead of a style, so WPF drew the **light system template** on top of the dark
panel. Now: `ComboBox` → `YtComboBox`, `CheckBox` → `YtCheckBox` (new), properties fields →
`YtTextBoxCompact` (new, BasedOn `YtTextBox`). Guard:
`ytLive.Tests/PropertiesStyleTests.cs`.
- **TASK 59 — the properties panel is a hierarchy** (shipped 2026-10-21). A title owns its
content and the content is indented one level (10px): `Shape` (new title) over the
Mirror switch and the shape choice, the drag hint indented under that row, Geometry /
Border Color / Border Thickness as titles with their fields indented, Background's
Remove + Blur indented, and Strength one level deeper under Blur. **The shape became a
CHOICE**: `Rect` | `Circle` as two segment options with **Rect selected by default**
(the old single button showed the *next* action, "Round"). The mutual exclusion lives
in the model (`IsRectShape`/`IsRoundShape` on `SceneElement`) — a WPF `RadioButton`
group would kill the bindings. Guard: `ytLive.Tests/PropertiesHierarchyTests.cs`.
- **TASK 60 — the border-colour drop-down names every colour** (shipped 2026-10-21). The creator:
*"the #hex values are good, but add a corresponding color name/label next to the value"* — each row
now reads `#e94560 Crimson` (the name dimmed, the tooltip "Crimson (#e94560)"). The 14 colours
moved out of the click handler into `Models/BorderColorPalette.cs` (`NamedColor(Hex, Name)` +
`NameOf(hex)`), so the list is testable and extensible. Guard:
`ytLive.Tests/BorderColorPaletteTests.cs`.
- **TASK 61 + 62 — the geometry fields and the border-colour field** (shipped 2026-10-21).
- **61:** the X/Y/W/H fields share ONE fixed width (44px — the largest geometry value, 1920,
plus a sign) and their rows set `LastChildFill="False"`, so Y/H no longer stretch to fill
the row while X/W sized themselves to their text. Guard: `ytLive.Tests/GeometryFieldWidthTests.cs`
(it MEASURES the widest value with WPF's `FormattedText` — measure, don't guess).
- **62:** the border-colour field shows the palette's **name beside the hex**
(`SceneElement.BorderColorName`, empty for a custom hex), and the hex field keeps a fixed
width so the name stays next to the hex. The field stays editable (a custom hex can
still be typed). Guard: `ytLive.Tests/BorderColorNameTests.cs`.
- **TASK 63 - the border-colour picker IS the app's drop-down** (shipped 2026-10-21). The creator:
*"why does the drop down under the web cam properties not show the same style for the border
color drop down list box?"* and *"we established look-and-feel standards. And yet you keep
ignoring these and I'm wondering why."* **Root cause of the miss:** the TASK 58 sweep looked for
control **types** (`ComboBox`/`CheckBox`/`TextBox`) without a theme style - and the border picker
was NONE of those: it was a `...` `Button` whose `Click` handler built a `ContextMenu` in code.
It was never in the sweep's scope, and `PropertiesStyleTests.cs` (a type-based guard) could not
see it either. Now: the picker is a **`YtComboBox`** bound to the palette
(`SelectedElement.BorderColorOptions` / `BorderColorEntry`), the **closed box shows the swatch +
the colour's NAME**, the **rows show swatch + hex + name**, the hex field stays for a custom
colour, the swatch is painted by the theme's `HexToBrush` converter, and the hand-built menu +
`WebcamBorderColorPicker_Click` are **gone**. The closed box != the rows only through
`Helpers/ClosedBoxTemplateSelector.cs` (WPF copies `ItemTemplate` into the read-only
`SelectionBoxItemTemplate`; the selector is told them apart by the `ContentPresenter`'s
templated parent - `ComboBox` = closed box, `ComboBoxItem` = a row). Guard:
`ytLive.Tests/BorderColorPickerTests.cs` (model + panel + the **real widget**). The TASK 60
guard (`BorderColorPaletteTests`) was updated to guard the new wiring (the drop-down is bound to
the palette; no second list of colours anywhere).
- **The previous session (HA lights) produced NO ytLive code** — it was spent on the creator's Home
Assistant light alert system (off-repo, `192.168.50.145`). See `MyMistakes.md` → 2026-10-21: the plan
was one tweak to a verified-working script; the script was rewritten twice, the alert path broke, and
@@ -153,15 +205,32 @@ Zombie entities deleted from the registry: `script.leak_strobe`, `script.doorbel
the full top area and the circle option is not offered. **PASSES.**
## Next step
- **Creator check of TASK 63 (needs the creator's eyes)** — open Live → Layers → webcam →
Properties → Border Color: the drop-down must now LOOK like the Display/Font drop-downs (same
dark themed chrome, same chevron), the CLOSED box must show the colour's name beside the hex
field ("Crimson"), and the open list must show swatch + hex + name. The hex field still accepts a
typed colour (the drop-down then shows the swatch with no name — "no name" is the honest answer).
- **Creator check (needs the creator's hands)** — the last piece of the vv feature: switch the
resolution dropdown to "Vertical 1080p60", confirm the Live camera tile lands inside the band and
the Shape button toggles circle/rect there, open the **Chat** scene and confirm the Shape button is
**gone** (rect only) and that dragging the tile up and enlarging it fills a whole 607×540 area, then
the **Circle** option (TASK 59 turned the Shape button into a Rect | Circle choice) turns the tile
into a circle there, open the **Chat** scene and confirm the **Circle** option is **gone** (rect only)
and that dragging the tile up and enlarging it fills a whole 607×540 area, then
switch back and confirm the desktop layout is untouched. The unit tests cover the geometry, not the
eyeball.
- Nothing else is queued for llamacasty — the creator queues work.
## Open (operational)
- **Follow-up (not fixed — out of scope for TASK 61):** the **web source** block's geometry rows in
`Controls/LeftPanel.xaml` (the `SelectedElement.IsWebSource` panel, ~lines 387-402) have the
identical shape and the identical defect as the webcam's did — the Y/H fields stretch to fill the
row. Same one-line-per-field fix as TASK 61, when the creator wants it.
- **✅ FIXED in TASK 63:** the border-colour swatches now carry a 1px `BorderBrush="#2a3a5e"`, so
the black (`#000000`) and dark swatches are visible against the dark menu (`#16213e`).
- **Follow-up spotted while doing TASK 63 (not fixed — out of scope for that change):** the theme has
**no implicit `Button` style**, so every plain `<Button>` in the app still renders with the light
WPF system template (`Controls/LeftPanel.xaml` ×7, `Controls/CameraPickerDialog.xaml` ×2). The
border picker's `...` button was one of them and is gone; the rest are still there. A theme-level
implicit `Button` style would fix them all at once — that is a creator-queued task, not a silent edit.
- **GOG Galaxy launcher shows "offline" (2026-10-21) — GOG's own overnight outage, NOT Windows.** GOG
put `www.gog.com` behind a maintenance page (503) and their Fastly services for `auth.gog.com` /
`api.gog.com` / `remote-config.gog.com` return `500 "Fastly error: unknown domain"`; the page itself
@@ -186,8 +255,11 @@ Zombie entities deleted from the registry: `script.leak_strobe`, `script.doorbel
- TASK 55 disk hygiene (`startup.log` uncapped — 23MB and growing every 5s).
## Tests
- Last full run: **429/430 passed, 0 warnings** (2026-10-21, this session — the vv Unit 2 gate run).
The one failure was `LayerReorderPersistenceTests.RealMouseDrag_OnTheLayerList_PersistsTheReorder`,
- Last full run: **435/435 passed, 0 warnings** (2026-10-21, this session — after TASK 63's
`BorderColorPickerTests`, which replaced TASK 62's `BorderColorNameTests`; the run before was also
435/435 after TASK 61+62, and the runs before that were 433/433 after TASK 60, 432/432 after
TASK 59 and 431/431 after TASK 58). The one failure seen earlier in the
session was `LayerReorderPersistenceTests.RealMouseDrag_OnTheLayerList_PersistsTheReorder`,
which **passes in isolation** (re-run: 3/3) — the known real-mouse flake, not a regression.
- Known flaky-by-environment (all pass in isolation): `AudioPipelineTests.Mix_HonorsProviderGains…`,
`MasterLimiter_CapsTheLiveMix_OnThePipe`, `LayerReorderPersistenceTests.RealMouseDrag…`,
+33
View File
@@ -0,0 +1,33 @@
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
namespace ytLive.Helpers;
/// <summary>Gives a ComboBox's CLOSED box a different template from its drop-down
/// rows (TASK 63).
///
/// <para>WPF asks an <see cref="ItemTemplateSelector"/> with the ContentPresenter's
/// templated parent: the <see cref="ComboBox"/> itself when it renders the closed
/// selection box, the <see cref="ComboBoxItem"/> when it renders a row. That is the
/// only way to tell the two apart — <c>ComboBox.SelectionBoxItemTemplate</c> is
/// read-only, and WPF copies <c>ItemTemplate</c> into it, so a ComboBox otherwise
/// cannot show one thing when shut and another when open.</para>
///
/// <para>Verified against WPF on .NET 8 (2026-10-21): with the rows' presenter the
/// templated parent is the ComboBoxItem, with the closed box's it is the ComboBox.</para>
/// </summary>
public sealed class ClosedBoxTemplateSelector : DataTemplateSelector
{
/// <summary>The template for the closed box — what the creator reads while the
/// drop-down is shut.</summary>
public DataTemplate? ClosedTemplate { get; set; }
/// <summary>The template for the drop-down rows.</summary>
public DataTemplate? RowTemplate { get; set; }
public override DataTemplate? SelectTemplate(object item, DependencyObject container)
=> container is FrameworkElement { TemplatedParent: ComboBox }
? ClosedTemplate
: RowTemplate;
}
+32
View File
@@ -0,0 +1,32 @@
using System;
using System.Globalization;
using System.Windows.Data;
using System.Windows.Media;
namespace ytLive.Helpers;
/// <summary>Turns a "#RRGGBB" hex string into a brush, so a template can paint a colour
/// swatch straight from the palette (TASK 63).
///
/// <para>Without it the UI would have to hand-build a brush in code — the hand-painted
/// approach the theme forbids. A colour is data; the theme paints it.</para></summary>
public class HexToBrushConverter : IValueConverter
{
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
if (value is not string hex || string.IsNullOrWhiteSpace(hex))
return Brushes.Transparent;
try
{
var colour = (Color)ColorConverter.ConvertFromString(hex.Trim());
return new SolidColorBrush(colour);
}
catch (FormatException)
{
return Brushes.Transparent;
}
}
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
=> throw new NotSupportedException("A brush is not a hex value.");
}
+2
View File
@@ -16,6 +16,8 @@ Cross-cutting utilities. See [`schema.md`](../schema.md) for the memory-map conv
| `CameraConflictProbe.cs` | Best-effort diagnostic: when a camera won't start, enumerates running processes known to hold cameras (OBS, Zoom, Teams, NVIDIA Broadcast, browsers, etc.) — Windows doesn't expose "which process has this device" via any public API, so this is a suspect list, not a verdict |
| `StripPool.cs` | Four DEDICATED background threads at `BelowNormal` priority that split one composite into horizontal row strips (the caller runs a strip too); started lazily, runs inline below 64 rows. UNIT 5 — do NOT demote the ThreadPool from `Parallel.For`, it leaks into unrelated work (see ai.md "Render budget") |
| `FrameCadence.cs` | Pure arrival-cadence counter (`FrameCadence` + `FrameCadenceSummary.Describe`): frames, gaps, hitches, longest/avg gap for a window of a timestamped stream. Fed by `MediaCaptureFrameSource` (camera stamps vs our arrivals) and `FramePump` (distinct webcam Epochs that reached the output) — UNIT 0, see ai.md "Frame cadence" |
| `HexToBrushConverter.cs` | A "#RRGGBB" hex string → `SolidColorBrush` (transparent on bad input), so a template can paint a colour swatch straight from the palette. Registered in the theme as `HexToBrush` — the theme paints the colour, never the UI code (TASK 63) |
| `ClosedBoxTemplateSelector.cs` | An `ItemTemplateSelector` that gives a ComboBox's CLOSED box a different template from its drop-down rows: WPF calls it with the `ContentPresenter`'s templated parent — the `ComboBox` for the closed box, the `ComboBoxItem` for a row. The only way to tell them apart (`SelectionBoxItemTemplate` is read-only and mirrors `ItemTemplate`) — TASK 63 |
Related: [`Themes/Controls.xaml`](../Themes/Controls.xaml) styles the lists this
class backs; [`Models/index.md`](../Models/index.md) and
+72
View File
@@ -0,0 +1,72 @@
namespace ytLive.Models;
/// <summary>A colour the border picker offers: its hex value and the plain-English
/// name shown next to it (TASK 60).</summary>
/// <param name="Hex">The hex value, e.g. <c>#e94560</c>.</param>
/// <param name="Name">The name the creator reads, e.g. <c>Crimson</c>.</param>
public readonly record struct NamedColor(string Hex, string Name)
{
/// <summary>"Crimson (#e94560)" — the tooltip the border drop-down shows.</summary>
public override string ToString()
=> string.IsNullOrEmpty(Name) ? Hex : $"{Name} ({Hex})";
}
/// <summary>The colours the webcam's border-colour drop-down offers — the SINGLE
/// source of truth for the list and for the names.
///
/// <para>TASK 60 (the creator, 2026-10-21): *"in the drop down box, the #hex values are
/// good, but add a corresponding color name/label next to the value."* The list used to
/// be a hard-coded array in the click handler, which is why a name could not be added
/// to it without touching the UI code.</para>
/// </summary>
public static class BorderColorPalette
{
/// <summary>The offered colours, in menu order.</summary>
public static IReadOnlyList<NamedColor> All { get; } = new NamedColor[]
{
new("#FFFFFF", "White"),
new("#000000", "Black"),
new("#FF0000", "Red"),
new("#00FF00", "Lime"),
new("#0000FF", "Blue"),
new("#FFFF00", "Yellow"),
new("#FF00FF", "Magenta"),
new("#00FFFF", "Cyan"),
new("#FF8800", "Orange"),
new("#8800FF", "Violet"),
new("#e94560", "Crimson"),
new("#22c55e", "Emerald"),
new("#3b82f6", "Azure"),
new("#f59e0b", "Amber"),
};
/// <summary>The colour's name, or an **empty string** when the palette has no name
/// for that hex (a custom hex the creator typed) — the caller decides what to show
/// then, and the border field shows the hex alone.</summary>
public static string NameOf(string hex) => EntryFor(hex).Name;
/// <summary>The palette entry for a hex value — the palette's OWN entry (so a
/// drop-down bound to it matches by value), or a nameless entry for a custom hex
/// the creator typed, so the drop-down always has a selection (TASK 63).</summary>
public static NamedColor EntryFor(string hex)
{
if (string.IsNullOrWhiteSpace(hex)) return new NamedColor(string.Empty, string.Empty);
var trimmed = hex.Trim();
foreach (var colour in All)
if (string.Equals(colour.Hex, trimmed, StringComparison.OrdinalIgnoreCase))
return colour;
return new NamedColor(trimmed, string.Empty);
}
/// <summary>The colours a border drop-down offers for a given hex — the palette, plus
/// the custom colour the creator typed when it is not one of the palette's, so the
/// drop-down always has a selection (TASK 63).</summary>
public static IReadOnlyList<NamedColor> OptionsFor(string hex)
{
var entry = EntryFor(hex);
var options = new List<NamedColor>(All);
if (!string.IsNullOrEmpty(entry.Hex) && !All.Contains(entry))
options.Add(entry);
return options;
}
}
+46 -2
View File
@@ -120,7 +120,36 @@ public abstract class SceneElement : INotifyPropertyChanged
set
{
if (Set(ref _clipShape, value))
Raise(nameof(ShapeButtonText));
{
Raise(nameof(IsRectShape));
Raise(nameof(IsRoundShape));
}
}
}
/// <summary>True when the element's clip is the plain rectangle — the shape a
/// camera has when none was ever picked (TASK 59: the properties panel shows
/// the shape as a CHOICE, and Rect is the selected default).</summary>
public bool IsRectShape
{
get => ClipShape != ClipShape.Round;
set
{
// Round -> rect (restores the pre-round rect, like the toggle does).
if (value && ClipShape == ClipShape.Round) ToggleClipShape();
Raise(nameof(IsRectShape));
}
}
/// <summary>True when the element's clip is a circle (TASK 59).</summary>
public bool IsRoundShape
{
get => ClipShape == ClipShape.Round;
set
{
// Rect -> round (stores the pre-round rect, like the toggle does).
if (value && ClipShape != ClipShape.Round) ToggleClipShape();
Raise(nameof(IsRoundShape));
}
}
@@ -170,7 +199,6 @@ public abstract class SceneElement : INotifyPropertyChanged
public double MirrorScale => IsMirrored ? -1 : 1;
public string MirrorButtonText => IsMirrored ? "Unmirror" : "Mirror";
public string ShapeButtonText => ClipShape == ClipShape.Round ? "Rect" : "Round";
// Live camera frames: the shared WriteableBitmap owned by CameraManager.
private WriteableBitmap? _videoImageSource;
@@ -236,10 +264,26 @@ public abstract class SceneElement : INotifyPropertyChanged
{
Raise(nameof(HasBorder));
Raise(nameof(BorderBrush));
Raise(nameof(BorderColorEntry));
Raise(nameof(BorderColorOptions));
}
}
}
/// <summary>The border colour as the drop-down sees it: the palette's entry for the
/// current hex — with its NAME — or a nameless entry for a custom hex the creator
/// typed. Setting it writes the hex back (TASK 63).</summary>
public NamedColor BorderColorEntry
{
get => BorderColorPalette.EntryFor(BorderColor);
set => BorderColor = value.Hex;
}
/// <summary>The colours the border drop-down offers — the palette, plus the custom
/// colour the creator typed (so the drop-down always has a selection) (TASK 63).</summary>
public IReadOnlyList<NamedColor> BorderColorOptions
=> BorderColorPalette.OptionsFor(BorderColor);
private double _borderOpacity = 1.0;
public double BorderOpacity
{
+3 -2
View File
@@ -7,12 +7,13 @@ Plain data types. No logic beyond what a property can carry. See
|------|---------|
| `Scene.cs` | A scene: `Name`, `IsHidden`, `IsChatScene`, `IsEditing`, **`HasBackdrop`** (Live-only policy flag, default off — only the canonical Live scene has one, see `SceneCatalog`), `Elements` collection (images + webcam config), `WebcamConfig` accessor |
| `SceneCatalog.cs` | The five canonical scenes (Starting/Live/BRB/Chat/Ending) — the product, by name: `All`, `IsCanonical`, `Is(name, canonical)` (case-insensitive trim), `IsChat`, `HasBackdrop(name)` (true only for Live). Drives the empty-DB seed, the "+" missing-scenes menu, and `MainViewModel.EnforceBackdropPolicy` |
| `Source.cs` | A source: `SourceType` enum (DisplayCapture/WindowCapture/Background/Image/TextOverlay/ChatBox/Countdown/WebSource/MediaSource/**AlertBox (TASK 43 — the celebration zone, one per layout)** — **Webcam removed**), `X`/`Y`/`Width`/`Height`, `Opacity`, `IsEnabled`, `ImageSource` (static) / `VideoImageSource` (live frames), `DisplaySource` (`IsLiveCapture` → video, else static), `ClipShape` enum (Traditional/Round), `IsMirrored` + `MirrorScale`, `MirrorButtonText`/`ShapeButtonText`, asset identity, `IsImageSource` (XAML binds this, never `Type`), **backdrop surface**: `IsBackdrop` (permanent bottom layer, never removable), `CaptureKey` (persisted `monitor:<n>`/`window:<hwnd>`/`picker:<name>`), `IsLiveCapture` (`Type` is Display/WindowCapture) |
| `SceneElement.cs` | Base for anything placeable in a scene: shared layout/clip/mirror/border surface, `virtual IsWebcam`/`virtual IsImageSource`/`virtual IsZoomLayer` (TASK 52); `ToggleClipShape` + persisted `RectWidth`/`RectHeight` (pre-Round rect so round→rect restores after reload); **TASK 57:** the geometry setters (`X`/`Y`/`Width`/`Height`) are `virtual` so a derived element can CONSTRAIN every write (the webcam clamps itself into the vertical tier's band) instead of every writer re-implementing the rule; `ClipShape` + `ToggleClipShape()` are `virtual` too, so a derived element can CONSTRAIN the shape (the Chat scene's vertical camera is rect-only) |
| `Source.cs` | A source: `SourceType` enum (DisplayCapture/WindowCapture/Background/Image/TextOverlay/ChatBox/Countdown/WebSource/MediaSource/**AlertBox (TASK 43 — the celebration zone, one per layout)** — **Webcam removed**), `X`/`Y`/`Width`/`Height`, `Opacity`, `IsEnabled`, `ImageSource` (static) / `VideoImageSource` (live frames), `DisplaySource` (`IsLiveCapture` → video, else static), `ClipShape` enum (Traditional/Round), `IsMirrored` + `MirrorScale`, `MirrorButtonText`, asset identity, `IsImageSource` (XAML binds this, never `Type`), **backdrop surface**: `IsBackdrop` (permanent bottom layer, never removable), `CaptureKey` (persisted `monitor:<n>`/`window:<hwnd>`/`picker:<name>`), `IsLiveCapture` (`Type` is Display/WindowCapture) |
| `SceneElement.cs` | Base for anything placeable in a scene: shared layout/clip/mirror/border surface, `virtual IsWebcam`/`virtual IsImageSource`/`virtual IsZoomLayer` (TASK 52); `ToggleClipShape` + persisted `RectWidth`/`RectHeight` (pre-Round rect so round→rect restores after reload); **TASK 57:** the geometry setters (`X`/`Y`/`Width`/`Height`) are `virtual` so a derived element can CONSTRAIN every write (the webcam clamps itself into the vertical tier's band) instead of every writer re-implementing the rule; `ClipShape` + `ToggleClipShape()` are `virtual` too, so a derived element can CONSTRAIN the shape (the Chat scene's vertical camera is rect-only); **TASK 59:** `IsRectShape`/`IsRoundShape` — the shape as a CHOICE (the properties panel shows Rect | Circle with Rect selected by default), each a settable bool whose setter flips the shape through `ToggleClipShape()` and always re-raises, so the UI snaps back when a write is refused; **TASK 63:** `BorderColorEntry` (the palette entry for the current border colour — hex + NAME, a nameless entry for a custom hex; setting it writes the hex back) and `BorderColorOptions` (the palette + that entry), both raised when `BorderColor` changes — the drop-down binds to them |
| `VerticalCameraLayout.cs` | **TASK 57 (the "vv" feature):** the vertical (9:16) tier's camera rules as pure math — `TileWidth`/`TileHeight` (270×152 master px = 480×270 in the 1080×1920 output), `Inset` (32 master px from the band's outer edge), `IsVerticalTier(QualityOption)` (portrait tier), `AreaFor(band, bottom)` (the band splits into two 607×540 areas), `MaxSizeFor(band)`, `SpotFor(band, w, h, centreX, centreY)` (the six spots — the area and column the tile's **centre** falls in, inset, never outside the band) and `DefaultSpot` (bottom-right, the corner the 16:9 default webcam uses), `FillsArea(band, width)` (the camera is wide enough to fill a whole area = "enlarged") and `SizeFor(band, requestedWidth)` (the Chat camera's TWO sizes - the small tile or one whole 607×540 area — whichever is nearer the requested width, so the resize handle has two detents) |
| `ZoomSceneElement.cs` | **Transient zoom box (TASK 52, creator-corrected 2026-10-08):** carries the box rect AND the anchored `CaptureX`/`CaptureY` (the fixed box/2 capture window set on `ActivateZoom`; moving the box never touches it). `IsZoomActive` flips `Kind` Static→Dynamic (leaves the baked base while active); never persisted (LayoutStore save skips non-Source/non-WebcamSceneConfig rows), never shown in the layers list or the element template (`IsZoomLayer` gates both). The compositor SKIPS the active element in every layer loop and draws its 2x content LAST (layer n+1) via `ZoomMagnifier`. |
| `Webcam.cs` | Singleton webcam identity: `Id`, `DeviceId`, `Name` (one row app-wide) |
| `BackgroundEffect.cs` | **TASK 51:** per-webcam background effect enum `None` / `Remove` / `Blur`, carried by `WebcamSceneConfig` |
| `BorderColorPalette.cs` | **TASK 60:** the colours the webcam's border-colour drop-down offers — `NamedColor(Hex, Name)` + `BorderColorPalette.All` (the single source of truth; the drop-down is bound to it) and `NameOf(hex)` (case-insensitive; **empty** for a colour outside the palette — "no name" is the honest answer, TASK 62). The creator asked for a colour NAME next to each hex: `#e94560 Crimson`. **TASK 63:** `EntryFor(hex)` (the palette entry for a hex, case-insensitively; a nameless entry for a colour outside the palette) and `OptionsFor(hex)` (the palette + that entry) — the two the drop-down binds to; `NamedColor.ToString()` is `"Crimson (#e94560)"` |
| `WebcamSceneConfig.cs` | Per-scene webcam placement (subclass of `SceneElement`): geometry + `IsVisible` + border (`BorderColor`/`BorderOpacity`/`BorderWidth`/`BorderAnimation`) + `VideoImageSource`; `WebcamId` links to `Webcam`; **TASK 51:** `BackgroundEffect` + `BackgroundStrength` (0..1, default 0.5) + the radio-pill surface `RemoveBackground`/`BlurBackground` (one on turns the other off; both off = None) + `HasBackgroundEffect` (shows the strength slider); **TASK 57 (the "vv" feature):** the vertical (9:16) tier's OWN placement per scene — `VerticalX/Y/Width/Height/VerticalClipShape/VerticalRectWidth/VerticalRectHeight` (persisted, NULL = no vertical placement yet) + the transient `VerticalBand`/`VerticalLayoutLive`/`LandscapeX…LandscapeRectHeight` (the parked 16:9 layout) + `BeginVerticalLayout`/`EndVerticalLayout`/`CommitLiveLayoutToVertical` (the tier swap) and the `X`/`Y`/`Width`/`Height` overrides that clamp every write into the band; `VerticalShapeLocked` (the Chat scene's vertical layout is rect-only) **overrides** `ClipShape` + `ToggleClipShape()` so no writer can slip a circle in |
| `QualityOption.cs` | Resolution tier record `(Display, Fps, Bitrate, Width, Height)`; `Label` formats as `1080p60 (8Mbps)` — drives the bottom-bar dropdown and the output-rect math |
| `StreamConfig.cs` | `StreamConfig` (note: `TargetBitrate`/`Resolution` defaults are stale — not yet wired to the dropdown), `StreamHealth` (bitrate/FPS/dropped/duration), `StreamStatus` enum |
+196
View File
@@ -2078,3 +2078,199 @@ that does not contain a dash (e.g. the `if (...) return false;` line instead of
it), and put the new prose in the `newString` where any character is fine. When a whole stale block
must go (e.g. half-overwritten comments), a three-line `python3` script that deletes the exact line
range is faster and safer than fighting the matcher.
## 2026-10-21 — A WPF CONTROL WITH NO STYLE DRAWS THE LIGHT SYSTEM CHROME (RECIPE — TASK 58)
The creator asked why the Stream Alerts "Display" drop-down looked like it belonged to another
app. The cause is not a colour: **setting `Background`/`Foreground`/`FontSize` on a WPF control
changes its paint, not its TEMPLATE.** An unstyled `ComboBox` still renders the system template —
grey border, system arrow, **white popup list** — so a dark blue box sat on the dark panel
wearing light-system chrome. Unstyled `CheckBox` is the same story.
**The recipe (audit + fix + guard):**
1. **Audit by grep, not by eye:** `grep -rn "<ComboBox\|<CheckBox" --include="*.xaml"` and look at
each one's `Style` attribute. Hand-painted colours are the smell — they mean "no style".
2. **Fix by style, never by more painting:** put the look in `Themes/Controls.xaml` once
(`YtComboBox`, `YtCheckBox`, a `BasedOn` compact variant when the panel is dense) and delete
the per-control colours. A dense panel needs a *variant* of the style, not a re-painted control.
3. **Guard it with a test** that parses the XAML and fails when a control of that type has no
style — otherwise the next hand-painted control walks the look back.
4. **Check the inherited defaults while you are there:** a compact `BasedOn` variant must set the
metrics it overrides (`FontSize`, `Padding`, `MinHeight`) or the panel silently changes density.
**Side effect worth naming:** the sweep also exposed a typo'd foreground (`#e0d0e0` instead of
`#e0e0e0`) that only a single source of truth can prevent from recurring.
## 2026-10-21 — A CHOICE IN WPF: MUTUAL EXCLUSION BELONGS IN THE MODEL, NOT IN A RADIOBUTTON GROUP (RECIPE — TASK 59)
A "pick one of these" control (Rect | Circle) bound in WPF has two classic traps, both
of which break the panel silently — the control keeps showing what it last showed while
the model says something else.
**Trap 1 — the RadioButton group kills the binding.** A `RadioButton` group unchecks its
siblings by setting `IsChecked` **in code** (`RadioButton.UncheckRadioButton` →
`SetCurrentValueInternal`). WPF sees the target property being set outside the binding
and **drops the binding** — the options stop reflecting the model for good
("Losing the binding for radio button after I set the property manually in code",
stackoverflow 7376373; the same trap in a template, 57275046).
**Trap 2 — a ToggleButton unchecks itself when clicked while checked.** A plain
`ToggleButton` (unlike `RadioButton`) toggles `IsChecked` off when you click it while it
is on. With a TwoWay binding the model receives `false` — and if the model refuses that
write, the UI must be told again, or the option stays visibly unchecked while the model
holds the truth.
**The recipe:**
1. **Give the model one bool per option** (`IsRectShape`, `IsRoundShape`) — each
settable, each doing the real work through the existing `Toggle…()` path so the
side-effects (store/restore the pre-round rect) are not duplicated.
2. **The setter ALWAYS re-raises** its own property name (`Raise(nameof(IsRectShape))`),
even when the write changed nothing. The binding re-reads the getter and the UI snaps
back to the model's truth — that is what makes a refused write (a locked shape)
visible instead of sticky.
3. **Raise BOTH option properties whenever the underlying value changes** (in the
`ClipShape` setter), so the pair can never disagree on screen.
4. **Do NOT use a `RadioButton` `GroupName`** for a bound pair. Two plain
`ToggleButton`s (styled as segments) with model-side mutual exclusion keep their
bindings alive (stackoverflow 50980687 measures the same behaviour with
`IsChecked` bound to separate bools).
5. **Show the CURRENT state, not the next action.** A button labelled "Round" when the
camera is a rectangle tells the user what will happen; a selected "Rect" option tells
them what IS. The creator called this out: *"Rect should be selected as a default if
none exists."*
**Bonus:** the same "title owns its content, content is indented one level" rule applies
to any dense settings panel — one indent level per ownership level, two when a title is
itself nested (Strength under Blur under Background).
## 2026-10-21 — A LIST THE CREATOR MAY WANT TO EXTEND BELONGS IN A TESTABLE CATALOGUE (RECIPE — TASK 60)
The creator asked for one thing ("add a colour name next to the hex") and the code could not
even express it: the drop-down's colours were a `string[]` **inside the click handler**. A
list of bare strings cannot carry a name, and a list hidden in UI code cannot be asserted on
without reading the UI code.
**The recipe (catalogue → UI consumer):**
1. **Move the list out of the handler into a plain data type** — `NamedColor(Hex, Name)` +
`static IReadOnlyList<NamedColor> All` in `Models/`. Models = "plain data types, no logic
beyond what a property can carry" (see `Models/index.md`).
2. **Give it a lookup** (`NameOf(hex)`, case-insensitive) that answers for a value the user
typed, not only for the palette's own entries. **What it answers for a colour OUTSIDE the
palette is a judgement call, and it changed once (TASK 62):** it used to fall back to the
value itself, so the UI never showed an empty name — but "no name" is the honest answer, and
the caller decides what to show (the border field shows the hex alone for a custom hex). The
fallback that lies is worse than the empty string.
3. **The UI becomes a consumer**: the handler iterates the catalogue and builds the rows. The
handler must not know any colour by heart.
4. **Guard it in the test**: every entry valid + named, no hex or name repeated, the colours
that used to be offered are still offered (a name is added, never a colour dropped), the
lookup answers, and **the old list is gone from the handler** (a source-level assertion:
`Assert.False(source.Contains("\"#FFFFFF\""))`).
5. **Name the colours so they do not collide** — the palette had two greens, two blues and two
warm colours, so `#00FF00` is **Lime** and `#22c55e` **Emerald**, `#0000FF` **Blue** and
`#3b82f6` **Azure**, `#FF8800` **Orange** and `#f59e0b` **Amber**. A name that repeats
another's meaning is worse than no name.
**Why this matters beyond names:** the next request ("add a dark mode", "let me type a
custom colour", "reorder them") is now a change in ONE file instead of a change inside a
click handler.
## 2026-10-21 — A ROW OF SAME-KIND FIELDS NEEDS EXPLICIT WIDTHS (RECIPE — TASK 61)
The creator: *"the Y and H boxes should be the same width as the X and W boxes and those
widths should be fixed to accomodate the max number of possible digits."* The row was a
`DockPanel`, and WPF gave the four fields **two different widths** for no reason anyone chose.
**The trap (worth remembering on its own):**
- A WPF element **sizes itself to its content** unless it is told otherwise. So the X/W fields
(not the last child) were as wide as their text ("1920" ≈ 34px).
- In a `DockPanel` the **LAST child fills the remaining space** — so the Y/H fields stretched to
the full width of the row. Two widths for the same kind of field, purely by position.
**The recipe:**
1. Give **every** field in the row an explicit `Width` (one value, repeated — the point is that
they are EQUAL).
2. Set `LastChildFill="False"` on the row, or the last field ignores its own `Width` and fills
anyway.
3. **Measure the width, don't guess it.** The needed width = the widest value the field can
hold, measured in the app's own font at the field's size, plus the field's padding and
border. WPF measures it for you, headlessly, with no window:
```csharp
var typeface = new Typeface(SystemFonts.MessageFontFamily, FontStyles.Normal,
FontWeights.Normal, FontStretches.Normal);
var text = new FormattedText("-1920", CultureInfo.CurrentCulture, FlowDirection.LeftToRight,
typeface, 11, Brushes.Black, pixelsPerDip: 1.0).Width;
var needed = text + 8 /* padding */ + 2 /* border */;
```
`SystemFonts.MessageFontFamily` is exactly what a control uses when no font is set, so the
measurement matches what is drawn. (The geometry fields came out at 44px — 28 of text, 10 of
chrome, 6 of slack.)
4. **Assert the measurement in the test**, not a magic number: parse the XAML, take the widths,
and fail if the row's width cannot hold the widest value. A hard-coded "44" in the test
would pass even if the app's font changed and 44 stopped fitting.
5. **A label beside a field needs the field to be FIXED too.** The same trap in a different
costume: a field that fills its row pushes whatever sits after it to the far edge, so the
label drifts away from the value it labels (TASK 62 — the colour name beside the hex field).
## 2026-10-21 — A STYLE SWEEP THAT LOOKS AT CONTROL TYPES CANNOT SEE A HAND-ROLLED PICKER (RECIPE — TASK 63)
The creator: *"one of the first things we did was define branding. We established look-and-feel
standards. And yet you keep ignoring these and I'm wondering why"* — about the webcam's
border-colour drop-down.
**The trap:** the TASK 58 sweep had already walked the whole app for un-themed controls, and the
border picker STILL was not themed. The reason is a one-line blind spot in the sweep itself:
it asked *"does this `ComboBox`/`CheckBox`/`TextBox` carry a theme style?"* — and the border
picker was **none of those types**. It was a `Button` whose `Click` handler built a
`ContextMenu` of swatches in code. The sweep's guard (`PropertiesStyleTests.cs`) asserts control
**types**, so it could not see it either. A picker that LOOKS like a drop-down is invisible to a
type-based audit.
**The recipe:**
1. When auditing for the design system, search for **what a control DOES**, not what it IS:
grep for `Click=` handlers that open a `ContextMenu`/`Menu`/`Popup`, and for `new
ContextMenu`/`new MenuItem` in code-behind. A menu built in code is a control that never
wears the theme.
2. The guard must assert the **design system**, not the control type: for every control that
presents a CHOICE to the creator, assert it is the app's widget (`Style="{StaticResource
YtComboBox}"` etc.). "It is a ComboBox" is not the requirement; "it is the app's ComboBox" is.
3. **A hand-painted brush in code is the same smell as a hand-painted `Background`** — the theme
owns the colour. A colour is DATA: bind it and let a converter paint it
(`Helpers/HexToBrushConverter.cs`).
## 2026-10-21 — A COMBOBOX'S CLOSED BOX AND ITS ROWS CAN ONLY DIFFER THROUGH A TEMPLATE SELECTOR (RECIPE — TASK 63)
WPF copies `ComboBox.ItemTemplate` into `SelectionBoxItemTemplate`, and that property is
**read-only in C#** ("Property or indexer 'ComboBox.SelectionBoxItemTemplate' cannot be assigned
to -- it is read only"). So one `ItemTemplate` renders the SAME content in the closed box and in
every row — you cannot show "Crimson" when shut and "#e94560 Crimson" when open.
**The one mechanism that works — an `ItemTemplateSelector`.** WPF calls it with the
`ContentPresenter`'s **templated parent**:
| What is rendering | The container's `TemplatedParent` |
|---|---|
| the CLOSED selection box | the **`ComboBox`** |
| a drop-down ROW | the **`ComboBoxItem`** |
```csharp
public override DataTemplate? SelectTemplate(object item, DependencyObject container)
=> container is FrameworkElement { TemplatedParent: ComboBox } ? ClosedTemplate : RowTemplate;
```
**What does NOT work:** setting `ComboBoxItem.ContentTemplate` through an `ItemContainerStyle`.
WPF overwrites the container's `ContentTemplate` with the ComboBox's `ItemTemplate` (null) when
it prepares the container, so the style's value is wiped.
**How I found it (the probe recipe):** a throwaway xunit test in the test project (STA via the
`RealApp` collection host), a `ComboBox` in a shown `Window`, an `ItemTemplateSelector` that
logs `container.GetType().Name` + the parent's type, and a `DispatcherPriority.Loaded` pump so
the popup materialises its rows. The log `item=a,parent=ComboBox | item=a,parent=ComboBoxItem`
settled the design in one run. **Probe, then code** — this is not in any WPF book.
**Bonus measurement (the theme's popup):** the `YtComboBox` popup carries no width binding, so it
sizes to its **content**, not to the ComboBox — measured 142.7px of popup for a 100px ComboBox
whose rows read "swatch + hex + name". A long row is never clipped, and the popup may be wider
than the closed box. Measure with `ActualWidth` in a throwaway test; do not guess widths.
+1 -1
View File
@@ -25,7 +25,7 @@
Foreground="#e0e0e0" Margin="0,0,0,12"/>
<!-- Show/hide the bar on the stream -->
<CheckBox Grid.Row="1" IsChecked="{Binding BarEnabled}" Margin="0,0,0,10">
<CheckBox Grid.Row="1" IsChecked="{Binding BarEnabled}" Style="{StaticResource YtCheckBox}" Margin="0,0,0,10">
<TextBlock Text="Show the social bar on the stream" Foreground="#e0e0e0" FontSize="13"/>
</CheckBox>
+6
View File
@@ -73,6 +73,12 @@
| 55 | **Disk hygiene: log rotation + low-disk guard** | ☐ Queued (2026-10-09) — `startup.log` appears uncapped; also temp alert-clip cleanup + free-space check before recording | [`TASKS/task-55-disk-hygiene.md`](TASKS/task-55-disk-hygiene.md) |
| 56 | **Pre-publication secret scan (full git history)** | ☐ Queued (2026-10-09) — gate before the Gitea repo goes public; report commit/path only, never values | [`TASKS/task-56-secret-scan.md`](TASKS/task-56-secret-scan.md) |
| 57 | **Vertical (9:16) tier camera placement — the "vv" feature** | ✅ **Both units shipped 2026-10-21** (Unit 1 = Live scene: own placement per tier — small tile, circle-or-rect, one of six spots; drag-snap + manual entry; 16:9 restored exactly. Unit 2 = Chat scene: rect only — the circle is not offered — the tile or one whole 607×540 area) | [`TASKS/task-57-vertical-camera-layout.md`](TASKS/task-57-vertical-camera-layout.md) |
| 58 | **Properties-panel style consistency (the theme owns the look)** | ✅ Done — 2026-10-21 (the creator's "why is the drop-down not in the same style as the others?" — the drop-downs/fields/check boxes had been hand-painted instead of themed, so WPF drew the light system template; now every one carries a theme style, guarded by a test) | [`TASKS/task-58-properties-style-consistency.md`](TASKS/task-58-properties-style-consistency.md) |
| 59 | **The properties panel is a hierarchy (titles own their content)** | ✅ Done — 2026-10-21 (the creator's ruling: a "Shape" title above the Mirror/Rect row, the drag hint indented under it, the same for Geometry / Border Color / Border Thickness, Remove + Blur under Background, Strength under Blur — and the shape became a CHOICE: Rect | Circle with Rect selected by default) | [`TASKS/task-59-properties-hierarchy.md`](TASKS/task-59-properties-hierarchy.md) |
| 60 | **The border-colour drop-down names every colour** | ✅ Done — 2026-10-21 (the creator's "the #hex values are good, but add a corresponding color name/label next to the value" — the 14 colours moved out of the click handler into `Models/BorderColorPalette.cs`, and each row now reads "#e94560 Crimson") | [`TASKS/task-60-border-color-names.md`](TASKS/task-60-border-color-names.md) |
| 61 | **The geometry fields share one fixed width** | ✅ Done — 2026-10-21 (the creator's "the Y and H boxes should be the same width as the X and W boxes and those widths should be fixed to accomodate the max number of possible digits" — the Y/H fields used to stretch to fill the row; all four are now 44px, wide enough for 1920 plus a sign) | [`TASKS/task-61-geometry-field-widths.md`](TASKS/task-61-geometry-field-widths.md) |
| 62 | **The border-colour field shows the colour's name next to the hex** | ✅ Done — 2026-10-21 (the creator: "the border color drop down box content should also include the name of the color next to the hex value as they in the drop-down list box" — the field keeps the hex editable and the palette's name sits right beside it, empty for a custom hex) | [`TASKS/task-62-border-colour-name-in-the-field.md`](TASKS/task-62-border-colour-name-in-the-field.md) |
| 63 | **The border-colour picker is the app's own drop-down** | ✅ Done — 2026-10-21 (the creator's "why does the drop down under the web cam properties not show the same style for the border color drop down list box?" + "we established look-and-feel standards. And yet you keep ignoring these" — the hand-rolled `...` button and its code-built menu are gone; the border colour is now a `YtComboBox` like every other picker, the closed box shows the swatch + the colour's name, the rows show swatch + hex + name, and the hex field stays for a custom colour) | [`TASKS/task-63-border-colour-app-dropdown.md`](TASKS/task-63-border-colour-app-dropdown.md) |
---
@@ -0,0 +1,45 @@
# TASK 58 — Properties-panel style consistency (the theme owns the look)
**Status:** ✅ Done — shipped 2026-10-21.
## What it is
The creator asked (2026-10-21), pointing at the Live scene → Stream Alerts layer →
Properties → **Display**:
> why is the drop-down box not in the same style as the others? Can you run through the
> existing elements in properties to ensure that we're maintaining a consistent style?
## Why the drop-down looked alien
`Controls/LeftPanel.xaml` gave the drop-down a hand-painted `Background`/`Foreground`/
`FontSize` instead of the theme's `YtComboBox` style. Setting colours does **not** change a
control's template — WPF still drew the **light system ComboBox** (grey border, system
arrow, white popup list) on top of the dark panel. The code even said so: the alert
drop-down's comment says it was built "same shape as the chat Font selector above" — and the
chat Font drop-down was unstyled too, so the inconsistency was copied.
## The sweep (the whole properties panel, then the app)
| Control | Before | After |
|---|---|---|
| ComboBox ×2 (chat Font, alert Display) | hand-painted colours → light system template | `YtComboBox` (the same style as the bottom-bar/zoom drop-downs) |
| TextBox ×16 in the properties panel | hand-painted colours, no template, no focus/hover, one typo'd foreground `#e0d0e0` | `YtTextBoxCompact` (BasedOn `YtTextBox`: 11px, padding 4,2) |
| CheckBox ×2 (chat) | default light system check box | `YtCheckBox` (new dark style) |
| CheckBox ×3 (preview broadcast panel) + ×1 (Socials dialog) | default light system check box | `YtCheckBox` |
| TextBox (layer rename) | hand-painted colours | `YtTextBoxCompact` via `BasedOn`, borderless as before |
| Buttons, pills, sliders | already themed | unchanged |
New theme styles (`Themes/Controls.xaml`):
- `YtTextBoxCompact` — `BasedOn YtTextBox`, 11px + `Padding 4,2` + `MinHeight 22`. The
properties panel is dense (11px labels); the full `YtTextBox` (13px, 8,6) would have
doubled the panel's height.
- `YtCheckBox` — a 14×14 rounded box + tick path, accent fill when checked, hover/focus
border triggers. The default WPF check box is the light system one.
## The guard
`ytLive.Tests/PropertiesStyleTests.cs` — the ONE test for this change:
1. the theme defines `YtComboBox`/`YtCheckBox`/`YtTextBox`/`YtTextBoxCompact`;
2. every `ComboBox` and `CheckBox` in the app's XAML carries the matching theme style;
3. every field in the properties panel (`StackPanel x:Name="ElementsPanel"`) carries
`YtTextBoxCompact` and sets no `Background`/`BorderBrush` of its own.
## Tests
`ytLive.Tests/PropertiesStyleTests.cs` — 431/431 green at ship time.
+74
View File
@@ -0,0 +1,74 @@
# TASK 59 — The properties panel is a hierarchy (titles own their content)
**Status:** ✅ Done — shipped 2026-10-21.
## What it is
The creator's ruling (2026-10-21), on the Live scene → Layers → webcam → Properties:
> Mirror Rect: needs a title "Shape" above it and **Rect should be selected as a default
> if none exists** … also, the "drag to move" help text needs to be **indented under
> "Mirror Rect" to show parentage** … same with **Geometry, Border Color, Border
> Thickness**. Background → Remove: Remove should be indented under Background as should
> Blur. Blur → Strength: Strength should be indented under Blur.
One rule covers all of it: **a title owns its content, and the content is indented one
level (10px) under it.** Two levels deep when a title is itself nested (Strength under
Blur under Background).
## The panel now reads
```
Shape ← NEW title
[Mirror] [Rect] [Circle] ← the shape is a CHOICE, Rect selected by default
Drag to move · drag the corner dot to resize
Geometry ← title (already there), fields indented
X [ ] Y [ ]
W [ ] H [ ]
Border Color ← promoted to a title, the field indented
[#2c3a5c] [..]
Border Thickness ← promoted to a title, the slider indented
[slider] 3
Background ← title, its rows indented
Remove [pill]
Blur [pill]
Strength [slider] 50% ← one level deeper, under Blur
```
## The shape: a choice, not a toggle
Before: one button whose content was `ShapeButtonText` — the **next** action ("Round"
when the camera was a rectangle). The creator could not tell what the camera *was*, only
what it would become.
Now: two `SegmentToggle` options — **Rect** and **Circle** — bound to two new settable
bools on `SceneElement`:
- `IsRectShape` — true when the clip is the plain rectangle, which is what a camera is
when no shape was ever picked (**the creator's "Rect is selected by default if none
exists"**).
- `IsRoundShape` — true when the clip is a circle.
Each setter flips the shape through `ToggleClipShape()` (so the pre-round rect is still
stored/restored) and **always re-raises** — a refused write (the vertical Chat layout is
rect-only, TASK 57) makes the UI snap back to the truth instead of sticking.
`ShapeButtonText` is gone (dead code after this change).
### Why the mutual exclusion lives in the model
Not in a WPF `RadioButton` group: a RadioButton group unchecks its siblings by setting
`IsChecked` **in code**, which makes WPF drop the binding ("Losing the binding for radio
button after I set the property manually in code", stackoverflow 7376373) — the panel
would silently stop reflecting the model. Two bool properties with model-side setters keep
the binding alive (stackoverflow 50980687 / 57275046 describe the same trap).
## The circle option is still not offered where it must not be
The Circle option's `Visibility` binds to `CanToggleClipShape` — false in the vertical
tier's Chat layout (TASK 57) — so the option is not even shown there, and the model
refuses the write anyway.
## Tests
`ytLive.Tests/PropertiesHierarchyTests.cs` — the ONE test for this change:
- the model: Rect is selected by default → picking Circle makes the clip Round → picking
Rect again restores it → the vertical Chat lock still refuses the circle;
- the panel: the `Shape` title owns both options, the Circle option is the one gated by
`CanToggleClipShape`, the drag hint is indented and follows the shape row, every titled
block's content is indented 10px, and Strength sits at 20px (under Blur).
432/432 green at ship time.
+57
View File
@@ -0,0 +1,57 @@
# TASK 60 — The border-colour drop-down names every colour
**Status:** ✅ Done — shipped 2026-10-21.
## What it is
The creator (2026-10-21), on Live → Layers → webcam → Properties → **Border Color**:
> in the drop down box, the #hex values are good, but add a corresponding color name/label
> next to the value
So each row of the drop-down now reads **`#e94560 Crimson`**: the hex stays exactly as
it was, the colour's name goes next to it (dimmed, so the hex reads first), and the
tooltip becomes **`Crimson (#e94560)`**.
## The palette is the single source of truth
The 14 colours were a hard-coded `string[]` inside the click handler
(`WebcamBorderColorPicker_Click` in `Controls/LeftPanel.xaml.cs`) — a list that could not
carry a name, and a second place to edit if a colour were added. They now live in
**`Models/BorderColorPalette.cs`**:
```csharp
public readonly record struct NamedColor(string Hex, string Name);
public static class BorderColorPalette
{
public static IReadOnlyList<NamedColor> All { get; } = new NamedColor[]
{
new("#FFFFFF", "White"), new("#000000", "Black"), new("#FF0000", "Red"),
new("#00FF00", "Lime"), new("#0000FF", "Blue"), new("#FFFF00", "Yellow"),
new("#FF00FF", "Magenta"), new("#00FFFF", "Cyan"), new("#FF8800", "Orange"),
new("#8800FF", "Violet"), new("#e94560", "Crimson"),new("#22c55e", "Emerald"),
new("#3b82f6", "Azure"), new("#f59e0b", "Amber"),
};
public static string NameOf(string hex); // case-insensitive; an unknown hex stays as-is
}
```
The names are chosen to be unambiguous next to each other (the palette has two greens,
two blues and two warm colours: `#00FF00` is **Lime** and `#22c55e` **Emerald**;
`#0000FF` is **Blue** and `#3b82f6` **Azure**; `#FF8800` is **Orange** and `#f59e0b`
**Amber**).
`NameOf` also answers for a hex the creator **typed** into the field — the colours
outside the palette simply keep their hex as the "name".
## The menu row
The `MenuItem`'s `Header` is a horizontal `StackPanel` (the theme's `MenuItem` template
presents the header through a `ContentPresenter`, so any content works): the hex in the
normal foreground, the name dimmed (`#a0a0b0`) with a 10px gap. The swatch `Icon` is
unchanged.
## Tests
`ytLive.Tests/BorderColorPaletteTests.cs` — the ONE test for this change: every entry has
a valid hex and a name, no hex or name repeats, the 14 colours the picker used to offer
are all still offered, `NameOf` answers in any case and falls back to the hex, and the
picker builds its menu from the palette (no second list left in the code-behind).
+43
View File
@@ -0,0 +1,43 @@
# TASK 61 — The geometry fields share one fixed width
**Status:** ✅ Done — shipped 2026-10-21.
## What it is
The creator (2026-10-21), on Live → Layers → webcam → Properties → **Geometry**:
> the Y and H boxes under webcam properties should be the same width as the X and W boxes
> and those widths should be fixed to accomodate the max number of possible digits
> providing the geometry values
## Why they looked different
The row is a `DockPanel`:
```
[X label][X field][ Y label][Y field]
```
A WPF element **sizes itself to its content** unless it is told otherwise, and in a
`DockPanel` the **last child fills the remaining space**. So the X/W fields (not last)
were as wide as their text ("1920" ≈ 34px) while the Y/H fields (last) stretched to the
full width of the row — two different widths for the same kind of field.
## The fix
- **All four fields carry the same explicit `Width="44"`** — wide enough for the largest
geometry value (the master frame is 1920×1080 → four digits) **plus a sign** the creator
may be typing, and no wider.
- **The rows set `LastChildFill="False"`** — otherwise the last field would still stretch
to the row's width regardless of its own `Width`.
44px = the measured width of `-1920` in the app's font at 11px (≈28px) + the field's
padding (8) + its border (2) + slack.
## The test
`ytLive.Tests/GeometryFieldWidthTests.cs` — the ONE test for this change: the four
fields carry the same **explicit** width (no field sizes itself to its content), and that
width really fits the widest value — measured with WPF's `FormattedText` in the app's own
font (`SystemFonts.MessageFontFamily`) at the field's 11px, plus the padding and border.
## Not done (out of scope, noted in `HANDOFF.md`)
The **web source** block's geometry rows (`Controls/LeftPanel.xaml`, the
`SelectedElement.IsWebSource` panel) have the identical shape and the identical defect —
same fix, one line each, when the creator wants it.
@@ -0,0 +1,42 @@
# TASK 62 — The border-colour field shows the colour's name next to the hex
**Status:** ✅ Done — shipped 2026-10-21.
## What it is
The creator (2026-10-21), on Live → Layers → webcam → Properties → **Border Color**,
after TASK 60 gave the drop-down list its names:
> the border color drop drown box content should also include the name of the color next
> to the hex value as they in the drop-down list box
So the row that shows the **current** colour now reads the same way the list rows do:
```
Border Color
[#2c3a5c] Azure [...]
```
## How it is done
- **`SceneElement.BorderColorName`** — the palette's name for the current border colour,
raised whenever `BorderColor` changes. **Empty for a custom hex** the creator typed
(there is no name for it — the field shows the hex alone).
- The **hex field keeps a fixed width** (`Width="60"`): it used to fill the row, so a name
beside it would have drifted to the far edge instead of sitting next to the hex.
- The name is a dimmed `TextBlock` (`#a0a0b0`, 11px) in the same horizontal row as the
field, after it — the same reading order as the drop-down list (`#e94560 Crimson`).
**The field stays editable** — the hex is still typed into it (the picker's `...` button
opens the named list). Making the field read-only with a combined "#hex Name" string was
the alternative; it would have taken away typing a custom hex, so the name sits beside
the field instead.
## `BorderColorPalette.NameOf` changed
It used to fall back to **the hex itself** for a colour outside the palette ("there is no
name to give it"). It now returns an **empty string** — "no name" is the honest answer,
and the caller decides what to show (the field shows the hex alone). The TASK 60 test
assertion was updated with it.
## Tests
`ytLive.Tests/BorderColorNameTests.cs` — the ONE test for this change: the model carries
the name ("Crimson") and raises it when the colour changes, a custom hex has no name, the
hex field has a fixed width, and the name sits in the same row as the field, after it.
@@ -0,0 +1,71 @@
# TASK 63 — The border-colour picker is the app's own drop-down
**Status:** ✅ Done — shipped 2026-10-21.
## What it is
The creator (2026-10-21), on Live → Layers → webcam → Properties → **Border Color**:
> why does the drop down under the web cam properties not show the same style for the border
> color drop down list box?
and, with the reason:
> one of the first things we did was define branding. We established look-and-feel standards.
> And yet you keep ignoring these and I'm wondering why
So the border colour is picked with the **app's own drop-down** — the `YtComboBox` every other
picker wears — instead of a `...` button that built a `ContextMenu` in code.
## Why it was missed (the root cause, recorded so it cannot recur)
The TASK 58 sweep looked for controls **by type** (`ComboBox` / `CheckBox` / `TextBox`) that
carried no theme style. The border picker was **none of those** — it was a `Button` whose
`Click` handler built a `ContextMenu` of swatches in code. So it was never in the sweep's
scope, and `ytLive.Tests/PropertiesStyleTests.cs` could not see it either: the guard asserts
control **types**, not the design system. A style sweep must ask *"is this the app's widget?"*,
not *"is this control type themed?"*.
## How it is done
- **The picker** — a `ComboBox` with `Style="{StaticResource YtComboBox}"`, bound to
`SelectedElement.BorderColorOptions` (the palette) and `SelectedItem` to
`SelectedElement.BorderColorEntry` (TwoWay). `SelectedItem`, **not** `SelectedValue`: WPF
matches `SelectedValue` ordinally (case-sensitively) and the palette mixes `#FFFFFF` and
`#e94560`; the model matches by VALUE, so the hex the creator typed in any case still lands
on the right row.
- **The closed box shows the swatch + the colour's NAME; the rows show swatch + hex + name**
(the rows the creator approved in TASK 60). A ComboBox cannot do that with one
`ItemTemplate` — WPF copies it into the read-only `SelectionBoxItemTemplate` — so
`Helpers/ClosedBoxTemplateSelector.cs` picks the template by the `ContentPresenter`'s
templated parent (the `ComboBox` for the closed box, the `ComboBoxItem` for a row).
- **The hex field stays** (60px, editable): a colour outside the palette can still be typed,
and the drop-down then carries that hex as a **nameless** entry ("no name" is the honest
answer). The palette's name for the current colour moved out of a `TextBlock` (TASK 62's
`BorderColorName`) and into the drop-down's closed box.
- **The swatch is painted by the theme**, not by code: `Helpers/HexToBrushConverter.cs`
(registered in `Themes/Controls.xaml` as `HexToBrush`) turns the hex into a brush, and the
templates use the theme's border colour `#2a3a5e` so the black swatch is visible on the dark
popup. The hand-built menu, its hand-painted `SolidColorBrush`es and
`WebcamBorderColorPicker_Click` are **gone**.
## The model
- `SceneElement.BorderColorName` → **`BorderColorEntry`** (the palette entry for the current
hex, with its name; setting it writes the hex back) and **`BorderColorOptions`** (the
palette plus the custom entry). Both are raised when `BorderColor` changes.
- `BorderColorPalette.EntryFor(hex)` (case-insensitive; a nameless entry for a colour outside
the palette) and `OptionsFor(hex)` (the palette + that entry). `NameOf` now delegates to
`EntryFor`.
## Tests
`ytLive.Tests/BorderColorPickerTests.cs` — the ONE test for this change, in three parts:
the model (the palette entry, the custom hex, the raises), the panel (the picker IS a
`YtComboBox` over the palette, the hex field survives, the hand-rolled menu is gone from both
the XAML and the code-behind) and the **real widget** — with the app's theme style and the
panel's real templates, the closed box shows the name and the rows show the hex AND the name.
`ytLive.Tests/BorderColorPaletteTests.cs` (TASK 60) was updated: its "the picker builds its
menu from the palette" step now guards the new wiring (the drop-down is bound to the palette,
and neither the panel nor the code-behind carries a second list of colours).
## Follow-ups (not done — out of scope)
- The theme has **no implicit `Button` style**, so every plain `<Button>` in the app still
renders with the light WPF system template (`LeftPanel.xaml` ×7, `CameraPickerDialog.xaml`
×2). The border picker's `...` button was one of them; the rest are still there.
- The palette's hexes keep their mixed casing (`#FFFFFF` vs `#e94560`).
+60
View File
@@ -122,6 +122,65 @@
</Setter>
</Style>
<!-- Dense variant of YtTextBox for the properties panel (11px labels, tight
padding). Same template + colours as YtTextBox — only the metrics differ,
so the panel keeps its rhythm without hand-painting every field. -->
<Style x:Key="YtTextBoxCompact" TargetType="TextBox" BasedOn="{StaticResource YtTextBox}">
<Setter Property="FontSize" Value="11"/>
<Setter Property="Padding" Value="4,2"/>
<Setter Property="MinHeight" Value="22"/>
<Setter Property="VerticalContentAlignment" Value="Center"/>
</Style>
<!-- Dark check box. The default WPF template is the light system one — it
sticks out of the dark panels, so every check box carries this style
(the guard test in ytLive.Tests/PropertiesStyleTests.cs enforces it). -->
<Style x:Key="YtCheckBox" TargetType="CheckBox">
<Setter Property="Foreground" Value="#e0e0e0"/>
<Setter Property="FontSize" Value="11"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="HorizontalContentAlignment" Value="Left"/>
<Setter Property="VerticalContentAlignment" Value="Center"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="CheckBox">
<Grid Background="Transparent">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Border x:Name="Box" Grid.Column="0" Width="14" Height="14" CornerRadius="3"
Background="#0f3460" BorderBrush="#2a3a5e" BorderThickness="1"
VerticalAlignment="Center">
<Path x:Name="Tick" Data="M2.5,7.2 L5.8,10.5 L11.5,3.8"
Stroke="#ffffff" StrokeThickness="2"
StrokeStartLineCap="Round" StrokeEndLineCap="Round"
Visibility="Collapsed"/>
</Border>
<ContentPresenter Grid.Column="1" Margin="7,0,0,0" VerticalAlignment="Center"
HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"/>
</Grid>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Box" Property="BorderBrush" Value="#3d5076"/>
</Trigger>
<Trigger Property="IsKeyboardFocusWithin" Value="True">
<Setter TargetName="Box" Property="BorderBrush" Value="#e94560"/>
</Trigger>
<Trigger Property="IsChecked" Value="True">
<Setter TargetName="Box" Property="Background" Value="#e94560"/>
<Setter TargetName="Box" Property="BorderBrush" Value="#e94560"/>
<Setter TargetName="Tick" Property="Visibility" Value="Visible"/>
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Opacity" Value="0.45"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="YtComboBox" TargetType="ComboBox">
<Setter Property="Foreground" Value="#e0e0e0"/>
<Setter Property="FontSize" Value="13"/>
@@ -701,5 +760,6 @@
<Helpers:HiddenBoolToVisibilityConverter x:Key="HiddenBoolToVis"/>
<Helpers:EnumToBoolConverter x:Key="EnumToBool"/>
<Helpers:AllTrueToVisibilityConverter x:Key="AllTrueToVis"/>
<Helpers:HexToBrushConverter x:Key="HexToBrush"/>
</ResourceDictionary>
+22 -1
View File
@@ -5,7 +5,7 @@ memory-map conventions.
| File | Purpose |
|------|---------|
| `Controls.xaml` | All custom control styles/templates: `YtButton`, `YtButtonSecondary`, `IconButton`, `YtTextBox`, `YtComboBox`, scrollbar/slider/tooltip/context-menu/menu-item/separator/list-item (implicit where app-wide), `YtListBoxItem`, `YtLabel`, `SectionHeader` |
| `Controls.xaml` | All custom control styles/templates: `YtButton`, `YtButtonSecondary`, `IconButton`, `YtTextBox`, `YtTextBoxCompact` (the dense variant the properties panel uses), `YtComboBox`, `YtCheckBox`, scrollbar/slider/tooltip/context-menu/menu-item/separator/list-item (implicit where app-wide), `YtListBoxItem`, `YtLabel`, `SectionHeader` |
How it's wired:
- Merged once in [`App.xaml`](../App.xaml) — app-wide. New windows get the theme
@@ -19,6 +19,27 @@ Gotchas learned the hard way:
- A custom `ComboBox` template must put the `ContentPresenter` (`SelectionBoxItem`)
**inside the ComboBox template, not the ToggleButton template** — the latter
resolves against the wrong TemplatedParent and renders nothing.
- **A control with no style renders with the LIGHT WPF system template** — a
hand-painted `Background`/`Foreground` colours the box but leaves the system
border, the system arrow and the white popup list. That is why the Stream
Alerts "Display" drop-down looked like it belonged to another app (TASK 58,
2026-10-21). **Rule: every control carries a theme style** — `YtComboBox`,
`YtCheckBox`, `YtTextBox`/`YtTextBoxCompact` — and paints no colour of its
own. `ytLive.Tests/PropertiesStyleTests.cs` fails if that drifts back.
⚠️ **But that guard sees only those control TYPES** — a picker built as a
`Button` + a code-built `ContextMenu` is invisible to it (the border-colour
picker was exactly that, TASK 63). A picker that LOOKS like a drop-down must
BE the app's drop-down.
- **The `YtComboBox` popup sizes to its CONTENT, not to the ComboBox** (measured
2026-10-21: the border-colour popup came out 142.7px wide against a 100px
ComboBox) — a long row is never clipped, and the popup may be wider than the
closed box. Measure, don't guess.
- **The closed box and the rows of a ComboBox can only differ through an
`ItemTemplateSelector`** — WPF copies `ItemTemplate` into the read-only
`SelectionBoxItemTemplate`, so one template renders in both places. The
selector is told them apart by the `ContentPresenter`'s templated parent
(`ComboBox` = closed box, `ComboBoxItem` = a row) — see
`Helpers/ClosedBoxTemplateSelector.cs`.
Related: [`Helpers/index.md`](../Helpers/index.md) (custom controls the theme
styles), the XAML files at repo root that consume these styles.
+42 -1
View File
@@ -276,7 +276,48 @@ C# / WPF (.NET 8) following MVVM:
- Layout persists to SQLite (`Microsoft.Data.Sqlite`); scenes/sources/asset bytes stored in the DB, asset identity is a SHA-256 content hash (1:M reuse, no file paths — assets are always available). Loaded sources always derive `IsBackground` from `Type` (OR'd with the persisted column, so legacy DisplayCapture backdrops keep their flag) — pre-derivation rows with `IsBackground=0` heal on load
- **Element z-order is a unified space, not per-table (2026-09-20):** `Source` and `WebcamSceneConfig` live in different tables, and a webcam can be interleaved between sources in a scene. Both tables' `SortOrder` is stamped from the SAME counter — the element's index within `scene.Elements` — and `Load` merges the two tables' rows per scene by that shared z (tie-break sources-first, so legacy per-table rows read back stacked the way they were saved). Never re-introduce per-type sort counters + "append all Sources then all configs", or a webcam slotted mid-stack reverts on reload (creator-reproduced bug).
- **Five-scene catalog (`Models/SceneCatalog.cs`):** the product is exactly Starting/Live/BRB/Chat/Ending — work with less, never more (the escape hatch for "more" is OBS). Scenes are matched **by name** (`SceneCatalog.Is`, case-insensitive trim). Empty DBs seed all five; the scenes-header "+" (`ShowAddScene`/`MissingScenes` on `MainViewModel`) only appears while ≥1 canonical scene is missing and its menu lists only the missing ones, re-adding them by name (`AddSceneCommand`). Renaming a canonical scene makes it missing again; `AddScene` rejects non-canonical names.
- Theming: all custom styles live in `Themes/Controls.xaml`, merged in `App.xaml` — never duplicate styles per-window (dialog duplicates were consolidated into this dictionary)
- Theming: all custom styles live in `Themes/Controls.xaml`, merged in `App.xaml` — never duplicate styles per-window (dialog duplicates were consolidated into this dictionary). **Every control in the app carries a theme style (TASK 58, 2026-10-21):** an unstyled `ComboBox`/`CheckBox` falls back to the **light WPF system template** (grey border, system arrow, white popup list) and sticks out of the dark panels — the properties panel's drop-downs had been hand-painted with a `Background`/`Foreground` instead of a style (the chat Font drop-down was a copy of that), and every check box was default. The rule now: `ComboBox` → `YtComboBox`, `CheckBox` → `YtCheckBox`, the dense properties fields → `YtTextBoxCompact` (`BasedOn YtTextBox`: 11px, padding 4,2) — **no hand-painted `Background`/`BorderBrush` on a themed control**. `ytLive.Tests/PropertiesStyleTests.cs` is the guard: it fails if an unstyled drop-down, check box or properties field comes back — **but that guard sees only those three control types**, so a picker built as something else (a `Button` + a code-built `ContextMenu`) slipped through it and the creator had to ask twice (TASK 63)
- **The properties panel is a HIERARCHY (TASK 59, 2026-10-21):** a **title owns its content, and the content is indented one level (10px) under it** — the creator's ruling: *"the 'drag to move' help text needs to be indented under 'Mirror Rect' to show parentage … same with Geometry, Border Color, Border Thickness … Remove should be indented under Background as should Blur … Strength should be indented under Blur."* So the webcam's block reads `Shape` (the mirror switch + the shape choice) → `Geometry` → `Border Color` → `Border Thickness` → `Background` (Remove, Blur, and Strength one level deeper under Blur), each title SemiBold at 11px `#a0a0b0` like the existing ones. **The shape is a CHOICE, not a toggle button:** `IsRectShape`/`IsRoundShape` on `SceneElement` are two settable bools and the panel shows them as two `SegmentToggle` options (Rect | Circle) with **Rect selected by default** (an element that never picked a shape is a rect) — the old single button showed the *next* action ("Round"), which is why the creator could not tell what the camera was. The mutual exclusion lives in the MODEL (each setter flips through `ToggleClipShape()` and both props are re-raised), **not** in a WPF `RadioButton` group — a RadioButton group unchecks its siblings by setting `IsChecked` in code, which silently kills the binding (stackoverflow 7376373). Each setter always re-raises, so a refused write (the vertical Chat layout is rect-only) makes the UI snap back. Guard: `ytLive.Tests/PropertiesHierarchyTests.cs`
- **One list for the offered colours (TASK 60):** the border-colour drop-down builds its menu from
`Models/BorderColorPalette.cs` (`NamedColor(Hex, Name)` + `NameOf(hex)` — **empty** for a colour
outside the palette, "no name" being the honest answer), NOT from a list inside
the click handler — the creator asked for a colour NAME next to each hex (`#e94560 Crimson`), and a
hard-coded `string[]` cannot carry one. A palette the creator may want to extend belongs in a
testable catalogue, with the UI as a consumer. **TASK 62:** the field that shows the *current* colour
shows the name beside it too (`SceneElement.BorderColorName`), and the hex field keeps a fixed width
so the name stays next to the hex instead of drifting to the row's far edge
- **A picker IS the app's drop-down (TASK 63, 2026-10-21):** the border colour is now a
**`YtComboBox`** — the same widget the Display and Font pickers wear — not a `...` button that
built a `ContextMenu` in code. The creator's complaint (*"one of the first things we did was define
branding. We established look-and-feel standards. And yet you keep ignoring these"*) had a precise
cause: the TASK 58 sweep looked for `ComboBox`/`CheckBox`/`TextBox` **types**, and the border picker
was neither — it was a hand-rolled `Button` + code-built menu, so it was never in scope, and the
guard could not see it. **Lesson: a style sweep must ask "is this the app's widget?", not "is this
control type themed?"** — anything hand-rolled that *looks* like a picker (a button that opens a
menu) is invisible to a type-based guard. The hex field stays (a custom colour can still be typed);
the drop-down carries the swatch and the colour's NAME, the rows carry swatch + hex + name.
Guard: `ytLive.Tests/BorderColorPickerTests.cs`
- **A ComboBox's CLOSED box and its ROWS can differ — only through a template selector (TASK 63):**
WPF copies `ItemTemplate` into `SelectionBoxItemTemplate`, and that property is **read-only in
C#** ("Property or indexer 'ComboBox.SelectionBoxItemTemplate' cannot be assigned"), so a plain
`ItemTemplate` renders the SAME content in the closed box and in every row. The one mechanism that
tells the two apart is an `ItemTemplateSelector`: WPF calls it with the `ContentPresenter`'s
**templated parent** — the **`ComboBox`** when the closed box renders, the **`ComboBoxItem`** when a
row renders. `Helpers/ClosedBoxTemplateSelector.cs` does exactly that (`ClosedTemplate` for the
ComboBox, `RowTemplate` for the ComboBoxItem). A `ComboBoxItem.ContentTemplate` set through an
`ItemContainerStyle` does **not** work: WPF overwrites the container's `ContentTemplate` with the
ComboBox's `ItemTemplate` (null) — so use the selector.
- **The theme's drop-down popup sizes to its CONTENT, not to the ComboBox (measured 2026-10-21):**
the `YtComboBox` popup carries no width binding, so the pop-up is as wide as its widest row
(measured 142.7px for the border-colour rows against a 100px ComboBox) — a row is never clipped,
and the popup may be wider than the closed box. Measure (`RenderTargetBitmap`/`ActualWidth` in a
throwaway test), don't guess.
- **A field in a dense panel carries a FIXED width (TASK 61):** a WPF element sizes itself to its
content, and in a `DockPanel` the **last child fills the row** — so a row of same-kind fields looks
broken unless every field has an explicit `Width` AND the row sets `LastChildFill="False"`. The
geometry fields are 44px: the measured width of `-1920` in the app's font at 11px (via WPF's
`FormattedText` — **measure, don't guess**) plus the field's padding and border. Guard:
`ytLive.Tests/GeometryFieldWidthTests.cs`
- Resolution tiers: 1080p60@8 (default) → 1080p30@8 → 720p60@6 → 720p30@6 → **Vertical 1080p60@8 (9:16, 1080×1920)**. 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; the vertical tier uses 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. Bottom bar: gear icon far left, stream stats (bitrate/FPS/dropped/duration) centered under preview, resolution dropdown far right. 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
- Crash diagnosis: `AppLog` writes startup checkpoints to `%APPDATA%\ytLlive\startup.log`; `App.xaml.cs` logs `DispatcherUnhandledException`/`AppDomain.UnhandledException`. When WPF won't run from WSL, this log is how you find the failure (it caught the `MenuItemRole.Separator` XAML crash and the ComboBox SelectionBoxItem bug)
+62
View File
@@ -0,0 +1,62 @@
using System;
using System.Linq;
using Xunit;
using ytLive.Models;
namespace ytLive.Tests;
/// <summary>TASK 60 — the creator (2026-10-21): *"under Live-&gt;Layers-&gt;webcam-&gt;
/// Properties-&gt;border color: in the drop down box, the #hex values are good, but add a
/// corresponding color name/label next to the value."*
///
/// <para>One test: the palette is the single source of truth for the drop-down (the
/// picker builds its menu from it and carries no list of its own), every entry has a
/// valid hex and a name, the names are unique, the colours the picker used to offer are
/// all still offered, and the lookup answers for a hex the creator typed.</para></summary>
public class BorderColorPaletteTests
{
[Fact]
public void The_border_palette_names_every_colour_the_picker_offers()
{
// 1. Every entry is a valid hex with a name, and no hex or name repeats.
Assert.NotEmpty(BorderColorPalette.All);
foreach (var colour in BorderColorPalette.All)
{
Assert.Matches("^#[0-9a-fA-F]{6}$", colour.Hex);
Assert.False(string.IsNullOrWhiteSpace(colour.Name), $"{colour.Hex} has no name");
}
Assert.Equal(BorderColorPalette.All.Count,
BorderColorPalette.All.Select(c => c.Hex).Distinct(StringComparer.OrdinalIgnoreCase).Count());
Assert.Equal(BorderColorPalette.All.Count,
BorderColorPalette.All.Select(c => c.Name).Distinct(StringComparer.OrdinalIgnoreCase).Count());
// 2. The colours the picker used to offer are all still offered.
string[] legacy = ["#FFFFFF", "#000000", "#FF0000", "#00FF00", "#0000FF", "#FFFF00",
"#FF00FF", "#00FFFF", "#FF8800", "#8800FF", "#e94560", "#22c55e",
"#3b82f6", "#f59e0b"];
foreach (var hex in legacy)
Assert.Contains(BorderColorPalette.All, c => c.Hex == hex);
// 3. The lookup answers for a hex in any case, and a colour outside the palette
// has NO name (the field shows the hex alone — TASK 62).
Assert.Equal("Crimson", BorderColorPalette.NameOf("#e94560"));
Assert.Equal("Crimson", BorderColorPalette.NameOf("#E94560"));
Assert.Equal("White", BorderColorPalette.NameOf(" #ffffff "));
Assert.Equal("", BorderColorPalette.NameOf("#123456"));
// 4. The palette is still the single source of truth — the drop-down is bound to
// it (TASK 63 replaced the code-built menu with the app's YtComboBox), and
// neither the panel nor the code-behind carries a second list of colours.
var panel = SourceAssert.Text("Controls", "LeftPanel.xaml");
Assert.True(panel.Contains("SelectedElement.BorderColorOptions"),
"the border drop-down is not bound to the palette");
var model = SourceAssert.Text("Models", "SceneElement.cs");
Assert.True(model.Contains("BorderColorPalette.OptionsFor"),
"the border drop-down's options do not come from the palette");
var source = SourceAssert.Text("Controls", "LeftPanel.xaml.cs");
Assert.False(source.Contains("\"#FFFFFF\"", StringComparison.Ordinal),
"the border picker still carries its own list of colours");
Assert.False(panel.Contains("\"#FFFFFF\"", StringComparison.Ordinal),
"the border drop-down still carries its own list of colours");
}
}
+151
View File
@@ -0,0 +1,151 @@
using System.Collections.Generic;
using System.Linq;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Controls.Primitives;
using System.Windows.Media;
using System.Windows.Threading;
using System.Xml.Linq;
using Xunit;
using ytLive.Controls;
using ytLive.Helpers;
using ytLive.Models;
namespace ytLive.Tests;
/// <summary>TASK 63 — the creator (2026-10-21): *"why does the drop down under the web cam
/// properties not show the same style for the border color drop down list box?"* — and the
/// reason behind it: *"one of the first things we did was define branding. We established
/// look-and-feel standards. And yet you keep ignoring these and I'm wondering why."*
///
/// <para>The border colour was a hand-rolled <c>...</c> button that built a
/// <c>ContextMenu</c> in code — never a ComboBox, so the TASK 58 sweep (which only covered
/// ComboBox/CheckBox/TextBox) never reached it. This guard holds the fix in place, in three
/// parts:</para>
///
/// <para><b>The model</b> — the border colour is a palette entry the drop-down can select by
/// VALUE (carrying the colour's name), and a custom hex the creator typed is still offered.</para>
///
/// <para><b>The panel</b> — the picker IS the app's own <c>YtComboBox</c>, the widget every
/// other drop-down wears, and the hand-rolled menu is gone from both the XAML and the
/// code-behind. The hex field stays, so a colour outside the palette can still be typed.</para>
///
/// <para><b>The widget</b> — with the app's real theme style and the panel's real templates,
/// the CLOSED box shows the colour's name and the drop-down ROWS show the hex AND the name.
/// (WPF copies <c>ItemTemplate</c> into the read-only <c>SelectionBoxItemTemplate</c>, so the
/// two can only differ through <see cref="ClosedBoxTemplateSelector"/>.)</para></summary>
[Collection("RealApp")]
public sealed class BorderColorPickerTests
{
private readonly RealAppHost _app;
public BorderColorPickerTests(RealAppHost app) => _app = app;
[Fact]
public void The_border_colour_picker_is_the_app_drop_down()
{
// ── 1. the model: the palette entry, and the custom hex ──────────────────
var element = new WebcamSceneConfig { Name = "Webcam" };
var raised = new List<string?>();
element.PropertyChanged += (_, e) => raised.Add(e.PropertyName);
element.BorderColor = "#e94560";
Assert.Equal("Crimson", element.BorderColorEntry.Name);
Assert.Equal("#e94560", element.BorderColorEntry.Hex);
Assert.Contains("BorderColorEntry", raised);
Assert.Contains("BorderColorOptions", raised);
element.BorderColorEntry = BorderColorPalette.All[0]; // picking a row
Assert.Equal("#FFFFFF", element.BorderColor);
element.BorderColor = "#123456"; // a typed hex
Assert.Equal(string.Empty, element.BorderColorEntry.Name); // no name to show
Assert.Equal("#123456", element.BorderColorEntry.Hex);
Assert.Contains(element.BorderColorOptions, o => o.Hex == "#123456");
element.BorderColor = string.Empty; // no colour yet
Assert.Equal(BorderColorPalette.All.Count, element.BorderColorOptions.Count);
Assert.DoesNotContain(element.BorderColorOptions, o => string.IsNullOrEmpty(o.Hex));
// ── 2. the panel: the app's drop-down, no hand-rolled menu ───────────────
var xaml = XDocument.Load(SourceAssert.RepoPath("Controls", "LeftPanel.xaml"));
var picker = xaml.Descendants()
.Where(e => e.Name.LocalName == "ComboBox")
.SingleOrDefault(c => (string?)c.Attribute("ItemsSource")
== "{Binding SelectedElement.BorderColorOptions}");
Assert.True(picker != null, "the border colour is not a drop-down over the palette");
Assert.True(((string?)picker!.Attribute("Style") ?? string.Empty)
.Contains("YtComboBox", StringComparison.Ordinal),
"the border-colour drop-down is not styled with YtComboBox");
Assert.Equal("{Binding SelectedElement.BorderColorEntry, Mode=TwoWay}",
(string?)picker.Attribute("SelectedItem"));
var hexField = xaml.Descendants()
.Where(e => e.Name.LocalName == "TextBox")
.Any(t => (string?)t.Attribute("Text")
== "{Binding SelectedElement.BorderColor, UpdateSourceTrigger=PropertyChanged}");
Assert.True(hexField, "the hex field is gone — a colour outside the palette can no longer be typed");
var panelSource = SourceAssert.Text("Controls", "LeftPanel.xaml");
var codeBehind = SourceAssert.Text("Controls", "LeftPanel.xaml.cs");
Assert.False(panelSource.Contains("WebcamBorderColorPicker_Click", StringComparison.Ordinal),
"the hand-rolled colour menu is back in the panel");
Assert.False(codeBehind.Contains("WebcamBorderColorPicker_Click", StringComparison.Ordinal),
"the hand-rolled colour menu is back in the code-behind");
Assert.False(codeBehind.Contains("ColorConverter.ConvertFromString", StringComparison.Ordinal),
"a colour is hand-painted in code — the theme paints it");
// ── 3. the widget: the closed box names the colour, the rows show hex + name ──
element.BorderColor = "#FFFFFF";
_app.Run(() =>
{
var panel = new LeftPanel();
var selector = panel.Resources["BorderColorTemplates"] as ClosedBoxTemplateSelector;
Assert.True(selector != null, "the panel carries no colour templates");
var combo = new ComboBox
{
Style = (Style)Application.Current.Resources["YtComboBox"],
ItemTemplateSelector = selector,
ItemsSource = element.BorderColorOptions,
SelectedItem = element.BorderColorEntry,
};
var window = new Window { Width = 420, Height = 240, Content = combo };
window.Show();
combo.UpdateLayout();
var closed = Texts(combo);
Assert.True(closed.Contains("White", StringComparison.Ordinal),
$"the closed box does not name the colour: '{closed}'");
Assert.False(closed.Contains("#FFFFFF", StringComparison.Ordinal),
$"the closed box repeats the hex the field already shows: '{closed}'");
combo.IsDropDownOpen = true;
combo.UpdateLayout();
Dispatcher.CurrentDispatcher.Invoke(() => { }, DispatcherPriority.Loaded);
var popup = combo.Template.FindName("PART_Popup", combo) as Popup;
var rows = popup?.Child == null ? string.Empty : Texts(popup.Child);
Assert.True(rows.Contains("#FFFFFF", StringComparison.Ordinal),
$"the drop-down rows do not show the hex: '{rows}'");
Assert.True(rows.Contains("White", StringComparison.Ordinal),
$"the drop-down rows do not show the colour's name: '{rows}'");
window.Close();
});
}
/// <summary>Every text in a visual tree, so a template's content can be read back.</summary>
private static string Texts(DependencyObject root)
{
var texts = new List<string>();
void Walk(DependencyObject node)
{
if (node is TextBlock block) texts.Add(block.Text);
for (var i = 0; i < VisualTreeHelper.GetChildrenCount(node); i++)
Walk(VisualTreeHelper.GetChild(node, i));
}
Walk(root);
return string.Join("|", texts);
}
}
+65
View File
@@ -0,0 +1,65 @@
using System;
using System.Globalization;
using System.Linq;
using System.Windows;
using System.Windows.Media;
using System.Xml.Linq;
using Xunit;
namespace ytLive.Tests;
/// <summary>TASK 61 — the creator (2026-10-21): *"the Y and H boxes under webcam
/// properties should be the same width as the X and W boxes and those widths should be
/// fixed to accommodate the max number of possible digits providing the geometry
/// values."*
///
/// <para>One test: the four geometry fields carry the SAME explicit width (the Y/H
/// fields used to stretch to fill the row, because they were the last child of a
/// DockPanel, while X/W sized themselves to their content), and that width really fits
/// the widest value the panel can show — the largest geometry value (1920 master
/// pixels) with a sign, measured in the app's own font at the field's size.</para></summary>
public class GeometryFieldWidthTests
{
/// <summary>The widest value a geometry field can show: the master frame is
/// 1920×1080, so four digits — plus a sign the creator may be typing.</summary>
private const string WidestValue = "-1920";
[Fact]
public void The_webcam_geometry_fields_share_one_fixed_width_that_fits_the_largest_value()
{
var doc = XDocument.Load(SourceAssert.RepoPath("Controls", "LeftPanel.xaml"));
var geometry = doc.Descendants()
.Where(e => e.Name.LocalName == "TextBlock" && (string?)e.Attribute("Text") == "Geometry")
.Single(e => ((string?)e.Parent?.Attribute("Visibility") ?? "").Contains("IsWebcamSelected"));
var rows = geometry.Parent!.Elements().Where(e => e.Name.LocalName == "DockPanel").ToList();
Assert.Equal(2, rows.Count);
var fields = rows.SelectMany(r => r.Elements().Where(e => e.Name.LocalName == "TextBox")).ToList();
Assert.Equal(4, fields.Count);
// Every field carries the SAME explicit width — a field without one sizes
// itself to its content, and the last one in the row stretches to fill it.
var widths = fields.Select(f => (string?)f.Attribute("Width")).ToList();
Assert.All(widths, w => Assert.False(string.IsNullOrWhiteSpace(w),
"a geometry field has no fixed width — it sizes itself to its content"));
Assert.Single(widths.Distinct());
// And that width fits the widest value the panel can show, in the app's font
// at the field's size, plus the field's own padding and border.
var width = double.Parse(widths[0]!, CultureInfo.InvariantCulture);
var needed = MeasureWidestValue() + 8 /* YtTextBoxCompact padding */ + 2 /* border */;
Assert.True(width >= needed,
$"the geometry fields are {width:0}px wide; {needed:0.#}px are needed for '{WidestValue}'");
}
/// <summary>The width of the widest possible value, in the app's font at 11px.</summary>
private static double MeasureWidestValue()
{
var typeface = new Typeface(SystemFonts.MessageFontFamily,
FontStyles.Normal, FontWeights.Normal, FontStretches.Normal);
return new FormattedText(WidestValue, CultureInfo.CurrentCulture,
FlowDirection.LeftToRight, typeface, 11, Brushes.Black, pixelsPerDip: 1.0).Width;
}
}
+130
View File
@@ -0,0 +1,130 @@
using System.Globalization;
using System.Linq;
using System.Xml.Linq;
using Xunit;
using ytLive.Models;
namespace ytLive.Tests;
/// <summary>TASK 59 — the creator's properties-panel hierarchy (2026-10-21):
/// "Mirror Rect: needs a title 'Shape' above it and Rect should be selected as a default
/// if none exists … the 'drag to move' help text needs to be indented under 'Mirror Rect'
/// to show parentage … same with Geometry, Border Color, Border Thickness … Remove should
/// be indented under Background as should Blur … Strength should be indented under Blur."
///
/// <para>One test, the whole story: the shape is a CHOICE and Rect is the selected
/// default (the model), the options live under a "Shape" title with the drag hint
/// indented beneath them, every titled block owns its content one indent deeper, and the
/// strength row sits under Blur — and the TASK 57 rule still holds: the circle option is
/// not offered in the vertical Chat layout, and the model refuses it there.</para></summary>
public class PropertiesHierarchyTests
{
[Fact]
public void The_webcam_properties_show_a_Shape_title_with_Rect_selected_and_indent_their_content()
{
// ── The model: the shape is a choice, and Rect is the default ─────────
var camera = new WebcamSceneConfig { WebcamId = "webcam-1", Name = "Webcam" };
Assert.True(camera.IsRectShape);
Assert.False(camera.IsRoundShape);
camera.IsRoundShape = true;
Assert.Equal(ClipShape.Round, camera.ClipShape);
Assert.False(camera.IsRectShape);
Assert.True(camera.IsRoundShape);
camera.IsRectShape = true;
Assert.Equal(ClipShape.Traditional, camera.ClipShape);
Assert.True(camera.IsRectShape);
Assert.False(camera.IsRoundShape);
// The vertical Chat layout locks the shape to a rect — the circle option is
// not offered there, and the model refuses it even if the UI asks.
camera.VerticalShapeLocked = true;
camera.IsRoundShape = true;
Assert.Equal(ClipShape.Traditional, camera.ClipShape);
Assert.False(camera.IsRoundShape);
Assert.True(camera.IsRectShape);
// ── The panel: a Shape title owns the options, the hint is indented ───
var doc = XDocument.Load(SourceAssert.RepoPath("Controls", "LeftPanel.xaml"));
var shapeTitle = doc.Descendants().Single(e => e.Name.LocalName == "TextBlock"
&& (string?)e.Attribute("Text") == "Shape");
Assert.Equal("SemiBold", (string?)shapeTitle.Attribute("FontWeight"));
var rectOption = OptionBoundTo(doc, "IsRectShape");
var circleOption = OptionBoundTo(doc, "IsRoundShape");
Assert.Contains("TwoWay", (string?)rectOption.Attribute("IsChecked") ?? "");
// The CIRCLE is the option that is not offered in the vertical Chat layout.
Assert.Contains("CanToggleClipShape", (string?)circleOption.Attribute("Visibility") ?? "");
// Both options live under the Shape title.
var shapeBlock = shapeTitle.Parent!;
Assert.Contains(rectOption, shapeBlock.Descendants());
Assert.Contains(circleOption, shapeBlock.Descendants());
// The drag hint is indented, and it comes after the shape row — it reads as
// part of the shape block.
var hint = doc.Descendants().Single(e => e.Name.LocalName == "TextBlock"
&& ((string?)e.Attribute("Text") ?? "").StartsWith("Drag to move"));
Assert.True(LeftIndent(hint) > 0, "the drag hint is not indented under the shape row");
var siblings = shapeBlock.Parent!.Elements().ToList();
Assert.True(siblings.IndexOf(shapeBlock) < siblings.IndexOf(hint),
"the drag hint comes before the shape row");
// ── Every titled block owns its content one indent deeper ────────────
var geometryTitle = doc.Descendants()
.Where(e => e.Name.LocalName == "TextBlock" && (string?)e.Attribute("Text") == "Geometry")
.Single(e => ((string?)e.Parent?.Attribute("Visibility") ?? "").Contains("IsWebcamSelected"));
Assert.Equal("SemiBold", (string?)geometryTitle.Attribute("FontWeight"));
foreach (var row in geometryTitle.Parent!.Elements().Where(e => e.Name.LocalName == "DockPanel"))
Assert.Equal(10, LeftIndent(row));
// The webcam block (the one the TASK 51 test finds by its binding).
var webcamPanel = doc.Descendants().Single(e => e.Name.LocalName == "StackPanel"
&& ((string?)e.Attribute("Visibility") ?? "").Contains("SelectedElement.IsWebcam"));
foreach (var title in new[] { "Border Color", "Border Thickness" })
{
var label = webcamPanel.Descendants().Single(e => e.Name.LocalName == "TextBlock"
&& (string?)e.Attribute("Text") == title);
Assert.Equal("SemiBold", (string?)label.Attribute("FontWeight"));
Assert.Equal(10, LeftIndent(RowAfter(label)));
}
// Remove and Blur are indented under Background; Strength one level deeper,
// under Blur.
Assert.Equal(10, LeftIndent(RowContaining(webcamPanel, "Remove")));
Assert.Equal(10, LeftIndent(RowContaining(webcamPanel, "Blur")));
Assert.Equal(20, LeftIndent(RowContaining(webcamPanel, "Strength")));
}
/// <summary>The shape option bound to the given model property.</summary>
private static XElement OptionBoundTo(XDocument doc, string property) =>
doc.Descendants().Single(e => e.Name.LocalName == "ToggleButton"
&& ((string?)e.Attribute("IsChecked") ?? "").Contains(property));
/// <summary>The row (DockPanel) that holds the given label.</summary>
private static XElement RowContaining(XElement scope, string label) =>
scope.Descendants().Single(e => e.Name.LocalName == "TextBlock"
&& (string?)e.Attribute("Text") == label).Parent!;
/// <summary>The row that follows a title.</summary>
private static XElement RowAfter(XElement title)
{
var next = title.ElementsAfterSelf().First(e => e.Name.LocalName == "DockPanel");
return next;
}
/// <summary>The left inset of an element's Margin, in pixels.</summary>
private static int LeftIndent(XElement element)
{
var margin = (string?)element.Attribute("Margin");
if (string.IsNullOrWhiteSpace(margin)) return 0;
var first = margin.Split(',')[0].Trim();
return int.TryParse(first, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value)
? value
: 0;
}
}
+84
View File
@@ -0,0 +1,84 @@
using System.IO;
using System.Linq;
using System.Xml.Linq;
using Xunit;
namespace ytLive.Tests;
/// <summary>TASK 58 — the creator's style audit (2026-10-21): "in the Live scene, for the
/// layer Stream Alerts, the properties → display — why is the drop-down box not in the same
/// style as the others?" It was never given the theme's <c>YtComboBox</c> style — only a
/// hand-painted Background/Foreground — so WPF fell back to the light system template, and
/// the chat Font drop-down (copied from it) was the same. The sweep found the same smell in
/// every check box and every properties field.
///
/// <para>This test is the guard: every drop-down and check box in the app carries the theme's
/// style, and every field in the properties panel carries the compact theme style and paints
/// no colour of its own — the theme owns the look, so it cannot drift back.</para></summary>
public class PropertiesStyleTests
{
private static readonly XNamespace Xaml = "http://schemas.microsoft.com/winfx/2006/xaml";
[Fact]
public void Every_drop_down_check_box_and_properties_field_carries_the_theme_style()
{
// 1. The theme defines the styles the app relies on.
var theme = XDocument.Load(SourceAssert.RepoPath("Themes", "Controls.xaml"));
var keys = theme.Descendants()
.Where(e => e.Name.LocalName == "Style")
.Select(e => (string?)e.Attribute(Xaml + "Key"))
.Where(k => k != null)
.ToHashSet();
foreach (var required in new[] { "YtComboBox", "YtCheckBox", "YtTextBox", "YtTextBoxCompact" })
Assert.Contains(required, keys);
// 2. Every drop-down and check box in the app carries the theme style — an unstyled
// one falls back to the light WPF system template.
foreach (var file in AppXamlFiles())
{
var doc = XDocument.Load(file);
var where = Path.GetFileName(file);
foreach (var combo in doc.Descendants().Where(e => e.Name.LocalName == "ComboBox"))
AssertStyled(combo, "YtComboBox",
$"{where}: a ComboBox is not styled with YtComboBox — it renders with the light system template");
foreach (var check in doc.Descendants().Where(e => e.Name.LocalName == "CheckBox"))
AssertStyled(check, "YtCheckBox",
$"{where}: a CheckBox is not styled with YtCheckBox — it renders with the light system template");
}
// 3. Every field in the properties panel carries the compact theme style and paints
// no colours of its own.
var left = XDocument.Load(SourceAssert.RepoPath("Controls", "LeftPanel.xaml"));
var properties = left.Descendants().Single(e => e.Name.LocalName == "StackPanel"
&& (string?)e.Attribute(Xaml + "Name") == "ElementsPanel");
var fields = properties.Descendants().Where(e => e.Name.LocalName == "TextBox").ToList();
Assert.NotEmpty(fields);
foreach (var box in fields)
{
AssertStyled(box, "YtTextBoxCompact",
"a properties field is not styled with YtTextBoxCompact");
Assert.Null(box.Attribute("Background"));
Assert.Null(box.Attribute("BorderBrush"));
}
}
/// <summary>The app's XAML: the windows at the repo root plus the carved-up controls.</summary>
private static string[] AppXamlFiles()
{
var root = Path.GetDirectoryName(SourceAssert.RepoPath("ytLive.csproj"))!;
return Directory.GetFiles(root, "*.xaml", SearchOption.TopDirectoryOnly)
.Concat(Directory.GetFiles(Path.Combine(root, "Controls"), "*.xaml", SearchOption.TopDirectoryOnly))
.OrderBy(f => f)
.ToArray();
}
/// <summary>Asserts the element carries the named theme style.</summary>
private static void AssertStyled(XElement element, string key, string message)
{
var style = (string?)element.Attribute("Style");
Assert.True(style is not null && style.Contains(key, StringComparison.Ordinal), message);
}
}