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))
{
child = child is Visual or System.Windows.Media.Media3D.Visual3D
var next = child is Visual or System.Windows.Media.Media3D.Visual3D
? VisualTreeHelper.GetParent(child)
: LogicalTreeHelper.GetParent(child);
: null;
child = next
?? LogicalTreeHelper.GetParent(child)
?? (child as FrameworkElement)?.TemplatedParent;
}
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` |
| `PreviewPane.xaml(.cs)` | Row 1 Col 1 center: the 1920×1080 `OverlayCanvas`/`SelectionOverlay`/`BrandFlashLayer`/`SocialBarElement`, text pull-out drawer, and the live-controls row (TRAX / game / mic meters + sliders + speakers + socials). **TASK 57:** dragging a webcam under the vertical tier routes through `MainViewModel.MoveWebcamInVerticalTier` (the six-spot snap inside the cropped band) instead of free-floating over the master, and a resize re-snaps the tile onto its spot (TASK 57 Unit 2: the resize routes through `MainViewModel.ResizeWebcamInVerticalTier` — the Chat camera's two sizes, then the snap) |
| `SceneThumbnailStrip.xaml(.cs)` | Row 0 scene-picker strip (`ItemsControl` over `Scenes`), staged-red / live-green border highlight |
| `LeftPanel.xaml(.cs)` | Row 1 Col 0 left panel: edit mode → layers+properties; live → YouTube chat. Source add/edit/remove, list drag-to-reorder, snapshot revert-accept. **TASK 51:** the webcam properties carry the Background section — `RemoveBackgroundPill` / `BlurBackgroundPill` (`PillToggle`, radio-exclusive via `WebcamSceneConfig.RemoveBackground`/`BlurBackground`) + `BackgroundStrengthSlider` (0..1, 5% snaps, shown only while an effect is on). **TASK 57:** the webcam properties carry a Geometry section (X/Y/W/H) — manual placement of the camera, bound to the **live** placement (the one the current tier shows), gated on `IsWebcamSelected` (not `SelectedElement.IsWebcam` — the TASK 51 test finds that panel by binding text and would match twice); the Shape button's `Visibility` is bound to `CanToggleClipShape`, so the circle option is NOT OFFERED in the vertical Chat layout. **TASK 58:** every drop-down, check box and field in the properties panel carries the theme's style (`YtComboBox`/`YtCheckBox`/`YtTextBoxCompact`) and paints no colour of its own — the guard is `ytLive.Tests/PropertiesStyleTests.cs`. **TASK 59:** the webcam's properties are a HIERARCHY — a `Shape` title owns the mirror switch and the shape choice (`Rect` | `Circle` as two `SegmentToggle` options, Rect selected by default, the circle option hidden where `CanToggleClipShape` is false), and every titled block (Geometry, Border Color, Border Thickness, Background) owns its content one indent deeper (Strength sits under Blur). **TASK 60:** the border-colour drop-down builds its menu from `Models/BorderColorPalette.cs` — each row is the hex plus the colour's NAME (`#e94560 Crimson`, tooltip "Crimson (#e94560)"); the swatch icon is unchanged. **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 |
| `SceneThumbnailStrip.xaml.cs` | Code-behind for the strip 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
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`.
- **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
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
@@ -66,8 +67,17 @@
`WebcamBorderColorPicker_Click` are **gone**. The closed box != the rows only through
`Helpers/ClosedBoxTemplateSelector.cs` (WPF copies `ItemTemplate` into the read-only
`SelectionBoxItemTemplate`; the selector is told them apart by the `ContentPresenter`'s
templated parent - `ComboBox` = closed box, `ComboBoxItem` = a row). Guard:
`ytLive.Tests/BorderColorPickerTests.cs` (model + panel + the **real widget**). The TASK 60
templated parent - `ComboBox` = closed box, `ComboBoxItem` = a row). **2026-10-22 creator bug fix:**
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
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
@@ -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.**
## Next step
- **Creator check of TASK 63 (needs the creator's eyes)** — open Live → Layers → webcam →
Properties → Border Color: the drop-down must now LOOK like the Display/Font drop-downs (same
dark themed chrome, same chevron), the CLOSED box must show the colour's name beside the hex
field ("Crimson"), and the open list must show swatch + hex + name. The hex field still accepts a
typed colour (the drop-down then shows the swatch with no name — "no name" is the honest answer).
- **✅ TASK 63 + popup-click fix verified by creator (2026-10-22)** — clicking a colour in the
Border Color `YtComboBox` dropdown now stays inside `LeftPanel` (`IsDescendantOf` crosses
`PopupRoot` → `PART_Popup` via `LogicalTreeHelper.GetParent`), updates the webcam border colour,
and does not exit the properties listing.
- **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
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
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)
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) |
| 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) |
| 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
`#e94560`; the model matches by VALUE, so the hex the creator typed in any case still lands
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 rows the creator approved in TASK 60). A ComboBox cannot do that with one
`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
(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
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
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
@@ -2068,13 +2074,13 @@ Apply this to every UI decision:
> Everything below is the historical paid-tier design.
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.
Free forever: **all features unlocked for everyone** — no feature lock between free and paid.
The **only difference is watermarking**. This is deliberate: no creator will tolerate a watermark,
and as a good-guy developer, we give them complete access to every feature so no one can call us
crooked.
Free forever: **all features unlocked for everyone** — no feature lock, no paid tier, and since
2026-10-08 **no watermark either** (the credit is OFF by default; see the banner above). No
creator will tolerate a watermark, and as a good-guy developer we give everyone complete access
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,
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 —
everything is free. **How `IsPremium` is obtained is a billing concern and has changed twice;
what it gates has not.**
- **Pricing (✅ DECIDED 2026-09-27 — supersedes the 2026-09-21 one-time "own it" model):**
**`$10/mo` subscription, or `$400` lifetime. No annual. No `.99` prices.** ⛔ The old `$29`
founder → `$49` list lifetime ruling is **dead — do not cite it.** Two tiers, one delta: they
break even at **month 40**, so they barely compete, which is why lifetime is safe to offer at
launch instead of saving it for later. The free tier is the *full* app (only the watermark
differs), so accessibility is preserved without pricing down. ⛔ No annual tier: Microsoft does
not pro-rate and **the developer cannot refund** the forfeited remainder — verbatim the
grievance that drove the ruling. **ADA §6(h): raising the price disables auto-renew, so `$10` is
effectively locked for the product's life.** Full reasoning in `MONETIZATION.md` → Pricing.
- **Billing: ⛔ Polar is DELETED — Microsoft Store IAP, Microsoft as merchant of record** (ruling
2026-09-27; teardown in `TASKS/task-48-distribution-msix.md` item 0). **Revenue share 15%,
subscriptions included** — verified from the ADA v8.10 PDF, §6(b)(i); the agreement's own v8.0
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.
- **Pricing: ⛔ SUPERSEDED 2026-10-08 by the free-forever ruling (TASK 54).** Retained as
reasoning only: `$10/mo` subscription or `$400` lifetime, no annual, ruled 2026-09-27 and
overtaken ten days later. If a paid tier ever returns, these are the numbers to argue from.
**How `IsPremium` would be obtained is a billing concern; what it gates is not.**
- **Billing: ⏸ dormant — no paid tier exists** (free-forever ruling 2026-10-08, TASK 54). The
Store IAP plan of 2026-09-27 was never implemented (`Windows.Services.Store` appears nowhere
in the repo) and was withdrawn with the Store route on 2026-10-22. If a paid tier ever
returns, the provider is **Polar** — merchant of record for VAT/sales tax, 5% + $0.50 Starter,
**$50 already paid**; `Distribution.md` §4.2/§4.5 holds the design. ⚠️ No Polar product, org
ID, key prefix, or discount code exists (`d105dfa1-…`, `c05fb364-…`, `LCYT-`, `LLAMAFOUNDER`/
`LLAMA50`, the customer-portal validate endpoint, and the 14-day offline-grace machine are all
gone).
- **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
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
reminder — now moot** (superseded twice: keys are moot because the lifetime entitlement never
expires, and the entire license-key system is moot because Store IAP has no keys); paid users see
zero license UI. TASK 36 item 5 (expiry reminder) is struck.
emergency patches; not a 24/7 service promise. There is **no licence chatter at all** — the app
is free, there are no keys, and no user sees licence UI. TASK 36 item 5 (expiry reminder) is
struck.
**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
@@ -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
criteria, verbatim: *"zero headaches, minimal maintenance (for me) while still providing
accountability and a reasonably easy upgrade flow."* Route A was chosen because it is the
only option where all four are solved by handing the work to Microsoft rather than to a
certificate vendor: **$0/yr, no certificate, no HSM, no annual renewal, no SmartScreen
ramp**, plus Store auto-update, Store-side payments/entitlements/refunds/support, and
Microsoft review as the accountability layer. The rejected options and their reasons live in
`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 Microsoft Store route is dead.** A brand name on the Store's *publisher* line requires a
**Company** account, which requires a registered legal entity (an LLC — ~$85 New Mexico with a
~2-week SOS backlog, ~$143 Wyoming). Partner Center does **not** convert an Individual account to
a Company account later, and an Individual account publishes under the verified legal name. The
creator will not fund an entity for a nameplate, so the 2026-09-27 Store ruling was withdrawn on
2026-10-22. Store IAP was never implemented either — `Windows.Services.Store` appears nowhere in
the repo.
**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:**
- **⛔ Velopack and the update URL are deleted** — the Store auto-updates. **DONE 2026-10-22**
(TASK 48 slice 3): the `PackageReference` in `ytLive.csproj` and the bootstrap in
`App.xaml.cs` are gone, and `ytLive.Tests/StorePackagingTests.cs` guards that no updater
package or updater API comes back. The self-hosted DO droplet dies with it.
- **The package is built by `scripts/package-msix.sh` (2026-10-22, TASK 48 slice 2)** — publish
- **⛔ The updater is now MISSING, not deleted-by-design.** Velopack and its bootstrap were
removed on 2026-10-22 (TASK 48 slice 3) because the Store was going to deliver updates — that
premise is gone. An update path must come back, and
`ytLive.Tests/StorePackagingTests.cs` (which asserts no updater returns) must be rewritten
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
`Packaging/Package.appxmanifest` in as `AppxManifest.xml` + `Packaging/Assets/`, then
`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
`runFullTrust` + webcam + microphone — that is what lets the app launch the bundled ffmpeg as
a child process; an `appContainer` package would virtualise the recordings folder instead.
`Square310x310Logo`/`Wide310x150Logo` live on `uap:DefaultTile`, **not** on `VisualElements`.
Logos come from the app's own artwork via `scripts/make-store-logos.py`.
- **⛔ 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.**
`runFullTrust` + webcam + microphone. `Square310x310Logo`/`Wide150x150Logo` traps are in
`MyMistakes.md`. Logos come from the app's own artwork via `scripts/make-store-logos.py`.
- **⛔ Bundle ffmpeg — no runtime downloads** (was Store policy 10.2.2; still right for a direct
download, and it fixes the 2026-09-01 404). See the FFmpeg locator section above.
- **⛔ 64-bit Windows only (creator ruling 2026-10-09).** Both csproj files set
`<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
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
MSIX publish targets x64 only (ARM64 is a possible later add: both ffmpeg and ONNX Runtime have
arm64 builds). Public copy says "64-bit Windows 10 or 11".
32-bit address space can't hold the 1080p frame buffers. Windows 11 is 64-bit-only anyway.
Public copy says "64-bit Windows 10 or 11".
**What does NOT change: the monetization posture.** Free gets everything; the branding flash
stays the **only** paid delta (`TASK 36` item 2). Store IAP changes how `IsPremium` is
*obtained*, never what it *gates*. `Monetization` above still governs.
**What does NOT change: the monetization posture.** The app is **free forever with no watermark**
(TASK 54, 2026-10-08), so `IsPremium` gates nothing in v1 and the branding flash is OFF by
default. `Monetization` above governs.
### ⛔ 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
*malware* warning immediately, but "unrecognized publisher" clears only as SmartScreen accrues
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
needs no certificate at all** — Microsoft re-signs. `Distribution.md` §4.3 was corrected
2026-09-27; it previously recommended EV.
line item) and private keys must live on an **HSM or hardware token**. **The 2026-10-22 ruling
is to ship unsigned and accept the "unrecognized publisher" warning** until download reputation
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
explicitly permits a secure third-party purchase API for non-game PC products), so "should we
use the Store?" did not logically imply "drop Polar?" — route B was a real option. **The
creator chose route A instead**, so Polar is gone. Recording the distinction because it was
the reason the decision was thought-through rather than assumed: the licensing choice rode on
a distribution decision, and the *only* route that removed the licensing backend was the one
that also removed the certificate. Both burdens had the same single solution.
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 "which
channel?" never implied "which billing?" — route B was a real option. The 2026-10-22 reversal
proves the point in the other direction: dropping the Store did not by itself restart Polar,
because the **2026-10-08 free-forever ruling** (TASK 54) had already removed the paid tier.
Polar is therefore *dormant*, ready if a paid tier ever returns, and nothing in v1 depends on it.
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
+98
View File
@@ -132,9 +132,107 @@ public sealed class BorderColorPickerTests
$"the drop-down rows do not show the colour's name: '{rows}'");
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>
private static string Texts(DependencyObject root)
{