diff --git a/.gitignore b/.gitignore index 0b5fd7e..98d679f 100644 --- a/.gitignore +++ b/.gitignore @@ -68,6 +68,11 @@ logs/ # shipped sound assets out of the CI tag checkout. /data/ /server/data/ +# Defensive: if the server is launched from server/src/ (uncommon path), +# its relative `data/` dir resolves to server/src/data/. Templates now +# live in SQLite, so any *.json that lands here is stale runtime export +# and must not be committed. +/server/src/data/ *.db *.sqlite *.json.bak diff --git a/TODO.md b/TODO.md index d02a243..ddd01e9 100644 --- a/TODO.md +++ b/TODO.md @@ -1,5 +1,259 @@ # LedGrab TODO +## Server shutdown action + +Let user choose what happens to LED targets on server shutdown. + +- [x] Backend storage: `shutdown_action` in `db.settings` (`"stop_targets"` default | `"nothing"`) +- [x] Backend route: `GET/PUT /api/v1/system/shutdown-action` in `system_settings.py` +- [x] Backend schema: `ShutdownActionResponse/Request` in `schemas/system.py` +- [x] Backend wiring: lifespan shutdown in `main.py` reads action, passes `restore_devices` flag to `processor_manager.stop_all()` +- [x] `processor_manager.stop_all(restore_devices: bool = True)` — when False, calls public `proc.cancel_task()` (defined on `TargetProcessor`) which awaits cancellation without restoring device state; skips `_restore_device_idle_state` loop. No reach into private `_task` attribute. +- [x] Frontend: hidden `` audit deferred (project already enforces via + CLAUDE.md rule + IconSelect/EntitySelect wrappers). + +### Phase 6 — Mobile dedicated shell + +- [x] `mobile.css` (existing file, not forked) — fixed-bottom `.tab-bar` + promoted to full Lumenworks treatment: gradient background + hairline + divider at top + channel-accent rule matching the transport-bar + bottom. Active tab gets an LED pip above the icon and a channel-tint + background. Tab labels + badges use mono uppercase to match the + rest of the app. Phone (≤600 px): modal corner-bracket hidden + (fullscreen modals), modal-header stripe slimmed to 18 px. +- [x] Phase 2's layout.css already strips the transport-center on phones + and collapses the sidebar via `display: contents`, so the mobile + shell automatically routes the tab-bar to the bottom without a + separate JS hook. +- [WONTDO] Fork into `mobile-shell.css` — keeping changes in `mobile.css` + since the cascade was already organized by viewport. A rename adds + churn without improving maintainability. + +### Phase 7 — Microcopy + retire legacy + +- [x] Locale rename: `targets.title` + `dashboard.section.targets` → + "Channels" (en) / "Каналы" (ru) / "通道" (zh); + `streams.title` → "Inputs" / "Входы" / "输入". + Automations kept as-is (Automations + Scenes is a meaningful + distinction; "Patches" would conflate them). Internal tab keys + (`dashboard` / `automations` / `targets` / `streams` / `integrations` + / `graph`) unchanged so no JS or localStorage migration needed. +- [x] Ambient WebGL background — default is already `off`; kept the + toggle button and localStorage preference so users who want the + shader can turn it on. No entry-point change needed: `data-bg-anim` + is initialized from localStorage with `off` fallback. +- [DEFERRED] Delete DM Sans + legacy color tokens — would cascade through + every file that reads `--primary-color` / `--text-color` etc. Safer + as a separate cleanup PR after the new design has soaked. +- [WONTDO] Delete `mobile.css` — Phase 6 kept the filename. + +## Dashboard Customization + +Per-account dashboard layout — slide-in Customize panel lets users +toggle section / perf-cell visibility, reorder via drag, change density, +pick presets, and import/export the layout as JSON. Server-synced via +`db.get_setting('dashboard_layout')` so settings follow the user. + +- [x] `js/features/dashboard-layout.ts` — schema (open registry of section + / perf-cell keys so v1.1 cards slot in with no migration), defaults, + 5 built-in presets (Studio/Operator/Showrunner/Diagnostics/TV), + localStorage cache + server sync, legacy-key migration from + `dashboard_collapsed`, `perfMetricsMode`, `perfChartColor_*`. +- [x] `api/routes/preferences.py` — `GET/PUT/DELETE + /api/v1/preferences/dashboard-layout`. Treats payload as opaque + (frontend owns the schema); validates only that body is an object + with a numeric `version`. 6 pytest tests in + `tests/test_preferences_api.py` cover round-trip, default-empty, + validation, delete, and unknown-field passthrough. +- [x] `js/features/dashboard.ts` — sections rendered into a fragment map, + then assembled in layout-driven order; perf section stays pinned + top (chart-persistence reasons) but its visibility is layout- + driven. Layout-change subscription invalidates the in-place-update + optimization so density / order / visibility changes always + rebuild section HTML. +- [x] `js/features/perf-charts.ts` — `renderPerfSection()` iterates + `getOrderedPerfCells()`; existing legacy `setPerfMode` writes + through to the layout so the global toggle and the customize + panel stay in sync. +- [x] `js/features/dashboard-customize.ts` + `css/dashboard-customize.css` + — slide-in panel, hand-rolled HTML5 drag-and-drop reorder, ↑/↓ + buttons for keyboard / TV remote, debounced (300 ms) autosave, + live preview while open. Reset / export / import actions. +- [x] i18n keys for `dashboard.customize.*` in en/ru/zh. +- [ ] (v1.1) Audio meters section — peak / RMS / BPM bars per audio + source. Schema key `audio-meters` already reserved. +- [ ] (v1.1) Alerts section — quiet by default, loud on issues. + Reserved key `alerts`. +- [ ] (v1.1) Live LED preview strip per running device. Reserved + key `led-preview`. +- [ ] (v1.1) Source thumbnails grid (1 fps multiviewer). Reserved + key `source-thumbs`. +- [ ] (v1.2) Pinned section (user-curated mix of targets / scenes / + devices). Reserved key `pinned`. +- [ ] (v1.2) Patch/flow map — read-only mini graph of routing. + Reserved key `flow`. + ## BLE LED Controller Support (SP110E / Triones / Zengge / Govee) Add support for Bluetooth Low Energy LED controllers driven by mobile apps like "LED Hue", HappyLighting, iLightsIn. Whole-strip ambient-color output only — these protocols don't support per-pixel streaming. diff --git a/server/docs/ui-redesign-mockup.html b/server/docs/ui-redesign-mockup.html new file mode 100644 index 0000000..f99c5ca --- /dev/null +++ b/server/docs/ui-redesign-mockup.html @@ -0,0 +1,1378 @@ + + + + + +LUMENWORKS — LedGrab Redesign Mockup + + + + + + + +
PROTOTYPE · LUMENWORKS v0.1
+ + + + + + + + + + +
+ + +
+
+
+
+
LUMENWORKS
+
LED · GRAB · v0.4.2
+
+
+ +
+ + + +
+ +
+
Uptime04:21:08
+ +
CPU14%
+
Mem182MB
+ +
+ + + + + +
+ + + +
+
+ + + + + +
+ +
+
+
Workspace · Dashboard · Studio A
+

Live Channels3 of 12 patched

+
+
+ + + +
+
+ + +
+
+ Active Patches +
03/ 12
+
+
Living Room WLED59.4 FPS
+
Desk Strip29.8 FPS
+
Bedroom Halo44.2 FPS
+
+
+
+ Throughput +
133FPS
+
▲ 4.2vs last min
+ + + + +
+
+ CPU · Capture Pipe +
14%
+
▼ 1.1nominal
+ + + +
+
+ Latency · WLED Net +
12ms
+
▲ 0.4p95 stable
+ + + +
+
+ + +
+
+

Capture Targets 03 LIVE / 12 TOTAL

+
+
+ + +
+
+ +
+ + +
+
+
+
+ CH·01 · WLED +

Living Room Strip

+
192.168.1.42 · 144 LEDs · DDP
+
+
+ + + +
+
+
+
+ FPS + 59.4 + + + + +
+
+ Latency + 8ms + + + +
+
+ Source + SCREEN
2560×1440
+
+
+
+
+ + PATCHED · IN→OUT +
+ + +
+
+ + +
+
+
+
+ CH·02 · ARTNET +

Desk Underglow

+
10.0.0.88 · 60 LEDs · ArtNet U1
+
+
+ + + +
+
+
+
+ FPS + 29.8 + + + + +
+
+ Latency + 14ms +
+
+ Effect + GRAD
fire+sparkle
+
+
+
+
+ + PATCHED · CSS→RGB +
+ + +
+
+ + +
+
+
+
+ CH·03 · AUDIO +

Bedroom Halo (FFT)

+
192.168.1.55 · 90 LEDs · E1.31
+
+
+ + + +
+
+
+
+ FPS + 44.2 + + + + +
+
+ Peak + −6dB +
+
+ Source + MIC
loopback
+
+
+
+
+ + PATCHED · FFT→COLOR +
+ + +
+
+ + +
+
+
+ CH·04 · WLED +

Kitchen Counter

+
192.168.1.61 · 30 LEDs · DDP · idle
+
+
+ + + +
+
+
+
+ FPS + -- +
+
+ Last Run + 2h
ago
+
+
+ Autostart + ARMED +
+
+
+
+ + STANDBY +
+ + +
+
+ + +
+
+
+ CH·05 · WLED +

Hallway Run

+
192.168.1.77 · OFFLINE · last seen 3d
+
+
+ + + +
+
+
+
+ FPS + -- +
+
+ Health + OFFLINE +
+
+ Action + PING
required
+
+
+
+
+ + UNREACHABLE +
+ + +
+
+ + +
+
+
+ CH·06 · GRAPH +

Sunset Scene Composer

+
3 inputs · 2 outputs · scheduled
+
+
+ + + +
+
+
+
+ Nodes + 14 +
+
+ Schedule + 19:42
−12m
+
+
+ Targets + Living + Bedroom +
+
+
+
+ + SCHEDULED +
+ + +
+
+ +
+
+ + +
+
+

Active Sources 04 STREAMING

+
+
+ +
+
+ +
+ +
+
+
+
+ SRC·01 · SCREEN +

Display 2 — 4K HDR

+
3840×2160 · 60 Hz · BGRA · DXGI
+
+
+ + +
+
+
+
+ Capture + 60.0fps +
+
+ CPU + 3.2% +
+
+ Patches + 2 +
+
+
+ +
+
+
+
+ SRC·02 · AUDIO +

Stereo Mix · 48kHz

+
WASAPI loopback · 1024 FFT · log scale
+
+
+ + +
+
+
+
+ Peak L + −4dB +
+
+ Peak R + −6dB +
+
+ Patches + 1 +
+
+
+ +
+
+
+ SRC·03 · CSS +

Gradient Fire

+
linear-gradient · 12 stops · animated 4s
+
+
+ +
+
+
+
+ Refresh + 30Hz +
+
+ CPU + 0.4% +
+
+ Patches + 0 +
+
+
+ +
+
+ +
+ SYSTEM NOMINAL +
BUILD 0.4.2 · KERNEL READY · 3 CHANNELS PATCHED · 04:21:08 UPTIME · NO ERRORS
+
+
+ +
+
+ + + diff --git a/server/package-lock.json b/server/package-lock.json index 1ee47a9..f58dfbc 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -14,6 +14,9 @@ "marked": "^17.0.5" }, "devDependencies": { + "@fontsource-variable/big-shoulders-display": "^5.2.5", + "@fontsource-variable/jetbrains-mono": "^5.2.8", + "@fontsource-variable/manrope": "^5.2.8", "esbuild": "^0.27.4", "typescript": "^5.9.3" } @@ -434,6 +437,33 @@ "node": ">=18" } }, + "node_modules/@fontsource-variable/big-shoulders-display": { + "version": "5.2.5", + "resolved": "https://registry.npmjs.org/@fontsource-variable/big-shoulders-display/-/big-shoulders-display-5.2.5.tgz", + "integrity": "sha512-ZH2w9u6018xbSf8vPZ42P/KxpQHfIsKnxSnMtLFgwui1zIS05vzlijAWRcaRQoY2pXu4Z3SVa88OANsmq6mkvA==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource-variable/jetbrains-mono": { + "version": "5.2.8", + "resolved": "https://registry.npmjs.org/@fontsource-variable/jetbrains-mono/-/jetbrains-mono-5.2.8.tgz", + "integrity": "sha512-WBA9elru6Jdp5df2mES55wuOO0WIrn3kpXnI4+W2ek5u3ZgLS9XS4gmIlcQhiZOWEKl95meYdvK7xI+ETLCq/Q==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource-variable/manrope": { + "version": "5.2.8", + "resolved": "https://registry.npmjs.org/@fontsource-variable/manrope/-/manrope-5.2.8.tgz", + "integrity": "sha512-nc9lOuCRz73UHnovDE2bwXUdghE2SEOc7Aii0qGe3CLyE03W1a7VnY5Z6euRiapiKbCkGS+eXbY3s/kvWeGeSw==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@kurkle/color": { "version": "0.3.4", "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", @@ -704,6 +734,24 @@ "dev": true, "optional": true }, + "@fontsource-variable/big-shoulders-display": { + "version": "5.2.5", + "resolved": "https://registry.npmjs.org/@fontsource-variable/big-shoulders-display/-/big-shoulders-display-5.2.5.tgz", + "integrity": "sha512-ZH2w9u6018xbSf8vPZ42P/KxpQHfIsKnxSnMtLFgwui1zIS05vzlijAWRcaRQoY2pXu4Z3SVa88OANsmq6mkvA==", + "dev": true + }, + "@fontsource-variable/jetbrains-mono": { + "version": "5.2.8", + "resolved": "https://registry.npmjs.org/@fontsource-variable/jetbrains-mono/-/jetbrains-mono-5.2.8.tgz", + "integrity": "sha512-WBA9elru6Jdp5df2mES55wuOO0WIrn3kpXnI4+W2ek5u3ZgLS9XS4gmIlcQhiZOWEKl95meYdvK7xI+ETLCq/Q==", + "dev": true + }, + "@fontsource-variable/manrope": { + "version": "5.2.8", + "resolved": "https://registry.npmjs.org/@fontsource-variable/manrope/-/manrope-5.2.8.tgz", + "integrity": "sha512-nc9lOuCRz73UHnovDE2bwXUdghE2SEOc7Aii0qGe3CLyE03W1a7VnY5Z6euRiapiKbCkGS+eXbY3s/kvWeGeSw==", + "dev": true + }, "@kurkle/color": { "version": "0.3.4", "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", diff --git a/server/package.json b/server/package.json index 0207458..87dc94c 100644 --- a/server/package.json +++ b/server/package.json @@ -16,6 +16,9 @@ "author": "", "license": "ISC", "devDependencies": { + "@fontsource-variable/big-shoulders-display": "^5.2.5", + "@fontsource-variable/jetbrains-mono": "^5.2.8", + "@fontsource-variable/manrope": "^5.2.8", "esbuild": "^0.27.4", "typescript": "^5.9.3" }, diff --git a/server/src/ledgrab/__main__.py b/server/src/ledgrab/__main__.py index 7402001..c11d8ac 100644 --- a/server/src/ledgrab/__main__.py +++ b/server/src/ledgrab/__main__.py @@ -12,6 +12,8 @@ import threading import time import webbrowser from pathlib import Path +from urllib.error import URLError +from urllib.request import urlopen def _fix_embedded_tcl_paths() -> None: @@ -54,9 +56,25 @@ def _run_server(server: uvicorn.Server) -> None: loop.run_until_complete(server.serve()) -def _open_browser(port: int, delay: float = 2.0) -> None: - """Open the UI in the default browser after a short delay.""" - time.sleep(delay) +def _wait_for_server(port: int, timeout: float = 30.0, interval: float = 0.25) -> bool: + """Poll /health until the server responds or *timeout* seconds elapse.""" + url = f"http://localhost:{port}/health" + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + try: + with urlopen(url, timeout=1) as resp: # noqa: S310 - localhost only + if 200 <= resp.status < 500: + return True + except (URLError, ConnectionError, OSError, TimeoutError): + pass + time.sleep(interval) + return False + + +def _open_browser(port: int) -> None: + """Open the UI in the default browser once the server is ready.""" + if not _wait_for_server(port): + logger.warning("Server did not become ready in time; opening browser anyway") webbrowser.open(f"http://localhost:{port}") diff --git a/server/src/ledgrab/api/__init__.py b/server/src/ledgrab/api/__init__.py index ae716cc..6ef367c 100644 --- a/server/src/ledgrab/api/__init__.py +++ b/server/src/ledgrab/api/__init__.py @@ -31,6 +31,7 @@ from .routes.game_integration import router as game_integration_router from .routes.audio_processing_templates import router as audio_processing_templates_router from .routes.audio_filters import router as audio_filters_router from .routes.pattern_templates import router as pattern_templates_router +from .routes.preferences import router as preferences_router router = APIRouter() router.include_router(system_router) @@ -62,5 +63,6 @@ router.include_router(game_integration_router) router.include_router(audio_processing_templates_router) router.include_router(audio_filters_router) router.include_router(pattern_templates_router) +router.include_router(preferences_router) __all__ = ["router"] diff --git a/server/src/ledgrab/api/routes/preferences.py b/server/src/ledgrab/api/routes/preferences.py new file mode 100644 index 0000000..70a9b81 --- /dev/null +++ b/server/src/ledgrab/api/routes/preferences.py @@ -0,0 +1,75 @@ +"""User preferences routes — currently dashboard layout only. + +The dashboard layout schema is owned by the frontend (open registry of +section/cell keys); the backend treats the value as an opaque JSON blob, +validates it's a dict with a `version` field, and persists it under the +`dashboard_layout` settings key. +""" + +from typing import Any + +from fastapi import APIRouter, Body, Depends, HTTPException + +from ledgrab.api.auth import AuthRequired +from ledgrab.api.dependencies import get_database +from ledgrab.storage.database import Database +from ledgrab.utils import get_logger + +logger = get_logger(__name__) + +router = APIRouter() + +_DASHBOARD_LAYOUT_KEY = "dashboard_layout" + + +@router.get( + "/api/v1/preferences/dashboard-layout", + tags=["Preferences"], +) +async def get_dashboard_layout( + _: AuthRequired, + db: Database = Depends(get_database), +) -> dict[str, Any]: + """Read the saved dashboard layout. Returns an empty object when no + layout has been saved yet — the frontend falls back to its built-in + default in that case.""" + value = db.get_setting(_DASHBOARD_LAYOUT_KEY) + return value if value is not None else {} + + +@router.put( + "/api/v1/preferences/dashboard-layout", + tags=["Preferences"], +) +async def put_dashboard_layout( + _: AuthRequired, + body: dict[str, Any] = Body(...), + db: Database = Depends(get_database), +) -> dict[str, bool]: + """Save the dashboard layout. The body must be a JSON object with a + numeric `version` field; everything else is treated as opaque payload + that the frontend will validate on read.""" + if not isinstance(body, dict): + raise HTTPException(status_code=422, detail="Body must be a JSON object") + if not isinstance(body.get("version"), int): + raise HTTPException( + status_code=422, + detail="Layout must include a numeric 'version' field", + ) + db.set_setting(_DASHBOARD_LAYOUT_KEY, body) + return {"ok": True} + + +@router.delete( + "/api/v1/preferences/dashboard-layout", + tags=["Preferences"], +) +async def delete_dashboard_layout( + _: AuthRequired, + db: Database = Depends(get_database), +) -> dict[str, bool]: + """Delete the saved layout — frontend will revert to the default + on next load. Used by the 'Reset' button when the user wants + to clear the server-side override entirely.""" + db.set_setting(_DASHBOARD_LAYOUT_KEY, {}) + return {"ok": True} diff --git a/server/src/ledgrab/api/routes/system.py b/server/src/ledgrab/api/routes/system.py index fffcaeb..b94876f 100644 --- a/server/src/ledgrab/api/routes/system.py +++ b/server/src/ledgrab/api/routes/system.py @@ -7,6 +7,7 @@ import asyncio import platform import subprocess import sys +import time from datetime import datetime, timezone from typing import Optional @@ -92,6 +93,13 @@ def _get_cpu_name() -> str | None: _cpu_name: str | None = _get_cpu_name() +# Captured at first import of this module. Process-wide elapsed time is +# the closest the server has to "app start" without instrumenting main.py; +# the system module is imported during router setup, before the server +# accepts requests, so the drift is negligible. Used by /health to expose +# uptime_seconds for the transport-bar ticker. +_APP_START_MONOTONIC: float = time.monotonic() + router = APIRouter() @@ -122,6 +130,7 @@ async def health_check(request: Request): setup_required=setup_required, repo_url=REPO_URL, donate_url=DONATE_URL, + uptime_seconds=time.monotonic() - _APP_START_MONOTONIC, ) @@ -316,6 +325,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 +346,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/routes/system_settings.py b/server/src/ledgrab/api/routes/system_settings.py index fcfd55b..172a37d 100644 --- a/server/src/ledgrab/api/routes/system_settings.py +++ b/server/src/ledgrab/api/routes/system_settings.py @@ -19,6 +19,9 @@ from ledgrab.api.schemas.system import ( LogLevelResponse, MQTTSettingsRequest, MQTTSettingsResponse, + ShutdownAction, + ShutdownActionRequest, + ShutdownActionResponse, ) from ledgrab.config import get_config from ledgrab.storage.database import Database @@ -150,6 +153,55 @@ async def update_external_url( return ExternalUrlResponse(external_url=url) +# --------------------------------------------------------------------------- +# Shutdown action setting +# --------------------------------------------------------------------------- + +_VALID_SHUTDOWN_ACTIONS: tuple[str, ...] = ("stop_targets", "nothing") +_DEFAULT_SHUTDOWN_ACTION: ShutdownAction = "stop_targets" + + +def load_shutdown_action(db: Database | None = None) -> ShutdownAction: + """Load the configured shutdown action. Returns the default if unset or corrupt.""" + if db is None: + from ledgrab.api.dependencies import get_database + + db = get_database() + data = db.get_setting("shutdown_action") + if not data: + return _DEFAULT_SHUTDOWN_ACTION + value = data.get("action") + if value in _VALID_SHUTDOWN_ACTIONS: + return value # type: ignore[return-value] + return _DEFAULT_SHUTDOWN_ACTION + + +@router.get( + "/api/v1/system/shutdown-action", + response_model=ShutdownActionResponse, + tags=["System"], +) +async def get_shutdown_action(_: AuthRequired, db: Database = Depends(get_database)): + """Get the configured server shutdown action.""" + return ShutdownActionResponse(action=load_shutdown_action(db)) + + +@router.put( + "/api/v1/system/shutdown-action", + response_model=ShutdownActionResponse, + tags=["System"], +) +async def update_shutdown_action( + _: AuthRequired, + body: ShutdownActionRequest, + db: Database = Depends(get_database), +): + """Set what happens to LED targets when the server shuts down.""" + db.set_setting("shutdown_action", {"action": body.action}) + logger.info("Shutdown action updated: %s", body.action) + return ShutdownActionResponse(action=body.action) + + # --------------------------------------------------------------------------- # Live log viewer WebSocket # --------------------------------------------------------------------------- diff --git a/server/src/ledgrab/api/schemas/system.py b/server/src/ledgrab/api/schemas/system.py index 703fe32..7ad43cb 100644 --- a/server/src/ledgrab/api/schemas/system.py +++ b/server/src/ledgrab/api/schemas/system.py @@ -26,6 +26,10 @@ class HealthResponse(BaseModel): ) repo_url: str = Field(default="", description="Source code repository URL") donate_url: str = Field(default="", description="Donation page URL") + uptime_seconds: float = Field( + default=0.0, + description="Process uptime in seconds since the server started.", + ) class VersionResponse(BaseModel): @@ -98,6 +102,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") @@ -191,6 +204,32 @@ class ExternalUrlRequest(BaseModel): external_url: str = Field(default="", description="External base URL. Empty string to clear.") +# ─── Shutdown action schemas ─────────────────────────────────── + + +ShutdownAction = Literal["stop_targets", "nothing"] + + +class ShutdownActionResponse(BaseModel): + """Current server shutdown action setting.""" + + action: ShutdownAction = Field( + description=( + "What happens to LED targets when the server shuts down. " + "`stop_targets` runs the normal stop sequence (per-device " + "auto_shutdown decides whether prior state is restored). " + "`nothing` skips device-touching teardown — lights freeze on " + "their last frame regardless of per-device auto_shutdown." + ), + ) + + +class ShutdownActionRequest(BaseModel): + """Update the server shutdown action setting.""" + + action: ShutdownAction = Field(description="New shutdown action.") + + # ─── Log level schemas ───────────────────────────────────────── diff --git a/server/src/ledgrab/core/processing/processor_manager.py b/server/src/ledgrab/core/processing/processor_manager.py index 08ac370..a1bcde2 100644 --- a/server/src/ledgrab/core/processing/processor_manager.py +++ b/server/src/ledgrab/core/processing/processor_manager.py @@ -770,8 +770,16 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin) # ===== LIFECYCLE ===== - async def stop_all(self): - """Stop processing and health monitoring for all targets and devices.""" + async def stop_all(self, restore_devices: bool = True): + """Stop processing and health monitoring for all targets and devices. + + When ``restore_devices`` is False, processor tasks are cancelled + directly instead of going through ``proc.stop()`` (which sends + per-device auto_shutdown restore frames), and the global + idle-state restore loop is skipped. Used by the "Nothing" + shutdown action so lights freeze on their last frame regardless + of per-device auto_shutdown. + """ await self._metrics_history.stop() await self.stop_health_monitoring() @@ -781,18 +789,35 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin) if rs.restart_task and not rs.restart_task.done(): rs.restart_task.cancel() - # Stop all processors - for target_id, proc in list(self._processors.items()): - if proc.is_running: - try: - await proc.stop() - except Exception as e: - logger.error(f"Error stopping target {target_id}: {e}") + if restore_devices: + # Stop all processors (per-device auto_shutdown decides whether + # the prior device state is restored). + for target_id, proc in list(self._processors.items()): + if proc.is_running: + try: + await proc.stop() + except Exception as e: + logger.error(f"Error stopping target {target_id}: {e}") - # Restore idle state for devices that have auto-restore enabled - # (serial devices already dark from processor close; WLED restored by snapshot) - for device_id in self._devices: - await self._restore_device_idle_state(device_id) + # Restore idle state for devices that have auto-restore enabled + # (serial devices already dark from processor close; WLED restored by snapshot) + for device_id in self._devices: + await self._restore_device_idle_state(device_id) + else: + # "Nothing" mode: cancel processor capture tasks without sending + # restore frames so the LEDs keep displaying the last frame. + # ``cancel_task`` (defined on ``TargetProcessor``) awaits the + # cancellation so the loop's current iteration completes — no + # half-written frame on the wire when the process exits. + for target_id, proc in list(self._processors.items()): + try: + await proc.cancel_task() + except Exception as e: + logger.error(f"Error cancelling task for target {target_id}: {e}") + logger.info( + "Shutdown action 'nothing': skipped device restore for %d target(s)", + len(self._processors), + ) # Close any cached idle LED clients (WLED only; serial has no cached clients) for did in list(self._idle_clients): diff --git a/server/src/ledgrab/core/processing/target_processor.py b/server/src/ledgrab/core/processing/target_processor.py index ffe96ea..5fdb2cb 100644 --- a/server/src/ledgrab/core/processing/target_processor.py +++ b/server/src/ledgrab/core/processing/target_processor.py @@ -16,6 +16,10 @@ from dataclasses import dataclass from datetime import datetime from typing import TYPE_CHECKING, Any, Callable, Dict, Optional, Tuple +from ledgrab.utils import get_logger + +logger = get_logger(__name__) + if TYPE_CHECKING: from ledgrab.core.processing.color_strip_stream_manager import ColorStripStreamManager from ledgrab.core.processing.live_stream_manager import LiveStreamManager @@ -145,6 +149,32 @@ class TargetProcessor(ABC): """ ... + async def cancel_task(self) -> None: + """Cancel the processing task without restoring device state. + + Used by ``ProcessorManager.stop_all(restore_devices=False)`` at + server shutdown when the user has chosen "Nothing" — LEDs should + keep displaying their last frame, so we skip the per-device + ``stop()`` path that sends restore frames. We still flip + ``_is_running`` and await the cancellation so the loop's current + iteration completes (no half-written frame on the wire). + + Subclasses with extra non-device cleanup (e.g. live-stream + release) may override this; the default just stops the task. + """ + self._is_running = False + task = self._task + if task is not None and not task.done(): + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + except Exception: + # Log but don't propagate — caller is shutting down. + logger.debug("Task raised during cancel_task", exc_info=True) + self._task = None + # ----- Settings ----- @abstractmethod diff --git a/server/src/ledgrab/main.py b/server/src/ledgrab/main.py index bced30b..12c8823 100644 --- a/server/src/ledgrab/main.py +++ b/server/src/ledgrab/main.py @@ -412,9 +412,22 @@ async def lifespan(app: FastAPI): except Exception as e: logger.error(f"Error stopping OS notification listener: {e}") - # Stop all processing + # Stop all processing. + # The shutdown action setting controls whether per-device restore + # frames are sent: "stop_targets" (default) runs the normal stop + # sequence; "nothing" cancels capture tasks so the LEDs freeze on + # their last frame. try: - await processor_manager.stop_all() + from ledgrab.api.routes.system_settings import load_shutdown_action + + action = load_shutdown_action(db) + except Exception as e: + logger.error(f"Error reading shutdown action setting, defaulting to stop_targets: {e}") + action = "stop_targets" + + logger.info("Shutdown action: %s", action) + try: + await processor_manager.stop_all(restore_devices=action != "nothing") logger.info("Stopped all processors") except Exception as e: logger.error(f"Error stopping processors: {e}") diff --git a/server/src/ledgrab/static/css/all.css b/server/src/ledgrab/static/css/all.css index 3f69e4f..91f0b9d 100644 --- a/server/src/ledgrab/static/css/all.css +++ b/server/src/ledgrab/static/css/all.css @@ -2,12 +2,14 @@ @import './fonts.css'; @import './base.css'; @import './layout.css'; +@import './sidebar.css'; @import './components.css'; @import './cards.css'; @import './modal.css'; @import './calibration.css'; @import './advanced-calibration.css'; @import './dashboard.css'; +@import './dashboard-customize.css'; @import './streams.css'; @import './patterns.css'; @import './automations.css'; diff --git a/server/src/ledgrab/static/css/automations.css b/server/src/ledgrab/static/css/automations.css index 3b3a909..f284250 100644 --- a/server/src/ledgrab/static/css/automations.css +++ b/server/src/ledgrab/static/css/automations.css @@ -1,19 +1,23 @@ /* ===== AUTOMATIONS ===== */ .badge-automation-active { - background: var(--success-color); - color: #fff; + background: color-mix(in srgb, var(--ch-signal, var(--primary-color)) 16%, transparent); + border-color: color-mix(in srgb, var(--ch-signal, var(--primary-color)) 45%, transparent); + color: var(--ch-signal, var(--primary-color)); + box-shadow: 0 0 8px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 20%, transparent); } .badge-automation-inactive { - background: var(--border-color); - color: var(--text-color); + background: transparent; + border-color: var(--lux-line, var(--border-color)); + color: var(--lux-ink-dim, var(--text-color)); } .badge-automation-disabled { - background: var(--border-color); - color: var(--text-muted); - opacity: 0.7; + background: transparent; + border-color: var(--lux-line, var(--border-color)); + color: var(--lux-ink-mute, var(--text-muted)); + opacity: 0.8; } .automation-status-disabled { diff --git a/server/src/ledgrab/static/css/base.css b/server/src/ledgrab/static/css/base.css index 2a89088..f6b1592 100644 --- a/server/src/ledgrab/static/css/base.css +++ b/server/src/ledgrab/static/css/base.css @@ -16,7 +16,25 @@ --danger-color: #f44336; --warning-color: #ff9800; --info-color: #2196F3; - --font-mono: 'Cascadia Code', 'Fira Code', 'JetBrains Mono', 'SF Mono', 'Consolas', 'Liberation Mono', monospace; + --font-mono: 'JetBrains Mono', 'Cascadia Code', 'Fira Code', 'SF Mono', 'Consolas', 'Liberation Mono', monospace; + + /* ── Lumenworks design tokens (additive; active alongside legacy tokens + during phased migration). Typography + spatial system for the + studio-console redesign. Channel colors defined in the theme + blocks below so they can shift with light/dark mode. ──────── */ + --font-display: 'Big Shoulders Display', 'Orbitron', 'Manrope', sans-serif; + --font-brand: 'Orbitron', sans-serif; + --font-body: 'Manrope', 'DM Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + + --lux-r-sm: 3px; + --lux-r-md: 6px; + --lux-r-lg: 10px; + --lux-r-xl: 14px; + + /* Hairline + bold dividers — thinner than the legacy 1px --border-color + to get the "silkscreened panel" feel. */ + --lux-hairline: 1px; + --lux-rule: 2px; /* Spacing scale */ --space-xs: 4px; @@ -81,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: #000000; --text-color: #e0e0e0; --text-primary: #e0e0e0; --text-secondary: #999; @@ -96,12 +114,40 @@ --hover-bg: rgba(255, 255, 255, 0.05); --input-bg: #1a1a2e; color-scheme: dark; + + /* ── 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; + --lux-line-bold:#2e3440; + --lux-ink: #e6ebf2; + --lux-ink-dim: #8b95a5; + --lux-ink-mute: #5b6473; + --lux-ink-faint:#3a414c; + + /* 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 */ + --ch-coral: #ff5e5e; /* offline / error / alarm */ + --ch-violet: #8b7eff; /* graph / scenes / automations */ + + --lux-signal-glow: 0 0 14px color-mix(in srgb, var(--ch-signal) 40%, transparent); + --lux-shadow-rack: 0 1px 0 rgba(255, 255, 255, 0.03), 0 8px 24px rgba(0, 0, 0, 0.5); } /* Light theme */ [data-theme="light"] { - --bg-color: #f5f5f5; - --bg-secondary: #eee; + --bg-color: #ffffff; + --bg-secondary: #fafbfc; --card-bg: #ffffff; --text-color: #333333; --text-primary: #333333; @@ -120,6 +166,32 @@ --primary-color-on-light-bg: #2e7d32; --primary-text: #2e7d32; color-scheme: light; + + /* ── 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; + --lux-ink-dim: #4c5866; + --lux-ink-mute: #6b7684; + --lux-ink-faint:#a5afbc; + + /* --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; + --ch-coral: #d8392e; + --ch-violet: #5b4fd0; + + --lux-signal-glow: 0 0 12px color-mix(in srgb, var(--ch-signal) 28%, transparent); + --lux-shadow-rack: 0 1px 0 rgba(255, 255, 255, 0.6), 0 6px 18px rgba(0, 0, 0, 0.08); } /* Default to dark theme */ @@ -137,10 +209,12 @@ html { } body { - font-family: 'DM Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; + font-family: var(--font-body, 'Manrope', 'DM Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif); background: var(--bg-color); color: var(--text-color); - line-height: 1.6; + line-height: 1.55; + font-feature-settings: "ss01", "cv11"; + -webkit-font-smoothing: antialiased; } html.modal-open { @@ -167,21 +241,11 @@ html.modal-open { background: transparent; } -/* When bg-anim is active, make entity cards slightly translucent - so the shader bleeds through. Only target cards — NOT modals, - pickers, tab bars, headers, or other chrome. */ -[data-bg-anim="on"][data-theme="dark"] .card, -[data-bg-anim="on"][data-theme="dark"] .template-card, -[data-bg-anim="on"][data-theme="dark"] .add-device-card, -[data-bg-anim="on"][data-theme="dark"] .dashboard-target { - background: rgba(45, 45, 45, 0.88); -} -[data-bg-anim="on"][data-theme="light"] .card, -[data-bg-anim="on"][data-theme="light"] .template-card, -[data-bg-anim="on"][data-theme="light"] .add-device-card, -[data-bg-anim="on"][data-theme="light"] .dashboard-target { - background: rgba(255, 255, 255, 0.85); -} +/* Card backgrounds are intentionally stable across the dynamic-bg + toggle — the shader bleeds through the page background only. + (Previously a translucent override let the shader show through + cards too, but it made the same card look different depending on + whether the user had the WebGL background enabled.) */ /* Blur behind header via pseudo-element — applying backdrop-filter directly to header would create a containing block and break position:fixed on the .tab-bar nested inside it (mobile bottom nav). */ diff --git a/server/src/ledgrab/static/css/cards.css b/server/src/ledgrab/static/css/cards.css index 7c62dcd..6f296f3 100644 --- a/server/src/ledgrab/static/css/cards.css +++ b/server/src/ledgrab/static/css/cards.css @@ -2,41 +2,68 @@ section { margin-bottom: 40px; } -/* ── Skeleton loading placeholders ── */ -@keyframes skeletonPulse { - 0%, 100% { opacity: 0.06; } - 50% { opacity: 0.12; } +/* ── Skeleton loading placeholders — subtle shimmer, not a text-color flash */ +@keyframes skeletonShimmer { + 0% { background-position: -200% 0; } + 100% { background-position: 200% 0; } } .skeleton-card { - background: var(--card-bg); - border: 1px solid var(--border-color); - border-radius: var(--radius-md); - padding: 16px 20px 20px; + 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; display: flex; flex-direction: column; gap: 12px; 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 + placeholder, not a blank box. */ +.skeleton-card::before { + content: ''; + position: absolute; + left: 0; top: 0; bottom: 0; + width: 3px; + background: var(--lux-line, var(--border-color)); + opacity: 0.5; +} +.skeleton-card::after { + content: ''; + position: absolute; + top: 8px; right: 8px; + width: 12px; height: 12px; + border-top: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color)); + border-right: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color)); + opacity: 0.6; } .skeleton-line { - height: 14px; - border-radius: 4px; - background: var(--text-color); - animation: skeletonPulse 1.5s ease-in-out infinite; + height: 12px; + border-radius: 2px; + background: linear-gradient(90deg, + var(--lux-bg-2, var(--bg-secondary)) 0%, + var(--lux-bg-3, var(--border-color)) 50%, + var(--lux-bg-2, var(--bg-secondary)) 100%); + background-size: 200% 100%; + animation: skeletonShimmer 2.2s ease-in-out infinite; } .skeleton-line-title { - width: 60%; - height: 18px; + width: 55%; + height: 16px; } .skeleton-line-short { - width: 40%; + width: 35%; } .skeleton-line-medium { - width: 75%; + width: 72%; } .skeleton-actions { @@ -44,15 +71,19 @@ section { gap: 8px; margin-top: auto; padding-top: 12px; - border-top: 1px solid var(--border-color); + border-top: var(--lux-hairline, 1px) dashed var(--lux-line, var(--border-color)); } .skeleton-btn { - height: 32px; + height: 30px; flex: 1; - border-radius: var(--radius-sm); - background: var(--text-color); - animation: skeletonPulse 1.5s ease-in-out infinite; + border-radius: var(--lux-r-sm, var(--radius-sm)); + background: linear-gradient(90deg, + var(--lux-bg-2, var(--bg-secondary)) 0%, + var(--lux-bg-3, var(--border-color)) 50%, + var(--lux-bg-2, var(--bg-secondary)) 100%); + background-size: 200% 100%; + animation: skeletonShimmer 2.2s ease-in-out infinite; } .displays-grid, @@ -104,21 +135,130 @@ section { } .card { - background: var(--card-bg); - border: 1px solid var(--border-color); - border-radius: var(--radius-md); - padding: 16px 20px 20px; + --ch: var(--ch-signal, var(--primary-color)); /* channel accent (override per type) */ + 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; position: relative; transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease; display: flex; flex-direction: column; + overflow: hidden; +} + +/* Channel stripe on left edge — opt-in only: + * [data-has-color="1"] → user picked a personal color via the picker + * .card-running → "patched and live" indicator + * Idle cards without a personal color stay clean (no stripe), matching + * the pre-redesign behavior where the left border meant "I marked this". + * The dashboard module rows keep their always-on stripe (at 0.6 opacity) + * because the dashboard was approved as-is. */ +.card::before { + content: ''; + position: absolute; + left: 0; top: 0; bottom: 0; + width: 3px; + background: var(--ch); + box-shadow: 0 0 10px color-mix(in srgb, var(--ch) 40%, transparent); + pointer-events: none; + z-index: 1; + display: none; +} + +.card[data-has-color="1"]::before, +.card.card-running::before { + display: block; +} + +/* Corner bracket — silkscreened panel feel in the top-right */ +.card::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; + z-index: 1; } .card:hover { - box-shadow: 0 8px 24px var(--shadow-color); + box-shadow: var(--lux-shadow-rack, 0 8px 24px var(--shadow-color)); transform: translateY(-2px); + border-color: var(--lux-line-bold, var(--border-color)); } +/* Channel color variants — cards can opt in via class or data-attr. + Implicit mappings via attributes the JS already emits (no JS changes + required). Explicit classes provided as an escape hatch. */ +.card[data-card-type="led"], +.card[data-card-type="target"], +.card[data-target-id], +.card.ch-signal { --ch: var(--ch-signal, var(--primary-color)); } + +.card[data-card-type="screen"], +.card[data-card-type="source"], +.card[data-stream-id], +.card.ch-cyan { --ch: var(--ch-cyan, var(--info-color)); } + +.card[data-card-type="audio"], +.card[data-audio-source-id], +.card[data-audio-template-id], +.card.ch-magenta { --ch: var(--ch-magenta, #ff4ade); } + +.card[data-card-type="automation"], +.card[data-card-type="scene"], +.card[data-automation-id], +.card[data-scene-id], +.card.ch-violet { --ch: var(--ch-violet, #8b7eff); } + +.card.ch-amber { --ch: var(--ch-amber, var(--warning-color)); } + +.card[data-card-type="offline"], +.card.ch-coral { --ch: var(--ch-coral, var(--danger-color)); } + +/* ── Channel mapping for `.template-card` ── + * Cards rendered by `wrapCard({ type: 'template-card' })` are used by the + * Inputs and Integrations tabs (plus a few other CardSection consumers). + * Many of those use a generic `data-id` attribute, so we scope by the + * parent section's `data-card-section` instead of relying on a unique + * data-attr per row. Direct attribute hooks come first for the cards that + * already carry a domain-specific id. + */ + +/* Direct attribute hooks (Inputs tab — known per-domain attrs) */ +.template-card[data-stream-id], +.template-card[data-template-id], +.template-card[data-pp-template-id] { --ch: var(--ch-cyan, var(--info-color)); } + +.template-card[data-cspt-id], +.template-card[data-pattern-template-id] { --ch: var(--ch-signal, var(--primary-color)); } + +.template-card[data-audio-template-id], +.template-card[data-apt-id] { --ch: var(--ch-magenta, #ff4ade); } + +/* Section-scoped hooks (cards that share `data-id` and need their channel + * resolved via the surrounding section). Matches `
` emitted by `CardSection.render`. */ + +/* Network / data-input integrations → cyan (input language) */ +[data-card-section="ha-sources"] .template-card[data-id], +[data-card-section="mqtt-sources"] .template-card[data-id], +[data-card-section="weather-sources"] .template-card[data-id], +[data-card-section="value-sources"] .template-card[data-id] { --ch: var(--ch-cyan, var(--info-color)); } + +/* Game integrations → amber (events / surfaces) */ +[data-card-section="game-integrations"] .template-card[data-id], +.template-card[data-gi-id] { --ch: var(--ch-amber, var(--warning-color)); } + +/* Sync clocks → violet (timing / orchestration, mirrors automation/scenes) */ +[data-card-section="sync-clocks"] .template-card[data-id] { --ch: var(--ch-violet, #8b7eff); } + +/* HA light targets → signal (output target, mirrors led-targets) */ +[data-card-section="ha-light-targets"] .template-card[data-ha-target-id] { --ch: var(--ch-signal, var(--primary-color)); } + /* ── Card glare effect ── */ .card-glare::after, .template-card.card-glare::after, @@ -175,113 +315,61 @@ section { ); } -/* ── Running target: rotating gradient border ── */ -@property --border-angle { - syntax: ''; - initial-value: 0deg; - inherits: false; -} +/* ── Running module: channel stripe intensifies + signal-flow strip at the + bottom edge ("patched and live" indicator). Lightweight replacement + for the old rotating conic-gradient border — ~1 animated gradient on + a 2 px line, no GPU layer compositing needed per card. */ .card-running { - border-color: transparent; - background: linear-gradient( - calc(var(--border-angle) + 45deg), - var(--card-bg) 0%, - color-mix(in srgb, var(--primary-color) 12%, var(--card-bg)) 40%, - var(--card-bg) 60%, - color-mix(in srgb, var(--primary-color) 8%, var(--card-bg)) 85%, - var(--card-bg) 100% - ); -} - -/* When card has a custom color stripe, keep it and shift the animated border away from the left edge */ -.card-running[data-has-color]::before { - inset: 0 0 0 3px; - border-left: none; - border-radius: 0 8px 8px 0; + border-color: color-mix(in srgb, var(--ch) 35%, var(--lux-line, var(--border-color))); + box-shadow: + 0 0 0 1px color-mix(in srgb, var(--ch) 20%, transparent), + 0 6px 20px rgba(0, 0, 0, 0.25); } .card-running::before { - content: ''; - position: absolute; - inset: 0; - border-radius: inherit; - border: 2px solid transparent; - background: - conic-gradient( - from var(--border-angle), - var(--primary-color), - rgba(255,255,255,0.1) 25%, - var(--primary-color) 50%, - rgba(255,255,255,0.1) 75%, - var(--primary-color) - ) border-box; - -webkit-mask: - linear-gradient(#fff 0 0) padding-box, - linear-gradient(#fff 0 0); - -webkit-mask-composite: xor; - mask: - linear-gradient(#fff 0 0) padding-box, - linear-gradient(#fff 0 0); - mask-composite: exclude; - pointer-events: none; - z-index: 2; - animation: rotateBorder 4s linear infinite; - /* Promote to its own GPU layer so the rotating conic-gradient does not - force repaints of the whole card. */ - will-change: transform; + width: 4px; + box-shadow: + 0 0 14px color-mix(in srgb, var(--ch) 65%, transparent), + 0 0 4px color-mix(in srgb, var(--ch) 80%, transparent); +} + +/* Signal-flow strip — running cards replace the corner bracket with a + moving gradient along the bottom edge (the "patched and live" + indicator). Idle cards keep the corner bracket. */ +.card-running::after { + top: auto; right: auto; + left: 4px; bottom: 0; + width: calc(100% - 4px); + height: 2px; + border: none; + opacity: 0.7; + background: + linear-gradient(90deg, + transparent 0%, + color-mix(in srgb, var(--ch) 85%, transparent) 50%, + transparent 100%); + background-size: 30% 100%; + background-repeat: no-repeat; + animation: signalFlow 2.4s linear infinite; +} + +@keyframes signalFlow { + 0% { background-position: -30% 0; } + 100% { background-position: 130% 0; } } -/* Honor user preference for reduced motion — base.css globally clamps - animation durations, but the rotating border is decorative and we'd rather - not run it at all for these users. */ @media (prefers-reduced-motion: reduce) { - .card-running::before { + .card-running::after { animation: none; + background-position: 50% 0; + background-size: 60% 100%; } } -/* TODO(perf): pause animation when the card scrolls off-screen via an - IntersectionObserver toggling `animation-play-state: paused`. Not done in - CSS-only pass — would require a JS hook in card lifecycle. */ - -/* Fallback for browsers without mask-composite support (older Firefox) */ -@supports not (mask-composite: exclude) { - .card-running::before { - -webkit-mask: none; - mask: none; - background: none; - border: 2px solid var(--primary-color); - opacity: 0.7; - } -} - -@keyframes rotateBorder { - to { --border-angle: 360deg; } -} - -[data-theme="light"] .card-running { - background: linear-gradient( - calc(var(--border-angle) + 45deg), - var(--card-bg) 0%, - color-mix(in srgb, var(--primary-color) 18%, var(--card-bg)) 40%, - var(--card-bg) 60%, - color-mix(in srgb, var(--primary-color) 14%, var(--card-bg)) 85%, - var(--card-bg) 100% - ); -} - -[data-theme="light"] .card-running::before { - background: - conic-gradient( - from var(--border-angle), - var(--primary-color), - rgba(0,0,0,0.12) 25%, - var(--primary-color) 50%, - rgba(0,0,0,0.12) 75%, - var(--primary-color) - ) border-box; -} +/* Keep the corner bracket visible when NOT running (default), + and replace it with the signal flow when running (above). + No extra work needed — `.card::after` rules below cover this. */ /* ── Card entrance animation ── */ @keyframes cardEnter { @@ -546,18 +634,21 @@ body.cs-drag-active .card-drag-handle { display: flex; justify-content: space-between; align-items: center; - margin-bottom: 10px; + margin-bottom: 12px; padding-right: 60px; } .card-title { + font-family: var(--font-body, inherit); font-size: 1.05rem; - font-weight: 600; + font-weight: 700; + letter-spacing: -0.01em; min-width: 0; display: flex; align-items: center; gap: 8px; overflow: hidden; + color: var(--lux-ink, var(--text-color)); } .card-title-text { @@ -577,17 +668,18 @@ body.cs-drag-active .card-drag-handle { .device-url-badge { display: inline-flex; align-items: center; - gap: 4px; - font-size: 0.7rem; - font-weight: 400; - color: var(--text-secondary); - background: var(--border-color); + gap: 5px; + font-size: 0.68rem; + font-weight: 500; + color: var(--lux-ink-dim, var(--text-secondary)); + background: var(--lux-bg-0, var(--border-color)); + border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color)); padding: 2px 8px; - border-radius: 10px; - letter-spacing: 0.03em; - font-family: monospace; + border-radius: 2px; + letter-spacing: 0.04em; + font-family: var(--font-mono, monospace); text-decoration: none; - transition: background 0.2s; + transition: background 0.2s, border-color 0.2s, color 0.2s; white-space: nowrap; flex-shrink: 1; overflow: hidden; @@ -600,7 +692,9 @@ body.cs-drag-active .card-drag-handle { } .device-url-badge:hover { - background: var(--text-muted); + background: var(--lux-bg-2, var(--text-muted)); + border-color: var(--lux-line-bold, var(--border-color)); + color: var(--lux-ink, var(--text-color)); } .device-url-icon { @@ -614,17 +708,19 @@ body.cs-drag-active .card-drag-handle { .card-subtitle { display: flex; align-items: center; - gap: 12px; - margin-bottom: 15px; + gap: 8px; + margin-bottom: 14px; flex-wrap: wrap; } .card-meta { - font-size: 0.8rem; - color: var(--text-secondary); + font-family: var(--font-mono, monospace); + font-size: 0.7rem; + color: var(--lux-ink-mute, var(--text-secondary)); display: inline-flex; align-items: center; - gap: 4px; + gap: 5px; + letter-spacing: 0.04em; } .card-meta .icon { diff --git a/server/src/ledgrab/static/css/components.css b/server/src/ledgrab/static/css/components.css index fb89b48..c9dc248 100644 --- a/server/src/ledgrab/static/css/components.css +++ b/server/src/ledgrab/static/css/components.css @@ -23,32 +23,40 @@ .card-actions { display: flex; - gap: 8px; + gap: 6px; margin-top: auto; padding-top: 12px; - border-top: 1px solid var(--border-color); + border-top: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color)); align-items: center; } .card-actions .btn-icon { - padding: 6px 8px; - font-size: 1.1rem; + padding: 7px 10px; + min-width: 36px; + font-size: 0.95rem; } .btn { - padding: 8px 16px; - border: none; - border-radius: var(--radius-sm); + padding: 9px 18px; + border: var(--lux-hairline, 1px) solid transparent; + border-radius: var(--lux-r-sm, var(--radius-sm)); cursor: pointer; - font-size: 0.9rem; + font-family: var(--font-mono, inherit); + font-size: 0.78rem; font-weight: 600; - transition: opacity 0.2s ease, transform 0.15s ease, box-shadow 0.2s ease; + letter-spacing: 0.08em; + text-transform: uppercase; + transition: opacity 0.2s ease, transform 0.15s ease, box-shadow 0.2s ease, filter 0.15s ease; flex: 1 1 auto; min-width: 100px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; } .btn:hover { - opacity: 0.9; + filter: brightness(1.08); } .btn:active:not(:disabled) { @@ -62,30 +70,77 @@ } .btn-primary { - background: var(--primary-color); - color: var(--primary-contrast); + background: var(--ch-signal, var(--primary-color)); + color: var(--lux-bg-0, var(--primary-contrast)); + border-color: var(--ch-signal, var(--primary-color)); + box-shadow: 0 0 14px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 30%, transparent); } .btn-danger { - background: var(--danger-color); - color: white; + background: var(--ch-coral, var(--danger-color)); + color: #fff; + border-color: var(--ch-coral, var(--danger-color)); + box-shadow: 0 0 14px color-mix(in srgb, var(--ch-coral, var(--danger-color)) 30%, transparent); } .btn-secondary { - background: var(--border-color); - color: var(--text-color); + background: var(--lux-bg-2, var(--border-color)); + color: var(--lux-ink-dim, var(--text-color)); + border-color: var(--lux-line-bold, var(--border-color)); +} + +.btn-secondary:hover { + color: var(--lux-ink, var(--text-color)); + background: var(--lux-bg-3, var(--border-color)); } .btn-icon { min-width: auto; - padding: 8px 12px; - font-size: 1.2rem; + padding: 7px 10px; + font-size: 1rem; flex: 0 0 auto; + border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color)); + background: transparent; + color: var(--lux-ink-dim, var(--text-color)); + transition: color 0.15s, border-color 0.15s, background 0.15s, box-shadow 0.15s; } .btn-icon:hover { - transform: scale(1.1); + transform: none; opacity: 1; + color: var(--lux-ink, var(--text-color)); + background: var(--lux-bg-2, var(--bg-secondary)); + border-color: color-mix(in srgb, var(--ch-signal, var(--primary-color)) 35%, var(--lux-line-bold, var(--border-color))); + filter: none; + box-shadow: 0 0 8px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 18%, transparent); +} + +/* Variant: warning / success for enable/disable action buttons. Keep + flat hairline borders; just shift the color + hover glow. */ +.btn-icon.btn-warning { + color: var(--ch-amber, var(--warning-color)); + border-color: color-mix(in srgb, var(--ch-amber, var(--warning-color)) 35%, transparent); + background: transparent; + box-shadow: none; +} +.btn-icon.btn-warning:hover { + background: color-mix(in srgb, var(--ch-amber, var(--warning-color)) 12%, transparent); + color: var(--ch-amber, var(--warning-color)); + border-color: color-mix(in srgb, var(--ch-amber, var(--warning-color)) 50%, transparent); + box-shadow: 0 0 10px color-mix(in srgb, var(--ch-amber, var(--warning-color)) 25%, transparent); +} + +.btn-icon.btn-success { + color: var(--ch-signal, var(--primary-color)); + border-color: color-mix(in srgb, var(--ch-signal, var(--primary-color)) 40%, transparent); + background: transparent; + box-shadow: none; +} +.btn-icon.btn-success:hover { + background: color-mix(in srgb, var(--ch-signal, var(--primary-color)) 14%, transparent); + color: var(--ch-signal, var(--primary-color)); + border-color: color-mix(in srgb, var(--ch-signal, var(--primary-color)) 55%, transparent); + box-shadow: 0 0 12px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 30%, transparent); } .btn-icon:active:not(:disabled) { @@ -161,14 +216,29 @@ input[type="number"], input[type="password"], select { width: 100%; - padding: 10px; - border: 1px solid var(--border-color); - border-radius: var(--radius-sm); - background: var(--bg-color); - color: var(--text-color); - font-size: 1rem; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; - transition: border-color 0.25s ease, box-shadow 0.25s ease, opacity 0.2s ease; + padding: 9px 12px; + border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color)); + border-radius: var(--lux-r-sm, var(--radius-sm)); + background: var(--lux-bg-0, var(--bg-color)); + color: var(--lux-ink, var(--text-color)); + font-size: 0.95rem; + font-family: var(--font-body, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif); + transition: border-color 0.2s ease, box-shadow 0.2s ease, background 0.2s ease, opacity 0.2s ease; +} + +/* Numeric fields use mono for alignment */ +input[type="number"] { + font-family: var(--font-mono, monospace); + font-variant-numeric: tabular-nums; + letter-spacing: 0.02em; +} + +input[type="text"]:hover, +input[type="url"]:hover, +input[type="number"]:hover, +input[type="password"]:hover, +select:hover { + border-color: var(--lux-line-bold, var(--border-color)); } input[type="number"]:disabled, @@ -190,10 +260,14 @@ input[type="password"] { } input:focus, -select:focus { +select:focus, +textarea:focus { outline: none; - border-color: var(--primary-color); - box-shadow: 0 0 0 3px rgba(76, 175, 80, 0.15); + border-color: var(--ch-signal, var(--primary-color)); + box-shadow: + 0 0 0 3px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 18%, transparent), + 0 0 16px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 20%, transparent); + background: var(--lux-bg-1, var(--bg-color)); } /* Inline validation states */ diff --git a/server/src/ledgrab/static/css/dashboard-customize.css b/server/src/ledgrab/static/css/dashboard-customize.css new file mode 100644 index 0000000..8105eba --- /dev/null +++ b/server/src/ledgrab/static/css/dashboard-customize.css @@ -0,0 +1,517 @@ +/* ── Dashboard Customize Panel ── + * Slide-in panel on the right edge. Doesn't cover the full viewport so + * users see live previews of changes as they toggle settings. + */ + +.dash-cust-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.18); + backdrop-filter: blur(2px); + -webkit-backdrop-filter: blur(2px); + z-index: calc(var(--z-modal, 1000) - 5); + opacity: 0; + pointer-events: none; + transition: opacity 200ms ease; +} + +.dash-cust-backdrop.is-open { + opacity: 1; + pointer-events: auto; +} + +.dash-cust-panel { + position: fixed; + top: 60px; /* below transport bar */ + right: 0; + bottom: 0; + width: min(440px, 92vw); + background: var(--lux-bg-1, var(--card-bg)); + border-left: var(--lux-rule, 1px) solid var(--lux-line, var(--border-color)); + box-shadow: var(--lux-shadow-rack, -8px 0 32px rgba(0, 0, 0, 0.35)); + z-index: var(--z-modal, 1000); + transform: translateX(100%); + transition: transform 240ms cubic-bezier(0.2, 0.7, 0.3, 1); + display: flex; + flex-direction: column; + overflow: hidden; +} + +.dash-cust-panel.is-open { + transform: translateX(0); +} + +@media (prefers-reduced-motion: reduce) { + .dash-cust-panel { transition: none; } + .dash-cust-backdrop { transition: none; } +} + +.dash-cust-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 18px; + border-bottom: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color)); + flex: 0 0 auto; +} + +.dash-cust-header h2 { + margin: 0; + font-family: var(--font-display, var(--font-mono, monospace)); + font-size: 0.78rem; + font-weight: 700; + letter-spacing: 0.28em; + text-transform: uppercase; + color: var(--lux-ink, var(--text-color)); + position: relative; +} + +.dash-cust-header h2::after { + content: ''; + position: absolute; + left: 0; + bottom: -8px; + width: 32px; + height: 1px; + background: var(--ch-signal, var(--primary-color)); + box-shadow: 0 0 6px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 50%, transparent); +} + +.dash-cust-close { + background: transparent; + border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color)); + color: var(--lux-ink-dim, var(--text-secondary)); + width: 28px; + height: 28px; + border-radius: 4px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background 150ms ease, color 150ms ease, border-color 150ms ease; +} + +.dash-cust-close:hover { + color: var(--lux-ink, var(--text-color)); + border-color: var(--ch-signal, var(--primary-color)); + background: color-mix(in srgb, var(--ch-signal, var(--primary-color)) 12%, transparent); +} + +.dash-cust-body { + flex: 1 1 auto; + overflow-y: auto; + padding: 14px 16px 18px; + display: flex; + flex-direction: column; + gap: 12px; + /* Prevent scroll chaining: when the panel's scroll reaches its top + * or bottom, the wheel/touch scroll should NOT propagate to the + * underlying dashboard page. */ + overscroll-behavior: contain; +} + +/* Section blocks */ +.dash-cust-section { + display: flex; + flex-direction: column; + gap: 6px; +} + +.dash-cust-section + .dash-cust-section { + margin-top: 4px; +} + +.dash-cust-h3 { + margin: 0 0 2px; + font-family: var(--font-mono, monospace); + font-size: 0.66rem; + font-weight: 700; + letter-spacing: 0.24em; + text-transform: uppercase; + color: var(--lux-ink-dim, var(--text-secondary)); + display: flex; + align-items: center; + gap: 10px; + padding-bottom: 4px; + border-bottom: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color)); +} + +.dash-cust-modified { + font-size: 0.55rem; + letter-spacing: 0.18em; + color: var(--ch-amber, var(--warning-color)); + margin-left: auto; + font-weight: 600; + padding: 1px 6px; + border: 1px solid color-mix(in srgb, var(--ch-amber, var(--warning-color)) 50%, transparent); + border-radius: 2px; +} + +/* Preset chips */ +.dash-cust-chips { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.dash-cust-chip { + background: var(--lux-bg-2, var(--bg-secondary)); + color: var(--lux-ink-dim, var(--text-secondary)); + border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color)); + padding: 6px 12px; + border-radius: 3px; + font-family: var(--font-mono, monospace); + font-size: 0.7rem; + font-weight: 600; + letter-spacing: 0.08em; + cursor: pointer; + transition: background 150ms ease, color 150ms ease, border-color 150ms ease; +} + +.dash-cust-chip:hover { + color: var(--lux-ink, var(--text-color)); + border-color: var(--lux-line-bold, var(--text-secondary)); +} + +.dash-cust-chip.is-active { + background: color-mix(in srgb, var(--ch-signal, var(--primary-color)) 14%, transparent); + color: var(--lux-ink, var(--text-color)); + border-color: var(--ch-signal, var(--primary-color)); + box-shadow: 0 0 0 1px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 25%, transparent), + 0 0 12px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 30%, transparent); +} + +/* Rows + lists */ +.dash-cust-list { + display: flex; + flex-direction: column; + gap: 4px; +} + +.dash-cust-row { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 8px; + background: var(--lux-bg-2, var(--bg-secondary)); + border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color)); + border-radius: 3px; + transition: background 150ms ease, border-color 150ms ease, transform 100ms ease; +} + +.dash-cust-row.is-dragging { + opacity: 0.55; + transform: scale(0.98); +} + +.dash-cust-row.is-drop-target { + border-color: var(--ch-signal, var(--primary-color)); + background: color-mix(in srgb, var(--ch-signal, var(--primary-color)) 8%, transparent); +} + +.dash-cust-row-fixed { + background: color-mix(in srgb, var(--lux-line, var(--border-color)) 30%, transparent); +} + +.dash-cust-row-drag { + cursor: grab; +} + +.dash-cust-row-drag:active { + cursor: grabbing; +} + +.dash-cust-row-label { + flex: 1 1 auto; + font-family: var(--font-body, inherit); + font-size: 0.78rem; + color: var(--lux-ink, var(--text-color)); + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.dash-cust-row-label .dash-cust-pin { + display: inline-flex; + align-items: center; + margin-right: 6px; + color: var(--lux-ink-mute, var(--text-muted)); +} + +.dash-cust-grip { + color: var(--lux-ink-mute, var(--text-muted)); + width: 14px; + height: 14px; + flex: 0 0 14px; + display: flex; + align-items: center; + justify-content: center; +} + +.dash-cust-row-drag:hover .dash-cust-grip { + color: var(--lux-ink, var(--text-color)); +} + +/* Density buttons */ +.dash-cust-density-group { + display: inline-flex; + border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color)); + border-radius: 3px; + overflow: hidden; +} + +.dash-cust-density { + background: transparent; + border: 0; + padding: 2px 6px; + color: var(--lux-ink-dim, var(--text-secondary)); + font-family: var(--font-mono, monospace); + font-size: 0.6rem; + font-weight: 700; + cursor: pointer; + transition: background 150ms ease, color 150ms ease; +} + +.dash-cust-density:not(:last-child) { + border-right: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color)); +} + +.dash-cust-density:hover { + color: var(--lux-ink, var(--text-color)); +} + +.dash-cust-density.is-active { + background: color-mix(in srgb, var(--ch-signal, var(--primary-color)) 18%, transparent); + color: var(--lux-ink, var(--text-color)); +} + +/* Eye / toggle button */ +.dash-cust-eye, .dash-cust-arrow { + background: transparent; + border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color)); + color: var(--lux-ink-mute, var(--text-muted)); + width: 26px; + height: 26px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 3px; + cursor: pointer; + flex: 0 0 26px; + transition: background 150ms ease, color 150ms ease, border-color 150ms ease; +} + +.dash-cust-eye:hover, .dash-cust-arrow:hover { + color: var(--lux-ink, var(--text-color)); + border-color: var(--lux-line-bold, var(--text-secondary)); +} + +.dash-cust-eye.is-on { + color: var(--ch-signal, var(--primary-color)); + border-color: color-mix(in srgb, var(--ch-signal, var(--primary-color)) 50%, var(--lux-line, var(--border-color))); +} + +.dash-cust-arrow.is-active { + color: var(--ch-amber, var(--warning-color)); + border-color: color-mix(in srgb, var(--ch-amber, var(--warning-color)) 50%, var(--lux-line, var(--border-color))); +} + +.dash-cust-arrow { + font-family: var(--font-mono, monospace); + font-size: 0.85rem; + font-weight: 700; +} + +/* Segmented controls (global options) */ +.dash-cust-row .dash-cust-label { + flex: 0 0 auto; + font-family: var(--font-mono, monospace); + font-size: 0.66rem; + letter-spacing: 0.16em; + text-transform: uppercase; + color: var(--lux-ink-dim, var(--text-secondary)); + min-width: 80px; +} + +.dash-cust-seg { + display: inline-flex; + border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color)); + border-radius: 3px; + overflow: hidden; + flex: 1 1 auto; +} + +.dash-cust-seg-btn { + flex: 1 1 auto; + background: transparent; + border: 0; + padding: 5px 8px; + color: var(--lux-ink-dim, var(--text-secondary)); + font-family: var(--font-mono, monospace); + font-size: 0.65rem; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + cursor: pointer; + transition: background 150ms ease, color 150ms ease; +} + +.dash-cust-seg-btn:not(:last-child) { + border-right: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color)); +} + +.dash-cust-seg-btn:hover { + color: var(--lux-ink, var(--text-color)); +} + +.dash-cust-seg-btn.is-active { + background: color-mix(in srgb, var(--ch-signal, var(--primary-color)) 18%, transparent); + color: var(--lux-ink, var(--text-color)); +} + +/* Mini selects (perf cell options). + * The project's components.css applies `select { width: 100%; padding: 9px 12px }` + * globally — we override both with higher specificity so the selects size to + * their content rather than blowing the row out past the panel edge. */ +.dash-cust-panel select.dash-cust-mini-select { + width: auto; + background: var(--lux-bg-1, var(--card-bg)); + border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color)); + color: var(--lux-ink, var(--text-color)); + font-family: var(--font-mono, monospace); + font-size: 0.66rem; + padding: 3px 18px 3px 6px; + border-radius: 3px; + cursor: pointer; + flex: 1 1 auto; + min-width: 0; + height: 24px; + line-height: 1; +} + +.dash-cust-panel select.dash-cust-mini-select:focus { + outline: none; + border-color: var(--ch-signal, var(--primary-color)); +} + +/* Two-line perf-cell row. + * Top line carries the label + reorder + visibility controls so the cell + * name is *always* readable. Bottom line carries the per-cell options + * (mode / window / scale) labelled with tiny mono captions. */ +.dash-cust-cell-row { + flex-direction: column; + align-items: stretch; + gap: 6px; + padding: 8px 10px; +} + +.dash-cust-cell-top { + display: flex; + align-items: center; + gap: 8px; +} + +.dash-cust-cell-opts { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 6px; +} + +.dash-cust-cell-opt { + display: flex; + align-items: center; + gap: 4px; + min-width: 0; +} + +.dash-cust-cell-opt-k { + font-family: var(--font-mono, monospace); + font-size: 0.55rem; + font-weight: 700; + letter-spacing: 0.16em; + text-transform: uppercase; + color: var(--lux-ink-mute, var(--text-muted)); + flex: 0 0 auto; +} + +/* Help / actions */ +.dash-cust-help { + margin: 0; + font-size: 0.65rem; + color: var(--lux-ink-mute, var(--text-muted)); + font-style: italic; +} + +.dash-cust-actions { + border-top: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color)); + padding-top: 14px; + flex-direction: row; + flex-wrap: wrap; + gap: 8px; +} + +.dash-cust-actions .btn { + font-size: 0.7rem; + padding: 6px 12px; +} + +/* Width-mode hooks: applied to dashboard-content, not the panel */ +#dashboard-content[data-layout-width="centered"] { + max-width: 1280px; + margin-left: auto; + margin-right: auto; +} + +#dashboard-content[data-layout-width="narrow"] { + max-width: 960px; + margin-left: auto; + margin-right: auto; +} + +#dashboard-content[data-layout-anim="off"] *, +#dashboard-content[data-layout-anim="off"] *::before, +#dashboard-content[data-layout-anim="off"] *::after { + animation-duration: 0ms !important; + transition-duration: 0ms !important; +} + +#dashboard-content[data-layout-anim="reduced"] *, +#dashboard-content[data-layout-anim="reduced"] *::before, +#dashboard-content[data-layout-anim="reduced"] *::after { + animation-duration: 60ms !important; + transition-duration: 80ms !important; +} + +/* Density variants per section */ +.dashboard-section[data-density="compact"] .dashboard-section-content { + gap: 10px; +} + +.dashboard-section[data-density="compact"] .dashboard-section-header { + margin-bottom: 10px; + padding-bottom: 6px; +} + +.dashboard-section[data-density="dense"] .dashboard-section-content { + gap: 6px; +} + +.dashboard-section[data-density="dense"] .dashboard-section-header { + margin-bottom: 6px; + padding-bottom: 4px; + font-size: 0.72rem; +} + +.dashboard-section[data-density="dense"] .dashboard-target { + padding: 8px 10px; +} + +/* Mobile collapse */ +@media (max-width: 720px) { + .dash-cust-panel { + top: 56px; + width: 100vw; + max-width: 100vw; + } +} diff --git a/server/src/ledgrab/static/css/dashboard.css b/server/src/ledgrab/static/css/dashboard.css index df774e9..82c9d4a 100644 --- a/server/src/ledgrab/static/css/dashboard.css +++ b/server/src/ledgrab/static/css/dashboard.css @@ -5,16 +5,30 @@ } .dashboard-section-header { - font-size: 0.8rem; - font-weight: 600; - margin-bottom: 6px; - color: var(--text-secondary); + font-family: var(--font-mono, monospace); + font-size: 0.82rem; + font-weight: 700; + margin-bottom: 16px; + color: var(--lux-ink-dim, var(--text-secondary)); display: flex; align-items: center; - gap: 6px; + gap: 12px; text-transform: uppercase; - letter-spacing: 0.5px; + letter-spacing: 0.25em; user-select: none; + padding-bottom: 10px; + border-bottom: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color)); + position: relative; +} + +.dashboard-section-header::before { + content: ''; + position: absolute; + left: 0; bottom: -1px; + width: 40px; + height: 1px; + background: var(--ch-signal, var(--primary-color)); + box-shadow: 0 0 6px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 50%, transparent); } .dashboard-section-toggle { display: flex; @@ -34,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; } @@ -50,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 { @@ -91,27 +112,417 @@ } .dashboard-target { + --ch: var(--ch-signal, var(--primary-color)); position: relative; overflow: hidden; display: grid; grid-template-columns: 1fr auto auto; align-items: center; gap: 12px; - padding: 6px 12px; - background: var(--card-bg); - border: 1px solid var(--border-color); - border-radius: 6px; + padding: 10px 14px 10px 18px; + 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; + 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: ''; + position: absolute; + left: 0; top: 0; bottom: 0; + width: 3px; + 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, 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: 0 2px 8px var(--shadow-color); + box-shadow: var(--lux-shadow-rack, 0 2px 8px var(--shadow-color)); + border-color: var(--lux-line-bold, var(--border-color)); + transform: translateY(-1px); +} + +.dashboard-target:hover::before { + opacity: 1; + box-shadow: 0 0 14px color-mix(in srgb, var(--ch) 70%, transparent); } .dashboard-card-link:hover { - border-color: var(--primary-color); - box-shadow: 0 4px 12px var(--shadow-color); + border-color: color-mix(in srgb, var(--ch) 40%, var(--lux-line, var(--border-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); +} + +.dashboard-target.is-running::before { + opacity: 1; + width: 4px; + box-shadow: + 0 0 14px color-mix(in srgb, var(--ch) 70%, transparent), + 0 0 4px color-mix(in srgb, var(--ch) 90%, transparent); +} + +.dashboard-target:has(.mod-head).is-running::after { + top: auto; right: auto; + left: 4px; bottom: 0; + width: calc(100% - 4px); + height: 2px; + border: none; + opacity: 0.7; + background: linear-gradient(90deg, + transparent 0%, + color-mix(in srgb, var(--ch) 85%, transparent) 50%, + transparent 100%); + 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)); + display: inline-flex; + align-items: center; + gap: 4px; +} + +/* Label-row icon sizing — the status glyph (clock/check/warning) sits + next to the mono caps label, not the big value. Tiny + muted so it + reads as an accent, not a competing element. */ +.mod-metric .k > .icon, +.mod-metric .k > svg { + width: 10px; + height: 10px; + flex-shrink: 0; + opacity: 0.85; +} + +/* Errors cell: the label icon shifts to coral when there are errors + (matching the big-value tint). */ +.mod-metric.has-errors .k, +.mod-metric.has-errors .k > .icon, +.mod-metric.has-errors .k > svg { + color: var(--ch-coral, var(--danger-color)); + opacity: 1; +} + +.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); +} + +/* Errors cell — coral tint when > 0 so non-zero counts draw the eye + without needing an inline glyph that'd eat horizontal space. */ +.mod-metric .v.has-errors { + color: var(--ch-coral, var(--danger-color)); +} + +.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; + 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 { @@ -134,14 +545,16 @@ } .dashboard-target-name { - font-size: 0.85rem; + font-size: 0.88rem; font-weight: 600; + letter-spacing: -0.005em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; display: flex; align-items: center; gap: 4px; + color: var(--lux-ink, var(--text-color)); } .dashboard-target-name-text { @@ -159,11 +572,14 @@ } .dashboard-target-subtitle { - font-size: 0.7rem; - color: var(--text-secondary); + font-family: var(--font-mono, monospace); + font-size: 0.64rem; + letter-spacing: 0.05em; + color: var(--lux-ink-mute, var(--text-secondary)); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + margin-top: 2px; } .dashboard-target-metrics { @@ -179,19 +595,22 @@ } .dashboard-metric-value { - font-size: 0.8rem; - font-weight: 600; - color: var(--primary-text-color); - line-height: 1.2; font-family: var(--font-mono, monospace); + font-size: 0.82rem; + font-weight: 600; + color: var(--ch, var(--primary-text-color)); + line-height: 1.2; white-space: nowrap; + font-variant-numeric: tabular-nums; + letter-spacing: 0.02em; } .dashboard-metric-label { - font-size: 0.6rem; - color: var(--text-secondary); + font-family: var(--font-mono, monospace); + font-size: 0.55rem; + color: var(--lux-ink-mute, var(--text-secondary)); text-transform: uppercase; - letter-spacing: 0.3px; + letter-spacing: 0.15em; } .dashboard-fps-metric { @@ -329,36 +748,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; @@ -372,13 +791,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; } @@ -386,123 +825,96 @@ /* ── Per-metric accent colors ── */ .perf-chart-card { - --perf-accent: var(--primary-color); - --perf-accent-glow: transparent; - background: var(--card-bg); - border: 1px solid var(--border-color); - border-top: 3px solid var(--perf-accent); - border-radius: 6px; - padding: 10px 0 0; + --perf-accent: var(--ch-signal, var(--primary-color)); + --perf-accent-glow: color-mix(in srgb, var(--perf-accent) 18%, transparent); + background: transparent; + border: none; + border-radius: 0; + padding: 0; min-width: 0; position: relative; overflow: hidden; - transition: box-shadow var(--duration-normal) ease; + display: flex; + flex-direction: column; + transition: background var(--duration-normal) ease; +} + +/* 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; right: 0; top: 0; + height: 2px; + background: var(--perf-accent); + box-shadow: 0 0 10px color-mix(in srgb, var(--perf-accent) 50%, transparent); + opacity: 0.85; + z-index: 2; +} + +/* 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: 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.5; + pointer-events: none; } .perf-chart-card:hover { - box-shadow: 0 0 16px var(--perf-accent-glow); + background: color-mix(in srgb, var(--perf-accent) 4%, transparent); } .perf-chart-card[data-metric="cpu"] { - --perf-accent: #FF6B6B; - --perf-accent-glow: rgba(255, 107, 107, 0.12); + --perf-accent: var(--ch-coral, #FF6B6B); } .perf-chart-card[data-metric="ram"] { - --perf-accent: #A855F7; - --perf-accent-glow: rgba(168, 85, 247, 0.12); + --perf-accent: var(--ch-violet, #A855F7); } .perf-chart-card[data-metric="gpu"] { - --perf-accent: #10B981; - --perf-accent-glow: rgba(16, 185, 129, 0.12); + --perf-accent: var(--ch-signal, #10B981); } -.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; +.perf-chart-card[data-metric="temp"] { + --perf-accent: var(--ch-amber, #FFB800); } +/* ── 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-size: 0.75rem; + font-family: var(--font-mono, monospace); + font-size: 0.58rem; font-weight: 600; text-transform: uppercase; - letter-spacing: 0.3px; - 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.85rem; - font-weight: 700; - color: var(--perf-accent); - font-family: var(--font-mono, monospace); - display: flex; - align-items: baseline; - gap: 6px; -} - -/* 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; @@ -510,6 +922,468 @@ 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 pill when there's nothing to show (host-only metrics, or a + mode/state that has no app variant). Avoids a ghost bordered box in + the top-right corner. */ +.perf-chart-app:empty { display: none; } + +/* ── Spark hover tooltip (single floating element, reused across all + cards; positioned via JS; inline layout reads like an instrument + readout). ── */ +.perf-chart-tooltip { + position: fixed; + display: none; + z-index: var(--z-toast, 3500); + 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-sm, 3px); + box-shadow: + 0 0 0 1px rgba(255, 255, 255, 0.02), + 0 8px 24px rgba(0, 0, 0, 0.4); + padding: 8px 10px 6px; + pointer-events: none; + font-family: var(--font-mono, monospace); + font-size: 0.72rem; + color: var(--lux-ink, var(--text-color)); + letter-spacing: 0.02em; + line-height: 1.3; + min-width: 110px; +} + +.perf-chart-tooltip .perf-tip-row { + display: flex; + align-items: center; + gap: 8px; +} +.perf-chart-tooltip .perf-tip-dot { + width: 8px; + height: 8px; + border-radius: 2px; + flex-shrink: 0; + box-shadow: 0 0 4px currentColor; +} +.perf-chart-tooltip .perf-tip-dot-app { + background: transparent; + border: var(--lux-hairline, 1px) solid currentColor; + box-shadow: none; +} +.perf-chart-tooltip .perf-tip-k { + color: var(--lux-ink-mute, var(--text-secondary)); + font-size: 0.62rem; + font-weight: 600; + letter-spacing: 0.16em; + text-transform: uppercase; + margin-right: auto; +} +.perf-chart-tooltip .perf-tip-v { + color: var(--lux-ink, var(--text-color)); + font-variant-numeric: tabular-nums; + font-weight: 700; +} +.perf-chart-tooltip .perf-tip-app .perf-tip-v { + color: var(--lux-ink-dim, var(--text-secondary)); + font-weight: 500; +} +.perf-chart-tooltip .perf-tip-age { + margin-top: 4px; + padding-top: 4px; + border-top: var(--lux-hairline, 1px) dashed var(--lux-line, var(--border-color)); + font-size: 0.6rem; + color: var(--lux-ink-mute, var(--text-secondary)); + letter-spacing: 0.14em; + text-transform: uppercase; + text-align: right; +} + +/* Vertical marker line over the spark at cursor x. */ +.perf-chart-tooltip-marker { + position: fixed; + display: none; + width: 1px; + pointer-events: none; + background: var(--marker-color, var(--ch-signal, var(--primary-color))); + opacity: 0.55; + z-index: calc(var(--z-toast, 3500) - 1); + box-shadow: 0 0 6px var(--marker-color, var(--ch-signal, var(--primary-color))); +} + +/* 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. + Pointer events stay enabled so hover tooltips work; the SVG itself + is non-interactive via `perf-chart-svg` below. */ +.perf-chart-spark { + position: relative; + margin-top: auto; + height: 42px; + padding: 0 18px 14px; + cursor: crosshair; + filter: drop-shadow(0 0 5px color-mix(in srgb, var(--perf-accent) 45%, transparent)); +} + +.perf-chart-spark .perf-chart-svg { + pointer-events: none; +} + +.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; +} + +/* Target-FPS ceiling suffix — "/ 120" next to the big live number, sized + down + muted so the live value remains the primary reading. Matches + the "/ 12" style from the Active Patches cell. */ +.perf-chart-card[data-metric="fps"] .perf-fps-ceiling { + font-family: var(--font-mono, monospace); + font-size: 0.38em; + font-weight: 500; + color: var(--lux-ink-mute, var(--text-secondary)); + letter-spacing: 0.04em; + margin-left: 4px; + 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; @@ -522,33 +1396,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/fonts.css b/server/src/ledgrab/static/css/fonts.css index d5dcbd7..0bcc938 100644 --- a/server/src/ledgrab/static/css/fonts.css +++ b/server/src/ledgrab/static/css/fonts.css @@ -1,6 +1,7 @@ /* Local font faces — no external CDN dependency */ -/* DM Sans — latin-ext */ +/* ── DM Sans (legacy body font — kept during redesign transition) ── */ + @font-face { font-family: 'DM Sans'; font-style: normal; @@ -10,7 +11,6 @@ unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; } -/* DM Sans — latin */ @font-face { font-family: 'DM Sans'; font-style: normal; @@ -20,7 +20,8 @@ unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; } -/* Orbitron 700 — latin */ +/* ── Orbitron (brand mark only) ── */ + @font-face { font-family: 'Orbitron'; font-style: normal; @@ -29,3 +30,99 @@ src: url('../fonts/orbitron-700-latin.woff2') format('woff2'); unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; } + +/* ── Manrope — new primary body font (variable, 200..800) ── + Covers Latin, Latin-ext, Cyrillic, Cyrillic-ext. CJK falls through to + system stack via the body font-family cascade. */ + +@font-face { + font-family: 'Manrope'; + font-style: normal; + font-weight: 200 800; + font-display: swap; + src: url('../fonts/manrope-cyrillic-ext.woff2') format('woff2'); + unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F; +} +@font-face { + font-family: 'Manrope'; + font-style: normal; + font-weight: 200 800; + font-display: swap; + src: url('../fonts/manrope-cyrillic.woff2') format('woff2'); + unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; +} +@font-face { + font-family: 'Manrope'; + font-style: normal; + font-weight: 200 800; + font-display: swap; + src: url('../fonts/manrope-latin-ext.woff2') format('woff2'); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} +@font-face { + font-family: 'Manrope'; + font-style: normal; + font-weight: 200 800; + font-display: swap; + src: url('../fonts/manrope-latin.woff2') format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +/* ── JetBrains Mono — new monospace (variable, 100..800) ── + Used for technical labels, badges, metrics, code. Cyrillic-capable so + badge text (`CH·01 · WLED`) reads in RU locale. */ + +@font-face { + font-family: 'JetBrains Mono'; + font-style: normal; + font-weight: 100 800; + font-display: swap; + src: url('../fonts/jetbrains-mono-cyrillic-ext.woff2') format('woff2'); + unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F; +} +@font-face { + font-family: 'JetBrains Mono'; + font-style: normal; + font-weight: 100 800; + font-display: swap; + src: url('../fonts/jetbrains-mono-cyrillic.woff2') format('woff2'); + unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; +} +@font-face { + font-family: 'JetBrains Mono'; + font-style: normal; + font-weight: 100 800; + font-display: swap; + src: url('../fonts/jetbrains-mono-latin-ext.woff2') format('woff2'); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} +@font-face { + font-family: 'JetBrains Mono'; + font-style: normal; + font-weight: 100 800; + font-display: swap; + src: url('../fonts/jetbrains-mono-latin.woff2') format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +/* ── Big Shoulders Display — new display font (variable, 100..900) ── + Reserved for huge numeric readouts on the dashboard hero + module metric + cells. Latin + Latin-ext only; Cyrillic numerics would rarely occur in + that position so the system stack is an acceptable fallback. */ + +@font-face { + font-family: 'Big Shoulders Display'; + font-style: normal; + font-weight: 100 900; + font-display: swap; + src: url('../fonts/big-shoulders-display-latin-ext.woff2') format('woff2'); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} +@font-face { + font-family: 'Big Shoulders Display'; + font-style: normal; + font-weight: 100 900; + font-display: swap; + src: url('../fonts/big-shoulders-display-latin.woff2') format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} diff --git a/server/src/ledgrab/static/css/graph-editor.css b/server/src/ledgrab/static/css/graph-editor.css index b861a59..36a8e74 100644 --- a/server/src/ledgrab/static/css/graph-editor.css +++ b/server/src/ledgrab/static/css/graph-editor.css @@ -31,11 +31,15 @@ html:has(#tab-graph.active) { display: flex; gap: 4px; z-index: 20; - background: var(--card-bg); - border: 1px solid var(--border-color); - border-radius: 8px; + 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-bold, var(--border-color)); + border-radius: var(--lux-r-md, 6px); padding: 4px; - box-shadow: 0 2px 8px var(--shadow-color); + box-shadow: var(--lux-shadow-rack, 0 2px 8px var(--shadow-color)); + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); } .graph-toolbar-drag { @@ -406,8 +410,8 @@ html:has(#tab-graph.active) { /* ── Grid background ── */ .graph-grid-dot { - fill: var(--border-color); - opacity: 0.3; + fill: var(--lux-line, var(--border-color)); + opacity: 0.32; } /* ── Node styles ── */ @@ -427,20 +431,23 @@ html:has(#tab-graph.active) { .graph-node-body { fill: var(--card-bg); - stroke: none; - rx: 8; - ry: 8; - transition: stroke 0.15s; + stroke: var(--lux-line, var(--border-color)); + stroke-width: 1; + rx: 6; + ry: 6; + transition: stroke 0.15s, stroke-width 0.15s, filter 0.2s ease; } .graph-node:hover .graph-node-body { - stroke: var(--text-secondary); + stroke: var(--lux-line-bold, var(--text-secondary)); stroke-width: 1; + filter: drop-shadow(0 4px 14px rgba(0, 0, 0, 0.25)); } .graph-node.selected .graph-node-body { - stroke: var(--primary-color); + stroke: var(--ch-signal, var(--primary-color)); stroke-width: 2; + filter: drop-shadow(0 0 8px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 50%, transparent)); } .graph-node-color-bar { @@ -455,37 +462,45 @@ html:has(#tab-graph.active) { } .graph-node-title { - fill: var(--text-color); + fill: var(--lux-ink, var(--text-color)); font-size: 12px; font-weight: 600; - font-family: 'DM Sans', sans-serif; + /* Body font, not display — Big Shoulders is condensed and reads as + * "stretched" at 12 px in a node label. Display font is for hero + * headers only. */ + font-family: var(--font-body, 'Manrope', 'DM Sans', sans-serif); + letter-spacing: 0; } .graph-node-subtitle { - fill: var(--text-secondary); - font-size: 10px; - font-family: 'DM Sans', sans-serif; + fill: var(--lux-ink-dim, var(--text-secondary)); + font-size: 9.5px; + font-weight: 600; + font-family: var(--font-mono, monospace); + letter-spacing: 0.04em; + text-transform: uppercase; } .graph-node-icon { - stroke: var(--text-muted); + stroke: var(--lux-ink-mute, var(--text-muted)); fill: none; - stroke-width: 2; + stroke-width: 1.5; stroke-linecap: round; stroke-linejoin: round; - opacity: 0.5; + opacity: 0.55; } .graph-node.running .graph-node-icon { - stroke: var(--primary-color); - opacity: 0.85; + stroke: var(--ch-signal, var(--primary-color)); + opacity: 0.95; } -/* ── Running indicator (animated gradient border) ── */ +/* ── Running indicator (animated gradient border + signal-flow glow) ── */ .graph-node.running .graph-node-body { stroke: url(#running-gradient); stroke-width: 2; + filter: drop-shadow(0 0 12px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 50%, transparent)); } @keyframes graph-running-rotate { @@ -530,13 +545,16 @@ html:has(#tab-graph.active) { /* Port labels — hidden by default, shown on node hover, positioned outside node */ .graph-port-label { font-size: 9px; - font-weight: 600; - fill: var(--text-color); + font-weight: 700; + font-family: var(--font-mono, monospace); + letter-spacing: 0.08em; + text-transform: uppercase; + fill: var(--lux-ink-dim, var(--text-color)); pointer-events: none; opacity: 0; transition: opacity 0.15s; paint-order: stroke fill; - stroke: var(--bg-color); + stroke: var(--lux-bg-0, var(--bg-color)); stroke-width: 3px; stroke-linejoin: round; } @@ -565,9 +583,9 @@ html:has(#tab-graph.active) { .graph-port-drop-target { r: 7 !important; - stroke: var(--primary-color) !important; + stroke: var(--ch-signal, var(--primary-color)) !important; stroke-width: 3 !important; - filter: drop-shadow(0 0 6px var(--primary-color)); + filter: drop-shadow(0 0 6px var(--ch-signal, var(--primary-color))); } /* ── Edges ── */ diff --git a/server/src/ledgrab/static/css/layout.css b/server/src/ledgrab/static/css/layout.css index 4d984a6..03f7674 100644 --- a/server/src/ledgrab/static/css/layout.css +++ b/server/src/ledgrab/static/css/layout.css @@ -1,47 +1,271 @@ +:root { + --transport-height: 60px; +} + header { - display: flex; - justify-content: space-between; + display: grid; + grid-template-columns: var(--sidebar-width, 248px) 1fr auto auto; align-items: center; - padding: 8px 20px; + height: var(--transport-height, 60px); + padding: 0 16px 0 0; position: sticky; top: 0; z-index: var(--z-sticky); - background: var(--bg-color); - border-bottom: 2px solid var(--border-color); + background: linear-gradient(180deg, + var(--lux-bg-1, var(--bg-color)) 0%, + var(--lux-bg-0, var(--bg-color)) 100%); + border-bottom: var(--lux-hairline, 1px) solid var(--lux-line-bold, var(--border-color)); +} + +/* Accent rule — subtle bottom glow under the transport bar. + Uses ::before because ::after is reserved by base.css for the + ambient-background blur overlay. */ +header::before { + content: ''; + position: absolute; + left: 0; right: 0; bottom: -1px; + height: 1px; + background: linear-gradient(90deg, + transparent 0%, + color-mix(in srgb, var(--ch-signal, var(--primary-color)) 30%, transparent) 15%, + color-mix(in srgb, var(--ch-cyan, var(--primary-color)) 25%, transparent) 50%, + color-mix(in srgb, var(--ch-magenta, var(--primary-color)) 20%, transparent) 85%, + transparent 100%); + opacity: 0.8; + pointer-events: none; + z-index: 1; } .header-title { display: flex; align-items: center; - gap: 8px; + gap: 10px; + padding: 0 18px; + height: 100%; + border-right: var(--lux-hairline, 1px) solid var(--lux-line-bold, var(--border-color)); + position: relative; +} + +/* 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: 28px; + height: 28px; + flex-shrink: 0; + border-radius: 4px; + background: var(--ch-signal, var(--primary-color)); + 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); + position: relative; + animation: brandPulse 4s ease-in-out infinite; +} +.header-title::after { + content: ''; + position: absolute; + left: calc(18px + 8px); + top: 50%; + transform: translateY(-50%); + width: 12px; + height: 12px; + background: var(--lux-bg-0, var(--bg-color)); + 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: 1.15rem; - font-weight: 700; - letter-spacing: 0.06em; + 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 / + quick-search shortcut. Populated by JS in Phase 3; empty for now. ── */ +.transport-center { + display: flex; + align-items: center; + gap: 8px; + padding: 0 18px; + font-family: var(--font-mono, monospace); + font-size: 0.7rem; + color: var(--lux-ink-dim, var(--text-secondary)); + min-width: 0; + overflow: hidden; +} + +.transport-status { + display: inline-flex; + align-items: center; + 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-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, 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)) 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: 7px; height: 7px; + border-radius: 50%; + background: 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)); + box-shadow: none; + 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 { @@ -53,18 +277,17 @@ h2 { .header-toolbar { display: flex; align-items: center; - gap: 2px; - background: var(--card-bg); - border: 1px solid var(--border-color); - border-radius: 8px; - padding: 3px 4px; + gap: 4px; + background: transparent; + border: none; + padding: 0; } .header-toolbar-sep { width: 1px; - height: 18px; - background: var(--border-color); - margin: 0 3px; + height: 20px; + background: var(--lux-line, var(--border-color)); + margin: 0 6px; flex-shrink: 0; } @@ -156,14 +379,16 @@ h2 { } #server-version { - font-family: 'Orbitron', sans-serif; - font-size: 0.65rem; - font-weight: 700; - color: var(--text-secondary); - background: var(--border-color); - padding: 2px 8px; - border-radius: 10px; - letter-spacing: 0.03em; + font-family: var(--font-mono, 'Orbitron', sans-serif); + font-size: 0.55rem; + font-weight: 600; + color: var(--lux-ink-mute, var(--text-secondary)); + background: transparent; + border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color)); + padding: 2px 6px; + border-radius: 2px; + letter-spacing: 0.12em; + text-transform: uppercase; transition: background 0.3s, color 0.3s, box-shadow 0.3s; } @@ -270,17 +495,27 @@ h2 { to { transform: translateY(0); opacity: 1; } } +/* #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 { - font-size: 1rem; - animation: pulse 2s infinite; + display: none; } -.status-badge.online { - color: var(--primary-color); +/* 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 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; } - -.status-badge.offline { - color: var(--danger-color); +.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 { @@ -441,7 +676,8 @@ h2 { color: var(--danger-color); } -/* Tabs */ +/* ── Tabs (base styles; sidebar.css re-specializes for vertical rail; + mobile.css reverts to a fixed bottom bar on phones) ── */ .tab-bar { display: flex; align-items: center; @@ -458,7 +694,10 @@ h2 { cursor: pointer; border-bottom: 2px solid transparent; margin-bottom: -2px; - transition: color 0.2s ease, border-color 0.25s ease; + transition: color 0.2s ease, border-color 0.25s ease, background 0.2s ease; + display: inline-flex; + align-items: center; + gap: 8px; } .tab-btn:hover { @@ -524,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 */ @@ -682,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; } @@ -692,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; @@ -703,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); } @@ -710,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 { @@ -731,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 { @@ -782,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 { @@ -818,36 +1106,100 @@ 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 + the sidebar hides entirely and mobile.css reverts .tab-bar to a fixed + bottom strip. */ @media (max-width: 1100px) { - .tab-btn { - padding: 10px 10px; + /* Keep all four header children (title | center | meta | toolbar) on one + row. Without an explicit 4th track they wrap, doubling the header. */ + header { + grid-template-columns: var(--sidebar-width, 56px) auto 1fr auto; } - - .tab-btn > span[data-i18n] { + .header-title { + padding: 0 10px; + justify-content: center; + gap: 0; + } + .header-title h1, + #server-version, + .header-title::after { + display: none; + } + .transport-center { + padding: 0 10px; + } + /* Tighter meta cluster — drop the trailing separator and shrink gaps */ + .transport-meta { + gap: 10px; + padding: 0 4px 0 8px; + justify-content: flex-end; + } + .transport-meta .meta-sep:last-child { + display: none; + } + /* Tighter toolbar so it fits beside the meta cluster */ + .header-toolbar { + gap: 2px; + } + .header-toolbar-sep { + margin: 0 2px; + } + /* Hide secondary header items at narrow widths to free room */ + .header-link, + #tour-restart-btn { display: none; } - .tab-btn .icon { - width: 20px; - height: 20px; - } -} - -@media (max-width: 900px) { - header { - flex-direction: column; - gap: 8px; - text-align: center; - } - .container { - padding: 12px; + padding: 16px; + } +} + +/* Tablet/phone shoulder: the meta cluster still wants ~280px which collides + with the toolbar below 900px. Drop CPU + Mem cells (Uptime + Poll stay, + they're the most useful at-a-glance signals). */ +@media (max-width: 900px) { + #transport-cpu, + #transport-mem { + display: none; + } + .transport-meta .meta-cell:has(#transport-cpu), + .transport-meta .meta-cell:has(#transport-mem) { + display: none; + } + .transport-meta > .meta-sep:nth-of-type(1) { + display: none; + } +} + +@media (max-width: 600px) { + header { + grid-template-columns: auto 1fr auto; + padding: 0 8px 0 0; + } + .header-title { + padding: 0 10px; + border-right: none; + } + .transport-center { + display: none; + } + /* Below the phone breakpoint the sidebar vanishes and the bottom tab + bar takes over, so most of the meta cluster goes too. */ + .transport-meta { + display: none; + } + .container { + padding: 10px; } } diff --git a/server/src/ledgrab/static/css/mobile.css b/server/src/ledgrab/static/css/mobile.css index 9a0996e..cb5ff38 100644 --- a/server/src/ledgrab/static/css/mobile.css +++ b/server/src/ledgrab/static/css/mobile.css @@ -158,53 +158,122 @@ display: none; } - /* ── Bottom Tab Bar ── */ + /* ── Bottom Tab Bar — Lumenworks mobile shell ── */ + .sidebar .tab-bar, .tab-bar { position: fixed; bottom: 0; left: 0; right: 0; z-index: var(--z-sticky); - background: var(--card-bg); + background: linear-gradient(180deg, + var(--lux-bg-1, var(--card-bg)) 0%, + var(--lux-bg-0, var(--card-bg)) 100%); border-bottom: none; - border-top: 1px solid var(--border-color); + border-top: var(--lux-hairline, 1px) solid var(--lux-line-bold, var(--border-color)); margin-bottom: 0; display: flex; + flex-direction: row; flex-wrap: nowrap; justify-content: space-around; padding: 0; padding-bottom: env(safe-area-inset-bottom, 0px); - box-shadow: 0 -2px 8px var(--shadow-color); + box-shadow: 0 -2px 12px rgba(0, 0, 0, 0.3); gap: 0; + width: auto; + height: auto; + overflow: visible; + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); } + /* Top channel-accent rule — matches the transport bar bottom rule so + the two bars feel like bookends of the mobile layout. */ + .sidebar .tab-bar::before, + .tab-bar::before { + content: ''; + position: absolute; + left: 0; right: 0; top: 0; + height: 1px; + background: linear-gradient(90deg, + transparent 0%, + color-mix(in srgb, var(--ch-signal, var(--primary-color)) 28%, transparent) 15%, + color-mix(in srgb, var(--ch-cyan, var(--info-color)) 24%, transparent) 50%, + color-mix(in srgb, var(--ch-magenta, #ff4ade) 20%, transparent) 85%, + transparent 100%); + opacity: 0.8; + pointer-events: none; + } + + .sidebar .tab-btn, .tab-btn { flex: 1; display: flex; flex-direction: column; align-items: center; - gap: 2px; - padding: 8px 4px 6px; - font-size: 0.65rem; + justify-content: center; + gap: 3px; + padding: 7px 4px 6px; + font-family: var(--font-mono, inherit); + font-size: 0.55rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--lux-ink-mute, var(--text-secondary)); border-bottom: none; border-top: 2px solid transparent; + border-radius: 0; + background: transparent; margin-bottom: 0; position: relative; + grid-template-columns: none; + box-shadow: none; } + .sidebar .tab-btn.active, .tab-btn.active { + color: var(--ch-signal, var(--primary-color)); border-bottom-color: transparent; - border-top-color: var(--primary-color); + border-top-color: var(--ch-signal, var(--primary-color)); + background: linear-gradient(180deg, + color-mix(in srgb, var(--ch-signal, var(--primary-color)) 14%, transparent) 0%, + transparent 60%); + box-shadow: none; } + /* LED pip above the icon on the active tab (replaces the left-stripe + since the sidebar's box-shadow doesn't carry here). */ + .sidebar .tab-btn.active::before, + .tab-btn.active::before { + content: ''; + position: absolute; + top: 4px; left: 50%; + transform: translateX(-50%); + width: 4px; + height: 4px; + border-radius: 50%; + background: var(--ch-signal, var(--primary-color)); + box-shadow: 0 0 6px var(--ch-signal, var(--primary-color)); + } + + .sidebar .tab-btn .icon, .tab-btn .icon { width: 20px; height: 20px; display: block; + color: inherit; } + .sidebar .tab-btn.active .icon, + .tab-btn.active .icon { + color: var(--ch-signal, var(--primary-color)); + } + + .sidebar .tab-btn > span[data-i18n], .tab-btn > span[data-i18n] { - font-size: 0.6rem; + font-family: var(--font-mono, inherit); + font-size: 0.55rem; + letter-spacing: 0.08em; + text-transform: uppercase; line-height: 1.2; max-width: 100%; overflow: hidden; @@ -215,13 +284,19 @@ /* Tab badge repositioned to top-right of icon */ .tab-badge { position: absolute; - top: 2px; - right: calc(50% - 18px); - font-size: 0.55rem; + top: 6px; + right: calc(50% - 20px); + font-family: var(--font-mono, monospace); + font-size: 0.48rem; + font-weight: 700; padding: 0 4px; - min-width: 14px; - line-height: 1.2; + min-width: 12px; + line-height: 1.3; margin-left: 0; + background: var(--ch-signal, var(--primary-color)); + color: var(--lux-bg-0, var(--primary-contrast)); + border-radius: 2px; + letter-spacing: 0.02em; } /* Body padding for fixed bottom bar */ @@ -276,6 +351,12 @@ margin: 0; } + /* Hide the bottom-right corner bracket on fullscreen mobile modals — + there's no "panel" to decorate. Top channel rule stays. */ + .modal-content::after { + display: none; + } + .modal-content-wide { min-width: 0; width: 100%; @@ -284,11 +365,16 @@ } .modal-header { - padding: 12px 14px 10px; + padding: 14px 14px 12px 20px; + } + + .modal-header::before { + left: 8px; + height: 18px; } .modal-header h2 { - font-size: 1.15rem; + font-size: 1.05rem; } .modal-body { diff --git a/server/src/ledgrab/static/css/modal.css b/server/src/ledgrab/static/css/modal.css index ceb6e6a..8985ad4 100644 --- a/server/src/ledgrab/static/css/modal.css +++ b/server/src/ledgrab/static/css/modal.css @@ -6,12 +6,15 @@ left: 0; width: 100%; height: 100%; - background: rgba(0, 0, 0, 0.8); + background: radial-gradient(1200px 800px at 50% 40%, + rgba(0, 0, 0, 0.7) 0%, + rgba(0, 0, 0, 0.88) 100%); z-index: var(--z-modal); align-items: center; justify-content: center; animation: fadeIn 0.2s ease-out; - backdrop-filter: blur(2px); + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); } /* Confirm dialog must stack above all other modals */ @@ -784,18 +787,103 @@ } .modal-content { - background: var(--card-bg); - border: 1px solid var(--border-color); - border-radius: var(--radius-lg); + --modal-ch: var(--ch-signal, var(--primary-color)); + 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-bold, var(--border-color)); + border-radius: var(--lux-r-lg, var(--radius-lg)); max-width: 500px; width: 90%; max-height: calc(100vh - 40px); display: flex; flex-direction: column; - box-shadow: 0 8px 32px var(--shadow-color); + 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); animation: slideUp 0.3s cubic-bezier(0.16, 1, 0.3, 1); + position: relative; + overflow: hidden; } +/* Channel accent rule across the top edge of every modal. Type-specific + modals can override `--modal-ch` to get a different stripe color. */ +.modal-content::before { + content: ''; + position: absolute; + left: 0; right: 0; top: 0; + height: 2px; + background: linear-gradient(90deg, + transparent 0%, + var(--modal-ch) 20%, + var(--modal-ch) 80%, + transparent 100%); + opacity: 0.9; + box-shadow: 0 0 12px color-mix(in srgb, var(--modal-ch) 50%, transparent); + pointer-events: none; + z-index: 2; +} + +/* Corner bracket — silkscreened panel feel, bottom-right this time so + it doesn't clash with the header-actions row in the top-right. */ +.modal-content::after { + content: ''; + position: absolute; + right: 10px; bottom: 10px; + width: 12px; height: 12px; + border-bottom: 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.5; + pointer-events: none; + z-index: 1; +} + +/* Per-modal channel colors — map well-known modal IDs to channel lanes. + Modals not listed keep the default green stripe. */ +#target-editor-modal .modal-content, +#add-device-modal .modal-content, +#device-settings-modal .modal-content, +#ha-light-editor-modal .modal-content, +#calibration-modal .modal-content { --modal-ch: var(--ch-signal, var(--primary-color)); } + +#stream-modal .modal-content, +#test-stream-modal .modal-content, +#capture-template-modal .modal-content, +#test-template-modal .modal-content, +#pp-template-modal .modal-content, +#test-pp-template-modal .modal-content, +#cspt-modal .modal-content, +#css-editor-modal .modal-content, +#test-css-source-modal .modal-content, +#pattern-template-modal .modal-content, +#gradient-editor-modal .modal-content, +#value-source-editor-modal .modal-content, +#test-value-source-modal .modal-content, +#asset-editor-modal .modal-content, +#asset-upload-modal .modal-content, +#ha-source-editor-modal .modal-content, +#mqtt-source-editor-modal .modal-content, +#sync-clock-editor-modal .modal-content, +#weather-source-editor-modal .modal-content { --modal-ch: var(--ch-cyan, var(--info-color)); } + +#audio-source-editor-modal .modal-content, +#audio-template-modal .modal-content, +#audio-processing-template-modal .modal-content, +#test-audio-source-modal .modal-content, +#test-audio-template-modal .modal-content { --modal-ch: var(--ch-magenta, #ff4ade); } + +#automation-editor-modal .modal-content, +#scene-preset-editor-modal .modal-content, +#game-integration-editor-modal .modal-content { --modal-ch: var(--ch-violet, #8b7eff); } + +#settings-modal .modal-content, +#api-key-modal .modal-content, +#setup-required-modal .modal-content, +#notification-history-modal .modal-content { --modal-ch: var(--ch-amber, var(--warning-color)); } + +#confirm-modal .modal-content { --modal-ch: var(--ch-coral, var(--danger-color)); } + #template-modal .modal-content { max-width: 500px !important; width: 100% !important; @@ -817,21 +905,42 @@ } .modal-header { - padding: 24px 24px 16px 24px; - border-bottom: 1px solid var(--border-color); + padding: 22px 24px 14px 24px; + border-bottom: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color)); display: flex; align-items: center; justify-content: space-between; + position: relative; +} + +/* Tiny channel-color square to the left of the title, consistent with + the sidebar's section-label marker. */ +.modal-header::before { + content: ''; + position: absolute; + left: 12px; + top: 50%; + transform: translateY(-50%); + width: 3px; + height: 22px; + background: var(--modal-ch, var(--ch-signal, var(--primary-color))); + border-radius: 2px; + box-shadow: 0 0 10px color-mix(in srgb, var(--modal-ch, var(--ch-signal, var(--primary-color))) 50%, transparent); + opacity: 0.8; } .modal-header h2 { margin: 0; - font-size: 1.5rem; - color: var(--text-color); + font-family: var(--font-body, inherit); + font-size: 1.15rem; + font-weight: 700; + letter-spacing: -0.01em; + color: var(--lux-ink, var(--text-color)); overflow: hidden; white-space: nowrap; text-overflow: ellipsis; min-width: 0; + padding-left: 4px; } .modal-header-actions { @@ -1191,10 +1300,14 @@ } .modal-footer { - padding: 16px 24px 24px 24px; + padding: 16px 24px 20px 24px; display: flex; justify-content: flex-end; gap: 8px; + border-top: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color)); + background: linear-gradient(180deg, + transparent 0%, + color-mix(in srgb, var(--lux-bg-0, transparent) 30%, transparent) 100%); } .modal-footer .btn-icon { diff --git a/server/src/ledgrab/static/css/patterns.css b/server/src/ledgrab/static/css/patterns.css index 5ecc5b2..5d483a8 100644 --- a/server/src/ledgrab/static/css/patterns.css +++ b/server/src/ledgrab/static/css/patterns.css @@ -42,21 +42,30 @@ } .stream-card-prop { - display: inline-block; - font-size: 0.75rem; - color: var(--text-secondary); - background: var(--border-color); - padding: 2px 8px; - border-radius: 10px; + display: inline-flex; + align-items: center; + gap: 5px; + font-family: var(--font-mono, monospace); + font-size: 0.68rem; + color: var(--lux-ink-dim, var(--text-secondary)); + background: var(--lux-bg-0, var(--border-color)); + border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color)); + padding: 3px 8px; + border-radius: 2px; + letter-spacing: 0.04em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; - max-width: 180px; + max-width: 220px; vertical-align: middle; + line-height: 1.3; } .stream-card-prop .icon { - color: var(--primary-text-color); + color: var(--ch-signal, var(--primary-color)); + width: 11px; + height: 11px; + flex-shrink: 0; } .stream-card-prop-full { @@ -65,18 +74,19 @@ white-space: nowrap; overflow: hidden; text-overflow: ellipsis; - font-size: 0.7rem; + font-size: 0.66rem; } .stream-card-link { cursor: pointer; text-decoration: none; - transition: background 0.2s, color 0.2s; + transition: background 0.2s, color 0.2s, border-color 0.2s; } .stream-card-link:hover { - background: var(--primary-color); - color: var(--primary-contrast); + background: color-mix(in srgb, var(--ch-signal, var(--primary-color)) 15%, transparent); + color: var(--lux-ink, var(--text-color)); + border-color: color-mix(in srgb, var(--ch-signal, var(--primary-color)) 40%, var(--lux-line, var(--border-color))); } .stream-card-link:hover .icon { @@ -84,15 +94,31 @@ } @keyframes cardHighlight { - 0%, 100% { box-shadow: none; } - 25%, 75% { box-shadow: 0 0 0 3px var(--primary-color), 0 0 20px rgba(var(--primary-rgb, 59, 130, 246), 0.3); } + 0%, 100% { + box-shadow: + 0 0 0 0 color-mix(in srgb, var(--ch-signal, var(--primary-color)) 0%, transparent), + 0 0 0 0 transparent; + } + 25%, 75% { + box-shadow: + 0 0 0 2px var(--ch-signal, var(--primary-color)), + 0 0 32px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 55%, transparent), + 0 0 10px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 80%, transparent); + } } .card-highlight, -.template-card.card-highlight { - animation: cardHighlight 2s ease-in-out; +.template-card.card-highlight, +.dashboard-target.card-highlight { + animation: cardHighlight 2.2s ease-in-out; position: relative; z-index: 11; + /* Nudge the card forward during the highlight so the outer glow + isn't clipped by a containing overflow: hidden (strip cells, + tree-nav panels). Box-shadow is never clipped by the element's + own overflow but *is* clipped by parent overflow in stacking + contexts where the card doesn't escape. */ + isolation: isolate; } /* Dim overlay behind highlighted card */ diff --git a/server/src/ledgrab/static/css/sidebar.css b/server/src/ledgrab/static/css/sidebar.css new file mode 100644 index 0000000..18497d6 --- /dev/null +++ b/server/src/ledgrab/static/css/sidebar.css @@ -0,0 +1,314 @@ +/* ── Lumenworks sidebar (channel-strip nav) ───────────────────── + Primary navigation for desktop/tablet. Contains the 6 top-level + tabs (.tab-bar kept for JS compatibility with switchTab), a live + meter plate at the bottom, and collapses to a 56px icon rail + between 1100px and 600px. On phones (<=600px) the sidebar hides + entirely and mobile.css reverts .tab-bar to a fixed bottom strip. + ──────────────────────────────────────────────────────────────── */ + +/* ── App shell: header on top, 2-column body below ── */ + +.app-body { + display: grid; + grid-template-columns: var(--sidebar-width, 248px) 1fr; + gap: 0; + align-items: stretch; + min-height: calc(100vh - var(--transport-height, 64px)); +} + +.app-main { + min-width: 0; /* allow children to shrink instead of overflow */ + position: relative; +} + +/* ── Sidebar container ── */ + +.sidebar { + position: sticky; + top: var(--transport-height, 64px); + height: calc(100vh - var(--transport-height, 64px)); + overflow-y: auto; + overflow-x: hidden; + scrollbar-width: thin; + border-right: var(--lux-hairline) solid var(--lux-line-bold, var(--border-color)); + background: linear-gradient(180deg, var(--lux-bg-1, var(--bg-secondary)) 0%, var(--lux-bg-0, var(--bg-color)) 100%); + padding: 16px 0 20px; + display: flex; + flex-direction: column; + gap: 20px; + z-index: calc(var(--z-sticky) - 1); +} + +.sidebar-section { + padding: 0 12px; +} + +.sidebar-label { + display: flex; + align-items: center; + gap: 8px; + font-family: var(--font-mono, monospace); + font-size: 0.55rem; + letter-spacing: 0.25em; + text-transform: uppercase; + color: var(--lux-ink-mute, var(--text-secondary)); + padding: 0 8px 8px; + border-bottom: 1px dashed var(--lux-line, var(--border-color)); + margin-bottom: 8px; + font-weight: 500; +} +.sidebar-label::before { content: '['; color: var(--lux-ink-faint, var(--text-muted)); } +.sidebar-label::after { content: ']'; color: var(--lux-ink-faint, var(--text-muted)); margin-left: auto; } + +/* ── Tab-bar (kept as vertical nav inside sidebar on desktop) ── */ + +.sidebar .tab-bar { + display: flex; + flex-direction: column; + align-items: stretch; + gap: 1px; + width: 100%; +} + +.sidebar .tab-btn { + display: grid; + grid-template-columns: 18px 1fr auto; + gap: 12px; + align-items: center; + padding: 9px 10px; + margin: 0; + background: transparent; + border: none; + border-radius: var(--lux-r-sm, 3px); + font-family: var(--font-mono, monospace); + font-size: 0.82rem; + font-weight: 500; + letter-spacing: 0.04em; + color: var(--lux-ink-dim, var(--text-secondary)); + cursor: pointer; + position: relative; + text-align: left; + transition: color 0.15s ease, background 0.15s ease, box-shadow 0.15s ease; +} + +.sidebar .tab-btn:hover { + color: var(--lux-ink, var(--text-color)); + background: var(--lux-bg-2, var(--bg-secondary)); + border-bottom-color: transparent; +} + +.sidebar .tab-btn.active { + color: var(--lux-ink, var(--text-color)); + background: linear-gradient(90deg, + color-mix(in srgb, var(--ch-signal, var(--primary-color)) 14%, transparent) 0%, + transparent 80%); + box-shadow: inset 2px 0 0 var(--ch-signal, var(--primary-color)); + border-bottom-color: transparent; +} + +.sidebar .tab-btn.active::before { + content: ''; + position: absolute; + left: 0; top: 50%; + transform: translateY(-50%); + width: 2px; + height: 60%; + background: var(--ch-signal, var(--primary-color)); + box-shadow: var(--lux-signal-glow, 0 0 6px currentColor); + border-radius: 2px; +} + +.sidebar .tab-btn .icon { + width: 16px; + height: 16px; + color: inherit; + flex-shrink: 0; +} +.sidebar .tab-btn.active .icon { + color: var(--ch-signal, var(--primary-color)); +} + +.sidebar .tab-btn > span[data-i18n] { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sidebar .tab-btn .tab-badge { + 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.62rem; + font-weight: 600; + padding: 1px 7px; + border-radius: 10px; + min-width: 20px; + line-height: 1.4; + text-align: center; +} +.sidebar .tab-btn.active .tab-badge { + background: var(--ch-signal, var(--primary-color)); + color: var(--lux-bg-0, #000); +} + +/* ── Sidebar footer: live CPU/FPS meter plate ── */ + +.sidebar-foot { + margin-top: auto; + padding: 14px 20px 8px; + border-top: 1px dashed var(--lux-line, var(--border-color)); + display: flex; + flex-direction: column; + gap: 10px; +} + +.cpu-meter { + display: flex; + flex-direction: column; + gap: 10px; + font-family: var(--font-mono, monospace); +} + +.cpu-meter-row { + display: flex; + justify-content: space-between; + align-items: baseline; + font-size: 0.58rem; + letter-spacing: 0.15em; + text-transform: uppercase; + color: var(--lux-ink-mute, var(--text-secondary)); +} + +.cpu-meter-row b { + color: var(--lux-ink, var(--text-color)); + font-weight: 500; + font-variant-numeric: tabular-nums; + font-size: 0.7rem; + letter-spacing: 0; +} + +.cpu-bar { + height: 3px; + background: var(--lux-bg-3, var(--border-color)); + border-radius: 2px; + overflow: hidden; + position: relative; +} + +.cpu-bar > i { + display: block; + height: 100%; + background: linear-gradient(90deg, var(--ch-signal, var(--primary-color)), var(--ch-cyan, var(--info-color))); + box-shadow: 0 0 6px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 50%, transparent); + transition: width 0.4s ease; + width: 0; +} + +.cpu-bar-fps > i { + background: linear-gradient(90deg, var(--ch-cyan, var(--info-color)), var(--ch-magenta, #ff4ade)); + box-shadow: 0 0 6px color-mix(in srgb, var(--ch-cyan, var(--info-color)) 50%, transparent); +} + +.sidebar-version { + font-family: var(--font-mono, monospace); + font-size: 0.55rem; + letter-spacing: 0.2em; + text-transform: uppercase; + color: var(--lux-ink-faint, var(--text-muted)); + text-align: center; + padding-top: 6px; + border-top: 1px dashed var(--lux-line, var(--border-color)); +} + +/* ── Responsive: icon rail at tablet-desktop, hidden at phone ── */ + +@media (max-width: 1100px) { + :root { --sidebar-width: 56px; } + + .sidebar { + padding: 14px 0 20px; + gap: 16px; + } + .sidebar-section { + padding: 0 6px; + } + .sidebar-label, + .sidebar-version { + display: none; + } + .sidebar-foot { + padding: 10px 6px; + } + + .sidebar .tab-btn { + grid-template-columns: 1fr; + padding: 10px 2px; + justify-content: center; + justify-items: center; + gap: 3px; + } + /* Two-line caption with tight tracking — single-line ellipsis truncates + longer labels like "Automations"/"Integrations" to "AUTOMA…" which + isn't recoverable; two short lines are uglier per word but legible. */ + .sidebar .tab-btn > span[data-i18n] { + font-size: 0.46rem; + letter-spacing: 0.02em; + line-height: 1.1; + text-transform: uppercase; + color: inherit; + max-width: 100%; + white-space: normal; + overflow-wrap: anywhere; + text-align: center; + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + } + .sidebar .tab-btn .icon { + width: 20px; + height: 20px; + } + .sidebar .tab-btn .tab-badge { + position: absolute; + top: 4px; + right: 4px; + font-size: 0.55rem; + min-width: 14px; + padding: 0 4px; + } + + .cpu-meter-row { + font-size: 0.48rem; + letter-spacing: 0.08em; + } + .cpu-meter-row b { + font-size: 0.6rem; + } +} + +@media (max-width: 600px) { + /* On phones, sidebar disappears and mobile.css reverts .tab-bar to + a fixed bottom strip. The .app-body grid becomes a single column. */ + :root { --sidebar-width: 0px; } + + .app-body { + grid-template-columns: 1fr; + } + .sidebar { + /* Hide sidebar chrome; .tab-bar inside still gets fixed-bottom + styling from mobile.css regardless of its container. */ + position: static; + height: auto; + border-right: none; + padding: 0; + background: transparent; + overflow: visible; + display: contents; + } + .sidebar-foot, + .sidebar-label { + display: none !important; + } +} diff --git a/server/src/ledgrab/static/css/streams.css b/server/src/ledgrab/static/css/streams.css index 223c8b7..b2b86ed 100644 --- a/server/src/ledgrab/static/css/streams.css +++ b/server/src/ledgrab/static/css/streams.css @@ -9,19 +9,63 @@ } .template-card { + --ch: var(--ch-cyan, var(--info-color)); /* default channel — overridden per data-attr below */ background: var(--card-bg); - border: 1px solid var(--border-color); - border-radius: var(--radius-md); - padding: 16px; - transition: box-shadow 0.2s ease, transform 0.2s ease; + 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; + transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease; display: flex; flex-direction: column; position: relative; + overflow: hidden; +} + +/* Channel stripe on left edge — opt-in only (mirrors .card::before in + * cards.css). Idle template-cards without a custom color stay clean. + * The Add card never gets a stripe (it's not an entity). */ +.template-card::before { + content: ''; + position: absolute; + left: 0; top: 0; bottom: 0; + width: 3px; + background: var(--ch); + box-shadow: 0 0 10px color-mix(in srgb, var(--ch) 40%, transparent); + pointer-events: none; + z-index: 1; + transition: width 0.2s ease, box-shadow 0.2s ease; + display: none; +} + +.template-card[data-has-color="1"]::before, +.template-card.card-running::before { + display: block; +} + +.add-template-card::before { display: none !important; } + +/* Corner bracket — silkscreened panel feel in the top-right */ +.template-card::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; + z-index: 1; } .template-card:hover { - box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15); + box-shadow: var(--lux-shadow-rack, 0 8px 24px var(--shadow-color)); transform: translateY(-2px); + border-color: var(--lux-line-bold, var(--border-color)); +} + +.template-card:hover::before { + width: 4px; + box-shadow: 0 0 14px color-mix(in srgb, var(--ch) 70%, transparent); } .add-template-card { @@ -93,13 +137,19 @@ } .badge { - padding: 4px 8px; - border-radius: 4px; - font-size: 0.75rem; - font-weight: bold; + display: inline-flex; + align-items: center; + padding: 2px 8px; + border-radius: 2px; + border: var(--lux-hairline, 1px) solid transparent; + font-family: var(--font-mono, inherit); + font-size: 0.62rem; + font-weight: 700; + letter-spacing: 0.12em; text-transform: uppercase; white-space: nowrap; flex-shrink: 0; + line-height: 1.4; } .template-description { @@ -606,36 +656,44 @@ body.pp-filter-dragging .pp-filter-drag-handle { background: none; border: none; padding: 8px 14px; - font-size: 0.9rem; - font-weight: 500; - color: var(--text-secondary); + font-family: var(--font-mono, inherit); + font-size: 0.72rem; + font-weight: 600; + color: var(--lux-ink-mute, var(--text-secondary)); cursor: pointer; border-bottom: 2px solid transparent; + text-transform: uppercase; + letter-spacing: 0.12em; transition: color 0.2s ease, border-color 0.25s ease; } .stream-tab-btn:hover { - color: var(--text-color); + color: var(--lux-ink, var(--text-color)); } .stream-tab-btn.active { - color: var(--primary-text-color); - border-bottom-color: var(--primary-color); + color: var(--ch-signal, var(--primary-color)); + border-bottom-color: var(--ch-signal, var(--primary-color)); + text-shadow: 0 0 12px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 30%, transparent); } .stream-tab-count { - background: var(--border-color); - color: var(--text-secondary); - font-size: 0.7rem; - 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); + font-size: 0.56rem; + font-weight: 700; padding: 1px 6px; - border-radius: 8px; + border-radius: 2px; margin-left: 4px; + letter-spacing: 0.04em; + font-variant-numeric: tabular-nums; } .stream-tab-btn.active .stream-tab-count { - background: var(--primary-color); - color: var(--primary-contrast); + background: var(--ch-signal, var(--primary-color)); + color: var(--lux-bg-0, var(--primary-contrast)); + box-shadow: 0 0 6px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 40%, transparent); } .cs-expand-collapse-group { @@ -685,11 +743,26 @@ body.pp-filter-dragging .pp-filter-drag-handle { } .subtab-section-header { - font-size: 1rem; - font-weight: 600; - color: var(--text-secondary); - margin: 0 0 12px 0; - padding-bottom: 8px; + font-family: var(--font-mono, monospace); + font-size: 0.82rem; + font-weight: 700; + color: var(--lux-ink-dim, var(--text-secondary)); + margin: 0 0 16px 0; + padding-bottom: 10px; + text-transform: uppercase; + letter-spacing: 0.25em; + border-bottom: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color)); + position: relative; +} + +.subtab-section-header::before { + content: ''; + position: absolute; + left: 0; bottom: -1px; + width: 48px; + height: 1px; + background: var(--ch-signal, var(--primary-color)); + box-shadow: 0 0 6px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 50%, transparent); } .subtab-section-header.cs-header { @@ -731,13 +804,16 @@ body.pp-filter-dragging .pp-filter-drag-handle { } .cs-count { - background: var(--border-color); - color: var(--text-secondary); - border-radius: 10px; - padding: 0 7px; - font-size: 0.75rem; + background: var(--lux-bg-3, var(--border-color)); + color: var(--lux-ink-dim, var(--text-secondary)); + border-radius: 2px; + padding: 2px 7px; + font-family: var(--font-mono, monospace); + font-size: 0.6rem; font-weight: 600; flex-shrink: 0; + font-variant-numeric: tabular-nums; + letter-spacing: 0.04em; } .cs-collapsed .cs-filter-wrap, diff --git a/server/src/ledgrab/static/css/tree-nav.css b/server/src/ledgrab/static/css/tree-nav.css index e370e16..5fe4d6f 100644 --- a/server/src/ledgrab/static/css/tree-nav.css +++ b/server/src/ledgrab/static/css/tree-nav.css @@ -22,30 +22,53 @@ min-width: 0; } -/* ── Trigger bar ── */ +/* ── Trigger bar — module selector pill ── */ .tree-dd-trigger { display: inline-flex; align-items: center; - gap: 6px; - padding: 5px 10px; + gap: 8px; + padding: 7px 12px; cursor: pointer; - border: 1px solid var(--border-color); - border-radius: 8px; - background: var(--bg-secondary); + border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color)); + border-radius: var(--lux-r-sm, 3px); + background: var(--lux-bg-1, var(--bg-secondary)); user-select: none; - font-size: 0.82rem; - color: var(--text-color); - transition: border-color 0.15s, background 0.15s; + font-family: var(--font-mono, monospace); + font-size: 0.72rem; + font-weight: 500; + letter-spacing: 0.06em; + color: var(--lux-ink, var(--text-color)); + transition: border-color 0.15s, background 0.15s, box-shadow 0.15s; + position: relative; + overflow: hidden; +} + +/* Channel stripe on the left edge of the trigger */ +.tree-dd-trigger::before { + content: ''; + position: absolute; + left: 0; top: 0; bottom: 0; + width: 2px; + background: var(--ch-signal, var(--primary-color)); + opacity: 0.4; + transition: opacity 0.15s, box-shadow 0.15s; } .tree-dd-trigger:hover { - border-color: var(--primary-color); - background: color-mix(in srgb, var(--primary-color) 6%, var(--bg-secondary)); + border-color: var(--lux-line-bold, var(--border-color)); + background: var(--lux-bg-2, var(--bg-secondary)); +} + +.tree-dd-trigger:hover::before, +.tree-dd-trigger.open::before { + opacity: 1; + box-shadow: 0 0 8px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 60%, transparent); } .tree-dd-trigger.open { - border-color: var(--primary-color); + border-color: color-mix(in srgb, var(--ch-signal, var(--primary-color)) 35%, var(--lux-line, var(--border-color))); + background: var(--lux-bg-2, var(--bg-secondary)); } .tree-dd-trigger-icon { @@ -60,18 +83,24 @@ .tree-dd-trigger-title { font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.1em; white-space: nowrap; + color: var(--lux-ink, var(--text-color)); } .tree-dd-trigger-count { - background: var(--primary-color); - color: var(--primary-contrast); - font-size: 0.6rem; - font-weight: 600; - padding: 0 5px; - border-radius: 8px; - min-width: 16px; + background: var(--ch-signal, var(--primary-color)); + color: var(--lux-bg-0, var(--primary-contrast)); + font-family: var(--font-mono, monospace); + font-size: 0.55rem; + font-weight: 700; + padding: 1px 6px; + border-radius: 2px; + min-width: 18px; text-align: center; + letter-spacing: 0.04em; + box-shadow: 0 0 6px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 40%, transparent); } .tree-dd-chevron { @@ -94,24 +123,43 @@ padding-left: 8px; } -/* ── Dropdown panel ── */ +/* ── Dropdown panel — rack-selector popover ── */ .tree-dd-panel { display: none; position: absolute; top: 100%; left: 0; - min-width: 240px; - max-width: 340px; + min-width: 260px; + max-width: 360px; max-height: 70vh; overflow-y: auto; - background: var(--bg-color); - border: 1px solid var(--border-color); - border-radius: 8px; - box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25); + background: linear-gradient(180deg, + var(--lux-bg-1, var(--bg-color)) 0%, + var(--lux-bg-2, var(--bg-color)) 100%); + border: var(--lux-hairline, 1px) solid var(--lux-line-bold, var(--border-color)); + border-radius: var(--lux-r-md, 6px); + box-shadow: var(--lux-shadow-rack, 0 8px 24px rgba(0, 0, 0, 0.25)); z-index: 100; - padding: 4px 0; - margin-top: 4px; + padding: 6px 0; + margin-top: 6px; + scrollbar-width: thin; +} + +/* Channel accent rule at the top of the panel */ +.tree-dd-panel::before { + content: ''; + position: absolute; + left: 0; right: 0; top: 0; + height: 1px; + 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.5; + pointer-events: none; } .tree-dd-panel.open { @@ -123,14 +171,26 @@ .tree-dd-group-header { display: flex; align-items: center; - gap: 6px; - padding: 6px 12px 3px; - font-size: 0.68rem; - font-weight: 700; - color: var(--text-muted); + gap: 8px; + padding: 8px 14px 4px; + font-family: var(--font-mono, monospace); + font-size: 0.56rem; + font-weight: 600; + color: var(--lux-ink-mute, var(--text-muted)); text-transform: uppercase; - letter-spacing: 0.04em; + letter-spacing: 0.22em; user-select: none; + position: relative; +} + +/* Small square dot prefix — reads like a silkscreened section marker. */ +.tree-dd-group-header::before { + content: ''; + width: 4px; + height: 4px; + background: var(--lux-ink-faint, var(--text-muted)); + border-radius: 1px; + flex-shrink: 0; } .tree-dd-group-header.tree-dd-depth-1 { @@ -184,12 +244,15 @@ .tree-dd-leaf { display: flex; align-items: center; - gap: 6px; - padding: 5px 12px 5px 20px; + gap: 8px; + padding: 7px 14px 7px 22px; cursor: pointer; - font-size: 0.8rem; - color: var(--text-secondary); - transition: color 0.1s, background 0.1s; + font-family: var(--font-body, inherit); + font-size: 0.82rem; + font-weight: 500; + color: var(--lux-ink-dim, var(--text-secondary)); + transition: color 0.1s, background 0.1s, box-shadow 0.1s; + position: relative; } /* Indent leaves inside nested groups */ @@ -203,19 +266,38 @@ } .tree-dd-leaf:hover { - color: var(--text-color); - background: var(--bg-secondary); + color: var(--lux-ink, var(--text-color)); + background: var(--lux-bg-3, var(--bg-secondary)); } +/* Active leaf: LED pip on the left + channel glow + brighter text */ .tree-dd-leaf.active { - color: var(--primary-text-color); - background: color-mix(in srgb, var(--primary-color) 12%, transparent); + color: var(--lux-ink, var(--text-color)); + background: linear-gradient(90deg, + color-mix(in srgb, var(--ch-signal, var(--primary-color)) 14%, transparent) 0%, + transparent 80%); font-weight: 600; + box-shadow: inset 2px 0 0 var(--ch-signal, var(--primary-color)); +} + +.tree-dd-leaf.active::before { + content: ''; + position: absolute; + left: 8px; + top: 50%; + transform: translateY(-50%); + width: 5px; + height: 5px; + border-radius: 50%; + background: var(--ch-signal, var(--primary-color)); + box-shadow: 0 0 6px var(--ch-signal, var(--primary-color)); + animation: pulse 2s ease-in-out infinite; } .tree-dd-leaf.active .tree-count { - background: var(--primary-color); - color: var(--primary-contrast); + background: var(--ch-signal, var(--primary-color)); + color: var(--lux-bg-0, var(--primary-contrast)); + box-shadow: 0 0 6px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 50%, transparent); } .tree-dd-leaf .tree-node-icon { @@ -238,22 +320,26 @@ /* ── Count badge (shared) ── */ -.tree-count { - background: var(--border-color); - color: var(--text-secondary); - font-size: 0.6rem; +.tree-count, +.tree-dd-group-count { + background: var(--lux-bg-3, var(--border-color)); + color: var(--lux-ink-dim, var(--text-secondary)); + font-family: var(--font-mono, monospace); + font-size: 0.56rem; font-weight: 600; - padding: 0 5px; - border-radius: 8px; + padding: 1px 6px; + border-radius: 2px; flex-shrink: 0; - min-width: 16px; + min-width: 18px; text-align: center; + letter-spacing: 0.04em; + font-variant-numeric: tabular-nums; } -/* ── Group separator ── */ +/* ── Group separator — hairline-dashed between top-level groups ── */ .tree-dd-group + .tree-dd-group { - border-top: 1px solid var(--border-color); - margin-top: 2px; - padding-top: 2px; + border-top: 1px dashed var(--lux-line, var(--border-color)); + margin-top: 4px; + padding-top: 4px; } diff --git a/server/src/ledgrab/static/fonts/big-shoulders-display-latin-ext.woff2 b/server/src/ledgrab/static/fonts/big-shoulders-display-latin-ext.woff2 new file mode 100644 index 0000000..b6f519d Binary files /dev/null and b/server/src/ledgrab/static/fonts/big-shoulders-display-latin-ext.woff2 differ diff --git a/server/src/ledgrab/static/fonts/big-shoulders-display-latin.woff2 b/server/src/ledgrab/static/fonts/big-shoulders-display-latin.woff2 new file mode 100644 index 0000000..d0bb518 Binary files /dev/null and b/server/src/ledgrab/static/fonts/big-shoulders-display-latin.woff2 differ diff --git a/server/src/ledgrab/static/fonts/jetbrains-mono-cyrillic-ext.woff2 b/server/src/ledgrab/static/fonts/jetbrains-mono-cyrillic-ext.woff2 new file mode 100644 index 0000000..8d83f99 Binary files /dev/null and b/server/src/ledgrab/static/fonts/jetbrains-mono-cyrillic-ext.woff2 differ diff --git a/server/src/ledgrab/static/fonts/jetbrains-mono-cyrillic.woff2 b/server/src/ledgrab/static/fonts/jetbrains-mono-cyrillic.woff2 new file mode 100644 index 0000000..8ee2d70 Binary files /dev/null and b/server/src/ledgrab/static/fonts/jetbrains-mono-cyrillic.woff2 differ diff --git a/server/src/ledgrab/static/fonts/jetbrains-mono-latin-ext.woff2 b/server/src/ledgrab/static/fonts/jetbrains-mono-latin-ext.woff2 new file mode 100644 index 0000000..01769d9 Binary files /dev/null and b/server/src/ledgrab/static/fonts/jetbrains-mono-latin-ext.woff2 differ diff --git a/server/src/ledgrab/static/fonts/jetbrains-mono-latin.woff2 b/server/src/ledgrab/static/fonts/jetbrains-mono-latin.woff2 new file mode 100644 index 0000000..cd5102a Binary files /dev/null and b/server/src/ledgrab/static/fonts/jetbrains-mono-latin.woff2 differ diff --git a/server/src/ledgrab/static/fonts/manrope-cyrillic-ext.woff2 b/server/src/ledgrab/static/fonts/manrope-cyrillic-ext.woff2 new file mode 100644 index 0000000..57c7c0e Binary files /dev/null and b/server/src/ledgrab/static/fonts/manrope-cyrillic-ext.woff2 differ diff --git a/server/src/ledgrab/static/fonts/manrope-cyrillic.woff2 b/server/src/ledgrab/static/fonts/manrope-cyrillic.woff2 new file mode 100644 index 0000000..3a06229 Binary files /dev/null and b/server/src/ledgrab/static/fonts/manrope-cyrillic.woff2 differ diff --git a/server/src/ledgrab/static/fonts/manrope-latin-ext.woff2 b/server/src/ledgrab/static/fonts/manrope-latin-ext.woff2 new file mode 100644 index 0000000..bd24140 Binary files /dev/null and b/server/src/ledgrab/static/fonts/manrope-latin-ext.woff2 differ diff --git a/server/src/ledgrab/static/fonts/manrope-latin.woff2 b/server/src/ledgrab/static/fonts/manrope-latin.woff2 new file mode 100644 index 0000000..71eb731 Binary files /dev/null and b/server/src/ledgrab/static/fonts/manrope-latin.woff2 differ diff --git a/server/src/ledgrab/static/js/app.ts b/server/src/ledgrab/static/js/app.ts index e31c193..0c4348f 100644 --- a/server/src/ledgrab/static/js/app.ts +++ b/server/src/ledgrab/static/js/app.ts @@ -48,6 +48,12 @@ import { dashboardPauseClock, dashboardResumeClock, dashboardResetClock, toggleDashboardSection, changeDashboardPollInterval, } from './features/dashboard.ts'; +import { + hydrateDashboardLayoutFromCache, syncDashboardLayoutFromServer, +} from './features/dashboard-layout.ts'; +import { + openDashboardCustomize, closeDashboardCustomize, +} from './features/dashboard-customize.ts'; import { startEventsWS, stopEventsWS } from './core/events-ws.ts'; import { startEntityEventListeners } from './core/entity-events.ts'; import { @@ -213,6 +219,7 @@ import { connectLogViewer, disconnectLogViewer, clearLogViewer, applyLogFilter, openLogOverlay, closeLogOverlay, loadLogLevel, setLogLevel, + loadShutdownAction, setShutdownAction, saveExternalUrl, getBaseOrigin, loadExternalUrl, } from './features/settings.ts'; import { @@ -294,6 +301,8 @@ Object.assign(window, { // dashboard loadDashboard, + openDashboardCustomize, + closeDashboardCustomize, dashboardToggleAutomation, dashboardStartTarget, dashboardStopTarget, @@ -607,6 +616,8 @@ Object.assign(window, { closeLogOverlay, loadLogLevel, setLogLevel, + loadShutdownAction, + setShutdownAction, saveExternalUrl, getBaseOrigin, @@ -692,6 +703,11 @@ document.addEventListener('DOMContentLoaded', async () => { // Load API key from localStorage before anything that triggers API calls setApiKey(localStorage.getItem('ledgrab_api_key')); + // Hydrate dashboard layout from localStorage cache so the first paint + // already reflects the user's saved customizations (no flash of + // default-then-custom). Server sync runs after auth. + hydrateDashboardLayoutFromCache(); + // Initialize locale (dispatches languageChanged which may trigger API calls) await initLocale(); @@ -786,6 +802,11 @@ document.addEventListener('DOMContentLoaded', async () => { loadDisplays(); loadTargetsTab(); + // Pull the server-side dashboard layout (per-account, follows user + // across browsers). Fire-and-forget — the cached layout is already + // active; this overwrites it if the server has a newer copy. + syncDashboardLayoutFromServer(); + // Trigger the active tab's loader — initTabs() ran before authRequired // was known, so its conditional loader call may have been skipped. const activeTab = localStorage.getItem('activeTab') || 'dashboard'; @@ -797,6 +818,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/api.ts b/server/src/ledgrab/static/js/core/api.ts index 8c74ac4..5f596ca 100644 --- a/server/src/ledgrab/static/js/core/api.ts +++ b/server/src/ledgrab/static/js/core/api.ts @@ -305,6 +305,19 @@ export async function loadServerInfo() { if (data.repo_url) serverRepoUrl = data.repo_url; if (data.donate_url) serverDonateUrl = data.donate_url; + // Seed the transport-bar uptime ticker with the server's actual + // uptime. Survives page reloads and tracks the *server* process, + // not this browser session. The inline ticker reads this from + // ``window.__serverUptime`` and falls back to "—" if absent. + // ``recordedAtPerf`` uses ``performance.now()`` so wall-clock + // changes (NTP step, DST) don't make the counter jump. + if (typeof data.uptime_seconds === 'number') { + window.__serverUptime = { + uptimeSec: data.uptime_seconds, + recordedAtPerf: performance.now(), + }; + } + // Demo mode detection if (data.demo_mode && !demoMode) { demoMode = true; diff --git a/server/src/ledgrab/static/js/core/bg-anim.ts b/server/src/ledgrab/static/js/core/bg-anim.ts index a9aa5b6..3e61aab 100644 --- a/server/src/ledgrab/static/js/core/bg-anim.ts +++ b/server/src/ledgrab/static/js/core/bg-anim.ts @@ -114,7 +114,10 @@ let _particleBuf: Float32Array | null = null; // pre-allocated Float32Array for let _raf: number | null = null; let _startTime = 0; let _accent = [76 / 255, 175 / 255, 80 / 255]; -let _bgColor = [26 / 255, 26 / 255, 26 / 255]; +// Base canvas colour — must match `--bg-color` (pure black / white in the +// Lumenworks theme). Using mid-greys here washes the additive glow with a +// constant tint that doesn't exist on the surrounding page background. +let _bgColor = [0, 0, 0]; let _isLight = 0.0; // Particle state (CPU-side, positions in 0..1 UV space) @@ -262,7 +265,9 @@ export function updateBgAnimAccent(hex: string): void { } export function updateBgAnimTheme(isDark: boolean): void { - _bgColor = isDark ? [26 / 255, 26 / 255, 26 / 255] : [245 / 255, 245 / 255, 245 / 255]; + // Match the page's `--bg-color` (pure black/white) — see comment on + // the `_bgColor` declaration above. + _bgColor = isDark ? [0, 0, 0] : [1, 1, 1]; _isLight = isDark ? 0.0 : 1.0; } diff --git a/server/src/ledgrab/static/js/core/bg-shaders.ts b/server/src/ledgrab/static/js/core/bg-shaders.ts index df1b82f..b95d0a9 100644 --- a/server/src/ledgrab/static/js/core/bg-shaders.ts +++ b/server/src/ledgrab/static/js/core/bg-shaders.ts @@ -320,7 +320,10 @@ let _uBg: WebGLUniformLocation | null = null; let _uLight: WebGLUniformLocation | null = null; let _accent = [76 / 255, 175 / 255, 80 / 255]; -let _bgColor = [26 / 255, 26 / 255, 26 / 255]; +// Base canvas colour — must match `--bg-color` (pure black / white in +// the Lumenworks theme). Using mid-greys here washes the additive glow +// with a constant tint that doesn't exist on the surrounding page bg. +let _bgColor = [0, 0, 0]; let _isLight = 0.0; // ─── GL helpers ────────────────────────────────────────────── @@ -471,7 +474,8 @@ export function updateShaderAccent(hex: string): void { /** Update theme brightness (called on theme toggle). */ export function updateShaderTheme(isDark: boolean): void { - _bgColor = isDark ? [26 / 255, 26 / 255, 26 / 255] : [245 / 255, 245 / 255, 245 / 255]; + // Match the page's `--bg-color` token (pure black/white). + _bgColor = isDark ? [0, 0, 0] : [1, 1, 1]; _isLight = isDark ? 0.0 : 1.0; } diff --git a/server/src/ledgrab/static/js/core/card-colors.ts b/server/src/ledgrab/static/js/core/card-colors.ts index d514297..f43f126 100644 --- a/server/src/ledgrab/static/js/core/card-colors.ts +++ b/server/src/ledgrab/static/js/core/card-colors.ts @@ -25,6 +25,18 @@ import { ICON_TRASH } from './icons.ts'; const STORAGE_KEY = 'cardColors'; const DEFAULT_SWATCH = '#808080'; +/** Data attributes used as the entity-id key on card elements across the + * app. setCardColor() walks all of these so a single picker click updates + * every card representing the same entity (e.g. the targets-tab card AND + * its dashboard mirror), not just the one that owns the picker. */ +const CARD_ID_ATTRS: readonly string[] = [ + 'data-target-id', 'data-device-id', 'data-automation-id', + 'data-sync-clock-id', 'data-stream-id', 'data-template-id', + 'data-pattern-template-id', 'data-pp-template-id', 'data-cspt-id', + 'data-audio-template-id', 'data-audio-source-id', 'data-gi-id', + 'data-scene-id', 'data-id', +]; + function _getAll(): Record { try { return JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}') || {}; } catch { return {}; } @@ -38,15 +50,30 @@ export function setCardColor(id: string, hex: string): void { const m = _getAll(); if (hex) m[id] = hex; else delete m[id]; localStorage.setItem(STORAGE_KEY, JSON.stringify(m)); + + // Live-update every card representing this entity. The card stripe is + // the ::before pseudo-element backed by --ch (see cards.css), so we + // override --ch inline rather than setting border-left — that avoids + // the double-stripe (custom border + primary --ch) the old approach + // produced, and reaches dashboard mirrors that the picker callback's + // .closest() lookup couldn't. + const escaped = CSS.escape(id); + const selector = CARD_ID_ATTRS.map(a => `[${a}="${escaped}"]`).join(','); + document.querySelectorAll(selector).forEach(el => { + const card = el as HTMLElement; + if (hex) card.style.setProperty('--ch', hex); + else card.style.removeProperty('--ch'); + }); } /** - * Returns inline style string for card border-left. - * Empty string when no color is set. + * Returns the inline style fragment for a card's accent override. + * Sets the --ch CSS variable so the existing ::before channel stripe + * picks up the user's color. Empty string when no color is set. */ export function cardColorStyle(entityId: string): string { const c = getCardColor(entityId); - return c ? `border-left: 3px solid ${c}` : ''; + return c ? `--ch: ${c}` : ''; } /** @@ -59,12 +86,9 @@ export function cardColorButton(entityId: string, cardAttr: string): string { const pickerId = `cc-${entityId}`; registerColorPicker(pickerId, (hex) => { + // setCardColor handles the DOM update on every card representing + // this entity (including dashboard mirrors). Nothing else to do. setCardColor(entityId, hex); - // Find the card that contains this picker (not a global querySelector - // which could match a dashboard compact card first) - const wrapper = document.getElementById(`cp-wrap-${pickerId}`); - const card = wrapper?.closest(`[${cardAttr}]`) as HTMLElement | null; - if (card) card.style.borderLeft = hex ? `3px solid ${hex}` : ''; }); return createColorPicker({ id: pickerId, currentColor: color, onPick: undefined, anchor: 'left', showReset: true, resetColor: DEFAULT_SWATCH }); 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/core/graph-nodes.ts b/server/src/ledgrab/static/js/core/graph-nodes.ts index f61781f..b6d5b60 100644 --- a/server/src/ledgrab/static/js/core/graph-nodes.ts +++ b/server/src/ledgrab/static/js/core/graph-nodes.ts @@ -133,9 +133,54 @@ export function renderNodes(group: SVGGElement, nodeMap: Map, for (const node of nodeMap.values()) { const g = renderNode(node, callbacks); group.appendChild(g); + // Now that the is in the live SVG, `getComputedTextLength()` + // returns real values — fit the title/subtitle to the visible + // text area and append "…" if they overflow. + _fitNodeText(g, node.width); } } +/** Available text width per node — clip rect is x=14..(width-48) wide and + * text starts at x=16, so the usable run is `width - 50`. The 2 px slack + * on the right keeps the ellipsis from kissing the clip edge. */ +function _availableTextWidth(nodeWidth: number): number { + return Math.max(0, nodeWidth - 52); +} + +/** Replace the text of an SVG `` element with the longest prefix of + * its `data-full-text` that fits within `maxWidth`, suffixed with "…". + * No-op if the full text already fits. */ +function _fitTextToWidth(el: SVGTextElement, maxWidth: number): void { + const full = el.getAttribute('data-full-text') || el.textContent || ''; + el.textContent = full; + if (maxWidth <= 0) { el.textContent = ''; return; } + let len = 0; + try { len = el.getComputedTextLength(); } catch { return; } + if (len <= maxWidth) return; + + // Binary search for the longest character prefix that fits with "…". + let lo = 0, hi = full.length; + while (lo < hi) { + const mid = Math.ceil((lo + hi) / 2); + el.textContent = full.slice(0, mid).trimEnd() + '…'; + try { + if (el.getComputedTextLength() <= maxWidth) lo = mid; + else hi = mid - 1; + } catch { + return; + } + } + el.textContent = (full.slice(0, lo).trimEnd() || '') + '…'; +} + +function _fitNodeText(nodeG: Element, nodeWidth: number): void { + const maxW = _availableTextWidth(nodeWidth); + const title = nodeG.querySelector('.graph-node-title'); + const subtitle = nodeG.querySelector('.graph-node-subtitle'); + if (title) _fitTextToWidth(title, maxW); + if (subtitle) _fitTextToWidth(subtitle, maxW); +} + /** * Render a single node. */ @@ -342,23 +387,30 @@ function renderNode(node: GraphNode, callbacks: NodeCallbacks): SVGElement { clipPath.appendChild(svgEl('rect', { x: 14, y: 0, width: width - 48, height })); g.appendChild(clipPath); - // Title (shift left edge for icon to have room) + // Title (shift left edge for icon to have room). + // Full text is stashed on `data-full-text` so the post-mount fit pass + // can measure with `getComputedTextLength()` and binary-search the + // longest prefix that fits, appending "…" instead of relying on the + // clip-path (which silently chops mid-glyph with no ellipsis cue). const title = svgEl('text', { class: 'graph-node-title', x: 16, y: 24, 'clip-path': `url(#${clipId})`, + 'data-full-text': name, }); title.textContent = name; g.appendChild(title); // Subtitle (type) if (subtype) { + const subText = subtype.replace(/_/g, ' '); const sub = svgEl('text', { class: 'graph-node-subtitle', x: 16, y: 42, 'clip-path': `url(#${clipId})`, + 'data-full-text': subText, }); - sub.textContent = subtype.replace(/_/g, ' '); + sub.textContent = subText; g.appendChild(sub); } diff --git a/server/src/ledgrab/static/js/core/icon-paths.ts b/server/src/ledgrab/static/js/core/icon-paths.ts index 7c14bac..62cef63 100644 --- a/server/src/ledgrab/static/js/core/icon-paths.ts +++ b/server/src/ledgrab/static/js/core/icon-paths.ts @@ -23,6 +23,7 @@ export const flaskConical = ''; export const play = ''; export const square = ''; +export const circle = ''; export const pause = ''; export const settings = ''; export const ruler = ''; diff --git a/server/src/ledgrab/static/js/core/icons.ts b/server/src/ledgrab/static/js/core/icons.ts index e054ea8..6236f5a 100644 --- a/server/src/ledgrab/static/js/core/icons.ts +++ b/server/src/ledgrab/static/js/core/icons.ts @@ -342,6 +342,8 @@ export const ICON_GITHUB = _svg(P.github); export const ICON_CHEVRON_UP = _svg(P.chevronUp); export const ICON_CHEVRON_DOWN = _svg(P.chevronDown); export const ICON_PLUS = _svg(P.plus); +export const ICON_SQUARE = _svg(P.square); +export const ICON_CIRCLE = _svg(P.circle); export const ICON_GIT_MERGE = _svg(P.gitMerge); export const ICON_COPY = _svg(P.copy); diff --git a/server/src/ledgrab/static/js/features/appearance.ts b/server/src/ledgrab/static/js/features/appearance.ts index 274b1bd..67beb02 100644 --- a/server/src/ledgrab/static/js/features/appearance.ts +++ b/server/src/ledgrab/static/js/features/appearance.ts @@ -65,13 +65,17 @@ const STYLE_PRESETS: readonly StylePreset[] = [ fontHeading: "'Orbitron', sans-serif", accent: '#4CAF50', fontUrl: '', + // Color values mirror base.css so the preview swatch in Appearance + // matches what _applyThemeVars produces (which clears overrides for + // 'default' and lets base.css through — pure black on dark, pure + // white on light). dark: { - bgColor: '#1a1a1a', bgSecondary: '#242424', cardBg: '#2d2d2d', + bgColor: '#000000', bgSecondary: '#0a0b0d', cardBg: '#101216', textColor: '#e0e0e0', textSecondary: '#999', textMuted: '#777', borderColor: '#404040', inputBg: '#1a1a2e', }, light: { - bgColor: '#f5f5f5', bgSecondary: '#eee', cardBg: '#ffffff', + bgColor: '#ffffff', bgSecondary: '#fafbfc', cardBg: '#f5f6f8', textColor: '#333333', textSecondary: '#595959', textMuted: '#767676', borderColor: '#e0e0e0', inputBg: '#f0f0f0', }, @@ -500,9 +504,27 @@ export function getActiveBgEffect(): string { /** Apply theme color CSS variables for the current active theme (dark/light). */ function _applyThemeVars(preset: StylePreset): void { + const root = document.documentElement.style; + + if (preset.id === 'default') { + // Default preset = base.css palette (pure-black on dark, pure-white + // on light). Clear any inline overrides left behind by a previous + // preset so the base values come through, instead of stamping the + // muted greys this preset historically carried. + root.removeProperty('--bg-color'); + root.removeProperty('--bg-secondary'); + root.removeProperty('--card-bg'); + root.removeProperty('--text-color'); + root.removeProperty('--text-primary'); + root.removeProperty('--text-secondary'); + root.removeProperty('--text-muted'); + root.removeProperty('--border-color'); + root.removeProperty('--input-bg'); + return; + } + const theme = document.documentElement.getAttribute('data-theme') || 'dark'; const vars = theme === 'dark' ? preset.dark : preset.light; - const root = document.documentElement.style; root.setProperty('--bg-color', vars.bgColor); root.setProperty('--bg-secondary', vars.bgSecondary); diff --git a/server/src/ledgrab/static/js/features/dashboard-customize.ts b/server/src/ledgrab/static/js/features/dashboard-customize.ts new file mode 100644 index 0000000..e9aefe5 --- /dev/null +++ b/server/src/ledgrab/static/js/features/dashboard-customize.ts @@ -0,0 +1,576 @@ +/** + * Dashboard customization panel — slide-in panel that lets the user toggle + * section / perf-cell visibility, reorder them by drag, change density, + * pick presets, and import/export the layout as JSON. + * + * The panel writes through `dashboard-layout.ts` which debounces a server + * PUT and notifies subscribers — `dashboard.ts` listens and re-renders + * live, so every change shows immediately on the page behind the panel. + * + * Drag/drop is hand-rolled HTML5 drag-and-drop (no external dep). It only + * works on pointer devices; for keyboard / TV remote we expose ↑/↓ buttons + * on each row so the panel is fully reachable without a mouse. + */ +import { t } from '../core/i18n.ts'; +import { showToast, showConfirm } from '../core/ui.ts'; +import { + getDashboardLayout, + saveDashboardLayout, + applyDashboardPreset, + resetDashboardLayout, + exportDashboardLayoutJson, + importDashboardLayoutJson, + setSectionVisible, + setSectionOrder, + setSectionDensity, + setSectionCollapsedDefault, + setPerfCellVisible, + setPerfCellOrder, + setPerfCellMode, + setPerfCellWindow, + setPerfCellYScale, + setGlobalPerfMode, + setGlobalPerfWindow, + setGlobalConfig, + PRESETS, + subscribeDashboardLayout, + type DashboardLayoutV1, + type Density, + type PerfMode, + type SampleWindow, + type YScale, + type Width, + type AnimationsLevel, +} from './dashboard-layout.ts'; +import { + ICON_X, ICON_EYE, ICON_EYE_OFF, ICON_DOWNLOAD, ICON_REFRESH, +} from '../core/icons.ts'; + +const ICON_DRAG = ''; +const ICON_LOCK = ''; + +const PANEL_ID = 'dashboard-customize-panel'; +const BACKDROP_ID = 'dashboard-customize-backdrop'; + +/** Sections that the user can reorder. The perf section is special-cased + * (always at top in v1; only its visibility / cells are configurable), + * so it's not part of this list. */ +const REORDERABLE_SECTIONS: readonly string[] = [ + 'integrations', + 'automations', + 'scenes', + 'sync-clocks', + 'targets', +] as const; + +const SECTION_LABEL_KEYS: Record = { + perf: 'dashboard.section.performance', + integrations: 'dashboard.section.integrations', + automations: 'dashboard.section.automations', + scenes: 'dashboard.section.scenes', + 'sync-clocks': 'dashboard.section.sync_clocks', + targets: 'dashboard.section.targets', +}; + +const PERF_CELL_LABEL_KEYS: Record = { + patches: 'dashboard.perf.active_patches', + fps: 'dashboard.perf.total_fps', + devices: 'dashboard.perf.devices', + cpu: 'dashboard.perf.cpu', + ram: 'dashboard.perf.ram', + gpu: 'dashboard.perf.gpu', + temp: 'dashboard.perf.temp', +}; + +let _unsubscribe: (() => void) | null = null; + +export function openDashboardCustomize(): void { + let panel = document.getElementById(PANEL_ID); + if (!panel) { + _mountPanel(); + panel = document.getElementById(PANEL_ID)!; + } + panel.classList.add('is-open'); + const backdrop = document.getElementById(BACKDROP_ID); + if (backdrop) backdrop.classList.add('is-open'); + _renderPanelBody(); + if (!_unsubscribe) { + _unsubscribe = subscribeDashboardLayout(() => _renderPanelBody()); + } +} + +export function closeDashboardCustomize(): void { + const panel = document.getElementById(PANEL_ID); + const backdrop = document.getElementById(BACKDROP_ID); + if (panel) panel.classList.remove('is-open'); + if (backdrop) backdrop.classList.remove('is-open'); + if (_unsubscribe) { _unsubscribe(); _unsubscribe = null; } +} + +function _mountPanel(): void { + const backdrop = document.createElement('div'); + backdrop.id = BACKDROP_ID; + backdrop.className = 'dash-cust-backdrop'; + backdrop.addEventListener('click', closeDashboardCustomize); + document.body.appendChild(backdrop); + + const panel = document.createElement('aside'); + panel.id = PANEL_ID; + panel.className = 'dash-cust-panel'; + panel.setAttribute('role', 'dialog'); + panel.setAttribute('aria-modal', 'false'); + panel.setAttribute('aria-labelledby', 'dash-cust-title'); + panel.innerHTML = ` +
+

${t('dashboard.customize.title')}

+ +
+
+ `; + document.body.appendChild(panel); + + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && panel.classList.contains('is-open')) { + closeDashboardCustomize(); + } + }); +} + +function _renderPanelBody(): void { + const body = document.getElementById('dash-cust-body'); + if (!body) return; + const layout = getDashboardLayout(); + body.innerHTML = ` + ${_renderPresets(layout)} + ${_renderGlobal(layout)} + ${_renderSections(layout)} + ${_renderPerfCells(layout)} + ${_renderActions()} + `; + _bindHandlers(body); +} + +// ── Sub-renderers ──────────────────────────────────────────────────────── + +function _renderPresets(layout: DashboardLayoutV1): string { + const chips = Object.keys(PRESETS).map(name => { + const active = layout.presetActive === name; + return ``; + }).join(''); + const modifiedHint = layout.presetActive + ? '' + : `${t('dashboard.customize.modified')}`; + return `
+

${t('dashboard.customize.presets')}${modifiedHint}

+
${chips}
+
`; +} + +function _renderGlobal(layout: DashboardLayoutV1): string { + const widthOpts: { v: Width; k: string }[] = [ + { v: 'full', k: 'dashboard.customize.width.full' }, + { v: 'centered', k: 'dashboard.customize.width.centered' }, + { v: 'narrow', k: 'dashboard.customize.width.narrow' }, + ]; + const animOpts: { v: AnimationsLevel; k: string }[] = [ + { v: 'full', k: 'dashboard.customize.anim.full' }, + { v: 'reduced', k: 'dashboard.customize.anim.reduced' }, + { v: 'off', k: 'dashboard.customize.anim.off' }, + ]; + const modeOpts: { v: 'system' | 'app' | 'both'; k: string }[] = [ + { v: 'system', k: 'dashboard.perf.mode.system' }, + { v: 'app', k: 'dashboard.perf.mode.app' }, + { v: 'both', k: 'dashboard.perf.mode.both' }, + ]; + const windowOpts: SampleWindow[] = [30, 60, 120, 300]; + const widthBtns = widthOpts.map(o => ``).join(''); + const animBtns = animOpts.map(o => ``).join(''); + const modeBtns = modeOpts.map(o => ``).join(''); + const windowBtns = windowOpts.map(w => ``).join(''); + return `
+

${t('dashboard.customize.global')}

+
+ +
${widthBtns}
+
+
+ +
${animBtns}
+
+
+ +
${modeBtns}
+
+
+ +
${windowBtns}
+
+
`; +} + +function _renderSections(layout: DashboardLayoutV1): string { + const perfRow = (() => { + const perf = layout.sections.find(s => s.key === 'perf'); + if (!perf) return ''; + return `
+ + ${ICON_LOCK} + ${t(SECTION_LABEL_KEYS.perf)} + + ${_eyeBtn(perf.visible, 'section', 'perf')} +
`; + })(); + + const orderedSlugs = REORDERABLE_SECTIONS.filter(k => + layout.sections.some(s => s.key === k)); + const orderedFromLayout = layout.sections.map(s => s.key).filter(k => orderedSlugs.includes(k)); + + const rows = orderedFromLayout.map(key => { + const s = layout.sections.find(s => s.key === key); + if (!s) return ''; + const densityBtns: { v: Density; lbl: string }[] = [ + { v: 'comfortable', lbl: 'C' }, + { v: 'compact', lbl: 'M' }, + { v: 'dense', lbl: 'D' }, + ]; + const densityHtml = densityBtns.map(b => + `` + ).join(''); + return `
+ + ${t(SECTION_LABEL_KEYS[key] || key)} + ${densityHtml} + + + ${_collapseBtn(s.collapsedDefault, 'section', key)} + ${_eyeBtn(s.visible, 'section', key)} +
`; + }).join(''); + + return `
+

${t('dashboard.customize.sections')}

+ ${perfRow} +
${rows}
+

${t('dashboard.customize.drag_help')}

+
`; +} + +function _renderPerfCells(layout: DashboardLayoutV1): string { + const modeOpts: PerfMode[] = ['inherit', 'system', 'app', 'both']; + const windowOpts: (SampleWindow | 'inherit')[] = ['inherit', 30, 60, 120, 300]; + const yScaleOpts: YScale[] = ['auto', 'fixed', 'log']; + + const rows = layout.perfCells.map(c => { + const modeSel = ``; + const windowSel = ``; + const yScaleSel = ``; + return `
+
+ + ${t(PERF_CELL_LABEL_KEYS[c.key] || c.key)} + + + ${_eyeBtn(c.visible, 'cell', c.key)} +
+
+ + ${t('dashboard.customize.mode_short')} + ${modeSel} + + + ${t('dashboard.customize.window_short')} + ${windowSel} + + + ${t('dashboard.customize.scale_short')} + ${yScaleSel} + +
+
`; + }).join(''); + + return `
+

${t('dashboard.customize.perf_cells')}

+
${rows}
+

${t('dashboard.customize.cell_drag_help')}

+
`; +} + +function _renderActions(): string { + return `
+ + + +
`; +} + +function _eyeBtn(visible: boolean, kind: 'section' | 'cell', key: string): string { + const dataAttr = kind === 'section' ? 'data-section-toggle' : 'data-cell-toggle'; + const label = visible ? t('dashboard.customize.hide') : t('dashboard.customize.show'); + return ``; +} + +function _collapseBtn(collapsed: boolean, kind: 'section', key: string): string { + const label = collapsed ? t('dashboard.customize.collapse_default.on') : t('dashboard.customize.collapse_default.off'); + return ``; +} + +// ── Handlers ───────────────────────────────────────────────────────────── + +function _bindHandlers(root: HTMLElement): void { + // Presets + root.querySelectorAll('[data-preset]').forEach(btn => { + btn.addEventListener('click', () => { + const name = btn.dataset.preset!; + applyDashboardPreset(name); + }); + }); + + // Global toggles + root.querySelectorAll('[data-global-width]').forEach(btn => { + btn.addEventListener('click', () => { + saveDashboardLayout(setGlobalConfig(getDashboardLayout(), { width: btn.dataset.globalWidth as Width })); + }); + }); + root.querySelectorAll('[data-global-anim]').forEach(btn => { + btn.addEventListener('click', () => { + saveDashboardLayout(setGlobalConfig(getDashboardLayout(), { animations: btn.dataset.globalAnim as AnimationsLevel })); + }); + }); + root.querySelectorAll('[data-global-perfmode]').forEach(btn => { + btn.addEventListener('click', () => { + const mode = btn.dataset.globalPerfmode as 'system' | 'app' | 'both'; + saveDashboardLayout(setGlobalPerfMode(getDashboardLayout(), mode)); + }); + }); + + // Section visibility / density / order / collapse-default + root.querySelectorAll('[data-section-toggle]').forEach(btn => { + btn.addEventListener('click', () => { + const key = btn.dataset.sectionToggle!; + const layout = getDashboardLayout(); + const cur = layout.sections.find(s => s.key === key); + if (!cur) return; + saveDashboardLayout(setSectionVisible(layout, key, !cur.visible)); + }); + }); + root.querySelectorAll('[data-section-density]').forEach(btn => { + btn.addEventListener('click', () => { + const key = btn.dataset.sectionDensity!; + const density = btn.dataset.density as Density; + saveDashboardLayout(setSectionDensity(getDashboardLayout(), key, density)); + }); + }); + root.querySelectorAll('[data-section-collapse-default]').forEach(btn => { + btn.addEventListener('click', () => { + const key = btn.dataset.sectionCollapseDefault!; + const layout = getDashboardLayout(); + const cur = layout.sections.find(s => s.key === key); + if (!cur) return; + saveDashboardLayout(setSectionCollapsedDefault(layout, key, !cur.collapsedDefault)); + }); + }); + root.querySelectorAll('[data-move]').forEach(btn => { + btn.addEventListener('click', () => { + const key = btn.dataset.sectionKey!; + const dir = btn.dataset.move as 'up' | 'down'; + _moveSection(key, dir); + }); + }); + + // Perf cells + root.querySelectorAll('[data-cell-toggle]').forEach(btn => { + btn.addEventListener('click', () => { + const key = btn.dataset.cellToggle!; + const layout = getDashboardLayout(); + const cur = layout.perfCells.find(c => c.key === key); + if (!cur) return; + saveDashboardLayout(setPerfCellVisible(layout, key, !cur.visible)); + }); + }); + root.querySelectorAll('[data-cell-mode]').forEach(sel => { + sel.addEventListener('change', () => { + const key = sel.dataset.cellMode!; + saveDashboardLayout(setPerfCellMode(getDashboardLayout(), key, sel.value as PerfMode)); + }); + }); + root.querySelectorAll('[data-cell-window]').forEach(sel => { + sel.addEventListener('change', () => { + const key = sel.dataset.cellWindow!; + const raw = sel.value; + const win: SampleWindow | 'inherit' = raw === 'inherit' + ? 'inherit' + : (parseInt(raw, 10) as SampleWindow); + saveDashboardLayout(setPerfCellWindow(getDashboardLayout(), key, win)); + }); + }); + root.querySelectorAll('[data-global-perfwindow]').forEach(btn => { + btn.addEventListener('click', () => { + const w = parseInt(btn.dataset.globalPerfwindow || '120', 10) as SampleWindow; + saveDashboardLayout(setGlobalPerfWindow(getDashboardLayout(), w)); + }); + }); + root.querySelectorAll('[data-cell-yscale]').forEach(sel => { + sel.addEventListener('change', () => { + const key = sel.dataset.cellYscale!; + saveDashboardLayout(setPerfCellYScale(getDashboardLayout(), key, sel.value as YScale)); + }); + }); + root.querySelectorAll('[data-cell-move]').forEach(btn => { + btn.addEventListener('click', () => { + const key = btn.dataset.cellKey!; + const dir = btn.dataset.cellMove as 'up' | 'down'; + _movePerfCell(key, dir); + }); + }); + + // Drag-and-drop reorder + _bindDragSort(root, '#dash-cust-section-list', 'data-section-key', (orderedKeys) => { + const layout = getDashboardLayout(); + // Preserve relative position of fixed/non-reorderable keys (perf). + const nonReorderable = layout.sections.map(s => s.key).filter(k => !REORDERABLE_SECTIONS.includes(k)); + const merged = [...nonReorderable, ...orderedKeys]; + saveDashboardLayout(setSectionOrder(layout, merged)); + }); + _bindDragSort(root, '#dash-cust-cell-list', 'data-cell-key', (orderedKeys) => { + saveDashboardLayout(setPerfCellOrder(getDashboardLayout(), orderedKeys)); + }); + + // Actions + const exportBtn = root.querySelector('[data-action="export"]'); + if (exportBtn) exportBtn.addEventListener('click', _doExport); + const importBtn = root.querySelector('[data-action="import"]'); + if (importBtn) importBtn.addEventListener('click', _doImport); + const resetBtn = root.querySelector('[data-action="reset"]'); + if (resetBtn) resetBtn.addEventListener('click', async () => { + const confirmed = await showConfirm( + t('dashboard.customize.reset_confirm'), + t('dashboard.customize.reset'), + ); + if (confirmed) resetDashboardLayout(); + }); +} + +function _moveSection(key: string, dir: 'up' | 'down'): void { + const layout = getDashboardLayout(); + const orderable = layout.sections + .map(s => s.key) + .filter(k => REORDERABLE_SECTIONS.includes(k)); + const idx = orderable.indexOf(key); + if (idx < 0) return; + const swap = dir === 'up' ? idx - 1 : idx + 1; + if (swap < 0 || swap >= orderable.length) return; + const next = [...orderable]; + [next[idx], next[swap]] = [next[swap], next[idx]]; + const nonReorderable = layout.sections.map(s => s.key).filter(k => !REORDERABLE_SECTIONS.includes(k)); + saveDashboardLayout(setSectionOrder(layout, [...nonReorderable, ...next])); +} + +function _movePerfCell(key: string, dir: 'up' | 'down'): void { + const layout = getDashboardLayout(); + const order = layout.perfCells.map(c => c.key); + const idx = order.indexOf(key); + if (idx < 0) return; + const swap = dir === 'up' ? idx - 1 : idx + 1; + if (swap < 0 || swap >= order.length) return; + const next = [...order]; + [next[idx], next[swap]] = [next[swap], next[idx]]; + saveDashboardLayout(setPerfCellOrder(layout, next)); +} + +// ── Hand-rolled drag-and-drop sort ────────────────────────────────────── + +function _bindDragSort( + root: HTMLElement, + listSelector: string, + keyAttr: string, + onReorder: (orderedKeys: string[]) => void, +): void { + const list = root.querySelector(listSelector); + if (!list) return; + let dragKey: string | null = null; + + list.querySelectorAll('.dash-cust-row-drag').forEach(row => { + row.addEventListener('dragstart', (e) => { + dragKey = row.getAttribute(keyAttr); + row.classList.add('is-dragging'); + if (e.dataTransfer) { + e.dataTransfer.effectAllowed = 'move'; + // Required by Firefox to enable drag. + e.dataTransfer.setData('text/plain', dragKey || ''); + } + }); + row.addEventListener('dragend', () => { + row.classList.remove('is-dragging'); + dragKey = null; + list.querySelectorAll('.is-drop-target').forEach(el => el.classList.remove('is-drop-target')); + }); + row.addEventListener('dragover', (e) => { + if (!dragKey) return; + e.preventDefault(); + list.querySelectorAll('.is-drop-target').forEach(el => el.classList.remove('is-drop-target')); + row.classList.add('is-drop-target'); + }); + row.addEventListener('drop', (e) => { + e.preventDefault(); + const targetKey = row.getAttribute(keyAttr); + if (!dragKey || !targetKey || dragKey === targetKey) return; + const allRows = Array.from(list.querySelectorAll('.dash-cust-row-drag')); + const orderedKeys = allRows.map(r => r.getAttribute(keyAttr) || ''); + const fromIdx = orderedKeys.indexOf(dragKey); + const toIdx = orderedKeys.indexOf(targetKey); + if (fromIdx < 0 || toIdx < 0) return; + const [moved] = orderedKeys.splice(fromIdx, 1); + orderedKeys.splice(toIdx, 0, moved); + onReorder(orderedKeys.filter(Boolean)); + }); + }); +} + +// ── Export / import ───────────────────────────────────────────────────── + +function _doExport(): void { + const json = exportDashboardLayoutJson(); + const blob = new Blob([json], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `ledgrab-dashboard-layout-${new Date().toISOString().slice(0, 10)}.json`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + showToast(t('dashboard.customize.exported'), 'success'); +} + +function _doImport(): void { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = 'application/json,.json'; + input.onchange = async () => { + const file = input.files?.[0]; + if (!file) return; + try { + const text = await file.text(); + if (importDashboardLayoutJson(text)) { + showToast(t('dashboard.customize.imported'), 'success'); + } else { + showToast(t('dashboard.customize.import_failed'), 'error'); + } + } catch { + showToast(t('dashboard.customize.import_failed'), 'error'); + } + }; + input.click(); +} diff --git a/server/src/ledgrab/static/js/features/dashboard-layout.ts b/server/src/ledgrab/static/js/features/dashboard-layout.ts new file mode 100644 index 0000000..f12b9ac --- /dev/null +++ b/server/src/ledgrab/static/js/features/dashboard-layout.ts @@ -0,0 +1,579 @@ +/** + * Dashboard layout — schema, defaults, presets, and persistence for the + * customizable dashboard. + * + * Storage strategy: + * - localStorage `dashboard_layout_v1` is the cache (instant first-paint). + * - Server `GET/PUT /preferences/dashboard-layout` is the source of truth + * across browsers; pulled after auth, replaces local on mismatch. + * - Save path: PUT to server -> localStorage cache -> notify subscribers. + * + * Schema is intentionally an open registry: section/cell `key`s are strings, + * not a closed enum. New cards can be added in v1.1+ (audio meters, alerts, + * preview strips, etc.) without a schema bump or migration. + */ +import { fetchWithAuth } from '../core/api.ts'; + +const LS_KEY = 'dashboard_layout_v1'; +const SCHEMA_VERSION = 1; + +export type SectionKey = + | 'perf' + | 'integrations' + | 'automations' + | 'scenes' + | 'sync-clocks' + | 'targets' + // Reserved registry keys for v1.1+ (so saved layouts forward-compat). + | 'audio-meters' + | 'alerts' + | 'led-preview' + | 'source-thumbs' + | 'pinned' + | 'flow'; + +export type PerfCellKey = + | 'patches' + | 'fps' + | 'devices' + | 'cpu' + | 'ram' + | 'gpu' + | 'temp' + // Reserved. + | 'network' + | 'disk' + | 'audio-peak'; + +export type Density = 'comfortable' | 'compact' | 'dense'; +export type PerfMode = 'system' | 'app' | 'both' | 'inherit'; +export type YScale = 'auto' | 'fixed' | 'log'; +export type SampleWindow = 30 | 60 | 120 | 300; +export type Width = 'full' | 'centered' | 'narrow'; +export type AccentSource = 'target' | 'palette' | 'mono'; +export type AnimationsLevel = 'full' | 'reduced' | 'off'; +export type EmptyStateMode = 'hide' | 'cta' | 'skeleton'; +export type ToolbarPos = 'top' | 'bottom' | 'floating'; + +export interface SectionConfig { + key: string; + visible: boolean; + collapsedDefault: boolean; + density: Density; + /** Per-section options (sort, filters, etc.). Versioned per-section + * via `_v` so we can migrate one section without touching others. */ + options: Record; +} + +export interface PerfCellConfig { + key: string; + visible: boolean; + /** `inherit` defers to the global perf mode (system/app/both); a + * per-cell value pins that cell to one mode regardless of global. */ + mode: PerfMode; + span: 1 | 2; + /** `'inherit'` defers to `global.window`; a numeric value pins the + * cell's spark to that sample window regardless of global. */ + window: SampleWindow | 'inherit'; + yScale: YScale; + precision: 0 | 1 | 2; + showSubtitle: boolean; + showRefLine: boolean; + colorOverride?: string; +} + +export interface GlobalConfig { + width: Width; + accent: AccentSource; + animations: AnimationsLevel; + emptyState: EmptyStateMode; + toolbarPosition: ToolbarPos; + autoCollapseRunningEmpty: boolean; + showTutorial: boolean; + /** Global perf mode default — used when a cell has `mode: 'inherit'`. */ + perfMode: 'system' | 'app' | 'both'; + /** Global spark sample-window default in seconds — used when a cell + * has `window: 'inherit'`. */ + perfWindow: SampleWindow; + /** Poll interval for the perf strip + dashboard refresh, milliseconds. */ + pollMs: number; +} + +export interface DashboardLayoutV1 { + version: 1; + sections: SectionConfig[]; + perfCells: PerfCellConfig[]; + global: GlobalConfig; + /** Active preset key when the layout matches a built-in unmodified. + * Cleared on any user edit so the panel can show "modified" state. */ + presetActive?: string; +} + +const _defaultSection = (key: string, visible = true): SectionConfig => ({ + key, + visible, + collapsedDefault: false, + density: 'comfortable', + options: {}, +}); + +const _defaultPerfCell = (key: string, visible = true): PerfCellConfig => ({ + key, + visible, + mode: 'inherit', + span: 1, + window: 'inherit', + yScale: 'auto', + precision: 1, + showSubtitle: true, + showRefLine: true, +}); + +export const DEFAULT_LAYOUT: DashboardLayoutV1 = { + version: SCHEMA_VERSION, + sections: [ + _defaultSection('perf'), + _defaultSection('integrations'), + _defaultSection('automations'), + _defaultSection('scenes'), + _defaultSection('sync-clocks'), + _defaultSection('targets'), + ], + perfCells: [ + _defaultPerfCell('patches'), + _defaultPerfCell('fps'), + _defaultPerfCell('devices'), + _defaultPerfCell('cpu'), + _defaultPerfCell('ram'), + _defaultPerfCell('gpu'), + _defaultPerfCell('temp', false), + ], + global: { + width: 'full', + accent: 'target', + animations: 'full', + emptyState: 'hide', + toolbarPosition: 'top', + autoCollapseRunningEmpty: false, + showTutorial: true, + perfMode: 'both', + perfWindow: 120, + pollMs: 1000, + }, + presetActive: 'studio', +}; + +/** Built-in presets — each is a complete layout the user can apply with one + * click. Stored as functions so they always produce a fresh object (no + * shared mutable references). */ +export const PRESETS: Record DashboardLayoutV1> = { + studio: () => _clone(DEFAULT_LAYOUT, 'studio'), + + operator: () => { + const l = _clone(DEFAULT_LAYOUT, 'operator'); + const hide = new Set(['integrations', 'scenes', 'sync-clocks']); + l.sections = l.sections.map(s => hide.has(s.key) ? { ...s, visible: false } : s); + l.sections = l.sections.map(s => ({ ...s, density: 'compact' })); + return l; + }, + + showrunner: () => { + const l = _clone(DEFAULT_LAYOUT, 'showrunner'); + const hide = new Set(['perf', 'integrations']); + l.sections = l.sections.map(s => hide.has(s.key) ? { ...s, visible: false } : s); + l.sections = l.sections.map(s => ({ ...s, density: 'compact' })); + return l; + }, + + diagnostics: () => { + const l = _clone(DEFAULT_LAYOUT, 'diagnostics'); + l.perfCells = l.perfCells.map(c => ({ + ...c, + visible: true, + window: 'inherit', + showSubtitle: true, + showRefLine: true, + })); + l.global = { ...l.global, perfMode: 'both', perfWindow: 300, pollMs: 500 }; + return l; + }, + + tv: () => { + const l = _clone(DEFAULT_LAYOUT, 'tv'); + l.sections = l.sections.map(s => ({ ...s, density: 'dense' })); + const keep = new Set(['perf', 'targets']); + l.sections = l.sections.map(s => keep.has(s.key) ? s : { ...s, visible: false }); + l.global = { ...l.global, width: 'centered', toolbarPosition: 'top' }; + return l; + }, +}; + +function _clone(layout: DashboardLayoutV1, presetActive?: string): DashboardLayoutV1 { + return { + version: layout.version, + sections: layout.sections.map(s => ({ ...s, options: { ...s.options } })), + perfCells: layout.perfCells.map(c => ({ ...c })), + global: { ...layout.global }, + presetActive, + }; +} + +let _current: DashboardLayoutV1 = _clone(DEFAULT_LAYOUT, 'studio'); +let _serverSyncedOnce = false; +const _listeners = new Set<() => void>(); +let _saveTimer: ReturnType | null = null; + +/** Read the current layout. Always returns a defensive copy so callers + * can't mutate it directly — mutations must go through `saveDashboardLayout`. */ +export function getDashboardLayout(): DashboardLayoutV1 { + return _clone(_current, _current.presetActive); +} + +/** Subscribe to layout changes. Returns an unsubscribe function. */ +export function subscribeDashboardLayout(fn: () => void): () => void { + _listeners.add(fn); + return () => _listeners.delete(fn); +} + +function _notify(): void { + for (const fn of _listeners) { + try { fn(); } catch (e) { console.error('dashboard layout listener', e); } + } +} + +/** Hydrate from localStorage cache (synchronous, for first-paint). Falls + * back to defaults + legacy-key migration if no cached layout exists. */ +export function hydrateDashboardLayoutFromCache(): DashboardLayoutV1 { + try { + const raw = localStorage.getItem(LS_KEY); + if (raw) { + const parsed = JSON.parse(raw); + const merged = _mergeWithDefaults(parsed); + _current = merged; + return merged; + } + } catch (e) { + console.warn('dashboard layout cache parse failed', e); + } + // No cache — pull from legacy keys so first migration is seamless. + _current = _migrateFromLegacyKeys(); + try { localStorage.setItem(LS_KEY, JSON.stringify(_current)); } catch { /* quota */ } + return _clone(_current, _current.presetActive); +} + +/** Pull layout from server after auth. Replaces local cache if server has + * a saved layout, otherwise pushes the local cache up. Safe to call + * before login (will no-op on auth error). */ +export async function syncDashboardLayoutFromServer(): Promise { + if (_serverSyncedOnce) return; + try { + const resp = await fetchWithAuth('/preferences/dashboard-layout'); + if (!resp || !resp.ok) return; + const data = await resp.json(); + if (data && typeof data === 'object' && data.version) { + const merged = _mergeWithDefaults(data); + _current = merged; + try { localStorage.setItem(LS_KEY, JSON.stringify(_current)); } catch { /* quota */ } + _notify(); + } else { + // Server has nothing — push our cached/default layout up. + await _pushToServer(_current); + } + _serverSyncedOnce = true; + } catch (e) { + // Network or auth failure — keep using cache. + console.warn('dashboard layout server sync failed', e); + } +} + +/** Persist a layout. Updates in-memory state immediately, debounces + * the network write, and notifies listeners synchronously. */ +export function saveDashboardLayout(next: DashboardLayoutV1): void { + _current = _clone(next, next.presetActive); + try { localStorage.setItem(LS_KEY, JSON.stringify(_current)); } catch { /* quota */ } + _notify(); + if (_saveTimer) clearTimeout(_saveTimer); + _saveTimer = setTimeout(() => { + _saveTimer = null; + _pushToServer(_current).catch(e => console.warn('dashboard layout server PUT failed', e)); + }, 300); +} + +async function _pushToServer(layout: DashboardLayoutV1): Promise { + try { + await fetchWithAuth('/preferences/dashboard-layout', { + method: 'PUT', + body: JSON.stringify(layout), + }); + } catch (e) { + console.warn('dashboard layout PUT failed', e); + } +} + +/** Apply a built-in preset and persist it. */ +export function applyDashboardPreset(name: string): void { + const factory = PRESETS[name]; + if (!factory) return; + saveDashboardLayout(factory()); +} + +/** Reset to the studio default. */ +export function resetDashboardLayout(): void { + saveDashboardLayout(PRESETS.studio()); +} + +/** Export the current layout as a downloadable JSON string. */ +export function exportDashboardLayoutJson(): string { + return JSON.stringify(_current, null, 2); +} + +/** Import a JSON layout string. Returns true on success. */ +export function importDashboardLayoutJson(json: string): boolean { + try { + const parsed = JSON.parse(json); + if (!parsed || typeof parsed !== 'object') return false; + const merged = _mergeWithDefaults(parsed); + merged.presetActive = undefined; + saveDashboardLayout(merged); + return true; + } catch (e) { + console.warn('dashboard layout import failed', e); + return false; + } +} + +// ── Helpers exposed to other modules ───────────────────────────────────── + +export function getOrderedSections(): SectionConfig[] { + return _current.sections.map(s => ({ ...s, options: { ...s.options } })); +} + +export function getOrderedPerfCells(): PerfCellConfig[] { + return _current.perfCells.map(c => ({ ...c })); +} + +export function getSection(key: string): SectionConfig | undefined { + const s = _current.sections.find(s => s.key === key); + return s ? { ...s, options: { ...s.options } } : undefined; +} + +export function getPerfCell(key: string): PerfCellConfig | undefined { + const c = _current.perfCells.find(c => c.key === key); + return c ? { ...c } : undefined; +} + +export function isSectionVisible(key: string): boolean { + return _current.sections.find(s => s.key === key)?.visible ?? true; +} + +export function isPerfCellVisible(key: string): boolean { + return _current.perfCells.find(c => c.key === key)?.visible ?? true; +} + +export function getGlobalConfig(): GlobalConfig { + return { ..._current.global }; +} + +/** Effective perf mode for a given cell — resolves `inherit`. */ +export function effectivePerfMode(cellKey: string): 'system' | 'app' | 'both' { + const cell = _current.perfCells.find(c => c.key === cellKey); + if (!cell || cell.mode === 'inherit') return _current.global.perfMode; + return cell.mode; +} + +/** Effective spark window for a given cell — resolves `inherit`. */ +export function effectivePerfWindow(cellKey: string): SampleWindow { + const cell = _current.perfCells.find(c => c.key === cellKey); + if (!cell || cell.window === 'inherit') return _current.global.perfWindow; + return cell.window; +} + +// ── Mutation helpers — return a new layout, don't persist ──────────────── + +export function setSectionVisible(layout: DashboardLayoutV1, key: string, visible: boolean): DashboardLayoutV1 { + const next = _clone(layout); + const s = next.sections.find(s => s.key === key); + if (s) s.visible = visible; + next.presetActive = undefined; + return next; +} + +export function setSectionOrder(layout: DashboardLayoutV1, orderedKeys: string[]): DashboardLayoutV1 { + const next = _clone(layout); + const map = new Map(next.sections.map(s => [s.key, s])); + const reordered: SectionConfig[] = []; + for (const k of orderedKeys) { + const s = map.get(k); + if (s) { reordered.push(s); map.delete(k); } + } + // Append any sections not in the order list (e.g. new registry entries). + for (const s of map.values()) reordered.push(s); + next.sections = reordered; + next.presetActive = undefined; + return next; +} + +export function setSectionDensity(layout: DashboardLayoutV1, key: string, density: Density): DashboardLayoutV1 { + const next = _clone(layout); + const s = next.sections.find(s => s.key === key); + if (s) s.density = density; + next.presetActive = undefined; + return next; +} + +export function setSectionCollapsedDefault(layout: DashboardLayoutV1, key: string, collapsed: boolean): DashboardLayoutV1 { + const next = _clone(layout); + const s = next.sections.find(s => s.key === key); + if (s) s.collapsedDefault = collapsed; + next.presetActive = undefined; + return next; +} + +export function setPerfCellVisible(layout: DashboardLayoutV1, key: string, visible: boolean): DashboardLayoutV1 { + const next = _clone(layout); + const c = next.perfCells.find(c => c.key === key); + if (c) c.visible = visible; + next.presetActive = undefined; + return next; +} + +export function setPerfCellOrder(layout: DashboardLayoutV1, orderedKeys: string[]): DashboardLayoutV1 { + const next = _clone(layout); + const map = new Map(next.perfCells.map(c => [c.key, c])); + const reordered: PerfCellConfig[] = []; + for (const k of orderedKeys) { + const c = map.get(k); + if (c) { reordered.push(c); map.delete(k); } + } + for (const c of map.values()) reordered.push(c); + next.perfCells = reordered; + next.presetActive = undefined; + return next; +} + +export function setPerfCellMode(layout: DashboardLayoutV1, key: string, mode: PerfMode): DashboardLayoutV1 { + const next = _clone(layout); + const c = next.perfCells.find(c => c.key === key); + if (c) c.mode = mode; + next.presetActive = undefined; + return next; +} + +export function setPerfCellWindow(layout: DashboardLayoutV1, key: string, window: SampleWindow | 'inherit'): DashboardLayoutV1 { + const next = _clone(layout); + const c = next.perfCells.find(c => c.key === key); + if (c) c.window = window; + next.presetActive = undefined; + return next; +} + +export function setGlobalPerfWindow(layout: DashboardLayoutV1, window: SampleWindow): DashboardLayoutV1 { + const next = _clone(layout); + next.global.perfWindow = window; + next.presetActive = undefined; + return next; +} + +export function setPerfCellYScale(layout: DashboardLayoutV1, key: string, yScale: YScale): DashboardLayoutV1 { + const next = _clone(layout); + const c = next.perfCells.find(c => c.key === key); + if (c) c.yScale = yScale; + next.presetActive = undefined; + return next; +} + +export function setGlobalPerfMode(layout: DashboardLayoutV1, mode: 'system' | 'app' | 'both'): DashboardLayoutV1 { + const next = _clone(layout); + next.global.perfMode = mode; + next.presetActive = undefined; + return next; +} + +export function setGlobalConfig(layout: DashboardLayoutV1, patch: Partial): DashboardLayoutV1 { + const next = _clone(layout); + next.global = { ...next.global, ...patch }; + next.presetActive = undefined; + return next; +} + +// ── Internal: merge / migrate ──────────────────────────────────────────── + +/** Merge a (possibly partial or older) layout with current defaults. New + * registry keys not in the saved layout are appended to the end with + * default settings; unknown keys in the saved layout are dropped. */ +function _mergeWithDefaults(input: unknown): DashboardLayoutV1 { + const base = _clone(DEFAULT_LAYOUT); + if (!input || typeof input !== 'object') return base; + const obj = input as Partial; + + if (Array.isArray(obj.sections)) { + const known = new Map(base.sections.map(s => [s.key, s])); + const reordered: SectionConfig[] = []; + for (const s of obj.sections as SectionConfig[]) { + const def = known.get(s.key); + if (!def) continue; + reordered.push({ + ...def, + ...s, + options: { ...def.options, ...(s.options || {}) }, + }); + known.delete(s.key); + } + for (const s of known.values()) reordered.push(s); + base.sections = reordered; + } + + if (Array.isArray(obj.perfCells)) { + const known = new Map(base.perfCells.map(c => [c.key, c])); + const reordered: PerfCellConfig[] = []; + for (const c of obj.perfCells as PerfCellConfig[]) { + const def = known.get(c.key); + if (!def) continue; + reordered.push({ ...def, ...c }); + known.delete(c.key); + } + for (const c of known.values()) reordered.push(c); + base.perfCells = reordered; + } + + if (obj.global && typeof obj.global === 'object') { + base.global = { ...base.global, ...obj.global }; + } + + if (typeof obj.presetActive === 'string') base.presetActive = obj.presetActive; + return base; +} + +/** First-time migration from legacy keys (`dashboard_collapsed`, + * `perfMetricsMode`, `perfChartColor_*`). Reads them, builds a layout, + * then leaves the legacy keys in place — they remain harmless and + * some still drive existing UI paths until fully cut over. */ +function _migrateFromLegacyKeys(): DashboardLayoutV1 { + const layout = _clone(DEFAULT_LAYOUT, 'studio'); + + try { + const collapsedRaw = localStorage.getItem('dashboard_collapsed'); + if (collapsedRaw) { + const collapsed = JSON.parse(collapsedRaw) as Record; + for (const s of layout.sections) { + if (collapsed[s.key]) s.collapsedDefault = true; + } + } + } catch { /* ignore */ } + + try { + const mode = localStorage.getItem('perfMetricsMode'); + if (mode === 'system' || mode === 'app' || mode === 'both') { + layout.global.perfMode = mode; + } + } catch { /* ignore */ } + + for (const cell of layout.perfCells) { + try { + const color = localStorage.getItem(`perfChartColor_${cell.key}`); + if (color) cell.colorOverride = color; + } catch { /* ignore */ } + } + + return layout; +} diff --git a/server/src/ledgrab/static/js/features/dashboard.ts b/server/src/ledgrab/static/js/features/dashboard.ts index 50a3d6b..595811a 100644 --- a/server/src/ledgrab/static/js/features/dashboard.ts +++ b/server/src/ledgrab/static/js/features/dashboard.ts @@ -6,17 +6,26 @@ 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, rerenderPerfGrid } from './perf-charts.ts'; import { startAutoRefresh, updateTabBadge } from './tabs.ts'; import { isActiveTab } from '../core/tab-registry.ts'; import { ICON_TARGET, ICON_AUTOMATION, ICON_CLOCK, ICON_WARNING, ICON_OK, ICON_STOP, ICON_STOP_PLAIN, ICON_START, ICON_PAUSE, ICON_HELP, ICON_SCENE, - ICON_PLUG, ICON_HOME, ICON_RADIO, + ICON_PLUG, ICON_HOME, ICON_RADIO, ICON_SETTINGS, } from '../core/icons.ts'; import { loadScenePresets, renderScenePresetsSection, initScenePresetDelegation } from './scene-presets.ts'; import { cardColorStyle } from '../core/card-colors.ts'; import { createFpsSparkline } from '../core/chart-utils.ts'; +import { getOrderedSections, isSectionVisible, getSection, subscribeDashboardLayout, getGlobalConfig } from './dashboard-layout.ts'; + +function _applyGlobalLayoutAttrs(): void { + const c = document.getElementById('dashboard-content'); + if (!c) return; + const g = getGlobalConfig(); + c.dataset.layoutWidth = g.width; + c.dataset.layoutAnim = g.animations; +} import type { Device, OutputTarget, ColorStripSource, ScenePreset, SyncClock, Automation, HomeAssistantConnectionStatus, HomeAssistantStatusResponse, MQTTConnectionStatus, MQTTStatusResponse } from '../types.ts'; const DASHBOARD_COLLAPSED_KEY = 'dashboard_collapsed'; @@ -45,6 +54,24 @@ function _pushFps(targetId: string, actual: number, current: number): void { if (_fpsCurrentHistory[targetId].length > MAX_FPS_SAMPLES) _fpsCurrentHistory[targetId].shift(); } +/** Update the transport status chip in the top bar to reflect how many + * targets are currently running. "Ready" when idle, "Armed · N live" + * when one or more targets are processing. Safe to call any time. */ +function _updateTransportStatus(runningCount: number): void { + const chip = document.getElementById('transport-status'); + if (!chip) return; + const label = chip.querySelector('span:last-child'); + if (!label) return; + if (runningCount > 0) { + chip.classList.add('is-armed'); + const tmpl = t('transport.status.armed'); + label.textContent = tmpl.includes('{n}') ? tmpl.replace('{n}', String(runningCount)) : `${tmpl} · ${runningCount}`; + } else { + chip.classList.remove('is-armed'); + label.textContent = t('transport.status.ready'); + } +} + function _setUptimeBase(targetId: string, seconds: number): void { _uptimeBase[targetId] = { seconds, timestamp: Date.now() }; } @@ -72,7 +99,11 @@ function _startUptimeTimer(): void { if (!el) continue; const seconds = _getInterpolatedUptime(id); if (seconds != null) { - el.innerHTML = `${ICON_CLOCK} ${formatUptime(seconds)}`; + // Pure text — the .mod-metric "UPTIME" label already + // carries the icon meaning, and dropping it gives the + // value enough room for "4m 32s" / "1h 17m" without + // clipping inside the fixed-width metric cell. + el.textContent = formatUptime(seconds); } } }, 1000); @@ -194,7 +225,24 @@ function _updateRunningMetrics(enrichedRunning: any[]): void { } const errorsEl = cached?.errors || document.querySelector(`[data-errors-text="${CSS.escape(target.id)}"]`); - if (errorsEl) { errorsEl.innerHTML = `${errors > 0 ? ICON_WARNING : ICON_OK} ${formatCompact(errors)}`; (errorsEl as HTMLElement).title = String(errors); } + if (errorsEl) { + // Plain numeric in the big value — cleaner at display-font + // size. The status glyph (✓ / ⚠) sits next to the small + // label at the top of the cell; swap it here too so it + // reflects the live error count without flicker. + errorsEl.textContent = formatCompact(errors); + errorsEl.classList.toggle('has-errors', errors > 0); + (errorsEl as HTMLElement).title = String(errors); + const cell = document.querySelector(`[data-errors-cell="${CSS.escape(target.id)}"]`); + if (cell) { + const labelEl = cell.querySelector('.k'); + if (labelEl) { + const labelText = labelEl.querySelector('[data-i18n]')?.textContent || t('dashboard.errors'); + labelEl.innerHTML = `${errors > 0 ? ICON_WARNING : ICON_OK} ${labelText}`; + } + cell.classList.toggle('has-errors', errors > 0); + } + } // Update health dot — prefer streaming reachability when processing const isLed = target.target_type === 'led' || target.target_type === 'wled'; @@ -247,12 +295,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}`; } } } @@ -266,14 +325,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 `