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
This commit is contained in:
Zac Gaetano 2026-05-14 12:46:24 -04:00
parent 1d1ce6a2a0
commit c27130302f
14 changed files with 1606 additions and 1656 deletions

View file

@ -1,7 +1,10 @@
# DESIGN.md — TeamsISO design system
Target framework: **WinUI 3 (Windows App SDK)**. Tokens are framework-agnostic;
the WinUI XAML implementation lives in `src/TeamsISO.App/Themes/` (post-port).
Target framework: **WPF .NET 8**. Tokens are framework-agnostic; the WPF
XAML implementation lives in `src/TeamsISO.App/Themes/`. (A WinUI 3 rebuild
was attempted and rolled back — see
`docs/shapes/2026-05-13-teamsiso-v2-studio-terminal.md` for the v2 shape
this design serves.)
## Color
@ -106,39 +109,49 @@ The toggle is also surfaced inside the settings drawer under an "Appearance"
group as a tri-state pill (System / Dark / Light), so power users find it in
the obvious place too.
### Implementation (WinUI 3)
### Implementation (WPF)
WinUI 3's `ThemeDictionary` pattern handles automatic swapping based on
`FrameworkElement.RequestedTheme`. Tokens live as `Color` resources keyed by
theme, then `SolidColorBrush` references them via `{ThemeResource}`:
WPF doesn't have WinUI 3's `ThemeDictionary` pattern. The equivalent is to
**split tokens by theme into separate ResourceDictionary files**, all
addressed via `DynamicResource` (NOT `StaticResource`) so the values can
be swapped at runtime.
```xml
<ResourceDictionary>
<ResourceDictionary.ThemeDictionaries>
<ResourceDictionary x:Key="Dark">
<Color x:Key="BgCanvasColor">#0A0A0A</Color>
<SolidColorBrush x:Key="BgCanvas" Color="{ThemeResource BgCanvasColor}"/>
</ResourceDictionary>
<ResourceDictionary x:Key="Light">
<Color x:Key="BgCanvasColor">#FAFAFB</Color>
<SolidColorBrush x:Key="BgCanvas" Color="{ThemeResource BgCanvasColor}"/>
</ResourceDictionary>
</ResourceDictionary.ThemeDictionaries>
</ResourceDictionary>
```
Themes/
Theme.Tokens.xaml ← styles, control templates, key shape (no colors)
Theme.Dark.xaml ← color resources only — Dark variant
Theme.Light.xaml ← color resources only — Light variant
```
At runtime, the root `Page.RequestedTheme = ElementTheme.Dark | Light`
switch propagates down the visual tree instantly — no app restart, no
flicker. The custom title bar (drawn via `AppWindow.TitleBar.ExtendsContent`)
gets a manual color update on the same code path since its system buttons
aren't part of the XAML tree.
`Theme.Dark.xaml` and `Theme.Light.xaml` define the SAME set of keys —
`Wd.Bg.Canvas`, `Wd.Accent.Cyan`, etc. — with different `Color` values.
`Theme.Tokens.xaml` references them via `DynamicResource` from styles and
templates. At startup, `App.xaml` merges `Theme.Tokens.xaml` plus exactly
one of `Theme.Dark.xaml` or `Theme.Light.xaml`. At runtime, `ThemeManager`
swaps the merged dictionary's color file:
```csharp
var app = Application.Current;
var oldDict = app.Resources.MergedDictionaries
.First(d => d.Source?.OriginalString.EndsWith("Theme.Dark.xaml") == true
|| d.Source?.OriginalString.EndsWith("Theme.Light.xaml") == true);
var idx = app.Resources.MergedDictionaries.IndexOf(oldDict);
app.Resources.MergedDictionaries[idx] = new ResourceDictionary {
Source = new Uri($"/Themes/Theme.{newTheme}.xaml", UriKind.Relative)
};
```
`DynamicResource`-backed `SolidColorBrush` instances re-resolve on the
dictionary swap, so the visual tree repaints without an app restart.
### System mode
When `UIPreferences.Theme == System`, the app reads
`Application.Current.RequestedTheme` at startup and re-reads when the OS
theme changes (via `UISettings.ColorValuesChanged`). This is the default —
operators who don't care get whatever their Windows session is set to.
When `UIPreferences.Theme == "System"`, `ThemeManager` reads
`HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Themes\Personalize\AppsUseLightTheme`
at startup. It also subscribes to `SystemEvents.UserPreferenceChanged` so
the app re-resolves the theme when the operator flips Windows app-mode
mid-session. This is the default — operators who don't care get whatever
their Windows session is set to.
## Typography
@ -157,11 +170,12 @@ operators who don't care get whatever their Windows session is set to.
Body text caps at 6575ch where it wraps. Inline status text doesn't wrap —
it truncates with ellipsis.
### Fonts in WinUI 3
### Fonts in WPF
WPF's `pack://application:,,,/Assets/Fonts/#Inter` resource URI doesn't carry
over. WinUI 3 uses `ms-appx:///Assets/Fonts/Inter.ttf#Inter`. Migration is
mechanical but required.
Bundled fonts ship in `src/TeamsISO.App/Assets/Fonts/` and resolve via
`pack://application:,,,/Assets/Fonts/#Inter` / `#JetBrains Mono`. The
`<Resource>` glob in `TeamsISO.App.csproj` already covers the `.ttf` files;
new font weights go in the same directory and pick up automatically.
## Spacing (8px grid)

89
NEXT_STEPS.md Normal file
View file

@ -0,0 +1,89 @@
# Where we left off — v2 "Studio Terminal" shell landed (2026-05-13 night)
## What's done (uncommitted on local main)
**v2 redesign shape:** Approved brief at `docs/shapes/2026-05-13-teamsiso-v2-studio-terminal.md`. Aesthetic register is "broadcast-engineering instrument" — Linear's keyboard-first density × Avid console legibility. Goes hard against the "screams AI" failure mode.
**PRODUCT.md + DESIGN.md** updated to reflect WPF as the host (WinUI 3 lines removed), v2 IA decisions absorbed, recording references softened, theme implementation rewritten for WPF DynamicResource swap.
**Theme system split:**
- `src/TeamsISO.App/Themes/Theme.Dark.xaml` — color brushes only, dark variant
- `src/TeamsISO.App/Themes/Theme.Light.xaml` — color brushes only, light variant
- `src/TeamsISO.App/Themes/WildDragonTheme.xaml` — styles + control templates (no color brushes anymore; uses `DynamicResource` for every brush ref)
- `src/TeamsISO.App/Services/ThemeManager.cs` — singleton that swaps the merged dictionary at runtime, reads `HKCU\...\AppsUseLightTheme` for System mode, subscribes to `SystemEvents.UserPreferenceChanged`, persists via `UIPreferences.Theme`.
- `App.xaml` merges Theme.Dark.xaml + WildDragonTheme.xaml by default; `App.xaml.cs.OnStartup` calls `ThemeManager.Current.Apply()` before MainWindow shows.
- `UIPreferences.Prefs` record gets a new `Theme = "System"` field (forward-compatible — old json files still load).
- New brush key: `Wd.Accent.CyanText` (Dark `#97EDF0` / Light `#0E7C82`) for cyan-as-text contrast on light canvas. The existing `Wd.Accent.Cyan` stays bright in both modes for fill use.
**v2 main window shell:**
- Default Windows title bar (no more custom chromeless caption buttons — they looked generic and broke on DPI scaling).
- 32px header: Wild Dragon mark + "TeamsISO" wordmark left; three icon buttons right (⌘K command palette, theme toggle, settings gear). The mark is small (20px) as a quality cue — click opens About.
- 40px transport strip: mono-typed single line. `● 02:14:32 PART 4 · LIVE 2 CTRL :9755`. Cyan dot + timer only when at least one ISO live. CTRL cluster right-aligned in a Grid column.
- Body: alert banner + update banner + action toolbar (Enable / Refresh / Presets / Stop all + Teams launch/hide icons) + participants DataGrid.
- Conditional meeting bar at bottom — appears only when `IsTeamsInCall == true`, with Mute / Cam / Leave.
- 72px left rail: **gone**.
- 380px permanent settings panel: **gone**.
- Six-column footer: **gone**.
- Settings: slide-over drawer overlay (420px from right) triggered by the header gear; OUTPUT / NETWORK / APP tabs (DISPLAY renamed to APP); same bindings as v1. Scrim click dismisses; Esc dismisses.
**Hotkeys preserved + new:**
- F1 help, Ctrl+R refresh, Ctrl+Shift+S panic stop, 19 / NumPad 19 toggle Nth participant — all preserved.
- **Ctrl+T toggle theme (NEW)** — cycles dark ↔ light. Hooked through `MainViewModel.ToggleThemeCommand``ThemeManager.Toggle()`.
- **Ctrl+K command palette (placeholder)** — currently opens the help dialog. Task 40 replaces this with a real fuzzy-search palette window.
**View-model additions (MainViewModel):**
- `ParticipantCount` and `LiveCount` — feed the transport strip's "PART N · LIVE N" readout. Updated on the 1Hz stats tick.
- `ToggleThemeCommand` — wraps `ThemeManager.Current.Toggle()`.
**MainWindow code-behind cleanup:**
- Removed `OnMinimize`, `OnMaximizeRestore`, `OnClose`, `OnWindowStateChanged`, the maximize-icon swap logic (no more custom title bar).
- Added `OnCommandPaletteClick`, `OnSettingsScrimClick`, `OnPreviewKeyDown` (Esc to close drawer).
- `OnSettingsToggleClick` now toggles `SettingsDrawerOverlay.Visibility` (the slide-over) instead of toggling the v1 right-column width.
## To build, push, and demo
```powershell
cd "C:\Users\zacga\Documents\Claude\Projects\Teams ISO"
# Clear the corrupt local index from the earlier session
Remove-Item .git\index -Force -ErrorAction SilentlyContinue
git reset
# Build the WPF host
dotnet build src\TeamsISO.App\TeamsISO.App.csproj -c Release
# If the build is clean:
.\src\TeamsISO.App\bin\Release\net8.0-windows\TeamsISO.exe
```
In the running app, test:
1. **Theme toggle** — press `Ctrl+T`. Should swap dark ↔ light without restart. Header colors, surfaces, and text foregrounds all repaint.
2. **System theme follow** — open Windows Settings → Personalization → Colors → "Choose your mode" → flip between Dark and Light. TeamsISO should track the OS automatically (default preference is `System`).
3. **Settings drawer** — click the header gear icon. 420px drawer slides in from the right with OUTPUT / NETWORK / APP tabs. Esc or click-scrim dismisses.
4. **Transport strip** — should show the session timer when at least one ISO goes live, and the PART/LIVE counts always.
5. **Conditional meeting bar** — only appears when Teams is in a call.
If anything regresses, the v1 shell is preserved in git history at `1d1ce6a` — easy rollback with `git reset --hard 1d1ce6a` then publish.
## Commit + push when ready
```powershell
git add -A src/TeamsISO.App/ docs/ PRODUCT.md DESIGN.md
git commit -m "feat(wpf): v2 'Studio Terminal' shell — theme system, header, transport strip, drawer
- 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"
git push origin HEAD
```
## What's still queued (tasks 39 + 40)
- **Task 39** — Participants table redesign. The DataGrid columns in the v2 shell are still v1-style (Name / Source / ISO toggle). v2 wants: 5 columns (state LED, name+codec, audio meter, output name mono, ISO pill), 52px rows, full-row active-speaker tint instead of left stripe, hard-edged 8px state LED.
- **Task 40** — Real Ctrl+K command palette window. Floating 560×360 dialog, fuzzy search across categories (Quick / Teams / Presets / Output / Network / App). Replaces the current placeholder that opens the help dialog.
Both are scoped commits and depend on the v2 shell being confirmed working first. After this lands and you've verified the theme swap + drawer + transport strip render correctly, ping me and we'll knock those out.

View file

@ -15,14 +15,15 @@ OBS, Resolve, Ross, hardware capture), and does three things:
1. **Routes** each guest as a clean, individually-addressable, normalized NDI
source (consistent framerate, resolution, aspect, audio routing — regardless
of what each participant's webcam is doing).
2. **Records** every active ISO to disk simultaneously — raw BGRA + manifest
+ ffmpeg convert script — so post-production gets a per-guest archive
ready to cut from.
3. **Orchestrates Teams itself** — launch/hide Teams windows, drive in-call
2. **Orchestrates Teams itself** — launch/hide Teams windows, drive in-call
controls (mute, camera, share, leave, raise hand, quick-join) via
UIAutomation, so the operator never has to alt-tab away from the routing
table while the show is live.
(Recording — previously the second pillar — was removed in the WPF rollback
on 2026-05-13. The engine plumbing is intact for a future re-introduction,
but no UI surface, view-model command, REST route, or OSC route exposes it.)
External control surface (REST + WebSocket + OSC on localhost) lets a
Companion / Stream Deck / TouchOSC controller drive routing remotely.
@ -113,9 +114,11 @@ behind purposeful entry points.
### 4. At-a-glance status is sacred.
Recording state, disk health, control-surface reachability, session timer,
NDI signal-per-participant — these are the operator's situational awareness.
They must be readable in peripheral vision, in one place, without scanning.
Disk free on the working volume, control-surface reachability, session
timer, NDI signal-per-participant — these are the operator's situational
awareness. They must be readable in peripheral vision, in one place,
without scanning. (Recording state was a historical fifth field; it's
removed.)
### 5. Confident neutrality over decorative warmth.
@ -148,11 +151,15 @@ read as AI-generated. Concretely, this means none of:
## Technical constraints (informing design)
- Windows-only (Teams' NDI is Windows-only anyway).
- **WinUI 3 / Windows App SDK** is the new frontend target.
- **WPF .NET 8** is the supported frontend host. (A WinUI 3 rebuild was
attempted in May 2026; it proved fragile — XAML parser crashes on
DataTemplate, theme-glyph rendering issues — and was abandoned. The
rollback commit `1d1ce6a` is the canonical baseline.)
- Engine layer (.NET 8) is preserved verbatim — view-model surface is the
swap boundary.
- Bundled fonts via WinUI 3's `FontFamily` packaging (the WPF
`pack://...#Inter` resource URI doesn't translate; needs migration).
- Fonts are bundled via WPF's `pack://application:,,,/Assets/Fonts/#Inter`
resource URI so the operator's machine doesn't have to have Inter or
JetBrains Mono installed.
- MSIX-signed installer is on the v1.0 path; the new shell needs to package
cleanly through that pipeline.
- The external control surface (REST/WebSocket on `:9755`, OSC on `:9000`)

View file

@ -0,0 +1,194 @@
# TeamsISO v2 — Studio Terminal (approved shape brief)
**Date approved:** 2026-05-13
**Approver:** Zac (operator + product owner)
**Host:** WPF .NET 8 (`src/TeamsISO.App/`). WinUI 3 rebuild is abandoned.
**Predecessor:** the WPF rollback at `1d1ce6a` (recording axed, settings pane tab fix, settings button wired).
## Why this redesign
The v1 GUI failed the "AI made that" test. Quote from the operator: "its cluttered, screams that AI made it - and relatively inefficient to navigate." The PRODUCT.md anti-references — card-grid-of-icons, always-visible side panel, footer-as-theatre — all describe the current build. v2 commits to a different aesthetic register entirely.
## Aesthetic register
**Broadcast-engineering instrument.** Not a SaaS dashboard. Not Material. Not Fluent default.
Reference proximity: Linear's keyboard-first density × Avid S6 console legibility × Blackmagic ATEM's information hierarchy. The operator mental model is "I'm sitting at an audio mixer; every region has a job, no region is theatre."
## What goes away
- The 72px left rail (no actual navigation — there's only one screen)
- The 380px always-visible settings pane (settings change rarely, shouldn't claim permanent real estate)
- The 6-column footer status row (theatre, not information)
- The custom chromeless title-bar caption buttons (look worse than system chrome, break on DPI scaling)
- The "by Wild Dragon" pill and the always-visible "TeamsISO" wordmark as decorative chrome
- The in-call control bar as a permanent strip (only relevant in-call; should appear conditionally)
- The seven identical ghost buttons in the in-call bar (textbook card-grid anti-pattern)
## What replaces it
```
┌─ system Windows title bar [_ □ ✕] ─────────────────────┐
│ 🐉 TeamsISO [⌘K] [☾] [⚙] │ 32px header — mark + wordmark left, 3 icons right
├────────────────────────────────────────────────────────┤
│ ● 02:14:32 PART 4 · LIVE 2 DISK 482g CTRL :9755 │ transport strip — single mono line, replaces footer
├────────────────────────────────────────────────────────┤
│ │
│ ▮ alice ▮▮▮▮ t:5ms alice [LIVE] │
│ ▯ bob ▮▮ t:8ms bob [— OFF]│ participants table = the canvas
│ ▮ carlos ▮▮▮▮▮ t:9ms carlos [LIVE] │ (cyan-tinted row bg = active speaker)
│ ▮ guest 4 -- NO SIG guest_4 [ERROR]│
│ │
├────────────────────────────────────────────────────────┤
│ IN CALL · Daily standup [mute] [cam] [leave] │ conditional — only renders when in call
└────────────────────────────────────────────────────────┘
```
### Header (32px)
Left: Wild Dragon mark (~20px) + "TeamsISO" wordmark in Inter 13 Medium. Click on mark opens About.
Right: three icon buttons.
- `⌘K` (Tabler `ti-command`) — opens command palette (also Ctrl+K, Ctrl+P shortcut)
- `☾` / `☀` (Tabler `ti-moon` / `ti-sun`) — cycles theme dark ↔ light. Tooltip "Theme (System / Dark / Light)" — long-press could open the tri-state, but for v2 just a one-click cycle.
- `⚙` (Tabler `ti-settings`) — opens settings drawer
That's all the chrome. No nav rail because there's nothing to navigate to.
### Transport strip
Single horizontal line. Mono type (JetBrains Mono 12). Replaces the entire footer.
Fields:
- `● 02:14:32` — green dot + session timer when at least one ISO is live; both hidden otherwise
- `PART 4 · LIVE 2` — participant count and live-ISO count; "PART" / "LIVE" in Inter 11 SemiBold UPPER tracking 0.06em, numbers in mono
- `DISK 482g` — free disk space on the working volume; coral text if <10GB, hidden if no relevant volume is configured
- `CTRL :9755` — control surface bind; cyan text when active, hidden when off
No icons. No badges. No backgrounds. Just typed status — a console heads-up display.
### Participants table — the canvas
Five columns:
| # | Width | Content | Type |
|---|---|---|---|
| 1 | 24px | State LED — 8×8 filled cyan/coral or hollow neutral | hard-edged square, no rounding |
| 2 | * | Name (Inter 13/Medium) + codec/latency caption (Mono 11/Regular, tertiary fg) | "Alice Wong" / "NDIV5 · t:5ms" |
| 3 | 110px | Audio meter — 5 vertical bars, instantaneous level | hard-edged, cyan when LIVE, neutral when OFF |
| 4 | 130px | Output name | Mono 12 |
| 5 | 100px | ISO toggle pill | LIVE = cyan fill / OFF = hollow neutral / ERROR = coral outline |
Row height: 52px (was 56).
Active speaker: full-row background tint `bg.active-speaker` (cyan-tinted muted neutral). NOT a left-edge stripe — that trips the impeccable "side-stripe border" ban.
Each row reacts to:
- Click anywhere → focuses the row, keyboard-actions apply
- Click the pill → toggle ISO
- Right-click → context menu (preview, custom name, copy NDI source name, save snapshot)
- Hover → reveals a kebab affordance in column 5 right edge for less-frequent actions
### Conditional meeting bar
Renders below the table only when `TeamsControlBridge.DetectCallState().IsInCall == true`. Slides up from below on transition (~120ms ease-out-quart on `RenderTransform.Y` + `Opacity`).
Content: `IN CALL` label (Inter 11 SemiBold UPPER, cyan accent) + meeting title (Mono 12, truncated with ellipsis) + three buttons right-aligned (Mute / Cam / Leave). Share and Notes do NOT live here — they move to ⌘K, where they're invocable any time without the bar fighting for attention.
Width matches the table — not full-bleed; respects the page padding.
### Ctrl+K command palette
The redesign's navigation move. Replaces ~80% of what's in the v1 rail + tabbed settings.
Behavior:
- `Ctrl+K` (also `Ctrl+P`) opens a centered floating window over the main shell, 560×360px
- Search input at the top, results list below
- Empty input → frequent + recent commands
- Typing → fuzzy-matches across command label + category + keywords
- ↑/↓ navigates, Enter invokes, Esc closes
Command categories (each command has icon, label, optional value preview, optional shortcut hint):
- **Quick** — Enable all online, Stop all ISOs, Refresh discovery, Drop snapshot of all
- **Teams** — Launch Teams, Hide / show Teams windows, Mute, Toggle camera, Open share, Leave call
- **Presets** — Apply <preset name>… (one row per saved preset), Save current as preset, Manage presets
- **Output** — Framerate 24 / 30 / 60, Resolution 1080p / 720p, Aspect Pillarbox / Letterbox / Stretch
- **Network** — Apply transcoder topology, Restore default NDI groups, Edit output name template
- **App** — Theme dark / light / system, Open settings, About TeamsISO, Help (F1)
This is the keyboard-first surface broadcasters with Stream Decks already mentally use.
### Settings — slide-over drawer
Triggered from the header gear icon, or from `Open settings` in the palette, or hotkey `,` (comma).
- 420px wide, slides in from the right
- 40% canvas scrim behind
- Three tabs: **OUTPUT** (framerate / resolution / aspect / audio + Reset to defaults), **NETWORK** (discovery / output groups + Apply transcoder topology + Restore defaults + output name template), **APP** (theme tri-state, minimize to tray, sort order, Launch Teams on startup, Auto-hide Teams windows)
- Apply Changes button pinned to drawer footer; Esc dismisses; click outside the drawer dismisses
DISPLAY tab from v1 gets renamed APP and absorbs the theme tri-state.
### Empty states
- No participants yet: a single centered mono sentence, "no ndi sources yet — open teams and start a meeting", and one tertiary button "Refresh discovery (Ctrl+R)". No illustration, no mascot.
- Not in a call: meeting bar simply doesn't render. No placeholder.
- Discovery degraded: amber dot in transport strip's session timer position, mono text "NDI discovery — restarting". No banner.
## Color, theme, motion
**Color strategy:** Restrained (impeccable product default). Cyan accent earns its place — reserved for LIVE state, focus ring, active speaker tint. Coral reserved for destructive + error. Status amber for warnings. Green NOT used (would compete with cyan for "ok / live" semantics).
**Theme default:** Follow Windows. Theme persists per-operator via `UIPreferences.Theme`. Implementation: split `WildDragonTheme.xaml` into a single style + token-shape file plus two color-only ResourceDictionary files (`Theme.Dark.xaml`, `Theme.Light.xaml`). At runtime `ThemeManager` swaps the merged dictionary entry. WPF analog of WinUI's `ThemeDictionary`.
**Motion:**
- 120ms `cubic-bezier(0.16, 1, 0.3, 1)` (ease-out-quart) on the meeting bar slide-in/out
- 200ms ease-out on the drawer slide
- 180ms cross-fade on theme swap
- 90ms on focus + hover transitions
- No bounce, no elastic, no spring overshoots. Animate `RenderTransform` and `Opacity` only — never layout properties.
## Typography commitments
| Token | Family | Size | Weight | Used for |
|---|---|---|---|---|
| `text.timer` | JetBrains Mono | 14 | Medium | Session timer in transport strip — instrument-grade |
| `text.caption` | Inter | 11 | SemiBold (600) | UPPER + tracking 0.06em — transport-strip labels, "IN CALL", "SPEAKING" |
| `text.display` | Inter | 22 | SemiBold | Settings drawer headings only |
| `text.title` | Inter | 13 | Medium | Wordmark, table column headers |
| `text.body` | Inter | 13 | Regular | Participant display names |
| `text.mono.code` | JetBrains Mono | 12 | Regular | Output names, NDI IDs, meeting title |
| `text.mono.tech` | JetBrains Mono | 11 | Regular | Latency readouts, codec captions, transport-strip values |
## What this is NOT
- Not Fluent-styled. Default Fluent accent integration is generic Windows; TeamsISO is a broadcaster's tool.
- Not minimalism for its own sake. The participants table is *dense*. Density is the broadcaster's virtue.
- Not chromeless. Default system title bar stays. Chromeless windows break embarrassingly at 4K + DPI scaling.
- Not vanity-branded. The Wild Dragon mark sits small in the header as a quality cue, never as decoration.
## Migration path
The view-model surface in `src/TeamsISO.App/ViewModels/` is the contract. The redesign rewrites `MainWindow.xaml` and `Themes/*` but leaves view-models, the engine, the control surface server, and the OSC bridge untouched.
Order of operations (each step builds clean before the next):
1. **Theme split** — Refactor `WildDragonTheme.xaml``Themes/Theme.Tokens.xaml` (styles + key shape) + `Themes/Theme.Dark.xaml` + `Themes/Theme.Light.xaml` (color resources only). Port `ThemeManager` from the deleted WinUI project; wire system app-mode detection via registry (`HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Themes\Personalize\AppsUseLightTheme`).
2. **Main window shell** — Replace MainWindow.xaml's outer Grid. Add 32px header, transport strip, full-width content area, conditional meeting bar. Delete the 72px rail, the 380px right pane, the footer.
3. **Participants table redesign** — 5 columns, LED state, instantaneous audio meter, ISO pill.
4. **Settings drawer** — Slide-over from right, dismissable; reuses existing settings view-model.
5. **Command palette**`Ctrl+K` floating window with fuzzy command list.
Each step is a self-contained commit so the v1 build remains shippable at any rollback point.
## Anti-references — explicit on the "AI made that" failure
These are the failure modes the redesign defends against:
- Card-grid-of-icons (the v1 in-call bar's seven identical ghost buttons)
- Always-visible side panel (the v1 380px settings sidebar)
- Decorative chrome (the v1 "by Wild Dragon" pill, the 72px nav rail, the six-column footer)
- Generic Inter at 13 for everything
- Default WPF DataGrid (Excel)
- Custom chromeless title bars that look generic
- Gradient text, glassmorphism, side-stripe borders (impeccable absolute bans)
- "Hero metric + supporting stats + gradient" SaaS dashboards
- Mascots, "Welcome!" copy, illustrated onboarding cards

View file

@ -4,6 +4,18 @@
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<!--
Theme color brushes — DARK by default. ThemeManager.Apply()
swaps this entry to Theme.Light.xaml at runtime; the brushes
are referenced via DynamicResource from WildDragonTheme.xaml
so the visual tree re-resolves without an app restart.
The DEFAULT here is dark so the app boots into a
deterministic state before ThemeManager runs on startup;
if the operator's preference is Light or system app-mode
is Light, the dictionary swap happens before MainWindow
is shown so there's no visible flash.
-->
<ResourceDictionary Source="/Themes/Theme.Dark.xaml"/>
<ResourceDictionary Source="/Themes/WildDragonTheme.xaml"/>
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>

View file

@ -76,6 +76,12 @@ public partial class App : Application
DispatcherUnhandledException += OnDispatcherUnhandled;
System.Threading.Tasks.TaskScheduler.UnobservedTaskException += OnUnobservedTaskException;
// Resolve and apply the theme BEFORE any window is shown so we don't
// paint a dark frame for one tick then flip to light (or vice versa).
// ThemeManager.Apply swaps Application.Resources.MergedDictionaries
// in place; DynamicResource refs in WildDragonTheme.xaml re-bind.
TeamsISO.App.Services.ThemeManager.Current.Apply();
// Single-instance gate: if another TeamsISO is already running for this user,
// broadcast the bring-to-front message and exit silently. This prevents the
// NDI/config contention seen during testing where two finders, two senders

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,7 @@
using System;
using System.Linq;
using System.Windows;
using System.Windows.Input;
using System.Windows.Shapes;
using TeamsISO.App.Services;
using TeamsISO.App.ViewModels;
@ -12,9 +12,12 @@ public partial class MainWindow : Window
public MainWindow()
{
InitializeComponent();
StateChanged += OnWindowStateChanged;
SourceInitialized += OnSourceInitialized;
Closing += OnClosing;
// Esc dismisses the settings drawer when it's open. Bound at the
// window level so any focused control inside the drawer also gets
// the affordance.
PreviewKeyDown += OnPreviewKeyDown;
}
public MainWindow(MainViewModel viewModel) : this()
@ -38,32 +41,6 @@ public partial class MainWindow : Window
WindowStateStore.Save(this);
}
/// <summary>Custom min button — chrome'd window has no system caption buttons.</summary>
private void OnMinimize(object sender, RoutedEventArgs e) =>
WindowState = WindowState.Minimized;
/// <summary>Toggles maximize/restore. Bound to the maximize button + double-click on the drag region.</summary>
private void OnMaximizeRestore(object sender, RoutedEventArgs e) =>
WindowState = WindowState == WindowState.Maximized ? WindowState.Normal : WindowState.Maximized;
/// <summary>Custom close button.</summary>
private void OnClose(object sender, RoutedEventArgs e) => Close();
/// <summary>
/// Toggle the right-hand settings pane. Collapses the 380px column to 0
/// so the participants list claims the full content width, then restores
/// it on the next click. The pane's children stay loaded — we move the
/// grid column, not the panel's Visibility — so scroll position and any
/// in-flight edits survive the toggle.
/// </summary>
private void OnSettingsToggleClick(object sender, RoutedEventArgs e)
{
if (SettingsColumn is null) return;
SettingsColumn.Width = SettingsColumn.Width.Value > 0
? new System.Windows.GridLength(0)
: new System.Windows.GridLength(380);
}
/// <summary>Opens the About dialog — version, NDI runtime, build SHA.</summary>
private void OnAboutClick(object sender, RoutedEventArgs e)
{
@ -131,14 +108,9 @@ public partial class MainWindow : Window
/// <summary>
/// Three-state click behavior matching operator intuition:
/// 1. Teams not running → launch it via TeamsLauncher's fallback chain.
/// 2. Teams running but its windows are hidden (we toggled them off, OR
/// it launched into the tray) → restore the windows + foreground them.
/// This is the case the previous "ask to stop" dialog was ambushing —
/// operators don't think of a hidden Teams as "running and ready to
/// stop", they think of it as "I clicked Launch and nothing happened".
/// 3. Teams running with visible windows → bring the most recent one to
/// the foreground. (Stopping Teams is now a right-click action;
/// see OnLaunchTeamsRightClick.)
/// 2. Teams running but its windows are hidden → restore + foreground them.
/// 3. Teams running with visible windows → bring the most recent to front.
/// (Stopping Teams is a right-click action; see OnLaunchTeamsRightClick.)
/// </summary>
private void OnLaunchTeamsClick(object sender, RoutedEventArgs e)
{
@ -173,10 +145,6 @@ public partial class MainWindow : Window
return;
}
// Teams is running. Always try to restore + foreground its window —
// if windows are already visible, ShowWindows is a SetForegroundWindow
// no-op besides bringing them forward; if they were hidden by our
// own toggle, this is the operator's intuitive "show me Teams" path.
var shown = TeamsLauncher.ShowWindows();
_teamsWindowsHidden = false;
toast?.Show(shown > 0
@ -185,23 +153,10 @@ public partial class MainWindow : Window
}
/// <summary>
/// Right-click on the rail Launch button asks to stop Teams. Split out
/// from the left-click so a normal click is "open / surface" rather than
/// the previous "open OR ambush you with a stop dialog".
/// Right-click on the Launch button asks to stop Teams. Split out from the
/// left-click so a normal click is "open / surface" rather than the previous
/// "open OR ambush you with a stop dialog".
/// </summary>
/// <summary>
/// Open the experimental Teams embed window. Operator enables the
/// preference first; this button materializes the host. See
/// <see cref="TeamsEmbedWindow"/> for the SetParent lifecycle.
/// </summary>
private void OnOpenEmbedWindowClick(object sender, RoutedEventArgs e)
{
// Non-modal so the operator can keep using TeamsISO's controls.
// Owner = this so it minimizes / closes with TeamsISO.
var w = new TeamsEmbedWindow { Owner = this };
w.Show();
}
private void OnLaunchTeamsRightClick(object sender, MouseButtonEventArgs e)
{
if (!TeamsLauncher.IsRunning()) return;
@ -228,17 +183,65 @@ public partial class MainWindow : Window
}
/// <summary>
/// Swap the maximize-button glyph between the "single rectangle" (when normal) and the
/// "two-overlapping-rectangles" (when maximized) variants, matching the Windows 11
/// caption-button conventions.
/// Open the experimental Teams embed window. Operator enables the
/// preference first; this button materializes the host.
/// </summary>
private void OnWindowStateChanged(object? sender, EventArgs e)
private void OnOpenEmbedWindowClick(object sender, RoutedEventArgs e)
{
if (FindName("MaximizeIcon") is not Path icon) return;
icon.Data = WindowState == WindowState.Maximized
// Two-rectangle "restore" glyph
? System.Windows.Media.Geometry.Parse("M 2,0 L 10,0 L 10,8 M 0,2 L 8,2 L 8,10 L 0,10 Z")
// Single-rectangle "maximize" glyph
: System.Windows.Media.Geometry.Parse("M 0,0 L 10,0 L 10,10 L 0,10 Z");
var w = new TeamsEmbedWindow { Owner = this };
w.Show();
}
/// <summary>
/// Toggle the v2 settings drawer overlay. The header gear button and the
/// drawer's own Close button both call this. State is held by the
/// overlay's <see cref="UIElement.Visibility"/> directly — no separate
/// flag — so the toggle is idempotent regardless of how many entry
/// points open / close it.
/// </summary>
private void OnSettingsToggleClick(object sender, RoutedEventArgs e)
{
if (SettingsDrawerOverlay is null) return;
SettingsDrawerOverlay.Visibility = SettingsDrawerOverlay.Visibility == Visibility.Visible
? Visibility.Collapsed
: Visibility.Visible;
}
/// <summary>
/// Clicking the scrim behind the drawer dismisses it — same affordance as
/// every well-behaved slide-over on every platform.
/// </summary>
private void OnSettingsScrimClick(object sender, MouseButtonEventArgs e)
{
if (SettingsDrawerOverlay is null) return;
SettingsDrawerOverlay.Visibility = Visibility.Collapsed;
}
/// <summary>
/// Command palette placeholder. Task 40 builds the real Ctrl+K floating
/// window; for the v2 shell commit, the header button opens the same
/// help dialog that F1 does so the affordance isn't dead.
/// </summary>
private void OnCommandPaletteClick(object sender, RoutedEventArgs e)
{
if (DataContext is MainViewModel vm && vm.ShowHelpCommand.CanExecute(null))
{
vm.ShowHelpCommand.Execute(null);
}
}
/// <summary>
/// Esc closes the drawer when it's open. We use PreviewKeyDown rather than
/// KeyDown so the drawer's nested inputs (TextBox, ComboBox) don't swallow
/// the key before this handler sees it.
/// </summary>
private void OnPreviewKeyDown(object sender, System.Windows.Input.KeyEventArgs e)
{
if (e.Key != Key.Escape) return;
if (SettingsDrawerOverlay?.Visibility == Visibility.Visible)
{
SettingsDrawerOverlay.Visibility = Visibility.Collapsed;
e.Handled = true;
}
}
}

View file

@ -0,0 +1,194 @@
using System;
using System.Linq;
using System.Windows;
using Microsoft.Win32;
namespace TeamsISO.App.Services;
/// <summary>
/// Owns the active theme for the WPF host. Three preferences:
/// <list type="bullet">
/// <item><c>System</c> — follows the Windows app-mode setting (default for new
/// users; re-reads on <see cref="SystemEvents.UserPreferenceChanged"/>).</item>
/// <item><c>Dark</c> — pin dark regardless of OS.</item>
/// <item><c>Light</c> — pin light regardless of OS.</item>
/// </list>
/// The two color files (<c>Theme.Dark.xaml</c> + <c>Theme.Light.xaml</c>) are
/// kept in lockstep on the same set of brush keys; this manager swaps the
/// MergedDictionaries entry at runtime. Styles + control templates in
/// <c>WildDragonTheme.xaml</c> reach the brushes via <see langword="DynamicResource"/>,
/// so the visual tree re-resolves without an app restart.
///
/// Preference is persisted via <see cref="UIPreferences"/>'s <c>Theme</c> field,
/// reserved on disk during the v1 → v2 rollout so the rebuild doesn't lose the
/// operator's choice.
/// </summary>
public sealed class ThemeManager
{
public static ThemeManager Current { get; } = new();
private const string DarkUri = "/Themes/Theme.Dark.xaml";
private const string LightUri = "/Themes/Theme.Light.xaml";
private const string PreferenceKeySystem = "System";
private const string PreferenceKeyDark = "Dark";
private const string PreferenceKeyLight = "Light";
private ThemeManager()
{
// Hydrate preference from disk on first access. UIPreferences.Load()
// is best-effort — disk failures fall back to defaults so the app
// always boots into a deterministic theme.
try
{
var prefs = UIPreferences.Load();
if (IsValidPreference(prefs.Theme))
{
_preference = prefs.Theme;
}
}
catch
{
// Defensive — singleton ctor must not throw or the app loses theming.
}
// Re-evaluate when Windows app-mode flips, but only when the
// operator hasn't pinned a preference. The explicit choice wins.
SystemEvents.UserPreferenceChanged += OnSystemPreferenceChanged;
}
private string _preference = PreferenceKeySystem;
/// <summary>Current preference. One of "System", "Dark", "Light".</summary>
public string Preference => _preference;
/// <summary>Fires after a theme swap with the resolved (absolute) theme.</summary>
public event EventHandler<string>? Themed;
/// <summary>
/// Resolve the preference to an absolute theme name ("Dark" or "Light")
/// suitable for the dictionary lookup. "System" resolves to the OS
/// app-mode at the time of the call.
/// </summary>
public string ResolveTheme() => _preference switch
{
PreferenceKeyDark => PreferenceKeyDark,
PreferenceKeyLight => PreferenceKeyLight,
_ => IsSystemDark() ? PreferenceKeyDark : PreferenceKeyLight,
};
/// <summary>
/// Set the operator's preference, persist, and apply the resolved theme.
/// </summary>
public void Set(string preference)
{
if (!IsValidPreference(preference))
{
throw new ArgumentException(
"Preference must be 'System', 'Dark', or 'Light'.",
nameof(preference));
}
_preference = preference;
try { UIPreferences.SetTheme(preference); }
catch { /* persistence is best-effort */ }
Apply();
}
/// <summary>
/// Cycle the theme between Dark and Light (one-click toggle from the header
/// theme icon). If the current preference is "System", the cycle pins to
/// the OPPOSITE of the currently-resolved theme so the click has a
/// visible effect.
/// </summary>
public void Toggle()
{
var current = ResolveTheme();
Set(current == PreferenceKeyDark ? PreferenceKeyLight : PreferenceKeyDark);
}
/// <summary>
/// Apply the current resolved theme. Should be called once during app
/// startup (after Application.Current.Resources is initialized) and
/// whenever <see cref="Preference"/> changes — <see cref="Set"/> already
/// does the latter for you.
/// </summary>
public void Apply()
{
var theme = ResolveTheme();
var uri = theme == PreferenceKeyDark ? DarkUri : LightUri;
SwapColorDictionary(uri);
Themed?.Invoke(this, theme);
}
private static void SwapColorDictionary(string newUri)
{
var app = Application.Current;
if (app is null) return;
var dicts = app.Resources.MergedDictionaries;
// Find the existing theme color dictionary by source URI. We
// distinguish "color" dictionaries from "WildDragonTheme" by name —
// the color files are at Theme.Dark.xaml / Theme.Light.xaml; the
// styles file is at WildDragonTheme.xaml. Replace in place to
// preserve merge order so DynamicResource refs resolve to the new
// brushes.
ResourceDictionary? old = null;
for (var i = 0; i < dicts.Count; i++)
{
var src = dicts[i].Source?.OriginalString ?? string.Empty;
if (src.EndsWith("Theme.Dark.xaml", StringComparison.OrdinalIgnoreCase) ||
src.EndsWith("Theme.Light.xaml", StringComparison.OrdinalIgnoreCase))
{
old = dicts[i];
break;
}
}
var fresh = new ResourceDictionary { Source = new Uri(newUri, UriKind.Relative) };
if (old is null)
{
dicts.Insert(0, fresh);
}
else
{
var idx = dicts.IndexOf(old);
dicts.RemoveAt(idx);
dicts.Insert(idx, fresh);
}
}
/// <summary>
/// Read Windows' AppsUseLightTheme registry value. 1 = light, 0 = dark.
/// Returns true (dark) on any read failure — the dark scene is the
/// default per DESIGN.md so a missing value still lands somewhere sensible.
/// </summary>
private static bool IsSystemDark()
{
try
{
using var key = Registry.CurrentUser.OpenSubKey(
@"SOFTWARE\Microsoft\Windows\CurrentVersion\Themes\Personalize");
if (key?.GetValue("AppsUseLightTheme") is int value)
{
return value == 0;
}
}
catch
{
// Registry access can fail under unusual security contexts.
}
return true;
}
private void OnSystemPreferenceChanged(object? sender, UserPreferenceChangedEventArgs e)
{
if (e.Category != UserPreferenceCategory.General) return;
if (_preference != PreferenceKeySystem) return;
// Marshal to the UI thread — registry events fire on a system pool
// thread and resource dictionary mutations require dispatcher access.
Application.Current?.Dispatcher.BeginInvoke(new Action(Apply));
}
private static bool IsValidPreference(string? value) =>
value is PreferenceKeySystem or PreferenceKeyDark or PreferenceKeyLight;
}

View file

@ -52,7 +52,20 @@ public static class UIPreferences
// into a TeamsISO-owned host. WebView2 in modern Teams can render
// weirdly after reparent; if so the operator unticks and falls
// back to auto-hide mode. Off by default.
bool EmbedTeamsWindow = false);
bool EmbedTeamsWindow = false,
// Theme preference for the v2 redesign. One of "System" (follow
// Windows app-mode), "Dark", or "Light". ThemeManager hydrates
// from this on startup and persists back here on toggle. Default
// "System" matches DESIGN.md's "Follow Windows" choice — the
// operator who doesn't care gets whatever Windows is set to.
string Theme = "System");
/// <summary>Update just the Theme field without touching other prefs.</summary>
public static void SetTheme(string theme)
{
var current = Load();
Save(current with { Theme = theme });
}
public static Prefs Load()
{

View file

@ -0,0 +1,47 @@
<ResourceDictionary
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<!--
Wild Dragon — Dark palette. Color resources ONLY, no styles.
Loaded into App.xaml's MergedDictionaries; swapped at runtime
for Theme.Light.xaml via Services/ThemeManager.cs.
Every key here MUST also exist in Theme.Light.xaml with the
same name. Keep the two files in lockstep — adding a new
brush in one without the other will break light-mode rendering.
References inside WildDragonTheme.xaml (which carries the styles
+ control templates) reach these brushes via {DynamicResource},
so the runtime swap re-resolves automatically.
-->
<SolidColorBrush x:Key="Wd.Canvas" Color="#0A0A0A"/>
<SolidColorBrush x:Key="Wd.Rail" Color="#080808"/>
<SolidColorBrush x:Key="Wd.Surface" Color="#141414"/>
<SolidColorBrush x:Key="Wd.SurfaceElevated" Color="#1C1C1C"/>
<SolidColorBrush x:Key="Wd.SurfaceHover" Color="#2A2A2A"/>
<SolidColorBrush x:Key="Wd.SurfaceActive" Color="#363636"/>
<SolidColorBrush x:Key="Wd.Border" Color="#262626"/>
<SolidColorBrush x:Key="Wd.BorderStrong" Color="#383838"/>
<SolidColorBrush x:Key="Wd.Button.HoverBg" Color="#33333A"/>
<SolidColorBrush x:Key="Wd.Button.PressBg" Color="#3F3F47"/>
<SolidColorBrush x:Key="Wd.Text.Primary" Color="#F5F5F5"/>
<SolidColorBrush x:Key="Wd.Text.Secondary" Color="#A3A3A3"/>
<SolidColorBrush x:Key="Wd.Text.Tertiary" Color="#6B6B6B"/>
<SolidColorBrush x:Key="Wd.Text.Disabled" Color="#404040"/>
<SolidColorBrush x:Key="Wd.Accent.Cyan" Color="#97EDF0"/>
<SolidColorBrush x:Key="Wd.Accent.CyanHover" Color="#B5F2F4"/>
<SolidColorBrush x:Key="Wd.Accent.CyanMuted" Color="#1B3537"/>
<SolidColorBrush x:Key="Wd.Accent.CyanText" Color="#97EDF0"/>
<SolidColorBrush x:Key="Wd.Accent.Blue" Color="#9AE0FD"/>
<SolidColorBrush x:Key="Wd.Accent.Coral" Color="#FB819C"/>
<SolidColorBrush x:Key="Wd.Accent.CoralBg" Color="#3A1922"/>
<SolidColorBrush x:Key="Wd.Status.Live" Color="#4ADE80"/>
<SolidColorBrush x:Key="Wd.Status.LiveBg" Color="#13261A"/>
<SolidColorBrush x:Key="Wd.Status.Warn" Color="#FBBF24"/>
<SolidColorBrush x:Key="Wd.Status.Error" Color="#FB819C"/>
</ResourceDictionary>

View file

@ -0,0 +1,49 @@
<ResourceDictionary
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<!--
Wild Dragon — Light palette. Color resources ONLY, no styles.
Mirror of Theme.Dark.xaml — same keys, light-mode values.
Light-palette discipline (from DESIGN.md):
- Neutrals are cyan-tinted off-whites, not pure white, so the
surface still reads as Wild Dragon brand, not as generic OS.
- Wd.Accent.Cyan stays at #97EDF0 because its primary use is as
a fill where text-on-top is near-black (LIVE pill works in
both modes unchanged).
- Wd.Accent.CyanText drops to a darker cyan (#0E7C82) for
contrast when cyan is used as text/icon foreground on the
light canvas. Use this key for "cyan as text"; use
Wd.Accent.Cyan for "cyan as background fill".
-->
<SolidColorBrush x:Key="Wd.Canvas" Color="#FAFAFB"/>
<SolidColorBrush x:Key="Wd.Rail" Color="#F0F1F3"/>
<SolidColorBrush x:Key="Wd.Surface" Color="#FFFFFF"/>
<SolidColorBrush x:Key="Wd.SurfaceElevated" Color="#FFFFFF"/>
<SolidColorBrush x:Key="Wd.SurfaceHover" Color="#ECEEF1"/>
<SolidColorBrush x:Key="Wd.SurfaceActive" Color="#E0E3E7"/>
<SolidColorBrush x:Key="Wd.Border" Color="#E5E7EB"/>
<SolidColorBrush x:Key="Wd.BorderStrong" Color="#D1D5DA"/>
<SolidColorBrush x:Key="Wd.Button.HoverBg" Color="#E0E3E7"/>
<SolidColorBrush x:Key="Wd.Button.PressBg" Color="#D1D5DA"/>
<SolidColorBrush x:Key="Wd.Text.Primary" Color="#0A0A0A"/>
<SolidColorBrush x:Key="Wd.Text.Secondary" Color="#4A4B50"/>
<SolidColorBrush x:Key="Wd.Text.Tertiary" Color="#71747A"/>
<SolidColorBrush x:Key="Wd.Text.Disabled" Color="#B3B6BC"/>
<SolidColorBrush x:Key="Wd.Accent.Cyan" Color="#97EDF0"/>
<SolidColorBrush x:Key="Wd.Accent.CyanHover" Color="#0890A0"/>
<SolidColorBrush x:Key="Wd.Accent.CyanMuted" Color="#E6F8F9"/>
<SolidColorBrush x:Key="Wd.Accent.CyanText" Color="#0E7C82"/>
<SolidColorBrush x:Key="Wd.Accent.Blue" Color="#3578A8"/>
<SolidColorBrush x:Key="Wd.Accent.Coral" Color="#D43E5C"/>
<SolidColorBrush x:Key="Wd.Accent.CoralBg" Color="#FDECF0"/>
<SolidColorBrush x:Key="Wd.Status.Live" Color="#15803D"/>
<SolidColorBrush x:Key="Wd.Status.LiveBg" Color="#DCFCE7"/>
<SolidColorBrush x:Key="Wd.Status.Warn" Color="#B45309"/>
<SolidColorBrush x:Key="Wd.Status.Error" Color="#D43E5C"/>
</ResourceDictionary>

View file

@ -40,42 +40,23 @@
<CornerRadius x:Key="Radius.L">12</CornerRadius>
<CornerRadius x:Key="Radius.XL">16</CornerRadius>
<!-- ════ Wild Dragon palette (dark) ════ -->
<SolidColorBrush x:Key="Wd.Canvas" Color="#0A0A0A"/>
<SolidColorBrush x:Key="Wd.Rail" Color="#080808"/>
<SolidColorBrush x:Key="Wd.Surface" Color="#141414"/>
<SolidColorBrush x:Key="Wd.SurfaceElevated" Color="#1C1C1C"/>
<SolidColorBrush x:Key="Wd.SurfaceHover" Color="#2A2A2A"/>
<SolidColorBrush x:Key="Wd.SurfaceActive" Color="#363636"/>
<SolidColorBrush x:Key="Wd.Border" Color="#262626"/>
<SolidColorBrush x:Key="Wd.BorderStrong" Color="#383838"/>
<!--
Dedicated stronger hover background for buttons, used when the default
SurfaceHover doesn't read clearly enough on the dark cards. Combined
with a cyan accent border on hover this gives every interactive
button an unmistakable affordance regardless of what surface it
sits on (canvas, card, or rail).
Color brushes moved to Theme.Dark.xaml / Theme.Light.xaml so they
can be swapped at runtime. Styles below reference them via
{DynamicResource} so the swap re-resolves the visual tree.
The two color files MUST stay in lockstep — adding a brush
in one without the other breaks the missing-mode rendering.
New key (v2 redesign): Wd.Accent.CyanText. Use this when cyan
is the FOREGROUND on a light or dark canvas (status text,
captions, icons). Wd.Accent.Cyan stays for BACKGROUND fills
where the on-top text is near-black (LIVE pill, focus ring).
-->
<SolidColorBrush x:Key="Wd.Button.HoverBg" Color="#33333A"/>
<SolidColorBrush x:Key="Wd.Button.PressBg" Color="#3F3F47"/>
<SolidColorBrush x:Key="Wd.Text.Primary" Color="#F5F5F5"/>
<SolidColorBrush x:Key="Wd.Text.Secondary" Color="#A3A3A3"/>
<SolidColorBrush x:Key="Wd.Text.Tertiary" Color="#6B6B6B"/>
<SolidColorBrush x:Key="Wd.Text.Disabled" Color="#404040"/>
<!-- Accents from wilddragon.net -->
<SolidColorBrush x:Key="Wd.Accent.Cyan" Color="#97EDF0"/>
<SolidColorBrush x:Key="Wd.Accent.CyanHover" Color="#B5F2F4"/>
<SolidColorBrush x:Key="Wd.Accent.CyanMuted" Color="#1B3537"/>
<SolidColorBrush x:Key="Wd.Accent.Blue" Color="#9AE0FD"/>
<SolidColorBrush x:Key="Wd.Accent.Coral" Color="#FB819C"/>
<SolidColorBrush x:Key="Wd.Accent.CoralBg" Color="#3A1922"/>
<SolidColorBrush x:Key="Wd.Status.Live" Color="#4ADE80"/>
<SolidColorBrush x:Key="Wd.Status.LiveBg" Color="#13261A"/>
<SolidColorBrush x:Key="Wd.Status.Warn" Color="#FBBF24"/>
<SolidColorBrush x:Key="Wd.Status.Error" Color="#FB819C"/>
<!-- ════ Animation defaults for theme-aware micro-interactions ════ -->
<Duration x:Key="Wd.Motion.Fast">0:0:0.090</Duration>
<Duration x:Key="Wd.Motion.Med">0:0:0.180</Duration>
<Duration x:Key="Wd.Motion.Slow">0:0:0.280</Duration>
<!-- ════ Typography ════ -->
<!--
@ -91,38 +72,38 @@
<Setter Property="FontFamily" Value="{StaticResource Wd.Font.Sans}"/>
<Setter Property="FontSize" Value="20"/>
<Setter Property="FontWeight" Value="SemiBold"/>
<Setter Property="Foreground" Value="{StaticResource Wd.Text.Primary}"/>
<Setter Property="Foreground" Value="{DynamicResource Wd.Text.Primary}"/>
</Style>
<Style x:Key="Wd.Text.Heading" TargetType="TextBlock">
<Setter Property="FontFamily" Value="{StaticResource Wd.Font.Sans}"/>
<Setter Property="FontSize" Value="14"/>
<Setter Property="FontWeight" Value="SemiBold"/>
<Setter Property="Foreground" Value="{StaticResource Wd.Text.Primary}"/>
<Setter Property="Foreground" Value="{DynamicResource Wd.Text.Primary}"/>
</Style>
<Style x:Key="Wd.Text.Body" TargetType="TextBlock">
<Setter Property="FontFamily" Value="{StaticResource Wd.Font.Sans}"/>
<Setter Property="FontSize" Value="13"/>
<Setter Property="Foreground" Value="{StaticResource Wd.Text.Primary}"/>
<Setter Property="Foreground" Value="{DynamicResource Wd.Text.Primary}"/>
</Style>
<Style x:Key="Wd.Text.Subtle" TargetType="TextBlock" BasedOn="{StaticResource Wd.Text.Body}">
<Setter Property="Foreground" Value="{StaticResource Wd.Text.Secondary}"/>
<Setter Property="Foreground" Value="{DynamicResource Wd.Text.Secondary}"/>
</Style>
<Style x:Key="Wd.Text.Caption" TargetType="TextBlock">
<Setter Property="FontFamily" Value="{StaticResource Wd.Font.Sans}"/>
<Setter Property="FontSize" Value="11"/>
<Setter Property="FontWeight" Value="Medium"/>
<Setter Property="Foreground" Value="{StaticResource Wd.Text.Tertiary}"/>
<Setter Property="Foreground" Value="{DynamicResource Wd.Text.Tertiary}"/>
<Setter Property="Typography.Capitals" Value="AllSmallCaps"/>
</Style>
<Style x:Key="Wd.Text.Mono" TargetType="TextBlock">
<Setter Property="FontFamily" Value="{StaticResource Wd.Font.Mono}"/>
<Setter Property="FontSize" Value="12"/>
<Setter Property="Foreground" Value="{StaticResource Wd.Text.Secondary}"/>
<Setter Property="Foreground" Value="{DynamicResource Wd.Text.Secondary}"/>
</Style>
<Style TargetType="TextBlock" BasedOn="{StaticResource Wd.Text.Body}"/>
@ -133,9 +114,9 @@
<Setter Property="FontFamily" Value="{StaticResource Wd.Font.Sans}"/>
<Setter Property="FontSize" Value="13"/>
<Setter Property="FontWeight" Value="Medium"/>
<Setter Property="Foreground" Value="{StaticResource Wd.Text.Primary}"/>
<Setter Property="Foreground" Value="{DynamicResource Wd.Text.Primary}"/>
<Setter Property="Background" Value="Transparent"/>
<Setter Property="BorderBrush" Value="{StaticResource Wd.BorderStrong}"/>
<Setter Property="BorderBrush" Value="{DynamicResource Wd.BorderStrong}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="Padding" Value="14,8"/>
<Setter Property="Cursor" Value="Hand"/>
@ -164,8 +145,8 @@
palette is too subtle to spot at a glance.
-->
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{StaticResource Wd.Button.HoverBg}"/>
<Setter TargetName="Bd" Property="BorderBrush" Value="{StaticResource Wd.Accent.Cyan}"/>
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource Wd.Button.HoverBg}"/>
<Setter TargetName="Bd" Property="BorderBrush" Value="{DynamicResource Wd.Accent.Cyan}"/>
</Trigger>
<!--
Keyboard focus ring (Tab-navigation). FocusVisualStyle
@ -175,14 +156,14 @@
on the same affordance.
-->
<Trigger Property="IsKeyboardFocused" Value="True">
<Setter TargetName="Bd" Property="BorderBrush" Value="{StaticResource Wd.Accent.Cyan}"/>
<Setter TargetName="Bd" Property="BorderBrush" Value="{DynamicResource Wd.Accent.Cyan}"/>
</Trigger>
<Trigger Property="IsPressed" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{StaticResource Wd.Button.PressBg}"/>
<Setter TargetName="Bd" Property="BorderBrush" Value="{StaticResource Wd.Accent.CyanHover}"/>
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource Wd.Button.PressBg}"/>
<Setter TargetName="Bd" Property="BorderBrush" Value="{DynamicResource Wd.Accent.CyanHover}"/>
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Foreground" Value="{StaticResource Wd.Text.Disabled}"/>
<Setter Property="Foreground" Value="{DynamicResource Wd.Text.Disabled}"/>
<Setter TargetName="Bd" Property="Opacity" Value="0.6"/>
</Trigger>
</ControlTemplate.Triggers>
@ -193,8 +174,8 @@
<!-- Primary: cyan-on-black for the brand action -->
<Style x:Key="Wd.Button.Primary" TargetType="Button" BasedOn="{StaticResource Wd.Button.Ghost}">
<Setter Property="Background" Value="{StaticResource Wd.Accent.Cyan}"/>
<Setter Property="BorderBrush" Value="{StaticResource Wd.Accent.Cyan}"/>
<Setter Property="Background" Value="{DynamicResource Wd.Accent.Cyan}"/>
<Setter Property="BorderBrush" Value="{DynamicResource Wd.Accent.Cyan}"/>
<Setter Property="Foreground" Value="#0A0A0A"/>
<Setter Property="FontWeight" Value="SemiBold"/>
<Setter Property="Template">
@ -211,12 +192,12 @@
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{StaticResource Wd.Accent.CyanHover}"/>
<Setter TargetName="Bd" Property="BorderBrush" Value="{StaticResource Wd.Accent.CyanHover}"/>
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource Wd.Accent.CyanHover}"/>
<Setter TargetName="Bd" Property="BorderBrush" Value="{DynamicResource Wd.Accent.CyanHover}"/>
</Trigger>
<Trigger Property="IsKeyboardFocused" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{StaticResource Wd.Accent.CyanHover}"/>
<Setter TargetName="Bd" Property="BorderBrush" Value="{StaticResource Wd.Accent.CyanHover}"/>
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource Wd.Accent.CyanHover}"/>
<Setter TargetName="Bd" Property="BorderBrush" Value="{DynamicResource Wd.Accent.CyanHover}"/>
</Trigger>
<Trigger Property="IsPressed" Value="True">
<Setter TargetName="Bd" Property="Opacity" Value="0.85"/>
@ -231,10 +212,10 @@
pending vs. when nothing's queued to apply).
-->
<Trigger Property="IsEnabled" Value="False">
<Setter TargetName="Bd" Property="Background" Value="{StaticResource Wd.Accent.CyanMuted}"/>
<Setter TargetName="Bd" Property="BorderBrush" Value="{StaticResource Wd.Accent.CyanMuted}"/>
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource Wd.Accent.CyanMuted}"/>
<Setter TargetName="Bd" Property="BorderBrush" Value="{DynamicResource Wd.Accent.CyanMuted}"/>
<Setter TargetName="Bd" Property="Opacity" Value="0.7"/>
<Setter Property="Foreground" Value="{StaticResource Wd.Text.Disabled}"/>
<Setter Property="Foreground" Value="{DynamicResource Wd.Text.Disabled}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
@ -263,13 +244,13 @@
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{StaticResource Wd.Button.HoverBg}"/>
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource Wd.Button.HoverBg}"/>
</Trigger>
<Trigger Property="IsKeyboardFocused" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{StaticResource Wd.Button.HoverBg}"/>
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource Wd.Button.HoverBg}"/>
</Trigger>
<Trigger Property="IsPressed" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{StaticResource Wd.Button.PressBg}"/>
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource Wd.Button.PressBg}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
@ -301,7 +282,7 @@
<Style x:Key="Wd.Button.RailIcon" TargetType="Button">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="Foreground" Value="{StaticResource Wd.Text.Secondary}"/>
<Setter Property="Foreground" Value="{DynamicResource Wd.Text.Secondary}"/>
<Setter Property="Width" Value="48"/>
<Setter Property="Height" Value="48"/>
<Setter Property="Margin" Value="0,4"/>
@ -322,15 +303,15 @@
surface than Wd.Canvas. Use Wd.Button.HoverBg so
the affordance reads clearly. -->
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{StaticResource Wd.Button.HoverBg}"/>
<Setter Property="Foreground" Value="{StaticResource Wd.Accent.Cyan}"/>
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource Wd.Button.HoverBg}"/>
<Setter Property="Foreground" Value="{DynamicResource Wd.Accent.Cyan}"/>
</Trigger>
<Trigger Property="IsKeyboardFocused" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{StaticResource Wd.Button.HoverBg}"/>
<Setter Property="Foreground" Value="{StaticResource Wd.Accent.Cyan}"/>
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource Wd.Button.HoverBg}"/>
<Setter Property="Foreground" Value="{DynamicResource Wd.Accent.Cyan}"/>
</Trigger>
<Trigger Property="IsPressed" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{StaticResource Wd.Button.PressBg}"/>
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource Wd.Button.PressBg}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
@ -343,9 +324,9 @@
<Setter Property="FontFamily" Value="{StaticResource Wd.Font.Sans}"/>
<Setter Property="FontSize" Value="11"/>
<Setter Property="FontWeight" Value="SemiBold"/>
<Setter Property="Foreground" Value="{StaticResource Wd.Text.Primary}"/>
<Setter Property="Background" Value="{StaticResource Wd.SurfaceHover}"/>
<Setter Property="BorderBrush" Value="{StaticResource Wd.BorderStrong}"/>
<Setter Property="Foreground" Value="{DynamicResource Wd.Text.Primary}"/>
<Setter Property="Background" Value="{DynamicResource Wd.SurfaceHover}"/>
<Setter Property="BorderBrush" Value="{DynamicResource Wd.BorderStrong}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="Padding" Value="14,6"/>
<Setter Property="Cursor" Value="Hand"/>
@ -375,15 +356,15 @@
the data-trigger setter wins.
-->
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="BorderBrush" Value="{StaticResource Wd.Accent.CyanHover}"/>
<Setter TargetName="Bd" Property="BorderBrush" Value="{DynamicResource Wd.Accent.CyanHover}"/>
<Setter TargetName="Bd" Property="BorderThickness" Value="2"/>
</Trigger>
<Trigger Property="IsKeyboardFocused" Value="True">
<Setter TargetName="Bd" Property="BorderBrush" Value="{StaticResource Wd.Accent.CyanHover}"/>
<Setter TargetName="Bd" Property="BorderBrush" Value="{DynamicResource Wd.Accent.CyanHover}"/>
<Setter TargetName="Bd" Property="BorderThickness" Value="2"/>
</Trigger>
<Trigger Property="IsPressed" Value="True">
<Setter TargetName="Bd" Property="BorderBrush" Value="{StaticResource Wd.Accent.Cyan}"/>
<Setter TargetName="Bd" Property="BorderBrush" Value="{DynamicResource Wd.Accent.Cyan}"/>
<Setter TargetName="Bd" Property="BorderThickness" Value="2"/>
<Setter TargetName="Bd" Property="Opacity" Value="0.85"/>
</Trigger>
@ -397,11 +378,11 @@
<Style x:Key="Wd.TextBox.Default" TargetType="TextBox">
<Setter Property="FontFamily" Value="{StaticResource Wd.Font.Sans}"/>
<Setter Property="FontSize" Value="13"/>
<Setter Property="Foreground" Value="{StaticResource Wd.Text.Primary}"/>
<Setter Property="Background" Value="{StaticResource Wd.Surface}"/>
<Setter Property="CaretBrush" Value="{StaticResource Wd.Accent.Cyan}"/>
<Setter Property="SelectionBrush" Value="{StaticResource Wd.Accent.CyanMuted}"/>
<Setter Property="BorderBrush" Value="{StaticResource Wd.Border}"/>
<Setter Property="Foreground" Value="{DynamicResource Wd.Text.Primary}"/>
<Setter Property="Background" Value="{DynamicResource Wd.Surface}"/>
<Setter Property="CaretBrush" Value="{DynamicResource Wd.Accent.Cyan}"/>
<Setter Property="SelectionBrush" Value="{DynamicResource Wd.Accent.CyanMuted}"/>
<Setter Property="BorderBrush" Value="{DynamicResource Wd.Border}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="Padding" Value="12,9"/>
<Setter Property="Template">
@ -419,10 +400,10 @@
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsKeyboardFocused" Value="True">
<Setter TargetName="Bd" Property="BorderBrush" Value="{StaticResource Wd.Accent.Cyan}"/>
<Setter TargetName="Bd" Property="BorderBrush" Value="{DynamicResource Wd.Accent.Cyan}"/>
</Trigger>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="BorderBrush" Value="{StaticResource Wd.BorderStrong}"/>
<Setter TargetName="Bd" Property="BorderBrush" Value="{DynamicResource Wd.BorderStrong}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
@ -441,11 +422,21 @@
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ToggleButton">
<Path HorizontalAlignment="Right"
VerticalAlignment="Center"
Margin="0,0,14,0"
Data="M 0,0 L 4,4 L 8,0 Z"
Fill="{StaticResource Wd.Text.Secondary}"/>
<!--
Grid Background="Transparent" is required so the ENTIRE
ComboBox face opens the dropdown, not just the chevron.
Previously the template was just the <Path> with no
Background-bearing container — WPF hit-tests only the
rendered triangle (~8x4px), so the operator's click on
the visible field fell through and nothing happened.
-->
<Grid Background="Transparent">
<Path HorizontalAlignment="Right"
VerticalAlignment="Center"
Margin="0,0,14,0"
Data="M 0,0 L 4,4 L 8,0 Z"
Fill="{DynamicResource Wd.Text.Secondary}"/>
</Grid>
</ControlTemplate>
</Setter.Value>
</Setter>
@ -454,9 +445,9 @@
<Style TargetType="ComboBox">
<Setter Property="FontFamily" Value="{StaticResource Wd.Font.Sans}"/>
<Setter Property="FontSize" Value="13"/>
<Setter Property="Foreground" Value="{StaticResource Wd.Text.Primary}"/>
<Setter Property="Background" Value="{StaticResource Wd.Surface}"/>
<Setter Property="BorderBrush" Value="{StaticResource Wd.Border}"/>
<Setter Property="Foreground" Value="{DynamicResource Wd.Text.Primary}"/>
<Setter Property="Background" Value="{DynamicResource Wd.Surface}"/>
<Setter Property="BorderBrush" Value="{DynamicResource Wd.Border}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="Padding" Value="12,9"/>
<Setter Property="Template">
@ -481,8 +472,8 @@
AllowsTransparency="True"
Focusable="False"
PopupAnimation="None">
<Border Background="{StaticResource Wd.SurfaceElevated}"
BorderBrush="{StaticResource Wd.BorderStrong}"
<Border Background="{DynamicResource Wd.SurfaceElevated}"
BorderBrush="{DynamicResource Wd.BorderStrong}"
BorderThickness="1"
CornerRadius="{StaticResource Radius.M}"
MinWidth="{TemplateBinding ActualWidth}"
@ -494,10 +485,10 @@
</Grid>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="BorderBrush" Value="{StaticResource Wd.BorderStrong}"/>
<Setter TargetName="Bd" Property="BorderBrush" Value="{DynamicResource Wd.BorderStrong}"/>
</Trigger>
<Trigger Property="IsKeyboardFocusWithin" Value="True">
<Setter TargetName="Bd" Property="BorderBrush" Value="{StaticResource Wd.Accent.Cyan}"/>
<Setter TargetName="Bd" Property="BorderBrush" Value="{DynamicResource Wd.Accent.Cyan}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
@ -506,7 +497,7 @@
</Style>
<Style TargetType="ComboBoxItem">
<Setter Property="Foreground" Value="{StaticResource Wd.Text.Primary}"/>
<Setter Property="Foreground" Value="{DynamicResource Wd.Text.Primary}"/>
<Setter Property="Padding" Value="12,9"/>
<Setter Property="Template">
<Setter.Value>
@ -520,10 +511,10 @@
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{StaticResource Wd.SurfaceHover}"/>
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource Wd.SurfaceHover}"/>
</Trigger>
<Trigger Property="IsHighlighted" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{StaticResource Wd.SurfaceHover}"/>
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource Wd.SurfaceHover}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
@ -535,7 +526,7 @@
<Style TargetType="CheckBox">
<Setter Property="FontFamily" Value="{StaticResource Wd.Font.Sans}"/>
<Setter Property="FontSize" Value="13"/>
<Setter Property="Foreground" Value="{StaticResource Wd.Text.Primary}"/>
<Setter Property="Foreground" Value="{DynamicResource Wd.Text.Primary}"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Padding" Value="10,0,0,0"/>
<!--
@ -558,9 +549,9 @@
<Border x:Name="Box"
Grid.Column="0"
Width="18" Height="18"
BorderBrush="{StaticResource Wd.BorderStrong}"
BorderBrush="{DynamicResource Wd.BorderStrong}"
BorderThickness="1"
Background="{StaticResource Wd.Surface}"
Background="{DynamicResource Wd.Surface}"
CornerRadius="4"
VerticalAlignment="Center">
<Path x:Name="Tick"
@ -593,12 +584,12 @@
</Grid>
<ControlTemplate.Triggers>
<Trigger Property="IsChecked" Value="True">
<Setter TargetName="Box" Property="Background" Value="{StaticResource Wd.Accent.Cyan}"/>
<Setter TargetName="Box" Property="BorderBrush" Value="{StaticResource Wd.Accent.Cyan}"/>
<Setter TargetName="Box" Property="Background" Value="{DynamicResource Wd.Accent.Cyan}"/>
<Setter TargetName="Box" Property="BorderBrush" Value="{DynamicResource Wd.Accent.Cyan}"/>
<Setter TargetName="Tick" Property="Visibility" Value="Visible"/>
</Trigger>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Box" Property="BorderBrush" Value="{StaticResource Wd.Accent.Cyan}"/>
<Setter TargetName="Box" Property="BorderBrush" Value="{DynamicResource Wd.Accent.Cyan}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
@ -625,7 +616,7 @@
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<Border Grid.Row="0"
BorderBrush="{StaticResource Wd.Border}"
BorderBrush="{DynamicResource Wd.Border}"
BorderThickness="0,0,0,1">
<TabPanel x:Name="HeaderPanel"
IsItemsHost="True"
@ -644,7 +635,7 @@
<Setter Property="FontFamily" Value="{StaticResource Wd.Font.Sans}"/>
<Setter Property="FontSize" Value="11"/>
<Setter Property="FontWeight" Value="Medium"/>
<Setter Property="Foreground" Value="{StaticResource Wd.Text.Tertiary}"/>
<Setter Property="Foreground" Value="{DynamicResource Wd.Text.Tertiary}"/>
<Setter Property="Background" Value="Transparent"/>
<Setter Property="Padding" Value="0,8,0,10"/>
<Setter Property="Margin" Value="0,0,20,0"/>
@ -677,16 +668,16 @@
<Rectangle x:Name="Underline"
Grid.Row="1"
Height="2"
Fill="{StaticResource Wd.Accent.Cyan}"
Fill="{DynamicResource Wd.Accent.Cyan}"
Visibility="Hidden"/>
</Grid>
<ControlTemplate.Triggers>
<Trigger Property="IsSelected" Value="True">
<Setter TargetName="Underline" Property="Visibility" Value="Visible"/>
<Setter Property="Foreground" Value="{StaticResource Wd.Text.Primary}"/>
<Setter Property="Foreground" Value="{DynamicResource Wd.Text.Primary}"/>
</Trigger>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Foreground" Value="{StaticResource Wd.Text.Secondary}"/>
<Setter Property="Foreground" Value="{DynamicResource Wd.Text.Secondary}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
@ -696,8 +687,8 @@
<!-- ════ Card ════ -->
<Style x:Key="Wd.Card" TargetType="Border">
<Setter Property="Background" Value="{StaticResource Wd.Surface}"/>
<Setter Property="BorderBrush" Value="{StaticResource Wd.Border}"/>
<Setter Property="Background" Value="{DynamicResource Wd.Surface}"/>
<Setter Property="BorderBrush" Value="{DynamicResource Wd.Border}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="CornerRadius" Value="{StaticResource Radius.L}"/>
<Setter Property="Padding" Value="16"/>
@ -706,7 +697,7 @@
<!-- ════ DataGrid (re-skinned, Teams-style) ════ -->
<Style TargetType="DataGrid">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="Foreground" Value="{StaticResource Wd.Text.Primary}"/>
<Setter Property="Foreground" Value="{DynamicResource Wd.Text.Primary}"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="GridLinesVisibility" Value="None"/>
<Setter Property="HeadersVisibility" Value="Column"/>
@ -727,9 +718,9 @@
<Setter Property="FontFamily" Value="{StaticResource Wd.Font.Sans}"/>
<Setter Property="FontSize" Value="11"/>
<Setter Property="FontWeight" Value="Medium"/>
<Setter Property="Foreground" Value="{StaticResource Wd.Text.Tertiary}"/>
<Setter Property="Foreground" Value="{DynamicResource Wd.Text.Tertiary}"/>
<Setter Property="Background" Value="Transparent"/>
<Setter Property="BorderBrush" Value="{StaticResource Wd.Border}"/>
<Setter Property="BorderBrush" Value="{DynamicResource Wd.Border}"/>
<Setter Property="BorderThickness" Value="0,0,0,1"/>
<Setter Property="Padding" Value="14,12"/>
<Setter Property="HorizontalContentAlignment" Value="Left"/>
@ -738,23 +729,23 @@
<Style TargetType="DataGridRow">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="BorderBrush" Value="{StaticResource Wd.Border}"/>
<Setter Property="BorderBrush" Value="{DynamicResource Wd.Border}"/>
<Setter Property="BorderThickness" Value="0,0,0,1"/>
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="{StaticResource Wd.SurfaceHover}"/>
<Setter Property="Background" Value="{DynamicResource Wd.SurfaceHover}"/>
</Trigger>
<Trigger Property="IsSelected" Value="True">
<Setter Property="Background" Value="{StaticResource Wd.SurfaceElevated}"/>
<Setter Property="Background" Value="{DynamicResource Wd.SurfaceElevated}"/>
</Trigger>
<!-- Active speaker highlight: set by MainViewModel at the 1Hz
stats tick based on DisplayedAudioLevel. Cyan left border
(3px) makes whoever's talking pop without changing the
background color (which IsMouseOver / IsSelected own). -->
<DataTrigger Binding="{Binding IsActiveSpeaker}" Value="True">
<Setter Property="BorderBrush" Value="{StaticResource Wd.Accent.Cyan}"/>
<Setter Property="BorderBrush" Value="{DynamicResource Wd.Accent.Cyan}"/>
<Setter Property="BorderThickness" Value="3,0,0,1"/>
<Setter Property="Background" Value="{StaticResource Wd.Accent.CyanMuted}"/>
<Setter Property="Background" Value="{DynamicResource Wd.Accent.CyanMuted}"/>
</DataTrigger>
</Style.Triggers>
</Style>
@ -764,7 +755,7 @@
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="Padding" Value="14,0"/>
<Setter Property="VerticalAlignment" Value="Center"/>
<Setter Property="Foreground" Value="{StaticResource Wd.Text.Primary}"/>
<Setter Property="Foreground" Value="{DynamicResource Wd.Text.Primary}"/>
<Setter Property="FocusVisualStyle" Value="{x:Null}"/>
<Setter Property="Template">
<Setter.Value>
@ -788,7 +779,7 @@
(the RepeatButton does the work) but render as invisible hit zones.
-->
<Style x:Key="Wd.ScrollBar.Thumb" TargetType="Thumb">
<Setter Property="Background" Value="{StaticResource Wd.BorderStrong}"/>
<Setter Property="Background" Value="{DynamicResource Wd.BorderStrong}"/>
<Setter Property="OverridesDefaultStyle" Value="True"/>
<Setter Property="IsTabStop" Value="False"/>
<Setter Property="Focusable" Value="False"/>
@ -801,10 +792,10 @@
Margin="2"/>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{StaticResource Wd.Text.Tertiary}"/>
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource Wd.Text.Tertiary}"/>
</Trigger>
<Trigger Property="IsDragging" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{StaticResource Wd.Accent.Cyan}"/>
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource Wd.Accent.Cyan}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
@ -888,8 +879,8 @@
<Setter Property="Width" Value="32"/>
<Setter Property="Height" Value="32"/>
<Setter Property="CornerRadius" Value="16"/>
<Setter Property="Background" Value="{StaticResource Wd.Accent.CyanMuted}"/>
<Setter Property="BorderBrush" Value="{StaticResource Wd.Accent.Cyan}"/>
<Setter Property="Background" Value="{DynamicResource Wd.Accent.CyanMuted}"/>
<Setter Property="BorderBrush" Value="{DynamicResource Wd.Accent.Cyan}"/>
<Setter Property="BorderThickness" Value="1"/>
</Style>
@ -902,10 +893,10 @@
verbose explanation doesn't stretch across a whole monitor.
-->
<Style TargetType="ToolTip">
<Setter Property="Background" Value="{StaticResource Wd.SurfaceElevated}"/>
<Setter Property="BorderBrush" Value="{StaticResource Wd.BorderStrong}"/>
<Setter Property="Background" Value="{DynamicResource Wd.SurfaceElevated}"/>
<Setter Property="BorderBrush" Value="{DynamicResource Wd.BorderStrong}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="Foreground" Value="{StaticResource Wd.Text.Primary}"/>
<Setter Property="Foreground" Value="{DynamicResource Wd.Text.Primary}"/>
<Setter Property="FontFamily" Value="{StaticResource Wd.Font.Sans}"/>
<Setter Property="FontSize" Value="12"/>
<Setter Property="Padding" Value="10,6"/>
@ -943,10 +934,10 @@
chevron arrows on items without submenus, rounded outer corner.
-->
<Style TargetType="ContextMenu">
<Setter Property="Background" Value="{StaticResource Wd.SurfaceElevated}"/>
<Setter Property="BorderBrush" Value="{StaticResource Wd.BorderStrong}"/>
<Setter Property="Background" Value="{DynamicResource Wd.SurfaceElevated}"/>
<Setter Property="BorderBrush" Value="{DynamicResource Wd.BorderStrong}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="Foreground" Value="{StaticResource Wd.Text.Primary}"/>
<Setter Property="Foreground" Value="{DynamicResource Wd.Text.Primary}"/>
<Setter Property="FontFamily" Value="{StaticResource Wd.Font.Sans}"/>
<Setter Property="FontSize" Value="13"/>
<Setter Property="Padding" Value="4"/>
@ -967,7 +958,7 @@
</Style>
<Style TargetType="MenuItem">
<Setter Property="Foreground" Value="{StaticResource Wd.Text.Primary}"/>
<Setter Property="Foreground" Value="{DynamicResource Wd.Text.Primary}"/>
<Setter Property="Background" Value="Transparent"/>
<Setter Property="FontFamily" Value="{StaticResource Wd.Font.Sans}"/>
<Setter Property="FontSize" Value="13"/>
@ -1002,15 +993,15 @@
VerticalAlignment="Center"
FontFamily="{StaticResource Wd.Font.Mono}"
FontSize="11"
Foreground="{StaticResource Wd.Text.Tertiary}"/>
Foreground="{DynamicResource Wd.Text.Tertiary}"/>
<Popup x:Name="PART_Popup"
Placement="Right"
IsOpen="{TemplateBinding IsSubmenuOpen}"
AllowsTransparency="True"
Focusable="False"
PopupAnimation="None">
<Border Background="{StaticResource Wd.SurfaceElevated}"
BorderBrush="{StaticResource Wd.BorderStrong}"
<Border Background="{DynamicResource Wd.SurfaceElevated}"
BorderBrush="{DynamicResource Wd.BorderStrong}"
BorderThickness="1"
CornerRadius="{StaticResource Radius.M}"
Padding="4"
@ -1022,11 +1013,11 @@
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsHighlighted" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{StaticResource Wd.Button.HoverBg}"/>
<Setter Property="Foreground" Value="{StaticResource Wd.Accent.Cyan}"/>
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource Wd.Button.HoverBg}"/>
<Setter Property="Foreground" Value="{DynamicResource Wd.Accent.Cyan}"/>
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Foreground" Value="{StaticResource Wd.Text.Disabled}"/>
<Setter Property="Foreground" Value="{DynamicResource Wd.Text.Disabled}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
@ -1036,7 +1027,7 @@
<!-- ════ Status pill ════ -->
<Style x:Key="Wd.Pill" TargetType="Border">
<Setter Property="Background" Value="{StaticResource Wd.SurfaceElevated}"/>
<Setter Property="Background" Value="{DynamicResource Wd.SurfaceElevated}"/>
<Setter Property="CornerRadius" Value="999"/>
<Setter Property="Padding" Value="10,4"/>
<Setter Property="HorizontalAlignment" Value="Left"/>

View file

@ -158,6 +158,14 @@ public sealed class MainViewModel : ObservableObject, IDisposable
/// <summary>F1 binding — opens the help / cheat-sheet dialog.</summary>
public RelayCommand ShowHelpCommand { get; }
/// <summary>
/// Ctrl+T binding — cycles dark ↔ light theme via ThemeManager.
/// Persists the operator's choice through UIPreferences.Theme.
/// The v2 header surfaces this as a click affordance too; the
/// command exists once so both bindings reach the same path.
/// </summary>
public RelayCommand ToggleThemeCommand { get; }
/// <summary>Opens the inline notes viewer for today's show-notes file.</summary>
public RelayCommand ShowNotesCommand { get; }
@ -198,6 +206,29 @@ public sealed class MainViewModel : ObservableObject, IDisposable
// RecordingElapsed, RecordingFreeSpace, IsLowDiskSpace) removed when the
// recording feature was axed.
/// <summary>
/// Total visible participants — feeds the v2 transport strip's "PART N"
/// readout. Updated on every 1Hz stats tick alongside <see cref="LiveCount"/>.
/// </summary>
public int ParticipantCount
{
get => _participantCount;
private set => SetField(ref _participantCount, value);
}
private int _participantCount;
/// <summary>
/// Currently-enabled (live) ISO count — feeds the v2 transport strip's
/// "LIVE N" readout. The number is cyan-tinted when non-zero to draw
/// the operator's eye to active state.
/// </summary>
public int LiveCount
{
get => _liveCount;
private set => SetField(ref _liveCount, value);
}
private int _liveCount;
/// <summary>True when the REST control surface (or OSC bridge, or both) is listening.</summary>
public bool IsControlSurfaceRunning
{
@ -330,6 +361,13 @@ public sealed class MainViewModel : ObservableObject, IDisposable
Toast.Show("Refreshing NDI discovery…");
});
ToggleThemeCommand = new RelayCommand(() =>
{
// ThemeManager.Toggle persists the new preference to UIPreferences
// and fires the resource-dictionary swap on the dispatcher thread.
Services.ThemeManager.Current.Toggle();
});
ShowHelpCommand = new RelayCommand(() =>
{
// Showing a Window from a VM violates strict MVVM, but TeamsISO doesn't
@ -667,6 +705,12 @@ public sealed class MainViewModel : ObservableObject, IDisposable
// Recording-elapsed timer + disk-free polling removed alongside the rest
// of the recording surface.
// Expose counts as VM properties for the v2 transport-strip binding.
// The strip's "PART 4 · LIVE 2" reads these — pushing them on the
// 1Hz tick keeps the cost off the per-frame UI path.
ParticipantCount = totalParticipants;
LiveCount = enabledCount;
// Session timer — start on first ISO going live, reset when none are
// live anymore. Subsequent enables after a full-zero gap restart the
// timer rather than resuming, which is the operator's mental model: