diff --git a/Controls/LeftPanel.xaml b/Controls/LeftPanel.xaml
index 2019db4..5c26fce 100644
--- a/Controls/LeftPanel.xaml
+++ b/Controls/LeftPanel.xaml
@@ -238,18 +238,31 @@
ValueChanged="OpacitySlider_ValueChanged" VerticalAlignment="Center"/>
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Foreground="#a0a0b0" FontSize="10" Margin="10,2,0,0"/>
-
-
+
+
@@ -268,7 +281,7 @@
-
+
@@ -280,8 +293,8 @@
-
-
+
+
@@ -289,8 +302,8 @@
Style="{StaticResource YtTextBoxCompact}"
MaxLength="7" ToolTip="#RRGGBB"/>
-
-
+
+
-
-
+ (one on turns the other off; both off = no effect). TASK 59:
+ the pills are indented under the Background title, and the
+ strength row one level deeper, under Blur. -->
+
+
-
+
- True when the element's clip is the plain rectangle — the shape a
+ /// camera has when none was ever picked (TASK 59: the properties panel shows
+ /// the shape as a CHOICE, and Rect is the selected default).
+ public bool IsRectShape
+ {
+ get => ClipShape != ClipShape.Round;
+ set
+ {
+ // Round -> rect (restores the pre-round rect, like the toggle does).
+ if (value && ClipShape == ClipShape.Round) ToggleClipShape();
+ Raise(nameof(IsRectShape));
+ }
+ }
+
+ /// True when the element's clip is a circle (TASK 59).
+ public bool IsRoundShape
+ {
+ get => ClipShape == ClipShape.Round;
+ set
+ {
+ // Rect -> round (stores the pre-round rect, like the toggle does).
+ if (value && ClipShape != ClipShape.Round) ToggleClipShape();
+ Raise(nameof(IsRoundShape));
}
}
@@ -170,7 +199,6 @@ public abstract class SceneElement : INotifyPropertyChanged
public double MirrorScale => IsMirrored ? -1 : 1;
public string MirrorButtonText => IsMirrored ? "Unmirror" : "Mirror";
- public string ShapeButtonText => ClipShape == ClipShape.Round ? "Rect" : "Round";
// Live camera frames: the shared WriteableBitmap owned by CameraManager.
private WriteableBitmap? _videoImageSource;
diff --git a/Models/index.md b/Models/index.md
index 1c4a513..e884ec9 100644
--- a/Models/index.md
+++ b/Models/index.md
@@ -8,7 +8,7 @@ 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) |
-| `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) |
+| `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) |
diff --git a/MyMistakes.md b/MyMistakes.md
index 847eeb3..e19eae4 100644
--- a/MyMistakes.md
+++ b/MyMistakes.md
@@ -2100,3 +2100,45 @@ wearing light-system chrome. Unstyled `CheckBox` is the same story.
**Side effect worth naming:** the sweep also exposed a typo'd foreground (`#e0d0e0` instead of
`#e0e0e0`) that only a single source of truth can prevent from recurring.
+
+## 2026-10-21 — A CHOICE IN WPF: MUTUAL EXCLUSION BELONGS IN THE MODEL, NOT IN A RADIOBUTTON GROUP (RECIPE — TASK 59)
+
+A "pick one of these" control (Rect | Circle) bound in WPF has two classic traps, both
+of which break the panel silently — the control keeps showing what it last showed while
+the model says something else.
+
+**Trap 1 — the RadioButton group kills the binding.** A `RadioButton` group unchecks its
+siblings by setting `IsChecked` **in code** (`RadioButton.UncheckRadioButton` →
+`SetCurrentValueInternal`). WPF sees the target property being set outside the binding
+and **drops the binding** — the options stop reflecting the model for good
+("Losing the binding for radio button after I set the property manually in code",
+stackoverflow 7376373; the same trap in a template, 57275046).
+
+**Trap 2 — a ToggleButton unchecks itself when clicked while checked.** A plain
+`ToggleButton` (unlike `RadioButton`) toggles `IsChecked` off when you click it while it
+is on. With a TwoWay binding the model receives `false` — and if the model refuses that
+write, the UI must be told again, or the option stays visibly unchecked while the model
+holds the truth.
+
+**The recipe:**
+1. **Give the model one bool per option** (`IsRectShape`, `IsRoundShape`) — each
+ settable, each doing the real work through the existing `Toggle…()` path so the
+ side-effects (store/restore the pre-round rect) are not duplicated.
+2. **The setter ALWAYS re-raises** its own property name (`Raise(nameof(IsRectShape))`),
+ even when the write changed nothing. The binding re-reads the getter and the UI snaps
+ back to the model's truth — that is what makes a refused write (a locked shape)
+ visible instead of sticky.
+3. **Raise BOTH option properties whenever the underlying value changes** (in the
+ `ClipShape` setter), so the pair can never disagree on screen.
+4. **Do NOT use a `RadioButton` `GroupName`** for a bound pair. Two plain
+ `ToggleButton`s (styled as segments) with model-side mutual exclusion keep their
+ bindings alive (stackoverflow 50980687 measures the same behaviour with
+ `IsChecked` bound to separate bools).
+5. **Show the CURRENT state, not the next action.** A button labelled "Round" when the
+ camera is a rectangle tells the user what will happen; a selected "Rect" option tells
+ them what IS. The creator called this out: *"Rect should be selected as a default if
+ none exists."*
+
+**Bonus:** the same "title owns its content, content is indented one level" rule applies
+to any dense settings panel — one indent level per ownership level, two when a title is
+itself nested (Strength under Blur under Background).
diff --git a/TASKS.md b/TASKS.md
index 0646f71..3d17232 100644
--- a/TASKS.md
+++ b/TASKS.md
@@ -74,6 +74,7 @@
| 56 | **Pre-publication secret scan (full git history)** | ☐ Queued (2026-10-09) — gate before the Gitea repo goes public; report commit/path only, never values | [`TASKS/task-56-secret-scan.md`](TASKS/task-56-secret-scan.md) |
| 57 | **Vertical (9:16) tier camera placement — the "vv" feature** | ✅ **Both units shipped 2026-10-21** (Unit 1 = Live scene: own placement per tier — small tile, circle-or-rect, one of six spots; drag-snap + manual entry; 16:9 restored exactly. Unit 2 = Chat scene: rect only — the circle is not offered — the tile or one whole 607×540 area) | [`TASKS/task-57-vertical-camera-layout.md`](TASKS/task-57-vertical-camera-layout.md) |
| 58 | **Properties-panel style consistency (the theme owns the look)** | ✅ Done — 2026-10-21 (the creator's "why is the drop-down not in the same style as the others?" — the drop-downs/fields/check boxes had been hand-painted instead of themed, so WPF drew the light system template; now every one carries a theme style, guarded by a test) | [`TASKS/task-58-properties-style-consistency.md`](TASKS/task-58-properties-style-consistency.md) |
+| 59 | **The properties panel is a hierarchy (titles own their content)** | ✅ Done — 2026-10-21 (the creator's ruling: a "Shape" title above the Mirror/Rect row, the drag hint indented under it, the same for Geometry / Border Color / Border Thickness, Remove + Blur under Background, Strength under Blur — and the shape became a CHOICE: Rect | Circle with Rect selected by default) | [`TASKS/task-59-properties-hierarchy.md`](TASKS/task-59-properties-hierarchy.md) |
---
diff --git a/TASKS/task-59-properties-hierarchy.md b/TASKS/task-59-properties-hierarchy.md
new file mode 100644
index 0000000..0dcb8a0
--- /dev/null
+++ b/TASKS/task-59-properties-hierarchy.md
@@ -0,0 +1,74 @@
+# TASK 59 — The properties panel is a hierarchy (titles own their content)
+
+**Status:** ✅ Done — shipped 2026-10-21.
+
+## What it is
+The creator's ruling (2026-10-21), on the Live scene → Layers → webcam → Properties:
+
+> Mirror Rect: needs a title "Shape" above it and **Rect should be selected as a default
+> if none exists** … also, the "drag to move" help text needs to be **indented under
+> "Mirror Rect" to show parentage** … same with **Geometry, Border Color, Border
+> Thickness**. Background → Remove: Remove should be indented under Background as should
+> Blur. Blur → Strength: Strength should be indented under Blur.
+
+One rule covers all of it: **a title owns its content, and the content is indented one
+level (10px) under it.** Two levels deep when a title is itself nested (Strength under
+Blur under Background).
+
+## The panel now reads
+```
+Shape ← NEW title
+ [Mirror] [Rect] [Circle] ← the shape is a CHOICE, Rect selected by default
+ Drag to move · drag the corner dot to resize
+Geometry ← title (already there), fields indented
+ X [ ] Y [ ]
+ W [ ] H [ ]
+Border Color ← promoted to a title, the field indented
+ [#2c3a5c] [..]
+Border Thickness ← promoted to a title, the slider indented
+ [slider] 3
+Background ← title, its rows indented
+ Remove [pill]
+ Blur [pill]
+ Strength [slider] 50% ← one level deeper, under Blur
+```
+
+## The shape: a choice, not a toggle
+Before: one button whose content was `ShapeButtonText` — the **next** action ("Round"
+when the camera was a rectangle). The creator could not tell what the camera *was*, only
+what it would become.
+
+Now: two `SegmentToggle` options — **Rect** and **Circle** — bound to two new settable
+bools on `SceneElement`:
+- `IsRectShape` — true when the clip is the plain rectangle, which is what a camera is
+ when no shape was ever picked (**the creator's "Rect is selected by default if none
+ exists"**).
+- `IsRoundShape` — true when the clip is a circle.
+
+Each setter flips the shape through `ToggleClipShape()` (so the pre-round rect is still
+stored/restored) and **always re-raises** — a refused write (the vertical Chat layout is
+rect-only, TASK 57) makes the UI snap back to the truth instead of sticking.
+
+`ShapeButtonText` is gone (dead code after this change).
+
+### Why the mutual exclusion lives in the model
+Not in a WPF `RadioButton` group: a RadioButton group unchecks its siblings by setting
+`IsChecked` **in code**, which makes WPF drop the binding ("Losing the binding for radio
+button after I set the property manually in code", stackoverflow 7376373) — the panel
+would silently stop reflecting the model. Two bool properties with model-side setters keep
+the binding alive (stackoverflow 50980687 / 57275046 describe the same trap).
+
+## The circle option is still not offered where it must not be
+The Circle option's `Visibility` binds to `CanToggleClipShape` — false in the vertical
+tier's Chat layout (TASK 57) — so the option is not even shown there, and the model
+refuses the write anyway.
+
+## Tests
+`ytLive.Tests/PropertiesHierarchyTests.cs` — the ONE test for this change:
+- the model: Rect is selected by default → picking Circle makes the clip Round → picking
+ Rect again restores it → the vertical Chat lock still refuses the circle;
+- the panel: the `Shape` title owns both options, the Circle option is the one gated by
+ `CanToggleClipShape`, the drag hint is indented and follows the shape row, every titled
+ block's content is indented 10px, and Strength sits at 20px (under Blur).
+
+432/432 green at ship time.
diff --git a/ai.md b/ai.md
index d0e81bf..0cc7cc2 100644
--- a/ai.md
+++ b/ai.md
@@ -277,6 +277,7 @@ C# / WPF (.NET 8) following MVVM:
- **Element z-order is a unified space, not per-table (2026-09-20):** `Source` and `WebcamSceneConfig` live in different tables, and a webcam can be interleaved between sources in a scene. Both tables' `SortOrder` is stamped from the SAME counter — the element's index within `scene.Elements` — and `Load` merges the two tables' rows per scene by that shared z (tie-break sources-first, so legacy per-table rows read back stacked the way they were saved). Never re-introduce per-type sort counters + "append all Sources then all configs", or a webcam slotted mid-stack reverts on reload (creator-reproduced bug).
- **Five-scene catalog (`Models/SceneCatalog.cs`):** the product is exactly Starting/Live/BRB/Chat/Ending — work with less, never more (the escape hatch for "more" is OBS). Scenes are matched **by name** (`SceneCatalog.Is`, case-insensitive trim). Empty DBs seed all five; the scenes-header "+" (`ShowAddScene`/`MissingScenes` on `MainViewModel`) only appears while ≥1 canonical scene is missing and its menu lists only the missing ones, re-adding them by name (`AddSceneCommand`). Renaming a canonical scene makes it missing again; `AddScene` rejects non-canonical names.
- Theming: all custom styles live in `Themes/Controls.xaml`, merged in `App.xaml` — never duplicate styles per-window (dialog duplicates were consolidated into this dictionary). **Every control in the app carries a theme style (TASK 58, 2026-10-21):** an unstyled `ComboBox`/`CheckBox` falls back to the **light WPF system template** (grey border, system arrow, white popup list) and sticks out of the dark panels — the properties panel's drop-downs had been hand-painted with a `Background`/`Foreground` instead of a style (the chat Font drop-down was a copy of that), and every check box was default. The rule now: `ComboBox` → `YtComboBox`, `CheckBox` → `YtCheckBox`, the dense properties fields → `YtTextBoxCompact` (`BasedOn YtTextBox`: 11px, padding 4,2) — **no hand-painted `Background`/`BorderBrush` on a themed control**. `ytLive.Tests/PropertiesStyleTests.cs` is the guard: it fails if an unstyled drop-down, check box or properties field comes back
+- **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`
- 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/PropertiesHierarchyTests.cs b/ytLive.Tests/PropertiesHierarchyTests.cs
new file mode 100644
index 0000000..73612a2
--- /dev/null
+++ b/ytLive.Tests/PropertiesHierarchyTests.cs
@@ -0,0 +1,130 @@
+using System.Globalization;
+using System.Linq;
+using System.Xml.Linq;
+using Xunit;
+using ytLive.Models;
+
+namespace ytLive.Tests;
+
+/// TASK 59 — the creator's properties-panel hierarchy (2026-10-21):
+/// "Mirror Rect: needs a title 'Shape' above it and Rect should be selected as a default
+/// if none exists … the 'drag to move' help text needs to be indented under 'Mirror Rect'
+/// to show parentage … same with Geometry, Border Color, Border Thickness … Remove should
+/// be indented under Background as should Blur … Strength should be indented under Blur."
+///
+/// One test, the whole story: the shape is a CHOICE and Rect is the selected
+/// default (the model), the options live under a "Shape" title with the drag hint
+/// indented beneath them, every titled block owns its content one indent deeper, and the
+/// strength row sits under Blur — and the TASK 57 rule still holds: the circle option is
+/// not offered in the vertical Chat layout, and the model refuses it there.
+public class PropertiesHierarchyTests
+{
+ [Fact]
+ public void The_webcam_properties_show_a_Shape_title_with_Rect_selected_and_indent_their_content()
+ {
+ // ── The model: the shape is a choice, and Rect is the default ─────────
+ var camera = new WebcamSceneConfig { WebcamId = "webcam-1", Name = "Webcam" };
+ Assert.True(camera.IsRectShape);
+ Assert.False(camera.IsRoundShape);
+
+ camera.IsRoundShape = true;
+ Assert.Equal(ClipShape.Round, camera.ClipShape);
+ Assert.False(camera.IsRectShape);
+ Assert.True(camera.IsRoundShape);
+
+ camera.IsRectShape = true;
+ Assert.Equal(ClipShape.Traditional, camera.ClipShape);
+ Assert.True(camera.IsRectShape);
+ Assert.False(camera.IsRoundShape);
+
+ // The vertical Chat layout locks the shape to a rect — the circle option is
+ // not offered there, and the model refuses it even if the UI asks.
+ camera.VerticalShapeLocked = true;
+ camera.IsRoundShape = true;
+ Assert.Equal(ClipShape.Traditional, camera.ClipShape);
+ Assert.False(camera.IsRoundShape);
+ Assert.True(camera.IsRectShape);
+
+ // ── The panel: a Shape title owns the options, the hint is indented ───
+ var doc = XDocument.Load(SourceAssert.RepoPath("Controls", "LeftPanel.xaml"));
+
+ var shapeTitle = doc.Descendants().Single(e => e.Name.LocalName == "TextBlock"
+ && (string?)e.Attribute("Text") == "Shape");
+ Assert.Equal("SemiBold", (string?)shapeTitle.Attribute("FontWeight"));
+
+ var rectOption = OptionBoundTo(doc, "IsRectShape");
+ var circleOption = OptionBoundTo(doc, "IsRoundShape");
+ Assert.Contains("TwoWay", (string?)rectOption.Attribute("IsChecked") ?? "");
+
+ // The CIRCLE is the option that is not offered in the vertical Chat layout.
+ Assert.Contains("CanToggleClipShape", (string?)circleOption.Attribute("Visibility") ?? "");
+
+ // Both options live under the Shape title.
+ var shapeBlock = shapeTitle.Parent!;
+ Assert.Contains(rectOption, shapeBlock.Descendants());
+ Assert.Contains(circleOption, shapeBlock.Descendants());
+
+ // The drag hint is indented, and it comes after the shape row — it reads as
+ // part of the shape block.
+ var hint = doc.Descendants().Single(e => e.Name.LocalName == "TextBlock"
+ && ((string?)e.Attribute("Text") ?? "").StartsWith("Drag to move"));
+ Assert.True(LeftIndent(hint) > 0, "the drag hint is not indented under the shape row");
+ var siblings = shapeBlock.Parent!.Elements().ToList();
+ Assert.True(siblings.IndexOf(shapeBlock) < siblings.IndexOf(hint),
+ "the drag hint comes before the shape row");
+
+ // ── Every titled block owns its content one indent deeper ────────────
+ var geometryTitle = doc.Descendants()
+ .Where(e => e.Name.LocalName == "TextBlock" && (string?)e.Attribute("Text") == "Geometry")
+ .Single(e => ((string?)e.Parent?.Attribute("Visibility") ?? "").Contains("IsWebcamSelected"));
+ Assert.Equal("SemiBold", (string?)geometryTitle.Attribute("FontWeight"));
+ foreach (var row in geometryTitle.Parent!.Elements().Where(e => e.Name.LocalName == "DockPanel"))
+ Assert.Equal(10, LeftIndent(row));
+
+ // The webcam block (the one the TASK 51 test finds by its binding).
+ var webcamPanel = doc.Descendants().Single(e => e.Name.LocalName == "StackPanel"
+ && ((string?)e.Attribute("Visibility") ?? "").Contains("SelectedElement.IsWebcam"));
+
+ foreach (var title in new[] { "Border Color", "Border Thickness" })
+ {
+ var label = webcamPanel.Descendants().Single(e => e.Name.LocalName == "TextBlock"
+ && (string?)e.Attribute("Text") == title);
+ Assert.Equal("SemiBold", (string?)label.Attribute("FontWeight"));
+ Assert.Equal(10, LeftIndent(RowAfter(label)));
+ }
+
+ // Remove and Blur are indented under Background; Strength one level deeper,
+ // under Blur.
+ Assert.Equal(10, LeftIndent(RowContaining(webcamPanel, "Remove")));
+ Assert.Equal(10, LeftIndent(RowContaining(webcamPanel, "Blur")));
+ Assert.Equal(20, LeftIndent(RowContaining(webcamPanel, "Strength")));
+ }
+
+ /// The shape option bound to the given model property.
+ private static XElement OptionBoundTo(XDocument doc, string property) =>
+ doc.Descendants().Single(e => e.Name.LocalName == "ToggleButton"
+ && ((string?)e.Attribute("IsChecked") ?? "").Contains(property));
+
+ /// The row (DockPanel) that holds the given label.
+ private static XElement RowContaining(XElement scope, string label) =>
+ scope.Descendants().Single(e => e.Name.LocalName == "TextBlock"
+ && (string?)e.Attribute("Text") == label).Parent!;
+
+ /// The row that follows a title.
+ private static XElement RowAfter(XElement title)
+ {
+ var next = title.ElementsAfterSelf().First(e => e.Name.LocalName == "DockPanel");
+ return next;
+ }
+
+ /// The left inset of an element's Margin, in pixels.
+ private static int LeftIndent(XElement element)
+ {
+ var margin = (string?)element.Attribute("Margin");
+ if (string.IsNullOrWhiteSpace(margin)) return 0;
+ var first = margin.Split(',')[0].Trim();
+ return int.TryParse(first, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value)
+ ? value
+ : 0;
+ }
+}