feat(wpf): v2 'Studio Terminal' shell - theme system, header, transport strip, drawer
Some checks failed
CI / build-and-test (push) Failing after 31s
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:
parent
1d1ce6a2a0
commit
c27130302f
14 changed files with 1606 additions and 1656 deletions
78
DESIGN.md
78
DESIGN.md
|
|
@ -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 65–75ch 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
89
NEXT_STEPS.md
Normal 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, 1–9 / NumPad 1–9 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.
|
||||
27
PRODUCT.md
27
PRODUCT.md
|
|
@ -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`)
|
||||
|
|
|
|||
194
docs/shapes/2026-05-13-teamsiso-v2-studio-terminal.md
Normal file
194
docs/shapes/2026-05-13-teamsiso-v2-studio-terminal.md
Normal 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
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
194
src/TeamsISO.App/Services/ThemeManager.cs
Normal file
194
src/TeamsISO.App/Services/ThemeManager.cs
Normal 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;
|
||||
}
|
||||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
47
src/TeamsISO.App/Themes/Theme.Dark.xaml
Normal file
47
src/TeamsISO.App/Themes/Theme.Dark.xaml
Normal 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>
|
||||
49
src/TeamsISO.App/Themes/Theme.Light.xaml
Normal file
49
src/TeamsISO.App/Themes/Theme.Light.xaml
Normal 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>
|
||||
|
|
@ -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"/>
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Reference in a new issue