diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..95aad4d --- /dev/null +++ b/DESIGN.md @@ -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.06–0.08em letterspacing | Column heads, section labels | +| 11–11.5px | Metadata, secondary rows | +| 12–12.5px | Body in lists/tables | +| 13px, 500–600 | Row labels, button text | +| 14px | Default body | +| 15px, 600 | Modal titles, panel heads | +| 22–28px, 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: 80–120ms 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 — 9–10px 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 (~6–8px) 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. diff --git a/PRODUCT.md b/PRODUCT.md new file mode 100644 index 0000000..db02124 --- /dev/null +++ b/PRODUCT.md @@ -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. diff --git a/services/web-ui/public/styles-rest.css b/services/web-ui/public/styles-rest.css index 8c056cb..dbf9e32 100644 --- a/services/web-ui/public/styles-rest.css +++ b/services/web-ui/public/styles-rest.css @@ -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 ========== */