diff --git a/server/src/ledgrab/api/routes/system.py b/server/src/ledgrab/api/routes/system.py index fffcaeb..190ceac 100644 --- a/server/src/ledgrab/api/routes/system.py +++ b/server/src/ledgrab/api/routes/system.py @@ -316,6 +316,15 @@ def get_system_performance(_: AuthRequired): except Exception as e: logger.debug("NVML query failed: %s", e) + # Windows has no user-space CPU die temperature source without a kernel + # driver. We rely on LibreHardwareMonitor / OpenHardwareMonitor publishing + # WMI sensors when the user runs them. When no reading arrives, surface + # that explicitly so the dashboard can show a "here's how to enable it" + # hint instead of silently hiding the card. + cpu_temp_hint_key: str | None = None + if thermals.cpu_temp_c is None and platform.system() == "Windows": + cpu_temp_hint_key = "dashboard.perf.temp.install_lhm" + return PerformanceResponse( cpu_name=_cpu_name, cpu_percent=metrics.cpu_percent(), @@ -328,6 +337,7 @@ def get_system_performance(_: AuthRequired): battery_percent=thermals.battery_percent, battery_temp_c=thermals.battery_temp_c, cpu_temp_c=thermals.cpu_temp_c, + cpu_temp_hint_key=cpu_temp_hint_key, timestamp=datetime.now(timezone.utc), ) diff --git a/server/src/ledgrab/api/schemas/system.py b/server/src/ledgrab/api/schemas/system.py index 703fe32..23502dc 100644 --- a/server/src/ledgrab/api/schemas/system.py +++ b/server/src/ledgrab/api/schemas/system.py @@ -98,6 +98,15 @@ class PerformanceResponse(BaseModel): default=None, description="Hottest CPU/SoC thermal zone in °C (null if unsupported)", ) + cpu_temp_hint_key: str | None = Field( + default=None, + description=( + "i18n key for an explainer shown in the Temperature card when " + "cpu_temp_c is null and the platform has a known workaround " + "(e.g. install LibreHardwareMonitor on Windows). Null on " + "platforms where unavailable simply means 'not reported'." + ), + ) timestamp: datetime = Field(description="Measurement timestamp") diff --git a/server/src/ledgrab/static/css/base.css b/server/src/ledgrab/static/css/base.css index 7f60eb7..f3f91a5 100644 --- a/server/src/ledgrab/static/css/base.css +++ b/server/src/ledgrab/static/css/base.css @@ -99,9 +99,9 @@ /* Dark theme (default) */ [data-theme="dark"] { - --bg-color: #1a1a1a; - --bg-secondary: #242424; - --card-bg: #2d2d2d; + --bg-color: #000000; + --bg-secondary: #0a0b0d; + --card-bg: #101216; --text-color: #e0e0e0; --text-primary: #e0e0e0; --text-secondary: #999; @@ -115,9 +115,9 @@ --input-bg: #1a1a2e; color-scheme: dark; - /* ── Lumenworks dark palette ── */ - --lux-bg-0: #0a0b0d; - --lux-bg-1: #101216; + /* ── Lumenworks dark palette — page is pure black, cards elevate ── */ + --lux-bg-0: #000000; + --lux-bg-1: #0e1014; --lux-bg-2: #15181d; --lux-bg-3: #1c2027; --lux-line: #232831; @@ -127,9 +127,13 @@ --lux-ink-mute: #5b6473; --lux-ink-faint:#3a414c; - /* Channel palette — consistent across tabs for entity types */ - --ch-signal: #00ff7a; /* capture / targets — primary */ - --ch-signal-dim: #00b85a; + /* Channel palette — consistent across tabs for entity types. + --ch-signal tracks --primary-color so the accent color picker + propagates through the brand mark, running stripes, transport + chip, active tabs, etc. Other channels are fixed hues used for + non-primary entity types. */ + --ch-signal: var(--primary-color); + --ch-signal-dim: var(--primary-text-color, var(--primary-color)); --ch-cyan: #00d8ff; /* data / sources / screen */ --ch-magenta: #ff4ade; /* audio / FFT */ --ch-amber: #ffb800; /* autostart / pending */ @@ -142,9 +146,9 @@ /* Light theme */ [data-theme="light"] { - --bg-color: #f5f5f5; - --bg-secondary: #eee; - --card-bg: #ffffff; + --bg-color: #ffffff; + --bg-secondary: #fafbfc; + --card-bg: #f5f6f8; --text-color: #333333; --text-primary: #333333; --text-secondary: #595959; @@ -163,13 +167,13 @@ --primary-text: #2e7d32; color-scheme: light; - /* ── Lumenworks light palette — tuned for WCAG AA on white. - Channel colors darkened vs dark theme so they read against - near-white surfaces. ── */ - --lux-bg-0: #f5f6f8; - --lux-bg-1: #ffffff; - --lux-bg-2: #fafbfc; - --lux-bg-3: #eef1f5; + /* ── Lumenworks light palette — page is pure white, cards slightly + off-white so the stripe + hairline border still read against + the page. WCAG AA tuned. ── */ + --lux-bg-0: #ffffff; + --lux-bg-1: #f6f8fb; + --lux-bg-2: #eef1f5; + --lux-bg-3: #e4e8ee; --lux-line: #dee3ea; --lux-line-bold:#c4ccd6; --lux-ink: #0f1419; @@ -177,8 +181,9 @@ --lux-ink-mute: #6b7684; --lux-ink-faint:#a5afbc; - --ch-signal: #008f3f; - --ch-signal-dim: #006b2f; + /* --ch-signal tracks --primary-color so the accent picker propagates. */ + --ch-signal: var(--primary-color); + --ch-signal-dim: var(--primary-text-color, var(--primary-color)); --ch-cyan: #006b88; --ch-magenta: #b01a99; --ch-amber: #a56a00; diff --git a/server/src/ledgrab/static/css/cards.css b/server/src/ledgrab/static/css/cards.css index 15f6282..4322bde 100644 --- a/server/src/ledgrab/static/css/cards.css +++ b/server/src/ledgrab/static/css/cards.css @@ -19,6 +19,7 @@ section { min-height: 140px; position: relative; overflow: hidden; + /* keep solid — same flat black/white language as real cards */ } /* Small corner bracket + left hairline so the skeleton reads as a module @@ -135,9 +136,7 @@ section { .card { --ch: var(--ch-signal, var(--primary-color)); /* channel accent (override per type) */ - background: linear-gradient(180deg, - var(--lux-bg-1, var(--card-bg)) 0%, - var(--lux-bg-2, var(--card-bg)) 100%); + background: var(--lux-bg-1, var(--card-bg)); border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color)); border-radius: var(--lux-r-md, var(--radius-md)); padding: 18px 20px 16px; @@ -203,7 +202,6 @@ section { .card[data-scene-id], .card.ch-violet { --ch: var(--ch-violet, #8b7eff); } -.card[data-card-type="integration"], .card.ch-amber { --ch: var(--ch-amber, var(--warning-color)); } .card[data-card-type="offline"], diff --git a/server/src/ledgrab/static/css/dashboard.css b/server/src/ledgrab/static/css/dashboard.css index 4bb156d..82ee954 100644 --- a/server/src/ledgrab/static/css/dashboard.css +++ b/server/src/ledgrab/static/css/dashboard.css @@ -6,18 +6,18 @@ .dashboard-section-header { font-family: var(--font-mono, monospace); - font-size: 0.68rem; - font-weight: 600; - margin-bottom: 10px; + font-size: 0.82rem; + font-weight: 700; + margin-bottom: 16px; color: var(--lux-ink-dim, var(--text-secondary)); display: flex; align-items: center; - gap: 8px; + gap: 12px; text-transform: uppercase; - letter-spacing: 0.22em; + letter-spacing: 0.25em; user-select: none; - padding-bottom: 6px; - border-bottom: var(--lux-hairline, 1px) dashed var(--lux-line, var(--border-color)); + padding-bottom: 10px; + border-bottom: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color)); position: relative; } @@ -48,12 +48,19 @@ } .dashboard-section-count { - background: var(--border-color); - color: var(--text-secondary); - border-radius: 10px; - padding: 0 6px; - font-size: 0.75rem; - font-weight: 600; + background: var(--lux-bg-3, var(--border-color)); + color: var(--lux-ink-dim, var(--text-secondary)); + font-family: var(--font-mono, monospace); + border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color)); + border-radius: 2px; + padding: 2px 7px; + font-size: 0.62rem; + font-weight: 700; + letter-spacing: 0.08em; + font-variant-numeric: tabular-nums; + line-height: 1.3; + min-width: 22px; + text-align: center; } @@ -64,8 +71,8 @@ .dashboard-subsection .dashboard-section-content { display: grid; - grid-template-columns: repeat(auto-fill, minmax(min(500px, 100%), 1fr)); - gap: 4px; + grid-template-columns: repeat(auto-fill, minmax(min(380px, 100%), 1fr)); + gap: 14px; } .dashboard-subsection .dashboard-section-content .dashboard-target { @@ -113,15 +120,25 @@ align-items: center; gap: 12px; padding: 10px 14px 10px 18px; - background: linear-gradient(180deg, - var(--lux-bg-1, var(--card-bg)) 0%, - var(--lux-bg-2, var(--card-bg)) 100%); + background: var(--lux-bg-1, var(--card-bg)); border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color)); border-radius: var(--lux-r-md, 6px); margin-bottom: 4px; transition: box-shadow 0.2s ease, transform 0.2s ease, border-color 0.2s ease; } +/* Full module layout — activated when the row contains the rich mod-head + markup emitted by renderDashboardTarget for LED/HA targets. Integration + and sync-clock "autostart" rows keep the compact grid layout above. */ +.dashboard-target:has(.mod-head) { + display: flex; + flex-direction: column; + gap: 14px; + padding: 16px 18px 14px 22px; + margin-bottom: 0; + align-items: stretch; +} + /* Channel stripe on left edge */ .dashboard-target::before { content: ''; @@ -131,13 +148,27 @@ background: var(--ch); box-shadow: 0 0 8px color-mix(in srgb, var(--ch) 40%, transparent); opacity: 0.6; - transition: opacity 0.2s ease, box-shadow 0.2s ease; + transition: opacity 0.2s ease, box-shadow 0.2s ease, width 0.2s ease; +} + +/* Corner bracket top-right — silkscreened panel cue on idle modules only + (compact autostart/integration rows don't have space for it). Swapped + for a signal-flow line when the module is running (see below). */ +.dashboard-target:has(.mod-head)::after { + content: ''; + position: absolute; + top: 8px; right: 8px; + width: 12px; height: 12px; + border-top: var(--lux-hairline, 1px) solid var(--lux-line-bold, var(--border-color)); + border-right: var(--lux-hairline, 1px) solid var(--lux-line-bold, var(--border-color)); + pointer-events: none; + opacity: 0.7; } .dashboard-target:hover { box-shadow: var(--lux-shadow-rack, 0 2px 8px var(--shadow-color)); border-color: var(--lux-line-bold, var(--border-color)); - transform: translateX(1px); + transform: translateY(-1px); } .dashboard-target:hover::before { @@ -147,11 +178,17 @@ .dashboard-card-link:hover { border-color: color-mix(in srgb, var(--ch) 40%, var(--lux-line, var(--border-color))); - box-shadow: 0 4px 14px var(--shadow-color); + box-shadow: 0 6px 20px var(--shadow-color); +} + +/* Running modules: stripe widens + glows, corner bracket swaps for + signal-flow strip along the bottom. */ +.dashboard-target.is-running { + border-color: color-mix(in srgb, var(--ch) 32%, var(--lux-line, var(--border-color))); + box-shadow: 0 0 0 1px color-mix(in srgb, var(--ch) 18%, transparent), + 0 6px 20px rgba(0, 0, 0, 0.25); } -/* Running rows glow brighter and emit a signal-flow strip along the bottom */ -.dashboard-target[data-target-id]:has([data-fps-text])::before, .dashboard-target.is-running::before { opacity: 1; width: 4px; @@ -160,13 +197,13 @@ 0 0 4px color-mix(in srgb, var(--ch) 90%, transparent); } -.dashboard-target[data-target-id]:has([data-fps-text])::after, -.dashboard-target.is-running::after { - content: ''; - position: absolute; +.dashboard-target:has(.mod-head).is-running::after { + top: auto; right: auto; left: 4px; bottom: 0; width: calc(100% - 4px); - height: 1px; + height: 2px; + border: none; + opacity: 0.7; background: linear-gradient(90deg, transparent 0%, color-mix(in srgb, var(--ch) 85%, transparent) 50%, @@ -174,8 +211,289 @@ background-size: 30% 100%; background-repeat: no-repeat; animation: signalFlow 2.4s linear infinite; +} + +/* Dashboard-target channel mapping — targets default to signal green + (tracks the accent picker), sync clocks to violet. Integrations use + the default stripe so they match the overall accent; the health-dot + inside the card already carries the connection state. */ +.dashboard-target[data-target-id] { --ch: var(--ch-signal, var(--primary-color)); } +.dashboard-target[data-sync-clock-id] { --ch: var(--ch-violet, #8b7eff); } + +/* ── Module head: ID badge + name + meta · LED cluster ── */ +.mod-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.mod-id { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; + flex: 1; +} + +.mod-badge { + display: inline-flex; + align-items: center; + gap: 6px; + align-self: flex-start; + font-family: var(--font-mono, monospace); + font-size: 0.55rem; + font-weight: 600; + letter-spacing: 0.2em; + text-transform: uppercase; + color: var(--ch); + padding: 2px 6px; + border: var(--lux-hairline, 1px) solid color-mix(in srgb, var(--ch) 35%, var(--lux-line, var(--border-color))); + border-radius: 3px; + background: color-mix(in srgb, var(--ch) 8%, transparent); + line-height: 1.4; + white-space: nowrap; +} + +.mod-name { + font-family: var(--font-body, inherit); + font-size: 1.05rem; + font-weight: 700; + letter-spacing: -0.01em; + color: var(--lux-ink, var(--text-color)); + display: flex; + align-items: center; + gap: 6px; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.mod-name > span:first-child { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; +} + +.mod-meta { + font-family: var(--font-mono, monospace); + font-size: 0.66rem; + letter-spacing: 0.06em; + color: var(--lux-ink-mute, var(--text-secondary)); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.mod-leds { + display: flex; + align-items: center; + gap: 4px; + padding: 5px 7px; + background: var(--lux-bg-0, var(--bg-color)); + border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color)); + border-radius: var(--lux-r-sm, 3px); + flex-shrink: 0; +} + +.mod-leds .led { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--lux-ink-faint, var(--text-muted)); + box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.5); + transition: background 0.2s, box-shadow 0.2s; +} +.mod-leds .led.on { + background: var(--ch); + box-shadow: 0 0 6px color-mix(in srgb, var(--ch) 80%, transparent); +} +.mod-leds .led.blink { animation: ledBlink 1.2s ease-in-out infinite; } +.mod-leds .led.blink:nth-child(2) { animation-delay: 0.2s; } +.mod-leds .led.blink:nth-child(3) { animation-delay: 0.4s; } + +@keyframes ledBlink { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.28; } +} + +/* ── Metric grid: FPS · Uptime · Errors ── */ +.mod-metrics { + display: grid; + grid-template-columns: 1.2fr 1fr 1fr; + gap: 0; + background: var(--lux-bg-0, var(--bg-color)); + border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color)); + border-radius: var(--lux-r-sm, 3px); + overflow: hidden; +} + +.mod-metric { + padding: 9px 12px 10px; + border-right: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color)); + display: flex; + flex-direction: column; + gap: 3px; + min-width: 0; + position: relative; +} +.mod-metric:last-child { border-right: none; } + +.mod-metric .k { + font-family: var(--font-mono, monospace); + font-size: 0.55rem; + font-weight: 600; + letter-spacing: 0.18em; + text-transform: uppercase; + color: var(--lux-ink-mute, var(--text-secondary)); +} + +.mod-metric .v { + font-family: var(--font-display, 'Big Shoulders Display', 'Orbitron', sans-serif); + font-size: 2rem; + font-weight: 800; + line-height: 1; + color: var(--lux-ink, var(--text-color)); + font-variant-numeric: tabular-nums; + letter-spacing: -0.01em; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.mod-metric .v.signal { + color: var(--ch); +} + +.mod-metric .v small { + font-family: var(--font-mono, monospace); + font-size: 0.65rem; + font-weight: 500; + color: var(--lux-ink-mute, var(--text-secondary)); + letter-spacing: 0.08em; + margin-left: 3px; +} + +.mod-metric .v .dashboard-fps-target { + font-family: var(--font-mono, monospace); + font-size: 0.6rem; + color: var(--lux-ink-mute, var(--text-secondary)); + opacity: 0.6; + margin-left: 3px; +} +.mod-metric .v .dashboard-fps-avg { + display: block; + font-family: var(--font-mono, monospace); + font-size: 0.55rem; + font-weight: 500; + color: var(--lux-ink-mute, var(--text-secondary)); + letter-spacing: 0.06em; opacity: 0.7; - pointer-events: none; + margin-top: 2px; + line-height: 1; +} + +.mod-metric-spark { + width: 100%; + height: 20px; + margin-top: 3px; + display: block; +} + +.mod-metric-spark-canvas { + width: 100% !important; + height: 20px !important; + display: block; +} + +/* ── Module foot: patch indicator + action buttons ── */ +.mod-foot { + display: flex; + align-items: center; + gap: 8px; + padding-top: 2px; +} + +.mod-patch { + display: flex; + align-items: center; + gap: 6px; + font-family: var(--font-mono, monospace); + font-size: 0.6rem; + font-weight: 600; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--lux-ink-mute, var(--text-secondary)); + margin-right: auto; + min-width: 0; +} + +.mod-patch .patch-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--lux-bg-0, var(--bg-color)); + border: var(--lux-hairline, 1px) solid color-mix(in srgb, var(--ch) 55%, var(--lux-line-bold, var(--border-color))); + flex-shrink: 0; + position: relative; +} +.mod-patch .patch-dot.is-live::after { + content: ''; + position: absolute; + inset: 1px; + border-radius: 50%; + background: var(--ch); + box-shadow: 0 0 6px var(--ch); + animation: pulse 2s ease-in-out infinite; +} + +.mod-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + font-family: var(--font-mono, monospace); + font-size: 0.7rem; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + padding: 7px 14px; + min-width: 0; + flex: 0 0 auto; + border: var(--lux-hairline, 1px) solid var(--lux-line-bold, var(--border-color)); + border-radius: var(--lux-r-sm, 3px); + background: var(--lux-bg-2, var(--card-bg)); + color: var(--lux-ink-dim, var(--text-secondary)); + cursor: pointer; + transition: all 0.15s ease; +} +.mod-btn:hover { + color: var(--lux-ink, var(--text-color)); + border-color: color-mix(in srgb, var(--ch) 40%, var(--lux-line-bold, var(--border-color))); + background: var(--lux-bg-3, var(--border-color)); +} +.mod-btn.mod-btn-go { + background: var(--ch); + color: var(--lux-bg-0, var(--primary-contrast)); + border-color: var(--ch); + box-shadow: 0 0 14px color-mix(in srgb, var(--ch) 35%, transparent); +} +.mod-btn.mod-btn-go:hover { + filter: brightness(1.1); + color: var(--lux-bg-0, var(--primary-contrast)); +} +.mod-btn.mod-btn-stop { + color: var(--ch-coral, var(--danger-color)); + border-color: color-mix(in srgb, var(--ch-coral, var(--danger-color)) 40%, transparent); +} +.mod-btn.mod-btn-stop:hover { + background: color-mix(in srgb, var(--ch-coral, var(--danger-color)) 12%, transparent); + color: var(--ch-coral, var(--danger-color)); +} +.mod-btn .icon { + width: 12px; + height: 12px; } .dashboard-target-info { @@ -401,36 +719,36 @@ flex-shrink: 0; } -.dashboard-automation { - max-width: 500px; -} - +/* Automation cards use the autostart grid now; no special width cap. */ .dashboard-automation .dashboard-target-metrics { min-width: 48px; } -/* ── Integrations grid ── */ -.dashboard-integrations-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); - gap: 4px; -} - +/* ── Integrations / autostart grid — tuned for the new module cards ── */ +.dashboard-integrations-grid, .dashboard-autostart-grid { display: grid; - grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); - gap: 4px; + grid-template-columns: repeat(auto-fill, minmax(min(320px, 100%), 1fr)); + gap: 12px; } -.dashboard-autostart { +/* Legacy row-style overrides kept for any card that still lacks .mod-head */ +.dashboard-autostart:not(:has(.mod-head)) { grid-template-columns: 1fr auto; margin-bottom: 0; } -.dashboard-autostart .dashboard-target-info > div { +.dashboard-autostart:not(:has(.mod-head)) .dashboard-target-info > div { min-width: 0; } +/* Compact autostart module — has .mod-head but no metric grid */ +.dashboard-autostart:has(.mod-head) { + gap: 10px; + padding-top: 14px; + padding-bottom: 12px; +} + @media (max-width: 768px) { .dashboard-target { grid-template-columns: 1fr auto; @@ -444,13 +762,33 @@ /* ===== PERFORMANCE CHARTS ===== */ +/* ── Perf strip — equal-width cells sharing one rack-module shell, + divided by hairlines rather than separate cards. Mirrors the + mockup hero. Max 4 columns even on widescreen so each cell + stays substantial; wraps to 2 rows when the full 7 cells are + present. ── */ .perf-charts-grid { display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 12px; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 0; + background: var(--lux-bg-1, var(--card-bg)); + border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color)); + border-radius: var(--lux-r-md, 6px); + overflow: hidden; + position: relative; } -@media (max-width: 900px) { +@media (max-width: 1100px) { + .perf-charts-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} +@media (max-width: 760px) { + .perf-charts-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} +@media (max-width: 480px) { .perf-charts-grid { grid-template-columns: 1fr; } @@ -460,43 +798,52 @@ .perf-chart-card { --perf-accent: var(--ch-signal, var(--primary-color)); --perf-accent-glow: color-mix(in srgb, var(--perf-accent) 18%, transparent); - background: linear-gradient(180deg, - var(--lux-bg-1, var(--card-bg)) 0%, - var(--lux-bg-2, var(--card-bg)) 100%); - border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color)); - border-radius: var(--lux-r-md, 6px); - padding: 12px 0 0; + background: transparent; + border: none; + border-radius: 0; + padding: 0; min-width: 0; position: relative; overflow: hidden; - transition: box-shadow var(--duration-normal) ease, border-color var(--duration-normal) ease; + display: flex; + flex-direction: column; + transition: background var(--duration-normal) ease; } -/* Channel stripe on left edge (replaces the old border-top accent) */ +/* Hairline divider between cells (except the first) */ +.perf-chart-card + .perf-chart-card { + border-left: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color)); +} + +/* Top-edge channel accent per cell — marks the metric's identity without + needing an explicit left stripe that'd clash with the divider. */ .perf-chart-card::before { content: ''; position: absolute; - left: 0; top: 0; bottom: 0; - width: 3px; + left: 0; right: 0; top: 0; + height: 2px; background: var(--perf-accent); - box-shadow: 0 0 10px var(--perf-accent-glow); + box-shadow: 0 0 10px color-mix(in srgb, var(--perf-accent) 50%, transparent); + opacity: 0.85; + z-index: 2; } -/* Corner bracket in the top-right */ +/* Corner bracket — keep in top-right but smaller and subtler now that it's + on a shared surface. Hidden when the card is in hint mode (see below) + or has an APP tag occupying that slot. */ .perf-chart-card::after { content: ''; position: absolute; - top: 8px; right: 8px; - width: 12px; height: 12px; + top: 10px; right: 10px; + width: 10px; height: 10px; border-top: var(--lux-hairline, 1px) solid var(--lux-line-bold, var(--border-color)); border-right: var(--lux-hairline, 1px) solid var(--lux-line-bold, var(--border-color)); - opacity: 0.7; + opacity: 0.5; pointer-events: none; } .perf-chart-card:hover { - box-shadow: 0 4px 20px var(--perf-accent-glow), var(--lux-shadow-rack, 0 0 0 transparent); - border-color: var(--lux-line-bold, var(--border-color)); + background: color-mix(in srgb, var(--perf-accent) 4%, transparent); } .perf-chart-card[data-metric="cpu"] { @@ -515,94 +862,30 @@ --perf-accent: var(--ch-amber, #FFB800); } -.perf-chart-wrap { - position: relative; - height: 100px; - overflow: hidden; -} - -/* Subtle gradient wash in chart background */ -.perf-chart-wrap::before { - content: ''; - position: absolute; - inset: 0; - background: linear-gradient(180deg, var(--perf-accent-glow), transparent 60%); - pointer-events: none; - z-index: 0; -} - +/* ── Header (mono label + color picker) at the top of each cell ── */ .perf-chart-header { display: flex; justify-content: space-between; align-items: center; - margin-bottom: 0; - padding: 0 12px; + padding: 14px 18px 6px; + position: relative; + z-index: 2; } .perf-chart-label { font-family: var(--font-mono, monospace); - font-size: 0.62rem; + font-size: 0.58rem; font-weight: 600; text-transform: uppercase; - letter-spacing: 0.22em; - color: var(--perf-accent); + letter-spacing: 0.25em; + color: var(--lux-ink-mute, var(--text-secondary)); display: flex; align-items: center; - gap: 6px; + gap: 8px; } -/* Accent dot before label */ -.perf-chart-label::before { - content: ''; - width: 7px; - height: 7px; - border-radius: 50%; - background: var(--perf-accent); - box-shadow: 0 0 6px var(--perf-accent); - flex-shrink: 0; -} - -.perf-chart-subtitle { - position: absolute; - top: 0; - left: 12px; - font-size: 0.6rem; - font-weight: 400; - color: var(--text-secondary); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - max-width: calc(100% - 4px); - pointer-events: none; - z-index: 1; -} - -/* ── Value display ── */ -.perf-chart-value { - font-size: 0.92rem; - font-weight: 700; - color: var(--perf-accent); - font-family: var(--font-mono, monospace); - display: flex; - align-items: baseline; - gap: 6px; - font-variant-numeric: tabular-nums; - text-shadow: 0 0 12px color-mix(in srgb, var(--perf-accent) 40%, transparent); -} - -/* App value shown as subdued tag in "both" mode */ -.perf-chart-value .perf-val-app { - font-size: 0.65rem; - font-weight: 500; - color: var(--text-secondary); - background: var(--hover-bg); - padding: 2px 5px; - border-radius: 3px; - letter-spacing: 0.2px; - vertical-align: middle; - display: inline-flex; - align-items: center; -} +/* No pre-dot now — the top accent bar and the value color already carry + the channel identity. Keeps the label tidy. */ .perf-chart-label .color-picker-swatch { width: 12px; @@ -610,6 +893,364 @@ vertical-align: middle; } +/* ── Body: value block on top, sparkline inline below ── */ +.perf-chart-body { + position: relative; + flex: 1; + display: flex; + flex-direction: column; + justify-content: flex-start; +} + +.perf-chart-value-block { + position: relative; + z-index: 2; + padding: 2px 18px 4px; + display: flex; + flex-direction: column; + gap: 4px; + line-height: 1; +} + +/* Big numeric readout in the display font — instrument-style. + Solid fill, no glow — glow is reserved for the sparkline stroke which + already renders a soft bloom for the "lit panel" feel. + Font size uses clamp() so the widest cells (RAM "18.9/31.8 GB", + GPU "50% · 37°C") shrink gracefully in narrow layouts while still + reading as the hero element of each cell. */ +.perf-chart-value { + font-family: var(--font-display, 'Big Shoulders Display', sans-serif); + font-size: clamp(1.8rem, 2.8vw, 2.8rem); + font-weight: 800; + color: var(--lux-ink, var(--text-color)); + line-height: 0.95; + letter-spacing: -0.02em; + font-variant-numeric: tabular-nums; + display: flex; + align-items: baseline; + gap: 8px; + flex-wrap: nowrap; + white-space: nowrap; + overflow: hidden; + text-overflow: clip; +} + +/* APP load tag pinned to the true top-right corner of the perf card, + overlapping where the idle corner bracket would be. Only visible in + 'both' mode (hidden by JS when a metric has no app variant or mode + is single). */ +.perf-chart-app { + position: absolute; + top: 10px; + right: 12px; + z-index: 3; + display: inline-flex; + align-items: center; + gap: 6px; + font-family: var(--font-mono, monospace); + font-size: 0.82rem; + font-weight: 700; + color: var(--perf-accent); + background: color-mix(in srgb, var(--perf-accent) 12%, transparent); + border: var(--lux-hairline, 1px) solid color-mix(in srgb, var(--perf-accent) 40%, transparent); + padding: 3px 8px; + border-radius: 3px; + letter-spacing: 0.04em; + font-variant-numeric: tabular-nums; + white-space: nowrap; + line-height: 1.1; +} + +/* Hide the idle corner bracket on perf cards — the APP tag now + owns that slot in 'both' mode. */ +.perf-chart-card::after { + display: none; +} + +.perf-chart-app .perf-chart-app-k { + font-size: 0.55rem; + font-weight: 600; + letter-spacing: 0.2em; + text-transform: uppercase; + color: color-mix(in srgb, var(--perf-accent) 80%, var(--lux-ink-mute, #888)); + opacity: 0.85; +} + +/* "Install LibreHardwareMonitor" explainer shown in the Temperature card + on Windows when no CPU die sensor is reachable. Replaces the big number + with wrapped, readable secondary text so users understand why the card + is empty and how to enable it. */ +.perf-chart-card-hint .perf-chart-value { + font-family: var(--font-body, system-ui, sans-serif); + font-size: 0.78rem; + font-weight: 500; + line-height: 1.35; + letter-spacing: 0; + color: var(--lux-ink-mute, var(--text-secondary)); + white-space: normal; + flex-wrap: nowrap; + text-transform: none; +} + +.perf-chart-hint { + display: inline-block; + max-width: 100%; + opacity: 0.9; +} + +/* CPU / GPU model name — silkscreened micro-type under the big value */ +.perf-chart-subtitle { + font-family: var(--font-mono, monospace); + font-size: 0.55rem; + letter-spacing: 0.08em; + color: var(--lux-ink-mute, var(--text-secondary)); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 100%; + margin-top: 2px; +} + +/* Sparkline sits inline below the value block, flush with the cell + bottom. `margin-top: auto` pushes it to the bottom so the spark + baseline aligns across cells regardless of subtitle presence — + cells with CPU/GPU model names, FPS min/max etc. no longer have a + higher spark than cells without a subtitle. */ +.perf-chart-spark { + position: relative; + margin-top: auto; + height: 42px; + padding: 0 18px 14px; + pointer-events: none; + filter: drop-shadow(0 0 5px color-mix(in srgb, var(--perf-accent) 45%, transparent)); +} + +.perf-chart-spark .perf-chart-svg { + width: 100%; + height: 100%; + display: block; +} + +.perf-chart-unavailable { + text-align: center; + padding: 20px 0; + color: var(--lux-ink-mute, var(--text-secondary)); + font-family: var(--font-mono, monospace); + font-size: 0.7rem; + letter-spacing: 0.15em; + text-transform: uppercase; +} + +/* ── Active Patches cell — first in the perf strip. Shows channel count + and a short list of running targets with their live FPS. ── */ +.perf-patches-cell { + --perf-accent: var(--ch-signal, var(--primary-color)); + position: relative; + isolation: isolate; + /* Soft radial glow anchored to the bottom-right corner of the cell + itself — marks this as the "live" channel bank. Percent-based so + it always lands in the corner regardless of cell height/width. */ + background: + radial-gradient( + circle at 95% 105%, + color-mix(in srgb, var(--perf-accent) 32%, transparent) 0%, + color-mix(in srgb, var(--perf-accent) 14%, transparent) 18%, + color-mix(in srgb, var(--perf-accent) 4%, transparent) 38%, + transparent 55%); +} + +.perf-patches-cell .perf-chart-value-block, +.perf-patches-cell .perf-patches-list, +.perf-patches-cell .perf-chart-header { + position: relative; + z-index: 1; +} + +.perf-patches-cell .perf-chart-value { + color: var(--lux-ink, var(--text-color)); +} + +/* Total count is dramatically smaller than the running count so the eye + lands on the big live number first, with "/12" as muted context. */ +.perf-patches-cell .perf-patches-sep, +.perf-patches-cell .perf-patches-total { + color: var(--lux-ink-mute, var(--text-secondary)); + font-family: var(--font-mono, monospace); + font-weight: 500; + font-size: 0.38em; + letter-spacing: 0.04em; + align-self: center; + margin-left: -2px; +} +.perf-patches-cell .perf-patches-sep { + opacity: 0.6; + margin-right: 1px; +} + +.perf-patches-list { + padding: 6px 18px 14px; + display: flex; + flex-direction: column; + gap: 5px; + font-family: var(--font-mono, monospace); + font-size: 0.72rem; + min-height: 42px; +} + +.perf-patches-row { + display: grid; + grid-template-columns: 4px 1fr auto; + gap: 10px; + align-items: center; + line-height: 1.2; + min-width: 0; +} + +.perf-patches-stripe { + width: 4px; + height: 14px; + border-radius: 2px; + background: var(--ch-signal, var(--primary-color)); + box-shadow: 0 0 6px currentColor; + flex-shrink: 0; +} + +.perf-patches-name { + color: var(--lux-ink, var(--text-color)); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; +} + +.perf-patches-fps { + color: var(--ch-signal, var(--primary-color)); + font-variant-numeric: tabular-nums; + white-space: nowrap; + letter-spacing: 0.02em; +} + +.perf-patches-more { + font-size: 0.62rem; + color: var(--lux-ink-mute, var(--text-secondary)); + letter-spacing: 0.12em; + text-transform: uppercase; + padding-top: 2px; +} + +/* Patches cell has no sparkline, so it doesn't need the bottom spark slot + — the list owns the rest of the cell height. */ +.perf-patches-cell .perf-chart-spark { display: none; } + +/* ── Devices cell — online/total count + dot strip per device ── */ +.perf-devices-cell { + --perf-accent: var(--ch-signal, var(--primary-color)); +} + +.perf-devices-cell .perf-chart-value { + color: var(--lux-ink, var(--text-color)); +} + +.perf-devices-cell .perf-chart-subtitle { + font-family: var(--font-mono, monospace); + font-size: 0.58rem; + letter-spacing: 0.16em; + text-transform: uppercase; + color: var(--lux-ink-mute, var(--text-secondary)); +} + +.perf-devices-dots { + padding: 8px 18px 14px; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + min-height: 32px; +} + +.perf-devices-dot { + width: 10px; + height: 10px; + border-radius: 2px; + background: var(--lux-ink-faint, var(--text-muted)); + flex-shrink: 0; + position: relative; + transition: background 0.2s, box-shadow 0.2s; +} + +.perf-devices-dot.is-online { + background: var(--ch-signal, var(--primary-color)); + box-shadow: 0 0 6px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 60%, transparent); +} + +.perf-devices-dot.is-offline { + background: var(--ch-coral, var(--danger-color)); + box-shadow: 0 0 6px color-mix(in srgb, var(--ch-coral, var(--danger-color)) 50%, transparent); +} + +.perf-devices-more { + font-family: var(--font-mono, monospace); + font-size: 0.62rem; + color: var(--lux-ink-mute, var(--text-secondary)); + letter-spacing: 0.08em; + margin-left: 2px; +} + +.perf-devices-cell .perf-chart-spark { display: none; } + +/* ── Total FPS cell — unit suffix styling (fps text after the number) ── */ +.perf-chart-card[data-metric="fps"] .perf-chart-value { + color: var(--perf-accent); +} + +.perf-chart-card[data-metric="fps"] .perf-fps-unit { + font-family: var(--font-mono, monospace); + font-size: 0.3em; + font-weight: 500; + color: var(--lux-ink-mute, var(--text-secondary)); + letter-spacing: 0.2em; + text-transform: uppercase; + margin-left: 6px; + align-self: center; +} + +/* Hint mode — the card is revealed with an explanatory message instead + of a live metric (e.g. Windows without LibreHardwareMonitor for CPU + temp). Neutralizes the big display font + hides the sparkline so the + explainer text reads as plain body copy. */ +.perf-chart-card-hint .perf-chart-value { + font-size: 0; /* kill inherited 3.2rem */ + text-shadow: none; + line-height: normal; + letter-spacing: 0; + display: block; + color: inherit; +} + +.perf-chart-card-hint .perf-chart-spark, +.perf-chart-card-hint .perf-chart-subtitle, +.perf-chart-card-hint .perf-chart-body::after { + display: none; +} + +.perf-chart-card-hint .perf-chart-body { + min-height: 0; + padding-bottom: 14px; +} + +.perf-chart-hint { + font-family: var(--font-body, inherit); + font-size: 0.78rem; + font-weight: 400; + line-height: 1.45; + color: var(--lux-ink-dim, var(--text-secondary)); + letter-spacing: 0; + text-transform: none; + display: block; + white-space: normal; + max-width: 42ch; +} + .perf-chart-unavailable { text-align: center; padding: 20px 0; @@ -622,33 +1263,37 @@ display: inline-flex; gap: 0; margin-left: auto; - border: 1px solid var(--border-color); - border-radius: 4px; + border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color)); + border-radius: var(--lux-r-sm, 3px); overflow: hidden; + background: var(--lux-bg-0, var(--bg-secondary)); } .perf-mode-btn { background: transparent; border: none; - color: var(--text-secondary); - font-size: 0.65rem; + color: var(--lux-ink-mute, var(--text-secondary)); + font-family: var(--font-mono, inherit); + font-size: 0.6rem; font-weight: 600; text-transform: uppercase; - letter-spacing: 0.3px; - padding: 2px 8px; + letter-spacing: 0.15em; + padding: 4px 10px; cursor: pointer; - transition: background 0.15s, color 0.15s; + transition: background 0.15s, color 0.15s, box-shadow 0.15s; } .perf-mode-btn:not(:last-child) { - border-right: 1px solid var(--border-color); + border-right: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color)); } .perf-mode-btn:hover { - background: var(--hover-bg); + color: var(--lux-ink, var(--text-color)); + background: var(--lux-bg-2, var(--hover-bg)); } .perf-mode-btn.active { - background: var(--primary-color); - color: #fff; + background: var(--ch-signal, var(--primary-color)); + color: var(--lux-bg-0, #fff); + box-shadow: inset 0 0 8px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 60%, transparent); } diff --git a/server/src/ledgrab/static/css/layout.css b/server/src/ledgrab/static/css/layout.css index 67c0f6a..bf8d163 100644 --- a/server/src/ledgrab/static/css/layout.css +++ b/server/src/ledgrab/static/css/layout.css @@ -4,7 +4,7 @@ header { display: grid; - grid-template-columns: var(--sidebar-width, 248px) 1fr auto; + grid-template-columns: var(--sidebar-width, 248px) 1fr auto auto; align-items: center; height: var(--transport-height, 60px); padding: 0 16px 0 0; @@ -49,58 +49,96 @@ header::before { /* Glowing LED brand mark. Rendered as a ::before on .header-title so no HTML change is required. The existing #server-status pulse dot sits inside as the "core" of the mark (see status-badge rule below). */ +/* LED brand mark — 28 px glowing square with inset dark core. + Glow intensity pulses subtly to reinforce the "live instrument" feel. */ .header-title::before { content: ''; - width: 26px; - height: 26px; + width: 28px; + height: 28px; flex-shrink: 0; border-radius: 4px; background: var(--ch-signal, var(--primary-color)); box-shadow: - var(--lux-signal-glow, 0 0 14px color-mix(in srgb, var(--primary-color) 40%, transparent)), - inset 0 0 0 1px rgba(0, 0, 0, 0.3); + 0 0 22px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 55%, transparent), + 0 0 8px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 90%, transparent), + inset 0 0 0 1px rgba(0, 0, 0, 0.35); position: relative; + animation: brandPulse 4s ease-in-out infinite; } .header-title::after { content: ''; position: absolute; - left: calc(18px + 7px); + left: calc(18px + 8px); top: 50%; transform: translateY(-50%); width: 12px; height: 12px; background: var(--lux-bg-0, var(--bg-color)); - border-radius: 1px; + border-radius: 2px; + box-shadow: 0 0 0 2px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 40%, transparent); pointer-events: none; } +@keyframes brandPulse { + 0%, 100% { + box-shadow: + 0 0 22px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 55%, transparent), + 0 0 8px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 90%, transparent), + inset 0 0 0 1px rgba(0, 0, 0, 0.35); + } + 50% { + box-shadow: + 0 0 30px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 70%, transparent), + 0 0 12px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 95%, transparent), + inset 0 0 0 1px rgba(0, 0, 0, 0.35); + } +} + +/* Brand stack — title on one line, version under it, no wrap. */ +.brand-stack { + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: center; + gap: 3px; + line-height: 1; + min-width: 0; +} + h1 { font-family: 'Orbitron', sans-serif; - font-size: 0.95rem; - font-weight: 700; - letter-spacing: 0.2em; + font-size: 1.25rem; + font-weight: 900; + letter-spacing: 0.18em; text-transform: uppercase; - -webkit-text-stroke: 0.5px var(--primary-color); + white-space: nowrap; + -webkit-text-stroke: 0.4px color-mix(in srgb, var(--primary-color) 60%, transparent); paint-order: stroke fill; background: linear-gradient( - 120deg, - var(--primary-color) 0%, - var(--primary-text-color) 35%, - var(--primary-color) 50%, - var(--primary-text-color) 65%, - var(--primary-color) 100% + 90deg, + var(--lux-ink, #e6ebf2) 0%, + var(--ch-signal, var(--primary-color)) 50%, + var(--lux-ink, #e6ebf2) 100% ); - background-size: 250% 100%; + background-size: 220% 100%; -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent; - animation: titleShimmer 6s ease-in-out infinite; + animation: titleShimmer 8s linear infinite; line-height: 1; + margin: 0; + filter: drop-shadow(0 0 12px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 25%, transparent)); +} + +.brand-stack #server-version { + font-size: 0.6rem; + padding: 2px 7px; + letter-spacing: 0.25em; + align-self: flex-start; } @keyframes titleShimmer { - 0%, 100% { background-position: 100% 50%; } - 50% { background-position: 0% 50%; } + to { background-position: -220% 50%; } } /* ── Transport center: reserved area for armed-status / master-stop / @@ -120,31 +158,38 @@ h1 { .transport-status { display: inline-flex; align-items: center; - gap: 8px; - padding: 6px 12px; + gap: 10px; + padding: 9px 18px; background: var(--lux-bg-2, var(--bg-secondary)); border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color)); border-radius: var(--lux-r-sm, 3px); color: var(--lux-ink-dim, var(--text-secondary)); - font-size: 0.68rem; - letter-spacing: 0.08em; + font-family: var(--font-mono, inherit); + font-size: 0.8rem; + font-weight: 600; + letter-spacing: 0.1em; text-transform: uppercase; white-space: nowrap; - transition: color 0.2s, border-color 0.2s, background 0.2s; + transition: color 0.2s, border-color 0.2s, background 0.2s, box-shadow 0.2s; } .transport-status.is-armed { color: var(--ch-signal, var(--primary-color)); - border-color: color-mix(in srgb, var(--ch-signal, var(--primary-color)) 40%, transparent); - background: color-mix(in srgb, var(--ch-signal, var(--primary-color)) 8%, transparent); + border-color: color-mix(in srgb, var(--ch-signal, var(--primary-color)) 45%, transparent); + background: color-mix(in srgb, var(--ch-signal, var(--primary-color)) 10%, transparent); + box-shadow: + inset 0 0 14px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 12%, transparent), + 0 0 18px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 20%, transparent); + text-shadow: 0 0 10px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 40%, transparent); } .transport-status .dot { - width: 6px; height: 6px; + width: 7px; height: 7px; border-radius: 50%; background: currentColor; - box-shadow: 0 0 6px currentColor; + box-shadow: 0 0 8px currentColor, 0 0 3px currentColor; animation: pulse 1.4s ease-in-out infinite; + flex-shrink: 0; } .transport-status:not(.is-armed) .dot { background: var(--lux-ink-faint, var(--text-muted)); @@ -152,6 +197,77 @@ h1 { animation: none; } +/* Transport meta — Uptime / CPU / Mem readouts as vertical KEY/VALUE stacks */ +.transport-meta { + display: flex; + align-items: center; + gap: 16px; + padding: 0 6px 0 16px; + font-family: var(--font-mono, monospace); +} + +.meta-cell { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 3px; + line-height: 1; + min-width: 0; +} + +.meta-cell .k { + font-size: 0.6rem; + font-weight: 600; + letter-spacing: 0.22em; + text-transform: uppercase; + color: var(--lux-ink-faint, var(--text-muted)); +} + +.meta-cell .v { + font-size: 0.9rem; + font-weight: 600; + color: var(--lux-ink, var(--text-color)); + font-variant-numeric: tabular-nums; + letter-spacing: 0.02em; + white-space: nowrap; +} + +/* Interactive meta-cell — clickable variant used by the Poll control. + Lightweight hover + focus states so it reads as actionable without + looking like a button. */ +.meta-cell-interactive { + cursor: pointer; + padding: 4px 8px; + margin: 0 -2px; + border-radius: var(--lux-r-sm, 3px); + border: var(--lux-hairline, 1px) solid transparent; + outline: none; + transition: background 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease; + user-select: none; +} +.meta-cell-interactive:hover { + background: var(--lux-bg-2, var(--bg-secondary)); + border-color: var(--lux-line, var(--border-color)); +} +.meta-cell-interactive:focus-visible { + border-color: color-mix(in srgb, var(--ch-signal, var(--primary-color)) 50%, transparent); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 20%, transparent); +} +.meta-cell-interactive:active { + transform: translateY(0.5px); +} +.meta-cell-interactive .v { + color: var(--ch-signal, var(--primary-color)); + text-shadow: 0 0 10px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 25%, transparent); +} + +.meta-sep { + width: 1px; + height: 24px; + background: var(--lux-line, var(--border-color)); + flex-shrink: 0; +} + h2 { margin-bottom: 20px; color: var(--text-color); @@ -161,18 +277,17 @@ h2 { .header-toolbar { display: flex; align-items: center; - gap: 2px; - background: var(--lux-bg-1, var(--card-bg)); - border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color)); - border-radius: var(--lux-r-sm, 3px); - padding: 3px 4px; + gap: 4px; + background: transparent; + border: none; + padding: 0; } .header-toolbar-sep { width: 1px; - height: 16px; + height: 20px; background: var(--lux-line, var(--border-color)); - margin: 0 3px; + margin: 0 6px; flex-shrink: 0; } @@ -380,35 +495,27 @@ h2 { to { transform: translateY(0); opacity: 1; } } -/* #server-status is overlaid on the brand mark as a small LED pip that - changes color based on connection. Inline element rendered via text node - ('●'); we hide the glyph and use ::before for a clean circular dot so - sizing is consistent across fonts. */ +/* #server-status visual hidden — the brand mark itself carries the + connection state. When JS adds `.offline`, the mark shifts to coral + via the :has() modifier on .header-title below. */ .status-badge { - position: absolute; - left: calc(18px + 13px); - top: 50%; - transform: translate(-50%, -50%); - width: 6px; - height: 6px; - border-radius: 50%; - color: transparent; /* hide the '●' text glyph */ - font-size: 0; - background: var(--lux-bg-0, var(--bg-color)); - box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.4), 0 0 6px var(--ch-signal, var(--primary-color)); - z-index: 2; - animation: pulse 2s infinite; - pointer-events: none; + display: none; } -.status-badge.online { - background: var(--ch-signal, var(--primary-color)); - box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.4), 0 0 8px var(--ch-signal, var(--primary-color)); -} - -.status-badge.offline { +/* Brand mark reflects connection state. Default is the running-color + (tracks --ch-signal / --primary-color). When the server-status element + has `.offline`, override to coral so the header reads "disconnected" + without needing a separate pip. */ +.header-title:has(#server-status.offline)::before { background: var(--ch-coral, var(--danger-color)); - box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.4), 0 0 8px var(--ch-coral, var(--danger-color)); + box-shadow: + 0 0 22px color-mix(in srgb, var(--ch-coral, var(--danger-color)) 55%, transparent), + 0 0 8px color-mix(in srgb, var(--ch-coral, var(--danger-color)) 90%, transparent), + inset 0 0 0 1px rgba(0, 0, 0, 0.35); + animation: none; +} +.header-title:has(#server-status.offline)::after { + box-shadow: 0 0 0 2px color-mix(in srgb, var(--ch-coral, var(--danger-color)) 40%, transparent); } @keyframes pulse { @@ -656,23 +763,35 @@ h2 { } /* Header toolbar buttons */ +/* Header icon buttons — hairline-bordered squares with channel glow + on hover. Mirrors the mockup's `.icon-btn` treatment. */ .header-btn { + width: 30px; + height: 30px; + padding: 0; background: transparent; - border: none; - padding: 4px 6px; - border-radius: 5px; + border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color)); + border-radius: var(--lux-r-sm, 3px); cursor: pointer; font-size: 0.9rem; - color: var(--text-secondary); - transition: color 0.2s, background 0.2s; - display: inline-flex; - align-items: center; + color: var(--lux-ink-dim, var(--text-secondary)); + transition: color 0.2s, background 0.2s, border-color 0.2s, box-shadow 0.2s; + display: inline-grid; + place-items: center; line-height: 1; + flex-shrink: 0; } .header-btn:hover { - color: var(--text-color); - background: var(--bg-secondary); + color: var(--lux-ink, var(--text-color)); + background: var(--lux-bg-2, var(--bg-secondary)); + border-color: var(--lux-line-bold, var(--border-color)); + box-shadow: 0 0 12px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 18%, transparent); +} + +.header-btn .icon { + width: 15px; + height: 15px; } /* Reusable color picker popover */ @@ -814,8 +933,11 @@ h2 { .cp-backdrop { position: absolute; inset: 0; - background: rgba(0, 0, 0, 0.5); - backdrop-filter: blur(2px); + background: radial-gradient(1000px 600px at 50% 30%, + rgba(0, 0, 0, 0.55) 0%, + rgba(0, 0, 0, 0.8) 100%); + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); animation: fadeIn 0.15s ease-out; } @@ -824,10 +946,13 @@ h2 { width: 520px; max-width: 90vw; max-height: 60vh; - background: var(--card-bg); - border: 1px solid var(--border-color); - border-radius: 12px; - box-shadow: 0 16px 48px var(--shadow-color); + background: var(--lux-bg-1, var(--card-bg)); + border: var(--lux-hairline, 1px) solid var(--lux-line-bold, var(--border-color)); + border-radius: var(--lux-r-md, 12px); + box-shadow: + 0 0 0 1px rgba(255, 255, 255, 0.02), + 0 20px 60px rgba(0, 0, 0, 0.5), + 0 8px 32px var(--shadow-color); display: flex; flex-direction: column; overflow: hidden; @@ -835,6 +960,24 @@ h2 { animation: cpSlideDown 0.2s cubic-bezier(0.16, 1, 0.3, 1); } +/* Channel-accent rule across the top edge (matches modals) */ +.cp-dialog::before { + content: ''; + position: absolute; + left: 0; right: 0; top: 0; + height: 2px; + background: linear-gradient(90deg, + transparent 0%, + var(--ch-signal, var(--primary-color)) 20%, + var(--ch-cyan, var(--primary-color)) 50%, + var(--ch-magenta, var(--primary-color)) 80%, + transparent 100%); + opacity: 0.9; + box-shadow: 0 0 12px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 40%, transparent); + pointer-events: none; + z-index: 2; +} + @keyframes cpSlideDown { from { opacity: 0; transform: translateY(-12px) scale(0.98); } to { opacity: 1; transform: translateY(0) scale(1); } @@ -842,18 +985,23 @@ h2 { .cp-input { width: 100%; - padding: 14px 16px; + padding: 16px 18px 14px 18px; border: none; - border-bottom: 1px solid var(--border-color); + border-bottom: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color)); background: transparent; - color: var(--text-color); + color: var(--lux-ink, var(--text-color)); + font-family: var(--font-body, inherit); font-size: 1rem; outline: none; box-sizing: border-box; + letter-spacing: -0.005em; } .cp-input::placeholder { - color: var(--text-secondary); + color: var(--lux-ink-mute, var(--text-secondary)); + font-family: var(--font-mono, inherit); + font-size: 0.9rem; + letter-spacing: 0.04em; } .cp-results { @@ -863,32 +1011,38 @@ h2 { } .cp-group-header { - font-size: 0.7rem; + font-family: var(--font-mono, inherit); + font-size: 0.58rem; font-weight: 700; text-transform: uppercase; - letter-spacing: 0.05em; - color: var(--text-secondary); - padding: 8px 16px 4px; + letter-spacing: 0.22em; + color: var(--lux-ink-mute, var(--text-secondary)); + padding: 10px 18px 4px; } .cp-result { display: flex; align-items: center; - gap: 8px; - padding: 8px 16px; + gap: 10px; + padding: 9px 18px; cursor: pointer; transition: background 0.1s; position: relative; z-index: 1; + color: var(--lux-ink-dim, var(--text-color)); } .cp-result:hover { - background: var(--bg-secondary); + background: var(--lux-bg-3, var(--bg-secondary)); + color: var(--lux-ink, var(--text-color)); } .cp-result.cp-active { - background: var(--primary-color); - color: var(--primary-contrast); + background: linear-gradient(90deg, + color-mix(in srgb, var(--ch-signal, var(--primary-color)) 18%, transparent) 0%, + transparent 100%); + color: var(--lux-ink, var(--text-color)); + box-shadow: inset 2px 0 0 var(--ch-signal, var(--primary-color)); } .cp-result.cp-active .cp-detail { @@ -914,8 +1068,10 @@ h2 { .cp-detail { flex-shrink: 0; - font-size: 0.75rem; - color: var(--text-secondary); + font-family: var(--font-mono, inherit); + font-size: 0.66rem; + letter-spacing: 0.04em; + color: var(--lux-ink-mute, var(--text-secondary)); } .cp-running { @@ -950,11 +1106,14 @@ h2 { } .cp-footer { - padding: 6px 16px; - border-top: 1px solid var(--border-color); - font-size: 0.7rem; - color: var(--text-secondary); + padding: 8px 18px; + border-top: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color)); + font-family: var(--font-mono, inherit); + font-size: 0.62rem; + letter-spacing: 0.08em; + color: var(--lux-ink-mute, var(--text-secondary)); text-align: center; + background: color-mix(in srgb, var(--lux-bg-0, transparent) 40%, transparent); } /* On narrow screens the brand column shrinks to just the mark; on phones diff --git a/server/src/ledgrab/static/css/sidebar.css b/server/src/ledgrab/static/css/sidebar.css index 9ebd1a1..3687088 100644 --- a/server/src/ledgrab/static/css/sidebar.css +++ b/server/src/ledgrab/static/css/sidebar.css @@ -81,7 +81,7 @@ border: none; border-radius: var(--lux-r-sm, 3px); font-family: var(--font-mono, monospace); - font-size: 0.78rem; + font-size: 0.82rem; font-weight: 500; letter-spacing: 0.04em; color: var(--lux-ink-dim, var(--text-secondary)); @@ -139,12 +139,12 @@ background: var(--lux-bg-3, var(--border-color)); color: var(--lux-ink-mute, var(--text-secondary)); font-family: var(--font-mono, monospace); - font-size: 0.6rem; + font-size: 0.62rem; font-weight: 600; - padding: 1px 6px; - border-radius: 8px; - min-width: 18px; - line-height: 1.3; + padding: 1px 7px; + border-radius: 10px; + min-width: 20px; + line-height: 1.4; text-align: center; } .sidebar .tab-btn.active .tab-badge { diff --git a/server/src/ledgrab/static/css/streams.css b/server/src/ledgrab/static/css/streams.css index b9ff178..95c8e4a 100644 --- a/server/src/ledgrab/static/css/streams.css +++ b/server/src/ledgrab/static/css/streams.css @@ -694,14 +694,14 @@ body.pp-filter-dragging .pp-filter-drag-handle { .subtab-section-header { font-family: var(--font-mono, monospace); - font-size: 0.72rem; - font-weight: 600; + font-size: 0.82rem; + font-weight: 700; color: var(--lux-ink-dim, var(--text-secondary)); - margin: 0 0 14px 0; - padding-bottom: 8px; + margin: 0 0 16px 0; + padding-bottom: 10px; text-transform: uppercase; - letter-spacing: 0.2em; - border-bottom: var(--lux-hairline, 1px) dashed var(--lux-line, var(--border-color)); + letter-spacing: 0.25em; + border-bottom: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color)); position: relative; } diff --git a/server/src/ledgrab/static/js/app.ts b/server/src/ledgrab/static/js/app.ts index e31c193..887fc01 100644 --- a/server/src/ledgrab/static/js/app.ts +++ b/server/src/ledgrab/static/js/app.ts @@ -797,6 +797,10 @@ document.addEventListener('DOMContentLoaded', async () => { startEventsWS(); startEntityEventListeners(); startAutoRefresh(); + // Perf poll starts globally so the transport-bar CPU / Mem cells stay + // live regardless of which tab is active. Tab-hidden pauses it via the + // visibilitychange handler in perf-charts.ts. + startPerfPolling(); // Initialize update checker (banner + WS listener) initUpdateListener(); diff --git a/server/src/ledgrab/static/js/core/chart-utils.ts b/server/src/ledgrab/static/js/core/chart-utils.ts index f093ad1..8415ea0 100644 --- a/server/src/ledgrab/static/js/core/chart-utils.ts +++ b/server/src/ledgrab/static/js/core/chart-utils.ts @@ -3,11 +3,14 @@ * * Both dashboard.js and targets.js need nearly identical Chart.js line charts * for FPS visualization. This module provides a single factory so the config - * lives in one place. - * - * Requires Chart.js to be registered globally (done by perf-charts.js). + * lives in one place and owns the global Chart.js registration. */ +import { Chart, registerables } from 'chart.js'; +Chart.register(...registerables); +// Expose globally for legacy code paths that still reference window.Chart. +window.Chart = Chart; + const DEFAULT_MAX_SAMPLES = 120; /** Left-pad an array with nulls so it always has `maxSamples` entries. */ @@ -28,7 +31,7 @@ function _padLeft(arr: number[], maxSamples: number): (number | null)[] { * @returns {Chart|null} */ export function createFpsSparkline(canvasId: string, actualHistory: number[], currentHistory: number[], fpsTarget: number, opts: any = {}) { - const canvas = document.getElementById(canvasId); + const canvas = document.getElementById(canvasId) as HTMLCanvasElement | null; if (!canvas) return null; const maxSamples = opts.maxSamples || DEFAULT_MAX_SAMPLES; diff --git a/server/src/ledgrab/static/js/core/color-picker.ts b/server/src/ledgrab/static/js/core/color-picker.ts index e7f5ff5..170eded 100644 --- a/server/src/ledgrab/static/js/core/color-picker.ts +++ b/server/src/ledgrab/static/js/core/color-picker.ts @@ -69,6 +69,25 @@ function _rgbToHex(rgb: string) { return '#' + m.slice(0, 3).map(n => parseInt(n).toString(16).padStart(2, '0')).join(''); } +/** True if any ancestor between `el` and
has overflow:hidden / clip + * / auto on x or y. Used by the picker toggle to decide whether it must + * detach the popover to with fixed positioning so it isn't + * clipped. */ +function _hasOverflowClipAncestor(el: Element): boolean { + let cur: Element | null = el.parentElement; + while (cur && cur !== document.body) { + const cs = getComputedStyle(cur); + const ox = cs.overflowX; + const oy = cs.overflowY; + if (ox === 'hidden' || ox === 'clip' || ox === 'auto' || + oy === 'hidden' || oy === 'clip' || oy === 'auto') { + return true; + } + cur = cur.parentElement; + } + return false; +} + window._cpToggle = function (id) { // Close all other pickers first (and drop their card elevation) document.querySelectorAll('.color-picker-popover').forEach((p: Element) => { @@ -108,6 +127,32 @@ window._cpToggle = function (id) { pop.style.animation = 'none'; pop.style.zIndex = '10000'; pop.classList.add('cp-fixed'); + } else { + // Desktop: detach to body with fixed positioning when the swatch sits + // inside an overflow:hidden ancestor (e.g. the perf-chart strip, + // modal body, tree-dd panel). Otherwise the popover is clipped. + const swatchEl = document.getElementById(`cp-swatch-${id}`); + const hasClippingAncestor = swatchEl && _hasOverflowClipAncestor(swatchEl); + if (hasClippingAncestor && pop.parentElement !== document.body) { + (pop as any)._cpOrigParent = pop.parentElement; + (pop as any)._cpOrigNext = pop.nextSibling; + document.body.appendChild(pop); + const swRect = swatchEl!.getBoundingClientRect(); + pop.style.position = 'fixed'; + pop.style.top = `${swRect.bottom + 8}px`; + // Anchor on the left edge of the swatch, but clamp so the + // popover doesn't run off the right edge of the viewport. + const popWidth = 240; // approx; refined after first paint + let left = swRect.left; + if (left + popWidth > window.innerWidth - 12) { + left = Math.max(12, window.innerWidth - popWidth - 12); + } + pop.style.left = `${left}px`; + pop.style.right = 'auto'; + pop.style.margin = '0'; + pop.style.zIndex = '10000'; + pop.classList.add('cp-fixed'); + } } // Mark active dot diff --git a/server/src/ledgrab/static/js/features/dashboard.ts b/server/src/ledgrab/static/js/features/dashboard.ts index 0999749..756d7c7 100644 --- a/server/src/ledgrab/static/js/features/dashboard.ts +++ b/server/src/ledgrab/static/js/features/dashboard.ts @@ -6,7 +6,7 @@ import { apiKey, _dashboardLoading, set_dashboardLoading, dashboardPollInterval, import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, fetchMetricsHistory } from '../core/api.ts'; import { t } from '../core/i18n.ts'; import { showToast, showConfirm, formatUptime, formatCompact, setTabRefreshing } from '../core/ui.ts'; -import { renderPerfSection, renderPerfModeToggle, setPerfMode, initPerfCharts, startPerfPolling, stopPerfPolling } from './perf-charts.ts'; +import { renderPerfSection, renderPerfModeToggle, setPerfMode, initPerfCharts, startPerfPolling, stopPerfPolling, updateActivePatches, updateTotalFps, updateDevices } from './perf-charts.ts'; import { startAutoRefresh, updateTabBadge } from './tabs.ts'; import { isActiveTab } from '../core/tab-registry.ts'; import { @@ -265,12 +265,23 @@ function _updateSyncClocksInPlace(syncClocks: SyncClock[]): void { const card = document.querySelector(`[data-sync-clock-id="${CSS.escape(c.id)}"]`); if (!card) continue; const speedEl = card.querySelector('.dashboard-clock-speed'); - if (speedEl) speedEl.textContent = `${c.speed}x`; - const btn = card.querySelector('.dashboard-target-actions .dashboard-action-btn'); + if (speedEl) speedEl.textContent = `${c.speed}×`; + card.classList.toggle('is-running', c.is_running); + const led = card.querySelector('.mod-leds .led'); + if (led) led.className = c.is_running ? 'led on blink' : 'led'; + const patch = card.querySelector('.mod-patch'); + if (patch) { + const dot = patch.querySelector('.patch-dot'); + if (dot) dot.className = c.is_running ? 'patch-dot is-live' : 'patch-dot'; + const label = patch.querySelector('span:last-child'); + if (label) label.textContent = c.is_running ? 'TICKING' : 'PAUSED'; + } + const btn = card.querySelector('.mod-foot .mod-btn'); if (btn) { - btn.className = `dashboard-action-btn ${c.is_running ? 'stop' : 'start'}`; - btn.setAttribute('onclick', c.is_running ? `dashboardPauseClock('${c.id}')` : `dashboardResumeClock('${c.id}')`); - btn.innerHTML = c.is_running ? ICON_PAUSE : ICON_START; + btn.className = c.is_running ? 'mod-btn mod-btn-stop' : 'mod-btn mod-btn-go'; + btn.setAttribute('onclick', `event.stopPropagation(); ${c.is_running ? `dashboardPauseClock('${c.id}')` : `dashboardResumeClock('${c.id}')`}`); + const label = c.is_running ? (t('sync_clock.action.pause') || 'Pause') : (t('sync_clock.action.resume') || 'Resume'); + btn.innerHTML = `${c.is_running ? ICON_PAUSE : ICON_START} ${label}`; } } } @@ -284,14 +295,24 @@ function _renderIntegrationCard(conn: HomeAssistantConnectionStatus): string { const subtitle = conn.connected ? `${conn.entity_count} ${t('dashboard.integrations.entities')}` : t('ha_source.disconnected'); + const short = (conn.source_id || '').replace(/^src_/, '').slice(0, 2).toUpperCase() || 'HA'; + const ledCls = conn.connected ? 'led on blink' : 'led'; + const patchLabel = conn.connected ? 'ONLINE' : 'OFFLINE'; + const patchLive = conn.connected ? ' is-live' : ''; - return `