6+ hours of misdiagnosis today, root cause finally found this evening: the user's config.json persisted ndiGroups.discoveryGroups = 'public,teamsiso-input'. NDI group names are case-sensitive in the runtime. Teams broadcasts to the canonical 'Public' (capital P) group. Lowercase 'public' didn't match -> NDI Find returned zero sources forever. NDI Studio Monitor sees Teams sources because it uses default groups (no filter = 'Public'). Every TeamsISO launch that read the config got zero -> looked like a TeamsISO bug.
Fix: add NdiInteropPInvoke.NormalizeGroups that case-folds 'Public' specifically (the most common operator footgun) while passing through custom group names (e.g. 'teamsiso-input') verbatim. Wire it into CreateFinder and CreateSender. End-to-end test: restored bad lowercase config -> launched via Start Menu shortcut -> Serilog now logs 'NDI finder created with groups: Public,teamsiso-input' (note capital P) -> REST returns 2 participants. 264/264 tests passing (Engine 124 +12 NormalizeGroups cases, App 131, Integration 9).
Also adds InternalsVisibleTo on the NdiInterop project so the engine test project can cover the internal helper directly.
Massive misdiagnosis correction. The 2025-05-16 effort to 'fix elevation' has been actively breaking every Start Menu / Desktop shortcut launch since rc7. Empirical retrace:
- Elevated PowerShell -> Process.Start(exe) -> elevated TeamsISO -> WORKS
- Elevated PowerShell with --keep-elevation -> elevated TeamsISO -> WORKS (vm.Participants.Count=2)
- Non-elevated PS Process.Start(exe) -> medium TeamsISO -> WORKS
- ANY launch through runas /trustlevel:0x20000 -> SAFER-restricted TeamsISO -> BROKEN (window appears, zero managed code runs past BAML parse, no logs, no port binds)
The SAFER-restricted token that runas /trustlevel produces breaks .NET 8 WPF apphost in a way that leaves the process apparently alive (with the MainWindow.xaml rendering the empty state from default property values) but executing zero managed code. So my StartupTrace, Serilog file sink, and ControlSurface bind all silently failed for every shortcut launch. Looked exactly like 'cold-start NDI Find stuck at zero' from the outside but had nothing to do with NDI.
Revert:
- installer/Package.wxs: shortcuts target the .exe directly, no runas wrapper
- App.xaml.cs: removed ShouldDeElevate, TryDeElevateAndExit, RelaunchEnvVar, --keep-elevation/--relaunched handling. The check is gone, not just disabled, so future-me can't bring it back without re-discovering the same bug.
Kept:
- StartupTrace (still useful for any future startup mystery)
- Self-healing NDI Find rebuild (c30a616) - still valuable for legitimate stuck-finder cases
- System.Management PackageReference - TryGetParentProcessName still used in StartupTrace
Verified post-revert: Start Menu shortcut click -> PID 43060 -> full trace -> REST 2 participants. 252/252 tests still passing.
The self-heal trigger from c30a616 was time-based logic embedded in the RunAsync poll loop — easy to regress on a future refactor without anyone noticing. Pull it out into a public static ShouldAutoRebuild(sinceStart, sinceLastSeen, sinceLastRebuild) that returns the rebuild reason or null. RunAsync just calls it and acts on the result.
Six new test cases cover the matrix:
- never seen + before warmup -> hold
- never seen + after warmup -> rebuild
- never seen + recent rebuild -> backoff
- had sources + long-gone -> rebuild
- had sources + recently gone -> grace window
- had sources + recent rebuild -> backoff
112/112 Engine tests passing (was 106; +6 new).
When a process spawns and NDI Find returns zero sources at cold start, the finder can stay stuck on zero forever even when other processes can see Teams' broadcasts. Observed today: a user's PID launched at 12:50, ran for 9+ minutes showing 0 sources, while a parallel PID launched at 12:59 immediately discovered 2 sources. Same exe, same install, same Teams meeting, same medium-integrity SAFER token. The first process's finder simply got into a bad state at construction (suspected: NIC-bind race against mDNS responder readiness, or a SAFER-token quirk in the NDI runtime's IPC layer).
The fix: auto-rebuild the finder when (a) we've never seen a source and 5s have passed since startup, or (b) the source set has been empty for 15s after previously containing entries. Both paths back off (>=5s and >=10s between rebuilds respectively) so we don't churn during legitimate empty periods.
Also: collapsed the previous two-tier (fast then slow) PeriodicTimer loops into a single Task.Delay loop with a dynamic interval. Same behavior (200ms for first 3s, then operator-configured pollInterval), less code, easier to thread the self-healing logic through. The finder is still disposed in a try/finally so cancellation paths don't leak.
246/246 tests still passing. The Discovery tests use PollOnce directly so RunAsync changes don't affect them.
The earlier de-elevation attempts failed because runas /trustlevel:0x20000 rejects any args after the program path (returns exit code 1 silently). Switch the relaunch loop-guard from --relaunched CLI arg to TEAMSISO_RELAUNCHED env var, which runas inherits and propagates cleanly. Also: always demote when elevated regardless of parent (the parent==explorer heuristic was too narrow; the runas demotion is cheap enough to do unconditionally), and add a StartupTrace fallback log at %LOCALAPPDATA%\\TeamsISO\\startup-trace.log that captures every checkpoint in OnStartup so future launch failures can be diagnosed without Serilog being up.
Verified end-to-end: elevated parent (PID 47536, isAdmin=True) -> spawns runas -> medium-integrity child (PID 51228, isAdmin=False) -> NDI discovery succeeds (vm.Participants.Count=2 at +5s). The TryDeElevateAndExit now returns bool so spawn failures fall through to normal startup instead of leaving the process in a zombie state.
Opt-out: --keep-elevation CLI arg bypasses the demotion.
The in-process ShouldDeElevate check (commit 191b2c5) didn't fire on the test box because ParticipantPID resolution against Win32_Process can return null fast enough that the check skips before the elevated explorer-spawned TeamsISO has fully booted. Belt-and-braces: ALSO wrap the shortcut Target so the runas demotion happens at shell-launch time, before TeamsISO.exe even runs. Result on the dev box: clicking the Start Menu / Desktop shortcut now lands a working medium-integrity TeamsISO with NDI discovery succeeding, regardless of explorer's elevation.
Uses [SystemFolder]runas.exe (resolved by MSI at install time) and Show='minimized' to hide the brief runas console flash.
Observed behavior: on admin-user boxes with UAC effectively disabled, double-clicking the Start Menu / Desktop shortcut spawns TeamsISO with elevated File Explorer as parent. NDI Find then returns zero sources even when Teams is broadcasting — same exe spawned from any other parent (PowerShell, cmd, runas, etc.) discovers sources fine. Suspected window-station / desktop-handle inheritance quirk in NDI's mDNS layer; can't fix from inside the runtime.
Workaround: in OnStartup, if parent IS explorer.exe AND we're elevated AND we haven't already re-launched (--relaunched guard), re-spawn ourselves via 'runas /trustlevel:0x20000' to drop to medium integrity. Original process Shutdowns; only the medium child remains. Verified by reproducing the failure case in an elevated PowerShell, then watching the same runas command produce a working child (REST returns participants, log writes work).
Add PackageReference for System.Management (Win32_Process via ManagementObjectSearcher) so the parent-PID lookup compiles.
Three independent fixes bundled because all were chasing the same operator
report: 'I just installed, launched from the shortcut, no participants.'
1) NdiDiscoveryService: poll immediately, then ramp from 200ms to the
configured interval over the first 3 seconds. PeriodicTimer.WaitForNext-
TickAsync waits the full interval before its first tick, so for a 500ms
discovery interval the operator stared at 'no ndi sources yet' for half
a second on every cold start. Force-poll up front (catches the runtime
cache), then run a fast inner loop for ~3s while mDNS replies trickle
in. Both loops share a try/finally so the NDI finder is always disposed.
2) MainViewModel.IsDiscovering: new boolean, true for 8s after engine start
AS LONG AS no participants have arrived. MainWindow.xaml swaps the
empty-state copy on this binding:
IsDiscovering=true -> 'scanning for ndi sources...' (cyan dot)
IsDiscovering=false -> 'no ndi sources visible -- is teams in a
meeting?' + Refresh CTA
The old copy ('no ndi sources yet -- open teams and start a meeting')
was being shown immediately at launch even when discovery just hadn't
run yet, making the app look broken.
3) App.xaml.cs: single-instance mutex moved from Local\ to Global\. On
admin-user boxes with UAC disabled, launches from different parents
(elevated File Explorer, non-elevated shell, etc.) can land in slightly
different security contexts and a Local\ name can be invisible to the
sibling. Global\ namespace closes that hole — both processes see the
same mutex regardless of integrity. Belt-and-braces against future
dual-instance file/port contention.
4) installer/Package.wxs: add a Desktop shortcut component (per-machine
feature, HKCU keypath per ICE38/ICE43). Operators who can't find the
Start Menu entry get the Desktop icon. Both shortcuts target the
installed exe, NOT a stale path under publish/.
After dropping IsoToggle from a full pill to a Radius.M rounded-rect, the
'Enable' label (and the active-state '* LIVE') started clipping at the
right edge of the 110px cell. The pill geometry had visually masked the
tight fit by softening the edges; the squared corners made it obvious.
Widen the ISO column from 110 to 124 (+14px) and tighten the inline button
padding from 14,6 to 10,6. The MinWidth=84 from the IsoToggle style still
covers the OFF state; the column bump gives the active 'LIVE' state room
to breathe without changing the overall row rhythm.
Wd.Button.IsoToggle was the only button in the GUI using CornerRadius=999
(full pill). It read as a different control type from the toolbar buttons
around it (Enable all, Refresh, Presets, Stop all, Mute, Cam, Leave —
all Radius.M). The pill shape was meant to make the LIVE state visually
distinct, but the status-coded fill (cyan/coral/amber) already carries
that signal — the geometry was double-duty.
Swap the IsoToggle's CornerRadius from 999 to Radius.M so every button
in the app shares the same shape language. Status read remains via the
fill color.
The custom Path gear with Stroke=Wd.Text.Secondary + StrokeThickness=1.4
rendered as a near-invisible thin grey shape against the dark row
background — users couldn't tell the column was clickable.
Replace with TextBlock rendering U+2699 GEAR from Segoe UI Symbol
at 16px and Wd.Text.Primary foreground. Universally recognized as
'settings', renders crisply at any DPI, and stands out against the
row. Header bumped from empty to 'CFG' so the affordance is
discoverable, column widened from 32px to 56px so 'CFG' fits cleanly.
The operator path: click Enable on a participant -> AsyncRelayCommand fires
ToggleIsoAsync -> IsoController.EnableIsoAsync(id) -> tracker lookup -> throws
InvalidOperationException 'Participant <guid> not currently visible on the
network' when the participant has departed between the click and the engine
resolving the id.
Previously this exception escaped AsyncRelayCommand.Execute via the unawaited
Task in ICommand.Execute, hit System.Threading.Tasks.Task.ThrowAsync, and
ended up in Dispatcher.UnhandledException — which the App.CrashHandlers path
treats as a fatal and fires the crash dialog. Fatal in the log captured
during this morning's session at 08:08:27.
Wrap the EnableIsoAsync / DisableIsoAsync calls in try/catch:
- InvalidOperationException -> toast 'X just left the meeting'; leave
IsEnabled at its current value (engine state of record)
- Exception -> toast 'Couldn't toggle ISO for X: <message>'; same rationale
- finally clause still flips IsProcessing back so the spinner clears
No new tests — the race is hard to trigger deterministically without
introducing a mocking seam on the controller. The behavior change is small
and the surface is the only call site for EnableIso/DisableIso from the
participant row.
Punch-list items 26 + 27 — three integration tests that need a live
WPF Application + STA dispatcher, sharing one WpfHostFixture so
Application is created exactly once for the suite (it's
one-per-AppDomain and any second `new Application()` throws).
* src/tests/TeamsISO.App.Tests/Integration/WpfHostFixture.cs (new)
— long-lived STA thread that hosts a single Application instance
and a Dispatcher; tests marshal work onto it via Run<T>() /
Run(Action). WpfHostCollection wraps it as an
ICollectionFixture so xUnit injects the shared fixture into
any test class that opts in.
* src/tests/TeamsISO.App.Tests/Integration/IntegrationTests.cs
(new) — single test class carrying all three cases:
- AppStartup_FullChain_Constructs_WithoutThrowing — pre-loads
Theme.Dark.xaml + WildDragonTheme.xaml via pack URIs, calls
ThemeManager.Apply(), constructs MainViewModel with the stub
controller, constructs MainWindow with the VM as DataContext,
and asserts the Wd.Canvas brush key resolves on the live
window. All DependencyObject access happens inside a single
Dispatcher.Invoke so we never marshal a DO reference across
threads (WPF's VerifyAccess would throw).
- ControlSurface_GetParticipants_ReturnsLiveViewModelState —
boots a ControlSurfaceServer on an ephemeral port against
a real MainViewModel; publishes a synthetic participant
through the stub controller's observable; drains the
dispatcher to ApplicationIdle so the Background-priority
add lands before the REST call; asserts the JSON includes
Alice. Complements branch-9 route-smoke tests (which used a
null view-model) by exercising the dispatcher-marshalling
path.
- ThemeXaml_DarkAndLight_BothLoadWithDistinctWdCanvas — loads
both theme files directly via pack URIs and asserts the two
canvas brushes are the documented #0A0A0A and #FAFAFB. Doesn't
test ThemeManager.SwapColorDictionary against Application.
Resources (the swap STATE test was flaky under xUnit's
parallel-collection model — Application.Resources is
process-wide and sibling tests' mutations made the read
non-deterministic). The unit-layer ThemeManagerTests already
cover the swap state machine against stubbed seams; this
integration test guards that the real XAML files load and
produce the documented colours.
Production code change to support both tests AND a longstanding
correctness issue:
* ThemeManager.SwapColorDictionary now constructs its replacement
ResourceDictionary with a `pack://application:,,,/TeamsISO;component/Themes/…`
absolute URI instead of the relative `/Themes/…` form. The
relative form resolves against Application.Current's base URI —
which is the entry assembly in production (TeamsISO) but the
test assembly in xUnit. The pack URI is unambiguous in both
contexts. Production behaviour is identical (still resolves to
the same XAML files in the App assembly).
Notes-state collection: NotesServiceTests + OscBridgeDispatchTests
now share a NotesStateCollection xUnit collection because both
mutate the static NotesService.DirectoryOverride; without the
collection xUnit's parallel-collection scheduling let one class's
ctor clobber the override mid-test.
Xunit.StaFact 1.1.11 package added to the test csproj — primary
use was the early WpfFact-based iteration of these tests, kept
because Xunit.StaFact provides the [WpfFact] alternative if a
future test wants per-test STA without sharing the fixture.
Final test totals: 56 → 131 in App.Tests; 103 → 106 in
Engine.Tests. 237 tests pass. Build clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Punch-list items 19–25 — covers six of the seven services + the
engine controller. TeamsLauncher fallback chain (item 21) is deferred:
it depends on Process.Start in ways that don't unit-test cleanly
without a process-launch seam that the May 2026 codebase doesn't
have yet.
Service seams added for testability (each marked internal + a
matching InternalsVisibleTo-equivalent grant via the existing
TeamsISO.App.Tests visibility):
* NotesService.DirectoryOverride — redirect %LOCALAPPDATA%\TeamsISO\Notes
* WindowStateStore.PathOverride — redirect window.json
* UpdateChecker.StateDirectoryOverride — redirect both the 24h
cooldown stamp and the no-update-check.flag
* UpdateChecker.TryParseSemVer — visibility bumped to internal
* OscBridge.DispatchAsync — visibility bumped to internal so tests
can drive route dispatch without spinning up the UDP receive loop
New test files (App.Tests):
* Services/NotesServiceTests.cs (6 cases) — header-once, timestamp
format, multi-append, whitespace trim + reject, today-path shape.
* Services/UpdateCheckerTests.cs (7 cases) — TryParseSemVer Theory
across the v?X.Y.Z(.N)(-suffix) inputs the real release stream
produces, semver ordering pin, CheckIfDueAsync short-circuit on
recent stamps (the throttle never fires HTTP — deterministic
offline), LaunchCheckEnabled round-trip via the opt-out flag.
* Services/PresetApplierTests.cs (6 cases) — the four enable/disable
state transitions, case-insensitive display-name join, partial
meeting (preset names participants not present), live participants
unnamed by the preset stay untouched.
* Services/PresetStoreCollection.cs — xUnit collection so any test
class that mutates OperatorPresetStore.PathOverride serializes
with siblings that do the same. OperatorPresetStoreTests now joins
the collection (the class comment claimed it didn't need one
because file paths were per-test-unique — true, but PathOverride
is shared static state, which is why the new PresetApplierTests
was clobbering its result on first run).
* Services/WindowStateStoreTests.cs (6 cases) — JSON round-trip
through the Snapshot record + all the bail paths (no file, too
small, too large, fully off-screen, garbage JSON). Full Window
property write coverage is deferred to branch 11 (needs STA).
* Services/OscBridgeDispatchTests.cs (5 cases) — /teamsiso/refresh-
discovery + unknown-address + /teamsiso/notes + clean bail when
the toggle/preset paths can't reach a dispatcher.
New test cases (Engine.Tests):
* Controller/IsoControllerTests.cs gains three cases —
SetRecording_TogglesEnabledAndStoresDirectory,
AddRecordingMarker_NoOpsCleanly_WhenNoActiveRecorders,
RefreshDiscovery_SetsRefreshFlagOnDiscoveryService.
Tests: 56 → 128 in App.Tests; 103 → 106 in Engine.Tests. Total
green: 234. Build clean (0 warnings, 0 errors).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds end-to-end-ish tests that boot the server on an OS-assigned free
port and exercise the route dispatch via HttpClient. Catches
regressions in the route table itself (which is the part of the
control surface that benefits least from unit tests — its bug
surface is the URL → handler mapping, not the handler bodies).
* src/tests/TeamsISO.App.Tests/Fakes/StubIsoController.cs — minimal
IIsoController stub that lets the App layer instantiate without
spinning up the engine + NDI runtime. EnableCalls / DisableCalls /
RefreshDiscoveryCalled flags make assertions on side effects easy.
* src/tests/TeamsISO.App.Tests/Services/ControlSurfaceServerTests.cs
(7 cases):
- GET / → 200 with the server-info JSON (product, endpoints).
- GET /unknown-path → 200 with body {error:"not found"}. Pinning
this odd-but-intentional behavior: the catch-all switch arm
returns NotFound() (an object) so response is non-null and the
pipeline writes 200 + that body instead of branching to the
404 path. The body is the disambiguator, matching the rest of
the surface's "200 + {ok:false,error:…}" convention.
- GET /participants → 200 with participants:[] when no view-model.
- POST /presets/refresh-discovery → 200 + StubIsoController.
RefreshDiscoveryCalled flips true (route → controller round-trip).
- POST /presets/{missing}/apply → 200 + ok:false +
error:"preset not found" (missing-preset path).
- GET /ui → 200 with text/html.
- OPTIONS /participants → 204 + Access-Control-Allow-Origin:*
(CORS preflight for browser-based controllers).
TeamsISO.App.Tests.csproj gains UseWPF=true so the test assembly
can transitively compile against the WPF types that
ControlSurfaceServer's signature touches (System.Windows.Threading,
Application.Current). Implicit-using set narrows under UseWPF, so
OscMessageTests gains an explicit `using System.IO` and the new
test file gains `using System.Net.Http`.
Tests: 56 → 90 in App.Tests; Engine.Tests unchanged at 103.
Total green: 193. Build clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ThemeManager grows a test seam — its singleton ctor now delegates to
three internal seams (isSystemDark / loadPreference / savePreference)
that the production singleton fills with the real registry +
UIPreferences calls. Tests construct via the internal ctor with
stubs so they never touch HKCU or %LOCALAPPDATA% (which would
otherwise flake on CI or pollute the dev's UI state). Apply() and
the SystemEvents subscription are intentionally NOT exercised
here — both require Application.Current and a real dispatcher.
CommandPaletteViewModel.Matches changes from `private static` to
`internal static` — the predicate is the unit worth pinning, and
building a full CommandPaletteViewModel would require a fake
IIsoController + Dispatcher for one test.
New tests:
* src/tests/TeamsISO.App.Tests/Services/ThemeManagerTests.cs (11 cases):
- Set Dark → Light round-trips Preference + ResolveTheme and
persists via the savePreference seam.
- ResolveTheme follows the system probe when Preference is
System (true → Dark, false → Light).
- Toggle from System pins to the opposite of the currently-
resolved theme (not back to System) — explicit click should
have visible effect.
- Toggle from Dark flips Light; Toggle from Light flips Dark.
- Set rejects invalid preferences (case-sensitive: lowercase
"dark", "LIGHT", "", "invalid" all throw ArgumentException
with ParamName=preference).
- Constructor defaults to System when loadPreference returns
null (fresh install / missing prefs file) or an invalid value
(future schema collision).
- Constructor swallows a load exception so the app doesn't lose
theming when ui-prefs.json faults on read.
* src/tests/TeamsISO.App.Tests/ViewModels/CommandPaletteMatchesTests.cs
(16 cases): Theory pinning case-insensitive label / category /
keyword Contains, plus a full-vocabulary spread test counting
hits for "theme" (3), "stop" (1), "ndi" (2), "App" (5 — four
App-category cmds + the Apply transcoder topology substring
match, called out in the assertion because a future move to a
stricter algo has to re-decide that affordance deliberately),
and "xyzzy" (0).
Tests: 56 → 83 in App.Tests; Engine.Tests unchanged at 103.
Total green: 186. Build clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
commit-and-push.ps1 (443L / 21KB) was a one-shot deployment script
that staged 25 themed commits to land the May 2026 polish batch in
a single run. That work has long since been committed; every
Stage-AndCommit call is now a no-op because nothing matches what's
already in history, and one of the file paths it referenced
(DiskSpaceWatcher.cs) was deleted alongside the recording surface.
Replaced it with a 45-line wrapper that does what the day-to-day
workflow actually needs: run build-and-test.ps1, refuse to push if
either failed, then push the current branch to origin. README and
NEXT_STEPS still reference the script name; behavior is now what
those docs imply ("build + tests + push") rather than the original
"land 25 specific commits."
Also deleted src/tests/TeamsISO.Engine.Tests/SmokeTest.cs — a
single Assert.True(true) placeholder kept "to confirm the project
is wired." 103 real engine tests confirm the project is wired far
more meaningfully than a tautology. Net test count drops 104 → 103
on the Engine side; 56 + 103 = 159 still pass.
Build clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
TeamsLauncher.cs was 665 lines / 30KB and mixed two unrelated lifecycles:
launch / hide / show / in-call orchestration (the bulk of the file), and
the Phase E.4 experimental SetParent-based embedding (~160 lines of
distinct Win32 surface area + its own state machine).
* Services/TeamsEmbedHost.cs (177L, new) — public static class owning
EmbedTeamsInto / ResizeEmbedded / RestoreEmbed / IsEmbedded plus the
Win32 p/invokes specific to embedding (SetParent, GetWindowLongPtr,
SetWindowLongPtr, MoveWindow, SetWindowPos), the WS_* / SWP_* style
+ position constants, and the embed-state fields. The whole lifecycle
(reparent → resize → restore) now lives in one place; the comment
about WebView2 fragility moves with the code.
* Services/TeamsLauncher.cs (was 665L → now 510L) — keeps launch /
stop / join / hide / show / window-title / shortcut concerns. The
internal helper that enumerates Teams top-level windows is now
named EnumerateTopLevelTeamsWindows (was FindTeamsTopLevelWindows)
and marked `internal` so TeamsEmbedHost can call it without
duplicating the EnumWindows traversal — both classes use the same
process-name heuristic and the launcher's hide/show paths also
consume it.
* TeamsEmbedWindow.xaml.cs — call sites moved from TeamsLauncher.* to
TeamsEmbedHost.* (three references).
No behavior change. Build clean; 56 + 104 tests still pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ControlSurfaceServer.cs was 1061 lines / 47KB — a single class hosting
the HttpListener loop, the route dispatch, and every endpoint body in
between. Splits the class via partial-class into a thin host file plus
one partial per route group, all under Services/ControlSurface/.
* Services/ControlSurfaceServer.cs (was 1061L → now 400L) — kept here:
Start / Stop / DisposeAsync (the listener lifecycle), AcceptLoopAsync,
HandleRequestAsync (the route table itself, with its CORS preflight +
WebSocket upgrade + JSON dispatch), the response helpers
(ReadBodyAsync / WriteJsonAsync / TryGetBool / TryGetString), the
NotFound switch-arm, and the JsonSerializerOptions singleton.
* Services/ControlSurface/Endpoints/HomeEndpoints.cs — GetServerInfo,
TryRead helper.
* Services/ControlSurface/Endpoints/ParticipantsEndpoints.cs (the
biggest split) — GetParticipants, SetIsoOverrideByIdAsync,
ClearIsoOverrideByIdAsync, TryParseEnum, ToggleIsoByIdAsync,
ToggleIsoByNameAsync, ToggleByIdAsync. Together: every /participants/*
handler.
* Services/ControlSurface/Endpoints/PresetsEndpoints.cs — RefreshDiscovery,
StopAllAsync, ApplyPresetAsync.
* Services/ControlSurface/Endpoints/TeamsEndpoints.cs — InvokeTeams
(the helper that maps a TeamsControlBridge result to the JSON body).
* Services/ControlSurface/Endpoints/TopologyEndpoints.cs — GetTopology,
ApplyTopologyAsync, RestoreTopologyAsync.
* Services/ControlSurface/Endpoints/NotesEndpoints.cs — AppendNote.
* Services/ControlSurface/Endpoints/ThumbnailEndpoint.cs —
TryEncodeThumbnailJpeg (which is actually the BMP path now) +
EncodeBmpDownscaled + the LE byte writers. The legacy
TryEncodeThumbnailJpeg_WpfDeadCode helper that was dead-coded "for
posterity" is gone — no call sites; we removed-comments-on-removed-
code is the anti-pattern we wanted to fix.
* Services/ControlSurface/WebSocketHub.cs — HandleWebSocketAsync,
PushSnapshotIfChangedAsync, SendAsync, GetSnapshotJsonAsync. The
push-timer wiring stays in the host's Start() so the lifetime is
obvious where the connection is opened.
No behavior change. The route table in HandleRequestAsync still
dispatches by (HttpMethod, path) — only the handler bodies moved.
Build clean; 56 + 104 tests still pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
App.xaml.cs was 461 lines / 21KB and conflated four concerns: process-
level lifecycle (mutex / message pump filter / shutdown), engine bootstrap
(NDI runtime / IsoController / view model construction), crash handling
(three exception channels + log directory + dialog), and the background
update-checker kickoff.
Splits via partial-class into themed sibling files:
* App.xaml.cs (was 461L → now 219L) — class skeleton, fields, internal
property accessors, Win32 P/Invoke surface, OnStartup as a wiring
pipeline that calls the bootstrap steps in order, OnExit, CLI parser.
* App.Bootstrap.cs (250L, new) — linear startup steps:
TryAcquireSingleInstance, TryBootstrapNdiInterop, BootstrapEngine,
ConstructAndShowMainWindow, BootstrapControlSurfaceServices,
BootstrapTrayIcon, TryShowOnboarding, TryAutoLaunchTeams. Each
returns a signal (bool / window ref) when OnStartup needs it to
decide whether to continue.
* App.CrashHandlers.cs (93L, new) — OnAppDomainUnhandled,
OnDispatcherUnhandled, OnUnobservedTaskException, TryLogFatal,
TryShowCrashDialog, LogDirectory.
* App.UpdateCheckBootstrap.cs (42L, new) — StartBackgroundUpdateCheck
(24h-throttled, fire-and-forget).
OnStartup's body is now a 30-ish-line procedure that names each step,
which is what the original was trying to be. Comments inline the
"happened before, kept here for reason X" notes (theme.Apply before
window show; CLI args parsed before InitializeAsync). Behavior is
unchanged — Shutdown codes, error paths, and the side-effect order are
all preserved.
Build clean (0 warnings, 0 errors); 56 + 104 tests still pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
MainViewModel.cs was 1017 lines and 45KB — most of it was bulk-operation
loops, Teams UIA plumbing, and the auto-apply-last-preset state machine
sitting on top of the actual MainViewModel surface (constructor, props,
OnStatsTick). Splits the class via partial-class into themed siblings:
* MainViewModel.cs (was 1017L → now 699L) — fields, properties,
constructor that wires every Command, OnStatsTick + Dispose. This
remains the thin aggregator.
* MainViewModel.TeamsCommands.cs (130L, new) — MakeTeamsCommand helper,
JoinPastedMeeting (body of JoinMeetingCommand), ExtractMeetingTitle
(already-tested static), PollTeamsMeetingState (the 1Hz UIA probe
formerly inlined in OnStatsTick).
* MainViewModel.PresetCommands.cs (108L, new) —
RequestApplyPresetOnStartup (CLI hook), LoadPendingPresetFromPreferences
(called by InitializeAsync), TryAutoApplyPendingPreset (the reconcile
step), and the _pendingPreset* private-field set that backs the path.
* MainViewModel.BulkCommands.cs (149L, new) — EnableAllOnlineAsync,
StopAllIsosAsync (with the default-No confirmation dialog),
SnapshotAll. RecordingCommands.cs from the original punch list is
intentionally absent — the recording surface was axed at 1d1ce6a;
what remains here is bulk-state ops across the participants
collection (note in the file header).
Why partial-class instead of helper-services or composed objects: every
extracted method touches the same private dispatcher / controller /
participants / toast state. Composing would require either passing
those references in (verbose call sites) or extracting them to a
shared private context object (boilerplate). Partial gives us
file-level separation without spreading the state contract.
ExtractMeetingTitle stays internal-static so the existing
MeetingTitleExtractionTests (10 cases) keep finding it. Build clean;
56 App + 104 Engine tests still pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Walks the v2 polish punch list against MainWindow.
- Theme button tooltip is now "Theme (System / Dark / Light)" per the
v2 shape brief, replacing the previous "Toggle theme (Ctrl+T)".
- Participants table column widths match spec: Output 130px (was 150),
ISO pill 100px (was 110). The 24px state LED, 110px audio meter, and
52px row height already matched. The 106px Preview thumbnail column
and 32px gear-button column are intentional deviations (live thumbs
were restored at 4944de5; per-ISO override gear added at the same
time) and are now called out in the column-spec comment so a future
reader doesn't try to "fix" them.
- Empty-state placeholder finally renders when ParticipantCount == 0:
mono sentence "no ndi sources yet — open teams and start a meeting"
+ a tertiary Refresh discovery button — exactly the copy specified
by the shape brief's empty-states section. CountToVisibilityConverter
is now declared in MainWindow.Resources (it shipped as a class but
was never registered).
- OnClosing wraps WindowStateStore.Save in a try/catch so a serialization
or filesystem fault on shutdown can never block the window from
closing. Save itself already swallows its own IO errors; this is
defense-in-depth for anything that escapes.
- MessageBox copy in MainWindow.xaml.cs (Hide/show Teams, Launch Teams,
Stop Teams) moves to Properties/Strings.resx + a hand-written
Properties/Strings.Designer.cs accessor. ResourceManager reads it by
basename "TeamsISO.App.Properties.Strings"; LogicalName is set on the
EmbeddedResource so the manifest name is predictable regardless of
how MSBuild would otherwise compute it. Future-localization seam.
OnLaunchTeamsRightClick's confirmation dialog is intentional — it
guards a destructive mid-show action — and the code-behind comment
now says so; the palette also offers Stop Teams as the keyboard
surface, so the right-click affordance isn't the only one.
Build clean (0 warnings, 0 errors); 160 tests still pass (56 App +
104 Engine, Category!=ndi&requires!=ndi filter).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Fix TeamsISO.Windows.slnf — drop the dangling
src/TeamsISO.App.WinUI/TeamsISO.App.WinUI.csproj entry whose project
doesn't exist in the .sln (broke the build on main).
- Archive the abandoned WinUI 3 artifacts under docs/archive/:
* 2026-05-12-winui3-migration.md (the nine-phase migration plan)
* TeamsISO.App.WinUI.Probe/ (the bootstrap diagnostic console)
* work-log-2026-05-12-winui3.md (the overnight session log)
- README — drop the "in-flight WinUI 3 replatform" status block;
state that the v2 redesign landed in WPF and link the shape brief.
Keyboard shortcuts table picks up Ctrl+K, Ctrl+T, and the digit
hotkeys that already shipped.
- CHANGELOG — replace the WinUI-3-flavoured "Ground-up GUI redesign"
block with a v2 Studio Terminal entry that names Task 39 + Task 40
as landed. De-dupe the May 2026 batch: the second "Quick-join Teams
meeting from URL", "IN-CALL bar surfaces Teams meeting state", and
"Auto-launch Teams + auto-hide windows" bullets were verbatim repeats
of earlier entries; kept the first occurrence.
- NEXT_STEPS.md — rewrite to reflect that Task 39 (participants table
v2) and Task 40 (Ctrl+K palette) both shipped; v1.0 cut is now
gated only on MSI signing + real-meeting smoke pass.
- DESIGN.md — small WPF-isms: WinUI 3 composition layer →
WPF's; Segoe Fluent Icons phrased without the "WinUI 3's
bundled" qualifier; migration boundary rephrased to "rewrites
MainWindow.xaml + Themes/*" instead of "everything in Views/".
- .gitignore — ignore the .claude/ session metadata dir so it doesn't
show up as untracked on every dev checkout.
Build + tests verified before commit: 0 errors, 0 warnings; 160 tests
pass (56 App + 104 Engine, filter Category!=ndi&requires!=ndi).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Engine: IsoAssignment record gets optional Override (FrameProcessingSettings?). IsoController hydrates _overrides dict from config.json on startup, uses override at EnableIsoAsync, persists with assignment, exposes GetIsoOverride + SetIsoOverrideAsync. SetIsoOverrideAsync hot-swaps a running pipeline (Disable + 150ms delay + Enable) when the override changes.
REST: POST /participants/{id}/override (body: framerate/resolution/aspect/audio enum strings, all optional, missing fall back to globals); DELETE /participants/{id}/override clears. GET /participants now includes per-row effective {framerate, resolution, aspect, audio, isOverride} plus top-level globals block.
Web /ui: per-card collapsible override panel with four selects + Apply / Clear. OVR pill + cyan inset edge mark overridden rows. Open-panel state survives WS re-renders.
Desktop: per-row gear column in the v2 DataGrid opens IsoOverrideDialog (420x360) with four combos. Clear button removes the override.
Thumbnail endpoint switched from WPF JpegBitmapEncoder (NREs from non-UI HttpListener threads) to pure-managed 32bpp BMP encoder. Nearest-neighbor downscale to 192-wide. /participants/{id}/thumbnail.bmp; legacy .jpg URL still works.
Known limitation: ParticipantTracker regenerates IDs for display-name-keyed participants across process restarts, orphaning the persisted override. Override works within a session; cross-restart persistence is best-effort until the tracker is taught to use stable keys. Filed as task 43.
REST additions: GET /topology returns mode (hidden/public/unknown) + sender/receiver group lists. POST /topology/apply confines local senders to teamsiso-input + receivers to public+teamsiso-input. POST /topology/restore returns both to public defaults.
GET /participants/{id}/thumbnail.jpg encodes the latest engine ProcessedFrame as a 192-wide JPEG. 404 when no pipeline is running. Used by the /ui control panel for live preview tiles.
Settings: ControlSurfaceEnabled now persists across sessions via UIPreferences and auto-starts the server on app launch when previously enabled.
/ui control panel rebuilt: live thumbnail per row, topology toggle card with Hide/Restore buttons, removed dead recording marker button, larger layout (920px), participant rows in single card with hover affordances.
96x54 thumbnail (16:9) fed from the engine's most recent ProcessedFrame. Em-dash placeholder when no pipeline is running. Same pattern as v1 - lifted Image binding to Thumbnail with HasThumbnail visibility flip. Sits between the state LED and the name+codec caption.
The .ObserveOn(SynchronizationContextScheduler(SyncContext.Current)) path captured a synchronization context at subscribe time that didn't pump subsequent OnNext emissions in WPF startup, leaving the Participants collection empty even though the engine's discovery was firing. Console probe confirmed engine sees Teams sources; only the GUI consumer was broken.
Switched to direct Subscribe + Dispatcher.InvokeAsync inside the callback (same pattern proven by Console.Program.cs). Subscribe-time context capture is gone; every emission marshals to the UI thread on its own.
Two visual upgrades on the participant rows:
* The ISO state pill now flips background / border / text colors
based on the engine's reported state — green for LIVE, coral for
ERROR, amber for STARTING / NO SIGNAL, neutral surface for OFF.
Brushes pulled from the ThemeResource ramp (StatusLiveBg /
StatusLive / AccentCoralBg / AccentCoral / StatusWarnBg /
StatusWarn / BgSurface / BorderStrong). Mirrors the WPF host's
IsoToggle data-trigger behavior but built imperatively.
* The active-speaker left accent — a 3px cyan border at the row's
left edge — appears when MainViewModel's 1Hz stats tick marks the
loudest participant. Hidden by default; flips Visibility on
PropertyChanged(IsActiveSpeaker).
Row layout extended to accommodate: column 0 = 3px accent strip,
column 1 = 20px spacer, column 2 = name + codec (1*), column 3 =
output name (140px fixed), column 4 = ISO pill (auto).
ApplyIsoPillStyling is the brush-mapping helper — called once at row
construct and again on every IsoStateLabel / IsEnabled change. The
brush keys all resolve via Application.Current.Resources rather than
ThemeResource markup since the row is constructed imperatively (no
XAML to apply ThemeResource markup against).
Verified end-to-end: dotnet build clean, app launches with 3 live
participants in the row, all pills showing OFF (neutral surface +
strong border). Once a participant goes through OFF → STARTING (amber)
→ LIVE (green), the pill colors will update on the 1Hz stats tick.
Services/UIPreferences.cs — mirror of the WPF host's UIPreferences,
sharing %LOCALAPPDATA%\TeamsISO\ui-prefs.json on disk. Adds a Theme
field ("System" / "Dark" / "Light") that the WPF host's UIPreferences
will pick up when its theme system lands (JSON deserialization is
forward-compatible — extra fields are ignored, missing fields fall
back to defaults).
ThemeManager hydration:
* Constructor reads UIPreferences.Theme on first .Current access.
* Defaults to "System" when the file is missing, the value is
invalid, or load throws (defensive — ThemeManager.Current is a
static singleton, a throw would break theme resolution app-wide).
ThemeManager.Set persistence:
* Calls UIPreferences.SetTheme(preference) which does a read-modify-
write of the JSON (so other fields aren't trampled).
* Persistence is best-effort wrapped in try/catch — disk full,
permission denied, etc. fall through and the in-memory state still
holds for the session.
End-to-end now: title-bar sun/moon toggle → ThemeManager.Toggle →
.Set("Dark"/"Light") → JSON write → next launch reads the preference
and applies before the first frame. Operator's theme choice survives
across launches and across host swaps once the WPF host learns the
field.
Adds the operator's shortcut surface to the WinUI 3 host via
KeyboardAccelerator attached to the window's content root:
* F1 — open the keyboard-cheat-sheet HelpDialog as a ContentDialog.
* Ctrl+M — drop a recording marker (invokes
MainViewModel.DropRecordingMarkerCommand, which fans out to every
active recorder via IIsoController.AddRecordingMarker).
* Ctrl+Shift+S — panic stop (invokes StopAllIsosCommand).
* Ctrl+R — refresh NDI discovery.
* 1-9 + NumPad 1-9 — toggle ISO for the Nth visible participant
(invokes ToggleByIndexCommand with the digit as the parameter).
* Esc — dismiss the settings drawer when open.
Mirrors the WPF host's <Window.InputBindings> verbatim so the
operator's muscle memory transfers across hosts.
Wire-up note: WinUI 3 KeyboardAccelerator uses
TypedEventHandler<KeyboardAccelerator, KeyboardAcceleratorInvokedEvent
Args>, not System.EventHandler<T> like XAML islands suggest in some
docs. The Bind local fn takes the correct type explicitly so the
compiler doesn't trip on the conversion.
Verified: dotnet build clean, app launches and accelerators register
without crashing the XAML parser.
In-call control bar now drives the live Teams app via UIAutomation:
* Mute button → TeamsControlBridge.ToggleMute()
* Camera button → TeamsControlBridge.ToggleCamera()
* Share button → TeamsControlBridge.OpenShareTray()
* Leave button → TeamsControlBridge.LeaveCall()
Each button reports the result through the status bar (Invoked /
Teams-not-running / Control-not-visible / Invoke-failed).
Rail buttons also wired:
* Launch / surface Teams → TeamsLauncher.IsRunning()/TryLaunch()/ShowWindows()
* Hide / show Teams windows → TeamsLauncher.HideWindows()/ShowWindows()
with a _teamsHidden flag tracking the toggle state
The Marker button was already command-bound to MainViewModel.DropRecording
MarkerCommand (which fans out to IIsoController.AddRecordingMarker), so
the only thing that wasn't covered before is the Teams-side stuff.
Implementation notes:
* Services/TeamsControlBridge.cs and Services/TeamsLauncher.cs are
copied verbatim from src/TeamsISO.App/Services/ with only the
namespace adjusted (TeamsISO.App.Services → TeamsISO.App.WinUI.
Services). Neither file has WPF-specific dependencies — they use
System.Windows.Automation (UIAutomationClient) which works
identically across WPF and WinUI 3 builds. Duplication is
acceptable migration debt; the long-term plan is to lift these
into a shared TeamsISO.App.Shared library once both hosts
stabilize.
* DescribeBridgeResult maps the InvokeResult enum to operator-tone
status text so a failing mute reads "Mute failed — control not
visible (not in a call?)" instead of an opaque "ControlNotFound".
The in-call bar now does what the WPF host's in-call bar does, minus
the MUTED / CAM OFF state pills (those would need a 1Hz UIA poll of
the Teams call state — wire-up to come).
docs/preview/winui3-engine-wired-with-live-teams.png — fullscreen
capture of the redesigned WinUI 3 TeamsISO running against the live
Teams meeting on the build host. Shows:
* Three participants discovered from the live meeting — (Local),
Active Speaker, Brendon Power — each with their TEAMSISO_<name>
output name in the redesigned shell at the right column.
* Section header reads "Participants 3" with the live count badge.
* Status bar reads "3 participants · 0 routing".
* Windows Security / Firewall dialog asking permission for TeamsISO
to access public + private networks. This appeared the moment the
operator clicked "Enable all online" — which proves the click was
wired to MainViewModel.EnableAllOnlineCommand which fanned out to
each ParticipantViewModel.ToggleIsoCommand which awaited
IsoController.EnableIsoAsync, which spun up an NDI sender, which
Windows Firewall intercepted on first launch.
* Bottom row: the in-call control bar with Muted / Camera / Share /
Marker / Leave — visible alongside the participants area as
designed.
So the engine wiring is verified end-to-end:
click on cyan "Enable all online" CTA
→ MainViewModel.EnableAllOnlineCommand
→ ParticipantViewModel.ToggleIsoCommand for each online row
→ IIsoController.EnableIsoAsync(id, outputName)
→ IsoPipeline.StartAsync
→ NdiInteropPInvoke.CreateSender
→ bind to NDI port → Windows Firewall prompt
Next session: the user clicks Allow on the firewall prompt (once,
remembered for subsequent runs), and the ISO pills will transition
from OFF → STARTING → LIVE as each NDI sender comes online.
The WinUI 3 host now stands up the full engine pipeline on launch and
discovers participants from the operator's live Teams meeting. Verified
end-to-end against a real call: window opened, NDI runtime preflight
passed, IsoController spun up, and Participants observable yielded
three live entries ((Local), Active Speaker, Brendon Power) with their
TEAMSISO_<name> output names in the redesigned shell.
What this commit lands:
ViewModels/ (slim ports of the WPF host's view-models — engine layer
shared verbatim via ProjectReference):
* ObservableObject.cs — INPC base, mirrors the WPF version
* RelayCommand.cs — sync + typed + async variants; ICommand is the
same shared type across both hosts (System.ObjectModel.dll)
* ParticipantViewModel.cs — DisplayName / Initials / SourceCodec /
IsoStateLabel / DisplayedAudioLevel / IsActiveSpeaker / IsOnline /
OutputName + ToggleIsoCommand. Drops the WPF-specific thumbnail
WriteableBitmap path, clipboard, PreviewWindow, snapshot encoder —
those come back when the WinUI imaging pipeline is wired (Phase 5
of the migration plan).
* MainViewModel.cs — subscribes to IIsoController.Participants on a
DispatcherQueueSynchronizationContext, owns the ObservableCollection,
runs a 1Hz DispatcherQueueTimer for stats + active-speaker
highlight + session-elapsed text. Commands: EnableAllOnline,
StopAllIsos, RefreshDiscovery, DropRecordingMarker, ToggleByIndex.
App.xaml.cs:
* OnLaunched brings up MainWindow first, then fires WireEngineAsync
so the user sees the shell immediately while NDI preflight + engine
setup proceed.
* Full pipeline: EngineLogging → NdiInteropPInvoke (with friendly
fallback message if the NDI runtime isn't installed) → ConfigStore
at %APPDATA%\TeamsISO\config.json → NdiRuntimeProbe + scaler →
IsoPipeline factory → IsoController → MainViewModel →
MainWindow.AttachViewModel → IsoController.StartAsync.
* Logger writes to the same %LOCALAPPDATA%\TeamsISO\Logs as the WPF
host so a mixed-host operator sees a single timeline.
MainWindow.xaml + .xaml.cs:
* x:Name on the section header buttons (RefreshButton, StopAllButton,
EnableAllButton, MarkerButton) and on the status bar text + the
ParticipantsHost grid.
* AttachViewModel wires those buttons to view-model commands; pushes
StatusText + ParticipantCountText through PropertyChanged.
* BuildSimpleRow imperatively constructs each row (Grid with name +
codec + output + ISO toggle pill) instead of going through a
DataTemplate. Rationale: declaring a DataTemplate in
Grid.Resources OR loading one via XamlReader.Load both crash WinUI
3's XAML parser at runtime on this build host (same HR=0x802b000a
we saw with the SettingsDrawer NavigationView). Imperative
construction sidesteps the parser. The rich row template (avatar
circle, audio meter, active-speaker accent) returns in Phase 5
alongside the CommunityToolkit DataGrid swap.
* Per-row PropertyChanged subscriptions refresh DisplayName, codec,
output name, and ISO pill text as the engine pushes updates.
Verified live (PID 4824, 2026-05-13 08:02): three real Teams
participants appeared in the redesigned shell within seconds of launch,
status bar populated with "3 participants · 0 routing", section
header showed "Participants 3", and the title-bar live pills rendered
in their proper places (placeholder values for now; binding to
SessionElapsed / IsRecording lands in the next commit).
Replaces the NavigationView in SettingsDrawer with a simpler
StackPanel of tab buttons built imperatively at runtime. The
NavigationView's resource-dictionary expansion (or its default
template loading) was crashing the XAML parser at SettingsDrawer's
InitializeComponent on WinUI 3 1.8.
New shape:
- `TabStrip` StackPanel populated in BuildTabStrip() with five
Tertiary-styled Button instances. Selection updates the foreground
to AccentCyanText for the active tab and FgSecondary for the rest.
- `TabContent` ScrollViewer remains; RebuildTabContent(key) clears
and rebuilds via the same helpers as before (SettingHeader,
SettingRow, SettingNote, AccentSwatch).
- Each tab's content moved into its own helper method
(BuildAppearanceTab / Routing / Display / Control / Advanced) so
the switch in the old OnTabSelectionChanged disappears.
MainWindow re-hosts the drawer at Grid.Row=0, RowSpan=4, right-
aligned, 400px wide, Visibility=Collapsed. OnSettingsClick toggles
visibility. Verified: dotnet build + run launches cleanly, and the
window stays alive (PID confirmed via Get-Process).
This closes Phase 6 (secondary windows) for the drawer specifically.
The Help, About, and Onboarding dialogs are ContentDialogs that
don't host inline so they should be straightforward to wire to
their respective triggers (F1 / About button / first launch) in
Phase 7.
Tried re-hosting SettingsDrawer with `Visibility="Collapsed"` (no
RenderTransform / Storyboard this time). Still crashes the XAML parser
at startup with the same HR 0x802b000a.
Narrows the suspect: the crash is inside SettingsDrawer.xaml's
InitializeComponent, not in MainWindow.xaml's hosting of it. Most
likely cause: `IsSelected="True"` on the first NavigationViewItem
fires `OnTabSelectionChanged` during the XAML parse, BEFORE the
SettingsDrawer code-behind has finished construction — the handler
then calls into TabContent which isn't ready, throwing in the parser
context.
Two fixes to try next session:
1. Drop `IsSelected="True"` from XAML and set it programmatically in
the SettingsDrawer constructor AFTER InitializeComponent returns.
2. Verify the OnTabSelectionChanged signature for WinUI 3 1.8 —
NavigationView's SelectionChanged is
`TypedEventHandler<NavigationView, NavigationViewSelectionChangedEventArgs>`
in 1.8 (might be different from the 1.6 SDK signature I wrote
against).
For now, the MainWindow's OnSettingsClick is a no-op stub. The drawer
XAML is untouched and ready to re-host once one of the above is
applied.
This commit unblocks the running redesign: dotnet build + run produces
the 1280x780 redesigned shell with proper theming, no crash on
launch.
Rewrites the TL;DR + commit list + tomorrow's first-action list to
reflect the actual end-of-session state:
- The WinUI 3 redesigned host LAUNCHES and renders correctly at
1280x780 with proper dark/light theming.
- Two activation blockers identified and both resolved (DDLM swap +
inline SettingsDrawer host removed).
- 18 commits pushed to origin/main.
- Phase 3 of the migration plan is closed; Phase 4 (view-model
wiring) opens next session.
This is the closing log entry. The redesign is real and on disk.
Two screenshots captured from the live TeamsISO.App.WinUI .exe at
1280×780, one per theme. Both prove the redesign renders end-to-end
on Windows 11 with WindowsAppSDK 1.8 and no view-model wiring yet:
* docs/preview/winui3-mainwindow-light.png — App.Current.RequestedTheme
set to Light via ThemeManager. Wild Dragon "W" mark renders as cyan
(#0E7C82) on cyan-muted (#E6F8F9) tile per the light-mode accent
split from DESIGN.md. All other rail icons render at FgSecondary
(#4A4B50) for AA contrast.
* docs/preview/winui3-mainwindow-dark.png — same render, dark theme.
Wild Dragon mark uses the airy #97EDF0 cyan on the deeper
cyan-muted (#1B3537) tile. Rail icons + section text at FgPrimary
(#F4F4F6).
ThemeManager default reverted to "System" (the screenshot for dark
mode was taken with the default temporarily set to "Dark", then
reverted before this commit). The light-mode screenshot is what runs
when the OS app-mode is light, which is what happened on this build
host tonight.
These are the artifacts to point at when stakeholders ask "what does
the redesign look like in practice?" — they are the WinUI 3 .exe, not
the HTML preview.
Removing the inline-hosted SettingsDrawer (and its accompanying
Storyboard resources targeting TranslateTransform.X) unblocks the
launch. The WinUI 3 host now opens, paints, and stays alive. Verified
via screenshot:
* 64px left rail with Wild Dragon "W" brand mark + participants /
Teams / hide-Teams / settings / engine-status puck buttons (Segoe
Fluent Icons throughout, uniform stroke)
* 44px custom title bar with the live pills inline (live · session
timer · REC count · disk free) and a theme toggle to the left of
the system min/max/close
* Section header: "Participants 4" + filter input + Refresh +
Presets + the single cyan Primary CTA "Enable all online"
* Participants list placeholder ("View-model wiring queued for the
next session") in the hero row — real DataGrid + bindings land in
Phase 4/5 of the migration plan
* Conditional in-call control bar: Muted (destructive coral) +
Camera/Share/Marker (Secondary) + Leave (destructive coral) +
overflow kebab
* Slim status bar: control-surface URL + keyboard shortcut hints
* Rendered in LIGHT THEME on first run (matched the OS app-mode
setting via ThemeManager.ResolveTheme), confirming the
ThemeDictionary swap works end-to-end
Two open suspects causing the SettingsDrawer host to crash WinUI 3's
XAML parser with HR=0x802b000a (XAML_E_PARSER_GENERAL_ERROR):
* RenderTransform with a x:Name'd TranslateTransform — WinUI 3
might not allow naming transforms inside RenderTransform the way
WPF does
* Storyboard.TargetName pointing at the named transform — WinUI 3
Storyboards have stricter resolution
The drawer XAML itself (Views/SettingsDrawer.xaml + .cs) is unchanged
and ships alongside this commit. Re-host it in MainWindow.xaml once
the parse error is triaged (likely fix: replace TranslateTransform.X
animation with the AppWindow composition API or use the
CompositionTarget approach instead of a Storyboard).
The migration plan's Phase 3 is now substantially CLOSED — the
WindowsAppSDK activation blocker is resolved (1.8 DDLM swap). Next
session opens with Phase 4 (view-model wiring) plus the SettingsDrawer
re-host triage.
MddBootstrapInitialize2 probe identifies HR=0x80670016 =
MDD_E_BOOTSTRAP_INITIALIZE_DDLM_NOT_FOUND. Three suggested fixes
documented inline, with option 2 (switch to WindowsAppSDK 1.8, which
has its DDLM installed) already taken in commit 166e7d6.
After fix#2 the bootstrap succeeds and the .exe launches, but a
secondary XAML parse error (HR=0x802b000a) terminates it within 1s.
Triaging that is the next session's task.
Two big findings from a custom MddBootstrapInitialize2 P/Invoke probe
this session:
1. The original WinUI 3 activation failure ("this application could not
be started") was MDD_E_BOOTSTRAP_INITIALIZE_DDLM_NOT_FOUND (HR
0x80670016). The framework package Microsoft.WindowsAppRuntime.1.6
was installed, but the Dynamic Dependency Lifetime Manager sibling
(MicrosoftCorporationII.WinAppRuntime.Main.1.6) wasn't. This machine
has Main.1.5 and Main.1.8 packages but no Main.1.6, so bootstrap for
1.6 fails.
2. Switching the WindowsAppSDK NuGet to 1.8.250916003 + the bootstrap
major.minor to 0x00010008 in Program.cs gets past activation. The
.exe now launches and Bootstrap.TryInitialize returns S_OK. The 1.8
DDLM is present and the runtime spins up.
Also lands `src/TeamsISO.App.WinUI.Probe/`, a tiny console diagnostic
that calls MddBootstrapInitialize2 directly via P/Invoke (bypassing the
full WindowsAppSDK NuGet to avoid the MRT/PRI MSBuild tasks that need
VS's AppxPackage tooling installed). The probe prints the HResult and a
human-readable description; use it to triage WindowsAppSDK activation
on any deployment target:
dotnet run --project src/TeamsISO.App.WinUI.Probe
A SECOND ISSUE surfaces after activation: the .exe crashes 1 second
after launch with 0xC000027B inside Microsoft.UI.Xaml.dll, sub-code
0x802b000a (XAML_E_PARSER_GENERAL_ERROR). The participants ItemsRepeater
with {Binding ...} markup is suspect (WinUI 3 prefers x:Bind with
x:DataType, and Visibility="{Binding bool}" needs a converter). The
ItemsRepeater is stubbed out to a plain "Participants list renders here"
TextBlock placeholder for now; same crash recurs, so the XAML issue is
elsewhere — likely in Controls.xaml (one of CharacterSpacing /
TextCaption / etc. unsupported), in App.xaml's MergedDictionary chain,
or in MainWindow.xaml's Storyboard target.
Triaging the XAML parse error is the next session's first action. The
sub-code 0x802b000a will help (search WindowsAppSDK source for the
matching XAML parser error). The migration plan in
docs/superpowers/plans/2026-05-12-winui3-migration.md is updated.
Build remains clean.