feat(alerts): TASK 43 — native events & alerts (six unique animations) + chat parity

Replace the external StreamElements feed with NATIVE YouTube events per the creator's
2026-09-23 question.

Chat parity half:
- YouTubeChatService decodes ALL SIX liveChat/messages event types into
  ChatMessage.Kind (superChat/superSticker/newSponsor/membershipGifting/
  giftMembershipReceived/memberMilestoneChat) — the four formerly-empty overlay
  rows are real now — and re-arms its one-shot poll on the server's
  pollingIntervalMillis (streamList connection semantics; clamp 1000-6000ms,
  maxResults=2000). ParsePage internal static seam + ChatPage record for
  deterministic tests; optional HttpClient ctor seam kept; InjectSimulatedMessage
  (TASK 41) preserved.

Alerts half (creator rulings: one celebration zone, six UNIQUE animations, no
menus/polls, sub mention = chat row only, NO viewer count):
- New SourceType.AlertBox 'Stream Alerts' (one per layout, CanAddAlerts gate
  mirroring chat; idle = transparent).
- AlertRenderer: six distinct branded animations (SuperChat slide-up/shine/
  count-up, SuperSticker scale-pop, NewMember drop-in/flash, MemberGift slide-left/
  chip-fan, GiftReceived confetti, MemberMilestone rise/growth-bar), every card
  drawing the 'made with LlamaCasty!' brand line.
- AlertOverlayLayer: true component (Commit-G pattern) — queue (cap 10, drop tail
  never stall), 33ms UI ticker, cache-first RenderFrame + UpdatePreview,
  Advance(double) as the deterministic test clock; ChatEventKind.None rows never
  enqueue.
- Wired: resolver RenderAlertBox, _alertLayer ctor + dispose, LoadLayout previews,
  Add menu item (Controls/LeftPanel.xaml), TestSessionViewModel sims tagged
  (member->NewMember, superchat->SuperChat).

Good Dog ONE integration test: AlertLayerTests (RealApp STA, real WPF raster) —
ParsePage classifies all six kinds + cadence fields; None rows enqueue nothing;
six events play pairwise-distinct moving frames then drain to null.

Gate: clean build 0 warnings; full suite 316/317, the one failure
(LayerReorderPersistenceTests.RealMouseDrag) repros on the clean tree — the
known environmental class (real-mouse-drag no-ops with a game/fullscreen window
focused).

References (OBS/overlay ecosystem):
- streamList semantics: https://developers.google.com/youtube/v3/live/docs/liveChatMessages/streamList
- OBS alert-box pattern (designated celebration zone, idle transparent): creator-chosen model
This commit is contained in:
2026-09-24 08:27:00 -07:00
parent 3a4e17c735
commit d4aa588da0
19 changed files with 1347 additions and 185 deletions
+58 -80
View File
@@ -1,103 +1,81 @@
# HANDOFF — 2026-09-23 (build #77 feedback round; LOG fixes all shipped; app likely running)
# HANDOFF — 2026-09-24 (TASK 43 shipped: native events & alerts — creator said "go")
## Branch / Commit State
`main` HEAD = **`d72949e`** (build-77 top-bar feedback: avatar/gear/cluster). Backlog:
`ac6e67a` (end close-out), `18ab553` (health-poll), `e69db4d` (TASK 42), `bb5dcb4` (TASK 41).
Pushed state at `197ee81` (36 commits ahead locally — push only at a sub-milestone / on the
user's say-so).
`main` HEAD = new commit for TASK 43 (native events & six-animation alert box); previous:
`3a4e17c` (account zone world-independent), `cb75066` (YPP 403), `d72949e` (top-bar feedback),
`ac6e67a` (end close-out). Repo is ~10 commits ahead of pushed `197ee81` — **push is the
creator's call** (sub-milestone decision, not per-commit).
**Working tree DIRTY — account zone made world-independent (creator 2026-09-23):**
**Working tree CLEAN after this unit's commit.**
```
M ViewModels/MainViewModel.Streaming.cs (ShowSignInButton = !IsConnected, ShowAvatarButton = IsConnected)
M ytLive.Tests/TopBarModeTests.cs (REC-world asserts: avatar stays, Sign In visible signed-out)
M ai.md, TASKS/task-42, HANDOFF.md
```
## ⚠️ The unit that just shipped (this turn, creator said "go")
Previous two hotfix units (audio-sync dot + YPP 403/statistics-string) are COMMITTED (`cb75066`);
top-bar feedback COMMITTED (`d72949e`).
The creator asked (2026-09-23) whether the app could use **native YouTube events instead of
StreamElements**. Research pinned the API facts; creator rulings locked the product shape. One
combined work unit ("go"), task filed as **TASK 43** (`TASKS/task-43-native-alerts.md`):
## ⚠️ The account-zone unit (this turn)
1. **Chat parity half** — `YouTubeChatService` now decodes **all six** `liveChat/messages`
event types into `ChatMessage.Kind` (the four previously-empty event rows are real now) and
re-arms its poll on the server's `pollingIntervalMillis` (streamList semantics; clamp
1000–6000ms, `maxResults=2000`).
2. **Alerts half** — new **`SourceType.AlertBox`** ("Stream Alerts", one per layout,
`CanAddAlerts` gate mirroring chat): an OBS-style celebration zone, idle = transparent.
`Services/AlertRenderer.cs` draws **six DISTINCT branded animations** (SuperChat slide-up/
shine/count-up; SuperSticker scale-pop; NewMember drop-in/flash; MemberGift slide-left/chip-fan;
GiftReceived confetti; MemberMilestone rise/growth-bar), each card carrying the "made with
LlamaCasty!" brand line (free product placement per broadcast). `Services/AlertOverlayLayer.cs`
is a true component (Commit-G pattern): queue (cap 10) + 33ms ticker + cache-first
`RenderFrame` + `UpdatePreview`; **`Advance(double)` = deterministic test clock**.
3. **Creator rulings baked in:** no menus/polls (asked once, held); free-sub mention = chat row
only, **no sub alert** (YouTube emits none); **no viewer count** (vetoed — demotivating,
weaponizable); six events = six unique animations, not a recolored template.
Creator, clicking RECORD on the live build: "why do you make the youtube user icon go away?" — the
TASK 42 one-surface bar had scoped the account zone to the Stream world ("Record world has no YouTube
identity at all"), so arming REC dropped the avatar AND the bar height bounced with it ("really fucking
annoying"). Fix: `ShowSignInButton`/`ShowAvatarButton` are now world-independent (`!IsConnected` /
`IsConnected`) — the right column never pops in/out, bar height pinned. Test stays a child of Stream.
`TopBarModeTests` updated: signed-out Record world shows Sign In; flipping back to REC keeps the avatar.
**Good Dog:** `ytLive.Tests/AlertLayerTests.cs` (RealApp STA, real WPF raster) — ParsePage
classifies all six kinds + cadence fields; playback never enqueues a `None` (sub-mention) row,
plays six pairwise-distinct moving frames, drains to idle. **Gate: clean build 0 warnings,
316/317** — the ONE failure (`LayerReorderPersistenceTests.RealMouseDrag…`) repros on the CLEAN
tree (proved via `git stash` + rebuild + isolated run): a real-mouse-drag test that no-ops while
a game/fullscreen window steals the mouse (the HANDOFF-documented environmental class).
**NOT a regression — do not chase it in-app; close games and rerun if in doubt.**
## ⚠️ The app is RUNNING, and that's the story of this round
## Follow-ups queued (NOT done in this unit)
Ac6e67a **could not be verified through the normal gate** because the user was *looking at the
running app* (PID 17752) while it happened. `verify.sh` (clean build 0-warnings + full suite +
scope check) **cannot run until the app is closed** — an in-place build fails the `apphost.exe`
copy MSB3021/MSB3027 while the exe is locked. Compile gate used instead: `dotnet build` reaches
the copy stage with XAML/markup compiling clean (only the 2 copy errors → code + XAML are valid).
## What the user asked this round (live-launch feedback on #77, all XAML-only)
1. **Avatar** much bigger — now 40×40 (was 26), CornerRadius 20, initial 20pt. Shows the streaming
account at a glance (113% bigger than the old 26 read).
2. **Gear** moved further from the brand — margin 6 → 18 (a couple more "places").
3. **Uniform 32-height cluster in the top center** — segments host Border, Start/Go Live, and Test
all `Height="32"` so the REC|ON-AIR switch + its actions read as ONE family ("so the user can
intuit their purpose"). Running world (reality line) intentionally untouched. **[COMMITTED `d72949e`]**
4. **Audio-sync status light LOST** — `"AUDIO SYNC"` slider keeps its numeric tooltip; the green/
amber 8×8 dot was "what is the point" dead weight. `IntToSyncBrushConverter` + its resource +
TASK-22's "visual feedback" decision all retired with it, both docs updated in-place.
5. **YPP refresh bug (this turn, "Seriously?")** — `channels.list?mine=true&part=statistics,
auditDetails,contentDetails` 403'd `insufficientPermissions` on EVERY real refresh since slice 1
shipped: the **`auditDetails` part alone requires the `youtubepartner-channel-audit` scope**
(MCN partner tooling the app must never hold; the "no re-consent / scopes suffice" slice-1 claim
was wrong). Fix: part list is now `statistics,contentDetails`; standing flags dropped from
snapshot→VM→drawer in favour of an honest "not exposed to apps — check the Earn page" row;
`channels.list` failures now log the response body's `error.reason` (the bare code hid this for
days); new Good Dog `ChannelStatsServiceTests` guards no-auditDetails + still-parses — which it
then used to catch a SECOND latent bug the 403 masked (statistics are JSON strings; `GetInt64`
throws → now the tolerant `ReadInt64`, ValueKind-first). Docs:
ai.md / TASK-39 / TASKS.md / MyMistakes all corrected. Cite:
https://developers.google.com/youtube/v3/docs/channels/list
## What shipped before this round (all committed)
- **`ac6e67a` end-of-stream close-out fix** — every 2026-09-22 stop logged
`transition(complete) 403 invalidTransition`; the blind POST raced YouTube/autoStop marking the
broadcast complete (`enableAutoStop=true` always set). `EndBroadcastAsync` now pre-flights
`liveBroadcasts.list→status.lifeCycleStatus` and skips the POST when already complete/revoked;
inconclusive pre-check still posts (backstopped); never throws. Good Dog:
`EndBroadcast_Verifies_LifeCycle_Then_Transitions_Complete_Never_Throws` (3 halves: live→GET+POST,
complete→skip, 403→error-string). Cite: youtube liveBroadcasts/transition errors table. **Full
suite 315/315** (audio-timing flake `Mix_HonorsProviderGains…` cleared on rerun); verify.sh clean
pass predates this round's XAML-only edits.
- **`18ab553` health-poll fix** — `status.healthStatus` is an OBJECT, not a string; every session
start logged `requires an element of type 'String'`. Nested shape parsed (flat tolerated), banner
alive again.
- **TASK 42** (`e69db4d`) + **TASK 41** (`bb5dcb4`) — one-surface top bar, Test Stream drawer.
- **Creator report still open — not yet diagnosed:** "I still cannot post a chat message in the
TEST tab." The TEST drawer's Mock Chat Input posts a REAL `liveChat/messages.insert`; the fix
needs its own scoped unit.
- **TASK 3 item 20 persistence half** — the canonical `RewardEvents` SQLite table +
`superChatEvents.list` (30-day) backfill + session-report rollup (the shipped alert path parses
events to `ChatMessage` in memory; the report reads that table once it exists).
- TASK 3 item 16 (Text source) still open; TASK 40 units A/C/D queued; TASK 32–36 queued.
## Around the task (carried facts)
- RealMouseDrag test no-ops while a game/fullscreen window steals the mouse (POE 2 seen 2026-09-22)
— close games before full-suite runs. AudioPipeline timing flake similar (load-dependent).
- RealMouseDrag tests no-op while a game/fullscreen window steals the mouse (POE 2 seen
2026-09-22) — close games before full-suite runs. AudioPipeline timing flake similar.
- Test-env trap: saved OAuth session loads synchronously → force `vm.IsConnected` in signed-in tests.
- `subscriberCount` YPP slice 2 needs re-consent — do not merge with other units. Polar `$99/yr`
- `subscriberCount` YPP slice 2 needs re-consent — do not merge with other units. `$99/yr` Polar
must become one-time before launch. `MARCOM.md`/`MONETIZATION.md` gitignored — never commit.
- The **`YppSnapshot.OverallGoodStanding` etc. columns now serialize false forever** (the API path
no longer populates them; schema kept stable for the analytics slice). Don't "restore" the
standing flags via auditDetails — see the MyMistakes recipe.
- TASK 40 App Settings round: units **A (camera) → C (defaults) → D (accent)** remain; A's
SharedReadOnly control-write question = only genuine uncertainty.
- Latent bug the YPP 403 masked: statistics are JSON **strings** — always the ValueKind-first
`ReadInt64` (MyMistakes recipe), never `GetInt64`.
- New facts recorded: the chat feed IS the event feed (six types, no sub event); streamList is a
connection-semantics description of that same endpoint (pollingIntervalMillis drives cadence).
- Every committed change needs a **close + relaunch** of the running app to be seen — running
builds are live; verify.sh can't run while the exe is locked.
## Next step
1. **Run the gate on the account-zone unit** (declared scope: `ViewModels/MainViewModel.Streaming.cs`,
`ytLive.Tests/TopBarModeTests.cs`, `ai.md`, `TASKS/task-42-top-bar-redesign.md`, `HANDOFF.md`) —
`./scripts/verify.sh`: clean build 0-warnings + full suite + scope check. Then commit it
("account zone is world-independent — avatar stays in Record world", code + docs in one).
Repo is 36 commits ahead of pushed `197ee81`; push decision is the creator's (sub-milestone).
1. **Creator verifies the alerts unit on the next run**: Add → Stream Alerts, then (Test Stream
drawer) Simulated Super Chat / New Member — confirm the zone stays transparent at idle and each
event plays its own animation on the canvas and preview; a real Super Chat during the next live
run confirms the real path.
2. Push decision is the creator's (repo ~10 ahead). If pushed, note the commit id in this file.
3. When scoped: diagnose the TEST-tab chat posting report ("I still cannot post a chat message in
the TEST tab") as its own Good-Dog unit.
## Critical working rules (unchanged, still binding)
- **Good Dog = ONE integration test per change.** Scope lock + `./scripts/scope-check.sh` before commit.
- Windows dotnet host for all WSL builds (`/mnt/c/Program Files/dotnet/dotnet.exe`, quoted paths).
- 0 warnings on real clean builds (verify.sh only, never incremental). One runtime model.
- 0 warnings on real clean builds (verify.sh only, never incremental). One runtime model.
- No menus/polls with the creator; decisions asked once then held; do the work, then report.