dragonflight/docs/superpowers/plans/2026-05-21-ui-shell-rework-wave-1-plan.md
Zac Gaetano c97759dc4e docs: UI shell rework wave-1 implementation plan
Detailed 22-task plan for the foundation wave (build pipeline + theme
port + every CSS primitive, no page migration yet). Smoke-test page at
_primitives-smoke.html is the wave-1 QA gate. Waves 2-4 will get their
own plan documents after each prior wave ships.
2026-05-21 10:53:31 -04:00

2086 lines
62 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.

# UI Shell Rework — Wave 1 (Foundation) Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Stand up a Tailwind + flyon-ui build pipeline in `services/web-ui` and define every CSS primitive the rework needs, without changing any user-visible page yet. End state: `docker compose up -d --build web-ui` succeeds, `app.[hash].css` is served, no existing page is broken.
**Architecture:** Add a Node build stage to the web-ui Dockerfile that runs `tailwindcss --minify` once at image-build time. Tailwind config defines a custom flyon-ui theme that maps the existing oklch palette into flyon-ui's color slots, preserving brand hue 266. CSS primitives are split one-per-file under `src/css/components/` so each file holds one responsibility and stays small. Runtime image stays nginx-static.
**Tech Stack:** Tailwind CSS 3, flyon-ui plugin, PostCSS, nginx (runtime), Docker multi-stage build. No JavaScript runtime added — build-only Node.
**Reference spec:** `docs/superpowers/specs/2026-05-21-ui-shell-rework-design.md`
---
## File structure
**Files this wave creates or modifies:**
```
services/web-ui/
├── Dockerfile (MODIFY: add Node build stage)
├── package.json (CREATE: tailwind/flyon-ui deps)
├── tailwind.config.js (CREATE: theme port + flyon-ui plugin)
├── postcss.config.js (CREATE: tailwindcss + autoprefixer)
├── .gitignore (MODIFY: ignore node_modules + dist)
├── nginx.conf (MODIFY: long-cache /dist/*)
├── src/
│ └── css/
│ ├── app.css (CREATE: Tailwind directives + @layer + imports)
│ └── components/
│ ├── tokens.css (CREATE: :root oklch token definitions)
│ ├── motion.css (CREATE: shimmer/stutter keyframes + reduced-motion)
│ ├── sidebar.css (CREATE: .wd-sidebar, .wd-nav-item, IN DEV pill)
│ ├── topbar.css (CREATE: .wd-topbar, .wd-breadcrumb, .wd-search)
│ ├── button.css (CREATE: .wd-btn variants × sizes)
│ ├── form-controls.css (CREATE: .wd-input, .wd-label, .wd-hint, .wd-toggle)
│ ├── field-group.css (CREATE: tabbed-section primitive)
│ ├── slide-panel.css (CREATE: locked-layout slide-panel)
│ ├── card-asset.css (CREATE: asset card family)
│ ├── card-operational.css (CREATE: operational card family)
│ ├── list-row.css (CREATE: inline list row)
│ ├── empty-state.css (CREATE: empty-state pattern)
│ ├── badge.css (CREATE: status pills + signal badges)
│ └── toast.css (CREATE: toast with top-strip)
├── public/
│ ├── dist/ (CREATE: gitignored, build output target)
│ └── fonts/ (CREATE: self-hosted woff2)
│ ├── inter-400.woff2
│ ├── inter-500.woff2
│ ├── inter-600.woff2
│ ├── jetbrains-mono-400.woff2
│ └── jetbrains-mono-600.woff2
└── (existing HTML files untouched in wave 1)
```
**Files this wave does NOT touch:** every `services/web-ui/public/*.html`. Wave 2+ migrates them.
---
## Tasks
### Task 1: Scaffold Node build deps
**Files:**
- Create: `services/web-ui/package.json`
- Modify: `services/web-ui/.gitignore`
- [ ] **Step 1: Create package.json**
Write `services/web-ui/package.json`:
```json
{
"name": "wild-dragon-web-ui-build",
"version": "0.1.0",
"private": true,
"description": "Build-time-only deps for the Wild Dragon web-ui Tailwind/flyon-ui pipeline. Not shipped at runtime.",
"scripts": {
"build:css": "tailwindcss -i ./src/css/app.css -o ./public/dist/app.css --minify"
},
"devDependencies": {
"tailwindcss": "^3.4.0",
"postcss": "^8.4.35",
"autoprefixer": "^10.4.17",
"flyonui": "^1.0.0"
}
}
```
- [ ] **Step 2: Update .gitignore**
Append to `services/web-ui/.gitignore` (create if missing):
```gitignore
node_modules/
public/dist/
```
- [ ] **Step 3: Commit**
```bash
cd /opt/wild-dragon
HOME=/root git add services/web-ui/package.json services/web-ui/.gitignore
HOME=/root git commit -m "web-ui: scaffold tailwind+flyon-ui build deps"
```
---
### Task 2: PostCSS config
**Files:**
- Create: `services/web-ui/postcss.config.js`
- [ ] **Step 1: Write postcss.config.js**
```js
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
```
- [ ] **Step 2: Commit**
```bash
HOME=/root git add services/web-ui/postcss.config.js
HOME=/root git commit -m "web-ui: add postcss config for tailwind+autoprefixer"
```
---
### Task 3: Tailwind config with custom flyon-ui theme
**Files:**
- Create: `services/web-ui/tailwind.config.js`
Maps the existing oklch palette into flyon-ui's color slots. Brand hue 266 preserved. Wave-1 deliberately keeps the theme tight — only colors, fonts, and spacing scale. Additional theme keys (animation curves, radii, etc.) live in component CSS files via `@layer`.
- [ ] **Step 1: Write tailwind.config.js**
```js
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
'./public/**/*.html',
'./src/**/*.{css,js}',
],
darkMode: 'class',
theme: {
extend: {
colors: {
// ── Surfaces (5-step depth, tinted toward brand hue 266) ──
'bg-deep': 'oklch(8% 0.011 266)',
'bg-base': 'oklch(11% 0.010 266)',
'bg-panel': 'oklch(15% 0.013 266)',
'bg-surface': 'oklch(19% 0.014 266)',
'bg-raised': 'oklch(24% 0.015 266)',
'bg-hover': 'oklch(28% 0.015 266)',
// ── Accent (brand) ──
accent: {
DEFAULT: 'oklch(45% 0.20 266)',
subtle: 'oklch(55% 0.20 266 / 0.12)',
border: 'oklch(55% 0.20 266 / 0.36)',
},
// ── Text ──
'text-primary': 'oklch(94% 0.008 266)',
'text-secondary': 'oklch(72% 0.014 266)',
// tertiary bumped from 52% to 56% per a11y section in the spec
'text-tertiary': 'oklch(56% 0.012 266)',
'text-disabled': 'oklch(38% 0.010 266)',
// ── Borders ──
'border-faint': 'oklch(22% 0.013 266)',
'border-default': 'oklch(28% 0.015 266)',
'border-strong': 'oklch(38% 0.018 266)',
// ── Status / semantic signal ──
'signal-good': 'oklch(70% 0.18 148)',
'signal-bad': 'oklch(64% 0.22 25)',
'signal-warn': 'oklch(80% 0.16 90)',
'signal-idle': 'oklch(58% 0.012 266)',
'signal-good-bg': 'oklch(70% 0.18 148 / 0.12)',
'signal-bad-bg': 'oklch(64% 0.22 25 / 0.12)',
'signal-warn-bg': 'oklch(80% 0.16 90 / 0.12)',
},
fontFamily: {
sans: ['Inter', '-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'system-ui', 'sans-serif'],
mono: ['"JetBrains Mono"', 'ui-monospace', '"SF Mono"', 'Consolas', 'monospace'],
},
fontSize: {
// tighter scale per density target
'xs': ['11px', { lineHeight: '1.4' }],
'sm': ['13px', { lineHeight: '1.5' }],
'base': ['14px', { lineHeight: '1.5' }],
'md': ['16px', { lineHeight: '1.4' }],
'lg': ['18px', { lineHeight: '1.4' }],
'xl': ['22px', { lineHeight: '1.3' }],
'2xl': ['28px', { lineHeight: '1.2' }],
'3xl': ['40px', { lineHeight: '1.1' }],
'tc': ['64px', { lineHeight: '1' }], // tally-grade timecode
},
borderRadius: {
'sm': '4px',
'md': '6px',
'lg': '8px',
},
transitionTimingFunction: {
'out-quart': 'cubic-bezier(0.25, 1, 0.5, 1)',
'out-expo': 'cubic-bezier(0.16, 1, 0.3, 1)',
},
transitionDuration: {
'120': '120ms',
'180': '180ms',
'240': '240ms',
},
zIndex: {
'dropdown': '40',
'overlay': '80',
'panel': '90',
'toast': '200',
},
},
},
plugins: [
require('flyonui'),
],
};
```
- [ ] **Step 2: Commit**
```bash
HOME=/root git add services/web-ui/tailwind.config.js
HOME=/root git commit -m "web-ui: tailwind config with oklch theme port (brand hue 266 preserved)"
```
---
### Task 4: Create design-token CSS
**Files:**
- Create: `services/web-ui/src/css/components/tokens.css`
Tailwind exposes the colors as utility classes, but we also keep CSS custom properties so non-Tailwind callers (the existing JS that sets `el.style.color = 'var(--accent)'`) still work.
- [ ] **Step 1: Write tokens.css**
```css
/* tokens.css ─ oklch design tokens
* Mirrors tailwind.config.js so legacy callers that read
* var(--accent), var(--bg-panel), etc. keep working during the
* page-by-page migration. After all pages migrate to Tailwind
* utility classes (wave 4 complete), this file shrinks to
* just the few tokens that are referenced from inline styles. */
:root {
/* Surfaces */
--bg-deep: oklch(8% 0.011 266);
--bg-base: oklch(11% 0.010 266);
--bg-panel: oklch(15% 0.013 266);
--bg-surface: oklch(19% 0.014 266);
--bg-raised: oklch(24% 0.015 266);
--bg-hover: oklch(28% 0.015 266);
/* Accent */
--accent: oklch(45% 0.20 266);
--accent-subtle: oklch(55% 0.20 266 / 0.12);
--accent-border: oklch(55% 0.20 266 / 0.36);
/* Text — tertiary bumped to 56% for AA contrast on bg-panel */
--text-primary: oklch(94% 0.008 266);
--text-secondary: oklch(72% 0.014 266);
--text-tertiary: oklch(56% 0.012 266);
--text-disabled: oklch(38% 0.010 266);
/* Borders */
--border-faint: oklch(22% 0.013 266);
--border: oklch(28% 0.015 266);
--border-strong: oklch(38% 0.018 266);
/* Semantic signal */
--signal-good: oklch(70% 0.18 148);
--signal-bad: oklch(64% 0.22 25);
--signal-warn: oklch(80% 0.16 90);
--signal-idle: oklch(58% 0.012 266);
--signal-good-bg: oklch(70% 0.18 148 / 0.12);
--signal-bad-bg: oklch(64% 0.22 25 / 0.12);
--signal-warn-bg: oklch(80% 0.16 90 / 0.12);
/* Spacing (mirrors Tailwind 4pt scale) */
--sp-1: 4px; --sp-2: 8px; --sp-3: 12px; --sp-4: 16px;
--sp-5: 20px; --sp-6: 24px; --sp-8: 32px; --sp-12: 48px;
/* Type */
--font: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
--font-mono: 'JetBrains Mono', ui-monospace, 'SF Mono', Consolas, monospace;
/* Layout */
--sidebar-w: 200px;
--topbar-h: 48px;
/* Z */
--z-dropdown: 40;
--z-overlay: 80;
--z-panel: 90;
--z-toast: 200;
}
```
- [ ] **Step 2: Commit**
```bash
HOME=/root git add services/web-ui/src/css/components/tokens.css
HOME=/root git commit -m "web-ui: oklch design tokens (mirror tailwind theme for legacy callers)"
```
---
### Task 5: Create motion CSS (shimmer + stutter + reduced-motion)
**Files:**
- Create: `services/web-ui/src/css/components/motion.css`
- [ ] **Step 1: Write motion.css**
```css
/* motion.css ─ keyframes + global reduced-motion override.
* Keep keyframes here, not scattered across primitive files,
* so a single import disables them under prefers-reduced-motion. */
/* Skeleton shimmer — gradient sweep, NOT opacity pulse */
@keyframes wd-shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
/* "LIVE" tally-light stutter — bright 0.9s / dim 0.3s / bright 0.9s */
@keyframes wd-stutter {
0%, 42% { opacity: 1; }
42.01%, 56% { opacity: 0.55; }
56.01%, 100% { opacity: 1; }
}
/* Signal-strip sweep for recording cards */
@keyframes wd-signal-sweep {
0% { transform: translateX(-100%); }
100% { transform: translateX(100%); }
}
.wd-shimmer {
background: linear-gradient(
90deg,
var(--bg-surface) 0%,
var(--bg-hover) 50%,
var(--bg-surface) 100%
);
background-size: 200% 100%;
animation: wd-shimmer 1.8s ease-in-out infinite;
}
.wd-stutter { animation: wd-stutter 2.1s ease-in-out infinite; }
.wd-sweep { animation: wd-signal-sweep 1.8s ease-in-out infinite; }
@media (prefers-reduced-motion: reduce) {
.wd-shimmer, .wd-stutter, .wd-sweep {
animation: none;
}
*, *::before, *::after {
transition-duration: 0ms !important;
animation-duration: 0ms !important;
}
}
```
- [ ] **Step 2: Commit**
```bash
HOME=/root git add services/web-ui/src/css/components/motion.css
HOME=/root git commit -m "web-ui: motion primitives (shimmer/stutter/sweep + reduced-motion)"
```
---
### Task 6: Create sidebar CSS primitive
**Files:**
- Create: `services/web-ui/src/css/components/sidebar.css`
Matches design section 2: 200px wide, 28px rows, leading 4px accent dot for active (NOT a side-stripe).
- [ ] **Step 1: Write sidebar.css**
```css
/* sidebar.css ─ flat app navigation rail (200px, 28px rows) */
.wd-sidebar {
width: var(--sidebar-w);
flex-shrink: 0;
display: flex;
flex-direction: column;
background: var(--bg-panel);
border-right: 1px solid var(--border);
overflow-y: auto;
overflow-x: hidden;
}
.wd-sidebar-header {
height: var(--topbar-h);
display: flex;
align-items: center;
gap: 8px;
padding: 0 12px;
border-bottom: 1px solid var(--border-faint);
flex-shrink: 0;
}
.wd-sidebar-logo { width: 18px; height: 18px; }
.wd-sidebar-brand { font: 600 13px/1 var(--font); letter-spacing: -0.01em; color: var(--text-primary); }
.wd-sidebar-nav {
display: flex;
flex-direction: column;
padding: 8px 0;
gap: 1px;
}
.wd-sidebar-section {
font: 600 10px/1 var(--font);
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--text-tertiary);
padding: 16px 12px 4px;
}
.wd-nav-item {
position: relative;
display: flex;
align-items: center;
gap: 8px;
height: 28px;
padding: 0 8px 0 16px; /* extra left padding for the accent-dot indent */
font: 500 13px/1 var(--font);
color: var(--text-secondary);
text-decoration: none;
transition: background-color 120ms cubic-bezier(0.25, 1, 0.5, 1),
color 120ms cubic-bezier(0.25, 1, 0.5, 1);
}
.wd-nav-item:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.wd-nav-item svg { width: 14px; height: 14px; flex-shrink: 0; color: var(--text-tertiary); }
.wd-nav-item:hover svg { color: var(--text-secondary); }
.wd-nav-item.is-active {
background: var(--bg-surface);
color: var(--text-primary);
}
.wd-nav-item.is-active svg { color: var(--text-primary); }
/* 4px leading accent dot — NOT a side-stripe border (banned).
* Vertically centered, 8px tall, 4px from the left edge. */
.wd-nav-item.is-active::before {
content: '';
position: absolute;
left: 4px;
top: 50%;
transform: translateY(-50%);
width: 4px;
height: 8px;
border-radius: 2px;
background: var(--accent);
}
/* IN DEV pill (auth-guard.js injects this) */
.wd-nav-item .nav-dev-badge {
margin-left: auto;
font: 700 9px/1 var(--font);
letter-spacing: 0.12em;
padding: 2px 5px;
border-radius: 3px;
background: oklch(28% 0.14 80 / 0.45);
color: oklch(85% 0.16 85);
border: 1px solid oklch(50% 0.16 80 / 0.55);
text-transform: uppercase;
flex-shrink: 0;
}
/* Footer user widget */
.wd-sidebar-footer {
margin-top: auto;
padding: 12px;
border-top: 1px solid var(--border-faint);
}
.wd-sidebar-user {
display: flex;
align-items: center;
gap: 8px;
}
.wd-sidebar-user-avatar {
width: 28px; height: 28px;
border-radius: 50%;
background: var(--bg-surface);
display: flex; align-items: center; justify-content: center;
font: 600 11px/1 var(--font);
color: var(--text-secondary);
flex-shrink: 0;
}
.wd-sidebar-user-info { flex: 1; min-width: 0; }
.wd-sidebar-user-name { font: 500 12px/1.3 var(--font); color: var(--text-primary); }
.wd-sidebar-user-role { font: 500 10px/1.3 var(--font); color: var(--text-tertiary); letter-spacing: 0.06em; text-transform: uppercase; }
.wd-sidebar-user-logout { opacity: 0; transition: opacity 120ms cubic-bezier(0.25, 1, 0.5, 1); }
.wd-sidebar-user:hover .wd-sidebar-user-logout { opacity: 1; }
```
- [ ] **Step 2: Commit**
```bash
HOME=/root git add services/web-ui/src/css/components/sidebar.css
HOME=/root git commit -m "web-ui: sidebar primitive (200px, 28px rows, leading accent dot)"
```
---
### Task 7: Create topbar CSS primitive
**Files:**
- Create: `services/web-ui/src/css/components/topbar.css`
- [ ] **Step 1: Write topbar.css**
```css
/* topbar.css ─ sticky 48px topbar with breadcrumb + scoped search */
.wd-topbar {
position: sticky;
top: 0;
z-index: 30;
height: var(--topbar-h);
display: flex;
align-items: center;
padding: 0 12px 0 16px;
gap: 16px;
background: var(--bg-panel);
border-bottom: 1px solid var(--border-faint);
flex-shrink: 0;
}
.wd-topbar-left { flex: 0 1 auto; min-width: 0; }
.wd-topbar-center { flex: 1 1 360px; display: flex; justify-content: center; }
.wd-topbar-right { flex: 0 0 auto; display: flex; align-items: center; gap: 8px; margin-left: auto; }
/* Breadcrumb */
.wd-breadcrumb {
display: flex;
align-items: center;
gap: 0;
font: 500 13px/1 var(--font);
}
.wd-breadcrumb-crumb { color: var(--text-secondary); }
.wd-breadcrumb-crumb:last-child { color: var(--text-primary); font-weight: 600; }
.wd-breadcrumb-sep {
width: 10px; height: 10px;
margin: 0 8px;
color: var(--text-tertiary);
flex-shrink: 0;
}
/* Page-scoped search */
.wd-topbar-search {
width: 100%;
max-width: 360px;
height: 28px;
display: flex;
align-items: center;
gap: 6px;
padding: 0 10px;
background: var(--bg-deep);
border: 1px solid var(--border-faint);
border-radius: 4px;
}
.wd-topbar-search:focus-within {
border-color: var(--accent-border);
outline: 2px solid var(--accent-subtle);
outline-offset: 1px;
}
.wd-topbar-search svg {
width: 12px; height: 12px;
color: var(--text-tertiary);
flex-shrink: 0;
}
.wd-topbar-search input {
flex: 1;
background: transparent;
border: 0;
outline: 0;
font: 400 13px/1 var(--font-mono);
color: var(--text-primary);
}
.wd-topbar-search input::placeholder {
color: var(--text-tertiary);
font: 400 13px/1 var(--font-mono);
}
/* Vertical divider between secondary actions and primary CTA */
.wd-topbar-divider {
width: 1px;
height: 20px;
background: var(--border-faint);
margin: 0 4px;
}
```
- [ ] **Step 2: Commit**
```bash
HOME=/root git add services/web-ui/src/css/components/topbar.css
HOME=/root git commit -m "web-ui: topbar primitive (breadcrumb + scoped search + sticky)"
```
---
### Task 8: Create button CSS primitive
**Files:**
- Create: `services/web-ui/src/css/components/button.css`
- [ ] **Step 1: Write button.css**
```css
/* button.css ─ sm/md/lg × primary/secondary/ghost/danger */
.wd-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
border-radius: 4px;
border: 1px solid transparent;
font: 500 13px/1 var(--font);
cursor: pointer;
user-select: none;
transition: background-color 120ms cubic-bezier(0.25, 1, 0.5, 1),
border-color 120ms cubic-bezier(0.25, 1, 0.5, 1),
color 120ms cubic-bezier(0.25, 1, 0.5, 1);
}
.wd-btn:focus-visible {
outline: 2px solid var(--accent-subtle);
outline-offset: 1px;
}
.wd-btn:active:not(:disabled) { opacity: 0.85; transition: opacity 60ms; }
.wd-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.wd-btn > svg { width: 12px; height: 12px; flex-shrink: 0; }
/* Sizes */
.wd-btn--sm { height: 28px; padding: 0 10px; font-size: 12px; }
.wd-btn--md { height: 32px; padding: 0 12px; font-size: 13px; }
.wd-btn--lg { height: 36px; padding: 0 14px; font-size: 13px; }
/* Variants */
.wd-btn--primary {
background: var(--accent);
color: oklch(98% 0.005 266);
}
.wd-btn--primary:hover:not(:disabled) {
background: oklch(52% 0.20 266);
}
.wd-btn--secondary {
background: var(--bg-surface);
color: var(--text-primary);
border-color: var(--border);
}
.wd-btn--secondary:hover:not(:disabled) {
background: var(--bg-hover);
border-color: var(--border-strong);
}
.wd-btn--ghost {
background: transparent;
color: var(--text-secondary);
}
.wd-btn--ghost:hover:not(:disabled) {
background: var(--bg-hover);
color: var(--text-primary);
}
.wd-btn--danger {
background: var(--signal-bad);
color: oklch(98% 0.005 25);
}
.wd-btn--danger:hover:not(:disabled) {
background: oklch(68% 0.22 25);
}
/* Icon-only (28px square) */
.wd-btn--icon {
width: 28px;
padding: 0;
}
```
- [ ] **Step 2: Commit**
```bash
HOME=/root git add services/web-ui/src/css/components/button.css
HOME=/root git commit -m "web-ui: button primitive (sm/md/lg x primary/secondary/ghost/danger)"
```
---
### Task 9: Create form-controls CSS primitive
**Files:**
- Create: `services/web-ui/src/css/components/form-controls.css`
- [ ] **Step 1: Write form-controls.css**
```css
/* form-controls.css ─ label/input/select/textarea/hint/toggle */
.wd-label {
display: block;
font: 600 11px/1 var(--font);
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-tertiary);
margin-bottom: 4px;
}
.wd-input, .wd-select, .wd-textarea {
width: 100%;
height: 32px;
padding: 0 10px;
background: var(--bg-deep);
border: 1px solid var(--border);
border-radius: 4px;
font: 400 13px/1 var(--font);
color: var(--text-primary);
transition: border-color 120ms cubic-bezier(0.25, 1, 0.5, 1);
}
.wd-textarea { height: auto; min-height: 80px; padding: 8px 10px; line-height: 1.5; resize: vertical; }
.wd-input:focus, .wd-select:focus, .wd-textarea:focus {
border-color: var(--accent-border);
outline: 2px solid var(--accent-subtle);
outline-offset: 1px;
}
.wd-input:disabled, .wd-select:disabled, .wd-textarea:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.wd-input::placeholder, .wd-textarea::placeholder {
color: var(--text-tertiary);
}
.wd-hint {
font: 400 11px/1.5 var(--font);
color: var(--text-tertiary);
margin-top: 4px;
}
.wd-hint code {
font-family: var(--font-mono);
font-size: 11px;
background: var(--bg-surface);
padding: 1px 4px;
border-radius: 2px;
}
.wd-form-group { display: flex; flex-direction: column; }
.wd-form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
.wd-form-row--3 { grid-template-columns: 1fr 1fr 1fr; }
/* Toggle switch — 34x18 */
.wd-toggle {
display: inline-flex;
align-items: center;
gap: 10px;
cursor: pointer;
user-select: none;
}
.wd-toggle input {
position: absolute;
opacity: 0;
width: 0; height: 0;
}
.wd-toggle-track {
position: relative;
width: 34px;
height: 18px;
background: var(--bg-hover);
border: 1px solid var(--border);
border-radius: 999px;
transition: background 200ms cubic-bezier(0.25, 1, 0.5, 1);
}
.wd-toggle-track::after {
content: '';
position: absolute;
top: 2px; left: 2px;
width: 12px; height: 12px;
border-radius: 50%;
background: var(--text-secondary);
transition: transform 200ms cubic-bezier(0.25, 1, 0.5, 1),
background 200ms cubic-bezier(0.25, 1, 0.5, 1);
}
.wd-toggle input:checked ~ .wd-toggle-track {
background: var(--accent);
border-color: var(--accent);
}
.wd-toggle input:checked ~ .wd-toggle-track::after {
transform: translateX(16px);
background: oklch(98% 0.005 266);
}
.wd-toggle-label { font: 400 13px/1 var(--font); color: var(--text-secondary); }
```
- [ ] **Step 2: Commit**
```bash
HOME=/root git add services/web-ui/src/css/components/form-controls.css
HOME=/root git commit -m "web-ui: form controls primitive (label/input/select/textarea/hint/toggle)"
```
---
### Task 10: Create field-group CSS primitive (tabbed sections)
**Files:**
- Create: `services/web-ui/src/css/components/field-group.css`
Generalization of the codec-block pattern from the recent recorders rewrite.
- [ ] **Step 1: Write field-group.css**
```css
/* field-group.css ─ titled section with optional inner tabs.
* Generalized from the codec-block pattern in recorders.html. */
.wd-field-group {
border: 1px solid var(--border-faint);
border-radius: 6px;
background: var(--bg-panel);
overflow: hidden;
}
.wd-field-group-header {
display: flex;
align-items: center;
justify-content: space-between;
height: 36px;
padding: 0 14px;
background: var(--bg-surface);
border-bottom: 1px solid var(--border-faint);
}
.wd-field-group-title {
font: 600 11px/1 var(--font);
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--text-secondary);
}
.wd-tabs {
display: flex;
height: 32px;
background: var(--bg-deep);
border-bottom: 1px solid var(--border-faint);
}
.wd-tab {
flex: 1;
background: transparent;
border: 0;
border-bottom: 2px solid transparent;
font: 500 11px/1 var(--font);
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-tertiary);
cursor: pointer;
transition: color 120ms cubic-bezier(0.25, 1, 0.5, 1),
border-color 120ms cubic-bezier(0.25, 1, 0.5, 1);
}
.wd-tab:hover { color: var(--text-secondary); }
.wd-tab.is-active {
color: var(--accent);
border-bottom-color: var(--accent);
}
.wd-tab-panel { display: none; padding: 14px; }
.wd-tab-panel.is-active { display: block; }
```
- [ ] **Step 2: Commit**
```bash
HOME=/root git add services/web-ui/src/css/components/field-group.css
HOME=/root git commit -m "web-ui: field-group primitive (tabbed section, codec-block generalized)"
```
---
### Task 11: Create slide-panel CSS primitive
**Files:**
- Create: `services/web-ui/src/css/components/slide-panel.css`
Codifies the layout fix from the codec-clipping bug as the primitive.
- [ ] **Step 1: Write slide-panel.css**
```css
/* slide-panel.css ─ right-sliding 460px panel with locked flex layout.
* The min-height:0 on body is the flex-overflow footgun fix from
* the codec-clipping bug. Don't remove it. */
.wd-slide-overlay {
position: fixed;
inset: 0;
z-index: var(--z-overlay);
background: oklch(8% 0.010 266 / 0.65);
opacity: 0;
pointer-events: none;
transition: opacity 240ms cubic-bezier(0.25, 1, 0.5, 1);
}
.wd-slide-overlay.is-open {
opacity: 1;
pointer-events: all;
}
.wd-slide-panel {
position: fixed;
top: 0; right: 0; bottom: 0;
width: 460px;
height: 100vh;
max-height: 100vh;
z-index: var(--z-panel);
background: var(--bg-panel);
border-left: 1px solid var(--border);
display: flex;
flex-direction: column;
overflow: hidden;
transform: translateX(100%);
transition: transform 240ms cubic-bezier(0.16, 1, 0.3, 1);
}
.wd-slide-panel.is-open { transform: translateX(0); }
.wd-slide-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
height: 52px;
padding: 0 18px;
border-bottom: 1px solid var(--border-faint);
flex-shrink: 0;
}
.wd-slide-panel-title {
font: 600 14px/1 var(--font);
letter-spacing: -0.005em;
color: var(--text-primary);
}
.wd-slide-panel-body {
flex: 1;
min-height: 0; /* critical for flex overflow */
overflow-y: auto;
padding: 18px;
display: flex;
flex-direction: column;
gap: 16px;
}
.wd-slide-panel-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 14px 18px;
background: var(--bg-deep);
border-top: 1px solid var(--border-faint);
flex-shrink: 0;
}
```
- [ ] **Step 2: Commit**
```bash
HOME=/root git add services/web-ui/src/css/components/slide-panel.css
HOME=/root git commit -m "web-ui: slide-panel primitive (locked flex, codec-clipping bug codified)"
```
---
### Task 12: Create asset card CSS primitive
**Files:**
- Create: `services/web-ui/src/css/components/card-asset.css`
- [ ] **Step 1: Write card-asset.css**
```css
/* card-asset.css ─ 16:9 thumbnail + metadata + role pill.
* For Library, Project asset grids, recorder cards' video portion. */
.wd-card-asset {
display: flex;
flex-direction: column;
background: var(--bg-panel);
border: 1px solid var(--border-faint);
border-radius: 6px;
overflow: hidden;
transition: border-color 120ms cubic-bezier(0.25, 1, 0.5, 1);
}
.wd-card-asset:hover {
border-color: var(--border);
}
.wd-card-asset-thumb {
position: relative;
width: 100%;
aspect-ratio: 16 / 9;
background: oklch(0% 0 0);
overflow: hidden;
}
.wd-card-asset-thumb img,
.wd-card-asset-thumb video {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
transition: filter 120ms cubic-bezier(0.25, 1, 0.5, 1);
}
.wd-card-asset:hover .wd-card-asset-thumb img,
.wd-card-asset:hover .wd-card-asset-thumb video {
filter: brightness(1.04);
}
.wd-card-asset-chip {
position: absolute;
font: 500 10px/1 var(--font-mono);
color: var(--text-primary);
background: oklch(8% 0.010 266 / 0.7);
border: 1px solid var(--border-faint);
border-radius: 3px;
padding: 3px 6px;
letter-spacing: 0.02em;
}
.wd-card-asset-chip--duration { bottom: 8px; right: 8px; }
.wd-card-asset-chip--comments {
bottom: 8px; left: 8px;
display: inline-flex; align-items: center; gap: 4px;
}
.wd-card-asset-chip--version { top: 8px; right: 8px; font-weight: 600; }
.wd-card-asset-checkbox {
position: absolute;
top: 8px; left: 8px;
opacity: 0;
transition: opacity 120ms cubic-bezier(0.25, 1, 0.5, 1);
}
.wd-card-asset:hover .wd-card-asset-checkbox,
.wd-card-asset.is-selected .wd-card-asset-checkbox,
.wd-card-asset-grid.has-selection .wd-card-asset-checkbox { opacity: 1; }
.wd-card-asset-meta {
padding: 8px;
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.wd-card-asset-name {
font: 500 13px/1.3 var(--font);
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.wd-card-asset-sub {
font: 400 11px/1.3 var(--font);
color: var(--text-tertiary);
font-variant-numeric: tabular-nums;
}
.wd-card-asset-role {
display: block;
width: 100%;
padding: 6px 8px;
border: 1px solid var(--border-faint);
border-top: 0;
background: var(--bg-deep);
font: 600 10px/1 var(--font);
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-secondary);
cursor: pointer;
}
.wd-card-asset-role--unset {
border-style: dashed;
color: var(--text-tertiary);
}
/* Grid container */
.wd-card-asset-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 12px;
}
```
- [ ] **Step 2: Commit**
```bash
HOME=/root git add services/web-ui/src/css/components/card-asset.css
HOME=/root git commit -m "web-ui: asset card primitive (16:9 thumb + role pill, replay-style)"
```
---
### Task 13: Create operational card CSS primitive
**Files:**
- Create: `services/web-ui/src/css/components/card-operational.css`
- [ ] **Step 1: Write card-operational.css**
```css
/* card-operational.css ─ header + content + footer, for recorders/cluster/jobs.
* Different SHAPE than asset cards on purpose (impeccable ban: identical card grids). */
.wd-card-op {
display: flex;
flex-direction: column;
gap: 10px;
padding: 14px;
background: var(--bg-panel);
border: 1px solid var(--border-faint);
border-radius: 6px;
transition: border-color 120ms cubic-bezier(0.25, 1, 0.5, 1);
}
.wd-card-op:hover { border-color: var(--border); }
.wd-card-op.is-active { border-color: var(--accent-border); }
.wd-card-op-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.wd-card-op-name {
font: 600 14px/1.2 var(--font);
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.wd-card-op-content {
display: flex;
flex-direction: column;
gap: 8px;
font: 400 13px/1.4 var(--font);
color: var(--text-secondary);
}
.wd-card-op-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding-top: 8px;
border-top: 1px solid var(--border-faint);
}
.wd-card-op-meta {
font: 400 11px/1 var(--font-mono);
color: var(--text-tertiary);
letter-spacing: 0.02em;
}
.wd-card-op-actions { display: flex; gap: 6px; }
/* Mini bars (CPU/mem on cluster cards) */
.wd-mini-bar {
width: 100%;
height: 4px;
background: var(--bg-deep);
border-radius: 2px;
overflow: hidden;
}
.wd-mini-bar-fill {
height: 100%;
background: var(--accent);
transition: width 240ms cubic-bezier(0.16, 1, 0.3, 1);
}
.wd-mini-bar-fill--warn { background: var(--signal-warn); }
.wd-mini-bar-fill--bad { background: var(--signal-bad); }
/* Signal strip (recorder live indicator) */
.wd-signal-strip {
position: relative;
height: 4px;
background: var(--bg-deep);
border-radius: 2px;
overflow: hidden;
}
.wd-signal-strip-fill {
position: absolute;
inset: 0;
width: 100%;
background: linear-gradient(90deg, var(--accent), oklch(70% 0.18 266));
}
/* Grid container */
.wd-card-op-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(380px, 1fr));
gap: 14px;
}
```
- [ ] **Step 2: Commit**
```bash
HOME=/root git add services/web-ui/src/css/components/card-operational.css
HOME=/root git commit -m "web-ui: operational card primitive (header/content/footer, recorder/cluster/jobs)"
```
---
### Task 14: Create list-row + empty-state primitives
**Files:**
- Create: `services/web-ui/src/css/components/list-row.css`
- Create: `services/web-ui/src/css/components/empty-state.css`
Two small files batched into one task to keep granularity bite-sized but avoid trivial single-rule commits.
- [ ] **Step 1: Write list-row.css**
```css
/* list-row.css ─ table-like row, NOT a card. For containers/users/tokens. */
.wd-list {
display: flex;
flex-direction: column;
}
.wd-list-row {
position: relative;
display: flex;
align-items: center;
gap: 16px;
height: 44px;
padding: 0 16px;
border-bottom: 1px solid var(--border-faint);
transition: background-color 120ms cubic-bezier(0.25, 1, 0.5, 1);
}
.wd-list-row:hover { background: var(--bg-hover); }
.wd-list-row.is-selected { background: var(--bg-surface); }
.wd-list-row.is-selected::before {
content: '';
position: absolute;
left: 4px;
top: 50%;
transform: translateY(-50%);
width: 4px;
height: 8px;
border-radius: 2px;
background: var(--accent);
}
.wd-list-cell {
font: 400 13px/1 var(--font);
color: var(--text-primary);
}
.wd-list-cell--name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 500; }
.wd-list-cell--meta { color: var(--text-tertiary); font-family: var(--font-mono); font-size: 12px; }
.wd-list-cell--actions { margin-left: auto; display: flex; gap: 6px; }
.wd-list-header {
display: flex;
align-items: center;
gap: 16px;
height: 36px;
padding: 0 16px;
border-bottom: 1px solid var(--border);
font: 600 11px/1 var(--font);
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-tertiary);
}
```
- [ ] **Step 2: Write empty-state.css**
```css
/* empty-state.css ─ centered icon + title + body + CTA, no card chrome */
.wd-empty {
min-height: 360px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
text-align: center;
padding: 48px 24px;
}
.wd-empty-icon {
width: 28px;
height: 28px;
color: var(--text-tertiary);
}
.wd-empty-title {
font: 600 14px/1.3 var(--font);
color: var(--text-primary);
}
.wd-empty-body {
font: 400 13px/1.5 var(--font);
color: var(--text-secondary);
max-width: 360px;
}
.wd-empty-actions {
display: flex;
gap: 8px;
margin-top: 4px;
}
```
- [ ] **Step 3: Commit**
```bash
HOME=/root git add services/web-ui/src/css/components/list-row.css services/web-ui/src/css/components/empty-state.css
HOME=/root git commit -m "web-ui: list-row + empty-state primitives"
```
---
### Task 15: Create badge + toast primitives
**Files:**
- Create: `services/web-ui/src/css/components/badge.css`
- Create: `services/web-ui/src/css/components/toast.css`
- [ ] **Step 1: Write badge.css**
```css
/* badge.css ─ status pills + signal badges */
.wd-badge {
display: inline-flex;
align-items: center;
gap: 4px;
height: 18px;
padding: 0 6px;
border-radius: 3px;
font: 600 10px/1 var(--font);
letter-spacing: 0.08em;
text-transform: uppercase;
}
.wd-badge--good { background: var(--signal-good-bg); color: var(--signal-good); }
.wd-badge--bad { background: var(--signal-bad-bg); color: var(--signal-bad); }
.wd-badge--warn { background: var(--signal-warn-bg); color: var(--signal-warn); }
.wd-badge--idle { background: var(--bg-surface); color: var(--text-tertiary); }
.wd-badge--info { background: var(--accent-subtle); color: var(--accent); }
/* Status dot (4px circle, leading indicator on cards/rows) */
.wd-dot {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.wd-dot--good { background: var(--signal-good); }
.wd-dot--bad { background: var(--signal-bad); }
.wd-dot--warn { background: var(--signal-warn); }
.wd-dot--idle { background: var(--signal-idle); }
```
- [ ] **Step 2: Write toast.css**
```css
/* toast.css ─ bottom-right, top-strip variant.
* 4px top strip = allowed (not a side-stripe). */
.wd-toast-container {
position: fixed;
bottom: 16px;
right: 16px;
z-index: var(--z-toast);
display: flex;
flex-direction: column;
gap: 8px;
max-width: 320px;
}
.wd-toast {
position: relative;
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: 4px;
padding: 12px 14px 12px 14px;
display: flex;
flex-direction: column;
gap: 4px;
box-shadow: 0 12px 32px -16px oklch(0% 0 0 / 0.7);
}
.wd-toast::before {
content: '';
position: absolute;
top: 0; left: 0; right: 0;
height: 4px;
border-radius: 4px 4px 0 0;
}
.wd-toast--success { border-color: var(--signal-good); }
.wd-toast--success::before { background: var(--signal-good); }
.wd-toast--error { border-color: var(--signal-bad); }
.wd-toast--error::before { background: var(--signal-bad); }
.wd-toast--warning { border-color: var(--signal-warn); }
.wd-toast--warning::before { background: var(--signal-warn); }
.wd-toast--info { border-color: var(--accent); }
.wd-toast--info::before { background: var(--accent); }
.wd-toast-title {
font: 600 13px/1.3 var(--font);
color: var(--text-primary);
margin-top: 4px;
}
.wd-toast-body {
font: 400 12px/1.5 var(--font);
color: var(--text-secondary);
}
```
- [ ] **Step 3: Commit**
```bash
HOME=/root git add services/web-ui/src/css/components/badge.css services/web-ui/src/css/components/toast.css
HOME=/root git commit -m "web-ui: badge + toast primitives"
```
---
### Task 16: Create entry CSS that imports everything
**Files:**
- Create: `services/web-ui/src/css/app.css`
- [ ] **Step 1: Write app.css**
```css
/* app.css ─ Tailwind directives + primitive imports.
*
* Order matters:
* 1. tokens (CSS custom properties for legacy callers)
* 2. tailwind base (CSS reset)
* 3. tailwind components
* 4. our primitives (use tokens + can be overridden by utilities)
* 5. tailwind utilities (highest specificity, last word)
* 6. motion (animations + reduced-motion override)
*/
@import "./components/tokens.css";
@tailwind base;
@tailwind components;
/* Primitives — each one ~50-200 lines, one responsibility per file */
@import "./components/sidebar.css";
@import "./components/topbar.css";
@import "./components/button.css";
@import "./components/form-controls.css";
@import "./components/field-group.css";
@import "./components/slide-panel.css";
@import "./components/card-asset.css";
@import "./components/card-operational.css";
@import "./components/list-row.css";
@import "./components/empty-state.css";
@import "./components/badge.css";
@import "./components/toast.css";
@tailwind utilities;
@import "./components/motion.css";
/* Global base — self-hosted fonts */
@font-face {
font-family: 'Inter';
src: url('/fonts/inter-400.woff2') format('woff2');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Inter';
src: url('/fonts/inter-500.woff2') format('woff2');
font-weight: 500;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Inter';
src: url('/fonts/inter-600.woff2') format('woff2');
font-weight: 600;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'JetBrains Mono';
src: url('/fonts/jetbrains-mono-400.woff2') format('woff2');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'JetBrains Mono';
src: url('/fonts/jetbrains-mono-600.woff2') format('woff2');
font-weight: 600;
font-style: normal;
font-display: swap;
}
html {
font-family: var(--font);
background: var(--bg-base);
color: var(--text-primary);
-webkit-font-smoothing: antialiased;
}
```
- [ ] **Step 2: Commit**
```bash
HOME=/root git add services/web-ui/src/css/app.css
HOME=/root git commit -m "web-ui: app.css entry — tailwind directives + primitive imports"
```
---
### Task 17: Add self-hosted font files
**Files:**
- Create: `services/web-ui/public/fonts/inter-400.woff2`
- Create: `services/web-ui/public/fonts/inter-500.woff2`
- Create: `services/web-ui/public/fonts/inter-600.woff2`
- Create: `services/web-ui/public/fonts/jetbrains-mono-400.woff2`
- Create: `services/web-ui/public/fonts/jetbrains-mono-600.woff2`
Download from Google Fonts API (woff2 only, latin subset). These are binary files committed to the repo.
- [ ] **Step 1: Download font files via MeshCentral on zampp1**
Run on zampp1:
```bash
cd /opt/wild-dragon/services/web-ui/public
mkdir -p fonts
cd fonts
# Inter
for WEIGHT in 400 500 600; do
curl -sL -o "inter-${WEIGHT}.woff2" \
"https://github.com/rsms/inter/raw/master/docs/font-files/Inter-$(case $WEIGHT in 400) echo Regular;; 500) echo Medium;; 600) echo SemiBold;; esac).woff2"
done
# JetBrains Mono
for WEIGHT in 400 600; do
curl -sL -o "jetbrains-mono-${WEIGHT}.woff2" \
"https://github.com/JetBrains/JetBrainsMono/raw/master/fonts/webfonts/JetBrainsMono-$(case $WEIGHT in 400) echo Regular;; 600) echo SemiBold;; esac).woff2"
done
ls -la
```
Expected: 5 .woff2 files, each 50-200KB.
- [ ] **Step 2: Verify each file is a valid woff2**
```bash
for f in *.woff2; do
echo "$f: $(file "$f" | grep -c 'Web Open Font Format')"
done
```
Expected: each line ends in `: 1`.
- [ ] **Step 3: Commit binary files**
```bash
cd /opt/wild-dragon
HOME=/root git add services/web-ui/public/fonts/*.woff2
HOME=/root git commit -m "web-ui: self-host Inter + JetBrains Mono woff2 fonts"
```
---
### Task 18: Modify Dockerfile to add Node build stage
**Files:**
- Modify: `services/web-ui/Dockerfile`
- [ ] **Step 1: Read existing Dockerfile to see current structure**
Run on zampp1:
```bash
cat /opt/wild-dragon/services/web-ui/Dockerfile
```
Note the structure. The current single-stage Dockerfile is likely just `FROM nginx + COPY public/ + COPY nginx.conf`. The new file is multi-stage.
- [ ] **Step 2: Rewrite Dockerfile as two-stage build**
Replace `services/web-ui/Dockerfile` with:
```dockerfile
# ── Stage 1: build CSS bundle ─────────────────────────────────
FROM node:20-alpine AS css-build
WORKDIR /build
# Copy only the files needed to install deps (better cache layering)
COPY package.json package-lock.json* ./
RUN npm install --no-audit --no-fund
# Copy source CSS + tailwind config + every HTML file (tailwind
# scans HTML to determine which utilities to emit)
COPY tailwind.config.js postcss.config.js ./
COPY src/ ./src/
COPY public/ ./public/
# Build into public/dist/app.css
RUN npx tailwindcss -i ./src/css/app.css -o ./public/dist/app.css --minify
# ── Stage 2: runtime ──────────────────────────────────────────
FROM nginx:1.27-alpine
COPY --from=css-build /build/public/ /usr/share/nginx/html/
COPY nginx.conf /etc/nginx/conf.d/default.conf
```
- [ ] **Step 3: Commit**
```bash
HOME=/root git add services/web-ui/Dockerfile
HOME=/root git commit -m "web-ui: two-stage Dockerfile (Node build + nginx runtime)"
```
---
### Task 19: Modify nginx.conf to cache /dist/* aggressively
**Files:**
- Modify: `services/web-ui/nginx.conf`
- [ ] **Step 1: Read existing nginx.conf**
```bash
cat /opt/wild-dragon/services/web-ui/nginx.conf
```
- [ ] **Step 2: Add a location block for /dist/ and /fonts/**
Add the following inside the existing `server` block (above the catch-all `location /`):
```nginx
location /dist/ {
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
}
location /fonts/ {
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
}
```
- [ ] **Step 3: Commit**
```bash
HOME=/root git add services/web-ui/nginx.conf
HOME=/root git commit -m "web-ui: aggressive caching for /dist/ and /fonts/"
```
---
### Task 20: Build and verify the new image locally on zampp1
**Files:** none modified, verification only.
- [ ] **Step 1: Build the new image**
On zampp1:
```bash
cd /opt/wild-dragon
docker compose build web-ui 2>&1 | tail -40
```
Expected: build succeeds. Look for `tailwindcss` running and `app.css` being emitted. No errors.
- [ ] **Step 2: Restart the container with the new image**
```bash
docker compose up -d web-ui
sleep 3
docker ps --filter name=wild-dragon-web-ui --format 'table {{.Names}}\t{{.Status}}'
```
Expected: container running, recent uptime.
- [ ] **Step 3: Verify the new CSS bundle is being served**
```bash
curl -sk -o /dev/null -w 'HTTP=%{http_code} size=%{size_download}\n' http://localhost:47434/dist/app.css
```
Expected: HTTP=200, size > 10000 bytes (minified CSS bundle).
- [ ] **Step 4: Verify fonts are being served**
```bash
for f in inter-400 inter-500 inter-600 jetbrains-mono-400 jetbrains-mono-600; do
STATUS=$(curl -sk -o /dev/null -w '%{http_code}/%{size_download}' "http://localhost:47434/fonts/${f}.woff2")
echo " ${f}.woff2: $STATUS"
done
```
Expected: each line ends in `: 200/<size>` where size is 50000-200000.
- [ ] **Step 5: Verify existing pages STILL WORK (no regression)**
```bash
for p in home recorders cluster index login editor; do
STATUS=$(curl -sk -o /dev/null -w '%{http_code}' "http://localhost:47434/${p}.html")
echo " ${p}.html: $STATUS"
done
```
Expected: every page returns 200. (No page references the new CSS yet, but the build must not have broken anything.)
- [ ] **Step 6: Verify the recorders page specifically still has its codec UI intact**
```bash
curl -sk http://localhost:47434/recorders.html | grep -cE 'codec-tab|sdi-card-wrap|min-height: 0|BMDCards.render'
```
Expected: 4+ matches (the in-page CSS + JS from the previous rewrite is still there).
- [ ] **Step 7: Commit a verification note**
Nothing to commit — this is verification only. If all steps pass, move on. If any fails, stop and debug before continuing.
---
### Task 21: Smoke-test sample HTML against the new primitives
**Files:**
- Create: `services/web-ui/public/_primitives-smoke.html`
A one-off page that exercises every primitive class so we can visually QA the bundle without migrating a real page yet. Will be deleted at the end of wave 4.
- [ ] **Step 1: Write the smoke-test page**
Write `services/web-ui/public/_primitives-smoke.html`:
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Primitives smoke test</title>
<link rel="stylesheet" href="/dist/app.css">
</head>
<body>
<div style="display:flex; min-height:100vh;">
<nav class="wd-sidebar">
<div class="wd-sidebar-header">
<div style="width:18px;height:18px;background:var(--accent);border-radius:3px"></div>
<span class="wd-sidebar-brand">Z-AMPP</span>
</div>
<div class="wd-sidebar-nav">
<a href="#" class="wd-nav-item is-active"><span style="width:14px;height:14px;background:currentColor;opacity:0.6"></span>Home</a>
<a href="#" class="wd-nav-item"><span style="width:14px;height:14px;background:currentColor;opacity:0.6"></span>Library</a>
<a href="#" class="wd-nav-item"><span style="width:14px;height:14px;background:currentColor;opacity:0.6"></span>Recorders</a>
<div class="wd-sidebar-section">Admin</div>
<a href="#" class="wd-nav-item"><span style="width:14px;height:14px;background:currentColor;opacity:0.6"></span>Settings <span class="nav-dev-badge">IN DEV</span></a>
</div>
</nav>
<div style="flex:1;display:flex;flex-direction:column;">
<header class="wd-topbar">
<div class="wd-topbar-left">
<nav class="wd-breadcrumb">
<span class="wd-breadcrumb-crumb">Library</span>
<svg class="wd-breadcrumb-sep" viewBox="0 0 10 10"><path d="M3 1l4 4-4 4" fill="none" stroke="currentColor"/></svg>
<span class="wd-breadcrumb-crumb">Project Alpha</span>
<svg class="wd-breadcrumb-sep" viewBox="0 0 10 10"><path d="M3 1l4 4-4 4" fill="none" stroke="currentColor"/></svg>
<span class="wd-breadcrumb-crumb">Key Scenes</span>
</nav>
</div>
<div class="wd-topbar-center">
<div class="wd-topbar-search">
<svg viewBox="0 0 12 12"><circle cx="5" cy="5" r="3.5" fill="none" stroke="currentColor"/><path d="M8 8l3 3" stroke="currentColor"/></svg>
<input type="text" placeholder="Search in Key Scenes...">
</div>
</div>
<div class="wd-topbar-right">
<button class="wd-btn wd-btn--ghost wd-btn--sm wd-btn--icon" aria-label="Filter">f</button>
<button class="wd-btn wd-btn--ghost wd-btn--sm wd-btn--icon" aria-label="Sort">s</button>
<div class="wd-topbar-divider"></div>
<button class="wd-btn wd-btn--primary wd-btn--sm">+ New asset</button>
</div>
</header>
<main style="flex:1; padding:20px 20px 32px;">
<!-- Asset card grid -->
<h2 style="font:600 13px/1 var(--font); letter-spacing:0.08em; text-transform:uppercase; color:var(--text-tertiary); margin:0 0 12px;">Asset cards</h2>
<div class="wd-card-asset-grid" style="margin-bottom:32px;">
<article class="wd-card-asset">
<div class="wd-card-asset-thumb" style="background:linear-gradient(135deg,#333,#111)">
<span class="wd-card-asset-chip wd-card-asset-chip--duration">00:30</span>
<span class="wd-card-asset-chip wd-card-asset-chip--comments">2</span>
</div>
<div class="wd-card-asset-meta">
<div class="wd-card-asset-name">DRP_B004_081606_V1_0099.mov</div>
<div class="wd-card-asset-sub">Alissa Morris · Oct 14th, 2024</div>
</div>
<button class="wd-card-asset-role" style="color:var(--signal-warn)">Coloring</button>
</article>
<article class="wd-card-asset">
<div class="wd-card-asset-thumb" style="background:linear-gradient(135deg,#553,#221)">
<span class="wd-card-asset-chip wd-card-asset-chip--duration">00:05</span>
<span class="wd-card-asset-chip wd-card-asset-chip--version">V2</span>
</div>
<div class="wd-card-asset-meta">
<div class="wd-card-asset-name">DRP_A015_0815OF_V1_0023.mov</div>
<div class="wd-card-asset-sub">Alissa Morris · Oct 14th, 2024</div>
</div>
<button class="wd-card-asset-role wd-card-asset-role--unset">Select role</button>
</article>
</div>
<!-- Operational cards -->
<h2 style="font:600 13px/1 var(--font); letter-spacing:0.08em; text-transform:uppercase; color:var(--text-tertiary); margin:0 0 12px;">Operational cards</h2>
<div class="wd-card-op-grid" style="margin-bottom:32px;">
<article class="wd-card-op is-active">
<header class="wd-card-op-header">
<span class="wd-card-op-name">Studio A SRT</span>
<span class="wd-badge wd-badge--bad"><span class="wd-dot wd-dot--bad"></span>Recording</span>
</header>
<div class="wd-card-op-content">
<div class="wd-signal-strip"><div class="wd-signal-strip-fill wd-sweep"></div></div>
<div style="display:flex; justify-content:space-between; align-items:center;">
<span>Receiving · 12450 fr · 30 fps</span>
<span style="font-family:var(--font-mono); font-variant-numeric:tabular-nums; color:var(--signal-bad); font-weight:600;">00:23:14</span>
</div>
</div>
<footer class="wd-card-op-footer">
<span class="wd-card-op-meta">Last: Oct 14th, 2024 4:00 PM</span>
<div class="wd-card-op-actions">
<button class="wd-btn wd-btn--danger wd-btn--sm">Stop</button>
</div>
</footer>
</article>
<article class="wd-card-op">
<header class="wd-card-op-header">
<span class="wd-card-op-name">zampp2</span>
<span class="wd-badge wd-badge--good"><span class="wd-dot wd-dot--good"></span>Online</span>
</header>
<div class="wd-card-op-content">
<div>
<div style="display:flex; justify-content:space-between; font:400 11px/1 var(--font-mono); color:var(--text-tertiary); margin-bottom:4px;">
<span>CPU</span><span>42%</span>
</div>
<div class="wd-mini-bar"><div class="wd-mini-bar-fill" style="width:42%"></div></div>
</div>
<div>
<div style="display:flex; justify-content:space-between; font:400 11px/1 var(--font-mono); color:var(--text-tertiary); margin-bottom:4px;">
<span>Memory</span><span>71%</span>
</div>
<div class="wd-mini-bar"><div class="wd-mini-bar-fill wd-mini-bar-fill--warn" style="width:71%"></div></div>
</div>
</div>
<footer class="wd-card-op-footer">
<span class="wd-card-op-meta">DeckLink Duo 2 · 4 ports</span>
<div class="wd-card-op-actions">
<button class="wd-btn wd-btn--ghost wd-btn--sm">Ping</button>
</div>
</footer>
</article>
</div>
<!-- List rows -->
<h2 style="font:600 13px/1 var(--font); letter-spacing:0.08em; text-transform:uppercase; color:var(--text-tertiary); margin:0 0 12px;">List rows</h2>
<div class="wd-list" style="margin-bottom:32px; border:1px solid var(--border-faint); border-radius:6px; overflow:hidden;">
<div class="wd-list-header">
<span style="flex:1">Name</span>
<span>Image</span>
<span>Status</span>
<span>Actions</span>
</div>
<div class="wd-list-row is-selected">
<span class="wd-list-cell wd-list-cell--name">wild-dragon-mam-api-1</span>
<span class="wd-list-cell wd-list-cell--meta">wild-dragon-mam-api:latest</span>
<span><span class="wd-badge wd-badge--good">Up</span></span>
<span class="wd-list-cell--actions"><button class="wd-btn wd-btn--ghost wd-btn--sm">Logs</button></span>
</div>
<div class="wd-list-row">
<span class="wd-list-cell wd-list-cell--name">wild-dragon-web-ui-1</span>
<span class="wd-list-cell wd-list-cell--meta">wild-dragon-web-ui:latest</span>
<span><span class="wd-badge wd-badge--good">Up</span></span>
<span class="wd-list-cell--actions"><button class="wd-btn wd-btn--ghost wd-btn--sm">Logs</button></span>
</div>
</div>
<!-- Buttons matrix -->
<h2 style="font:600 13px/1 var(--font); letter-spacing:0.08em; text-transform:uppercase; color:var(--text-tertiary); margin:0 0 12px;">Buttons</h2>
<div style="display:flex; gap:12px; flex-wrap:wrap; margin-bottom:32px;">
<button class="wd-btn wd-btn--primary wd-btn--sm">Primary sm</button>
<button class="wd-btn wd-btn--primary wd-btn--md">Primary md</button>
<button class="wd-btn wd-btn--secondary wd-btn--md">Secondary md</button>
<button class="wd-btn wd-btn--ghost wd-btn--md">Ghost md</button>
<button class="wd-btn wd-btn--danger wd-btn--md">Danger md</button>
<button class="wd-btn wd-btn--primary wd-btn--md" disabled>Disabled</button>
</div>
<!-- Forms -->
<h2 style="font:600 13px/1 var(--font); letter-spacing:0.08em; text-transform:uppercase; color:var(--text-tertiary); margin:0 0 12px;">Form controls</h2>
<div style="max-width:460px; display:flex; flex-direction:column; gap:14px; margin-bottom:32px;">
<div class="wd-form-group">
<label class="wd-label" for="smoke-input">Recorder name</label>
<input class="wd-input" id="smoke-input" placeholder="e.g. Studio A SRT">
<div class="wd-hint">Letters, numbers, dashes. Used in clip filenames.</div>
</div>
<div class="wd-form-row">
<div class="wd-form-group">
<label class="wd-label" for="smoke-select">Codec</label>
<select class="wd-select" id="smoke-select">
<option>ProRes 422 HQ</option>
<option>H.264 NVENC</option>
</select>
</div>
<div class="wd-form-group">
<label class="wd-label" for="smoke-fps">Framerate</label>
<input class="wd-input" id="smoke-fps" value="29.97">
</div>
</div>
<label class="wd-toggle">
<input type="checkbox" checked>
<span class="wd-toggle-track"></span>
<span class="wd-toggle-label">Generate proxy</span>
</label>
</div>
<!-- Field-group with tabs -->
<h2 style="font:600 13px/1 var(--font); letter-spacing:0.08em; text-transform:uppercase; color:var(--text-tertiary); margin:0 0 12px;">Field group</h2>
<div style="max-width:460px; margin-bottom:32px;">
<div class="wd-field-group">
<div class="wd-field-group-header">
<span class="wd-field-group-title">Master recording</span>
</div>
<div class="wd-tabs">
<button class="wd-tab is-active">Video</button>
<button class="wd-tab">Audio</button>
<button class="wd-tab">Container</button>
</div>
<div class="wd-tab-panel is-active">
<div class="wd-form-row">
<div class="wd-form-group">
<label class="wd-label">Codec</label>
<select class="wd-select"><option>ProRes 422 HQ</option></select>
</div>
<div class="wd-form-group">
<label class="wd-label">Resolution</label>
<select class="wd-select"><option>1920×1080</option></select>
</div>
</div>
</div>
</div>
</div>
<!-- Empty state -->
<h2 style="font:600 13px/1 var(--font); letter-spacing:0.08em; text-transform:uppercase; color:var(--text-tertiary); margin:0 0 12px;">Empty state</h2>
<div class="wd-empty">
<svg class="wd-empty-icon" viewBox="0 0 28 28" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="2" y="6" width="18" height="16" rx="2"/><path d="M20 11l6-3v12l-6-3"/>
</svg>
<div class="wd-empty-title">No recorders yet</div>
<div class="wd-empty-body">Create a recorder to ingest live streams via SRT, RTMP, or SDI.</div>
<div class="wd-empty-actions">
<button class="wd-btn wd-btn--primary wd-btn--sm">+ New recorder</button>
</div>
</div>
</main>
</div>
</div>
</body>
</html>
```
- [ ] **Step 2: Rebuild the web-ui image so the smoke page ships**
```bash
cd /opt/wild-dragon
docker compose up -d --build web-ui 2>&1 | tail -8
```
Expected: image rebuilt, container restarted, exit code 0.
- [ ] **Step 3: Verify the smoke page renders**
```bash
curl -sk -o /dev/null -w 'HTTP=%{http_code} size=%{size_download}\n' http://localhost:47434/_primitives-smoke.html
```
Expected: HTTP=200, size > 5000 bytes.
- [ ] **Step 4: Inspect the rendered CSS for known primitive classes**
```bash
curl -sk http://localhost:47434/dist/app.css | grep -cE '\.wd-sidebar|\.wd-card-asset|\.wd-card-op|\.wd-slide-panel|\.wd-field-group|\.wd-toast|\.wd-btn|\.wd-input'
```
Expected: at least 30 matches.
- [ ] **Step 5: Commit the smoke page**
```bash
HOME=/root git add services/web-ui/public/_primitives-smoke.html
HOME=/root git commit -m "web-ui: add _primitives-smoke.html for wave-1 QA (delete at wave-4 end)"
```
- [ ] **Step 6: User QA gate**
Stop. Ask user to load `http://<zampp1-ip>:47434/_primitives-smoke.html` in their browser and visually QA the new primitives. If any primitive looks wrong, file a new task to fix it. Do not proceed to wave 2 until the user signs off on the visual quality of the smoke page.
---
### Task 22: Push everything to forge.wilddragon.net
**Files:** none modified.
- [ ] **Step 1: Push commits to main**
```bash
cd /opt/wild-dragon
HOME=/root git push origin main 2>&1 | tail -5
```
Expected: push succeeds, several commits land on `origin/main`.
- [ ] **Step 2: Verify the new files exist in the remote**
```bash
curl -s -H "Authorization: token <token>" \
"https://forge.wilddragon.net/api/v1/repos/zgaetano/wild-dragon/contents/services/web-ui/src/css/app.css?ref=main" \
| head -c 200
```
Expected: JSON response with `"sha"` field, not 404.
---
## Wave 2 / 3 / 4 placeholder
These waves are **out of scope for this plan document**. Each will get its own detailed plan after the prior wave ships and is verified.
- **Wave 2** (`docs/superpowers/plans/2026-05-2X-ui-shell-rework-wave-2-plan.md`): migrate `login.html`, `home.html`, `settings.html`, `tokens.html`, `users.html`, `containers.html` to the new primitives. One task per page.
- **Wave 3** (`docs/superpowers/plans/2026-05-2X-ui-shell-rework-wave-3-plan.md`): migrate `index.html`, `projects.html`, `upload.html`, `jobs.html`, `api-tokens.html`. One task per page.
- **Wave 4** (`docs/superpowers/plans/2026-05-2X-ui-shell-rework-wave-4-plan.md`): migrate `recorders.html`, `cluster.html`, `capture.html`. One task per page, plus a final task to delete `_primitives-smoke.html`.
---
## Self-review notes
(For the implementer / reviewer.)
- **Spec coverage:** Every numbered section of the spec maps to at least one task. Tokens → task 4. Sidebar → task 6. Topbar → task 7. Cards (4 families) → tasks 12, 13, 14. Forms → tasks 9, 10. Slide-panel → task 11. Motion → task 5. Badges/toasts → task 15. A11y (tertiary lightness bump) → task 4. Build/theme port → tasks 1-3, 18, 19. Self-hosted fonts → task 17. Smoke test → task 21.
- **Placeholders:** none. Every code step has the actual code.
- **Type consistency:** class names use the `wd-` prefix throughout. Sidebar active state uses `.is-active` everywhere. Slide-panel uses `.is-open` everywhere. List-row selected uses `.is-selected`.
- **Open risk:** the Inter / JetBrains Mono download URLs in task 17 step 1 depend on those GitHub repos remaining at the same paths. If they 404, fall back to fonts.google.com woff2 downloads or vendor the files in manually.