## 2026-10-30 — C# record struct: `new T()` ignores the parameter defaults **Symptom:** two property controls "had no code behind them" — the Background checkbox and the opacity slider changed nothing visible. **Cause:** `public readonly record struct ScoreboardStyle(string TextColor = "#FFFFFF", bool BackgroundEnabled = true, …)`. `new ScoreboardStyle()` binds to the implicit **parameterless constructor**, which zero-initialises — the defaults in the parameter list are only used when the constructor is called with those arguments omitted *at a call site that supplies some of them*. Result: null colour, `BackgroundEnabled = false`, `FontOpacity = 0`, `FontSize = 0`. The renderer drew the zeros faithfully, so the UI looked dead while every unit test of the renderer passed (they constructed the style explicitly). **Rule:** a record struct with meaningful defaults needs an explicit parameterless constructor (`public T() : this() { }`). And a "defaults" test must construct the type the same way production does — with `new()` — never with explicit arguments, or it proves nothing. **Guard:** `ScoreboardRendererTests` asserts `new ScoreboardStyle()` yields white / on / black / 0.20 / max 200. # MyMistakes.md > **Two jobs**, distinguished by heading: > > 1. **Per-task failure log** — updated before every commit touching that task: > current iteration + why the last one failed. On task complete, committed AND > pushed → truncate to this stub. A new task does NOT seed this file until its > first failure. > 2. **Recipes registry** (DERIVED-SOLUTION RULE, see `AGENTS.md` 🔬) — the durable > home for one-off derived solutions, recipes, and how-tos. The moment you work > out a reusable solution, write it here **in the same session**. GREP THIS FILE > FIRST when you hit a "I've done this before but have to figure it out again" > wall. Recipe entries stay permanently (they are NOT truncated on task > completion) — only the failure log truncates. ## 🔬 Recipes registry ### A SETTING THAT "DIDN'T STICK" — CHECK THE DEBOUNCE FIRST (RECIPE, 2026-11-12) **Symptom:** a value is set in the UI, behaves correctly for one session, and is gone after a restart. It looks like a wiring bug in the control; it is almost always `ScheduleSave()`. **Cause:** `MainViewModel.ScheduleSave()` restarts a debounce timer that later calls `Save()`, which writes the whole layout. Anything changed in the last few seconds before the app closes is lost — and none of it is logged, so the next session reads a value the creator never chose. **Quick fix:** for anything small and decisive (a sync offset, a toggle the creator just clicked), call the `_layoutStore.Save…()` method **in the setter itself**, before `ScheduleSave()`. Two rows cost nothing; a lost setting costs a whole reproduce-and-test cycle. Live example: `MainViewModel.Audio.cs` → `AudioSyncOffsetMs` / `MicSyncOffsetMs`. **Check list when a remembered setting misbehaves:** grep the property for `ScheduleSave`, confirm a direct save exists, then reproduce with a *restart* (not just a new session) before debugging deeper. **Bit us:** the lip-sync offset. `Audio.SyncOffsetMs` was set to −130, lost on close, and two clips were measured against a setting that was not in force — the numbers disagreed by ~65 ms and the disagreement was blamed on the audio path. ### A SYNC OFFSET IS NEVER A PRODUCT DEFAULT (2026-11-12) **Mistake:** `MicSyncOffsetMs` shipped at **175 ms** because that suited one C920 on one desk. It shifted every creator's voice to correct one machine and hid whether a desync was the app's fault. **Rule:** offsets ship at 0 and are opt-in. `AudioSyncSettingsTests` pins it. And the tool that measures them, `scripts/avsync.py`, varies **±35 ms between clips** on identical settings — it can answer "is this 100 ms out?" but never "is this 20 ms out?". Below ~50 ms the creator's judgement is the better instrument, so stop tuning rather than chasing the tool's noise. ### A LOOSE FILE IN `Assets/` IS NOT AN ASSET — THE CSProj LIST IS (RECIPE, 2026-11-04) **Problem:** four untracked `Assets/llamacasty-*` exports sat in the tree for days, reported in `HANDOFF.md` as "untouched", and every session spent tokens re-litigating whether to commit them. **What it actually is:** shipped art is embedded in the assembly via **``** in `ytLive.csproj` (icons, logo, backgrounds, fonts) or `` (the ONNX model), and the Store tiles in `Packaging/Assets/` are generated from `Assets/llamacasty-logo.jpg` by `scripts/make-store-logos.py`. The app reads art through `pack://`/`/LlamaCasty;component/Assets/…` URIs — it never loads a file from disk at runtime. So a loose `.jpg`/`.png` in `Assets/` that appears in **neither** the csproj nor any `Source=`/`Icon=`/script is a **source export**, not a dependency. **The recipe (30 seconds, do it before mentioning the file):** 1. `git status --short` → the untracked list. 2. For each file: `grep -rIn -F "" --exclude-dir=.git --exclude-dir=bin --exclude-dir=obj .` plus `grep -n 'Assets' ytLive.csproj`. **No hit in either = dead.** (Check `scripts/` too — a generator can be the only consumer.) 3. Referenced → add it to `` **and** commit it in the same unit. Unreferenced → `rm` it and commit the deletion; do not park it in `HANDOFF.md`. **Deleted 2026-11-04 by this rule:** `llamacasty-icon.jpg`, `llamacasty-logo-white.jpg`, `llamacasty-logo-white-nobg.png`, `llamacasty-logo-white-nobg-dim.png` — the shipped brand art is `llamacasty-icon.ico`/`.png` and `llamacasty-logo.jpg`, all three already tracked and embedded. ### DEPLOYING llamacasty.com TO THE DROPLET (RECIPE, 2026-10-30) **Problem:** "upload the site" burned a session: `ssh -p 2214 gramps@llamachile.tube` hung on a prompt, then answered `Permission denied (publickey)` everywhere, and the two md files that document the deploy only said *"copy this working tree into `/var/www/linkstack/`"* — a destination, not a transport. **What it actually is:** the static site lives in **`/var/www/linkstack/`** on the YunoHost droplet (`llamachile.tube` = `llamacasty.com` = `143.244.176.131`), owned `linkstack:www-data`. The host sshd is **port 2214** with **key-only auth** (`PasswordAuthentication no`; publickey is the only method offered for every user). The `CREDENTIALS.md` password is the **sudo** password — it is not an SSH auth method and never was. **The hang was the second factor:** sshd had `AuthenticationMethods publickey,keyboard-interactive` (PAM `pam_google_authenticator`), so a key got through and then the server asked for a verification code that a non-interactive tool call cannot answer. Disabled permanently 2026-10-30 by commenting that line and the PAM module in `/etc/pam.d/sshd`, then `sshd -t && systemctl restart ssh`. **The recipe:** `llamacasty.web/deploy.sh` — tars the **public** files only (`index.html`, `privacy.html`, `css/`, `js/`, `assets/`), `scp`s the tarball to `/tmp`, then one `sudo -S` shell that backs up the live root to `/var/backups/linkstack-.tgz`, extracts over it, restores `linkstack:www-data` + `755/644`, deletes `build-this.md` from the root, and curls the site. Password comes in on **stdin** (`SITE_SUDO_PW=... ./deploy.sh`), never on a command line. - ⚠️ **Never publish the md files.** `build-this.md` shipped in the web root until 2026-10-30 and now carries **INTERNAL** pricing notes. The deploy script's file list is explicit for that reason. - ⚠️ **drvfs sets 777 on copied files** — the chmod pass in the script is not cosmetic; a world- writable web root is a defacement waiting to happen. - ⚠️ **`/var/www/linkstack__2` is a different app's directory** (the old LinkStack PHP install). The static site is `linkstack`, without the suffix. - ⚠️ Ports **22 and 1319 are closed**; 2214 is the only SSH door, and it is the *host*, not Gitea (Gitea pushes go over **HTTPS** with the credential helper — see `docs/ai/environment.md`). ### POINTING www AT THE APEX ON THE YUNOHOST DROPLET (RECIPE, 2026-10-31) **Problem:** `www.llamacasty.com` did not resolve at all, and once a record existed the server half was four separate traps: YunoHost added the vhost with a **self-signed** cert, refused to issue Let's Encrypt without a diagnosis, and served the **SSO login** because the domain has no app. **1. DNS** — Namecheap BasicDNS, zone `llamacasty.com`. `www CNAME llamacasty.com.` (YunoHost's own subdomain example uses a CNAME per subdomain, and a CNAME follows the apex if the droplet IP moves; an `A www` record would need a second edit for every rebuild). The apex `A` is the only IP holder. **2. Domain + cert on the droplet:** ```bash yunohost domain add www.llamacasty.com --install-letsencrypt-cert # vhost + SELF-SIGNED cert yunohost domain cert install www.llamacasty.com --no-checks # real LE cert ``` - ⚠️ The `--install-letsencrypt-cert` flag **does nothing here**: *"Skipping Let's Encrypt certificate attempt because there's no wildcard configured on the parent domain's DNS records."* It exits SUCCESS with a self-signed cert, so check `openssl s_client -servername ` afterwards. - ⚠️ Plain `yunohost domain cert install` refuses with *"no diagnosis result for domain … use `--no-checks`"*. `--no-checks` is safe when the name resolves publicly — LE's HTTP-01 validation is itself the DNS proof (it failed over to a real cert on the first attempt). **3. The redirect** — with no app installed the vhost 302s to `/yunohost/sso?r=…`, so a `301` is needed. YunoHost includes `/etc/nginx/conf.d/.d/*.conf` **inside the server{} block**; `location / { return 301 https://llamacasty.com$request_uri; }` there wins over SSO because `return` runs in the rewrite phase, before `access_by_lua_file` (the global SSO guard). The `.d` include survives YunoHost regenerating the `.conf`; never edit the `.conf` itself. - ⚠️ **The heredoc ate `$request_uri`.** Writing that file through `ssh "sudo bash -c 'cat > … <`. **Verify:** `curl --resolve www.llamacasty.com:443:143.244.176.131 -o /dev/null -w '%{http_code} %{redirect_url}' https://www.llamacasty.com/privacy.html` → `301 https://llamacasty.com/privacy.html`; the cert must read `CN=www.llamacasty.com`, issuer Let's Encrypt. ### LICENSING WITHOUT A SERVER — POLAR'S CUSTOMER-PORTAL API (RECIPE, 2026-10-31) **Problem:** the plan carried a "claim page" — a hosted OAuth flow that wrote the buyer's channel ID onto the licence key — plus an implied server to hold keys. Both were invented from an assumption that binding needs a browser. **It does not.** Polar's customer-portal endpoints authenticate on the **key itself** plus the `organization_id` (a public identifier, safe in the binary). The **org access token is only needed to create products** and must never ship. ```bash # activate (label is required; conditions are re-checked on every validate) POST https://api.polar.sh/v1/customer-portal/license-keys/activate { "key": "LCYT-…", "organization_id": "c05fb364-…", "label": "DESKTOP-NAME", "conditions": { "channel_id": "UC…" } } # validate on launch + daily, with the SAME conditions object POST https://api.polar.sh/v1/customer-portal/license-keys/validate { "key": "LCYT-…", "organization_id": "c05fb364-…", "activation_id": "…", "conditions": { "channel_id": "UC…" } } ``` - `limit_activations` on the benefit = the device cap (2). Polar's **customer portal** already does deactivation, key rotation and "where is my key", so build no UI for it. - A **subscription key is auto-revoked when the subscription ends** — that is what makes lapse a real state; check `status`, not `expires_at`. - ⚠️ `conditions` is **agreement, not cryptography**: a patched client can send someone else's ID. Accepted — the free tier is the defence, not the check. - ⚠️ There is **no push to a desktop app**: delivery is email or the customer portal. Auto-delivery needs a webhook plus an endpoint *you* host, holding keys. Not worth it for one copy-paste. ### YOUTUBE "VERIFIED" — THREE DIFFERENT THINGS (2026-10-31) `youtube.com/verify` saying "your account is verified" answers only the first tier. The tiers live in Studio → Settings → Channel → **Feature eligibility**: | Tier | Requires | Gives | |---|---|---| | Standard | nothing | uploads, playlists, posts (low daily limits) | | **Intermediate** | phone verification | >15 min videos, **live streaming on a computer**, custom thumbnails | | Advanced | **channel history** or ID/video verification | higher daily limits, clickable description links, chapters, pin comments, monetization application | - **Live streaming is intermediate, not advanced** — a verified channel can stream; the API's "live streaming not enabled" error means the *user's* channel is unverified, so the app must map it to "verify at youtube.com/verify". - **2 channels per phone number per year**, 365-day reset; VoIP/Google Voice numbers are refused. That is the "used too many times" wall, and there is no appeal. - Verification is **sticky** — it survives the phone being removed, which is why an old account can read "verified" while no number on it will pass today. - Advanced via **ID/video verification** is a privacy trade with a 24 h review; channel history is free and usually sufficient within ~2 months of normal activity. Prefer history. - ⚠️ Only the **primary owner** can do ID/video verification, and brand-account managers hit *"Advanced YouTube features aren't available for this account"*. If ID verification is ever wanted on a personal channel, do it **before** converting to a brand account. ### THE `const` THAT MAKES A GUARD UNREACHABLE (2026-11-01) A dev-build flag declared `const bool` turns `if (!DevBuild.IsDevelopment) return;` into a compile-time branch: Debug reports **CS0162 unreachable** on the guard, Release reports it on the body — and the repo builds at 0 warnings, so either config fails the gate. **Fix:** `public static readonly bool IsDevelopment =` … set once from `#if DEBUG`. Both arms compile clean in both configurations, and the JIT still folds the branch. The *real* guarantee for "this must not ship" is not the flag anyway — it is removing the UI's `Page`/`Compile` items from the Release build (``). ⚠️ A Condition on the **ItemGroup** is invisible to a test that reads `item.Attribute("Condition")`. Read the parent too — that mistake cost one failing test here. ### THE SINGLE-FILE PUBLISHER EATS ffmpeg (2026-11-01) **Problem:** `dotnet publish` with `PublishSingleFile=true` + `IncludeNativeLibrariesForSelfExtract=true` produced a **9-file** output with **no `ffmpeg\` folder** and a **182 MB exe** — the libav\*.dll family had been bundled *inside* the binary. The build was green, the tests passed, and the app would have shipped unable to find ffmpeg at runtime (the locator looks for `ffmpeg\ffmpeg.exe` beside the exe). **Diagnosis that worked:** diff the publish output against a **control publish with the single-file flags removed** — ffmpeg was there. Then add flags back one at a time; `IncludeNativeLibrariesForSelfExtract` was the one that broke it. **Fix:** mark the content `ExcludeFromSingleFile="true"`. ```xml ``` After: 127 MB exe, `ffmpeg\` present, depot ≈ 257 MB total. - ⚠️ **The `` before it is not the fix** — it silences a duplicate-item warning from the SDK's default glob, and I tried it first and wrongly believed it had worked. Two failed remedies in a row is the research trigger: read the publisher's own metadata (`ExcludeFromSingleFile`) instead of guessing at item globs. - ⚠️ **A clean build and green tests cannot see this.** Only the artifact can, which is why `scripts/publish.sh` fails loudly when `ffmpeg\`, `LICENSE`, `PRIVACY.md` or `THIRD-PARTY-NOTICES.txt` are missing, and when test assemblies leak in. ### STEAM DISTRIBUTION, COMMERCIAL USE, AND THE DEMO OWNERSHIP CHECK (2026-11-02) **Why recorded:** the Steam Subscriber Agreement's private-use wording was misread as a blocker for monetized streaming. Check the exception and the established Steam software precedent before raising it again. - **Steam installs skip SmartScreen entirely** — Valve's client delivers the files, so there is no browser download, no Mark-of-the-Web and no publisher warning. An unsigned exe is *fine* in this channel, and a certificate is only needed if a non-Steam download ever exists. - **Steam updates the app** — no self-updater, no version banner, no update endpoint. - **⛔ In-app purchases must use the Steam Wallet.** Steamworks, *Microtransactions (In-Game Purchases)*: *"For any in-game purchases, you'll need to use the microtransaction API so Steam customers can only make purchases from the Steam Wallet."* LlamaCasty has no in-app checkout. - **The private-use wording is not, by itself, a commercial-use ban for LlamaCasty.** The Steam Subscriber Agreement §2.A permits commercial use when the applicable product terms expressly allow it; §5 has general private/personal-use wording for third-party software. OBS Studio is distributed on Steam as video-production software under GPL, and OBS confirms the Steam build is the same as its website build. GPL permits commercial use. LlamaCasty's EULA should expressly allow commercial and monetized streaming; confirm the wording with Valve or counsel. Do not call this a Steam blocker based only on §5. Sources: https://store.steampowered.com/subscriber_agreement/ · https://obsproject.com/kb/steam-faq · https://www.gnu.org/licenses/gpl-faq.html - **Demo ownership check:** the free Demo uses its own App ID; `ISteamApps::BIsSubscribedApp` checks ownership of the paid app. The paid App ID must be configured; zero/missing ID fails closed. Valve reference: https://partner.steamgames.com/doc/api/ISteamApps#BIsSubscribedApp - **Steam sells one paid app and distributes its associated free Demo.** Same code; no GitHub binary. The demo links to the paid store page and can send a one-time wishlist notification. - **Renaming a live exe is painful** (Steamworks → *Tip – Renaming a live exe*). Do the `LlamaCasty.exe` rename **before** the first depot upload, not after. - **The paid app's Steam store page must be public two weeks before release.** The Steamworks application and $100 fee are already submitted; Valve's decision is pending. ### CONVERTING A MEDIAPIPE .tflite (WITH CUSTOM OPS) TO ONNX — AND PROVING IT (RECIPE, TASK 51) **Problem:** MediaPipe segmentation models end in the custom op `Convolution2DTransposeBias`; tf2onnx and tflite2onnx refuse it. **Recipe:** `tools/selfie-seg/convert_tflite_to_onnx.py` — parse the flatbuffer with the `tflite` pip package, emit ONNX with `onnx.helper`, transpose NHWC→NCHW once at input/output, fold every `DEQUANTIZE` (fp16 weights) into fp32 initializers. Weight layouts: CONV OHWI→OIHW; DEPTHWISE [1,H,W,C]→[C,1,H,W] with `group=C`; transpose-conv [O,H,W,I]→[I,O,H,W]. TF `SAME` padding = ONNX `auto_pad=SAME_UPPER`. - ⚠️ **Enum trap:** the custom op's options are the C-API `TfLiteTransposeConvParams`, whose `padding` uses `TfLitePadding` (Unknown=0, **SAME=1**, VALID=2) — NOT the flatbuffer `Padding` enum (SAME=0). Comparing against `Padding.SAME` rejects the real model. - **Proof, not hope:** `pip install mediapipe` ships the TFLite runtime WITH the custom op. Feed an image already at the model's exact input size (no resampling on either side) to MediaPipe's `ImageSegmenter` and to onnxruntime; compare masks (`verify_against_mediapipe.py`, exit 0 at max |diff| < 1e-3 — we got 5e-5). Work in a throwaway venv under `/tmp/opencode/`. ### ⛔ NEVER PRINT CREDENTIALS.md — NOT EVEN "FILTERED" (2026-10-09 incident) **What happened:** to look for an API key, a `sed -n '1,40p' CREDENTIALS.md` piped through a redaction regex was run. The regex only matched `password: \`…\``; the **"Sudo pattern" line stores the password inside `echo '…'`**, slipped past the filter, and the full sudo password was printed into the session output (and so into local session logs and the model provider's transcript). Owner had to rotate it. **Rule:** never `cat`/`sed`/`grep`/`read` CREDENTIALS.md (or any secrets file) in a way that can print values. Redaction regexes are not a safety net — secrets hide in shapes you didn't anticipate. - **Need a secret for a command?** Load it into a variable, never echo it: `PW=$(sed -n 's/.*password:.*\`\(.*\)\`.*/\1/p' CREDENTIALS.md | head -1)` then use `"$PW"`. - **Need to know what's in the file?** List **keys only**, never values: `grep -oE '^\s*- \*\*[^*]+\*\*' CREDENTIALS.md` (bold labels) — or ask the owner. - **Looking for a specific secret?** Test for presence without printing: `grep -qi 'gitea' CREDENTIALS.md && echo present || echo absent`. - Secrets in source (e.g. the Google client secret) live in gitignored `Helpers/OAuthCredentials.cs` — check with `git check-ignore -v`, don't open it. **Rotation (YunoHost user `gramps`):** YunoHost owns the password (LDAP) — plain `passwd` may not apply. Owner rotates via web admin → Users → gramps, or `sudo yunohost user update gramps -p` (prompts). Never route the new password through the AI. ### YOUTUBE STATISTICS ARE JSON STRINGS; TryGetInt64 THROWS ON STRINGS (RECIPE) YouTube Data API v3 returns `statistics.subscriberCount / viewCount / videoCount` as JSON **strings** (`"350"`), not numbers. (2026-09-23: the YPP parse bug surfaced the moment the auditDetails 403 stopped masking it.) Fix = a tolerant read that checks `ValueKind` FIRST, then `TryGetInt64` for `Number`, then `long.TryParse(GetString())` for `String`. Trap within the fix: **`JsonElement.TryGetInt64` THROWS `InvalidOperationException` on any non-Number token** ("requires an element of type 'Number'") — it is try-type-in, not try-catch. Guard on `value.ValueKind` before calling it; a leaked 403→parse chain means your "whole request failed" symptom can paper over a second crash that only appears once the 403 is fixed (test the full happy path, not just the error path). ### liveChatId LIVES IN SNIPPET AND ONLY EXISTS ONCE THE BROADCAST IS LIVE (RECIPE) YouTube Data API v3 `liveBroadcasts`. `liveChatId` is in **`snippet.liveChatId`** — `contentDetails` has NO such property. AND it is only populated once the broadcast is **live** (the official `GetLiveChatId.java` sample lists `broadcastStatus=active`); a fetch right after insert (lifecycleStatus `ready`) returns nothing. (2026-09-25: this cost a whole investigation — the TEST-tab "Chat polling couldn't start — Mock Chat Input is disabled" report — because the code read `part=contentDetails` + ran before the frame pump pushed RTMP.) Fix = read part=snippet + bounded retry AFTER the encoder starts (`GetBroadcastLiveChatIdAsync(broadcastId, maxAttempts=10, delayMs=2000)`). Debug lens: missing liveChatId ≈ "broadcast not live yet", NOT an auth failure. ### liveChat/MESSAGES.INSERT BODY REQUIRES snippet.type (RECIPE) YouTube Data API v3 `liveChat/messages.insert` rejects the body with `400 MISSING_REQUIRED_FIELD` (`domain: youtube.api.v3.LiveChatMessageInsertResponse.Error`) unless the snippet declares `snippet.type` = `textMessageEvent` (or `pollEvent`) alongside `liveChatId` and `textMessageDetails.messageText` — the official insert reference lists `type` as a required property. (2026-09-25: the TEST-tab "YouTube rejected the message (error 400)" report — the liveChatId fix in TASK 44 had worked and the drawer's Mock Chat Input was issuing a real insert, but the body omitted `type`, and the TASK 41 test only asserted `liveChatId` + `messageText` were present so it stayed green while real YouTube rejected every send. Fix = add `type = "textMessageEvent"` to the body + assert it in the Good Dog test.) Debug lens: MISSING_REQUIRED_FIELD ≠ auth/scope — it means the request body shape is wrong, and the false-green test is the classic trap: assertion on the body was about WHAT WE SEND, so YouTube's required fields must be mirrored in the test. ### CHANNELS.LIST auditDetails PART 403s THE WHOLE REQUEST WITHOUT A PARTNER SCOPE (RECIPE) YouTube Data API v3 `channels.list` rejects the ENTIRE request with `403 insufficientPermissions` if your `part=` list includes `auditDetails` but the token lacks `https://www.googleapis.com/auth/youtubepartner-channel-audit` — the docs' exact words: "A request that retrieves the auditDetails part for a channel resource must provide an authorization token that contains the youtubepartner-channel-audit scope". That scope is MCN content-partner tooling (with a two-week token-revocation rule); normal-creator apps should NEVER ask for it. 2026-09-23 real-log evidence: YPP refresh 403'd three times in a row while the mock-fake tests stayed green ("current scopes suffice, no re-consent" was wrong). Fixes: (1) drop `auditDetails` from `part=`; (2) log the response BODY — the bare status code could not name `insufficientPermissions`, which is what made this failure undiagnosable for days; (3) when a feature needs data no ordinary scope grants, deep-link to the site instead of requesting the partner privilege. ### TRANSITION(COMPLETE) RACES AUTOSTOP: PRE-FLIGHT lifeCycleStatus (RECIPE) A blind `liveBroadcasts.transition?broadcastStatus=complete` POST can 403 `invalidTransition` even though the stream just ended normally — 2026-09-22 logged it on EVERY session end. Cause: the broadcast's own state moves toward complete via `enableAutoStop`/YouTube auto-complete; the transition method's errors doc (https://developers.google.com/youtube/v3/live/docs/liveBroadcasts/transition) shows `invalidTransition` = "can't transition from its current status". A blind POST just races — read the broadcast's `lifeCycleStatus` first (`liveBroadcasts.list?part=status`) and only POST complete from `live`/`testing`; skip silently otherwise and let `enableAutoStop` finish it. Never throw on the close-out either way. ### YOUTUBE liveStreams LIST: healthStatus IS AN OBJECT, NOT A STRING (RECIPE) YouTube Data API v3 `liveStreams.list` nests health under `status.healthStatus = {status, lastUpdateTimeSeconds, configurationIssues[]}` — the `healthStatus` element is an **OBJECT**, and `configurationIssues[]` lives INSIDE it, not directly under `status`. Reading `healthStatus.GetString()` throws System.Text.Json's `The requested operation requires an element of type 'String', but the target element has type 'Object'` — the exact log line seen 2026-09-22 on every live health poll (two test sessions). The parse must read `healthStatus["status"]` (and nested `healthStatus["configurationIssues"]`). Something like REST-shape drift is a good reason to grep the API reference (https://developers.google.com/youtube/v3/live/docs/liveStreams) before writing parsers against a "remembered" shape — our own flat-string fixture was the wrong assumption the whole time. ### WINRT RESOURCE-ALLOCATION CALLS: WRAP PER-CALL, DEGRADE TO NEXT OPTION (RECIPE) Creator callout (2026-09-15): WinRT/COM calls that allocate or start a resource — `InitializeAsync`, `CreateFrameReaderAsync`, `StartAsync`, `CreateReaderAsync` & friends — are THROW-HEAVY. An unsupported subtype/format, a device that just vanished, an access mode rejected mid-flight: these surface as `ArgumentException`/`E_INVALIDARG` ("value does not fall within the expected range") or HRESULTs, NOT as a returned status you can switch on. Relying on ONE outer catch to "handle failures" is not handling — one rejected call inside a fallback ladder aborts the whole ladder and every untried option. Rule: - Each allocation/start call inside a try/catch of its OWN, so a throw on candidate N falls through to candidate N+1 (log each rejection with its message/status; collect them for the final error string). - `return`/`break` on success must be reached WITHOUT passing through a `finally` that disposes the resource you just committed (classic reader/capture dispose-after-commit bug). - Unsubscribe + dispose the partial resource in the catch block when the subscription happened before the throwing call. - The outer catch stays as the LAST-RESORT net for device-level errors, not the primary one. - Same discipline applies to the frame-consumption side: teardown races reader threads (see HANDOFF crash follow-up) — a frame callback can't assume the pipeline is alive. Applied in `MediaCaptureFrameSource`'s reader-subtype ladder (2026-09-15, webcam-take fix). ### "SAVE-RECORDING DIALOG IS CLIPPING CONTENT" — WHERE + HOW (RECIPE) **WHERE:** the end-of-recording modal (creator: "when I end a recording the app throws up a save-recording dialog with a default filename") is **`RenameRecordingDialog.xaml` at the repo ROOT** (title "Rename Recording", `x:Class="ytLive.RenameRecordingDialog"`). It is NOT one of the `*Dialog.xaml` files under `Controls/` — grep for the window title, not the filename, when the user names a dialog by what it does. Only fix dialogs the user actually named; do not "while I'm here" bump sibling dialogs. **HOW:** the dialog is `ResizeMode="NoResize"` with a fixed `Height` attribute; content client area ≈ `Height − ~37px` of window chrome. Sizing/margin changes pushed content down in Z-order until it clipped — first the file-name textbox (230 too short), and after the +20% bump (→276) the creator found the Cancel/Save button row had ALSO been obscured. Fixed `Height` to 304 (+10% more), `x:Name` the last interactive control (`SaveRecordingButton`) so a test can measure it. **Verify (never eyeball-predict):** `RenameRecordingDialogSizingTests` (RealApp host) instantiates the real dialog, `Show()` + `UpdateLayout()`, then asserts the BOTTOM EDGE of each named control (`TransformToAncestor` against the content root → `TransformBounds(RenderSize).Bottom`) is ≤ the client `ActualHeight`. Every such dialog fix ships with that assertion for every control that was reported obscured. (Client-area math means the button row needs MORE slack than the textbox: its bottom sits deeper because of the `Margin="0,20,0,0"` above it.) ### SPIN GUARD → RESOLVED — web overlay transparency + bounding box (RECIPE) **THE ONE ROOT CAUSE THAT EXPLAINS EVERY FAILED TAKE:** WebView2's `CapturePreviewAsync` produces an **OPAQUE** PNG. From the WebView2 spec (sender: MicrosoftEdge/WebView2Feedback `specs/BackgroundColor.md`): "WebView will always honor a webpage's background content." `DefaultBackgroundColor = Transparent` only shows through pages with NO background style — the widget's own CSS paints html/body opaque. Every take below built on the false premise "the capture has transparent margins, alpha=0"; it never did. `FindContentBounds` then had no alpha-0 margins to find → wrong crop → black bounding box. The compositor blend saw alpha=255 → black over webcam = "transparency broken". Same bug, three symptoms. **THE FIX (the OBS way, applied 2026-09-08):** inject the transparency BEFORE the page parses using `CoreWebView2.AddScriptToExecuteOnDocumentCreatedAsync` — documented to run "before the HTML document has been parsed and before any other script included by the HTML document is run" (learn.microsoft.com/dotnet/api/microsoft.web.webview2.core.corewebview2.addscripttoexecuteondocumentcreatedasync). The old `ExecuteScriptAsync` on NavigationStarting/NavigationCompleted ran AFTER page scripts/CSS → widget page repainted background opaque → lost the fight. OBS browser sources do the same via a pre-parse user.css. Kept the nav handlers as a post-load re-assertion. **Take timeline (the honest record):** - `bccdb48` (Aug 28): added FindContentBounds crop — correct idea (tight bbox, no dead space), but the capture was OPAQUE so the bbox math was built on nothing. - `5348b5c` (Aug 28, 2 min later): reverted to full-frame no-crop — looked "good" for a full-bleed widget, but floating widgets regained dead space ("ghost boundary"). - take-21 (`e002847`) full canvas → shrunken/offset widget (UnifomToFill of whole canvas into a small element = lost resizing). - take-22 (`ed9d7c1`) crop width used as canvas stride for buffer indexing → garbage. - take-23 (`f6802c7`) stride fixed with `src.Width`; PasteKey lacked CropBounds → stale raster cache → stale crop. - take-24 (`081e4c1`) CropBounds in PasteKey — STILL BROKEN because the SOURCE ALPHA WAS NEVER REAL. - take-25 (**ME, this session — the user's "RE-INTRODUCING THE BOUNDING-BOX PROBLEM"**): I removed FindContentBounds + the crop path entirely, betting full-canvas UniformToFill was the answer. It WASN'T — the source is opaque-black, so the element rendered as a SOLID BLACK BOX (the user's screenshot: "a black box in the lower right corner"). Killed the crop → dead space returned AND black box. The compositor math was ALREADY correct; gutting it was vandalism in response to a source-level bug. **Rules, self-inflicted:** 1. Instrument FIRST. This session added: first-capture PNG dump of the raw WebView2 PNG (to `%TEMP%\ytLive-web-.png`) + alpha min/max/mean/%zero + FindContentBounds result logged to startup.log once per session. That's the diff between a five-take loop and a five-minute diagnosis. 2. When the same symptom loops across takes, the PREMISE is wrong, not the code — the capture being transparent was the load-bearing premise and it was never verified. 3. Do not delete code paths that fix one axis (crop=bbox) while debugging another (source alpha). Revert scope creep; keep layer contributions separable. **2026-09-10 follow-up → NOW VERIFIED and hardened.** The 12:54 and 13:50 sessions showed the REAL widget document capturing TRANSPARENT (widget dumps `w1..5` for `8d7234ec`: alpha 100% zero, content 12×3; whole-file decodes of `%TEMP%\ytLive-web-*.png` alpha max = 0) while the RECORDING kept showing a black, opaque box over the element rect (crisp edges at the exact rect 1231,679 705×396 — NOT the desktop showing through). Concluson: branch (a) — the OLD inline `element.style.background='transparent'` injection only wins while the page has nothing to paint; once the widget connects and paints its own container background-COLOR, the capture goes opaque again → black box. The OBS-validated answer (valid for arbitrary pages for a decade) is an injected pre-parse `