Captures the impeccable context for the GUI redesign greenlit on 2026-05-12:
PRODUCT.md - register=product, primary persona=solo broadcast operator at
1:50am with a live show twenty minutes out. Strategic principles foreground
progressive disclosure over progressive density. Anti-references explicitly
name the "vibe-coded GUI" failure mode (cards-in-a-grid, hero-metric template,
Zoom pastel, generic SaaS dashboard) so the redesign can be measured against
what it must not become.
DESIGN.md - tokens for the WinUI 3 replatform target. Dark + light palettes
as ThemeDictionary entries, context-aware accent split (accent.cyan.surface
for fill, accent.cyan.text for the darker AA-passing variant on light bg).
Theming via {ThemeResource}; toggle in title bar + settings drawer; System/
Dark/Light tri-state persisted in UIPreferences.Theme.
14 KiB
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).
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.
Implementation (WinUI 3)
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}:
<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>
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.
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.
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.
Fonts in WinUI 3
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.
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
TranslationandOpacity(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:
- The hover treatment thickens to a 2px cyan border — preserve.
- 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:
- Rail bottom dot (engine status)
- Header right pill (status text)
- 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.