diff --git a/Controls/LeftPanel.xaml b/Controls/LeftPanel.xaml index 340b264..b8c1312 100644 --- a/Controls/LeftPanel.xaml +++ b/Controls/LeftPanel.xaml @@ -284,6 +284,37 @@ + + + + + + + + + + + + + + + + diff --git a/Controls/index.md b/Controls/index.md index 6f1de28..6c587cc 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) | | `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 | +| `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) | | `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 37a87ab..b5f90c7 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -1,50 +1,47 @@ -# HANDOFF — 2026-10-09 (TASK 51 slice 1 shipped: background-effects engine) +# HANDOFF — 2026-10-09 (TASK 51 webcam background Remove + Blur — BUILT) ## Branch -- main (no feature branch). Pushed through `f8dd107`; the TASK 51 slice-1 commit sits on top, - **not pushed** (push at the user's call). +- main (no feature branch). Pushed through `f8dd107`; everything after is local, **not pushed** + (push at the creator's call). -## Dirty files (NOT mine — do NOT commit or co-edit) +## Dirty files (NOT mine — do NOT commit) - The in-flight "pinned chat" work unit: `Models/YouTube.cs`, `ViewModels/MainViewModel.Chat.cs`, - `Controls/LeftPanel.xaml`, untracked `Helpers/RelayCommandGeneric.cs`. + untracked `Helpers/RelayCommandGeneric.cs`, and **its hunks in `Controls/LeftPanel.xaml`** + (lines ~599–610, the chat area). TASK 51 slice 5 edited the SAME file in a different region (the + webcam properties, ~line 287); only the TASK 51 hunk was staged (`git apply --cached` of that hunk + alone) — the pinned-chat hunks are still unstaged and untouched. - The creator's untracked logo art in `Assets/` (`llamacasty text.*`, `llamacasty-text-nobg*.png`, `thin logo wordmark.png`). -## What just happened — TASK 51 slice 1 (engine) -- **Model:** Google's official MediaPipe Selfie Segmenter (landscape, 144×256, Apache-2.0), - converted to ONNX by our own `tools/selfie-seg/convert_tflite_to_onnx.py` and **verified against - MediaPipe's own runtime** (`verify_against_mediapipe.py`, max |diff| 5e-5). Embedded in the exe - (`Assets/Models/selfie_segmenter_landscape.onnx`, 430 KB, in git). -- **Code:** `Services/BackgroundEffects/` — `IPersonSegmenter` seam, `OnnxPersonSegmenter` - (onnxruntime 1.31.0 CPU, never throws), `BackgroundEffectProcessor` (None passthrough, Remove = - alpha mask, Blur = passthrough until slice 2). `Models/BackgroundEffect.cs` enum. -- **Not wired:** nothing calls it yet — no config, UI, or camera hookup. Zero risk to the live path. -- **Docs:** ai.md (stale "background removal PERMANENTLY OUT" bullet replaced by the reversal + a - "Webcam background effects" section + a model-provenance licensing guard), THIRD-PARTY-NOTICES - items 11 (ONNX Runtime, MIT) + 12 (MediaPipe model, Apache-2.0 with §4b change note), - TASK 51 file + TASKS.md row, Services/Models indexes, MyMistakes conversion recipe. +## What just happened — TASK 51, all five slices +1. **Engine** — Google's MediaPipe Selfie Segmenter (landscape, Apache-2.0) converted to ONNX by + `tools/selfie-seg/` and verified vs MediaPipe's own runtime; embedded resource; onnxruntime CPU. +2. **Blur** — background at ¼ res, 3 box passes, mask-blended; Remove gets edge feather. +3. **Config** — `WebcamSceneConfig.BackgroundEffect` + `BackgroundStrength`, persisted (additive cols). +4. **Hookup** — `CameraManager` renders per-scene effect **variants** (one segmentation per raw frame + on a latest-frame-drop worker; raw path untouched); VM `SyncWebcamEffects()` declares/adopts them. + Synthetic 720p Debug: segment 1.6 ms, Remove 3.3 ms, Blur 17.5 ms. +5. **UI** — Background section in the webcam's Layers properties: Remove / Blur `PillToggle`s, + radio-exclusive, + Strength slider (visible while an effect is on). +Also this session: 64-bit requirement (`PlatformTarget=x64`), GPL-3.0-or-later licensing, README +rewrite, TASK 55 (disk hygiene) + TASK 56 (secret scan before going public) queued. ## Tests -- Full suite **408 passed / 0 failed**, 0 build warnings. New: `BackgroundEffectEngineTests` (Remove - integration test with a fake segmenter; bundled-model contract test vs a MediaPipe reference mask, - fixtures in `ytLive.Tests/Fixtures/`). +- Full suite **412 passed / 0 failed**, 0 build warnings. New: `BackgroundEffectEngineTests` (Remove, + Blur, bundled-model contract), `CameraManagerTests` (variant reaches compositor + preview, raw + stays raw), `LayoutStorePersistenceTests` (per-scene roundtrip + legacy migration), + `WebcamBackgroundPillTests` (radio semantics + XAML wiring). +- Known environment flakes (not regressions): `Mix_HonorsProviderGains…` (fails while system audio + plays), `RealMouseDrag…` (needs an idle desktop). ## Known issues / follow-ups -- **64-bit only (2026-10-09):** `PlatformTarget=x64` in both csproj files (verified: ytLive.dll PE - machine 0x8664, 408/408 green). TASK 48 item 2 carries the x64-only MSIX + `-r win-x64` publish. -- `onnxruntime.dll` is ~17 MB; an RID-less build copies every RID's native folder — TASK 48's MSIX - publish must pin win-x64. -- **License: GPL-3.0-or-later** (2026-10-09: `LICENSE`, `ADDITIONAL-TERMS.md` with §7 b/c/e, icon + - logo all rights reserved, AI-generated backgrounds/alert video = GPL placeholders; both files ship - beside the exe). Source offer = `https://llgit.llamachile.tube/gramps/LlamaCasty` (Gitea; the - creator toggles it public), in the notices header + README. **Before it goes public: scan git - history for secrets.** README rewritten (creator's voice, generic comparison, real build steps). - Still stale and NOT yet rewritten: ai.md "Windows packaging" (Store IAP / branding-flash paid - delta) and "Monetization" sections, MARCOM.md asset table (`llama-logo.png` doesn't exist). +- **Creator's real-camera check (TASK 51):** toggle Remove/Blur on a webcam, watch edges/lighting, + and read `startup.log` → "background effects avg … ms/frame". +- `onnxruntime.dll` ~17 MB; TASK 48's MSIX publish must pin `-r win-x64` (x64-only already enforced). +- Still stale, not rewritten: ai.md "Windows packaging" (Store IAP / branding-flash paid delta) and + "Monetization" sections; MARCOM.md asset table (`llama-logo.png` doesn't exist). +- TASK 56: secret-scan the full history BEFORE the Gitea repo (`gramps/LlamaCasty`) goes public. - Carried: "Use default background" toggle still not working (from 2026-10-07). ## Next step -- TASK 51 slice 2: **Blur** in `BackgroundEffectProcessor` (separable box/gaussian, strength), one - integration test. Then slice 3 config + persistence (`BackgroundEffect` + `Strength`), slice 4 - camera-path hookup + perf check on the creator's box, slice 5 UI: radio-exclusive Remove/Blur pill - switches + strength slider in the webcam's Layers properties. +- Creator runs the app: webcam → Layers properties → Background → Remove / Blur + Strength. diff --git a/Models/WebcamSceneConfig.cs b/Models/WebcamSceneConfig.cs index 2e3ed75..2a84665 100644 --- a/Models/WebcamSceneConfig.cs +++ b/Models/WebcamSceneConfig.cs @@ -26,9 +26,13 @@ public class WebcamSceneConfig : SceneElement if (!Set(ref _backgroundEffect, value)) return; Raise(nameof(RemoveBackground)); Raise(nameof(BlurBackground)); + Raise(nameof(HasBackgroundEffect)); } } + /// True while Remove or Blur is on — shows the strength slider. + public bool HasBackgroundEffect => BackgroundEffect != BackgroundEffect.None; + private double _backgroundStrength = 0.5; /// TASK 51: 0..1 — Blur amount, or Remove's edge softness (persisted). public double BackgroundStrength diff --git a/Models/index.md b/Models/index.md index a1915a1..3c7a68e 100644 --- a/Models/index.md +++ b/Models/index.md @@ -12,7 +12,7 @@ Plain data types. No logic beyond what a property can carry. See | `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` | -| `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) | +| `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) | | `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 | | `MicStatus.cs` | Footer mic indicator: `NotConnected` / `Connected` / `Problem` — the state behind the MIC button's status dot (red = no device/capture off, green = capturing, yellow = a requested mic failed: in use/unplugged) | diff --git a/Services/CameraManager.cs b/Services/CameraManager.cs index 1b4e156..ad6fbff 100644 --- a/Services/CameraManager.cs +++ b/Services/CameraManager.cs @@ -380,14 +380,24 @@ public sealed class CameraManager : IDisposable }, DispatcherPriority.Render); } - // Caller holds _gate. + // Caller holds _gate. A new key inherits the last frame + bitmap of a dropped variant with + // the SAME effect (a strength change), so neither the stream nor the preview blanks for a + // frame while the creator drags the strength slider. private static void SyncVariants(CameraSession session, HashSet keys) { - foreach (var stale in session.Effects.Keys.Where(k => !keys.Contains(k)).ToList()) + var dropped = session.Effects.Where(kv => !keys.Contains(kv.Key)).ToList(); + foreach (var (stale, _) in dropped) session.Effects.Remove(stale); foreach (var key in keys) - if (!session.Effects.ContainsKey(key)) - session.Effects[key] = new EffectVariant(); + { + if (session.Effects.ContainsKey(key)) continue; + var heir = dropped.FirstOrDefault(kv => kv.Key.Effect == key.Effect).Value; + session.Effects[key] = new EffectVariant + { + LatestFrame = heir?.LatestFrame, + PreviewBitmap = heir?.PreviewBitmap, + }; + } } private void ScheduleEffects(CameraSession session) diff --git a/TASKS.md b/TASKS.md index caf4314..4b07233 100644 --- a/TASKS.md +++ b/TASKS.md @@ -66,7 +66,7 @@ | 48 | Distribution & packaging: **MSIX + Store IAP** | 🔶 In progress — **✅ route decided 2026-09-27 (Store MSIX + Store IAP)**; **item 1 (bundle ffmpeg) SHIPPED 2026-09-28** | [`TASKS/task-48-distribution-msix.md`](TASKS/task-48-distribution-msix.md) | | 49 | Chat profanity filter (local, opt-in, non-persistent, user word list) | ☐ Queued (2026-09-27) | [`TASKS/task-49-chat-profanity-filter.md`](TASKS/task-49-chat-profanity-filter.md) | | 50 | One-button "Publish this video" (resumable upload of the local recording) | ☐ Queued (2026-09-28) | [`TASKS/task-50-publish-recording.md`](TASKS/task-50-publish-recording.md) | -| 51 | **Webcam background effects: Remove + Blur** | 🔶 **v1 requirement — in progress**: slice 1 engine shipped 2026-10-09 (bundled MediaPipe Selfie Segmenter → ONNX, verified vs Google's runtime; Remove); next: Blur → config → camera hookup → UI | [`TASKS/task-51-webcam-bg-effects.md`](TASKS/task-51-webcam-bg-effects.md) | +| 51 | **Webcam background effects: Remove + Blur** | ✅ **Built 2026-10-09** (v1 requirement) — bundled MediaPipe Selfie Segmenter → ONNX (verified vs Google's runtime), Remove + Blur with strength, per-scene config persisted, one processing point in CameraManager, radio-pill UI in the webcam's Layers properties. Pending: creator's real-camera check | [`TASKS/task-51-webcam-bg-effects.md`](TASKS/task-51-webcam-bg-effects.md) | | 52 | **Zoom box (anchored 2× magnifier)** | ✅ Done — shipped 2026-10-08 (3 creator corrections during the build: loupe → anchored capture over the full composite, drawn last → never shows the dim shading) | [`TASKS/task-52-zoom-box.md`](TASKS/task-52-zoom-box.md) | | 53 | **Output-format verification: Shorts/TikTok geometry (9:16)** | ☐ Queued (2026-10-08) — output formats never tested end-to-end; pin + prove the vertical geometry | [`TASKS/task-53-output-formats.md`](TASKS/task-53-output-formats.md) | | 54 | **Marcom round: free-forever positioning + merch storefront** | 🔶 In progress — llamacasty.com copy live 2026-10-08 (pricing stripped); merch POD + app About links queued | [`TASKS/task-54-marcom-free-merch.md`](TASKS/task-54-marcom-free-merch.md) | diff --git a/TASKS/task-51-webcam-bg-effects.md b/TASKS/task-51-webcam-bg-effects.md index 72161c3..6fcad08 100644 --- a/TASKS/task-51-webcam-bg-effects.md +++ b/TASKS/task-51-webcam-bg-effects.md @@ -4,8 +4,9 @@ > **Booked 2026-10-08 by creator ruling** — *"we're doing the bg removal and blur for v1 - book it, Danno"*. > This **reverses** the 2026-10-05 "background removal: out of product" exclusion (TASK 3 item 19). -**Status:** 🔶 In progress — **slice 1 (engine: bundled model + runner + Remove) shipped 2026-10-09**. -Next: slice 2 (Blur), then config + persistence, then the camera-path hookup + UI. +**Status:** ✅ **Built 2026-10-09 — all five slices shipped** (engine, Blur, config + persistence, +camera hookup, UI). Remaining: the creator's manual check on a real camera (quality, lighting, +`startup.log` perf line). **Goal:** per-webcam **Background Remove** and **Background Blur** on the live webcam layer, selected per element in its properties panel, composited into stream + recording like any @@ -72,7 +73,7 @@ delivery shape changed: - ✅ `WebcamSceneConfig` gains `BackgroundEffect` (`None | Remove | Blur`) + `BackgroundStrength` (slice 3). - ✅ Persist via the existing `WebcamSceneConfig` table path in `LayoutStore.Save.cs`/ `Load.cs` (new columns + migration). Follow `LayoutStore.Migrations.cs` conventions. -- ☐ **Properties UI (creator spec 2026-10-09):** in the webcam's properties in the **Layers +- ✅ **Properties UI (creator spec 2026-10-09, slice 5):** in the webcam's properties in the **Layers panel**, two **pill switches** — "Remove background" and "Blur background" — using the existing `PillToggle` style (`Themes/Controls.xaml`, already used in `Controls/LeftPanel.xaml`). They act as **radio buttons**: turning one on turns the other off; at most one is active; both off = @@ -104,7 +105,7 @@ delivery shape changed: - ✅ Separate work unit after the zoom build (zoom shipped 2026-10-08). - Slice order: 1 engine ✅ → 2 Blur ✅ → 3 config + persistence ✅ → 4 camera-path hookup ✅ (one - processing point, both consumers) + perf check → 5 properties-panel UI. + processing point, both consumers) + perf check → 5 properties-panel UI ✅. ## Related (not duplicated here) diff --git a/ViewModels/MainViewModel.Webcam.cs b/ViewModels/MainViewModel.Webcam.cs index f5389aa..a2cd6b1 100644 --- a/ViewModels/MainViewModel.Webcam.cs +++ b/ViewModels/MainViewModel.Webcam.cs @@ -244,11 +244,18 @@ public partial class MainViewModel config.PropertyChanged += OnWebcamConfigEffectChanged; var key = EffectKeyFor(config); var bitmap = _cameraManager.GetPreviewBitmap(_webcam.DeviceId, key); - if (bitmap != null || key.Effect != BackgroundEffect.None) - config.VideoImageSource = bitmap; + // A strength change within the same effect keeps showing the previous variant's + // last frame until the new one renders (no blank flicker while dragging the slider); + // switching INTO an effect shows nothing first, so Remove never flashes the room. + var sameEffect = _shownEffect.TryGetValue(config, out var shown) && shown.Effect == key.Effect; + _shownEffect[config] = key; + if (bitmap != null) config.VideoImageSource = bitmap; + else if (key.Effect != BackgroundEffect.None && !sameEffect) config.VideoImageSource = null; } } + private readonly Dictionary _shownEffect = new(); + private void OnWebcamConfigEffectChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) { if (e.PropertyName is not (nameof(WebcamSceneConfig.BackgroundEffect) or nameof(WebcamSceneConfig.BackgroundStrength))) diff --git a/ai.md b/ai.md index a47f0d6..ebcbc46 100644 --- a/ai.md +++ b/ai.md @@ -663,6 +663,13 @@ This replaces the old five-seeder cluster (`Seed{Starting,Brb,Ending,Chat}Backgr per pixel) — the first naive per-pixel 2-D bilinear cost 46/80 ms. `CameraManager` logs the real average once per session after 120 processed frames (`startup.log`: "background effects avg …"). `MainViewModel.PersonSegmenterOverride` is the hermetic-test seam. +- **UI (slice 5, creator spec 2026-10-09):** the webcam's properties in the Layers panel + (`Controls/LeftPanel.xaml`) gain a **Background** section — two `PillToggle` switches, Remove and + Blur, bound to `RemoveBackground`/`BlurBackground` (radio-exclusive: one on turns the other off, + turning the active one off = None) and a Strength slider (0..1, 5% snaps, visible only while an + effect is on; Blur = amount, Remove = edge softness). Dragging the slider never blanks the webcam: + a new strength variant inherits the dropped same-effect variant's last frame + bitmap + (`CameraManager.SyncVariants`), and the VM keeps the shown bitmap until the new one renders. - **Tests:** `BackgroundEffectEngineTests` — the Remove integration test (fake segmenter), and a model contract test that runs the BUNDLED model through the C# preprocessing on a fixture and matches the MediaPipe-produced reference mask (≥99.5% agreement; fixture = MediaPipe's public test diff --git a/ytLive.Tests/WebcamBackgroundPillTests.cs b/ytLive.Tests/WebcamBackgroundPillTests.cs new file mode 100644 index 0000000..a374a72 --- /dev/null +++ b/ytLive.Tests/WebcamBackgroundPillTests.cs @@ -0,0 +1,63 @@ +using System.Linq; +using System.Xml.Linq; +using Xunit; +using ytLive.Models; + +namespace ytLive.Tests; + +/// TASK 51 slice 5 — the creator's UI spec (2026-10-09): Remove and Blur are two pill +/// switches in the webcam's Layers properties that act as radio buttons (one on turns the other +/// off; both off = no effect), plus a strength slider. +public class WebcamBackgroundPillTests +{ + [Fact] + public void Remove_and_Blur_pills_are_radio_exclusive_and_wired_in_the_webcam_properties() + { + // Behaviour: the bools the pills bind to. + var config = new WebcamSceneConfig(); + var raised = new System.Collections.Generic.List(); + config.PropertyChanged += (_, e) => raised.Add(e.PropertyName); + + config.RemoveBackground = true; + Assert.Equal(BackgroundEffect.Remove, config.BackgroundEffect); + Assert.True(config.HasBackgroundEffect); + + raised.Clear(); + config.BlurBackground = true; // turning Blur on turns Remove off + Assert.Equal(BackgroundEffect.Blur, config.BackgroundEffect); + Assert.False(config.RemoveBackground); + Assert.Contains(nameof(WebcamSceneConfig.RemoveBackground), raised); // Remove pill un-checks + Assert.Contains(nameof(WebcamSceneConfig.BlurBackground), raised); + + config.RemoveBackground = false; // turning the INACTIVE pill off changes nothing + Assert.Equal(BackgroundEffect.Blur, config.BackgroundEffect); + + config.BlurBackground = false; // turning the active pill off = None + Assert.Equal(BackgroundEffect.None, config.BackgroundEffect); + Assert.False(config.HasBackgroundEffect); + + // Wiring: the two PillToggle switches + the strength slider live in the webcam's + // properties section of the Layers panel, bound to those properties. + XNamespace x = "http://schemas.microsoft.com/winfx/2006/xaml"; + var doc = XDocument.Load(SourceAssert.RepoPath("Controls", "LeftPanel.xaml")); + var webcamPanel = doc.Descendants() + .Single(e => e.Name.LocalName == "StackPanel" + && ((string?)e.Attribute("Visibility") ?? "").Contains("SelectedElement.IsWebcam")); + + XElement Named(string name) => webcamPanel.Descendants().Single(e => (string?)e.Attribute(x + "Name") == name); + + foreach (var (pill, property) in new[] { ("RemoveBackgroundPill", "RemoveBackground"), ("BlurBackgroundPill", "BlurBackground") }) + { + var toggle = Named(pill); + Assert.Equal("ToggleButton", toggle.Name.LocalName); + Assert.Contains("PillToggle", (string?)toggle.Attribute("Style")); + Assert.Contains($"SelectedElement.{property}", (string?)toggle.Attribute("IsChecked")); + Assert.Contains("TwoWay", (string?)toggle.Attribute("IsChecked")); + } + + var slider = Named("BackgroundStrengthSlider"); + Assert.Contains("SelectedElement.BackgroundStrength", (string?)slider.Attribute("Value")); + Assert.Equal("0", (string?)slider.Attribute("Minimum")); + Assert.Equal("1", (string?)slider.Attribute("Maximum")); + } +}