Commit graph

153 commits

Author SHA1 Message Date
99d6d80754 ui(iso): inline-editable Output name + default to speaker display name
Some checks failed
CI / build-and-test (push) Failing after 26s
2026-05-16 23:34:08 -04:00
dfdfa9e0e1 ui(brand): superimposed dragon watermark behind participants, theme-flipped (white/dark, black/light)
Some checks failed
CI / build-and-test (push) Failing after 27s
2026-05-16 19:10:36 -04:00
80d9baf2d0 ui(header): drop Cmd+K button + swap settings glyph for a true gear (U+2699)
Some checks failed
CI / build-and-test (push) Failing after 26s
2026-05-16 18:55:46 -04:00
d880941ad5 fix(ndi): canonicalize 'public' -> 'Public' in discovery + sender group strings (the real bug)
Some checks failed
CI / build-and-test (push) Failing after 26s
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.
2026-05-16 18:33:49 -04:00
1cdd4ebd04 fix(installer+wpf): REVERT runas /trustlevel demotion (it was the bug, not the fix)
Some checks failed
CI / build-and-test (push) Failing after 26s
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.
2026-05-16 16:27:23 -04:00
ea940ffac4 test(engine): extract ShouldAutoRebuild as pure fn + cover 6 cases
Some checks failed
CI / build-and-test (push) Failing after 27s
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).
2026-05-16 13:38:44 -04:00
c30a6163c8 fix(engine): self-healing NDI discovery + unified poll loop
Some checks failed
CI / build-and-test (push) Failing after 26s
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.
2026-05-16 13:35:22 -04:00
54ee578fe9 fix(wpf): de-elevate via runas env-var marker (CLI arg breaks runas /trustlevel)
Some checks failed
CI / build-and-test (push) Failing after 26s
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.
2026-05-16 12:16:55 -04:00
191b2c5f52 fix(wpf): de-elevate when spawned by elevated explorer (NDI mDNS isolation)
Some checks failed
CI / build-and-test (push) Failing after 27s
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.
2026-05-16 11:36:52 -04:00
09e5b59dfd fix: cold-start discovery + installer shortcuts + single-instance hardening
Some checks failed
CI / build-and-test (push) Failing after 26s
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/.
2026-05-16 11:23:19 -04:00
f47edfb2f6 ISO toggle: widen column 110->124, tighten padding so 'Enable' fits
Some checks failed
CI / build-and-test (push) Failing after 28s
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.
2026-05-16 08:57:27 -04:00
47914fcd77 ISO toggle: square corners to match the rest of the button family
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.
2026-05-16 08:56:50 -04:00
dba7dcc8a8 gear icon: swap Path glyph for U+2699 + bump column to 56px
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.
2026-05-16 08:56:43 -04:00
6c9bee7391 fix(wpf): catch participant-left race in ToggleIsoAsync, toast instead of crash
Some checks failed
CI / build-and-test (push) Failing after 27s
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.
2026-05-16 08:48:06 -04:00
84861dafa5 test: integration — App+MainWindow STA smoke, control-surface live VM, theme XAML load
Some checks failed
CI / build-and-test (push) Failing after 30s
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>
2026-05-15 21:34:09 -04:00
6505a3cab0 test: services — NotesService, UpdateChecker, PresetApplier, OscBridge, IsoController
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>
2026-05-15 21:06:45 -04:00
d91f95379b test: ControlSurfaceServer route table smoke coverage
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>
2026-05-15 20:52:36 -04:00
fbcc56289e test: ThemeManager + CommandPaletteViewModel.Matches coverage
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>
2026-05-15 20:47:25 -04:00
e96a30b76f chore: trim stale batch-commit script + drop SmokeTest placeholder
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>
2026-05-15 20:16:14 -04:00
1f07992100 refactor(services): extract TeamsEmbedHost from TeamsLauncher
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>
2026-05-15 20:13:57 -04:00
2640739bfc refactor(control-surface): split server into endpoint partials
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>
2026-05-15 19:48:03 -04:00
e67c02c2ff refactor(app): split App.xaml.cs into themed partial files
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>
2026-05-15 19:36:07 -04:00
d02a2c059b refactor(viewmodels): split MainViewModel into themed partial classes
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>
2026-05-15 19:31:49 -04:00
33fca8e955 polish(mainwindow): empty state, table widths, strings, theme tooltip
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>
2026-05-15 19:26:23 -04:00
37390026b3 chore(docs): reconcile to WPF-only after WinUI 3 was abandoned
- 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>
2026-05-15 19:16:20 -04:00
5a43c9cb6a feat: per-ISO framerate/resolution/aspect/audio overrides + thumbnail BMP
Some checks failed
CI / build-and-test (push) Failing after 30s
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.
2026-05-15 15:31:32 -04:00
647deec304 feat(web): topology + thumbnail endpoints, redesigned /ui control panel
Some checks failed
CI / build-and-test (push) Failing after 30s
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.
2026-05-15 15:06:11 -04:00
4944de5feb feat(wpf): v2 - restore live thumbnail preview column in participants table
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.
2026-05-15 14:33:00 -04:00
209b643cd5 fix(wpf): MainViewModel subscription via direct Subscribe + Dispatcher marshal
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.
2026-05-15 14:27:17 -04:00
d282e1b0f8 feat(wpf): v2 task 39+40 - studio table redesign + Ctrl+K command palette
Some checks failed
CI / build-and-test (push) Failing after 30s
Task 39: 5-column participants table - state LED, name+codec caption, 5-bar audio meter, mono output name, ISO pill. Row height 52, full-row active-speaker tint (no left stripe). New converter LevelThresholdConverter, OutputName property on ParticipantViewModel.

Task 40: Ctrl+K / Ctrl+P command palette - chromeless centered floating window, fuzzy Contains match across Label/Category/Keywords, arrow nav, Enter invoke, Esc close. Quick/Teams/Network/App categories cover top operator verbs and theme switching.

Also: log startup exceptions to Serilog before the modal MessageBox fires - much better triage signal than user-pasted dialog text.
2026-05-15 11:15:00 -04:00
c27130302f feat(wpf): v2 'Studio Terminal' shell - theme system, header, transport strip, drawer
Some checks failed
CI / build-and-test (push) Failing after 31s
- Theme split: Theme.Dark.xaml + Theme.Light.xaml + ThemeManager

- New shell: 32px header (mark + wordmark + 3 icons), 40px transport strip, conditional meeting bar, slide-over settings drawer

- Removed: 72px rail, 380px permanent settings panel, 6-column footer, custom chromeless title bar buttons

- Ctrl+T toggles theme; follows Windows app-mode by default

- Shape doc at docs/shapes/2026-05-13-teamsiso-v2-studio-terminal.md
2026-05-14 12:46:24 -04:00
1d1ce6a2a0 feat(wpf): rollback to WPF host, axe recording, fix settings pane
Some checks failed
CI / build-and-test (push) Failing after 29s
2026-05-14 06:02:40 -04:00
9ae14c8ee9 feat(winui3): colored ISO pills + active-speaker accent on rows
Some checks failed
CI / build-and-test (push) Has been cancelled
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.
2026-05-13 21:37:36 -04:00
f7249c31c2 feat(winui3): persist theme preference to UIPreferences
Some checks failed
CI / build-and-test (push) Failing after 26s
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.
2026-05-13 21:35:31 -04:00
7c269f2c40 feat(winui3): keyboard shortcuts (F1, Ctrl+M, Ctrl+Shift+S, Ctrl+R, 1-9, Esc)
Some checks failed
CI / build-and-test (push) Failing after 27s
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.
2026-05-13 21:33:02 -04:00
7ac56c2661 feat(winui3): wire Teams orchestration into the in-call bar + rail buttons
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).
2026-05-13 21:31:04 -04:00
83c954d80d feat(winui3): engine wired — discovers Teams participants live
Some checks failed
CI / build-and-test (push) Failing after 27s
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).
2026-05-13 08:03:32 -04:00
a05c0a75d2 feat(winui3): SettingsDrawer hosts successfully — NavigationView swap
Some checks failed
CI / build-and-test (push) Failing after 29s
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.
2026-05-13 00:50:54 -04:00
27f47401d9 build(winui3): keep SettingsDrawer host deferred + narrow the suspect
Some checks failed
CI / build-and-test (push) Failing after 28s
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.
2026-05-13 00:48:03 -04:00
eee307d711 docs(preview): proof-of-running WinUI 3 screenshots (dark + light)
Some checks failed
CI / build-and-test (push) Failing after 27s
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.
2026-05-13 00:44:32 -04:00
a33f80d345 feat(winui3): WinUI 3 host LAUNCHES — verified rendering on Windows
Some checks failed
CI / build-and-test (push) Failing after 26s
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.
2026-05-13 00:41:49 -04:00
166e7d6e6a build(winui3): switch to WindowsAppSDK 1.8 + add diagnostic probe
Some checks failed
CI / build-and-test (push) Has been cancelled
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.
2026-05-13 00:39:43 -04:00
2f9f7092ed build(winui3): post-build target to strip WindowsDesktop.App from runtimeconfig
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.
2026-05-13 00:21:33 -04:00
2909d8b1d7 feat(winui3): wire Settings drawer slide-in animation into MainWindow
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).
2026-05-13 00:20:23 -04:00
8e29c1dc1e build(winui3): suppress UndockedRegFreeWinRT auto-init; document chase
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.
2026-05-13 00:16:11 -04:00
48ca16bc5e feat(winui3): ThemeManager service + Settings drawer + Help/About/Onboarding
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).
2026-05-13 00:13:58 -04:00
db341f9446 build(winui3): pin RID + flatten native DLLs into output dir
Locks RuntimeIdentifier=win-x64 so MSBuild flattens the WindowsAppSDK
native runtime files (Microsoft.WindowsAppRuntime.Bootstrap.dll +
WebView2Loader.dll, both in runtimes/win-x64/native/) directly alongside
TeamsISO.exe at build time, instead of leaving them in a runtimes/
subfolder where the loader can't find them at activation.

Also defers app.manifest from build pending the bootstrapper hardening
follow-up. WinUI 3 emits its own manifest with the DPI awareness +
supportedOS GUIDs that match what we want; reintroducing ours alongside
that needs a uap:VisualElements merge.

Build is clean. Activation still blocked on a separate WinUI 3
unpackaged-launch issue that doesn't reach Main(); diagnostics
captured in COREHOST_TRACE=1 confirm .NET host loads correctly through
CoreCLR.dll, the failure is downstream in the WinUI / WindowsAppSDK
activation path.

The WPF host remains the running build until the activation issue is
resolved.
2026-05-13 00:07:05 -04:00
9e176d8f10 feat(winui3): redesigned MainWindow + custom title bar + theme toggle
Lands the approved shape brief as the WinUI 3 MainWindow:

* 64px left rail with brand mark, primary nav (participants), Teams
  launch / hide / settings buttons, and the engine-status puck at the
  bottom. All five rail buttons use Segoe Fluent Icons glyphs at a
  uniform 20px optical size; no more bespoke <Path Data> shapes with
  inconsistent stroke weights.

* 44px custom title bar via ExtendsContentIntoTitleBar +
  SetTitleBar(AppTitleBar). The drag region absorbs the three live-state
  pills inline (session timer 'live * 00:14:32', REC count + elapsed,
  disk free) and a slim sun/moon theme-toggle button to the left of the
  system Min/Max/Close controls. System buttons inherit ButtonForeground
  Color etc. from AppWindow.TitleBar so they match palette in both
  themes.

* Section header with 'Participants * count' display, filter input,
  Refresh + Presets (Secondary buttons), and 'Enable all online' as
  the single cyan Primary button - finally a real button hierarchy
  instead of seven indistinguishable ghost buttons.

* Participants list rendered as ItemsRepeater + DataTemplate for now;
  the CommunityToolkit DataGrid migration follows in a separate commit.
  Row template at 64px height with: 3px cyan left border for active
  speaker, avatar with initials in cyan-muted circle, name + codec line,
  signal lock state with dot, audio meter via ProgressBar, output name
  in JetBrains Mono, ISO state pill (LIVE/OFF/ERROR) at right.

* Conditional in-call control bar below the table: Mute / Camera /
  Share / Marker / Leave + overflow kebab. Muted state binds the
  destructive coral treatment to the Mute button; Leave is also
  destructive (coral border + text); everything else is Secondary.
  Tight 8px spacing keeps the bar dense without crowding.

* Slim 32px status bar at the bottom: control-surface URL on the left
  (cyan dot indicator), keyboard-shortcut hints on the right in
  tertiary mono. Replaces the WPF host's six-column footer.

Implementation notes:

* MockParticipant model populates the table with representative data
  (Maya / Daniel / Aicha / Sam, one as active speaker) until the
  ParticipantViewModel binding migrates over from the WPF host.

* Custom Program.cs takes ownership of Main from the XAML compiler
  (DISABLE_XAML_GENERATED_MAIN). Calls Bootstrap.TryInitialize(0x00010006)
  before Application.Start so the unpackaged .exe can locate the
  WindowsAppSDK 1.6 framework MSIX at launch. Shutdown is paired in
  a finally block.

* Theme toggle in code-behind flips Window.Content.RequestedTheme
  between Dark and Light. {ThemeResource} bindings auto-swap across
  the visual tree; system title-bar buttons (outside the XAML tree)
  get color updates inline so they stay readable in both modes.

* app.manifest deferred from build - the framework-emitted manifest
  covers DPI awareness and supportedOS GUIDs; reintroducing our own
  goes in the next commit alongside the bootstrapper hardening.

Known issue: the unpackaged .exe currently fails to activate on this
build host with 'this application could not be started' before Main
runs. Build is clean; published output runs the same way. Diagnosing
the activation failure is the next session's first task (likely the
runtimeconfig.json including Microsoft.WindowsDesktop.App which WinUI 3
doesn't want, or a missing CRT redistributable). The WPF host remains
the running build until that's resolved.

dotnet build TeamsISO.Windows.slnf -c Debug: 0 warnings, 0 errors.
2026-05-13 00:03:12 -04:00
cb1402ec8d feat(winui3): scaffold TeamsISO.App.WinUI alongside the WPF host
First step of the WinUI 3 replatform per the approved redesign brief.
The new project coexists with the existing src/TeamsISO.App (WPF) so the
WPF host keeps building and shipping while the WinUI 3 redesign lands
incrementally. Once the WinUI 3 build is feature-complete and tested
against a real Teams meeting, the WPF project is retired.

Scaffold contents:

* src/TeamsISO.App.WinUI/TeamsISO.App.WinUI.csproj
  Windows App SDK 1.6 LTS (250602001), unpackaged mode
  (WindowsPackageType=None) so the existing MSI installer keeps working.
  Target framework net8.0-windows10.0.19041.0, min platform 10.0.17763.0
  to preserve Win10 1809+ compatibility for working broadcast hardware.
  Pins WindowsSdkPackageVersion=10.0.19041.38 so .NET SDK 8.0.301 builds
  cleanly without an SDK upgrade on the build host.

* src/TeamsISO.App.WinUI/app.manifest
  PerMonitorV2 DPI awareness + gdiScaling for crisp text on high-DPI
  broadcast monitors. asInvoker trust level (control surface :9755 and
  OSC :9000 bind to 127.0.0.1, no admin needed).

* App.xaml + App.xaml.cs
  Minimal startup: brings up MainWindow. The full pipeline (NDI runtime
  preflight, IsoController wiring, single-instance mutex, REST + OSC
  bridge, tray icon, crash diagnostics, auto-update banner, onboarding)
  migrates in subsequent commits.

* Themes/Tokens.xaml
  Wild Dragon design tokens as ThemeDictionary entries (Default = Dark,
  Light). Colors as Color resources, Brushes paired per theme so
  {ThemeResource} auto-swaps when RequestedTheme flips — no app restart,
  no flicker. Spacing/radii/typography tokens are theme-agnostic at the
  outer level. Light palette maintains brand recognition via cyan-tinted
  off-whites (#FAFAFB canvas, #F0F1F3 rail) rather than pure white, and
  splits cyan into accent.cyan.surface (#97EDF0, works in both modes
  because text on top is near-black) and accent.cyan.text (#97EDF0 dark
  / #0E7C82 light) so captions and inline labels keep AA contrast.

* Themes/Controls.xaml
  Button hierarchy with real commitments: Primary (cyan fill, one per
  surface), Secondary (transparent bordered), Tertiary (text only),
  Destructive (coral border + text), Caption (titlebar), RailIcon.
  Typographic ramp (Display / Title / Heading / Body / Subtle / Caption
  / Mono) at the DESIGN.md 1.25 ratio.

* CommunityToolkit.WinUI.UI.Controls.DataGrid 7.1.2 referenced for the
  participants table migration. (Toolkit 8.x dropped DataGrid; 7.x is
  the only currently-maintained free option for WinUI 3.)

* Inter.ttf + JetBrainsMono.ttf + dragon-mark.png + teamsiso.ico copied
  from the WPF project's Assets/ so the WinUI 3 host is self-contained.

* TeamsISO.sln + TeamsISO.Windows.slnf updated to include the new
  project. The .slnf paths switch to backslash form so MSBuild can match
  them against the .sln's canonical path representation.

Verified: dotnet build TeamsISO.Windows.slnf -c Debug succeeds with 0
warnings and 0 errors for all 8 projects (WPF host, WinUI 3 host, engine,
NDI interop, console, three test projects).
2026-05-12 23:52:35 -04:00
b0029a51bf Highlight active speaker row with cyan left border
Some checks failed
CI / build-and-test (push) Has been cancelled
Visual cue for who's currently speaking — operators don't need to watch every VU bar. MainViewModel.OnStatsTick scans enabled participants once per tick, picks the loudest above a 0.05 floor (anti-flicker threshold), sets IsActiveSpeaker on the winner and clears on everyone else. DataGridRow DataTrigger swaps in a 3px cyan-accent left border + CyanMuted background tint when IsActiveSpeaker is true.

Plays well with sort modes: LoudestFirst makes the highlighted row always the topmost; other sort modes leave the row position alone, just paints the indicator.
2026-05-10 21:28:09 -04:00