fix(border-color): keep ComboBox popup clicks inside LeftPanel (TASK 63)

This commit is contained in:
2026-10-22 12:02:05 -07:00
parent daac7689fd
commit c768a8ee49
8 changed files with 241 additions and 87 deletions
+5 -2
View File
@@ -54,9 +54,12 @@ public partial class LeftPanel : UserControl
{ {
while (child != null && !ReferenceEquals(child, ancestor)) while (child != null && !ReferenceEquals(child, ancestor))
{ {
child = child is Visual or System.Windows.Media.Media3D.Visual3D var next = child is Visual or System.Windows.Media.Media3D.Visual3D
? VisualTreeHelper.GetParent(child) ? VisualTreeHelper.GetParent(child)
: LogicalTreeHelper.GetParent(child); : null;
child = next
?? LogicalTreeHelper.GetParent(child)
?? (child as FrameworkElement)?.TemplatedParent;
} }
return child != null; return child != null;
} }
+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` | | `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) | | `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 | | `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. **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. **TASK 63:** the border colour is picked with the **app's own drop-down** (`YtComboBox`) — the `...` button and its code-built `ContextMenu` are gone; the closed box shows the swatch + the colour's NAME, the rows show swatch + hex + name (the panel's own `DataTemplate`s + `Helpers/ClosedBoxTemplateSelector.cs`), and the hex field stays for a custom colour | | `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. **TASK 63:** the border colour is picked with the **app's own drop-down** (`YtComboBox`) — the `...` button and its code-built `ContextMenu` are gone; the closed box shows the swatch + the colour's NAME, the rows show swatch + hex + name (the panel's own `DataTemplate`s + `Helpers/ClosedBoxTemplateSelector.cs`), and the hex field stays for a custom colour; `IsDescendantOf` falls back to `LogicalTreeHelper.GetParent` / `TemplatedParent` when `VisualTreeHelper.GetParent` returns `null` (at `PopupRoot`) so clicking a popup row does not trigger `MainWindow`'s deselection guard |
| `OverlayHost.xaml(.cs)` | Root-grid overlay dialogs: Settings / Report Bug / Feature Request / About (+ licenses + license-key entry). `BackToAbout_Click` lives here | | `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 | | `SceneThumbnailStrip.xaml.cs` | Code-behind for the strip above |
| `LeftPanel.xaml.cs` | Code-behind for the panel above | | `LeftPanel.xaml.cs` | Code-behind for the panel above |
+17 -8
View File
@@ -52,7 +52,8 @@
(`SceneElement.BorderColorName`, empty for a custom hex), and the hex field keeps a fixed (`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 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`. still be typed). Guard: `ytLive.Tests/BorderColorNameTests.cs`.
- **TASK 63 - the border-colour picker IS the app's drop-down** (shipped 2026-10-21). The creator: - **TASK 63 - the border-colour picker IS the app's drop-down** (shipped 2026-10-21; **popup click
deselection fixed 2026-10-22**). The creator:
*"why does the drop down under the web cam properties not show the same style for the border *"why does the drop down under the web cam properties not show the same style for the border
color drop down list box?"* and *"we established look-and-feel standards. And yet you keep color drop down list box?"* and *"we established look-and-feel standards. And yet you keep
ignoring these and I'm wondering why."* **Root cause of the miss:** the TASK 58 sweep looked for ignoring these and I'm wondering why."* **Root cause of the miss:** the TASK 58 sweep looked for
@@ -66,8 +67,17 @@
`WebcamBorderColorPicker_Click` are **gone**. The closed box != the rows only through `WebcamBorderColorPicker_Click` are **gone**. The closed box != the rows only through
`Helpers/ClosedBoxTemplateSelector.cs` (WPF copies `ItemTemplate` into the read-only `Helpers/ClosedBoxTemplateSelector.cs` (WPF copies `ItemTemplate` into the read-only
`SelectionBoxItemTemplate`; the selector is told them apart by the `ContentPresenter`'s `SelectionBoxItemTemplate`; the selector is told them apart by the `ContentPresenter`'s
templated parent - `ComboBox` = closed box, `ComboBoxItem` = a row). Guard: templated parent - `ComboBox` = closed box, `ComboBoxItem` = a row). **2026-10-22 creator bug fix:**
`ytLive.Tests/BorderColorPickerTests.cs` (model + panel + the **real widget**). The TASK 60 clicking a colour in the open `ComboBox` popup registered as an exit from the properties listing
because `LeftPanel.IsDescendantOf` used `child is Visual ? VisualTreeHelper.GetParent(child) :
LogicalTreeHelper.GetParent(child)` — at `PopupRoot` (which IS a `Visual`),
`VisualTreeHelper.GetParent` returns `null`, so `IsDescendantOf` stopped without crossing
`PopupRoot`'s logical parent (`PART_Popup` → `ComboBox` → `ElementsScrollViewer`), and
`MainWindow.Window_PreviewMouseLeftButtonDown` cleared `SelectedElement = null`. Fixed by falling
back to `LogicalTreeHelper.GetParent` / `TemplatedParent` when `VisualTreeHelper.GetParent`
returns `null`. Guard: `ytLive.Tests/BorderColorPickerTests.cs` (model + panel + the **real
widget**, including `LeftPanel.IsClickInside` on an open popup row and clicking a row to change
`BorderColor`). The TASK 60
guard (`BorderColorPaletteTests`) was updated to guard the new wiring (the drop-down is bound to guard (`BorderColorPaletteTests`) was updated to guard the new wiring (the drop-down is bound to
the palette; no second list of colours anywhere). the palette; no second list of colours anywhere).
- **TASK 55 slice 1 — the log is capped (shipped 2026-10-22).** `startup.log` was uncapped (23 MB - **TASK 55 slice 1 — the log is capped (shipped 2026-10-22).** `startup.log` was uncapped (23 MB
@@ -233,11 +243,10 @@ Zombie entities deleted from the registry: `script.leak_strobe`, `script.doorbel
the full top area and the circle option is not offered. **PASSES.** the full top area and the circle option is not offered. **PASSES.**
## Next step ## Next step
- **Creator check of TASK 63 (needs the creator's eyes)** — open Live → Layers → webcam → - **✅ TASK 63 + popup-click fix verified by creator (2026-10-22)** — clicking a colour in the
Properties → Border Color: the drop-down must now LOOK like the Display/Font drop-downs (same Border Color `YtComboBox` dropdown now stays inside `LeftPanel` (`IsDescendantOf` crosses
dark themed chrome, same chevron), the CLOSED box must show the colour's name beside the hex `PopupRoot` → `PART_Popup` via `LogicalTreeHelper.GetParent`), updates the webcam border colour,
field ("Crimson"), and the open list must show swatch + hex + name. The hex field still accepts a and does not exit the properties listing.
typed colour (the drop-down then shows the swatch with no name — "no name" is the honest answer).
- **Creator check (needs the creator's hands)** — the last piece of the vv feature: switch the - **Creator check (needs the creator's hands)** — the last piece of the vv feature: switch the
resolution dropdown to "Vertical 1080p60", confirm the Live camera tile lands inside the band and resolution dropdown to "Vertical 1080p60", confirm the Live camera tile lands inside the band and
the **Circle** option (TASK 59 turned the Shape button into a Rect | Circle choice) turns the tile the **Circle** option (TASK 59 turned the Shape button into a Rect | Circle choice) turns the tile
+30
View File
@@ -2275,6 +2275,36 @@ sizes to its **content**, not to the ComboBox — measured 142.7px of popup for
whose rows read "swatch + hex + name". A long row is never clipped, and the popup may be wider whose rows read "swatch + hex + name". A long row is never clipped, and the popup may be wider
than the closed box. Measure with `ActualWidth` in a throwaway test; do not guess widths. than the closed box. Measure with `ActualWidth` in a throwaway test; do not guess widths.
**2026-10-22 follow-up — `PopupRoot` IS a `Visual`, yet its `VisualTreeHelper.GetParent` is `null`:**
Switching from a code-built `ContextMenu` (which is not in `MainWindow`'s routed-event path) to a
`ComboBox` (`PART_Popup`) exposed a latent bug in `LeftPanel.IsDescendantOf`:
```csharp
child = child is Visual or System.Windows.Media.Media3D.Visual3D
? VisualTreeHelper.GetParent(child)
: LogicalTreeHelper.GetParent(child);
```
When the user clicked a colour row inside the open `ComboBox` popup, `PreviewMouseLeftButtonDown`
tunnelled through `MainWindow.Window_PreviewMouseLeftButtonDown` first. `IsDescendantOf` walked up
`VisualTreeHelper.GetParent` to `PopupRoot` (the root `Visual` of the popup's separate Win32
`HwndSource`). Because `PopupRoot` IS a `Visual`, the ternary took the `VisualTreeHelper.GetParent`
branch and got `null` — never crossing `PopupRoot`'s **logical** parent
(`LogicalTreeHelper.GetParent(popupRoot) == PART_Popup`). `LeftPanel.IsClickInside` returned
`false`, and `MainWindow` set `SelectedElement = null` (exiting the properties listing before the
`ComboBoxItem` click could register). Fix: always coalesce
`VisualTreeHelper.GetParent(child) ?? LogicalTreeHelper.GetParent(child) ?? (child as FrameworkElement)?.TemplatedParent`.
## 2026-10-22 — WINDOWS GIT IDENTITY + PRE-TASK GIT STATUS (RECIPE)
- **Windows `git` vs WSL `~/.gitconfig`:** the creator's `user.name` / `user.email` lived only in
WSL's `~/.gitconfig`, so running `git commit` from the Windows PowerShell host failed with
`Author identity unknown`. Fixed permanently in `.git/config` (`[user] name = gramps`,
`email = gramps@llamachile.shop`). If you ever need the repo's commit author without running a
shell command, read `.git/logs/HEAD` directly with `read_file`.
- **Check `git status` BEFORE starting a task** (and never run multiline/compound shell commands
when a single simple command or `read_file`/`edit_file` works): `verify.sh` runs `scope-check.sh`
over `git diff HEAD`, so any pre-existing uncommitted files in the worktree will fail step 3 at
the end of a 2-minute build+test run unless spotted up front.
## 2026-10-22 — SCANNING A GIT HISTORY FOR SECRETS WITHOUT EVER PRINTING ONE (RECIPE) ## 2026-10-22 — SCANNING A GIT HISTORY FOR SECRETS WITHOUT EVER PRINTING ONE (RECIPE)
A release gate ("the repo goes public — check the history first"). The rule from A release gate ("the repo goes public — check the history first"). The rule from
+1 -1
View File
@@ -78,7 +78,7 @@
| 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) | | 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) | | 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) | | 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) |
| 63 | **The border-colour picker is the app's own drop-down** | ✅ Done — 2026-10-21 (the creator's "why does the drop down under the web cam properties not show the same style for the border color drop down list box?" + "we established look-and-feel standards. And yet you keep ignoring these" — the hand-rolled `...` button and its code-built menu are gone; the border colour is now a `YtComboBox` like every other picker, the closed box shows the swatch + the colour's name, the rows show swatch + hex + name, and the hex field stays for a custom colour) | [`TASKS/task-63-border-colour-app-dropdown.md`](TASKS/task-63-border-colour-app-dropdown.md) | | 63 | **The border-colour picker is the app's own drop-down** | ✅ Done — 2026-10-21 (popup row click deselection fixed 2026-10-22: `LeftPanel.IsDescendantOf` now crosses `PopupRoot` → `PART_Popup` via `LogicalTreeHelper.GetParent` when `VisualTreeHelper.GetParent` returns `null`, so clicking a colour in the open `YtComboBox` updates `BorderColor` instead of exiting properties) | [`TASKS/task-63-border-colour-app-dropdown.md`](TASKS/task-63-border-colour-app-dropdown.md) |
--- ---
@@ -31,6 +31,17 @@ not *"is this control type themed?"*.
matches `SelectedValue` ordinally (case-sensitively) and the palette mixes `#FFFFFF` and matches `SelectedValue` ordinally (case-sensitively) and the palette mixes `#FFFFFF` and
`#e94560`; the model matches by VALUE, so the hex the creator typed in any case still lands `#e94560`; the model matches by VALUE, so the hex the creator typed in any case still lands
on the right row. on the right row.
- **Clicking a popup row stays inside `LeftPanel` (2026-10-22 fix):** a `ComboBox`'s open
`PART_Popup` lives in its own `PopupRoot` visual tree (`VisualTreeHelper.GetParent(popupRoot)`
is `null`), connected back to the `Popup` and `ComboBox` only via `PopupRoot`'s **logical**
parent (`LogicalTreeHelper.GetParent(popupRoot)`). `LeftPanel.IsDescendantOf` previously used
`child is Visual ? VisualTreeHelper.GetParent(child) : LogicalTreeHelper.GetParent(child)`,
which returned `null` at `PopupRoot` because `PopupRoot` is a `Visual`. That caused
`MainWindow.Window_PreviewMouseLeftButtonDown` to see `IsClickInside == false` on any click
inside the open dropdown and clear `SelectedElement = null` — exiting the properties panel
before the `ComboBoxItem` click could select the colour. `IsDescendantOf` now falls back to
`LogicalTreeHelper.GetParent` and `TemplatedParent` whenever `VisualTreeHelper.GetParent`
returns `null`.
- **The closed box shows the swatch + the colour's NAME; the rows show swatch + hex + name** - **The closed box shows the swatch + the colour's NAME; the rows show swatch + hex + name**
(the rows the creator approved in TASK 60). A ComboBox cannot do that with one (the rows the creator approved in TASK 60). A ComboBox cannot do that with one
`ItemTemplate` — WPF copies it into the read-only `SelectionBoxItemTemplate` — so `ItemTemplate` — WPF copies it into the read-only `SelectionBoxItemTemplate` — so
+78 -75
View File
@@ -311,7 +311,13 @@ C# / WPF (.NET 8) following MVVM:
the `YtComboBox` popup carries no width binding, so the pop-up is as wide as its widest row the `YtComboBox` popup carries no width binding, so the pop-up is as wide as its widest row
(measured 142.7px for the border-colour rows against a 100px ComboBox) — a row is never clipped, (measured 142.7px for the border-colour rows against a 100px ComboBox) — a row is never clipped,
and the popup may be wider than the closed box. Measure (`RenderTargetBitmap`/`ActualWidth` in a and the popup may be wider than the closed box. Measure (`RenderTargetBitmap`/`ActualWidth` in a
throwaway test), don't guess. throwaway test), don't guess. **And a `Popup`'s visual root (`PopupRoot`) has a `null` visual
parent (`VisualTreeHelper.GetParent(popupRoot) == null`)** even though `PopupRoot` IS a `Visual`:
its only link back to the `Popup` and the owning `ComboBox` is `LogicalTreeHelper.GetParent`. Any
ancestor walk used by a window-level click-outside guard (`LeftPanel.IsDescendantOf`) MUST fall
back to `LogicalTreeHelper.GetParent(child)` when `VisualTreeHelper.GetParent(child)` returns
`null`, or clicking a `ComboBoxItem` inside `PART_Popup` looks like an outside click and
deselects `SelectedElement` before the item click registers (fixed 2026-10-22).
- **A field in a dense panel carries a FIXED width (TASK 61):** a WPF element sizes itself to its - **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 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 broken unless every field has an explicit `Width` AND the row sets `LastChildFill="False"`. The
@@ -2068,13 +2074,13 @@ Apply this to every UI decision:
> Everything below is the historical paid-tier design. > Everything below is the historical paid-tier design.
Full pricing and the Year 1 revenue forecast in `MONETIZATION.md` (gitignored). ⚠️ **No Polar Full pricing and the Year 1 revenue forecast in `MONETIZATION.md` (gitignored). ⚠️ **No Polar
product, discount code, or license key exists any more** — billing is Microsoft Store IAP. product, discount code, or license key exists any more** — nothing bills today; Polar is dormant.
Brand palette and assets in `MARCOM.md` (gitignored). This section covers the in-app model. Brand palette and assets in `MARCOM.md` (gitignored). This section covers the in-app model.
Free forever: **all features unlocked for everyone** — no feature lock between free and paid. Free forever: **all features unlocked for everyone** — no feature lock, no paid tier, and since
The **only difference is watermarking**. This is deliberate: no creator will tolerate a watermark, 2026-10-08 **no watermark either** (the credit is OFF by default; see the banner above). No
and as a good-guy developer, we give them complete access to every feature so no one can call us creator will tolerate a watermark, and as a good-guy developer we give everyone complete access
crooked. so no one can call us crooked. Money comes from gratuity/merch, not gating (TASK 54).
- **Free:** a periodic branding credit — "made with LlamaCasty!" in **full-frame neon** (white core, - **Free:** a periodic branding credit — "made with LlamaCasty!" in **full-frame neon** (white core,
feathered red/blue halo with the channels split in opposite directions), at a **random position feathered red/blue halo with the channels split in opposite directions), at a **random position
@@ -2122,34 +2128,24 @@ crooked.
finish. That's it. No feature gating. Alerts, social bar slots, voice filters, TRAX, recording — finish. That's it. No feature gating. Alerts, social bar slots, voice filters, TRAX, recording —
everything is free. **How `IsPremium` is obtained is a billing concern and has changed twice; everything is free. **How `IsPremium` is obtained is a billing concern and has changed twice;
what it gates has not.** what it gates has not.**
- **Pricing (✅ DECIDED 2026-09-27 — supersedes the 2026-09-21 one-time "own it" model):** - **Pricing: ⛔ SUPERSEDED 2026-10-08 by the free-forever ruling (TASK 54).** Retained as
**`$10/mo` subscription, or `$400` lifetime. No annual. No `.99` prices.** ⛔ The old `$29` reasoning only: `$10/mo` subscription or `$400` lifetime, no annual, ruled 2026-09-27 and
founder → `$49` list lifetime ruling is **dead — do not cite it.** Two tiers, one delta: they overtaken ten days later. If a paid tier ever returns, these are the numbers to argue from.
break even at **month 40**, so they barely compete, which is why lifetime is safe to offer at **How `IsPremium` would be obtained is a billing concern; what it gates is not.**
launch instead of saving it for later. The free tier is the *full* app (only the watermark - **Billing: ⏸ dormant — no paid tier exists** (free-forever ruling 2026-10-08, TASK 54). The
differs), so accessibility is preserved without pricing down. ⛔ No annual tier: Microsoft does Store IAP plan of 2026-09-27 was never implemented (`Windows.Services.Store` appears nowhere
not pro-rate and **the developer cannot refund** the forfeited remainder — verbatim the in the repo) and was withdrawn with the Store route on 2026-10-22. If a paid tier ever
grievance that drove the ruling. **ADA §6(h): raising the price disables auto-renew, so `$10` is returns, the provider is **Polar** — merchant of record for VAT/sales tax, 5% + $0.50 Starter,
effectively locked for the product's life.** Full reasoning in `MONETIZATION.md` → Pricing. **$50 already paid**; `Distribution.md` §4.2/§4.5 holds the design. ⚠️ No Polar product, org
- **Billing: ⛔ Polar is DELETED — Microsoft Store IAP, Microsoft as merchant of record** (ruling ID, key prefix, or discount code exists (`d105dfa1-…`, `c05fb364-…`, `LCYT-`, `LLAMAFOUNDER`/
2026-09-27; teardown in `TASKS/task-48-distribution-msix.md` item 0). **Revenue share 15%, `LLAMA50`, the customer-portal validate endpoint, and the 14-day offline-grace machine are all
subscriptions included** — verified from the ADA v8.10 PDF, §6(b)(i); the agreement's own v8.0 gone).
changelog says *"85/15 revenue share for non-Game subscriptions"*. ⇒ net **~$8.50/mo**,
**~$340** lifetime. Microsoft handles VAT/GST/sales tax, refunds, chargebacks, the customer
portal, and device deactivation — the strongest argument for the switch. Net receipts are after
tax, so commission is not paid on pass-through tax. Payouts monthly, $50 threshold.
⚠️ **No Polar product, org ID, key prefix, or discount code exists any more** — `d105dfa1-…`,
`c05fb364-…`, `LCYT-`, `LLAMAFOUNDER`/`LLAMA50`, the customer-portal validate endpoint, and the
14-day offline-grace machine are all being deleted. Products are declared in **Partner Center**
instead: one auto-renewing monthly subscription + one non-expiring lifetime IAP.
⛔ A discounted subscriber→lifetime upgrade SKU is **not** approved.
- **Support (creator's model, corrected 2026-09-01):** support = email + GitHub issues — the - **Support (creator's model, corrected 2026-09-01):** support = email + GitHub issues — the
once-planned in-app bug-reporter is **out of product** (TASKS.md → closed list). Most queries are once-planned in-app bug-reporter is **out of product** (TASKS.md → closed list). Most queries are
how-tos / feature requests / manual-skimmers. Maintenance cadence = "when I get around to it" with how-tos / feature requests / manual-skimmers. Maintenance cadence = "when I get around to it" with
emergency patches; not a 24/7 service promise. The only license chatter is the paid-user **expiry emergency patches; not a 24/7 service promise. There is **no licence chatter at all** — the app
reminder — now moot** (superseded twice: keys are moot because the lifetime entitlement never is free, there are no keys, and no user sees licence UI. TASK 36 item 5 (expiry reminder) is
expires, and the entire license-key system is moot because Store IAP has no keys); paid users see struck.
zero license UI. TASK 36 item 5 (expiry reminder) is struck.
**Monetization awareness (built-in, ungated, free — 2026-09-01; v1 SCOPE per the complete-v1 **Monetization awareness (built-in, ungated, free — 2026-09-01; v1 SCOPE per the complete-v1
ruling — build order: capture → report → journey → Alerts):** the app is monetization-aware by ruling — build order: capture → report → journey → Alerts):** the app is monetization-aware by
@@ -2569,52 +2565,57 @@ published decisions (recorded in `TASKS/task-43-native-alerts.md`):
--- ---
## Windows packaging & distribution (✅ DECIDED 2026-09-27 — Store MSIX + Store IAP) ## Windows packaging & distribution (⛔ REVERSED 2026-10-22 — direct + Steam; Store dropped)
**The distribution route is settled: Microsoft Store, MSIX package, Store IAP.** Creator's **The Microsoft Store route is dead.** A brand name on the Store's *publisher* line requires a
criteria, verbatim: *"zero headaches, minimal maintenance (for me) while still providing **Company** account, which requires a registered legal entity (an LLC — ~$85 New Mexico with a
accountability and a reasonably easy upgrade flow."* Route A was chosen because it is the ~2-week SOS backlog, ~$143 Wyoming). Partner Center does **not** convert an Individual account to
only option where all four are solved by handing the work to Microsoft rather than to a a Company account later, and an Individual account publishes under the verified legal name. The
certificate vendor: **$0/yr, no certificate, no HSM, no annual renewal, no SmartScreen creator will not fund an entity for a nameplate, so the 2026-09-27 Store ruling was withdrawn on
ramp**, plus Store auto-update, Store-side payments/entitlements/refunds/support, and 2026-10-22. Store IAP was never implemented either — `Windows.Services.Store` appears nowhere in
Microsoft review as the accountability layer. The rejected options and their reasons live in the repo.
`TASKS/research-store-certification.md` §3 — **read that before reopening the question**, not
before re-deriving it. Executable checklist: `TASKS/task-48-distribution-msix.md`. **The route now:**
1. **Direct download from `llamacasty.com`** — the app is **free forever** (TASK 54), so there is
nothing to bill in v1.
2. **Steam when $100 exists** — Steam Direct is $100 per product (recoupable after $1,000 AGR),
accepted categories include *Audio/Video Production* / *Player Tools* (OBS Studio is the
precedent), the **developer/publisher name is self-chosen** so the brand needs no entity,
5+ screenshots at 1920×1080 + capsule art, a build Valve runs, and a **2-week live page**
before release. Valve takes 30%.
3. **Polar is dormant, not deleted-in-principle** — chosen checkout/licence provider if a paid
tier ever returns; $50 already paid. `Distribution.md` §4.2/§4.5 holds the design.
4. **The Microsoft Store: not pursued.** The MSIX package stays as a working artifact and is
submitted nowhere. History: `TASKS/research-store-certification.md`,
`TASKS/task-48-distribution-msix.md`.
**Consequences that are architecture, not packaging detail:** **Consequences that are architecture, not packaging detail:**
- **⛔ Velopack and the update URL are deleted** — the Store auto-updates. **DONE 2026-10-22** - **⛔ The updater is now MISSING, not deleted-by-design.** Velopack and its bootstrap were
(TASK 48 slice 3): the `PackageReference` in `ytLive.csproj` and the bootstrap in removed on 2026-10-22 (TASK 48 slice 3) because the Store was going to deliver updates — that
`App.xaml.cs` are gone, and `ytLive.Tests/StorePackagingTests.cs` guards that no updater premise is gone. An update path must come back, and
package or updater API comes back. The self-hosted DO droplet dies with it. `ytLive.Tests/StorePackagingTests.cs` (which asserts no updater returns) must be rewritten
- **The package is built by `scripts/package-msix.sh` (2026-10-22, TASK 48 slice 2)** — publish with the chosen updater rather than left to veto one.
- **The MSIX package still builds** via `scripts/package-msix.sh` (TASK 48 slice 2) — publish
`Release -r win-x64 --self-contained` into `artifacts/msix-stage`, copy `Release -r win-x64 --self-contained` into `artifacts/msix-stage`, copy
`Packaging/Package.appxmanifest` in as `AppxManifest.xml` + `Packaging/Assets/`, then `Packaging/Package.appxmanifest` in as `AppxManifest.xml` + `Packaging/Assets/`, then
`makeappx pack` → `artifacts/LlamaCasty.msix` (159 MB, gitignored; `MSIX_PFX` signs it for a `makeappx pack` → `artifacts/LlamaCasty.msix` (159 MB, gitignored; `MSIX_PFX` signs it for a
local install). The manifest is a **full-trust `packagedClassicApp` at `mediumIL`** with local install). The manifest is a **full-trust `packagedClassicApp` at `mediumIL`** with
`runFullTrust` + webcam + microphone — that is what lets the app launch the bundled ffmpeg as `runFullTrust` + webcam + microphone. `Square310x310Logo`/`Wide150x150Logo` traps are in
a child process; an `appContainer` package would virtualise the recordings folder instead. `MyMistakes.md`. Logos come from the app's own artwork via `scripts/make-store-logos.py`.
`Square310x310Logo`/`Wide310x150Logo` live on `uap:DefaultTile`, **not** on `VisualElements`. - **⛔ Bundle ffmpeg — no runtime downloads** (was Store policy 10.2.2; still right for a direct
Logos come from the app's own artwork via `scripts/make-store-logos.py`. download, and it fixes the 2026-09-01 404). See the FFmpeg locator section above.
- **⛔ The Polar licensing subsystem is deleted** — `PolarLicenseService`, `PolarLicense`,
`MainViewModel.License.cs` (`PremiumUrl`, customer portal, the
`OfflineGracePeriod = 14 days` subscription-era artifact, renewal/lapse copy), and the
wrong "Polar unlocks alerts" string in `OverlayHost.xaml`. **`IsPremium` is derived from the
Store entitlement**, not a remote HTTP call. That deletes the entire "network flaky → app
thinks I'm expired" bug class along with the offline-grace machine.
- **⛔ Bundle ffmpeg — no runtime downloads** (Store policy 10.2.2; also the 2026-09-01 404).
See the FFmpeg locator section above. **This is the first code unit.**
- **⛔ 64-bit Windows only (creator ruling 2026-10-09).** Both csproj files set - **⛔ 64-bit Windows only (creator ruling 2026-10-09).** Both csproj files set
`<PlatformTarget>x64</PlatformTarget>` (the test project too — an AnyCPU test project `<PlatformTarget>x64</PlatformTarget>` (the test project too — an AnyCPU test project
referencing an x64 app raises MSB3270 and breaks the 0-warning gate). Why: the bundled ffmpeg referencing an x64 app raises MSB3270 and breaks the 0-warning gate). Why: the bundled ffmpeg
is the win64 build, ONNX Runtime 1.31 ships no win-x86 native (only win-x64/win-arm64), and a is the win64 build, ONNX Runtime 1.31 ships no win-x86 native (only win-x64/win-arm64), and a
32-bit address space can't hold the 1080p frame buffers. Windows 11 is 64-bit-only anyway. The 32-bit address space can't hold the 1080p frame buffers. Windows 11 is 64-bit-only anyway.
MSIX publish targets x64 only (ARM64 is a possible later add: both ffmpeg and ONNX Runtime have Public copy says "64-bit Windows 10 or 11".
arm64 builds). Public copy says "64-bit Windows 10 or 11".
**What does NOT change: the monetization posture.** Free gets everything; the branding flash **What does NOT change: the monetization posture.** The app is **free forever with no watermark**
stays the **only** paid delta (`TASK 36` item 2). Store IAP changes how `IsPremium` is (TASK 54, 2026-10-08), so `IsPremium` gates nothing in v1 and the branding flash is OFF by
*obtained*, never what it *gates*. `Monetization` above still governs. default. `Monetization` above governs.
### ⛔ Durable invariant: full trust, or recording silently breaks ### ⛔ Durable invariant: full trust, or recording silently breaks
@@ -2642,19 +2643,21 @@ EV-signed file now accrues reputation exactly like an OV-signed one, so EV ($400
what OV (~$150–300/yr) buys. Signing is **not** instant regardless — a valid cert stops the what OV (~$150–300/yr) buys. Signing is **not** instant regardless — a valid cert stops the
*malware* warning immediately, but "unrecognized publisher" clears only as SmartScreen accrues *malware* warning immediately, but "unrecognized publisher" clears only as SmartScreen accrues
reputation from real download volume. Cert validity is capped at **460 days** (recurring reputation from real download volume. Cert validity is capped at **460 days** (recurring
line item) and private keys must live on an **HSM or hardware token**. The **Store MSIX route line item) and private keys must live on an **HSM or hardware token**. **The 2026-10-22 ruling
needs no certificate at all** — Microsoft re-signs. `Distribution.md` §4.3 was corrected is to ship unsigned and accept the "unrecognized publisher" warning** until download reputation
2026-09-27; it previously recommended EV. accrues — buying a certificate is deferred, not rejected. `Distribution.md` §4.3 was corrected
2026-09-27 (it previously recommended EV).
### Distribution and licensing are independent — but the ruling collapsed them ### Distribution and licensing are independent — and the channel decision does not decide licensing
A **Store-distributed** app *may* still verify licenses with **Polar** (policy 10.8.1 A **Store-distributed** app *may* still verify licences with **Polar** (Store policy 10.8.1
explicitly permits a secure third-party purchase API for non-game PC products), so "should we explicitly permits a secure third-party purchase API for non-game PC products), so "which
use the Store?" did not logically imply "drop Polar?" — route B was a real option. **The channel?" never implied "which billing?" — route B was a real option. The 2026-10-22 reversal
creator chose route A instead**, so Polar is gone. Recording the distinction because it was proves the point in the other direction: dropping the Store did not by itself restart Polar,
the reason the decision was thought-through rather than assumed: the licensing choice rode on because the **2026-10-08 free-forever ruling** (TASK 54) had already removed the paid tier.
a distribution decision, and the *only* route that removed the licensing backend was the one Polar is therefore *dormant*, ready if a paid tier ever returns, and nothing in v1 depends on it.
that also removed the certificate. Both burdens had the same single solution. The lesson worth keeping: the licensing choice rides on a **monetization** decision, not on a
distribution decision.
### ⛔ "Only when the user says so" is enforced by Windows, not by us ### ⛔ "Only when the user says so" is enforced by Windows, not by us
+98
View File
@@ -132,9 +132,107 @@ public sealed class BorderColorPickerTests
$"the drop-down rows do not show the colour's name: '{rows}'"); $"the drop-down rows do not show the colour's name: '{rows}'");
window.Close(); window.Close();
// ── 4. clicking a row inside the open popup stays inside LeftPanel ───────
// MainWindow_PreviewMouseLeftButtonDown deselects SelectedElement (exiting
// the properties listing) whenever LeftPanel.IsClickInside(e.OriginalSource)
// returns false. A ComboBox's PART_Popup lives in its own PopupRoot visual
// tree whose VisualTreeHelper.GetParent is null — the only link back to the
// ComboBox and ElementsScrollViewer is PopupRoot's LOGICAL parent.
panel.DataContext = new PanelContext { SelectedElement = element };
var hostWindow = new Window { Width = 320, Height = 720, Content = panel };
hostWindow.Show();
panel.UpdateLayout();
Dispatcher.CurrentDispatcher.Invoke(() => { }, DispatcherPriority.Loaded);
var realCombo = FindVisualChildren<ComboBox>(panel)
.Single(c => c.ItemsSource is IEnumerable<NamedColor> opts && opts.Any(o => o.Hex == "#e94560"));
realCombo.IsDropDownOpen = true;
realCombo.UpdateLayout();
Dispatcher.CurrentDispatcher.Invoke(() => { }, DispatcherPriority.Loaded);
var crimsonEntry = BorderColorPalette.EntryFor("#e94560");
var crimsonItem = realCombo.ItemContainerGenerator.ContainerFromItem(crimsonEntry) as ComboBoxItem;
Assert.NotNull(crimsonItem);
// The click target inside the popup row (e.g. a TextBlock inside the item)
// must be recognised as inside LeftPanel, or MainWindow clears SelectedElement.
var clickTarget = FindVisualChildren<TextBlock>(crimsonItem!).FirstOrDefault()
?? (DependencyObject)crimsonItem!;
Assert.True(panel.IsClickInside(clickTarget),
"clicking a colour row in the open ComboBox popup is treated as a click outside LeftPanel (exits properties)");
// Clicking the row in the open popup updates the webcam's BorderColor and closes the popup.
crimsonItem!.RaiseEvent(new System.Windows.Input.MouseButtonEventArgs(
System.Windows.Input.Mouse.PrimaryDevice, 0, System.Windows.Input.MouseButton.Left)
{
RoutedEvent = UIElement.MouseLeftButtonDownEvent,
Source = clickTarget,
});
crimsonItem.RaiseEvent(new System.Windows.Input.MouseButtonEventArgs(
System.Windows.Input.Mouse.PrimaryDevice, 0, System.Windows.Input.MouseButton.Left)
{
RoutedEvent = UIElement.MouseLeftButtonUpEvent,
Source = clickTarget,
});
realCombo.UpdateLayout();
Assert.Equal("#e94560", element.BorderColor);
Assert.Equal(crimsonEntry, realCombo.SelectedItem);
Assert.Contains("Crimson", Texts(realCombo));
Assert.False(realCombo.IsDropDownOpen);
// And when starting from a typed custom hex (#123456), picking a palette
// row in the open popup still lands cleanly on that palette colour.
element.BorderColor = "#123456";
realCombo.IsDropDownOpen = true;
realCombo.UpdateLayout();
Dispatcher.CurrentDispatcher.Invoke(() => { }, DispatcherPriority.Loaded);
var emeraldEntry = BorderColorPalette.EntryFor("#22c55e");
var emeraldItem = realCombo.ItemContainerGenerator.ContainerFromItem(emeraldEntry) as ComboBoxItem;
Assert.NotNull(emeraldItem);
var emeraldTarget = FindVisualChildren<TextBlock>(emeraldItem!).FirstOrDefault()
?? (DependencyObject)emeraldItem!;
Assert.True(panel.IsClickInside(emeraldTarget));
emeraldItem!.RaiseEvent(new System.Windows.Input.MouseButtonEventArgs(
System.Windows.Input.Mouse.PrimaryDevice, 0, System.Windows.Input.MouseButton.Left)
{
RoutedEvent = UIElement.MouseLeftButtonDownEvent,
Source = emeraldTarget,
});
emeraldItem.RaiseEvent(new System.Windows.Input.MouseButtonEventArgs(
System.Windows.Input.Mouse.PrimaryDevice, 0, System.Windows.Input.MouseButton.Left)
{
RoutedEvent = UIElement.MouseLeftButtonUpEvent,
Source = emeraldTarget,
});
realCombo.UpdateLayout();
Assert.Equal("#22c55e", element.BorderColor);
Assert.Equal(emeraldEntry, realCombo.SelectedItem);
Assert.Contains("Emerald", Texts(realCombo));
hostWindow.Close();
}); });
} }
private sealed class PanelContext
{
public SceneElement? SelectedElement { get; set; }
public bool IsWebcamSelected => true;
public bool CanToggleClipShape => true;
}
private static IEnumerable<T> FindVisualChildren<T>(DependencyObject root) where T : DependencyObject
{
for (var i = 0; i < VisualTreeHelper.GetChildrenCount(root); i++)
{
var child = VisualTreeHelper.GetChild(root, i);
if (child is T match) yield return match;
foreach (var nested in FindVisualChildren<T>(child))
yield return nested;
}
}
/// <summary>Every text in a visual tree, so a template's content can be read back.</summary> /// <summary>Every text in a visual tree, so a template's content can be read back.</summary>
private static string Texts(DependencyObject root) private static string Texts(DependencyObject root)
{ {