diff --git a/Controls/LeftPanel.xaml b/Controls/LeftPanel.xaml
index 5c26fce..9cb90e8 100644
--- a/Controls/LeftPanel.xaml
+++ b/Controls/LeftPanel.xaml
@@ -273,21 +273,24 @@
one panel bound to SelectedElement.IsWebcam. -->
-
+
+
+ Style="{StaticResource YtTextBoxCompact}" Width="44"/>
+ Style="{StaticResource YtTextBoxCompact}" Width="44"/>
-
+
+ Style="{StaticResource YtTextBoxCompact}" Width="44"/>
+ Style="{StaticResource YtTextBoxCompact}" Width="44"/>
@@ -298,9 +301,17 @@
-
+
+
+
+
+
diff --git a/Controls/index.md b/Controls/index.md
index de8b379..0dcdda0 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 |
+| `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 |
| `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 9bc112f..6dae579 100644
--- a/HANDOFF.md
+++ b/HANDOFF.md
@@ -1,11 +1,12 @@
# HANDOFF — 2026-10-21 (the creator's three properties-panel asks shipped; the vv webcam Units 1+2 state below)
## At a glance
-- **Branch** main, tracking `origin/main`. **HEAD = `6271bf3` — origin/main is still `3a77662`, so
- three commits are waiting to be pushed** (TASK 58 the theme sweep, TASK 59 the properties
- hierarchy, TASK 60 the colour names — the creator's three property-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.
+- **Branch** main, tracking `origin/main`. **origin/main is still `3a77662`, so five 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
+ 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
the Unit 1 push after testing it, and the completed feature is a milestone).
- **The push path changed:** `origin` is now
@@ -43,6 +44,15 @@
moved out of the click handler into `Models/BorderColorPalette.cs` (`NamedColor(Hex, Name)` +
`NameOf(hex)`), so the list is testable and extensible. Guard:
`ytLive.Tests/BorderColorPaletteTests.cs`.
+- **TASK 61 + 62 — the geometry fields and the border-colour field** (shipped 2026-10-21).
+ - **61:** the X/Y/W/H fields share ONE fixed width (44px — the largest geometry value, 1920,
+ plus a sign) and their rows set `LastChildFill="False"`, so Y/H no longer stretch to fill
+ the row while X/W sized themselves to their text. Guard: `ytLive.Tests/GeometryFieldWidthTests.cs`
+ (it MEASURES the widest value with WPF's `FormattedText` — measure, don't guess).
+ - **62:** the border-colour field shows the palette's **name beside the hex**
+ (`SceneElement.BorderColorName`, empty for a custom hex), and the hex field keeps a fixed
+ width so the name stays next to the hex. The field stays editable (a custom hex can
+ still be typed). Guard: `ytLive.Tests/BorderColorNameTests.cs`.
- **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 +198,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 (not fixed — out of scope for TASK 61):** the **web source** block's geometry rows in
+ `Controls/LeftPanel.xaml` (the `SelectedElement.IsWebSource` panel, ~lines 387-402) have the
+ identical shape and the identical defect as the webcam's did — the Y/H fields stretch to fill the
+ row. Same one-line-per-field fix as TASK 61, when the creator wants it.
- **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
@@ -216,10 +230,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: **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`,
+- 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
+ session was `LayerReorderPersistenceTests.RealMouseDrag_OnTheLayerList_PersistsTheReorder`,
which **passes in isolation** (re-run: 3/3) — the known real-mouse flake, not a regression.
- Known flaky-by-environment (all pass in isolation): `AudioPipelineTests.Mix_HonorsProviderGains…`,
`MasterLimiter_CapsTheLiveMix_OnThePipe`, `LayerReorderPersistenceTests.RealMouseDrag…`,
diff --git a/Models/BorderColorPalette.cs b/Models/BorderColorPalette.cs
index abaa489..9b26044 100644
--- a/Models/BorderColorPalette.cs
+++ b/Models/BorderColorPalette.cs
@@ -35,8 +35,9 @@ public static class BorderColorPalette
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.
+ /// 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)
{
if (string.IsNullOrWhiteSpace(hex)) return string.Empty;
@@ -44,6 +45,6 @@ public static class BorderColorPalette
foreach (var colour in All)
if (string.Equals(colour.Hex, trimmed, StringComparison.OrdinalIgnoreCase))
return colour.Name;
- return trimmed;
+ return string.Empty;
}
}
diff --git a/Models/SceneElement.cs b/Models/SceneElement.cs
index c5e6937..fa9847e 100644
--- a/Models/SceneElement.cs
+++ b/Models/SceneElement.cs
@@ -264,10 +264,16 @@ public abstract class SceneElement : INotifyPropertyChanged
{
Raise(nameof(HasBorder));
Raise(nameof(BorderBrush));
+ Raise(nameof(BorderColorName));
}
}
}
+ /// 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);
+
private double _borderOpacity = 1.0;
public double BorderOpacity
{
diff --git a/Models/index.md b/Models/index.md
index 36bd033..651b93c 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 |
+| `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 |
| `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` |
+| `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` |
| `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 40bed42..267db88 100644
--- a/MyMistakes.md
+++ b/MyMistakes.md
@@ -2155,8 +2155,11 @@ without reading the UI code.
`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.
+ typed, not only for the palette's own entries. **What it answers for a colour OUTSIDE the
+ palette is a judgement call, and it changed once (TASK 62):** it used to fall back to the
+ value itself, so the UI never showed an empty name — but "no name" is the honest answer, and
+ the caller decides what to show (the border field shows the hex alone for a custom hex). The
+ fallback that lies is worse than the empty string.
3. **The UI becomes a consumer**: the handler iterates the catalogue and builds the rows. The
handler must not know any colour by heart.
4. **Guard it in the test**: every entry valid + named, no hex or name repeated, the colours
@@ -2171,3 +2174,42 @@ without reading the UI code.
**Why this matters beyond names:** the next request ("add a dark mode", "let me type a
custom colour", "reorder them") is now a change in ONE file instead of a change inside a
click handler.
+
+## 2026-10-21 — A ROW OF SAME-KIND FIELDS NEEDS EXPLICIT WIDTHS (RECIPE — TASK 61)
+
+The creator: *"the Y and H boxes should be the same width as the X and W boxes and those
+widths should be fixed to accomodate the max number of possible digits."* The row was a
+`DockPanel`, and WPF gave the four fields **two different widths** for no reason anyone chose.
+
+**The trap (worth remembering on its own):**
+- A WPF element **sizes itself to its content** unless it is told otherwise. So the X/W fields
+ (not the last child) were as wide as their text ("1920" ≈ 34px).
+- In a `DockPanel` the **LAST child fills the remaining space** — so the Y/H fields stretched to
+ the full width of the row. Two widths for the same kind of field, purely by position.
+
+**The recipe:**
+1. Give **every** field in the row an explicit `Width` (one value, repeated — the point is that
+ they are EQUAL).
+2. Set `LastChildFill="False"` on the row, or the last field ignores its own `Width` and fills
+ anyway.
+3. **Measure the width, don't guess it.** The needed width = the widest value the field can
+ hold, measured in the app's own font at the field's size, plus the field's padding and
+ border. WPF measures it for you, headlessly, with no window:
+
+ ```csharp
+ var typeface = new Typeface(SystemFonts.MessageFontFamily, FontStyles.Normal,
+ FontWeights.Normal, FontStretches.Normal);
+ var text = new FormattedText("-1920", CultureInfo.CurrentCulture, FlowDirection.LeftToRight,
+ typeface, 11, Brushes.Black, pixelsPerDip: 1.0).Width;
+ var needed = text + 8 /* padding */ + 2 /* border */;
+ ```
+
+ `SystemFonts.MessageFontFamily` is exactly what a control uses when no font is set, so the
+ measurement matches what is drawn. (The geometry fields came out at 44px — 28 of text, 10 of
+ chrome, 6 of slack.)
+4. **Assert the measurement in the test**, not a magic number: parse the XAML, take the widths,
+ and fail if the row's width cannot hold the widest value. A hard-coded "44" in the test
+ would pass even if the app's font changed and 44 stopped fitting.
+5. **A label beside a field needs the field to be FIXED too.** The same trap in a different
+ costume: a field that fills its row pushes whatever sits after it to the far edge, so the
+ label drifts away from the value it labels (TASK 62 — the colour name beside the hex field).
diff --git a/TASKS.md b/TASKS.md
index 592f25c..9ba1835 100644
--- a/TASKS.md
+++ b/TASKS.md
@@ -76,6 +76,8 @@
| 58 | **Properties-panel style consistency (the theme owns the look)** | ✅ Done — 2026-10-21 (the creator's "why is the drop-down not in the same style as the others?" — the drop-downs/fields/check boxes had been hand-painted instead of themed, so WPF drew the light system template; now every one carries a theme style, guarded by a test) | [`TASKS/task-58-properties-style-consistency.md`](TASKS/task-58-properties-style-consistency.md) |
| 59 | **The properties panel is a hierarchy (titles own their content)** | ✅ Done — 2026-10-21 (the creator's ruling: a "Shape" title above the Mirror/Rect row, the drag hint indented under it, the same for Geometry / Border Color / Border Thickness, Remove + Blur under Background, Strength under Blur — and the shape became a CHOICE: Rect | Circle with Rect selected by default) | [`TASKS/task-59-properties-hierarchy.md`](TASKS/task-59-properties-hierarchy.md) |
| 60 | **The border-colour drop-down names every colour** | ✅ Done — 2026-10-21 (the creator's "the #hex values are good, but add a corresponding color name/label next to the value" — the 14 colours moved out of the click handler into `Models/BorderColorPalette.cs`, and each row now reads "#e94560 Crimson") | [`TASKS/task-60-border-color-names.md`](TASKS/task-60-border-color-names.md) |
+| 61 | **The geometry fields share one fixed width** | ✅ Done — 2026-10-21 (the creator's "the Y and H boxes should be the same width as the X and W boxes and those widths should be fixed to accomodate the max number of possible digits" — the Y/H fields used to stretch to fill the row; all four are now 44px, wide enough for 1920 plus a sign) | [`TASKS/task-61-geometry-field-widths.md`](TASKS/task-61-geometry-field-widths.md) |
+| 62 | **The border-colour field shows the colour's name next to the hex** | ✅ Done — 2026-10-21 (the creator: "the border color drop down box content should also include the name of the color next to the hex value as they in the drop-down list box" — the field keeps the hex editable and the palette's name sits right beside it, empty for a custom hex) | [`TASKS/task-62-border-colour-name-in-the-field.md`](TASKS/task-62-border-colour-name-in-the-field.md) |
---
diff --git a/TASKS/task-61-geometry-field-widths.md b/TASKS/task-61-geometry-field-widths.md
new file mode 100644
index 0000000..f1ac835
--- /dev/null
+++ b/TASKS/task-61-geometry-field-widths.md
@@ -0,0 +1,43 @@
+# TASK 61 — The geometry fields share one fixed width
+
+**Status:** ✅ Done — shipped 2026-10-21.
+
+## What it is
+The creator (2026-10-21), on Live → Layers → webcam → Properties → **Geometry**:
+
+> the Y and H boxes under webcam properties should be the same width as the X and W boxes
+> and those widths should be fixed to accomodate the max number of possible digits
+> providing the geometry values
+
+## Why they looked different
+The row is a `DockPanel`:
+
+```
+[X label][X field][ Y label][Y field]
+```
+
+A WPF element **sizes itself to its content** unless it is told otherwise, and in a
+`DockPanel` the **last child fills the remaining space**. So the X/W fields (not last)
+were as wide as their text ("1920" ≈ 34px) while the Y/H fields (last) stretched to the
+full width of the row — two different widths for the same kind of field.
+
+## The fix
+- **All four fields carry the same explicit `Width="44"`** — wide enough for the largest
+ geometry value (the master frame is 1920×1080 → four digits) **plus a sign** the creator
+ may be typing, and no wider.
+- **The rows set `LastChildFill="False"`** — otherwise the last field would still stretch
+ to the row's width regardless of its own `Width`.
+
+44px = the measured width of `-1920` in the app's font at 11px (≈28px) + the field's
+padding (8) + its border (2) + slack.
+
+## The test
+`ytLive.Tests/GeometryFieldWidthTests.cs` — the ONE test for this change: the four
+fields carry the same **explicit** width (no field sizes itself to its content), and that
+width really fits the widest value — measured with WPF's `FormattedText` in the app's own
+font (`SystemFonts.MessageFontFamily`) at the field's 11px, plus the padding and border.
+
+## Not done (out of scope, noted in `HANDOFF.md`)
+The **web source** block's geometry rows (`Controls/LeftPanel.xaml`, the
+`SelectedElement.IsWebSource` panel) have the identical shape and the identical defect —
+same fix, one line each, when the creator wants it.
diff --git a/TASKS/task-62-border-colour-name-in-the-field.md b/TASKS/task-62-border-colour-name-in-the-field.md
new file mode 100644
index 0000000..5c61bab
--- /dev/null
+++ b/TASKS/task-62-border-colour-name-in-the-field.md
@@ -0,0 +1,42 @@
+# TASK 62 — The border-colour field shows the colour's name next to the hex
+
+**Status:** ✅ Done — shipped 2026-10-21.
+
+## What it is
+The creator (2026-10-21), on Live → Layers → webcam → Properties → **Border Color**,
+after TASK 60 gave the drop-down list its names:
+
+> the border color drop drown box content should also include the name of the color next
+> to the hex value as they in the drop-down list box
+
+So the row that shows the **current** colour now reads the same way the list rows do:
+
+```
+Border Color
+ [#2c3a5c] Azure [...]
+```
+
+## How it is done
+- **`SceneElement.BorderColorName`** — the palette's name for the current border colour,
+ raised whenever `BorderColor` changes. **Empty for a custom hex** the creator typed
+ (there is no name for it — the field shows the hex alone).
+- The **hex field keeps a fixed width** (`Width="60"`): it used to fill the row, so a name
+ beside it would have drifted to the far edge instead of sitting next to the hex.
+- The name is a dimmed `TextBlock` (`#a0a0b0`, 11px) in the same horizontal row as the
+ field, after it — the same reading order as the drop-down list (`#e94560 Crimson`).
+
+**The field stays editable** — the hex is still typed into it (the picker's `...` button
+opens the named list). Making the field read-only with a combined "#hex Name" string was
+the alternative; it would have taken away typing a custom hex, so the name sits beside
+the field instead.
+
+## `BorderColorPalette.NameOf` changed
+It used to fall back to **the hex itself** for a colour outside the palette ("there is no
+name to give it"). It now returns an **empty string** — "no name" is the honest answer,
+and the caller decides what to show (the field shows the hex alone). The TASK 60 test
+assertion was updated with it.
+
+## Tests
+`ytLive.Tests/BorderColorNameTests.cs` — the ONE test for this change: the model carries
+the name ("Crimson") and raises it when the colour changes, a custom hex has no name, the
+hex field has a fixed width, and the name sits in the same row as the field, after it.
diff --git a/ai.md b/ai.md
index 4647b58..c7074a2 100644
--- a/ai.md
+++ b/ai.md
@@ -279,10 +279,19 @@ C# / WPF (.NET 8) following MVVM:
- 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
+ `Models/BorderColorPalette.cs` (`NamedColor(Hex, Name)` + `NameOf(hex)` — **empty** for a colour
+ outside the palette, "no name" being the honest answer), NOT from a list inside
the click handler — the creator asked for a colour NAME next to each hex (`#e94560 Crimson`), and a
hard-coded `string[]` cannot carry one. A palette the creator may want to extend belongs in a
- testable catalogue, with the UI as a consumer
+ 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 field in a dense panel carries a FIXED width (TASK 61):** a WPF element sizes itself to its
+ content, and in a `DockPanel` the **last child fills the row** — so a row of same-kind fields looks
+ broken unless every field has an explicit `Width` AND the row sets `LastChildFill="False"`. The
+ geometry fields are 44px: the measured width of `-1920` in the app's font at 11px (via WPF's
+ `FormattedText` — **measure, don't guess**) plus the field's padding and border. Guard:
+ `ytLive.Tests/GeometryFieldWidthTests.cs`
- Resolution tiers: 1080p60@8 (default) → 1080p30@8 → 720p60@6 → 720p30@6 → **Vertical 1080p60@8 (9:16, 1080×1920)**. The composition master frame is **always 1920×1080** — a tier is an output rect + target resolution over that master, so source geometry is never rewritten (no rounding drift). 16:9 tiers use the full frame; the vertical tier uses a centered **607×1080** window and the preview dims the cropped side strips at 55% black with an accent outline (semi-crop — the cut area stays visible). A resolution badge in the preview corner shows the active tier. Bottom bar: gear icon far left, stream stats (bitrate/FPS/dropped/duration) centered under preview, resolution dropdown far right. A **tooltip** explains finding upload bandwidth — an in-app speed test was deliberately dropped (unreliable). The future encoder crops the master to the rect and scales to the tier's Width×Height
- Crash diagnosis: `AppLog` writes startup checkpoints to `%APPDATA%\ytLlive\startup.log`; `App.xaml.cs` logs `DispatcherUnhandledException`/`AppDomain.UnhandledException`. When WPF won't run from WSL, this log is how you find the failure (it caught the `MenuItemRole.Separator` XAML crash and the ComboBox SelectionBoxItem bug)
diff --git a/ytLive.Tests/BorderColorNameTests.cs b/ytLive.Tests/BorderColorNameTests.cs
new file mode 100644
index 0000000..8358953
--- /dev/null
+++ b/ytLive.Tests/BorderColorNameTests.cs
@@ -0,0 +1,56 @@
+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 e50a345..7840be8 100644
--- a/ytLive.Tests/BorderColorPaletteTests.cs
+++ b/ytLive.Tests/BorderColorPaletteTests.cs
@@ -37,11 +37,12 @@ public class BorderColorPaletteTests
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.
+ // 3. The lookup answers for a hex in any case, and a colour outside the palette
+ // has NO name (the field shows the hex alone — TASK 62).
Assert.Equal("Crimson", BorderColorPalette.NameOf("#e94560"));
Assert.Equal("Crimson", BorderColorPalette.NameOf("#E94560"));
Assert.Equal("White", BorderColorPalette.NameOf(" #ffffff "));
- Assert.Equal("#123456", BorderColorPalette.NameOf("#123456"));
+ Assert.Equal("", 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");
diff --git a/ytLive.Tests/GeometryFieldWidthTests.cs b/ytLive.Tests/GeometryFieldWidthTests.cs
new file mode 100644
index 0000000..3f50375
--- /dev/null
+++ b/ytLive.Tests/GeometryFieldWidthTests.cs
@@ -0,0 +1,65 @@
+using System;
+using System.Globalization;
+using System.Linq;
+using System.Windows;
+using System.Windows.Media;
+using System.Xml.Linq;
+using Xunit;
+
+namespace ytLive.Tests;
+
+/// TASK 61 — the creator (2026-10-21): *"the Y and H boxes under webcam
+/// properties should be the same width as the X and W boxes and those widths should be
+/// fixed to accommodate the max number of possible digits providing the geometry
+/// values."*
+///
+/// One test: the four geometry fields carry the SAME explicit width (the Y/H
+/// fields used to stretch to fill the row, because they were the last child of a
+/// DockPanel, while X/W sized themselves to their content), and that width really fits
+/// the widest value the panel can show — the largest geometry value (1920 master
+/// pixels) with a sign, measured in the app's own font at the field's size.
+public class GeometryFieldWidthTests
+{
+ /// The widest value a geometry field can show: the master frame is
+ /// 1920×1080, so four digits — plus a sign the creator may be typing.
+ private const string WidestValue = "-1920";
+
+ [Fact]
+ public void The_webcam_geometry_fields_share_one_fixed_width_that_fits_the_largest_value()
+ {
+ var doc = XDocument.Load(SourceAssert.RepoPath("Controls", "LeftPanel.xaml"));
+
+ var geometry = doc.Descendants()
+ .Where(e => e.Name.LocalName == "TextBlock" && (string?)e.Attribute("Text") == "Geometry")
+ .Single(e => ((string?)e.Parent?.Attribute("Visibility") ?? "").Contains("IsWebcamSelected"));
+
+ var rows = geometry.Parent!.Elements().Where(e => e.Name.LocalName == "DockPanel").ToList();
+ Assert.Equal(2, rows.Count);
+
+ var fields = rows.SelectMany(r => r.Elements().Where(e => e.Name.LocalName == "TextBox")).ToList();
+ Assert.Equal(4, fields.Count);
+
+ // Every field carries the SAME explicit width — a field without one sizes
+ // itself to its content, and the last one in the row stretches to fill it.
+ var widths = fields.Select(f => (string?)f.Attribute("Width")).ToList();
+ Assert.All(widths, w => Assert.False(string.IsNullOrWhiteSpace(w),
+ "a geometry field has no fixed width — it sizes itself to its content"));
+ Assert.Single(widths.Distinct());
+
+ // And that width fits the widest value the panel can show, in the app's font
+ // at the field's size, plus the field's own padding and border.
+ var width = double.Parse(widths[0]!, CultureInfo.InvariantCulture);
+ var needed = MeasureWidestValue() + 8 /* YtTextBoxCompact padding */ + 2 /* border */;
+ Assert.True(width >= needed,
+ $"the geometry fields are {width:0}px wide; {needed:0.#}px are needed for '{WidestValue}'");
+ }
+
+ /// The width of the widest possible value, in the app's font at 11px.
+ private static double MeasureWidestValue()
+ {
+ var typeface = new Typeface(SystemFonts.MessageFontFamily,
+ FontStyles.Normal, FontWeights.Normal, FontStretches.Normal);
+ return new FormattedText(WidestValue, CultureInfo.CurrentCulture,
+ FlowDirection.LeftToRight, typeface, 11, Brushes.Black, pixelsPerDip: 1.0).Width;
+ }
+}