dragon-iso/DESIGN.md
Zac Gaetano c27130302f
Some checks failed
CI / build-and-test (push) Failing after 31s
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
2026-05-14 12:46:24 -04:00

339 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# DESIGN.md — TeamsISO design system
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
### 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.0050.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.
### 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)
};
```
`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"`, `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
### 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 6575ch where it wraps. Inline status text doesn't wrap —
it truncates with ellipsis.
### Fonts in WPF
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)
| 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
between 1.2 and 1.6. The redesign uses **WinUI 3's bundled Segoe Fluent Icons
font** as the baseline, with a custom subset added only where a broadcast
concept isn't covered (e.g. NDI signal lock, ISO routing state).
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.
- **Never animate** layout properties. Animate `Translation` and `Opacity`
(WinUI 3's composition layer handles these GPU-cheaply).
## 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.
The redesign rewrites everything in `Views/` (WinUI 3) but leaves view-model
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.