2026-05-12 23:45:04 -04:00
|
|
|
|
# DESIGN.md — TeamsISO design system
|
|
|
|
|
|
|
2026-05-14 12:46:24 -04:00
|
|
|
|
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.)
|
2026-05-12 23:45:04 -04:00
|
|
|
|
|
|
|
|
|
|
## Color
|
|
|
|
|
|
|
|
|
|
|
|
### Strategy
|
|
|
|
|
|
|
|
|
|
|
|
**Restrained — committed accent + neutral surface.** The surface is the work;
|
|
|
|
|
|
the cyan accent is reserved for live state, focus, and the few moments that
|
|
|
|
|
|
actually need attention. Coral is reserved for destructive and error.
|
|
|
|
|
|
Everything else is neutral.
|
|
|
|
|
|
|
|
|
|
|
|
This means: no rainbow status pills, no per-feature accent colors, no
|
|
|
|
|
|
Slack-style chroma everywhere. If something is cyan, the operator's eye
|
|
|
|
|
|
should know why.
|
|
|
|
|
|
|
|
|
|
|
|
### Scene sentence
|
|
|
|
|
|
|
|
|
|
|
|
**Dark (default):** A solo broadcast operator at 1:50am, ambient room lights
|
|
|
|
|
|
at 5%, leaning into a 24-inch monitor, twenty minutes before a live
|
|
|
|
|
|
international interview.
|
|
|
|
|
|
|
|
|
|
|
|
**Light:** A morning recording session in a glass-walled conference room with
|
|
|
|
|
|
the sun coming through the blinds, monitor brightness at 80%. Or a daytime
|
|
|
|
|
|
producer monitoring a remote interview from a hotel desk during a working
|
|
|
|
|
|
session before lunch.
|
|
|
|
|
|
|
|
|
|
|
|
The default is dark — that's the dominant operator scene. Light mode exists
|
|
|
|
|
|
because not every show happens at 1:50am.
|
|
|
|
|
|
|
|
|
|
|
|
### Dark palette
|
|
|
|
|
|
|
|
|
|
|
|
Every neutral is tinted toward cyan (h ≈ 200, chroma 0.005–0.008) so the
|
|
|
|
|
|
dark surface reads as deliberate dark, not as chromatically dead.
|
|
|
|
|
|
|
|
|
|
|
|
| Token | Role | Hex | OKLCH (approx) |
|
|
|
|
|
|
|---|---|---|---|
|
|
|
|
|
|
| `bg.canvas` | Window canvas | `#0A0A0A` | `oklch(0.12 0.005 200)` |
|
|
|
|
|
|
| `bg.rail` | Left rail | `#080808` | `oklch(0.10 0.005 200)` |
|
|
|
|
|
|
| `bg.surface` | Card / row | `#141416` | `oklch(0.18 0.006 200)` |
|
|
|
|
|
|
| `bg.elevated` | Popovers, menus | `#1C1C1F` | `oklch(0.22 0.007 200)` |
|
|
|
|
|
|
| `bg.hover` | Hover fill | `#26272B` | `oklch(0.28 0.008 200)` |
|
|
|
|
|
|
| `bg.active` | Pressed fill | `#33343A` | `oklch(0.34 0.010 200)` |
|
|
|
|
|
|
| `border.subtle` | Hairlines | `#26272B` | `oklch(0.28 0.008 200)` |
|
|
|
|
|
|
| `border.strong` | Hover / focus | `#3A3B40` | `oklch(0.36 0.010 200)` |
|
|
|
|
|
|
| `fg.primary` | Body text | `#F4F4F6` | `oklch(0.96 0.004 200)` |
|
|
|
|
|
|
| `fg.secondary` | Subdued text | `#A3A4AA` | `oklch(0.70 0.006 200)` |
|
|
|
|
|
|
| `fg.tertiary` | Captions | `#6B6C72` | `oklch(0.50 0.006 200)` |
|
|
|
|
|
|
| `fg.disabled` | Disabled | `#404145` | `oklch(0.32 0.006 200)` |
|
|
|
|
|
|
|
|
|
|
|
|
### Light palette
|
|
|
|
|
|
|
|
|
|
|
|
Mirrored token names; cyan-tinted off-white so the surface still reads as
|
|
|
|
|
|
Wild Dragon, not as generic white.
|
|
|
|
|
|
|
|
|
|
|
|
| Token | Role | Hex | OKLCH (approx) |
|
|
|
|
|
|
|---|---|---|---|
|
|
|
|
|
|
| `bg.canvas` | Window canvas | `#FAFAFB` | `oklch(0.98 0.003 200)` |
|
|
|
|
|
|
| `bg.rail` | Left rail | `#F0F1F3` | `oklch(0.95 0.004 200)` |
|
|
|
|
|
|
| `bg.surface` | Card / row | `#FFFFFF` | `oklch(1.00 0.000 200)` |
|
|
|
|
|
|
| `bg.elevated` | Popovers, menus | `#FFFFFF` | `oklch(1.00 0.000 200)` (+ shadow) |
|
|
|
|
|
|
| `bg.hover` | Hover fill | `#ECEEF1` | `oklch(0.93 0.005 200)` |
|
|
|
|
|
|
| `bg.active` | Pressed fill | `#E0E3E7` | `oklch(0.89 0.006 200)` |
|
|
|
|
|
|
| `border.subtle` | Hairlines | `#E5E7EB` | `oklch(0.91 0.004 200)` |
|
|
|
|
|
|
| `border.strong` | Hover / focus | `#D1D5DA` | `oklch(0.85 0.006 200)` |
|
|
|
|
|
|
| `fg.primary` | Body text | `#0A0A0A` | `oklch(0.12 0.005 200)` |
|
|
|
|
|
|
| `fg.secondary` | Subdued text | `#4A4B50` | `oklch(0.36 0.006 200)` |
|
|
|
|
|
|
| `fg.tertiary` | Captions | `#71747A` | `oklch(0.53 0.006 200)` |
|
|
|
|
|
|
| `fg.disabled` | Disabled | `#B3B6BC` | `oklch(0.76 0.005 200)` |
|
|
|
|
|
|
|
|
|
|
|
|
### Accents — context-aware
|
|
|
|
|
|
|
|
|
|
|
|
Some accents work in both modes; others need a darker variant for AA contrast
|
|
|
|
|
|
when used as text on the light canvas. The token table splits them:
|
|
|
|
|
|
|
|
|
|
|
|
| Token | Dark | Light | Reserved for |
|
|
|
|
|
|
|---|---|---|---|
|
|
|
|
|
|
| `accent.cyan.surface` | `#97EDF0` | `#97EDF0` | Primary button fill, badge fill (text on top is near-black in both modes — works) |
|
|
|
|
|
|
| `accent.cyan.text` | `#97EDF0` | `#0E7C82` | Cyan-as-text (links, "live" labels, active state) |
|
|
|
|
|
|
| `accent.cyan.hover` | `#B5F2F4` | `#0890A0` | Cyan hover |
|
|
|
|
|
|
| `accent.cyan.muted` | `#1B3537` | `#E6F8F9` | Cyan tint background, active speaker row fill |
|
|
|
|
|
|
| `accent.coral` | `#FB819C` | `#D43E5C` | Destructive, error, alert (as both border + text) |
|
|
|
|
|
|
| `accent.coral.bg` | `#3A1922` | `#FDECF0` | Coral tint background |
|
|
|
|
|
|
| `status.live` | `#4ADE80` | `#15803D` | Recording active, REC dot, "live" pill |
|
|
|
|
|
|
| `status.live.bg` | `#13261A` | `#DCFCE7` | Live pill background |
|
|
|
|
|
|
| `status.warn` | `#FBBF24` | `#B45309` | Low disk, NDI degraded |
|
|
|
|
|
|
|
|
|
|
|
|
**Discipline.** Cyan is the only color that competes with body text for
|
|
|
|
|
|
attention. It earns its place — wasted cyan is the design failing.
|
|
|
|
|
|
`accent.cyan.surface` (#97EDF0) reads identically in both modes because
|
|
|
|
|
|
its text is always near-black. `accent.cyan.text` exists specifically so
|
|
|
|
|
|
captions and inline labels stay readable on a light canvas.
|
|
|
|
|
|
|
|
|
|
|
|
## Theming
|
|
|
|
|
|
|
|
|
|
|
|
### The toggle
|
|
|
|
|
|
|
|
|
|
|
|
A single icon button (sun ↔ moon) lives in the title bar, positioned to the
|
|
|
|
|
|
left of the window controls. One click swaps the theme. State persists via
|
|
|
|
|
|
`UIPreferences.Theme` (`Dark | Light | System`). Default is `System` which
|
|
|
|
|
|
follows the Windows app-mode preference.
|
|
|
|
|
|
|
|
|
|
|
|
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.
|
|
|
|
|
|
|
2026-05-14 12:46:24 -04:00
|
|
|
|
### Implementation (WPF)
|
|
|
|
|
|
|
|
|
|
|
|
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.
|
|
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
|
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
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
`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)
|
|
|
|
|
|
};
|
2026-05-12 23:45:04 -04:00
|
|
|
|
```
|
|
|
|
|
|
|
2026-05-14 12:46:24 -04:00
|
|
|
|
`DynamicResource`-backed `SolidColorBrush` instances re-resolve on the
|
|
|
|
|
|
dictionary swap, so the visual tree repaints without an app restart.
|
2026-05-12 23:45:04 -04:00
|
|
|
|
|
|
|
|
|
|
### System mode
|
|
|
|
|
|
|
2026-05-14 12:46:24 -04:00
|
|
|
|
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.
|
2026-05-12 23:45:04 -04:00
|
|
|
|
|
|
|
|
|
|
## Typography
|
|
|
|
|
|
|
|
|
|
|
|
### Scale (1.25 step ratio enforced)
|
|
|
|
|
|
|
|
|
|
|
|
| Token | Family | Size | Weight | Line-height | Letter-spacing |
|
|
|
|
|
|
|---|---|---|---|---|---|
|
|
|
|
|
|
| `text.display` | Inter | 22 | 600 | 1.2 | -0.01em |
|
|
|
|
|
|
| `text.title` | Inter | 18 | 600 | 1.25 | -0.005em |
|
|
|
|
|
|
| `text.heading` | Inter | 14 | 600 | 1.3 | 0 |
|
|
|
|
|
|
| `text.body` | Inter | 13 | 400 | 1.45 | 0 |
|
|
|
|
|
|
| `text.subtle` | Inter | 13 | 400 | 1.45 | 0 |
|
|
|
|
|
|
| `text.caption` | Inter | 11 | 500 | 1.3 | 0.04em (smallcaps) |
|
|
|
|
|
|
| `text.mono` | JetBrains Mono | 12 | 400 | 1.4 | 0 |
|
|
|
|
|
|
|
|
|
|
|
|
Body text caps at 65–75ch where it wraps. Inline status text doesn't wrap —
|
|
|
|
|
|
it truncates with ellipsis.
|
|
|
|
|
|
|
2026-05-14 12:46:24 -04:00
|
|
|
|
### Fonts in WPF
|
2026-05-12 23:45:04 -04:00
|
|
|
|
|
2026-05-14 12:46:24 -04:00
|
|
|
|
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.
|
2026-05-12 23:45:04 -04:00
|
|
|
|
|
|
|
|
|
|
## Spacing (8px grid)
|
|
|
|
|
|
|
|
|
|
|
|
| Token | Value | Use |
|
|
|
|
|
|
|---|---|---|
|
|
|
|
|
|
| `space.xs` | 4 | Icon-to-text, tiny gaps |
|
|
|
|
|
|
| `space.s` | 8 | Row internal padding, pill padding |
|
|
|
|
|
|
| `space.m` | 12 | Card internal padding |
|
|
|
|
|
|
| `space.l` | 16 | Card padding, between cards |
|
|
|
|
|
|
| `space.xl` | 24 | Section gap |
|
|
|
|
|
|
| `space.xxl` | 32 | Page edge padding |
|
|
|
|
|
|
| `space.xxxl` | 48 | Hero section / large blocks |
|
|
|
|
|
|
|
|
|
|
|
|
**Rhythm rule.** No two adjacent regions share the same padding value. The
|
|
|
|
|
|
participant table breathes at `space.xl`; in-row controls compress to
|
|
|
|
|
|
`space.s`. Same padding everywhere is monotony.
|
|
|
|
|
|
|
|
|
|
|
|
## Radii
|
|
|
|
|
|
|
|
|
|
|
|
| Token | Value | Use |
|
|
|
|
|
|
|---|---|---|
|
|
|
|
|
|
| `radius.s` | 6 | Pills, inline tags, menu items |
|
|
|
|
|
|
| `radius.m` | 8 | Buttons, text inputs, dropdowns |
|
|
|
|
|
|
| `radius.l` | 12 | Cards, drawers, modals |
|
|
|
|
|
|
| `radius.pill` | 999 | Status pills, ISO toggle |
|
|
|
|
|
|
|
|
|
|
|
|
## Elevation
|
|
|
|
|
|
|
|
|
|
|
|
Elevation through **tone**, not through shadow. The dark surface makes
|
|
|
|
|
|
realistic drop-shadows look bolted-on. A `bg.elevated` tone difference does
|
|
|
|
|
|
the same job with less visual noise.
|
|
|
|
|
|
|
|
|
|
|
|
| Layer | Background | Border |
|
|
|
|
|
|
|---|---|---|
|
|
|
|
|
|
| Canvas | `bg.canvas` | none |
|
|
|
|
|
|
| Card | `bg.surface` | `border.subtle` |
|
|
|
|
|
|
| Drawer / Popover | `bg.elevated` | `border.strong` |
|
|
|
|
|
|
| Modal | `bg.elevated` | `border.strong` + 50% canvas scrim |
|
|
|
|
|
|
|
|
|
|
|
|
## Icons
|
|
|
|
|
|
|
|
|
|
|
|
**Single icon system, one stroke width, one optical size.** The previous GUI
|
|
|
|
|
|
inlined ~12 bespoke `<Path Data="...">` icons with stroke widths varying
|
2026-05-15 19:16:20 -04:00
|
|
|
|
between 1.2 and 1.6. The redesign uses **Segoe Fluent Icons font** (shipped
|
|
|
|
|
|
with Windows 11; falls back to Segoe MDL2 Assets on Windows 10) as the
|
|
|
|
|
|
baseline, with a custom subset added only where a broadcast concept isn't
|
|
|
|
|
|
covered (e.g. NDI signal lock, ISO routing state).
|
2026-05-12 23:45:04 -04:00
|
|
|
|
|
|
|
|
|
|
Sizes: 16 (inline), 20 (button), 24 (rail / hero).
|
|
|
|
|
|
Stroke: inherited from font; no hand-stroked paths.
|
|
|
|
|
|
|
|
|
|
|
|
## Motion
|
|
|
|
|
|
|
|
|
|
|
|
- Ease-out exponential (`cubic-bezier(0.16, 1, 0.3, 1)`) for entry.
|
|
|
|
|
|
- Ease-in-out for state changes that aren't entries.
|
|
|
|
|
|
- Durations: 120ms for affordance feedback, 200ms for panel transitions,
|
|
|
|
|
|
280ms hero (rarely used).
|
|
|
|
|
|
- No bounce. No elastic. No spring overshoots.
|
2026-05-15 19:16:20 -04:00
|
|
|
|
- **Never animate** layout properties. Animate `RenderTransform` and
|
|
|
|
|
|
`Opacity` (WPF's composition layer handles these GPU-cheaply).
|
2026-05-12 23:45:04 -04:00
|
|
|
|
|
|
|
|
|
|
## Component decisions
|
|
|
|
|
|
|
|
|
|
|
|
### Buttons — finally have a real hierarchy
|
|
|
|
|
|
|
|
|
|
|
|
The previous design used `Wd.Button.Ghost` for everything. The redesign has
|
|
|
|
|
|
**three commitments**:
|
|
|
|
|
|
|
|
|
|
|
|
| Variant | Use | Look |
|
|
|
|
|
|
|---|---|---|
|
|
|
|
|
|
| `Primary` | Single per surface, the brand action ("Apply", "Start session") | Cyan fill, near-black text |
|
|
|
|
|
|
| `Secondary` | Common operator actions ("Refresh", "Presets") | Transparent fill, `border.strong`, hover cyan border |
|
|
|
|
|
|
| `Tertiary` | Inline, low-frequency ("Dismiss", "Show advanced") | Text-only, no border, cyan on hover |
|
|
|
|
|
|
| `Destructive` | Stop, leave, delete | Coral border, coral text, no fill |
|
|
|
|
|
|
|
|
|
|
|
|
**One Primary per surface.** If a screen has two primaries, the design is
|
|
|
|
|
|
unranked.
|
|
|
|
|
|
|
|
|
|
|
|
### ISO toggle — keep, refine
|
|
|
|
|
|
|
|
|
|
|
|
The status-coded pill (LIVE cyan / ERROR coral / NO SIGNAL amber) is good.
|
|
|
|
|
|
Two evolutions:
|
|
|
|
|
|
|
|
|
|
|
|
1. The hover treatment thickens to a 2px cyan border — preserve.
|
|
|
|
|
|
2. Add a half-height ascender showing instantaneous audio level above the
|
|
|
|
|
|
pill. The operator sees who's talking without needing the active-speaker
|
|
|
|
|
|
row highlight to fire on next tick.
|
|
|
|
|
|
|
|
|
|
|
|
### Tables (Participants)
|
|
|
|
|
|
|
|
|
|
|
|
This is the product. The table gets:
|
|
|
|
|
|
|
|
|
|
|
|
- Row height 56 (current) → 64 to give the audio meter + signal indicator
|
|
|
|
|
|
room to breathe.
|
|
|
|
|
|
- The "active speaker" cyan left-border treatment stays. It's good.
|
|
|
|
|
|
- One participant action per row at rest (the ISO toggle). Other actions
|
|
|
|
|
|
(open preview, custom name, presets) live in a right-click context menu
|
|
|
|
|
|
(already exists) and in a row hover-revealed kebab — *not* visible at rest.
|
|
|
|
|
|
- Column count: avatar+name · NDI signal+codec · audio meter · output name ·
|
|
|
|
|
|
ISO toggle. Five columns. The current six-plus + custom-name editing
|
|
|
|
|
|
inline pushes density too far.
|
|
|
|
|
|
|
|
|
|
|
|
### Status — one place, not three
|
|
|
|
|
|
|
|
|
|
|
|
Recording / disk / session / control-surface state currently lives in:
|
|
|
|
|
|
1. Rail bottom dot (engine status)
|
|
|
|
|
|
2. Header right pill (status text)
|
|
|
|
|
|
3. Footer columns (six monospace fields)
|
|
|
|
|
|
|
|
|
|
|
|
The redesign consolidates to **two places only**:
|
|
|
|
|
|
|
|
|
|
|
|
- **Header right** — session timer, REC indicator + count, disk-free.
|
|
|
|
|
|
These are at-a-glance.
|
|
|
|
|
|
- **Status overlay (popover from rail bottom dot)** — control surface URLs,
|
|
|
|
|
|
log path, version, control-surface tokens. These are on-demand.
|
|
|
|
|
|
|
|
|
|
|
|
The footer goes away entirely. It was theatre, not information.
|
|
|
|
|
|
|
|
|
|
|
|
### Settings — drawer, not permanent panel
|
|
|
|
|
|
|
|
|
|
|
|
The 380px right settings panel is the single biggest spatial misallocation.
|
|
|
|
|
|
Settings are rarely changed mid-show. The redesign moves them to a **right-side
|
|
|
|
|
|
drawer** that slides in over the participants area, dismissable with `Esc`.
|
|
|
|
|
|
The participants table reclaims full width when the drawer is closed.
|
|
|
|
|
|
|
|
|
|
|
|
Trigger: rail "settings" icon. Same affordance as today, different surface.
|
|
|
|
|
|
|
|
|
|
|
|
### Onboarding
|
|
|
|
|
|
|
|
|
|
|
|
First-launch only. Three panes max, each one panes deep — no carousel.
|
|
|
|
|
|
Operator-tone copy ("Pick your NDI groups" not "Welcome to TeamsISO!").
|
|
|
|
|
|
Skippable from the first frame.
|
|
|
|
|
|
|
|
|
|
|
|
### Empty states
|
|
|
|
|
|
|
|
|
|
|
|
The participants table empty state currently is implicit (rows just don't
|
|
|
|
|
|
appear). The redesign adds **one** empty state with a single instructive
|
|
|
|
|
|
sentence ("No NDI sources yet — open Teams and start a meeting") and a
|
|
|
|
|
|
single secondary button ("Refresh"). No illustration. No mascot.
|
|
|
|
|
|
|
|
|
|
|
|
## Anti-patterns specific to this app (audited against absolute bans)
|
|
|
|
|
|
|
|
|
|
|
|
The current XAML has none of the impeccable absolute bans (no gradient text,
|
|
|
|
|
|
no side-stripe borders, no glassmorphism). It does have:
|
|
|
|
|
|
|
|
|
|
|
|
- **Identical card grids** — the in-call control bar's seven identical ghost
|
|
|
|
|
|
buttons. Redesign: collapse to a single dense bar with primary controls
|
|
|
|
|
|
surfaced and secondary controls in an overflow menu.
|
|
|
|
|
|
- **Status duplication** — fix as above.
|
|
|
|
|
|
- **Bespoke SVG icons** — fix as above.
|
|
|
|
|
|
|
|
|
|
|
|
## Migration boundary
|
|
|
|
|
|
|
|
|
|
|
|
The view-model surface in `src/TeamsISO.App/ViewModels/` is the contract.
|
2026-05-15 19:16:20 -04:00
|
|
|
|
The redesign rewrites `MainWindow.xaml` and `Themes/*` but leaves view-model
|
2026-05-12 23:45:04 -04:00
|
|
|
|
properties and commands untouched. Any place where the redesign needs a new
|
|
|
|
|
|
piece of view-model state, the contract widens via additive properties —
|
|
|
|
|
|
existing bindings keep working until the new view stops needing the old shape.
|
|
|
|
|
|
|
|
|
|
|
|
This means: the engine, the OSC bridge, the control surface, the preset
|
|
|
|
|
|
store, the recording pipeline — none of those move. The redesign is
|
|
|
|
|
|
a frontend-only operation.
|