diff --git a/Controls/LeftPanel.xaml b/Controls/LeftPanel.xaml
index 9cb90e8..2416809 100644
--- a/Controls/LeftPanel.xaml
+++ b/Controls/LeftPanel.xaml
@@ -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">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -297,21 +328,23 @@
+
-
-
-
-
-
-
+
+
diff --git a/Controls/LeftPanel.xaml.cs b/Controls/LeftPanel.xaml.cs
index a39b03d..73acf38 100644
--- a/Controls/LeftPanel.xaml.cs
+++ b/Controls/LeftPanel.xaml.cs
@@ -241,50 +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();
- foreach (var swatch in BorderColorPalette.All)
- {
- // TASK 60: the hex stays, and the colour's NAME goes next to it.
- var header = new System.Windows.Controls.StackPanel
- {
- Orientation = System.Windows.Controls.Orientation.Horizontal
- };
- header.Children.Add(new System.Windows.Controls.TextBlock { Text = swatch.Hex });
- header.Children.Add(new System.Windows.Controls.TextBlock
- {
- Text = swatch.Name,
- Foreground = new System.Windows.Media.SolidColorBrush(
- System.Windows.Media.Color.FromRgb(0xa0, 0xa0, 0xb0)),
- Margin = new System.Windows.Thickness(10, 0, 0, 0)
- });
- var item = new System.Windows.Controls.MenuItem
- {
- Header = header,
- ToolTip = $"{swatch.Name} ({swatch.Hex})"
- };
- var brush = new System.Windows.Media.SolidColorBrush(
- (System.Windows.Media.Color)System.Windows.Media.ColorConverter.ConvertFromString(swatch.Hex));
- item.Icon = new System.Windows.Controls.Border
- {
- Width = 16, Height = 16,
- Background = brush,
- CornerRadius = new System.Windows.CornerRadius(2)
- };
- var color = swatch.Hex;
- 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)
diff --git a/Controls/index.md b/Controls/index.md
index 0dcdda0..7d8d009 100644
--- a/Controls/index.md
+++ b/Controls/index.md
@@ -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. **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 |
+| `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 |
diff --git a/HANDOFF.md b/HANDOFF.md
index 6dae579..cb78653 100644
--- a/HANDOFF.md
+++ b/HANDOFF.md
@@ -1,10 +1,11 @@
-# HANDOFF — 2026-10-21 (the creator's three properties-panel asks shipped; the vv webcam Units 1+2 state below)
+# HANDOFF — 2026-10-21 (the creator's properties-panel asks shipped, incl. TASK 63; the vv webcam Units 1+2 state below)
## At a glance
-- **Branch** main, tracking `origin/main`. **origin/main is still `3a77662`, so five commits are
+- **Branch** main, tracking `origin/main`. **origin/main is still `3a77662`, so six commits are
waiting to be pushed** (TASK 58 the theme sweep, TASK 59 the properties hierarchy, TASK 60 the
- colour names, a docs commit, and TASK 61+62 the geometry field widths and the colour name beside
- the hex — the creator's properties-panel asks, done in one batch). **Not pushed yet:** the push
+ 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). **Not pushed yet:** the push
happens at a sub-milestone/milestone or at the creator's word ("push it") — the creator has not
said so for this batch.
- The vv feature (Units 1+2) **was** pushed at its close (2026-10-21 — the creator approved
@@ -53,6 +54,24 @@
(`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
@@ -188,6 +207,11 @@ 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 **Circle** option (TASK 59 turned the Shape button into a Rect | Circle choice) turns the tile
@@ -202,10 +226,13 @@ Zombie entities deleted from the registry: `script.leak_strobe`, `script.doorbel
`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.
-- **Follow-up spotted while doing TASK 60 (not fixed — out of scope for that change):** the border
- colour swatches in the drop-down are a plain `Border` with the colour as `Background` and **no
- border**, so the black (`#000000`) and dark swatches are nearly invisible against the dark menu
- (`#16213e`). A 1px `BorderBrush="#2a3a5e"` on the swatch would fix 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 `` 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
@@ -230,9 +257,10 @@ 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: **435/435 passed, 0 warnings** (2026-10-21, this session — after TASK 61's
- `GeometryFieldWidthTests` and TASK 62's `BorderColorNameTests`; the runs before were 433/433 after
- TASK 60, 432/432 after TASK 59 and 431/431 after TASK 58). The one failure seen earlier in the
+- 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…`,
diff --git a/Helpers/ClosedBoxTemplateSelector.cs b/Helpers/ClosedBoxTemplateSelector.cs
new file mode 100644
index 0000000..7189f8a
--- /dev/null
+++ b/Helpers/ClosedBoxTemplateSelector.cs
@@ -0,0 +1,33 @@
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Data;
+
+namespace ytLive.Helpers;
+
+/// Gives a ComboBox's CLOSED box a different template from its drop-down
+/// rows (TASK 63).
+///
+/// WPF asks an with the ContentPresenter's
+/// templated parent: the itself when it renders the closed
+/// selection box, the when it renders a row. That is the
+/// only way to tell the two apart — ComboBox.SelectionBoxItemTemplate is
+/// read-only, and WPF copies ItemTemplate into it, so a ComboBox otherwise
+/// cannot show one thing when shut and another when open.
+///
+/// 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.
+///
+public sealed class ClosedBoxTemplateSelector : DataTemplateSelector
+{
+ /// The template for the closed box — what the creator reads while the
+ /// drop-down is shut.
+ public DataTemplate? ClosedTemplate { get; set; }
+
+ /// The template for the drop-down rows.
+ public DataTemplate? RowTemplate { get; set; }
+
+ public override DataTemplate? SelectTemplate(object item, DependencyObject container)
+ => container is FrameworkElement { TemplatedParent: ComboBox }
+ ? ClosedTemplate
+ : RowTemplate;
+}
diff --git a/Helpers/HexToBrushConverter.cs b/Helpers/HexToBrushConverter.cs
new file mode 100644
index 0000000..7f8006f
--- /dev/null
+++ b/Helpers/HexToBrushConverter.cs
@@ -0,0 +1,32 @@
+using System;
+using System.Globalization;
+using System.Windows.Data;
+using System.Windows.Media;
+
+namespace ytLive.Helpers;
+
+/// Turns a "#RRGGBB" hex string into a brush, so a template can paint a colour
+/// swatch straight from the palette (TASK 63).
+///
+/// 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.
+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.");
+}
diff --git a/Helpers/index.md b/Helpers/index.md
index 1534588..938122a 100644
--- a/Helpers/index.md
+++ b/Helpers/index.md
@@ -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
diff --git a/Models/BorderColorPalette.cs b/Models/BorderColorPalette.cs
index 9b26044..44a863f 100644
--- a/Models/BorderColorPalette.cs
+++ b/Models/BorderColorPalette.cs
@@ -4,7 +4,12 @@ namespace ytLive.Models;
/// name shown next to it (TASK 60).
/// The hex value, e.g. #e94560 .
/// The name the creator reads, e.g. Crimson .
-public readonly record struct NamedColor(string Hex, string Name);
+public readonly record struct NamedColor(string Hex, string Name)
+{
+ /// "Crimson (#e94560)" — the tooltip the border drop-down shows.
+ public override string ToString()
+ => string.IsNullOrEmpty(Name) ? Hex : $"{Name} ({Hex})";
+}
/// The colours the webcam's border-colour drop-down offers — the SINGLE
/// source of truth for the list and for the names.
@@ -38,13 +43,30 @@ public static class BorderColorPalette
/// 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.
- public static string NameOf(string hex)
+ public static string NameOf(string hex) => EntryFor(hex).Name;
+
+ /// 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).
+ public static NamedColor EntryFor(string hex)
{
- if (string.IsNullOrWhiteSpace(hex)) return string.Empty;
+ 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.Name;
- return string.Empty;
+ return colour;
+ return new NamedColor(trimmed, string.Empty);
+ }
+
+ /// 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).
+ public static IReadOnlyList OptionsFor(string hex)
+ {
+ var entry = EntryFor(hex);
+ var options = new List(All);
+ if (!string.IsNullOrEmpty(entry.Hex) && !All.Contains(entry))
+ options.Add(entry);
+ return options;
}
}
diff --git a/Models/SceneElement.cs b/Models/SceneElement.cs
index fa9847e..935eb01 100644
--- a/Models/SceneElement.cs
+++ b/Models/SceneElement.cs
@@ -264,15 +264,25 @@ public abstract class SceneElement : INotifyPropertyChanged
{
Raise(nameof(HasBorder));
Raise(nameof(BorderBrush));
- Raise(nameof(BorderColorName));
+ Raise(nameof(BorderColorEntry));
+ Raise(nameof(BorderColorOptions));
}
}
}
- /// The palette's name for the current border colour — "Crimson" for
- /// #e94560, and EMPTY for a custom hex the creator typed (the field shows the hex
- /// alone then). Shown next to the hex field (TASK 62).
- public string BorderColorName => BorderColorPalette.NameOf(BorderColor);
+ /// 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).
+ public NamedColor BorderColorEntry
+ {
+ get => BorderColorPalette.EntryFor(BorderColor);
+ set => BorderColor = value.Hex;
+ }
+
+ /// 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).
+ public IReadOnlyList BorderColorOptions
+ => BorderColorPalette.OptionsFor(BorderColor);
private double _borderOpacity = 1.0;
public double BorderOpacity
diff --git a/Models/index.md b/Models/index.md
index 651b93c..9e8ecae 100644
--- a/Models/index.md
+++ b/Models/index.md
@@ -8,12 +8,12 @@ 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`, asset identity, `IsImageSource` (XAML binds this, never `Type`), **backdrop surface**: `IsBackdrop` (permanent bottom layer, never removable), `CaptureKey` (persisted `monitor:`/`window:`/`picker:`), `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 62:** `BorderColorName` — the palette's name for the current border colour (empty for a custom hex), raised when `BorderColor` changes |
+| `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 picker builds its menu from 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` |
+| `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 |
diff --git a/MyMistakes.md b/MyMistakes.md
index 267db88..d8fe151 100644
--- a/MyMistakes.md
+++ b/MyMistakes.md
@@ -2213,3 +2213,64 @@ widths should be fixed to accomodate the max number of possible digits."* The ro
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.
diff --git a/TASKS.md b/TASKS.md
index 9ba1835..71590ef 100644
--- a/TASKS.md
+++ b/TASKS.md
@@ -78,6 +78,7 @@
| 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) |
---
diff --git a/TASKS/task-63-border-colour-app-dropdown.md b/TASKS/task-63-border-colour-app-dropdown.md
new file mode 100644
index 0000000..e61afc4
--- /dev/null
+++ b/TASKS/task-63-border-colour-app-dropdown.md
@@ -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 `` 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`).
diff --git a/Themes/Controls.xaml b/Themes/Controls.xaml
index 26e057b..0ed5c54 100644
--- a/Themes/Controls.xaml
+++ b/Themes/Controls.xaml
@@ -760,5 +760,6 @@
+
diff --git a/Themes/index.md b/Themes/index.md
index 0827201..31a32ea 100644
--- a/Themes/index.md
+++ b/Themes/index.md
@@ -26,6 +26,20 @@ Gotchas learned the hard way:
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.
diff --git a/ai.md b/ai.md
index c7074a2..dc12579 100644
--- a/ai.md
+++ b/ai.md
@@ -276,7 +276,7 @@ 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). **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
+- 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
@@ -286,6 +286,32 @@ C# / WPF (.NET 8) following MVVM:
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
diff --git a/ytLive.Tests/BorderColorNameTests.cs b/ytLive.Tests/BorderColorNameTests.cs
deleted file mode 100644
index 8358953..0000000
--- a/ytLive.Tests/BorderColorNameTests.cs
+++ /dev/null
@@ -1,56 +0,0 @@
-using System.Collections.Generic;
-using System.Linq;
-using System.Xml.Linq;
-using Xunit;
-using ytLive.Models;
-
-namespace ytLive.Tests;
-
-/// TASK 62 — the creator (2026-10-21): *"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."*
-///
-/// One test: the model carries the palette's name for the current border colour
-/// (and nothing for a custom hex the creator typed), the name raises when the colour
-/// changes, and the panel shows it right next to the hex field — which keeps a fixed
-/// width so the name stays beside the hex instead of drifting to the far edge.
-public class BorderColorNameTests
-{
- [Fact]
- public void The_border_colour_field_shows_the_colour_name_next_to_the_hex()
- {
- // ── The model: the name of the current colour, and nothing for a custom hex ──
- var element = new WebcamSceneConfig { Name = "Webcam" };
- var raised = new List();
- element.PropertyChanged += (_, e) => raised.Add(e.PropertyName);
-
- element.BorderColor = "#e94560";
- Assert.Equal("Crimson", element.BorderColorName);
- Assert.Contains("BorderColorName", raised);
-
- raised.Clear();
- element.BorderColor = "#123456";
- Assert.Equal("", element.BorderColorName); // a custom hex has no name
- Assert.Contains("BorderColorName", raised);
-
- // ── The panel: the name sits right next to the hex field ──────────────────
- var doc = XDocument.Load(SourceAssert.RepoPath("Controls", "LeftPanel.xaml"));
- var title = doc.Descendants().Single(e => e.Name.LocalName == "TextBlock"
- && (string?)e.Attribute("Text") == "Border Color");
- var row = title.ElementsAfterSelf().First(e => e.Name.LocalName == "DockPanel");
-
- var field = row.Descendants().Single(e => e.Name.LocalName == "TextBox");
- var name = row.Descendants().Single(e => e.Name.LocalName == "TextBlock"
- && ((string?)e.Attribute("Text") ?? "").Contains("BorderColorName"));
-
- // The hex field has a fixed width, so the name stays beside the hex.
- Assert.False(string.IsNullOrWhiteSpace((string?)field.Attribute("Width")),
- "the hex field has no fixed width — the name would drift to the far edge");
-
- // The name is in the same horizontal row as the field, after it.
- Assert.Equal(field.Parent, name.Parent);
- var order = field.Parent!.Elements().ToList();
- Assert.True(order.IndexOf(field) < order.IndexOf(name),
- "the colour name comes before the hex field");
- }
-}
diff --git a/ytLive.Tests/BorderColorPaletteTests.cs b/ytLive.Tests/BorderColorPaletteTests.cs
index 7840be8..761516e 100644
--- a/ytLive.Tests/BorderColorPaletteTests.cs
+++ b/ytLive.Tests/BorderColorPaletteTests.cs
@@ -44,11 +44,19 @@ public class BorderColorPaletteTests
Assert.Equal("White", BorderColorPalette.NameOf(" #ffffff "));
Assert.Equal("", BorderColorPalette.NameOf("#123456"));
- // 4. The picker builds its menu from the catalogue — there is no second list.
+ // 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.True(source.Contains("BorderColorPalette"),
- "the border picker does not build its menu from the palette");
- Assert.False(source.Contains("\"#FFFFFF\""),
+ 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");
}
}
diff --git a/ytLive.Tests/BorderColorPickerTests.cs b/ytLive.Tests/BorderColorPickerTests.cs
new file mode 100644
index 0000000..c41914e
--- /dev/null
+++ b/ytLive.Tests/BorderColorPickerTests.cs
@@ -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;
+
+/// 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."*
+///
+/// The border colour was a hand-rolled ... button that built a
+/// ContextMenu 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:
+///
+/// The model — 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.
+///
+/// The panel — the picker IS the app's own YtComboBox , 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.
+///
+/// The widget — 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 ItemTemplate into the read-only SelectionBoxItemTemplate , so the
+/// two can only differ through .)
+[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();
+ 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();
+ });
+ }
+
+ /// Every text in a visual tree, so a template's content can be read back.
+ private static string Texts(DependencyObject root)
+ {
+ var texts = new List();
+ 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);
+ }
+}