diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..dd68092 --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,325 @@ +# 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}`: + +```xml + + + + #0A0A0A + + + + #FAFAFB + + + + +``` + +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 `` 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. diff --git a/PRODUCT.md b/PRODUCT.md new file mode 100644 index 0000000..643d692 --- /dev/null +++ b/PRODUCT.md @@ -0,0 +1,174 @@ +# PRODUCT.md — TeamsISO + +## Register + +**Product.** This is a tool, not a destination. The design serves the operator +running a live broadcast. The UI is judged by how invisible it gets once the +show is rolling. + +## Product purpose + +TeamsISO is a per-participant NDI ISO controller for Microsoft Teams. It sits +between Teams' raw NDI broadcast output and a live-production switcher (vMix, +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 + 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. + +External control surface (REST + WebSocket + OSC on localhost) lets a +Companion / Stream Deck / TouchOSC controller drive routing remotely. + +## Users — the primary persona + +**Solo operator.** One person, one Windows laptop or desk machine, often +running the show alone from a hotel room, conference green room, or home +studio. Picture them at 1:50am, twenty minutes before a live international +broadcast, ambient room lights down, the Teams call already started, four +guests joining staggered over the next ten minutes. They need to: + +- See which participants are present, online, and producing NDI signal. +- Toggle each one's ISO on as they join. +- Confirm at a glance that recording is live, the disk has room, and the + control surface is reachable. +- Drop a marker if the host says something quotable. +- Mute themselves without alt-tabbing. + +If the UI demands more than a glance for any of those, the show suffers. + +### Secondary personas (informed, not designed-for) + +- **TD at a broadcast desk** — multi-monitor, may use the OSC bridge to a + hardware control surface. Can tolerate a denser layout because their eyes + aren't the only thing on the surface. +- **Producer monitoring** — glances occasionally, mostly hands-off. Will see + this app over someone's shoulder; first read matters. +- **IT/AV admin** — installs it once, tunes config, walks away. Needs settings + to be findable, not present-at-all-times. + +The design optimizes for the solo operator. Everyone else is downstream. + +## Brand + +**Wild Dragon LLC.** Reference: wilddragon.net. + +Palette anchors: +- Canvas: near-black (`#0A0A0A`) +- Primary accent: cyan (`#97EDF0`) +- Secondary blue: (`#9AE0FD`) +- Coral (error / destructive): (`#FB819C`) +- Earth (warning): (`#423825`) + +Typography: +- Sans: **Inter** (variable, bundled as a resource — not assumed installed). +- Mono: **JetBrains Mono** (also bundled). + +The brand carries the surface but doesn't shout. Wild Dragon's authority is +in the restraint, not the saturation. + +## Voice and tone + +**Operator-first, terse, broadcaster-native.** The UI talks like a confident +peer, not a Slack bot. + +- "Stop all" not "Are you sure you want to stop all ISOs?" +- "Disk low — 8.3 GB" not "Heads up! Your disk space is running low." +- "Joined call · 4 guests" not "You have successfully joined a Teams meeting!" +- Numbers carry their unit, no sentence wraps them. +- Never apologetic. Never bubbly. Never "Let's get started!" + +When something goes wrong, name it: "NDI receiver dropped — restarting" beats +"Something went wrong, please try again." + +## Strategic principles + +These are the design's load-bearing commitments. Any choice that contradicts +one of these is wrong, even if it would otherwise be pretty. + +### 1. One operator, one screen, one show. + +The design is for someone running a live broadcast alone. Their attention +budget for chrome is roughly zero. Anything that's not the participants table +should fade until it's needed. + +### 2. The participants table IS the product. + +Everything else is support staff. Routing toggles, ISO state, and per-guest +signal health get the real estate, the contrast, and the typographic hierarchy. + +### 3. Progressive disclosure, not progressive density. + +The current GUI's failure mode is "every feature gets its own visible button." +The redesign's failure mode would be the opposite — burying important things +in menus. The discipline: surface the half-dozen actions an operator needs +mid-show; hide setup, presets, control-surface config, and exotic options +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. + +### 5. Confident neutrality over decorative warmth. + +This is a broadcast tool. It looks like one. No empty-state mascots, no +illustrated onboarding cards, no celebratory toasts. Restraint is the brand. + +## Anti-references — what this is NOT + +The "vibe-coded GUI" failure mode is the enemy. The redesign should never +read as AI-generated. Concretely, this means none of: + +- **Generic SaaS dashboard.** No "hero metric + supporting stats + gradient + accent" cards. No "icon + heading + body text" card grids. +- **Cards-in-a-grid template.** Same-sized cards repeated endlessly is the + defining LLM-design tell. If a layout would benefit from cards-in-a-grid, + it benefits more from a table. +- **Card-with-icon-and-text rows.** The in-call control bar's current + "icon + label" buttons (Mute / Camera / Share / Marker / Notes / Leave) + read AI-generated. The redesign uses iconography differently. +- **Zoom pastel.** Soft purples, friendly mint greens, rounded everything, + Inter-at-low-weight. +- **Skeuomorphic broadcast hardware.** No woodgrain, no chrome bezels, no + fake LCD readouts, no metallic gradients. Wild Dragon's confidence is in + flat surfaces with real typography. +- **Tour-everything onboarding.** No "Let's get started!" wizards with cute + copy. The OnboardingWindow exists for first-launch config, not pageantry. +- **Modal-as-first-thought.** Settings, presets, help all currently live in + modals; some should be drawers or inline-progressive. Modal is a last resort. + +## Technical constraints (informing design) + +- Windows-only (Teams' NDI is Windows-only anyway). +- **WinUI 3 / Windows App SDK** is the new frontend target. +- 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). +- 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`) + must not regress — its HTML control panel at `/ui` is a separate design + surface but shares brand tokens. + +## What "done" looks like + +The redesign is finished when: + +1. A first-time operator can launch TeamsISO, join a Teams meeting, and + route their first ISO without reading documentation. +2. A returning operator at 1:50am can find the four things they need + (participant signal · ISO toggle · recording state · disk free) in under + half a second of glance. +3. Nothing on the surface reads as AI-generated. Show this to a working + broadcast engineer and they say "someone who knows the job built this." +4. The design system is documented in DESIGN.md tightly enough that a future + contributor can add a new view that looks like it belongs.