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

14 KiB
Raw Blame History

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:

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.