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.
CHANGELOG.md gains an Added section at the top of [Unreleased] that
walks the redesign decisions: PRODUCT/DESIGN docs, WinUI 3 scaffold,
MainWindow IA, ThemeManager, Settings drawer, Help/About/Onboarding,
HTML preview, migration plan. Calls out the WPF host as the still-
shipping build until WinUI 3 reaches feature parity.
README.md picks up:
- A Status section paragraph naming the in-flight redesign and the
current activation blocker, with a pointer to Phase 3 of the
migration plan
- A Build section that names both hosts so a fresh checkout doesn't
surprise contributors with the new csproj
- Documentation section now links PRODUCT.md, DESIGN.md, the migration
plan, and the interactive HTML preview
Both docs land BEFORE Phase 4 (view-model wiring) so onlookers
understand what's already done and what's queued.
Updates the overnight 2026-05-12 work log to reflect:
- All 12 commits successfully pushed to origin/main (the credential
manager refreshed at some point during the session and pushes are
going through)
- The activation issue diagnosis got more specific: stripping
Microsoft.WindowsDesktop.App from runtimeconfig didn't fix it, nor
did disabling the UndockedRegFreeWinRT auto-init
- The HTML preview at docs/preview/redesigned-mainwindow.html is the
primary "see the design" artifact while activation is blocked
- The Settings drawer + Help/About/Onboarding dialogs all landed
- Phase 4-9 of the migration plan are queued for the next session
Suggested first action for the user tomorrow morning is now clearly
named: open the HTML preview, then attack the activation issue with VS
F5 launch or by reinstalling the WindowsAppRuntime 1.6 redist.
Replace the transform-only approach with display:none / display:flex
switching, plus a @keyframes drawer-slide-in for the entry animation.
The previous translateX trick let the drawer-head close button's SVG
bleed through somehow (likely a browser rendering quirk on a 4K display
with HiDPI scaling); display:none guarantees the hidden state stays
fully hidden across all browsers.
Visual is the same when the drawer is open; only the closed state is
hardened. Drawer still slides in on the rail settings button or the
banner "Open settings" CTA.
The drawer was bleeding its close-X out of the window's left side
because .content (its containing block) had display:grid but no
position:relative, so absolute-positioned children anchored to .window
or further up the tree. Adding position:relative makes the drawer
anchor to .content, and overflow:hidden clips the off-screen
translateX(100%) so its content doesn't render outside the bounds.
Adds a StripWindowsDesktopAppFromRuntimeConfig MSBuild target that
runs after GenerateBuildRuntimeConfigurationFiles and rewrites
TeamsISO.runtimeconfig.json to drop the implicit
Microsoft.WindowsDesktop.App framework reference. The .NET 8 SDK adds
that framework for any -windows TFM, but WinUI 3 doesn't use it and
including it in the runtimeconfig contributes to the broken
unpackaged activation path (one of the three suspects in the migration
plan's Phase 3).
Build log confirms the strip on every build:
Stripped Microsoft.WindowsDesktop.App from .../TeamsISO.runtimeconfig.json
Verified the runtimeconfig now reads:
{ "name": "Microsoft.NETCore.App", "version": "8.0.0" }
(no WindowsDesktop.App entry)
This didn't resolve the activation dialog on its own, but it's a
required step for any unpackaged WinUI 3 build and the next debugging
session can rule it out as a contributing cause.
Hosts SettingsDrawer in the main content grid as a fixed-width 400px
panel positioned at the right edge, with a TranslateTransform pre-set
to X=400 so it starts off-screen. The rail's settings icon triggers a
220ms ease-out-quart slide-in storyboard; CloseRequested (or the
drawer's Esc/close button) triggers a 180ms ease-in slide-out.
IsHitTestVisible toggles in sync so the off-screen drawer doesn't
intercept clicks on the participants list.
This is the structural commitment from DESIGN.md: settings live in a
right-drawer (not a permanent 380px panel), the participants table
reclaims full width when settings aren't being edited.
Builds clean.
Followup: tab content currently rebuilds imperatively in code-behind;
wire it to a SettingsViewModel mirror of the WPF host's
GlobalSettingsViewModel once the view-model migration starts (Phase 4
of the migration plan).
docs/preview/redesigned-mainwindow.html — a single-file faithful
rendering of the redesigned MainWindow at 1280×780 fidelity. Built so
you can SEE the redesign tomorrow morning even with the WinUI 3
activation issue unresolved.
What's interactive:
* Title-bar sun/moon icon toggles dark <-> light. The full CSS variable
set swaps in-place; both themes are accurate to DESIGN.md's token
table (Wild Dragon cyan stays as the surface fill in both; accent.cyan
for text darkens to #0E7C82 on the light palette for AA contrast).
* Rail "settings" icon (the gear) slides the settings drawer in from
the right with a 220ms ease-out-quart transition.
* Esc dismisses the drawer.
* Banner "Toggle dark / light" and "Open settings" buttons for
hover-discoverable parity.
What's faithful to the WinUI 3 implementation:
* All structural decisions from the shape brief: 64px rail, 44px
title bar with absorbed live pills (live · 00:14:32 / rec 3 / 482GB
free), section header with primary button + secondary actions, 64px
table rows with avatar+name, signal+lock dot, audio meter,
output-name in mono, ISO pill at right, conditional in-call control
bar, slim 32px status bar.
* Active-speaker row (Maya) has the cyan left border + cyan-muted
background tint, matching the data trigger in MainWindow.xaml.
* Wild Dragon brand mark in the rail uses the same cyan-muted square +
cyan-text "W" treatment as the WinUI 3 Avatar style.
* In-call buttons use the destructive style for Muted + Leave (coral
border and text) and Secondary for Camera / Share / Marker, mirroring
the button-hierarchy commitment from DESIGN.md.
* Settings drawer shows the Appearance tab with the System / Dark /
Light radio group + the accent peek panel.
Open it in any modern browser:
file:///C:/Users/zacga/Documents/Claude/Projects/Teams%20ISO/docs/preview/redesigned-mainwindow.html
This is documentation; the actual product is the WinUI 3 XAML in
src/TeamsISO.App.WinUI/. Once activation unblocks, the preview can be
deleted (or kept for future stakeholder demos before binaries ship).
Adds <WindowsAppSdkUndockedRegFreeWinRTInitialize>false</...> with a
comment chain that traces the runtime activation failure investigation
to the next maintainer:
1. WindowsAppSDK's UndockedRegFreeWinRTCommon.targets only auto-enables
the ModuleInitializer when WindowsAppSDKSelfContained=true.
2. Without it, framework-dependent unpackaged builds need our own
explicit Bootstrap.TryInitialize call (Program.cs already does this).
3. WITH it, the bundled auto-init P/Invokes Microsoft.WindowsAppRuntime.dll
during module load — but the runtime DLL lives in the framework MSIX
package, not the output dir, and Bootstrap hasn't yet added the
framework dir to the DLL search path. The P/Invoke fails and the
.exe dies before Main runs.
Setting the property to false explicitly suppresses the early P/Invoke
so our Program.Main + Bootstrap.TryInitialize can sequence correctly.
This didn't fix activation on this build host though — the .exe still
shows "this application could not be started." Strong suspicion: the
managed assembly references Microsoft.WinUI.dll which itself has
DllImport-style dependencies the .NET host probes during assembly load.
Recommended next steps (not done overnight to avoid further blind
swings): attach a debugger to TeamsISO.exe before Main runs (windbg
sxe ld for the runtime DLL, or VS 'Just My Code: off' attach), capture
the CLR fusion log, or try a known-good Microsoft WinUI 3 template
side-by-side to isolate whether the issue is project or machine.
Build remains clean. WPF host unaffected.
Builds out the secondary surfaces of the redesigned WinUI 3 host.
ThemeManager (Services/ThemeManager.cs)
Single-source-of-truth for the active theme. Holds the user preference
(System / Dark / Light), resolves it to ElementTheme at request, and
raises a Themed event when it changes so the MainWindow can push the
AppWindow title-bar button colors. Uses Windows.UI.ViewManagement
UISettings to follow the OS app-mode when preference is System.
Persistence to UIPreferences lands in the engine-wiring commit.
MainWindow theme wiring
Replaces the per-handler theme toggle with a ThemeManager subscription:
click the title-bar sun/moon -> Toggle() -> Themed event ->
ApplyResolvedTheme on the visual tree + the title-bar buttons. Glyph
cue: sun = "current is Light, click to Dark"; moon = "current is Dark,
click to Light." Initial state applied at construction so the first
frame matches the preference.
SettingsDrawer (Views/SettingsDrawer.xaml + .cs)
UserControl that slides in from the right over the participants table.
56px header, NavigationView with five tabs (Appearance, Routing,
Display, Control, Advanced), footer with Reset-to-defaults +
Apply/Close. Appearance tab has the theme tri-state picker (System /
Dark / Light radio group) and an "Accent peek" row showing the four
brand accents (cyan / coral / live / warn) as swatches so the
operator can verify Wild Dragon brand is respected on a light desk.
CloseRequested event signals the host to collapse the drawer.
HelpDialog (Views/HelpDialog.xaml + .cs)
ContentDialog with the keyboard shortcut cheat sheet, grouped by
category (Global / Participants / Look / Control surface). 540px max
height with scroll, mono-spaced shortcut labels at left, body text at
right. Replaces the WPF host's HelpWindow at parity.
AboutDialog (Views/AboutDialog.xaml + .cs)
ContentDialog with the Wild Dragon mark, version + host + engine +
brand info as label/value rows, and three quick action buttons
(open logs folder, open recordings, check for updates). Mirrors the
WPF host's AboutWindow.
OnboardingDialog (Views/OnboardingDialog.xaml + .cs)
Three numbered steps (Install NDI Runtime / Enable Teams NDI / Pick
transcoder topology), no carousel, operator-tone copy ("Don't show
this again" defaults checked). PrimaryButtonText "Get started",
SecondaryButtonText "Skip" so the dialog is skippable from the first
frame as the PRODUCT.md anti-references demand.
Build clean: dotnet build TeamsISO.App.WinUI -c Debug -> 0 / 0.
Next: wire the drawer's CloseRequested into MainWindow (so the settings
icon actually opens / collapses the drawer), then attack the runtime
activation blocker (Phase 3 of the migration plan).