feat(schedule): EPG stylesheet + impeccable context (PRODUCT / DESIGN.md) #23

Merged
zgaetano merged 1 commit from polish/schedule-epg-styling into main 2026-05-23 16:20:49 -04:00
3 changed files with 533 additions and 89 deletions

143
DESIGN.md Normal file
View file

@ -0,0 +1,143 @@
# Design system
Documented from the live tokens in `services/web-ui/public/styles.css` and the patterns used across `screens-*.jsx`. Treat this as the source of truth for shared primitives; pages may override locally but should not invent new color or type scales.
## Color
Dark theme only. Tokens live in `:root` in `styles.css`. Tinted neutrals — no `#000`, no `#fff`.
### Surfaces
| Token | Hex | Use |
|---|---|---|
| `--bg-0` | `#0B0D11` | Page background, deepest surface |
| `--bg-1` | `#14171E` | Panels, sidebars, primary chrome |
| `--bg-2` | `#1B1F27` | Panel headers, hover state |
| `--bg-3` | `#232833` | Inputs, raised buttons, badges |
| `--bg-4` | `#2D3340` | Strongly raised elements (rare) |
### Borders + overlays
| Token | Use |
|---|---|
| `--border` (rgba white 6%) | Default 1px separator |
| `--border-strong` (rgba white 10%) | Emphasized separator |
| `--border-stronger` (rgba white 14%) | Hover/active border |
| `--hover` (rgba white 4%) | Subtle hover fill |
| `--hover-strong` (rgba white 7%) | Stronger hover fill |
### Text
| Token | Hex | Use |
|---|---|---|
| `--text-1` | `#F2F3F6` | Primary content |
| `--text-2` | `#A8AEBC` | Secondary content |
| `--text-3` | `#6B7280` | Labels, metadata |
| `--text-4` | `#4B5260` | Section labels, off-month, disabled |
### Accent
`--accent: #5B7CFA` (Frame.io-ish blue). Soft variants `--accent-soft` (14%) and `--accent-soft-2` (22%) for fills. `--accent-text: #B4C3FF` for high-contrast accent text.
**Restrained strategy.** Accent is the only saturated color in chrome. Status colors below appear only where they reflect actual state. Project colors appear only on project chips.
### Status
| Token | Hex | Use |
|---|---|---|
| `--success` | `#2DD4A8` | Done, healthy, ready |
| `--warning` | `#F5A623` | Processing, attention-needed |
| `--danger` | `#FF5B5B` | Failed, error |
| `--live` | `#FF3B30` | Currently recording (broadcast red, intentionally hotter than `--danger`) |
| `--purple` | `#B57CFA` | Editor / dev tags |
Each has a `*-soft` variant at ~14% for background fills.
### Project palette
Six fixed colors cycled by index in `data.jsx` (`PROJECT_COLORS`):
`#5B7CFA · #2DD4A8 · #FF5B5B · #F5A623 · #B57CFA · #6B7280`
Used only on the project chip / rail dot. Do not reuse for status meaning.
## Typography
- **Sans:** Geist (with `cv11`, `ss01` features enabled). All UI text by default.
- **Mono:** Geist Mono. URLs, IDs, timestamps, durations, technical metadata.
Body scale runs small for density:
| Size | Use |
|---|---|
| 10.5px, 600, uppercase, 0.060.08em letterspacing | Column heads, section labels |
| 1111.5px | Metadata, secondary rows |
| 1212.5px | Body in lists/tables |
| 13px, 500600 | Row labels, button text |
| 14px | Default body |
| 15px, 600 | Modal titles, panel heads |
| 2228px, 600+ | Page H1 (`.page-header h1`) |
Never use `gradient text` (impeccable absolute ban). Emphasis via weight and size only.
## Layout
- Sidebar: 232px fixed (`--sidebar-w`).
- Topbar: 56px (`--topbar-h`).
- Row height: 44px default, 36px compact (`--row-h`, `data-density="compact"`).
- Gap unit: 16px default, 12px compact (`--gap`).
- Border radius scale: 4 / 6 / 8 / 12 / 16 px (`--r-xs` → `--r-xl`).
- Panels (`.panel`): `--bg-1` + 1px `--border` + `--r-lg`. Use for grouped lists, not for every section.
- Do NOT nest panels.
## Shadow
Two tokens, used sparingly:
- `--shadow-card`: subtle inset highlight + soft outer. Default for raised inputs.
- `--shadow-pop`: modal / popover / context menu.
No drop shadows on flat surfaces. No glow effects (except: the EPG now-line uses a tight `box-shadow` for the broadcast-red glow, and live status dots have a pulse halo; these are state cues, not decoration).
## Motion
- Default transition: 80120ms on background/border (`transition: background 80ms, border 80ms`).
- Heavier reveals: 200ms.
- Easing: prefer ease-out (no bounce, no elastic).
- Don't animate layout (width/height/top); animate transforms and opacity.
## Patterns
### Status badges (`.badge`)
Variants: `live` (red, animated dot), `success`, `danger`, `warning`, `accent`, `neutral`, `outline`. Tiny — 910px font, ~2px vertical padding. Reserved for state, not labels.
### Row tables (`.user-row`, `.token-row`, `.job-row`, `.schedule-row`, etc.)
CSS-grid with explicit columns. Header row uses `.head` (uppercase 10.5px). No card stacking — these are dense data lists.
### Schedule EPG (`.epg-*`)
Broadcast timeline pattern. Recorder rows × time-of-day axis. Single scrolling container with sticky left gutter (220px) and sticky top ruler (32px). Hour rhythm via `repeating-linear-gradient`. Now-line is a 1px hot-red vertical bar with `--live` glow, animated by re-rendering the line component every second (transform-only positioning, no layout thrash). Event blocks are absolute-positioned within each row, colored via `--epg-block-color` set per recorder's project color. Live events get a red gradient + pulse; failures get a glyph + full red border; past events fade to 0.55 opacity.
### Inputs
`.field-input``--bg-3` fill, 1px `--border`, `--r-sm`, 12.5px font. Focus: `--border-strong`.
### Status dot (`.signal-dot`, `.rec-dot`, etc.)
Small (~68px) circle, used inline with text. Recording dots pulse with a keyframe animation.
## Impeccable absolute bans (apply project-wide)
- No `border-left` / `border-right` greater than 1px as a colored accent (rewrite with full borders, leading icons, or background tint).
- No `background-clip: text` gradient text.
- No glassmorphism (blur + translucent) decoratively.
- No hero-metric template (big number, small label, gradient accent, supporting stats).
- No identical card grids.
- Modal as last resort — exhaust inline alternatives first.
- No em dashes in code or copy. Use commas, colons, parentheses, periods.
## To extend
When a new design need arises, prefer adding a variant to an existing primitive over inventing a new token. New tokens land in `styles.css`. New components land in the relevant `screens-*.jsx` only if reused; otherwise keep them local.

61
PRODUCT.md Normal file
View file

@ -0,0 +1,61 @@
# Product
## What it is
Dragonflight (codebase name; UI brand: "Dragonflight · Wild Dragon Broadcast") is an on-prem broadcast media asset manager and live ingest controller. Operators capture from SRT, RTMP, and SDI sources, schedule windowed recordings against named recorders, transcode/proxy in the background, browse and clip in a library, import from YouTube, and hand off to Premiere or an in-house editor.
The deployable surface is a React (Babel-in-browser) single-page app served by nginx, talking to a Node/Express API backed by Postgres + Redis + BullMQ, with capture and worker containers handling the actual media.
Stack lives at `services/web-ui` (UI), `services/mam-api` (API), `services/capture`, `services/worker`, `services/editor`.
## Register
**Product.** This is operator UI for a working broadcast tool, not a brand site or marketing surface. Design serves the operator's job, not the brand's identity.
## Users
Primary: broadcast operators and engineers running live productions. They schedule and supervise back-to-back recordings across multiple recorders in a single shift. They care about: what's recording right now, what's about to start, what failed, and which recorder is bound to what source.
Secondary: editors and producers who consume the resulting library, comment on assets, request proxy regeneration. They mostly live in the Library and Asset detail screens, not the scheduler.
Tertiary: admins managing recorders, users, cluster nodes, and storage. Live in the Admin screens.
## Product purpose
Replace a stack of one-trick tools (NewBlue scheduler, vMix capture, ad-hoc Premiere ingest, manual S3 syncs) with a single operator surface that supervises recorders, owns the asset catalog through proxy generation, and stays out of the editor's way once footage lands.
## Tone
Function-first. Dense. Operations-room. Mono fonts where data lives. Small type. Restrained chrome. The operator should be able to glance at any screen and read the state of the system in under a second; they should never wonder "is this still happening" or "did that finish."
Not: marketing-warm, conversational, gamified, congratulatory.
## Strategic principles
1. **Glance-readable status.** Every list, every cell, every badge must answer "what is the state of this thing right now" without a hover.
2. **Trust the operator.** No confirmation modals for reversible actions. No nag, no toasts for routine success. Errors stay visible until acknowledged.
3. **Time is the spine.** This product is about time-based events (recordings, schedules, jobs). UIs should privilege time as a primary axis, not bury it under categorical filters.
4. **Density over whitespace.** Operators run multi-monitor setups and want maximum signal per pixel. Generous whitespace is a brand-site reflex; reject it here.
5. **No half-states.** Pending UIs disable controls; live UIs show live data; failed UIs show the failure inline, not in a separate notification feed.
## Anti-references
Steer away from:
- **Linear-pastel SaaS aesthetics.** Purples, mint accents, friendly empty states with cartoon line illustrations.
- **Google-Calendar generic.** A neutral month grid with rounded event chips, no operational signal, optimized for "is Friday free" rather than "is recorder A in conflict at 14:00."
- **Gantt-chart project-management feel.** Implies long-horizon planning of tasks with dependencies; this product is hour-scale broadcast operations.
- **Cards-for-everything.** Identical card grids of icon+label+value. Particularly the SaaS hero-metric template.
- **Decorative blur / glassmorphism / gradient text.** Read as decorative AI slop in a broadcast-ops context.
- **NewBlue / Wirecast skinning.** Heavy bevels, gradient buttons, drop shadows. Read as outdated broadcast software.
## Decision context (Schedule v2)
The Schedule screen was rebuilt as an EPG (electronic program guide) timeline. Operator confirmed:
- Density: **heavy / back-to-back, many recorders all day** — month grid was the wrong primary.
- At-a-glance signals: now/next, recorder bookings (conflicts), project context, failure history.
- Aesthetic: **studio / cinematic — dark, type-led, accent moments.** DaVinci-Resolve-panel territory.
- Scope: full rethink — replace the primary view.
Implementation: recorder rows × time-of-day horizontal axis, sticky gutter + ruler, vertical hot-red now-line, event blocks colored by project, status pills in a top strip. Today / Week / List views.

View file

@ -617,113 +617,353 @@
.user-row:last-child, .token-row:last-child, .container-row:last-child, .schedule-row:last-child { border-bottom: 0; }
.token-row.revoked { opacity: 0.5; }
/* ========== Schedule calendar ========== */
.cal-toolbar {
display: flex; align-items: center; gap: 6px;
margin-bottom: 12px;
/* ========== Schedule (EPG timeline) ==========
Broadcast-control-room schedule. Recorders are rows, time is the horizontal
axis. The single scrollable .epg container uses a 2-column / 2-row grid
so the gutter (left) and ruler (top) stay sticky during scroll. */
.epg-page {
display: flex; flex-direction: column;
height: 100%;
min-height: 0;
--epg-pph: 88px; /* pixels per hour, overridden inline per view */
--epg-row-h: 60px;
--epg-gutter-w: 220px;
--epg-ruler-h: 32px;
}
.cal-month-label {
font-size: 14px; font-weight: 600;
min-width: 140px; text-align: center;
/* ---- Status strip (always on top of the schedule screen) -------------- */
.epg-status {
padding: 10px 20px 12px;
background: linear-gradient(180deg, var(--bg-1) 0%, var(--bg-0) 100%);
border-bottom: 1px solid var(--border);
}
.cal {
background: var(--bg-1);
border: 1px solid var(--border);
border-radius: var(--r-lg);
.epg-status-row {
display: flex; align-items: center; gap: 10px;
min-height: 22px;
font-size: 13px;
}
.epg-status-row.sub { margin-top: 2px; font-size: 12px; color: var(--text-3); }
.epg-status-dot {
width: 8px; height: 8px; border-radius: 50%;
flex-shrink: 0;
}
.epg-status-dot.live {
background: var(--live);
box-shadow: 0 0 0 4px rgba(255, 59, 48, 0.16);
animation: _epg_live_pulse 1.6s ease-out infinite;
}
.epg-status-dot.idle { background: var(--text-4); }
.epg-status-label {
font-weight: 600; letter-spacing: 0.02em;
text-transform: uppercase; font-size: 10.5px;
color: var(--text-2);
}
.epg-status-label.muted { color: var(--text-4); }
.epg-status-active {
display: flex; gap: 8px; flex-wrap: wrap; align-items: center;
flex: 1; min-width: 0;
}
.epg-status-pill {
display: inline-flex; align-items: center; gap: 8px;
padding: 2px 10px 2px 0;
background: var(--bg-2);
border: 1px solid var(--border-strong);
border-radius: 6px;
font-size: 12px;
overflow: hidden;
}
.cal-weekheads {
display: grid;
grid-template-columns: repeat(7, 1fr);
background: var(--bg-2);
.epg-status-pill-bar {
width: 3px; align-self: stretch;
background: var(--text-3);
}
.epg-status-pill-name { font-weight: 600; padding-left: 8px; }
.epg-status-pill-rec { color: var(--text-3); font-size: 11px; padding-left: 8px; border-left: 1px solid var(--border); }
.epg-status-pill-time { color: var(--text-2); font-size: 11px; padding-left: 8px; border-left: 1px solid var(--border); }
.epg-status-next { font-weight: 500; color: var(--text-1); }
.epg-status-next-rec { color: var(--text-3); padding-left: 6px; }
.epg-status-next-time { color: var(--accent-text); padding-left: 6px; }
@keyframes _epg_live_pulse {
0% { box-shadow: 0 0 0 0 rgba(255, 59, 48, 0.55); }
70% { box-shadow: 0 0 0 10px rgba(255, 59, 48, 0); }
100% { box-shadow: 0 0 0 0 rgba(255, 59, 48, 0); }
}
/* ---- Toolbar (date nav + view tabs + CTA) ------------------------------ */
.epg-toolbar {
display: flex; align-items: center; gap: 8px;
padding: 10px 20px;
border-bottom: 1px solid var(--border);
font-size: 10.5px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-4);
background: var(--bg-1);
}
.cal-weekheads > div { padding: 8px 10px; }
.cal-grid {
.epg-toolbar .spacer { flex: 1; }
.epg-date {
display: flex; align-items: center; gap: 4px;
}
.epg-date-label {
font-size: 15px; font-weight: 600;
min-width: 240px; padding: 0 6px;
letter-spacing: -0.01em;
}
/* ---- Today: scrollable timeline ---------------------------------------- */
.epg {
flex: 1; min-height: 0;
display: grid;
grid-template-columns: repeat(7, 1fr);
grid-auto-rows: minmax(110px, 1fr);
grid-template-columns: var(--epg-gutter-w) 1fr;
grid-template-rows: var(--epg-ruler-h) 1fr;
overflow: auto;
background: var(--bg-0);
position: relative;
}
.cal-cell {
.epg-corner {
position: sticky; top: 0; left: 0;
grid-row: 1; grid-column: 1;
z-index: 4;
background: var(--bg-1);
border-right: 1px solid var(--border);
border-bottom: 1px solid var(--border);
padding: 6px;
display: flex; flex-direction: column;
gap: 4px;
cursor: pointer;
display: flex; align-items: center;
padding: 0 14px;
font-size: 11px; color: var(--text-3);
letter-spacing: 0.02em;
}
.epg-gutter {
position: sticky; left: 0;
grid-row: 2; grid-column: 1;
z-index: 2;
background: var(--bg-1);
transition: background 80ms;
min-height: 110px;
border-right: 1px solid var(--border);
}
.cal-cell:hover { background: var(--bg-2); }
.cal-cell:nth-child(7n) { border-right: 0; }
.cal-cell.off-month { background: var(--bg-0); }
.cal-cell.off-month .cal-daynum { color: var(--text-4); }
.cal-cell.today { background: var(--accent-soft); }
.cal-cell.today:hover { background: var(--accent-soft-2); }
.cal-cell-head {
display: flex; align-items: center; gap: 6px;
.epg-gutter-rows { display: flex; flex-direction: column; }
.epg-gutter-row {
display: flex; align-items: center; gap: 10px;
height: var(--epg-row-h);
padding: 0 14px;
border-bottom: 1px solid var(--border);
position: relative;
}
.cal-daynum {
font-size: 12px;
font-weight: 600;
color: var(--text-2);
min-width: 18px;
}
.cal-today-pip {
font-size: 9.5px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--accent-text);
background: var(--bg-1);
padding: 1px 5px;
border-radius: 99px;
}
.cal-events {
display: flex; flex-direction: column;
gap: 2px;
overflow: hidden;
}
.cal-event {
display: flex; align-items: center; gap: 6px;
padding: 2px 6px;
border-radius: 3px;
border: 0;
cursor: pointer;
font-size: 11px;
text-align: left;
background: var(--bg-3);
color: var(--text-1);
border-left: 2px solid var(--text-3);
overflow: hidden;
}
.cal-event:hover { background: var(--bg-2); }
.cal-event.success { border-left-color: var(--success); }
.cal-event.danger { border-left-color: var(--danger); }
.cal-event.warning { border-left-color: var(--warning); }
.cal-event.accent { border-left-color: var(--accent); }
.cal-event.neutral { border-left-color: var(--text-3); }
.cal-event-time {
.epg-gutter-row:last-child { border-bottom: 0; }
.epg-gutter-status {
width: 8px; height: 8px; border-radius: 50%;
flex-shrink: 0;
font-size: 10.5px;
color: var(--text-3);
background: var(--text-4);
}
.cal-event-name {
flex: 1;
.epg-gutter-status.live {
background: var(--live);
box-shadow: 0 0 0 3px rgba(255, 59, 48, 0.18);
}
.epg-gutter-status.err { background: var(--danger); }
.epg-gutter-status.idle { background: var(--text-4); }
.epg-gutter-meta { display: flex; flex-direction: column; min-width: 0; }
.epg-gutter-name {
font-size: 12.5px; font-weight: 600;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
color: var(--text-1);
}
.epg-gutter-sub {
display: flex; align-items: center; gap: 6px;
font-size: 10.5px; color: var(--text-3);
letter-spacing: 0.06em; text-transform: uppercase;
}
.epg-gutter-dot {
display: inline-block;
width: 7px; height: 7px; border-radius: 50%;
}
.epg-canvas-head {
position: sticky; top: 0;
grid-row: 1; grid-column: 2;
z-index: 3;
background: var(--bg-1);
border-bottom: 1px solid var(--border);
}
.epg-canvas {
grid-row: 2; grid-column: 2;
position: relative;
background:
/* hour-band rhythm — alternating subtle stripe every other hour */
repeating-linear-gradient(
to right,
transparent 0,
transparent var(--epg-pph),
rgba(255,255,255,0.012) var(--epg-pph),
rgba(255,255,255,0.012) calc(var(--epg-pph) * 2)
),
/* hour separator lines every hour */
repeating-linear-gradient(
to right,
var(--border) 0,
var(--border) 1px,
transparent 1px,
transparent var(--epg-pph)
);
}
/* Hour ruler */
.epg-ruler {
position: relative;
height: var(--epg-ruler-h);
}
.epg-ruler-tick {
position: absolute; top: 0; bottom: 0;
padding-left: 8px;
display: flex; align-items: center;
font-size: 10.5px; font-weight: 600;
letter-spacing: 0.04em;
color: var(--text-3);
border-left: 1px solid var(--border);
}
.epg-ruler-tick.end { border-left: 0; }
/* Recorder rows + event blocks */
.epg-rows { display: flex; flex-direction: column; position: relative; }
.epg-row {
position: relative;
height: var(--epg-row-h);
border-bottom: 1px solid var(--border);
cursor: copy;
}
.epg-row:last-child { border-bottom: 0; }
.epg-block {
position: absolute;
top: 8px;
height: calc(var(--epg-row-h) - 16px);
display: flex; align-items: center; gap: 8px;
padding: 0 10px 0 14px;
background: var(--bg-2);
border: 1px solid var(--border-strong);
border-radius: 5px;
font-size: 11.5px;
text-align: left;
color: var(--text-1);
cursor: pointer;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
transition: background 80ms, border-color 80ms, transform 80ms;
}
.cal-event-overflow {
font-size: 10.5px;
.epg-block:hover {
background: var(--bg-3);
border-color: var(--border-stronger);
transform: translateY(-1px);
z-index: 1;
}
.epg-block-bar {
position: absolute;
left: 0; top: 0; bottom: 0;
width: 4px;
background: var(--epg-block-color, var(--accent));
}
.epg-block-name {
flex: 1; min-width: 0;
font-weight: 600;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.epg-block-time {
font-size: 10px; color: var(--text-3);
flex-shrink: 0;
}
.epg-block-glyph {
display: inline-flex; align-items: center; justify-content: center;
flex-shrink: 0;
width: 14px; height: 14px;
border-radius: 3px;
font-size: 9px; font-weight: 700;
font-family: var(--font-mono);
}
.epg-block-glyph.live { color: var(--live); background: rgba(255, 59, 48, 0.16); }
.epg-block-glyph.failed { color: var(--danger); background: var(--danger-soft); }
.epg-block.live {
background: linear-gradient(180deg, rgba(255,59,48,0.16) 0%, rgba(255,59,48,0.08) 100%);
border-color: var(--live);
box-shadow: 0 0 0 1px rgba(255, 59, 48, 0.25);
}
.epg-block.failed {
background: var(--danger-soft);
border-color: var(--danger);
}
.epg-block.failed .epg-block-bar { background: var(--danger); }
.epg-block.past { opacity: 0.55; }
.epg-block.past:hover { opacity: 0.85; }
/* Now-line: vertical hot-red line that ticks across the timeline */
.epg-now {
position: absolute;
top: 0; bottom: 0;
width: 1px;
background: var(--live);
pointer-events: none;
z-index: 2;
box-shadow: 0 0 6px rgba(255, 59, 48, 0.45);
}
.epg-now-pip {
position: absolute;
top: -3px; left: -4px;
width: 9px; height: 9px;
border-radius: 50%;
background: var(--live);
box-shadow: 0 0 6px rgba(255, 59, 48, 0.7);
}
/* ---- Week view: 7 day-sections stacked vertically --------------------- */
.epg-week {
flex: 1; min-height: 0;
overflow: auto;
background: var(--bg-0);
padding: 0 0 24px;
}
.epg-week-day {
border-bottom: 1px solid var(--border);
}
.epg-week-day:last-child { border-bottom: 0; }
.epg-week-day.today { background: linear-gradient(180deg, rgba(91,124,250,0.04) 0%, transparent 80%); }
.epg-week-dayhead {
display: flex; align-items: baseline; gap: 10px;
padding: 10px 20px 6px;
font-size: 12px; font-weight: 600;
color: var(--text-2);
position: sticky; left: 0;
z-index: 1;
}
.epg-week-dayname { letter-spacing: 0.02em; }
.epg-week-todaypip {
font-size: 9.5px; font-weight: 700;
text-transform: uppercase; letter-spacing: 0.08em;
color: var(--accent-text);
background: var(--accent-soft);
padding: 1px 6px; border-radius: 99px;
}
.epg-week-row-wrap {
position: relative;
padding: 0 0 8px 20px;
background:
repeating-linear-gradient(
to right,
transparent 0,
transparent var(--epg-pph),
rgba(255,255,255,0.012) var(--epg-pph),
rgba(255,255,255,0.012) calc(var(--epg-pph) * 2)
);
}
/* ---- List view (panel reuses .schedule-row from the row-tables block) - */
.epg-list { padding: 20px; flex: 1; min-height: 0; overflow: auto; }
/* ---- Empty states ----------------------------------------------------- */
.epg-empty {
flex: 1;
display: flex; flex-direction: column;
align-items: center; justify-content: center;
gap: 10px; padding: 80px 20px;
color: var(--text-3);
padding: 1px 6px;
}
.epg-empty-title {
font-size: 15px; font-weight: 600;
color: var(--text-2);
}
.epg-empty-sub {
font-size: 12.5px;
}
/* ========== Cluster ========== */