TASK 61 + 62: the geometry fields share one width, and the border field names its colour

The creator (2026-10-21), on Live -> Layers -> webcam -> Properties:

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

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

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

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

Guards: ytLive.Tests/GeometryFieldWidthTests.cs, ytLive.Tests/BorderColorNameTests.cs
Full suite: 435/435 passed, 0 warnings.
This commit is contained in:
2026-10-21 22:19:13 -07:00
parent 60b0218fa3
commit ef52508b1e
14 changed files with 322 additions and 30 deletions
+20 -9
View File
@@ -273,21 +273,24 @@
one panel bound to SelectedElement.IsWebcam. -->
<StackPanel Visibility="{Binding IsWebcamSelected, Converter={StaticResource BoolToVis}}" Margin="0,0,0,8">
<TextBlock Text="Geometry" Foreground="#a0a0b0" FontSize="11" FontWeight="SemiBold" Margin="0,8,0,4"/>
<DockPanel Margin="10,0,0,4">
<!-- TASK 61: the four fields share ONE fixed width — wide enough
for the largest geometry value (1920, plus a typed sign) and
no wider, so Y/H no longer stretch to fill the row. -->
<DockPanel Margin="10,0,0,4" LastChildFill="False">
<TextBlock DockPanel.Dock="Left" Text="X" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="16"/>
<TextBox Text="{Binding SelectedElement.X, UpdateSourceTrigger=PropertyChanged, StringFormat=F0}"
Style="{StaticResource YtTextBoxCompact}"/>
Style="{StaticResource YtTextBoxCompact}" Width="44"/>
<TextBlock DockPanel.Dock="Left" Text=" Y" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="20" Margin="8,0,0,0"/>
<TextBox Text="{Binding SelectedElement.Y, UpdateSourceTrigger=PropertyChanged, StringFormat=F0}"
Style="{StaticResource YtTextBoxCompact}"/>
Style="{StaticResource YtTextBoxCompact}" Width="44"/>
</DockPanel>
<DockPanel Margin="10,0,0,4">
<DockPanel Margin="10,0,0,4" LastChildFill="False">
<TextBlock DockPanel.Dock="Left" Text="W" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="16"/>
<TextBox Text="{Binding SelectedElement.Width, UpdateSourceTrigger=PropertyChanged, StringFormat=F0}"
Style="{StaticResource YtTextBoxCompact}"/>
Style="{StaticResource YtTextBoxCompact}" Width="44"/>
<TextBlock DockPanel.Dock="Left" Text=" H" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="20" Margin="8,0,0,0"/>
<TextBox Text="{Binding SelectedElement.Height, UpdateSourceTrigger=PropertyChanged, StringFormat=F0}"
Style="{StaticResource YtTextBoxCompact}"/>
Style="{StaticResource YtTextBoxCompact}" Width="44"/>
</DockPanel>
</StackPanel>
@@ -298,9 +301,17 @@
<Button DockPanel.Dock="Right" Content="..." Width="24" Height="24"
Click="WebcamBorderColorPicker_Click" Margin="4,0,0,0"
ToolTip="Pick color"/>
<TextBox Text="{Binding SelectedElement.BorderColor, UpdateSourceTrigger=PropertyChanged}"
Style="{StaticResource YtTextBoxCompact}"
MaxLength="7" ToolTip="#RRGGBB"/>
<!-- TASK 62: the hex field keeps its own fixed width so the
colour's NAME can sit right next to it, exactly as it
reads in the drop-down list. Empty for a custom hex. -->
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<TextBox Text="{Binding SelectedElement.BorderColor, UpdateSourceTrigger=PropertyChanged}"
Style="{StaticResource YtTextBoxCompact}"
MaxLength="7" ToolTip="#RRGGBB" Width="60"/>
<TextBlock Text="{Binding SelectedElement.BorderColorName}"
Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center"
Margin="8,0,0,0"/>
</StackPanel>
</DockPanel>
<TextBlock Text="Border Thickness" Foreground="#a0a0b0" FontSize="11" FontWeight="SemiBold" Margin="0,8,0,2"/>
<DockPanel Margin="10,0,0,6">
+1 -1
View File
@@ -12,7 +12,7 @@ See [`schema.md`](../schema.md) for the memory-map conventions.
| `TopBar.xaml(.cs)` | Row 0 session surface (2026-09-22 redesign): brand + gear menu (left), one segmented REC|ON-AIR mode switch + world actions (Record: Start Recording · Stream: Go Live + Test) + reality line while running (dot/word/elapsed) + End (center), account zone — Sign In or avatar w/ Change Account/Logout context menu (right). `RefreshAvatar()`, `GearButton_Click` |
| `PreviewPane.xaml(.cs)` | Row 1 Col 1 center: the 1920×1080 `OverlayCanvas`/`SelectionOverlay`/`BrandFlashLayer`/`SocialBarElement`, text pull-out drawer, and the live-controls row (TRAX / game / mic meters + sliders + speakers + socials). **TASK 57:** dragging a webcam under the vertical tier routes through `MainViewModel.MoveWebcamInVerticalTier` (the six-spot snap inside the cropped band) instead of free-floating over the master, and a resize re-snaps the tile onto its spot (TASK 57 Unit 2: the resize routes through `MainViewModel.ResizeWebcamInVerticalTier` — the Chat camera's two sizes, then the snap) |
| `SceneThumbnailStrip.xaml(.cs)` | Row 0 scene-picker strip (`ItemsControl` over `Scenes`), staged-red / live-green border highlight |
| `LeftPanel.xaml(.cs)` | Row 1 Col 0 left panel: edit mode → layers+properties; live → YouTube chat. Source add/edit/remove, list drag-to-reorder, snapshot revert-accept. **TASK 51:** the webcam properties carry the Background section — `RemoveBackgroundPill` / `BlurBackgroundPill` (`PillToggle`, radio-exclusive via `WebcamSceneConfig.RemoveBackground`/`BlurBackground`) + `BackgroundStrengthSlider` (0..1, 5% snaps, shown only while an effect is on). **TASK 57:** the webcam properties carry a Geometry section (X/Y/W/H) — manual placement of the camera, bound to the **live** placement (the one the current tier shows), gated on `IsWebcamSelected` (not `SelectedElement.IsWebcam` — the TASK 51 test finds that panel by binding text and would match twice); the Shape button's `Visibility` is bound to `CanToggleClipShape`, so the circle option is NOT OFFERED in the vertical Chat layout. **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 |
+23 -9
View File
@@ -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…`,
+4 -3
View File
@@ -35,8 +35,9 @@ public static class BorderColorPalette
new("#f59e0b", "Amber"),
};
/// <summary>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.</summary>
/// <summary>The colour's name, or an **empty string** when the palette has no name
/// for that hex (a custom hex the creator typed) — the caller decides what to show
/// then, and the border field shows the hex alone.</summary>
public static string NameOf(string hex)
{
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;
}
}
+6
View File
@@ -264,10 +264,16 @@ public abstract class SceneElement : INotifyPropertyChanged
{
Raise(nameof(HasBorder));
Raise(nameof(BorderBrush));
Raise(nameof(BorderColorName));
}
}
}
/// <summary>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).</summary>
public string BorderColorName => BorderColorPalette.NameOf(BorderColor);
private double _borderOpacity = 1.0;
public double BorderOpacity
{
+2 -2
View File
@@ -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:<n>`/`window:<hwnd>`/`picker:<name>`), `IsLiveCapture` (`Type` is Display/WindowCapture) |
| `SceneElement.cs` | Base for anything placeable in a scene: shared layout/clip/mirror/border surface, `virtual IsWebcam`/`virtual IsImageSource`/`virtual IsZoomLayer` (TASK 52); `ToggleClipShape` + persisted `RectWidth`/`RectHeight` (pre-Round rect so round→rect restores after reload); **TASK 57:** the geometry setters (`X`/`Y`/`Width`/`Height`) are `virtual` so a derived element can CONSTRAIN every write (the webcam clamps itself into the vertical tier's band) instead of every writer re-implementing the rule; `ClipShape` + `ToggleClipShape()` are `virtual` too, so a derived element can CONSTRAIN the shape (the Chat scene's vertical camera is rect-only); **TASK 59:** `IsRectShape`/`IsRoundShape` — the shape as a CHOICE (the properties panel shows Rect | Circle with Rect selected by default), each a settable bool whose setter flips the shape through `ToggleClipShape()` and always re-raises, so the UI snaps back when a write is refused |
| `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 |
+44 -2
View File
@@ -2155,8 +2155,11 @@ without reading the UI code.
`static IReadOnlyList<NamedColor> All` in `Models/`. Models = "plain data types, no logic
beyond what a property can carry" (see `Models/index.md`).
2. **Give it a lookup** (`NameOf(hex)`, case-insensitive) that answers for a value the user
typed, not only for the palette's own entries — 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).
+2
View File
@@ -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) |
---
+43
View File
@@ -0,0 +1,43 @@
# TASK 61 — The geometry fields share one fixed width
**Status:** ✅ Done — shipped 2026-10-21.
## What it is
The creator (2026-10-21), on Live → Layers → webcam → Properties → **Geometry**:
> the Y and H boxes under webcam properties should be the same width as the X and W boxes
> and those widths should be fixed to accomodate the max number of possible digits
> providing the geometry values
## Why they looked different
The row is a `DockPanel`:
```
[X label][X field][ Y label][Y field]
```
A WPF element **sizes itself to its content** unless it is told otherwise, and in a
`DockPanel` the **last child fills the remaining space**. So the X/W fields (not last)
were as wide as their text ("1920" ≈ 34px) while the Y/H fields (last) stretched to the
full width of the row — two different widths for the same kind of field.
## The fix
- **All four fields carry the same explicit `Width="44"`** — wide enough for the largest
geometry value (the master frame is 1920×1080 → four digits) **plus a sign** the creator
may be typing, and no wider.
- **The rows set `LastChildFill="False"`** — otherwise the last field would still stretch
to the row's width regardless of its own `Width`.
44px = the measured width of `-1920` in the app's font at 11px (≈28px) + the field's
padding (8) + its border (2) + slack.
## The test
`ytLive.Tests/GeometryFieldWidthTests.cs` — the ONE test for this change: the four
fields carry the same **explicit** width (no field sizes itself to its content), and that
width really fits the widest value — measured with WPF's `FormattedText` in the app's own
font (`SystemFonts.MessageFontFamily`) at the field's 11px, plus the padding and border.
## Not done (out of scope, noted in `HANDOFF.md`)
The **web source** block's geometry rows (`Controls/LeftPanel.xaml`, the
`SelectedElement.IsWebSource` panel) have the identical shape and the identical defect —
same fix, one line each, when the creator wants it.
@@ -0,0 +1,42 @@
# TASK 62 — The border-colour field shows the colour's name next to the hex
**Status:** ✅ Done — shipped 2026-10-21.
## What it is
The creator (2026-10-21), on Live → Layers → webcam → Properties → **Border Color**,
after TASK 60 gave the drop-down list its names:
> the border color drop drown box content should also include the name of the color next
> to the hex value as they in the drop-down list box
So the row that shows the **current** colour now reads the same way the list rows do:
```
Border Color
[#2c3a5c] Azure [...]
```
## How it is done
- **`SceneElement.BorderColorName`** — the palette's name for the current border colour,
raised whenever `BorderColor` changes. **Empty for a custom hex** the creator typed
(there is no name for it — the field shows the hex alone).
- The **hex field keeps a fixed width** (`Width="60"`): it used to fill the row, so a name
beside it would have drifted to the far edge instead of sitting next to the hex.
- The name is a dimmed `TextBlock` (`#a0a0b0`, 11px) in the same horizontal row as the
field, after it — the same reading order as the drop-down list (`#e94560 Crimson`).
**The field stays editable** — the hex is still typed into it (the picker's `...` button
opens the named list). Making the field read-only with a combined "#hex Name" string was
the alternative; it would have taken away typing a custom hex, so the name sits beside
the field instead.
## `BorderColorPalette.NameOf` changed
It used to fall back to **the hex itself** for a colour outside the palette ("there is no
name to give it"). It now returns an **empty string** — "no name" is the honest answer,
and the caller decides what to show (the field shows the hex alone). The TASK 60 test
assertion was updated with it.
## Tests
`ytLive.Tests/BorderColorNameTests.cs` — the ONE test for this change: the model carries
the name ("Crimson") and raises it when the colour changes, a custom hex has no name, the
hex field has a fixed width, and the name sits in the same row as the field, after it.
+11 -2
View File
@@ -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)
+56
View File
@@ -0,0 +1,56 @@
using System.Collections.Generic;
using System.Linq;
using System.Xml.Linq;
using Xunit;
using ytLive.Models;
namespace ytLive.Tests;
/// <summary>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."*
///
/// <para>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.</para></summary>
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<string?>();
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");
}
}
+3 -2
View File
@@ -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");
+65
View File
@@ -0,0 +1,65 @@
using System;
using System.Globalization;
using System.Linq;
using System.Windows;
using System.Windows.Media;
using System.Xml.Linq;
using Xunit;
namespace ytLive.Tests;
/// <summary>TASK 61 — the creator (2026-10-21): *"the Y and H boxes under webcam
/// properties should be the same width as the X and W boxes and those widths should be
/// fixed to accommodate the max number of possible digits providing the geometry
/// values."*
///
/// <para>One test: the four geometry fields carry the SAME explicit width (the Y/H
/// fields used to stretch to fill the row, because they were the last child of a
/// DockPanel, while X/W sized themselves to their content), and that width really fits
/// the widest value the panel can show — the largest geometry value (1920 master
/// pixels) with a sign, measured in the app's own font at the field's size.</para></summary>
public class GeometryFieldWidthTests
{
/// <summary>The widest value a geometry field can show: the master frame is
/// 1920×1080, so four digits — plus a sign the creator may be typing.</summary>
private const string WidestValue = "-1920";
[Fact]
public void The_webcam_geometry_fields_share_one_fixed_width_that_fits_the_largest_value()
{
var doc = XDocument.Load(SourceAssert.RepoPath("Controls", "LeftPanel.xaml"));
var geometry = doc.Descendants()
.Where(e => e.Name.LocalName == "TextBlock" && (string?)e.Attribute("Text") == "Geometry")
.Single(e => ((string?)e.Parent?.Attribute("Visibility") ?? "").Contains("IsWebcamSelected"));
var rows = geometry.Parent!.Elements().Where(e => e.Name.LocalName == "DockPanel").ToList();
Assert.Equal(2, rows.Count);
var fields = rows.SelectMany(r => r.Elements().Where(e => e.Name.LocalName == "TextBox")).ToList();
Assert.Equal(4, fields.Count);
// Every field carries the SAME explicit width — a field without one sizes
// itself to its content, and the last one in the row stretches to fill it.
var widths = fields.Select(f => (string?)f.Attribute("Width")).ToList();
Assert.All(widths, w => Assert.False(string.IsNullOrWhiteSpace(w),
"a geometry field has no fixed width — it sizes itself to its content"));
Assert.Single(widths.Distinct());
// And that width fits the widest value the panel can show, in the app's font
// at the field's size, plus the field's own padding and border.
var width = double.Parse(widths[0]!, CultureInfo.InvariantCulture);
var needed = MeasureWidestValue() + 8 /* YtTextBoxCompact padding */ + 2 /* border */;
Assert.True(width >= needed,
$"the geometry fields are {width:0}px wide; {needed:0.#}px are needed for '{WidestValue}'");
}
/// <summary>The width of the widest possible value, in the app's font at 11px.</summary>
private static double MeasureWidestValue()
{
var typeface = new Typeface(SystemFonts.MessageFontFamily,
FontStyles.Normal, FontWeights.Normal, FontStretches.Normal);
return new FormattedText(WidestValue, CultureInfo.CurrentCulture,
FlowDirection.LeftToRight, typeface, 11, Brushes.Black, pixelsPerDip: 1.0).Width;
}
}