diff --git a/Controls/LeftPanel.xaml.cs b/Controls/LeftPanel.xaml.cs
index 17096e0..a39b03d 100644
--- a/Controls/LeftPanel.xaml.cs
+++ b/Controls/LeftPanel.xaml.cs
@@ -247,21 +247,35 @@ public partial class LeftPanel : UserControl
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)
+ foreach (var swatch in BorderColorPalette.All)
{
- var item = new System.Windows.Controls.MenuItem { Header = c, ToolTip = c };
+ // 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(c));
+ (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 = c;
+ var color = swatch.Hex;
item.Click += (_, _) => src.BorderColor = color;
menu.Items.Add(item);
}
diff --git a/Controls/index.md b/Controls/index.md
index 2642e5a..de8b379 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) |
+| `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 |
| `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 5fc32e8..977a3a0 100644
--- a/HANDOFF.md
+++ b/HANDOFF.md
@@ -34,6 +34,12 @@
(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`.
- **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
@@ -169,10 +175,6 @@ 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
-- **The creator's remaining ask (not started):** the webcam **Border Color** drop-down should show the
- **colour NAME next to the hex** (e.g. `#e94560 Crimson`) — the hex stays, the name is the addition.
- Plan: a testable hex→name catalogue (Models/Helpers) consumed by the code-behind
- `WebcamBorderColorPicker_Click` in `Controls/LeftPanel.xaml.cs`; the tooltip becomes "Name (#hex)".
- **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
@@ -183,6 +185,10 @@ Zombie entities deleted from the registry: `script.leak_strobe`, `script.doorbel
- Nothing else is queued for llamacasty — the creator queues work.
## Open (operational)
+- **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.
- **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
@@ -207,9 +213,9 @@ 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: **432/432 passed, 0 warnings** (2026-10-21, this session — after TASK 59's
- `PropertiesHierarchyTests`; the run before it was 431/431 after TASK 58's
- `PropertiesStyleTests`). The one failure seen earlier in the session was
+- Last full run: **433/433 passed, 0 warnings** (2026-10-21, this session — after TASK 60's
+ `BorderColorPaletteTests`; the runs before it were 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/Models/BorderColorPalette.cs b/Models/BorderColorPalette.cs
new file mode 100644
index 0000000..abaa489
--- /dev/null
+++ b/Models/BorderColorPalette.cs
@@ -0,0 +1,49 @@
+namespace ytLive.Models;
+
+/// A colour the border picker offers: its hex value and the plain-English
+/// 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);
+
+/// The colours the webcam's border-colour drop-down offers — the SINGLE
+/// source of truth for the list and for the names.
+///
+/// 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.
+///
+public static class BorderColorPalette
+{
+ /// The offered colours, in menu order.
+ public static IReadOnlyList 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"),
+ };
+
+ /// The name of a hex value (case-insensitive). A colour that is not in
+ /// the palette keeps its hex — there is no name to give it.
+ public static string NameOf(string hex)
+ {
+ if (string.IsNullOrWhiteSpace(hex)) return string.Empty;
+ var trimmed = hex.Trim();
+ foreach (var colour in All)
+ if (string.Equals(colour.Hex, trimmed, StringComparison.OrdinalIgnoreCase))
+ return colour.Name;
+ return trimmed;
+ }
+}
diff --git a/Models/index.md b/Models/index.md
index e884ec9..36bd033 100644
--- a/Models/index.md
+++ b/Models/index.md
@@ -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:`/`window:`/`picker:`), `IsLiveCapture` (`Type` is Display/WindowCapture) |
+| `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 |
| `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; a colour outside the palette keeps its hex). The creator asked for a colour NAME next to each hex: `#e94560 Crimson` |
| `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 e19eae4..40bed42 100644
--- a/MyMistakes.md
+++ b/MyMistakes.md
@@ -2142,3 +2142,32 @@ holds the truth.
**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 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 — the fallback returns the value itself, so
+ the UI never shows an empty name.
+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.
diff --git a/TASKS.md b/TASKS.md
index 3d17232..592f25c 100644
--- a/TASKS.md
+++ b/TASKS.md
@@ -75,6 +75,7 @@
| 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) |
---
diff --git a/TASKS/task-60-border-color-names.md b/TASKS/task-60-border-color-names.md
new file mode 100644
index 0000000..a990de2
--- /dev/null
+++ b/TASKS/task-60-border-color-names.md
@@ -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 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).
diff --git a/ai.md b/ai.md
index 0cc7cc2..4647b58 100644
--- a/ai.md
+++ b/ai.md
@@ -278,6 +278,11 @@ C# / WPF (.NET 8) following MVVM:
- **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
- **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)`), 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
- 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)
diff --git a/ytLive.Tests/BorderColorPaletteTests.cs b/ytLive.Tests/BorderColorPaletteTests.cs
new file mode 100644
index 0000000..e50a345
--- /dev/null
+++ b/ytLive.Tests/BorderColorPaletteTests.cs
@@ -0,0 +1,53 @@
+using System;
+using System.Linq;
+using Xunit;
+using ytLive.Models;
+
+namespace ytLive.Tests;
+
+/// TASK 60 — the creator (2026-10-21): *"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."*
+///
+/// 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.
+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 keeps an unknown hex as-is.
+ Assert.Equal("Crimson", BorderColorPalette.NameOf("#e94560"));
+ Assert.Equal("Crimson", BorderColorPalette.NameOf("#E94560"));
+ Assert.Equal("White", BorderColorPalette.NameOf(" #ffffff "));
+ Assert.Equal("#123456", BorderColorPalette.NameOf("#123456"));
+
+ // 4. The picker builds its menu from the catalogue — there is no second list.
+ 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\""),
+ "the border picker still carries its own list of colours");
+ }
+}