Dev Variant Presenter — visual design + layout
Build the look, not just the behavior. Vanilla HTML/CSS/JS (no framework). This is the target
design; match it closely (colors exact, layout + components as specified).
⚠ SERVING CONTRACT — get this right or the entire UI is silently DEAD (do this FIRST)
The frontend is one IIFE. If any <script> 404s, NOTHING runs — Open does nothing, no chips, no
stream — and it looks like "the stream won't connect," not a 404. The base path the HTML references
MUST match where the server actually serves the files. This is the single most common way the whole
app ships broken (it happened: <script src="./app.js"> resolves to /app.js, but the assets were
mounted at /web → every script 404'd → 100% dead UI while every API test passed).
Pick ONE consistent scheme:
- Root scheme (simplest): mount
app.mount("/", StaticFiles(directory=WEB, html=True)) and reference
scripts at root: <script src="/app.js">. Register ALL /api/* and /events routes BEFORE the
mount so they take precedence; the / mount then serves index.html and every sibling asset.
- Prefixed scheme: mount at
/web and reference every asset under it: <script src="/web/app.js">,
<link href="/web/styles.css">, the fetched WebRTC lib at /web/<lib>.js, etc.
Never mix (root-relative src + a prefixed mount). After wiring, curl every <script src>/<link href>
and confirm 200 + a JS/CSS content-type — see stability-checklist items 11 & 12. Serve index.html
with Cache-Control: no-cache, no-store, must-revalidate. That alone is the cache story — do NOT add
?v=N query strings to the asset tags; they are redundant with it and advertise a revision count.
Theme tokens (use these EXACT values — define as CSS variables)
:root {
--bg:#0d1117; /* app background (near-black, GitHub-dark) */
--panel:#161b22; /* header, side panel, timeline strip */
--line:#30363d; /* all borders / dividers */
--txt:#e6edf3; /* primary text */
--muted:#8b949e; /* labels, secondary text, h2 headings */
--green:#76b900; /* NVIDIA green — THE accent: active/selected/live, sliders, progress */
--accent:#1f6feb; /* blue — timeline variant clips */
}
* { box-sizing:border-box; }
body { margin:0; font-family:-apple-system,'Segoe UI',Roboto,sans-serif; background:var(--bg);
color:var(--txt); height:100vh; display:flex; flex-direction:column; }
- The single accent is NVIDIA green
#76b900. Every active/selected/on state (tab, segmented
button, chip, mode button, timeline transport) is green background with near-black text
#04130a. Hover borders go green (border-color:var(--green)).
- Buttons:
background:#21262d; border:1px solid var(--line); border-radius:6px; padding:7px 12px; font-weight:600. Inputs/selects: background:#0d1117; border:1px solid var(--line); border-radius:6px.
- Status badge (top-right pill, uppercase): default muted;
.live = green bg / #04130a;
.warming = #fb8500; .error = #cf222e.
Layout (three regions)
┌ header ────────────────────────────────────────────────────────────────┐
│ h1 "Dev Variant Presenter" (green) │ [stage path input flex:1] [Open] │ STATUS pill │
├ main (flex row) ─────────────────────────────────────┬─ panel-resize ─┬ aside.panel (330px) ┐
│ .stage-col (flex:1, column) │ (6px col-resize)│ .tabs: Configure │
│ .viewport (flex:1, black; <video> + results-img/video overlay) │ │ Grid Timeline │
│ below-viewport DOCK (340px): TIMELINE strip ONLY │ │ Results │
│ (Results media overlays the .viewport, not here) │ │ .pane (active) │
│ │ │ .pane (active) │
└───────────────────────────────────────────────────────────────────────┴─────────────────────┘
header: flex row, padding:10px 14px, background:var(--panel), bottom border. h1
font-size:16px; color:var(--green). The stage <input> is flex:1. Status pill has
margin-left:auto.
main: flex:1; display:flex. .stage-col { flex:1; display:flex; flex-direction:column }.
A draggable .panel-resize (6px, cursor:col-resize, hover green) splits it from the panel.
.viewport: position:relative; flex:1; background:#000, centers #remote-video
(max-width/height:100%). Overlays live here: an #overlay message, a crosshair pick layer,
an SVG gizmo, a hint chip. The turntable pivot gizmo is a plain SVG/DOM overlay drawn from
/api/project screen coordinates — do NOT adopt ovui (or any native/in-renderer widget kit) for
it. The gizmo is client-side UI over the ovstream video, not scene geometry; ovui widgets are for
an in-viewport native overlay pipeline this app doesn't use. Keep #gizmo as SVG.
aside.panel: width:330px; border-left:1px solid var(--line); background:var(--panel); display:flex; flex-direction:column; overflow:hidden.
The right panel — 4 tabs
.tabs is a flex row of 4 buttons; the active tab is green TEXT with a green
border-bottom:2px on the dark bar — NOT a solid green filled block. Filling the active
tab with #76b900 while leaving the label green/white-on-green makes the tab name UNREADABLE.
Whatever the styling, every tab label (active + inactive) must keep text/background contrast ≥ 3:1
(aim 4.5:1) — this is gate-checked on computed styles. Each .pane is display:none unless .active. Inside a pane, content is
grouped into .blocks (padding:12px 14px; border-bottom:1px solid var(--line)) with an h2
(font-size:12px; text-transform:uppercase; letter-spacing:.6px; color:var(--muted)). A
.block.grow { flex:1; overflow-y:auto } holds the scrolling list at the bottom of a pane.
- Configure (live look): blocks — Camera (a
<select> of authored cameras + Save
framing / Reset camera) · Render mode (a .seg of Real-Time / Path Tracing) · Display
(read-only stage info + an aspect/resolution <select> + range sliders for ISO / focal length
/ f-stop + a focus-distance number with a Pick button) · Turntable (Pick pivot / Remove
- a nudge gizmo + frames + Create / Preview spin) · Variant sets (
.block.grow: one
.vcard per set, each a row of .chips).
- Grid: Matrix mode (
.seg One-at-a-time / Full Cartesian) · Quality & output
(.seg Real-Time / Path Tracing + W/H/SPP + an Animation-range checkbox + output folder) ·
Cameras — a COMPACT dropdown (#grid-cameras; a multi-select dropdown or a dropdown button
opening a checkbox popover, still selecting one OR several cameras) — NOT a tall per-camera
checklist: an ~18-camera stage (ConceptCar) must NOT push Include sets off-screen ·
Include sets (.block.grow, ticked sets get a green
border) · an estimate line + Render / Cancel + a progress bar.
- Timeline: Project (name + Save/Open/Delete) · Saved track views · explanatory help
text pointing at the strip below the viewport.
- Results: the SIDE PANEL holds only the controls + the list — Results folder (+ Refresh) ·
Post-process (
.seg Overlay labels / Cut sheet) · Renders (a <select> list of
stills/sequences/videos; the list is good). The chosen media takes over the MAIN VIEWPORT PANEL —
#results-img/#results-video are children of the .viewport section, overlaying #remote-video,
shown (.show) only on the Results tab; selecting a render replaces the live viewport with the
still/MP4 IN PLACE. It is NOT a separate player in a below-viewport dock (there is no
#results-dock). The multi-frame #results-slider+#results-frame-label control it from the side
panel. (Earlier guidance wrongly said "below-viewport dock" — align to the reference: results media =
in the viewport panel, where #remote-video lives.)
Components
- Pill chip (
.chip): display:inline-flex; align-items:center; font-size:11px; padding:3px 9px; border-radius:12px; border:1px solid var(--line); background:#161b22; color:var(--muted).
.on = green bg / #04130a / bold. A .swatch dot (11×11, border-radius:3px) shows the
variant's representative color. Wire these dots up — the usd-variant-scan-classify
classifier emits per-variant swatch hex (swatches: {set:{variant:"#rrggbb"}} on the
classified event); set each chip's .swatch background from it (hide the dot only when no
color was detected). Chips without swatch dots are an incomplete look.
- Segmented control (
.seg): a flex row of equal buttons (flex:1); the active one is green.
- Range sliders:
accent-color:var(--green). Progress bar: a .bar filled green.
- Section info popovers: a small circled "i" (
.info) and data-help attributes that show a
fixed popover bordered green — optional but on-brand.
The below-viewport dock is TAB-CONTEXTUAL — and it is the TIMELINE's, only
The strip area under the viewport (#timeline-strip) is NOT a permanent fixture:
- Timeline tab active → the NLE timeline strip shows there.
- Configure / Grid / Results → the dock is hidden; the viewport uses full height.
- Results media does NOT go in this dock. The selected still/MP4 OVERLAYS the MAIN VIEWPORT
(
#results-img/#results-video inside .viewport, over #remote-video, .show on the Results tab) —
it replaces the live viewport in place. There is no separate #results-dock below the viewport.
Drive it off the active tab: #timeline-strip is shown only while the Timeline tab is .active (e.g.
toggle a class on switchTab), hidden on every other tab even with a stage open. (Leaving the
timeline strip visible on ALL tabs whenever a stage is open is the bug to avoid.)
Timeline strip (the NLE — shown ONLY on the Timeline tab)
#timeline-strip { height:340px; border-top:1px solid var(--line); background:var(--panel) },
shown only when the Timeline tab is active (hidden on Configure/Grid/Results even with a stage open),
top edge drag-resizable. Three parts:
- a toolbar (
.tl-toolbar, wraps): playhead time (green, tabular-nums), length, append-mode
toggle (Stack / At playhead), presets (Slideshow / Mixer / Clear), new-clip duration + fps,
and a right-aligned render group (output folder + Render to MP4 in green + a progress bar).
- a ruler row with a transport gutter on the left (to-start / step-back / play / step-fwd /
to-end / loop, ~22px square buttons) and tick marks.
- tracks: one row per variant set + a camera track; each row has a sticky left label
(
var(--label-w,230px)) and absolutely-positioned clips. Variant clips are blue
(var(--accent)), camera clips purple (#8957e5), the selected clip has a green outline. A
green 2px playhead line overlays the tracks. Each clip carries a ▾ to change its variant (and a
tiny COLORED swatch ONLY when that variant has a detected swatch color — never a placeholder white box);
drag to move, drag the right edge to resize. Any edit re-applies the state at the playhead immediately.
Layout: the viewport shrinks for the dock ONLY when the dock is showing (flex sizing — common bug)
When the dock IS showing (the Timeline strip, on the Timeline tab), the viewport must SHRINK to make room
at any window height and any stream resolution — otherwise a 1080p stream makes the viewport tall and
pushes the 340px strip below the fold. When the dock is hidden (Configure/Grid/Results), the viewport uses
full height. Results media overlays the viewport (absolute, inside .viewport) — it does NOT take dock
space. Required flex rules:
.stage-col { flex:1; display:flex; flex-direction:column; min-height:0; overflow:hidden; } /* min-height:0 + overflow:hidden are both essential */
.viewport { flex:1; min-height:0; overflow:hidden; position:relative; } /* shrinks; never min-content */
#remote-video { max-width:100%; max-height:100%; } /* letterboxed, never forces height */
#results-img, #results-video { position:absolute; inset:0; } /* overlay the viewport on Results */
#timeline-strip { flex:none; height:340px; } /* the dock = TIMELINE only */
min-height:0 alone is not enough: a flex child's intrinsic content size (a raw <video>/<img> at its
native 1920×1080) can still overflow and push #tl-resize's drag handle (and the whole strip) off-screen
or force page scroll — overflow:hidden on BOTH .stage-col and .viewport is what actually clips the
oversized intrinsic size so the flexbox shrink takes effect. Verify at a normal window height (~800–900px):
on the Timeline tab the strip (and its #tl-resize top-edge handle) is fully visible and draggable without
scrolling, even with a 1920×1080 stream; on the Results tab the video + its scrubber are; on Configure/Grid
the viewport is full-height with no strip. (Make the dock height user-resizable via #tl-resize, but it
must never be clipped or push the page into scroll.)
Aspect / resolution must offer real ASPECT changes (not just 16:9 sizes)
The Display "Aspect / resolution" <select> must include genuinely different aspect ratios,
matching production — not three 16:9 resolutions. Provide at least:
1280×720 (16:9) · 1920×1080 (16:9) · 1080×1080 (1:1) · 1080×1920 (9:16) ·
1440×1080 (4:3) · 1920×804 (2.39:1). Changing it rebuilds the ovstream Server at the new
size (brief reconnect via the stream-reconnect path) AND updates the camera's
verticalAperture = horizontalAperture * h/w so the framing isn't stretched; the Grid tab
inherits the current size as its render default. Verify switching to 1:1 / 9:16 actually changes
the streamed frame's shape, not just its pixel count.
Pick flows must be wired end-to-end (backend working ≠ feature working)
- Focus picker: arm the Pick button → show the crosshair overlay over the video → on click,
read normalized coords from the
<video> rect → POST /api/pick-focus {nx,ny} → apply the
returned focus_distance to the focus field AND the live camera (this last step is the one
that gets dropped — the server returns a distance but the UI never uses it, so "focus picker
does nothing"). Disarm + remove the crosshair after.
- Turntable pivot: same arm→click pattern but
POST /api/pick-point {nx,ny} → drop the pivot
gizmo at the returned world point. (pick-focus returns a distance, not a point — using it for
the pivot is wrong.)
Verify the look
Open the app and confirm: green #76b900 accent on the active tab / selected chip / LIVE badge;
the dark #0d1117 background; the header with the title + stage field + Open + status pill; the
330px right panel with the four named tabs; variant chips with swatch dots. Switch to the Timeline
tab → the NLE strip appears below the viewport (green playhead, blue/purple clips); switch to
Configure/Grid → the strip is GONE and the viewport is full height; switch to Results, pick an
MP4 → it plays IN THE VIEWPORT AREA, overlaying the stream, with its scrubber/transport below the
video (not in the side panel). It should be recognizably the same product, not a generic form over the stream.
1---2name: omniverse-dev-variant-presenter-ui3description: The visual design + layout for the Dev Variant Presenter frontend — the GitHub-dark + NVIDIA-green theme, the header / viewport+timeline / right-side tabbed control panel layout, and the component styles (pill chips with swatches, segmented buttons, blocks, the NLE timeline strip). Use when building or styling the Variant Presenter browser UI — also known as the "variant studio" UI in the walkthrough video and earlier releases — so it looks and feels like the product, not just functions like it.4license: Apache-2.05---67# Dev Variant Presenter — visual design + layout89Build the look, not just the behavior. Vanilla HTML/CSS/JS (no framework). This is the target10design; match it closely (colors exact, layout + components as specified).1112## ⚠ SERVING CONTRACT — get this right or the entire UI is silently DEAD (do this FIRST)13The frontend is one IIFE. If any `<script>` 404s, NOTHING runs — Open does nothing, no chips, no14stream — and it looks like "the stream won't connect," not a 404. **The base path the HTML references15MUST match where the server actually serves the files.** This is the single most common way the whole16app ships broken (it happened: `<script src="./app.js">` resolves to `/app.js`, but the assets were17mounted at `/web` → every script 404'd → 100% dead UI while every API test passed).1819Pick ONE consistent scheme:20- **Root scheme (simplest):** mount `app.mount("/", StaticFiles(directory=WEB, html=True))` and reference21 scripts at root: `<script src="/app.js">`. Register ALL `/api/*` and `/events` routes BEFORE the22 mount so they take precedence; the `/` mount then serves `index.html` and every sibling asset.23- **Prefixed scheme:** mount at `/web` and reference every asset under it: `<script src="/web/app.js">`,24 `<link href="/web/styles.css">`, the fetched WebRTC lib at `/web/<lib>.js`, etc.25Never mix (root-relative `src` + a prefixed mount). After wiring, **`curl` every `<script src>`/`<link href>`26and confirm 200 + a JS/CSS content-type** — see stability-checklist items 11 & 12. Serve `index.html`27with `Cache-Control: no-cache, no-store, must-revalidate`. That alone is the cache story — do NOT add28`?v=N` query strings to the asset tags; they are redundant with it and advertise a revision count.2930## Theme tokens (use these EXACT values — define as CSS variables)3132```css33:root {34 --bg:#0d1117; /* app background (near-black, GitHub-dark) */35 --panel:#161b22; /* header, side panel, timeline strip */36 --line:#30363d; /* all borders / dividers */37 --txt:#e6edf3; /* primary text */38 --muted:#8b949e; /* labels, secondary text, h2 headings */39 --green:#76b900; /* NVIDIA green — THE accent: active/selected/live, sliders, progress */40 --accent:#1f6feb; /* blue — timeline variant clips */41}42* { box-sizing:border-box; }43body { margin:0; font-family:-apple-system,'Segoe UI',Roboto,sans-serif; background:var(--bg);44 color:var(--txt); height:100vh; display:flex; flex-direction:column; }45```4647- **The single accent is NVIDIA green `#76b900`.** Every active/selected/on state (tab, segmented48 button, chip, mode button, timeline transport) is green background with near-black text49 `#04130a`. Hover borders go green (`border-color:var(--green)`).50- Buttons: `background:#21262d; border:1px solid var(--line); border-radius:6px; padding:7px 12px;51 font-weight:600`. Inputs/selects: `background:#0d1117; border:1px solid var(--line);52 border-radius:6px`.53- Status badge (top-right pill, uppercase): default muted; `.live` = green bg / `#04130a`;54 `.warming` = `#fb8500`; `.error` = `#cf222e`.5556## Layout (three regions)5758```59┌ header ────────────────────────────────────────────────────────────────┐60│ h1 "Dev Variant Presenter" (green) │ [stage path input flex:1] [Open] │ STATUS pill │61├ main (flex row) ─────────────────────────────────────┬─ panel-resize ─┬ aside.panel (330px) ┐62│ .stage-col (flex:1, column) │ (6px col-resize)│ .tabs: Configure │63│ .viewport (flex:1, black; <video> + results-img/video overlay) │ │ Grid Timeline │64│ below-viewport DOCK (340px): TIMELINE strip ONLY │ │ Results │65│ (Results media overlays the .viewport, not here) │ │ .pane (active) │66│ │ │ .pane (active) │67└───────────────────────────────────────────────────────────────────────┴─────────────────────┘68```6970- `header`: flex row, `padding:10px 14px`, `background:var(--panel)`, bottom border. `h1`71 `font-size:16px; color:var(--green)`. The stage `<input>` is `flex:1`. Status pill has72 `margin-left:auto`.73- `main`: `flex:1; display:flex`. `.stage-col { flex:1; display:flex; flex-direction:column }`.74 A draggable `.panel-resize` (6px, `cursor:col-resize`, hover green) splits it from the panel.75- `.viewport`: `position:relative; flex:1; background:#000`, centers `#remote-video`76 (`max-width/height:100%`). Overlays live here: an `#overlay` message, a crosshair pick layer,77 an SVG gizmo, a hint chip. **The turntable pivot gizmo is a plain SVG/DOM overlay drawn from78 `/api/project` screen coordinates — do NOT adopt `ovui` (or any native/in-renderer widget kit) for79 it.** The gizmo is client-side UI over the ovstream video, not scene geometry; ovui widgets are for80 an in-viewport native overlay pipeline this app doesn't use. Keep `#gizmo` as SVG.81- `aside.panel`: `width:330px; border-left:1px solid var(--line); background:var(--panel);82 display:flex; flex-direction:column; overflow:hidden`.8384## The right panel — 4 tabs8586`.tabs` is a flex row of 4 buttons; the active tab is **green TEXT with a green87`border-bottom:2px` on the dark bar — NOT a solid green filled block**. Filling the active88tab with `#76b900` while leaving the label green/white-on-green makes the tab name UNREADABLE.89Whatever the styling, every tab label (active + inactive) must keep text/background contrast ≥ 3:190(aim 4.5:1) — this is gate-checked on computed styles. Each `.pane` is `display:none` unless `.active`. Inside a pane, content is91grouped into `.block`s (`padding:12px 14px; border-bottom:1px solid var(--line)`) with an `h2`92(`font-size:12px; text-transform:uppercase; letter-spacing:.6px; color:var(--muted)`). A93`.block.grow { flex:1; overflow-y:auto }` holds the scrolling list at the bottom of a pane.94951. **Configure** (live look): blocks — **Camera** (a `<select>` of authored cameras + Save96 framing / Reset camera) · **Render mode** (a `.seg` of Real-Time / Path Tracing) · **Display**97 (read-only stage info + an aspect/resolution `<select>` + range sliders for ISO / focal length98 / f-stop + a focus-distance number with a **Pick** button) · **Turntable** (Pick pivot / Remove99 + a nudge gizmo + frames + Create / Preview spin) · **Variant sets** (`.block.grow`: one100 `.vcard` per set, each a row of `.chip`s).1012. **Grid**: **Matrix mode** (`.seg` One-at-a-time / Full Cartesian) · **Quality & output**102 (`.seg` Real-Time / Path Tracing + W/H/SPP + an Animation-range checkbox + output folder) ·103 **Cameras** — a **COMPACT dropdown** (`#grid-cameras`; a multi-select dropdown or a dropdown button104 opening a checkbox popover, still selecting one OR several cameras) — **NOT a tall per-camera105 checklist**: an ~18-camera stage (ConceptCar) must NOT push **Include sets** off-screen ·106 **Include sets** (`.block.grow`, ticked sets get a green107 border) · an estimate line + Render / Cancel + a progress bar.1083. **Timeline**: **Project** (name + Save/Open/Delete) · **Saved track views** · explanatory help109 text pointing at the strip below the viewport.1104. **Results**: the SIDE PANEL holds only the controls + the list — **Results folder** (+ Refresh) ·111 **Post-process** (`.seg` Overlay labels / Cut sheet) · **Renders** (a `<select>` list of112 stills/sequences/videos; the list is good). **The chosen media takes over the MAIN VIEWPORT PANEL** —113 `#results-img`/`#results-video` are children of the `.viewport` section, overlaying `#remote-video`,114 shown (`.show`) only on the Results tab; selecting a render replaces the live viewport with the115 still/MP4 IN PLACE. It is **NOT** a separate player in a below-viewport dock (there is no116 `#results-dock`). The multi-frame `#results-slider`+`#results-frame-label` control it from the side117 panel. (Earlier guidance wrongly said "below-viewport dock" — align to the reference: results media =118 in the viewport panel, where `#remote-video` lives.)119120## Components121122- **Pill chip** (`.chip`): `display:inline-flex; align-items:center; font-size:11px; padding:3px123 9px; border-radius:12px; border:1px solid var(--line); background:#161b22; color:var(--muted)`.124 `.on` = green bg / `#04130a` / bold. A `.swatch` dot (11×11, `border-radius:3px`) shows the125 variant's representative color. **Wire these dots up** — the `usd-variant-scan-classify`126 classifier emits per-variant swatch hex (`swatches: {set:{variant:"#rrggbb"}}` on the127 `classified` event); set each chip's `.swatch` background from it (hide the dot only when no128 color was detected). Chips without swatch dots are an incomplete look.129- **Segmented control** (`.seg`): a flex row of equal buttons (`flex:1`); the active one is green.130- **Range sliders**: `accent-color:var(--green)`. **Progress bar**: a `.bar` filled green.131- **Section info popovers**: a small circled "i" (`.info`) and `data-help` attributes that show a132 fixed popover bordered green — optional but on-brand.133134## The below-viewport dock is TAB-CONTEXTUAL — and it is the TIMELINE's, only135The strip area under the viewport (`#timeline-strip`) is NOT a permanent fixture:136- **Timeline tab active →** the NLE timeline strip shows there.137- **Configure / Grid / Results →** the dock is hidden; the viewport uses full height.138- **Results media does NOT go in this dock.** The selected still/MP4 OVERLAYS the MAIN VIEWPORT139 (`#results-img`/`#results-video` inside `.viewport`, over `#remote-video`, `.show` on the Results tab) —140 it replaces the live viewport in place. There is no separate `#results-dock` below the viewport.141Drive it off the active tab: `#timeline-strip` is shown only while the Timeline tab is `.active` (e.g.142toggle a class on switchTab), hidden on every other tab even with a stage open. (Leaving the143timeline strip visible on ALL tabs whenever a stage is open is the bug to avoid.)144145## Timeline strip (the NLE — shown ONLY on the Timeline tab)146147`#timeline-strip { height:340px; border-top:1px solid var(--line); background:var(--panel) }`,148shown **only when the Timeline tab is active** (hidden on Configure/Grid/Results even with a stage open),149top edge drag-resizable. Three parts:150- a **toolbar** (`.tl-toolbar`, wraps): playhead time (green, tabular-nums), length, append-mode151 toggle (Stack / At playhead), presets (Slideshow / Mixer / Clear), new-clip duration + fps,152 and a right-aligned render group (output folder + **Render to MP4** in green + a progress bar).153- a **ruler** row with a transport gutter on the left (to-start / step-back / play / step-fwd /154 to-end / loop, ~22px square buttons) and tick marks.155- **tracks**: one row per variant set + a camera track; each row has a sticky left label156 (`var(--label-w,230px)`) and absolutely-positioned **clips**. Variant clips are blue157 (`var(--accent)`), camera clips purple (`#8957e5`), the selected clip has a green outline. A158 green 2px **playhead** line overlays the tracks. Each clip carries a `▾` to change its variant (and a159 tiny COLORED swatch ONLY when that variant has a detected swatch color — never a placeholder white box);160 drag to move, drag the right edge to resize. Any edit re-applies the state at the playhead immediately.161162## Layout: the viewport shrinks for the dock ONLY when the dock is showing (flex sizing — common bug)163164When the dock IS showing (the Timeline strip, on the Timeline tab), the viewport must SHRINK to make room165at any window height and any stream resolution — otherwise a 1080p stream makes the viewport tall and166pushes the 340px strip below the fold. When the dock is hidden (Configure/Grid/Results), the viewport uses167full height. Results media overlays the viewport (absolute, inside `.viewport`) — it does NOT take dock168space. Required flex rules:169```css170.stage-col { flex:1; display:flex; flex-direction:column; min-height:0; overflow:hidden; } /* min-height:0 + overflow:hidden are both essential */171.viewport { flex:1; min-height:0; overflow:hidden; position:relative; } /* shrinks; never min-content */172#remote-video { max-width:100%; max-height:100%; } /* letterboxed, never forces height */173#results-img, #results-video { position:absolute; inset:0; } /* overlay the viewport on Results */174#timeline-strip { flex:none; height:340px; } /* the dock = TIMELINE only */175```176`min-height:0` alone is not enough: a flex child's intrinsic content size (a raw `<video>`/`<img>` at its177native 1920×1080) can still overflow and push `#tl-resize`'s drag handle (and the whole strip) off-screen178or force page scroll — `overflow:hidden` on BOTH `.stage-col` and `.viewport` is what actually clips the179oversized intrinsic size so the flexbox shrink takes effect. Verify at a normal window height (~800–900px):180on the Timeline tab the strip (and its `#tl-resize` top-edge handle) is fully visible and draggable without181scrolling, even with a 1920×1080 stream; on the Results tab the video + its scrubber are; on Configure/Grid182the viewport is full-height with no strip. (Make the dock height user-resizable via `#tl-resize`, but it183must never be clipped or push the page into scroll.)184185## Aspect / resolution must offer real ASPECT changes (not just 16:9 sizes)186187The Display "Aspect / resolution" `<select>` must include genuinely different **aspect ratios**,188matching production — not three 16:9 resolutions. Provide at least:189`1280×720` (16:9) · `1920×1080` (16:9) · `1080×1080` (1:1) · `1080×1920` (9:16) ·190`1440×1080` (4:3) · `1920×804` (2.39:1). Changing it rebuilds the ovstream `Server` at the new191size (brief reconnect via the stream-reconnect path) AND updates the camera's192`verticalAperture = horizontalAperture * h/w` so the framing isn't stretched; the Grid tab193inherits the current size as its render default. Verify switching to 1:1 / 9:16 actually changes194the streamed frame's shape, not just its pixel count.195196## Pick flows must be wired end-to-end (backend working ≠ feature working)197198- **Focus picker:** arm the Pick button → show the crosshair overlay over the video → on click,199 read normalized coords from the `<video>` rect → `POST /api/pick-focus {nx,ny}` → **apply the200 returned `focus_distance`** to the focus field AND the live camera (this last step is the one201 that gets dropped — the server returns a distance but the UI never uses it, so "focus picker202 does nothing"). Disarm + remove the crosshair after.203- **Turntable pivot:** same arm→click pattern but `POST /api/pick-point {nx,ny}` → drop the pivot204 gizmo at the returned `world` point. (pick-focus returns a distance, not a point — using it for205 the pivot is wrong.)206207## Verify the look208Open the app and confirm: green `#76b900` accent on the active tab / selected chip / LIVE badge;209the dark `#0d1117` background; the header with the title + stage field + Open + status pill; the210330px right panel with the four named tabs; variant chips with swatch dots. Switch to the **Timeline**211tab → the NLE strip appears below the viewport (green playhead, blue/purple clips); switch to212**Configure/Grid** → the strip is GONE and the viewport is full height; switch to **Results**, pick an213MP4 → it plays IN THE VIEWPORT AREA, overlaying the stream, with its scrubber/transport below the214video (not in the side panel). It should be recognizably the same product, not a generic form over the stream.