Merge branch 'feat/lumenworks-ui-redesign'
Lint & Test / test (push) Successful in 2m36s

Lumenworks studio-console redesign + per-account dashboard customization
+ Inputs/Integrations/Graph treatment + transport-bar uptime + server
shutdown action.

Sub-features (in order on the branch):
- feat(ui): Lumenworks tokens, fonts, transport bar, channel-strip sidebar
- feat(ui): dashboard polish, perf strip, transport-bar controls
- feat(dashboard): per-account customizable dashboard with slide-in panel
- feat(ui): item-card restyle, perf hover tooltips, FPS ceiling
- feat(ui): Lumenworks treatment for Inputs / Integrations / Graph tabs
- fix(ui): cards on pure black/white, decoupled from bg-anim
- fix(ui): single-row header + readable sidebar labels at narrow widths
- feat: server shutdown action with public cancel_task lifecycle method
- feat(ui): live card-color picker, monotonic uptime ticker, default
  preset uses base palette
- fix(ui): channel stripe paints only on custom-color or running cards
- chore: harden test isolation, gitignore stale src/data, mark TODO done

Pre-merge audit:
- 886/886 pytest passed twice in a row
- ruff + tsc clean
- frontend bundle rebuilt at static/dist
- python package reinstalled in editable mode (dev WebUI now reports
  0.4.2 instead of stale 0.3.0 dist-info)
This commit is contained in:
2026-04-25 15:12:27 +03:00
68 changed files with 8677 additions and 1077 deletions
+5
View File
@@ -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
+254
View File
@@ -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 `<select>` + IconSelect in `settings.html` General tab (icons via `ICON_SQUARE` / `ICON_CIRCLE` from `core/icons.ts`)
- [x] Frontend: load/save handlers in `features/settings.ts`, wired into `openSettingsModal()`
- [x] i18n: en / ru / zh keys for label, hint, item descriptions
- [ ] Real-hardware test pending — verify that "nothing" actually leaves a WLED + a serial device on the last frame after `Ctrl+C`/SIGTERM.
## WebUI Redesign — "Lumenworks" Studio-Console Aesthetic
Full-app UI/UX refresh. Design direction committed to by user 2026-04-24.
Mockup lives at [server/docs/ui-redesign-mockup.html](server/docs/ui-redesign-mockup.html).
Phases are independent and CSS-only where possible — backend untouched.
### Phase 1 — Design tokens & font embed
- [x] Embed variable fonts (`server/src/ledgrab/static/fonts/`):
Manrope (latin + latin-ext + cyrillic + cyrillic-ext),
JetBrains Mono (same 4 subsets),
Big Shoulders Display (latin + latin-ext). Total +201 KB gzipped,
served via `unicode-range` so only latin paints on first load.
- [x] `fonts.css` — declare `@font-face` entries for all new families with
proper `unicode-range` subsetting; keep DM Sans + Orbitron registered
for legacy-token callers during migration.
- [x] `base.css` — add additive Lumenworks tokens:
`--font-display/--font-brand/--font-body`, `--lux-r-*`, `--lux-hairline`,
`--lux-rule`. Both `[data-theme="dark"]` and `[data-theme="light"]`
define `--lux-bg-0…3`, `--lux-line/-bold`, `--lux-ink/-dim/-mute/-faint`,
`--ch-signal/-cyan/-magenta/-amber/-coral/-violet`, `--lux-signal-glow`,
`--lux-shadow-rack`. Existing tokens untouched — no visual regression.
### Phase 2 — Shell (header → transport bar + channel-strip sidebar)
- [x] `index.html``.tab-bar` moved out of `<header>` into a new
`<aside class="sidebar">`; wrapped content in `.app-body` 2-col grid
(sidebar | main). `.transport-center` section added between
`.header-title` and `.header-toolbar` with a placeholder `.transport-status`
chip ("Ready" → "Armed · N live" wired in Phase 3). All tab-button IDs,
`data-tab` attributes, and `onclick="switchTab(…)"` handlers preserved.
- [x] `layout.css``<header>` rebuilt as the transport bar: 3-column grid
(brand | center | toolbar), 60 px fixed height, sticky, gradient bottom
rule with channel-color wash. `.header-title::before/::after` render
the glowing LED brand mark; `#server-status` repositioned as the LED
core pip. `#server-version` restyled as a mono-type console badge.
- [x] `sidebar.css` (new) — vertical channel-strip navigation. Active tab
gets a glowing left stripe + radial tint. `.sidebar-foot` contains
a `.cpu-meter` plate with two live bars (Load, FPS) ready to be
JS-bound in Phase 3. Collapses to a 56 px icon rail at ≤1100 px;
hides entirely at ≤600 px via `display: contents` so `.tab-bar`
falls through to `mobile.css`'s fixed-bottom strip unchanged.
- [x] `all.css` — new sidebar import after layout.
- [x] `base.css` — body font-family switched to `var(--font-body)` which
resolves to Manrope (with DM Sans + system fallbacks). Added
`font-feature-settings` for stylistic set + alternate 1.
- [x] Locale additions: `sidebar.workspaces`, `sidebar.load`, `sidebar.fps`,
`transport.status.ready`, `transport.status.armed` in en/ru/zh.
- [x] Tutorial + auth selectors (`header .header-title`, `#tab-btn-*`,
`.tab-bar` querySelector, `a.header-link[href="/docs"]`, onclick
markers on theme/settings/search) all survive the move.
- [ ] JS: bind `.cpu-meter` + `.transport-status` chip to existing
`performance` WebSocket / poller. Done as part of Phase 3.
- [ ] Tablet-range visual polish pass once other phases render (some tabs
currently have their own internal sticky headers that may overlap
the transport bar on narrow viewports).
### Phase 3 — Dashboard hero + module redesign
- [x] `cards.css``.card` gets rack-module treatment: channel stripe on
left edge (color-coded via `data-card-type` + `.ch-*` utility classes),
`::after` corner bracket in top-right, mono-typed metric labels
planned for Phase 4. Running cards glow the stripe brighter + emit a
`signalFlow` keyframe strip along the bottom edge.
- [x] Removed the `@property --border-angle` rotating conic-gradient border
(retired the WebKit mask workaround + light-theme variant + fallback
for `@supports not (mask-composite: exclude)`). Replaced with the
signal-flow strip — one animated linear-gradient on a 2 px line, no
GPU layer compositing per card.
- [x] `dashboard.css``.dashboard-target` rows pick up the same channel
stripe + signal-flow treatment. Section headers now use mono caps
with a channel-green underline accent. Metric values use mono with
tabular numerics; labels use silkscreened micro-caps.
- [x] Skeleton-card rewritten: left hairline + corner bracket so it reads
as "loading module" instead of a generic flashing block.
`skeletonShimmer` gradient replaces the old opacity-pulse on
`--text-color`.
- [x] `_updateSidebarMeter` binds CPU% (Load) and app-CPU share (FPS)
to the sidebar meter plate on every perf poll.
- [x] `_updateTransportStatus` updates the transport chip ("Ready" →
"Armed · N live") whenever the dashboard's running-target set is
recomputed.
- [ ] `.hero` 4-cell readout row (Active Patches / Throughput / CPU /
Latency + inline sparklines) — CSS tokens + layout are ready; HTML
render deferred until the dashboard JS is refactored to emit it
(Phase 3b, non-blocking).
### Phase 4 — Other tabs adopt module language
- [x] `tree-nav.css` — trigger pill gets a channel stripe on its left edge
(glows + widens when open). Trigger title uses mono-uppercase with
wide letter-spacing. Dropdown panel has a gradient channel-accent
rule across its top edge. Group headers use silkscreened micro-caps
with a small square marker instead of the old bold-uppercase. Active
leaf has a pulsing LED pip on the left and a channel tint behind it.
Count badges switched to mono tabular-nums in 2-px-radius pills.
- [x] `.subtab-section-header` — channel-green underline accent + mono
micro-caps. Consistent with the dashboard-section pattern so the
whole app shares one section-header language.
- [x] `.stream-tab-btn` sub-tabs — mono uppercase with wide tracking,
active tab shows channel-green underline + glowing count badge.
- [x] `.perf-chart-card` — channel stripe on the left (replaces old
`border-top` accent). Per-metric accents swapped to channel palette
(`--ch-coral` for CPU, `--ch-violet` for RAM, `--ch-signal` for GPU,
`--ch-amber` for temp). Corner bracket added. Metric values pick up
`tabular-nums` + a soft glow.
- [x] `cards.css` — channel-color mapping extended to attributes the JS
already emits (`data-target-id` → green, `data-stream-id` → cyan,
`data-audio-source-id` → magenta, `data-automation-id` /
`data-scene-id` → violet). No JS changes required; cards pick up
their correct stripe automatically on the Targets/Sources/Automations
tabs.
- [x] Graph editor — toolbar gets a gradient background + hairline +
rack shadow + backdrop blur. Canvas and nodes untouched.
- [x] `.template-card` — Lumenworks treatment (channel stripe on left,
corner bracket top-right, hairline border, hover lift + stripe
glow). Brings Inputs (streams / capture / pp / cspt / pattern
templates) and Integrations (HA / MQTT / weather / value /
sync-clock / game-integration cards) up to the same visual
language as `.card` and `.dashboard-target`.
- [x] `cards.css` — channel mapping extended to `.template-card`.
Direct attr hooks for `data-stream-id`/`data-template-id`/`data-pp-template-id`
(cyan), `data-cspt-id`/`data-pattern-template-id` (signal),
`data-audio-template-id`/`data-apt-id` (magenta). Section-scoped
hooks via `[data-card-section="…"]` for cards that share a
generic `data-id` (HA / MQTT / weather / value → cyan;
game-integrations → amber; sync-clocks → violet; HA-light-targets
→ signal). No JS changes — uses the section markup `CardSection`
already emits.
- [x] Graph editor nodes — body fill `--lux-bg-1` with hairline stroke,
hover bold-line, selected/running stroke `--ch-signal` with
drop-shadow glow. Title font switched from DM Sans to
`--font-display`; subtitle to mono uppercase wide-tracking.
Port-drop-target glow recoloured to `--ch-signal`. Port labels
adopt the mono caption treatment. Grid dots use `--lux-line`.
Running gradient stops switched from `--primary-color`/`--success-color`
to channel palette (signal → cyan → signal).
### Phase 5 — Modal restyle
- [x] `modal.css` — backdrop gains a radial dim + 6 px blur for stronger
separation. `.modal-content` gets a gradient background + hairline +
deep rack shadow. Channel-accent rule across the top edge driven by
`--modal-ch` (per-modal override). Corner bracket bottom-right on
desktop. `.modal-header` gains a vertical channel-color stripe to
the left of the title; `.modal-footer` picks up a hairline divider.
- [x] Per-modal channel mapping by modal ID:
- Target editors → green
- Input/Source editors → cyan
- Audio editors → magenta
- Automation / Scene / Game editors → violet
- Settings / API key / Setup / Notifications → amber
- Confirm dialog → coral
- [x] `components.css` — inputs use hairline borders, tabular-nums mono
for `input[type="number"]`, channel-green focus ring + glow. Buttons
use mono-uppercase type, signal-glow on primary, coral-glow on
danger. `<select>` 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.
File diff suppressed because it is too large Load Diff
+48
View File
@@ -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",
+3
View File
@@ -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"
},
+21 -3
View File
@@ -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}")
+2
View File
@@ -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"]
@@ -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}
+19
View File
@@ -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),
)
@@ -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
# ---------------------------------------------------------------------------
+39
View File
@@ -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 ─────────────────────────────────────────
@@ -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,7 +789,9 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
if rs.restart_task and not rs.restart_task.done():
rs.restart_task.cancel()
# Stop all processors
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:
@@ -793,6 +803,21 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
# (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):
@@ -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
+15 -2
View File
@@ -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}")
+2
View File
@@ -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';
+11 -7
View File
@@ -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 {
+87 -23
View File
@@ -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). */
+235 -139
View File
@@ -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 `<div class="subtab-section"
* data-card-section="…">` 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: '<angle>';
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);
}
/* 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 {
animation: none;
}
}
/* 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);
/* 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; }
}
@media (prefers-reduced-motion: reduce) {
.card-running::after {
animation: none;
background-position: 50% 0;
background-size: 60% 100%;
}
}
@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 {
+104 -30
View File
@@ -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 */
@@ -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;
}
}
File diff suppressed because it is too large Load Diff
+100 -3
View File
@@ -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;
}
+46 -28
View File
@@ -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 ── */
+449 -97
View File
@@ -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;
}
}
+102 -16
View File
@@ -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 {
+124 -11
View File
@@ -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 {
+42 -16
View File
@@ -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 */
+314
View File
@@ -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;
}
}
+108 -32
View File
@@ -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,
+142 -56
View File
@@ -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;
}
Binary file not shown.
+25
View File
@@ -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();
+13
View File
@@ -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;
+7 -2
View File
@@ -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;
}
@@ -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;
}
@@ -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<string, string> {
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 });
@@ -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;
@@ -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 <body> has overflow:hidden / clip
* / auto on x or y. Used by the picker toggle to decide whether it must
* detach the popover to <body> 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
@@ -133,9 +133,54 @@ export function renderNodes(group: SVGGElement, nodeMap: Map<string, GraphNode>,
for (const node of nodeMap.values()) {
const g = renderNode(node, callbacks);
group.appendChild(g);
// Now that the <g> 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 `<text>` 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<SVGTextElement>('.graph-node-title');
const subtitle = nodeG.querySelector<SVGTextElement>('.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);
}
@@ -23,6 +23,7 @@ export const flaskConical = '<path d="M14 2v6a2 2 0 0 0 .245.96l5.51 10.08A2 2 0
export const pencil = '<path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"/><path d="m15 5 4 4"/>';
export const play = '<path d="M5 5a2 2 0 0 1 3.008-1.728l11.997 6.998a2 2 0 0 1 .003 3.458l-12 7A2 2 0 0 1 5 19z"/>';
export const square = '<rect width="18" height="18" x="3" y="3" rx="2"/>';
export const circle = '<circle cx="12" cy="12" r="9"/>';
export const pause = '<rect x="14" y="3" width="5" height="18" rx="1"/><rect x="5" y="3" width="5" height="18" rx="1"/>';
export const settings = '<path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915"/><circle cx="12" cy="12" r="3"/>';
export const ruler = '<path d="M21.3 15.3a2.4 2.4 0 0 1 0 3.4l-2.6 2.6a2.4 2.4 0 0 1-3.4 0L2.7 8.7a2.41 2.41 0 0 1 0-3.4l2.6-2.6a2.41 2.41 0 0 1 3.4 0Z"/><path d="m14.5 12.5 2-2"/><path d="m11.5 9.5 2-2"/><path d="m8.5 6.5 2-2"/><path d="m17.5 15.5 2-2"/>';
@@ -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);
@@ -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);
@@ -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 = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon"><circle cx="9" cy="5" r="1"/><circle cx="9" cy="12" r="1"/><circle cx="9" cy="19" r="1"/><circle cx="15" cy="5" r="1"/><circle cx="15" cy="12" r="1"/><circle cx="15" cy="19" r="1"/></svg>';
const ICON_LOCK = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>';
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<string, string> = {
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<string, string> = {
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 = `
<header class="dash-cust-header">
<h2 id="dash-cust-title">${t('dashboard.customize.title')}</h2>
<button class="dash-cust-close" type="button" aria-label="${t('aria.close')}" onclick="closeDashboardCustomize()">${ICON_X}</button>
</header>
<div class="dash-cust-body" id="dash-cust-body"></div>
`;
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 `<button type="button" class="dash-cust-chip${active ? ' is-active' : ''}" data-preset="${name}">
${t('dashboard.customize.preset.' + name)}
</button>`;
}).join('');
const modifiedHint = layout.presetActive
? ''
: `<span class="dash-cust-modified">${t('dashboard.customize.modified')}</span>`;
return `<section class="dash-cust-section">
<h3 class="dash-cust-h3">${t('dashboard.customize.presets')}${modifiedHint}</h3>
<div class="dash-cust-chips">${chips}</div>
</section>`;
}
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 => `<button type="button" class="dash-cust-seg-btn${layout.global.width === o.v ? ' is-active' : ''}" data-global-width="${o.v}">${t(o.k)}</button>`).join('');
const animBtns = animOpts.map(o => `<button type="button" class="dash-cust-seg-btn${layout.global.animations === o.v ? ' is-active' : ''}" data-global-anim="${o.v}">${t(o.k)}</button>`).join('');
const modeBtns = modeOpts.map(o => `<button type="button" class="dash-cust-seg-btn${layout.global.perfMode === o.v ? ' is-active' : ''}" data-global-perfmode="${o.v}">${t(o.k)}</button>`).join('');
const windowBtns = windowOpts.map(w => `<button type="button" class="dash-cust-seg-btn${layout.global.perfWindow === w ? ' is-active' : ''}" data-global-perfwindow="${w}">${w >= 60 ? `${w / 60}m` : `${w}s`}</button>`).join('');
return `<section class="dash-cust-section">
<h3 class="dash-cust-h3">${t('dashboard.customize.global')}</h3>
<div class="dash-cust-row">
<label class="dash-cust-label">${t('dashboard.customize.width')}</label>
<div class="dash-cust-seg">${widthBtns}</div>
</div>
<div class="dash-cust-row">
<label class="dash-cust-label">${t('dashboard.customize.anim')}</label>
<div class="dash-cust-seg">${animBtns}</div>
</div>
<div class="dash-cust-row">
<label class="dash-cust-label">${t('dashboard.customize.perf_mode')}</label>
<div class="dash-cust-seg">${modeBtns}</div>
</div>
<div class="dash-cust-row">
<label class="dash-cust-label">${t('dashboard.customize.window')}</label>
<div class="dash-cust-seg">${windowBtns}</div>
</div>
</section>`;
}
function _renderSections(layout: DashboardLayoutV1): string {
const perfRow = (() => {
const perf = layout.sections.find(s => s.key === 'perf');
if (!perf) return '';
return `<div class="dash-cust-row dash-cust-row-fixed" data-section-key="perf">
<span class="dash-cust-row-label">
<span class="dash-cust-pin" title="${t('dashboard.customize.fixed_top')}">${ICON_LOCK}</span>
${t(SECTION_LABEL_KEYS.perf)}
</span>
${_eyeBtn(perf.visible, 'section', 'perf')}
</div>`;
})();
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 =>
`<button type="button" class="dash-cust-density${s.density === b.v ? ' is-active' : ''}" data-section-density="${key}" data-density="${b.v}" title="${t('dashboard.customize.density.' + b.v)}">${b.lbl}</button>`
).join('');
return `<div class="dash-cust-row dash-cust-row-drag" draggable="true" data-section-key="${key}">
<span class="dash-cust-grip" aria-hidden="true">${ICON_DRAG}</span>
<span class="dash-cust-row-label">${t(SECTION_LABEL_KEYS[key] || key)}</span>
<span class="dash-cust-density-group">${densityHtml}</span>
<button type="button" class="dash-cust-arrow" data-move="up" data-section-key="${key}" aria-label="↑"></button>
<button type="button" class="dash-cust-arrow" data-move="down" data-section-key="${key}" aria-label="↓"></button>
${_collapseBtn(s.collapsedDefault, 'section', key)}
${_eyeBtn(s.visible, 'section', key)}
</div>`;
}).join('');
return `<section class="dash-cust-section" data-section-list>
<h3 class="dash-cust-h3">${t('dashboard.customize.sections')}</h3>
${perfRow}
<div class="dash-cust-list" id="dash-cust-section-list">${rows}</div>
<p class="dash-cust-help">${t('dashboard.customize.drag_help')}</p>
</section>`;
}
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 = `<select class="dash-cust-mini-select" data-cell-mode="${c.key}" title="${t('dashboard.customize.perf_mode')}">${
modeOpts.map(m => `<option value="${m}"${c.mode === m ? ' selected' : ''}>${t('dashboard.customize.mode.' + m)}</option>`).join('')
}</select>`;
const windowSel = `<select class="dash-cust-mini-select" data-cell-window="${c.key}" title="${t('dashboard.customize.window')}">${
windowOpts.map(w => {
const lbl = w === 'inherit' ? t('dashboard.customize.mode.inherit') : (w >= 60 ? `${w / 60}m` : `${w}s`);
return `<option value="${w}"${c.window === w ? ' selected' : ''}>${lbl}</option>`;
}).join('')
}</select>`;
const yScaleSel = `<select class="dash-cust-mini-select" data-cell-yscale="${c.key}" title="${t('dashboard.customize.scale')}">${
yScaleOpts.map(y => `<option value="${y}"${c.yScale === y ? ' selected' : ''}>${t('dashboard.customize.yscale.' + y)}</option>`).join('')
}</select>`;
return `<div class="dash-cust-row dash-cust-row-drag dash-cust-cell-row" draggable="true" data-cell-key="${c.key}">
<div class="dash-cust-cell-top">
<span class="dash-cust-grip" aria-hidden="true">${ICON_DRAG}</span>
<span class="dash-cust-row-label">${t(PERF_CELL_LABEL_KEYS[c.key] || c.key)}</span>
<button type="button" class="dash-cust-arrow" data-cell-move="up" data-cell-key="${c.key}" aria-label="↑"></button>
<button type="button" class="dash-cust-arrow" data-cell-move="down" data-cell-key="${c.key}" aria-label="↓"></button>
${_eyeBtn(c.visible, 'cell', c.key)}
</div>
<div class="dash-cust-cell-opts">
<span class="dash-cust-cell-opt">
<span class="dash-cust-cell-opt-k">${t('dashboard.customize.mode_short')}</span>
${modeSel}
</span>
<span class="dash-cust-cell-opt">
<span class="dash-cust-cell-opt-k">${t('dashboard.customize.window_short')}</span>
${windowSel}
</span>
<span class="dash-cust-cell-opt">
<span class="dash-cust-cell-opt-k">${t('dashboard.customize.scale_short')}</span>
${yScaleSel}
</span>
</div>
</div>`;
}).join('');
return `<section class="dash-cust-section" data-cell-list>
<h3 class="dash-cust-h3">${t('dashboard.customize.perf_cells')}</h3>
<div class="dash-cust-list" id="dash-cust-cell-list">${rows}</div>
<p class="dash-cust-help">${t('dashboard.customize.cell_drag_help')}</p>
</section>`;
}
function _renderActions(): string {
return `<section class="dash-cust-section dash-cust-actions">
<button type="button" class="btn btn-secondary" data-action="export">${ICON_DOWNLOAD} ${t('dashboard.customize.export')}</button>
<button type="button" class="btn btn-secondary" data-action="import">${t('dashboard.customize.import')}</button>
<button type="button" class="btn btn-secondary" data-action="reset">${ICON_REFRESH} ${t('dashboard.customize.reset')}</button>
</section>`;
}
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 `<button type="button" class="dash-cust-eye${visible ? ' is-on' : ''}" ${dataAttr}="${key}" aria-pressed="${visible}" title="${label}" aria-label="${label}">${visible ? ICON_EYE : ICON_EYE_OFF}</button>`;
}
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 `<button type="button" class="dash-cust-arrow${collapsed ? ' is-active' : ''}" data-section-collapse-default="${key}" aria-pressed="${collapsed}" title="${label}" aria-label="${label}">▾</button>`;
}
// ── Handlers ─────────────────────────────────────────────────────────────
function _bindHandlers(root: HTMLElement): void {
// Presets
root.querySelectorAll<HTMLElement>('[data-preset]').forEach(btn => {
btn.addEventListener('click', () => {
const name = btn.dataset.preset!;
applyDashboardPreset(name);
});
});
// Global toggles
root.querySelectorAll<HTMLElement>('[data-global-width]').forEach(btn => {
btn.addEventListener('click', () => {
saveDashboardLayout(setGlobalConfig(getDashboardLayout(), { width: btn.dataset.globalWidth as Width }));
});
});
root.querySelectorAll<HTMLElement>('[data-global-anim]').forEach(btn => {
btn.addEventListener('click', () => {
saveDashboardLayout(setGlobalConfig(getDashboardLayout(), { animations: btn.dataset.globalAnim as AnimationsLevel }));
});
});
root.querySelectorAll<HTMLElement>('[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<HTMLElement>('[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<HTMLElement>('[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<HTMLElement>('[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<HTMLElement>('[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<HTMLElement>('[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<HTMLSelectElement>('[data-cell-mode]').forEach(sel => {
sel.addEventListener('change', () => {
const key = sel.dataset.cellMode!;
saveDashboardLayout(setPerfCellMode(getDashboardLayout(), key, sel.value as PerfMode));
});
});
root.querySelectorAll<HTMLSelectElement>('[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<HTMLElement>('[data-global-perfwindow]').forEach(btn => {
btn.addEventListener('click', () => {
const w = parseInt(btn.dataset.globalPerfwindow || '120', 10) as SampleWindow;
saveDashboardLayout(setGlobalPerfWindow(getDashboardLayout(), w));
});
});
root.querySelectorAll<HTMLSelectElement>('[data-cell-yscale]').forEach(sel => {
sel.addEventListener('change', () => {
const key = sel.dataset.cellYscale!;
saveDashboardLayout(setPerfCellYScale(getDashboardLayout(), key, sel.value as YScale));
});
});
root.querySelectorAll<HTMLElement>('[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<HTMLButtonElement>('[data-action="export"]');
if (exportBtn) exportBtn.addEventListener('click', _doExport);
const importBtn = root.querySelector<HTMLButtonElement>('[data-action="import"]');
if (importBtn) importBtn.addEventListener('click', _doImport);
const resetBtn = root.querySelector<HTMLButtonElement>('[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<HTMLElement>(listSelector);
if (!list) return;
let dragKey: string | null = null;
list.querySelectorAll<HTMLElement>('.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<HTMLElement>('.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();
}
@@ -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<string, unknown>;
}
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<string, () => 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<typeof setTimeout> | 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<void> {
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<void> {
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<GlobalConfig>): 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<DashboardLayoutV1>;
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<string, boolean>;
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;
}
+390 -118
View File
@@ -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} <span data-i18n="dashboard.errors">${labelText}</span>`;
}
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} <span>${label}</span>`;
}
}
}
@@ -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 `<div class="dashboard-target dashboard-autostart dashboard-card-link" data-integration-id="${conn.source_id}" onclick="if(!event.target.closest('button')){navigateToCard('integrations','home_assistant','ha-sources','data-id','${conn.source_id}')}">
<div class="dashboard-target-info">
<span class="dashboard-target-icon">${ICON_HOME}</span>
<div>
<div class="dashboard-target-name"><span class="dashboard-target-name-text">${escapeHtml(conn.name)}</span>${statusDot}</div>
<div class="dashboard-target-subtitle">${escapeHtml(subtitle)}</div>
return `<div class="dashboard-target dashboard-autostart dashboard-card-link ${conn.connected ? 'is-running' : ''}" data-integration-id="${conn.source_id}" onclick="if(!event.target.closest('button')){navigateToCard('integrations','home_assistant','ha-sources','data-id','${conn.source_id}')}">
<div class="mod-head">
<div class="mod-id">
<span class="mod-badge">HA · ${escapeHtml(short)}</span>
<div class="mod-name"><span>${escapeHtml(conn.name)}</span>${statusDot}</div>
<div class="mod-meta">${escapeHtml(subtitle)}</div>
</div>
<div class="mod-leds" aria-hidden="true">
<span class="${ledCls}"></span>
</div>
</div>
<div class="mod-foot">
<div class="mod-patch"><span class="patch-dot${patchLive}"></span><span>${patchLabel}</span></div>
</div>
</div>`;
}
@@ -283,20 +352,44 @@ function _renderMQTTIntegrationCard(conn: MQTTConnectionStatus): string {
const healthTitle = conn.connected ? t('mqtt_source.connected') : t('mqtt_source.disconnected');
const statusDot = `<span class="health-dot ${healthClass}" title="${escapeHtml(healthTitle)}" role="status"></span>`;
const subtitle = conn.connected ? escapeHtml(conn.broker) : t('mqtt_source.disconnected');
const short = (conn.source_id || '').replace(/^src_/, '').slice(0, 2).toUpperCase() || 'MQ';
const ledCls = conn.connected ? 'led on blink' : 'led';
const patchLabel = conn.connected ? 'ONLINE' : 'OFFLINE';
const patchLive = conn.connected ? ' is-live' : '';
return `<div class="dashboard-target dashboard-autostart dashboard-card-link" data-integration-id="${conn.source_id}" onclick="if(!event.target.closest('button')){navigateToCard('integrations','mqtt','mqtt-sources','data-id','${conn.source_id}')}">
<div class="dashboard-target-info">
<span class="dashboard-target-icon">${ICON_RADIO}</span>
<div>
<div class="dashboard-target-name"><span class="dashboard-target-name-text">${escapeHtml(conn.name)}</span>${statusDot}</div>
<div class="dashboard-target-subtitle">${escapeHtml(subtitle)}</div>
return `<div class="dashboard-target dashboard-autostart dashboard-card-link ${conn.connected ? 'is-running' : ''}" data-integration-id="${conn.source_id}" onclick="if(!event.target.closest('button')){navigateToCard('integrations','mqtt','mqtt-sources','data-id','${conn.source_id}')}">
<div class="mod-head">
<div class="mod-id">
<span class="mod-badge">MQTT · ${escapeHtml(short)}</span>
<div class="mod-name"><span>${escapeHtml(conn.name)}</span>${statusDot}</div>
<div class="mod-meta">${subtitle}</div>
</div>
<div class="mod-leds" aria-hidden="true">
<span class="${ledCls}"></span>
</div>
</div>
<div class="mod-foot">
<div class="mod-patch"><span class="patch-dot${patchLive}"></span><span>${patchLabel}</span></div>
</div>
</div>`;
}
function _updateIntegrationsInPlace(haStatus: HomeAssistantStatusResponse, mqttStatus?: MQTTStatusResponse): void {
// Update health dots and subtitles for each integration card
const applyState = (card: Element, connected: boolean, patchLabel: string): void => {
card.classList.toggle('is-running', connected);
const led = card.querySelector('.mod-leds .led');
if (led) {
led.className = connected ? 'led on blink' : 'led';
}
const patch = card.querySelector('.mod-patch');
if (patch) {
const dot = patch.querySelector('.patch-dot');
if (dot) dot.className = connected ? 'patch-dot is-live' : 'patch-dot';
const label = patch.querySelector('span:last-child');
if (label) label.textContent = patchLabel;
}
};
for (const conn of haStatus.connections) {
const card = document.querySelector(`[data-integration-id="${CSS.escape(conn.source_id)}"]`);
if (!card) continue;
@@ -307,14 +400,14 @@ function _updateIntegrationsInPlace(haStatus: HomeAssistantStatusResponse, mqttS
? `${t('ha_source.connected')}${conn.entity_count} ${t('dashboard.integrations.entities')}`
: t('ha_source.disconnected'));
}
const subtitle = card.querySelector('.dashboard-target-subtitle');
if (subtitle) {
subtitle.textContent = conn.connected
const meta = card.querySelector('.mod-meta');
if (meta) {
meta.textContent = conn.connected
? `${conn.entity_count} ${t('dashboard.integrations.entities')}`
: t('ha_source.disconnected');
}
applyState(card, conn.connected, conn.connected ? 'ONLINE' : 'OFFLINE');
}
// Update MQTT integration cards
if (mqttStatus) {
for (const conn of mqttStatus.connections) {
const card = document.querySelector(`[data-integration-id="${CSS.escape(conn.source_id)}"]`);
@@ -324,10 +417,11 @@ function _updateIntegrationsInPlace(haStatus: HomeAssistantStatusResponse, mqttS
dot.className = `health-dot ${conn.connected ? 'health-online' : 'health-offline'}`;
dot.setAttribute('title', conn.connected ? t('mqtt_source.connected') : t('mqtt_source.disconnected'));
}
const subtitle = card.querySelector('.dashboard-target-subtitle');
if (subtitle) {
subtitle.textContent = conn.connected ? conn.broker : t('mqtt_source.disconnected');
const meta = card.querySelector('.mod-meta');
if (meta) {
meta.textContent = conn.connected ? conn.broker : t('mqtt_source.disconnected');
}
applyState(card, conn.connected, conn.connected ? 'ONLINE' : 'OFFLINE');
}
}
// Update section count badge
@@ -345,41 +439,41 @@ function renderDashboardSyncClock(clock: SyncClock): string {
? `dashboardPauseClock('${clock.id}')`
: `dashboardResumeClock('${clock.id}')`;
const toggleTitle = clock.is_running ? t('sync_clock.action.pause') : t('sync_clock.action.resume');
const subtitle = [
`<span class="dashboard-clock-speed">${clock.speed}x</span>`,
const metaParts = [
`<span class="dashboard-clock-speed">${clock.speed}×</span>`,
clock.description ? escapeHtml(clock.description) : '',
].filter(Boolean).join(' · ');
].filter(Boolean);
const short = (clock.id || '').replace(/^sc_/, '').slice(0, 2).toUpperCase() || 'CK';
const ledCls = clock.is_running ? 'led on blink' : 'led';
const patchLabel = clock.is_running ? 'TICKING' : 'PAUSED';
const patchLive = clock.is_running ? ' is-live' : '';
const btnCls = clock.is_running ? 'mod-btn mod-btn-stop' : 'mod-btn mod-btn-go';
const btnLabel = clock.is_running ? (t('sync_clock.action.pause') || 'Pause') : (t('sync_clock.action.resume') || 'Resume');
const scStyle = cardColorStyle(clock.id);
return `<div class="dashboard-target dashboard-autostart dashboard-card-link" data-sync-clock-id="${clock.id}" onclick="if(!event.target.closest('button')){navigateToCard('streams','sync','sync-clocks','data-id','${clock.id}')}"${scStyle ? ` style="${scStyle}"` : ''}>
<div class="dashboard-target-info">
<span class="dashboard-target-icon">${ICON_CLOCK}</span>
<div>
<div class="dashboard-target-name">${escapeHtml(clock.name)}</div>
${subtitle ? `<div class="dashboard-target-subtitle">${subtitle}</div>` : ''}
return `<div class="dashboard-target dashboard-autostart dashboard-card-link ${clock.is_running ? 'is-running' : ''}" data-sync-clock-id="${clock.id}" onclick="if(!event.target.closest('button')){navigateToCard('streams','sync','sync-clocks','data-id','${clock.id}')}"${scStyle ? ` style="${scStyle}"` : ''}>
<div class="mod-head">
<div class="mod-id">
<span class="mod-badge">CLK · ${escapeHtml(short)}</span>
<div class="mod-name"><span>${escapeHtml(clock.name)}</span></div>
${metaParts.length ? `<div class="mod-meta">${metaParts.join(' · ')}</div>` : ''}
</div>
<div class="mod-leds" aria-hidden="true">
<span class="${ledCls}"></span>
</div>
</div>
<div class="dashboard-target-actions">
<button class="dashboard-action-btn ${clock.is_running ? 'stop' : 'start'}" onclick="${toggleAction}" title="${toggleTitle}">
${clock.is_running ? ICON_PAUSE : ICON_START}
</button>
<button class="dashboard-action-btn" onclick="dashboardResetClock('${clock.id}')" title="${t('sync_clock.action.reset')}">
${ICON_CLOCK}
</button>
<div class="mod-foot">
<div class="mod-patch"><span class="patch-dot${patchLive}"></span><span>${patchLabel}</span></div>
<button class="${btnCls}" onclick="event.stopPropagation(); ${toggleAction}" title="${toggleTitle}">${clock.is_running ? ICON_PAUSE : ICON_START} <span>${btnLabel}</span></button>
<button class="mod-btn" onclick="event.stopPropagation(); dashboardResetClock('${clock.id}')" title="${t('sync_clock.action.reset')}">${ICON_CLOCK}</button>
</div>
</div>`;
}
function _renderPollIntervalSelect(): string {
const sec = Math.round(dashboardPollInterval / 1000);
return `<span class="dashboard-poll-wrap"><input type="range" class="dashboard-poll-slider" min="1" max="10" value="${sec}" oninput="changeDashboardPollInterval(this.value)" title="${t('dashboard.poll_interval')}"><span class="dashboard-poll-value">${sec}s</span></span>`;
}
let _pollDebounce: ReturnType<typeof setTimeout> | undefined = undefined;
/** Called from the transport-bar poll cycler (and any legacy callers
* that might still reference `window.changeDashboardPollInterval`). */
export function changeDashboardPollInterval(value: string | number): void {
const label = document.querySelector('.dashboard-poll-value');
if (label) label.textContent = `${value}s`;
clearTimeout(_pollDebounce);
_pollDebounce = setTimeout(() => {
const ms = parseInt(String(value), 10) * 1000;
@@ -391,8 +485,22 @@ export function changeDashboardPollInterval(value: string | number): void {
}
function _getCollapsedSections(): Record<string, boolean> {
try { return JSON.parse(localStorage.getItem(DASHBOARD_COLLAPSED_KEY) ?? '{}') || {}; }
catch { return {}; }
let userOverrides: Record<string, boolean> = {};
try { userOverrides = JSON.parse(localStorage.getItem(DASHBOARD_COLLAPSED_KEY) ?? '{}') || {}; }
catch { /* ignore */ }
// Layered: layout's `collapsedDefault` is the floor; the user's
// per-session toggle overrides it. Lets users start every section
// collapsed via Customize without losing in-session expand/collapse.
const merged: Record<string, boolean> = {};
for (const s of getOrderedSections()) {
merged[s.key] = userOverrides[s.key] ?? s.collapsedDefault;
}
// Subsections like 'running' / 'stopped' aren't in the layout — preserve
// user overrides as-is.
for (const k of Object.keys(userOverrides)) {
if (!(k in merged)) merged[k] = userOverrides[k];
}
return merged;
}
export function toggleDashboardSection(sectionKey: string): void {
@@ -439,11 +547,17 @@ function _sectionHeader(sectionKey: string, label: string, count: number | strin
const collapsed = _getCollapsedSections();
const isCollapsed = !!collapsed[sectionKey];
const chevronStyle = isCollapsed ? '' : ' style="transform:rotate(90deg)"';
// Only render the count pill when there's an actual count to show.
// The Performance header passes '' (no item count makes sense here)
// and was rendering an empty grey badge next to the title.
const countHtml = (count !== '' && count != null)
? `<span class="dashboard-section-count">${count}</span>`
: '';
return `<div class="dashboard-section-header" data-dashboard-section="${sectionKey}">
<span class="dashboard-section-toggle" onclick="toggleDashboardSection('${sectionKey}')">
<span class="dashboard-section-chevron"${chevronStyle}>&#9654;</span>
${label}
<span class="dashboard-section-count">${count}</span>
${countHtml}
</span>
${extraHtml}
</div>`;
@@ -464,7 +578,7 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
try {
// Fire all requests in a single batch to avoid sequential RTTs
const [targets, automationsResp, devicesArr, cssArr, batchStatesResp, batchMetricsResp, scenePresets, syncClocksResp, haStatusResp, mqttStatusResp] = await Promise.all([
const [targets, automationsResp, devicesArr, cssArr, batchStatesResp, batchMetricsResp, scenePresets, syncClocksResp, haStatusResp, mqttStatusResp, deviceStatesResp] = await Promise.all([
outputTargetsCache.fetch().catch((): any[] => []),
fetchWithAuth('/automations').catch(() => null),
devicesCache.fetch().catch((): any[] => []),
@@ -475,8 +589,21 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
fetchWithAuth('/sync-clocks').catch(() => null),
fetchWithAuth('/home-assistant/status').catch(() => null),
fetchWithAuth('/mqtt/status').catch(() => null),
fetchWithAuth('/devices/batch/states').catch(() => null),
]);
// Devices cell — online/offline count + dot strip. Independent of
// the running-target set: shows every configured device regardless
// of whether any target is currently streaming to it.
if (deviceStatesResp && deviceStatesResp.ok) {
try {
const payload = await deviceStatesResp.json();
const statesObj = payload.states || {};
const deviceStateList = Object.values(statesObj) as any[];
updateDevices(deviceStateList);
} catch { /* ignore parse errors */ }
}
const automationsData = automationsResp && automationsResp.ok ? await automationsResp.json() : { automations: [] };
const automations = automationsData.automations || [];
const devicesMap = {};
@@ -510,6 +637,39 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
const running = enriched.filter(t => t.state && t.state.processing);
const stopped = enriched.filter(t => !t.state || !t.state.processing);
updateTabBadge('targets', running.length);
_updateTransportStatus(running.length);
updateActivePatches(
running.map(r => ({
id: r.id,
name: r.name,
fps: r.state?.fps_actual != null ? r.state.fps_actual
: r.state?.fps_current != null ? r.state.fps_current
: undefined,
})),
enriched.length,
);
// Aggregate throughput across all running targets — fills the
// Total FPS cell in the perf strip. `fpsTargetSum` is drawn as
// a dashed reference line ("max achievable throughput").
const fpsValues: number[] = [];
let fpsSum = 0;
let fpsTargetSum = 0;
for (const r of running) {
const fps = r.state?.fps_actual != null ? r.state.fps_actual
: r.state?.fps_current != null ? r.state.fps_current
: null;
if (fps != null) {
fpsValues.push(fps);
fpsSum += fps;
}
const tgt = r.state?.fps_target
?? (r.settings || {}).fps
?? r.update_rate;
if (typeof tgt === 'number' && tgt > 0) fpsTargetSum += tgt;
}
const fpsMin = fpsValues.length > 0 ? Math.min(...fpsValues) : null;
const fpsMax = fpsValues.length > 0 ? Math.max(...fpsValues) : null;
updateTotalFps(fpsSum, fpsMin, fpsMax, fpsTargetSum);
// Check if we can do an in-place metrics update (same targets, not first load)
const newRunningIds = running.map(t => t.id).sort().join(',');
@@ -539,6 +699,14 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
return;
}
// Build each section's HTML into a map so we can render in
// user-defined order (layout-driven). Sections with no content
// (e.g. `automations` when there are zero automations) produce
// null and are skipped, unless the user explicitly toggled
// them to show via Customize (we don't yet plumb a "show
// empty CTA" mode here; that's a v1.1 follow-up).
const sectionFragments: Record<string, string> = {};
// Integrations section (HA + MQTT sources)
const totalIntSources = haStatus.total_sources + mqttStatus.total_sources;
const totalIntConnected = haStatus.connected_count + mqttStatus.connected_count;
@@ -546,7 +714,7 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
const haCards = haStatus.connections.map(c => _renderIntegrationCard(c)).join('');
const mqttCards = mqttStatus.connections.map(c => _renderMQTTIntegrationCard(c)).join('');
const intGrid = `<div class="dashboard-integrations-grid">${haCards}${mqttCards}</div>`;
dynamicHtml += `<div class="dashboard-section">
sectionFragments['integrations'] = `<div class="dashboard-section" data-section="integrations">
${_sectionHeader('integrations', t('dashboard.section.integrations'), `${totalIntConnected}/${totalIntSources}`)}
${_sectionContent('integrations', intGrid)}
</div>`;
@@ -558,10 +726,11 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
updateTabBadge('automations', activeAutomations.length);
const sceneMap = new Map(scenePresets.map(s => [s.id, s]));
const automationItems = [...activeAutomations, ...inactiveAutomations].map(a => renderDashboardAutomation(a, sceneMap)).join('');
const automationGrid = `<div class="dashboard-autostart-grid">${automationItems}</div>`;
dynamicHtml += `<div class="dashboard-section">
sectionFragments['automations'] = `<div class="dashboard-section" data-section="automations">
${_sectionHeader('automations', t('dashboard.section.automations'), automations.length)}
${_sectionContent('automations', automationItems)}
${_sectionContent('automations', automationGrid)}
</div>`;
}
@@ -569,7 +738,7 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
if (scenePresets.length > 0) {
const sceneSec = renderScenePresetsSection(scenePresets);
if (sceneSec && typeof sceneSec === 'object') {
dynamicHtml += `<div class="dashboard-section">
sectionFragments['scenes'] = `<div class="dashboard-section" data-section="scenes">
${_sectionHeader('scenes', t('dashboard.section.scenes'), scenePresets.length, sceneSec.headerExtra)}
${_sectionContent('scenes', sceneSec.content)}
</div>`;
@@ -580,7 +749,7 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
if (syncClocks.length > 0) {
const clockCards = syncClocks.map(c => renderDashboardSyncClock(c)).join('');
const clockGrid = `<div class="dashboard-autostart-grid">${clockCards}</div>`;
dynamicHtml += `<div class="dashboard-section">
sectionFragments['sync-clocks'] = `<div class="dashboard-section" data-section="sync-clocks">
${_sectionHeader('sync-clocks', t('dashboard.section.sync_clocks'), syncClocks.length)}
${_sectionContent('sync-clocks', clockGrid)}
</div>`;
@@ -609,31 +778,62 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
</div>`;
}
dynamicHtml += `<div class="dashboard-section">
sectionFragments['targets'] = `<div class="dashboard-section" data-section="targets">
${_sectionHeader('targets', t('dashboard.section.targets'), targets.length)}
${_sectionContent('targets', targetsInner)}
</div>`;
}
// Now assemble in layout-driven order, skipping invisible
// sections and the perf section (which is always rendered
// separately at the top for chart-persistence reasons).
for (const section of getOrderedSections()) {
if (section.key === 'perf') continue;
if (!section.visible) continue;
const html = sectionFragments[section.key];
if (html) dynamicHtml += html;
}
}
// First load: build everything in one innerHTML to avoid flicker
// First load: build everything in one innerHTML to avoid flicker.
// Poll-interval control was moved to the transport bar (it's global,
// not dashboard-specific) — toolbar now keeps the tutorial help
// button + the new "Customize" gear that opens the layout panel.
const isFirstLoad = !container.querySelector('.dashboard-perf-persistent');
const pollSelect = _renderPollIntervalSelect();
const toolbar = `<div class="stream-tab-bar"><span class="cs-expand-collapse-group">${pollSelect}<button class="tutorial-trigger-btn" onclick="startDashboardTutorial()" title="${t('tour.restart')}">${ICON_HELP}</button></span></div>`;
const perfVisible = isSectionVisible('perf');
const customizeBtn = `<button class="tutorial-trigger-btn" onclick="openDashboardCustomize()" title="${t('dashboard.customize.title')}" aria-label="${t('dashboard.customize.title')}">${ICON_SETTINGS}</button>`;
const tutorialBtn = `<button class="tutorial-trigger-btn" onclick="startDashboardTutorial()" title="${t('tour.restart')}">${ICON_HELP}</button>`;
const toolbar = `<div class="stream-tab-bar"><span class="cs-expand-collapse-group">${customizeBtn}${tutorialBtn}</span></div>`;
if (isFirstLoad) {
container.innerHTML = `${toolbar}<div class="dashboard-perf-persistent dashboard-section">
const perfBlock = perfVisible
? `<div class="dashboard-perf-persistent dashboard-section" data-section="perf">
${_sectionHeader('perf', t('dashboard.section.performance'), '', renderPerfModeToggle())}
${_sectionContent('perf', renderPerfSection())}
</div>
<div class="dashboard-dynamic">${dynamicHtml}</div>`;
await initPerfCharts();
</div>`
: '';
container.innerHTML = `${toolbar}${perfBlock}<div class="dashboard-dynamic">${dynamicHtml}</div>`;
_applyGlobalLayoutAttrs();
if (perfVisible) await initPerfCharts();
// Event delegation for scene preset cards (attached once, works across innerHTML refreshes)
initScenePresetDelegation(container);
} else {
// Toggle perf visibility on subsequent renders without
// destroying its DOM (charts persist).
const existingPerf = container.querySelector('.dashboard-perf-persistent') as HTMLElement | null;
if (existingPerf) {
existingPerf.style.display = perfVisible ? '' : 'none';
}
const dynamic = container.querySelector('.dashboard-dynamic');
if (dynamic && dynamic.innerHTML !== dynamicHtml) {
dynamic.innerHTML = dynamicHtml;
}
_applyGlobalLayoutAttrs();
}
// Apply per-section density tags so CSS selectors like
// `.dashboard-section[data-density="dense"]` can take effect.
for (const s of getOrderedSections()) {
const el = container.querySelector(`.dashboard-section[data-section="${CSS.escape(s.key)}"]`) as HTMLElement | null;
if (el) el.dataset.density = s.density;
}
_lastRunningIds = runningIds;
_lastSyncClockIds = syncClocks.map(c => `${c.id}:${c.is_running}`).sort().join(',');
@@ -670,6 +870,9 @@ function renderDashboardTarget(target: any, isRunning: boolean, devicesMap: Reco
const device = target.device_id ? devicesMap[target.device_id] : null;
if (device) {
subtitleParts.push((device.device_type || '').toUpperCase());
if (device.led_count) {
subtitleParts.push(`${device.led_count} LED`);
}
}
}
const cssId = target.color_strip_source_id || '';
@@ -681,6 +884,17 @@ function renderDashboardTarget(target: any, isRunning: boolean, devicesMap: Reco
}
}
// Short channel label for the badge — first 2 chars of id hash after the
// `ot_` prefix, uppercased. Stable per target, consistent with the
// "CH·XX" convention in the mockup without needing a position counter.
const rawId = (target.id || '').replace(/^ot_/, '');
const chLabel = (rawId.slice(0, 2) || 'XX').toUpperCase();
const typeLabel2 = isLed
? ((target.device_id && devicesMap[target.device_id]?.device_type) || 'LED').toUpperCase()
: isHALight ? 'HA'
: 'KC';
const badgeText = `CH·${chLabel} · ${typeLabel2}`;
if (isRunning) {
const fpsTarget = state.fps_target || (target.settings || {}).fps || target.update_rate || '-';
const fpsCurrent = isHALight ? fpsTarget : (state.fps_current ?? 0);
@@ -706,47 +920,55 @@ function renderDashboardTarget(target: any, isRunning: boolean, devicesMap: Reco
}
const cStyle = cardColorStyle(target.id);
return `<div class="dashboard-target dashboard-card-link" data-target-id="${target.id}" onclick="${navOnclick}"${cStyle ? ` style="${cStyle}"` : ''}>
<div class="dashboard-target-info">
<span class="dashboard-target-icon">${icon}</span>
<div>
<div class="dashboard-target-name"><span class="dashboard-target-name-text">${escapeHtml(target.name)}</span>${healthDot}</div>
${subtitleParts.length ? `<div class="dashboard-target-subtitle">${escapeHtml(subtitleParts.join(' · '))}</div>` : ''}
return `<div class="dashboard-target dashboard-card-link is-running" data-target-id="${target.id}" onclick="${navOnclick}"${cStyle ? ` style="${cStyle}"` : ''}>
<div class="mod-head">
<div class="mod-id">
<span class="mod-badge">${escapeHtml(badgeText)}</span>
<div class="mod-name"><span>${escapeHtml(target.name)}</span>${healthDot}</div>
${subtitleParts.length ? `<div class="mod-meta">${escapeHtml(subtitleParts.join(' · '))}</div>` : ''}
</div>
<div class="mod-leds" aria-hidden="true">
<span class="led on blink"></span>
<span class="led on blink"></span>
<span class="led on blink"></span>
</div>
</div>
<div class="dashboard-target-metrics">
<div class="dashboard-metric dashboard-fps-metric">
<div class="dashboard-fps-sparkline">
<canvas id="dashboard-fps-${target.id}" data-fps-target="${fpsTarget}"></canvas>
<div class="mod-metrics">
<div class="mod-metric" title="${t('dashboard.fps') || 'FPS'}">
<span class="k">FPS</span>
<span class="v signal" data-fps-text="${target.id}">${fpsCurrent}<span class="dashboard-fps-target">/${fpsTarget}</span><span class="dashboard-fps-avg">avg ${fpsActual}</span></span>
<canvas class="mod-metric-spark-canvas" id="dashboard-fps-${target.id}" data-fps-target="${fpsTarget}"></canvas>
</div>
<div class="dashboard-fps-label">
<span class="dashboard-metric-value" data-fps-text="${target.id}">${fpsCurrent}<span class="dashboard-fps-target">/${fpsTarget}</span><span class="dashboard-fps-avg">avg ${fpsActual}</span></span>
<div class="mod-metric" title="${t('dashboard.uptime')}">
<span class="k">${ICON_CLOCK} <span data-i18n="dashboard.uptime">Uptime</span></span>
<span class="v" data-uptime-text="${target.id}">${uptime}</span>
</div>
<div class="mod-metric" title="${t('dashboard.errors')}" data-errors-cell="${target.id}">
<span class="k">${errors > 0 ? ICON_WARNING : ICON_OK} <span data-i18n="dashboard.errors">Errors</span></span>
<span class="v${errors > 0 ? ' has-errors' : ''}" data-errors-text="${target.id}" title="${errors}">${formatCompact(errors)}</span>
</div>
</div>
<div class="dashboard-metric" title="${t('dashboard.uptime')}">
<div class="dashboard-metric-value" data-uptime-text="${target.id}">${ICON_CLOCK} ${uptime}</div>
</div>
<div class="dashboard-metric" title="${t('dashboard.errors')}">
<div class="dashboard-metric-value" data-errors-text="${target.id}" title="${errors}">${errors > 0 ? ICON_WARNING : ICON_OK} ${formatCompact(errors)}</div>
</div>
</div>
<div class="dashboard-target-actions">
<button class="dashboard-action-btn stop" onclick="dashboardStopTarget('${target.id}')" title="${t('device.button.stop')}">${ICON_STOP_PLAIN}</button>
<div class="mod-foot">
<div class="mod-patch"><span class="patch-dot is-live"></span><span>PATCHED</span></div>
<button class="mod-btn mod-btn-stop" onclick="event.stopPropagation(); dashboardStopTarget('${target.id}')" title="${t('device.button.stop')}">${ICON_STOP_PLAIN} <span>${t('device.button.stop') || 'Stop'}</span></button>
</div>
</div>`;
} else {
const cStyle2 = cardColorStyle(target.id);
return `<div class="dashboard-target dashboard-card-link" onclick="${navOnclick}"${cStyle2 ? ` style="${cStyle2}"` : ''}>
<div class="dashboard-target-info">
<span class="dashboard-target-icon">${icon}</span>
<div>
<div class="dashboard-target-name">${escapeHtml(target.name)}</div>
${subtitleParts.length ? `<div class="dashboard-target-subtitle">${escapeHtml(subtitleParts.join(' · '))}</div>` : ''}
return `<div class="dashboard-target dashboard-card-link" data-target-id="${target.id}" onclick="${navOnclick}"${cStyle2 ? ` style="${cStyle2}"` : ''}>
<div class="mod-head">
<div class="mod-id">
<span class="mod-badge">${escapeHtml(badgeText)}</span>
<div class="mod-name"><span>${escapeHtml(target.name)}</span></div>
${subtitleParts.length ? `<div class="mod-meta">${escapeHtml(subtitleParts.join(' · '))}</div>` : ''}
</div>
<div class="mod-leds" aria-hidden="true">
<span class="led"></span>
</div>
</div>
<div class="dashboard-target-metrics"></div>
<div class="dashboard-target-actions">
<button class="dashboard-action-btn start" onclick="dashboardStartTarget('${target.id}')" title="${t('device.button.start')}">${ICON_START}</button>
<div class="mod-foot">
<div class="mod-patch"><span class="patch-dot"></span><span>STANDBY</span></div>
<button class="mod-btn mod-btn-go" onclick="event.stopPropagation(); dashboardStartTarget('${target.id}')" title="${t('device.button.start')}">${ICON_START} <span>${t('device.button.start') || 'Start'}</span></button>
</div>
</div>`;
}
@@ -772,31 +994,41 @@ function renderDashboardAutomation(automation: Automation, sceneMap: Map<string,
condSummary = parts.join(logic);
}
const statusBadge = isDisabled
? `<span class="dashboard-badge-stopped">${t('automations.status.disabled')}</span>`
: isActive
? `<span class="dashboard-badge-active">${t('automations.status.active')}</span>`
: `<span class="dashboard-badge-stopped">${t('automations.status.inactive')}</span>`;
// Scene info
const scene = automation.scene_preset_id ? sceneMap.get(automation.scene_preset_id) : null;
const sceneName = scene ? escapeHtml(scene.name) : t('automations.scene.none_selected');
const short = (automation.id || '').replace(/^auto_/, '').slice(0, 2).toUpperCase() || 'AU';
const ledCls = isActive ? 'led on blink' : (isDisabled ? 'led' : 'led on');
const patchLabel = isDisabled
? (t('automations.status.disabled') || 'DISABLED').toUpperCase()
: isActive
? (t('automations.status.active') || 'ACTIVE').toUpperCase()
: (t('automations.status.inactive') || 'STANDBY').toUpperCase();
const patchLive = isActive ? ' is-live' : '';
const btnCls = automation.enabled ? 'mod-btn mod-btn-stop' : 'mod-btn mod-btn-go';
const btnLabel = automation.enabled
? (t('automations.action.disable') || 'Disable')
: (t('automations.action.enable') || t('automations.status.active') || 'Enable');
const metaLines: string[] = [];
if (condSummary) metaLines.push(escapeHtml(condSummary));
metaLines.push(`${ICON_SCENE} ${sceneName}`);
const aStyle = cardColorStyle(automation.id);
return `<div class="dashboard-target dashboard-automation dashboard-card-link" data-automation-id="${automation.id}" onclick="if(!event.target.closest('button')){navigateToCard('automations',null,'automations','data-automation-id','${automation.id}')}"${aStyle ? ` style="${aStyle}"` : ''}>
<div class="dashboard-target-info">
<span class="dashboard-target-icon">${ICON_AUTOMATION}</span>
<div>
<div class="dashboard-target-name">${escapeHtml(automation.name)}</div>
${condSummary ? `<div class="dashboard-target-subtitle">${escapeHtml(condSummary)}</div>` : ''}
<div class="dashboard-target-subtitle">${ICON_SCENE} ${sceneName}</div>
return `<div class="dashboard-target dashboard-automation dashboard-card-link ${isActive ? 'is-running' : ''}" data-automation-id="${automation.id}" onclick="if(!event.target.closest('button')){navigateToCard('automations',null,'automations','data-automation-id','${automation.id}')}"${aStyle ? ` style="${aStyle}"` : ''}>
<div class="mod-head">
<div class="mod-id">
<span class="mod-badge">AUTO · ${escapeHtml(short)}</span>
<div class="mod-name"><span>${escapeHtml(automation.name)}</span></div>
<div class="mod-meta">${metaLines.join(' · ')}</div>
</div>
${statusBadge}
<div class="mod-leds" aria-hidden="true">
<span class="${ledCls}"></span>
</div>
<div class="dashboard-target-actions">
<button class="dashboard-action-btn ${automation.enabled ? 'stop' : 'start'}" onclick="dashboardToggleAutomation('${automation.id}', ${!automation.enabled})" title="${automation.enabled ? t('automations.action.disable') : t('automations.status.active')}">
${automation.enabled ? ICON_STOP_PLAIN : ICON_START}
</button>
</div>
<div class="mod-foot">
<div class="mod-patch"><span class="patch-dot${patchLive}"></span><span>${patchLabel}</span></div>
<button class="${btnCls}" onclick="event.stopPropagation(); dashboardToggleAutomation('${automation.id}', ${!automation.enabled})" title="${automation.enabled ? t('automations.action.disable') : t('automations.status.active')}">${automation.enabled ? ICON_STOP_PLAIN : ICON_START} <span>${btnLabel}</span></button>
</div>
</div>`;
}
@@ -944,6 +1176,46 @@ document.addEventListener('languageChanged', () => {
loadDashboard();
});
// Live-preview: re-render the dashboard whenever the customize panel
// changes the saved layout. Uses a debounce so dragging or rapid
// toggling doesn't thrash the DOM. The perf strip is preserved across
// re-renders (DOM persistence), so toggling its visibility is the
// only re-init path that needs `forceFullRender`.
let _layoutChangeRenderTimer: ReturnType<typeof setTimeout> | undefined;
subscribeDashboardLayout(() => {
if (!apiKey) return;
if (!_isDashboardActive()) return;
clearTimeout(_layoutChangeRenderTimer);
_layoutChangeRenderTimer = setTimeout(() => {
// Invalidate the in-place-update optimization in `loadDashboard`
// — section HTML must be rebuilt when sections reorder, change
// density, or toggle visibility. Without this reset the
// optimization would skip the rebuild entirely when the running-
// target set hasn't changed.
_lastRunningIds = [];
_lastSyncClockIds = '';
const perfInDom = !!document.querySelector('.dashboard-perf-persistent');
const perfShouldBe = isSectionVisible('perf');
if (perfShouldBe !== perfInDom) {
// Visibility flipped — full rebuild needed (charts re-init from
// server ring buffer + immediate fetch in `initPerfCharts`).
const container = document.getElementById('dashboard-content');
if (container) container.innerHTML = '';
} else if (perfShouldBe) {
// Perf still visible: in-place re-render of just the
// `.perf-charts-grid` so cell visibility / order / mode /
// window / yScale changes paint immediately without the
// full-dashboard innerHTML wipe (which previously caused a
// frame of jump and a window of "—" / "0" values).
rerenderPerfGrid();
}
loadDashboard(true);
}, 60);
});
// Pause uptime timer when browser tab is hidden, resume when visible
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
@@ -1140,9 +1140,9 @@ function _graphHTML(): string {
<svg class="graph-svg" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="running-gradient" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="var(--primary-color)"/>
<stop offset="50%" stop-color="var(--success-color)"/>
<stop offset="100%" stop-color="var(--primary-color)"/>
<stop offset="0%" stop-color="var(--ch-signal, var(--primary-color))"/>
<stop offset="50%" stop-color="var(--ch-cyan, var(--info-color))"/>
<stop offset="100%" stop-color="var(--ch-signal, var(--primary-color))"/>
</linearGradient>
</defs>
<rect class="graph-bg" width="100%" height="100%" fill="transparent"/>
File diff suppressed because it is too large Load Diff
@@ -129,23 +129,28 @@ export function renderScenePresetsSection(presets: ScenePreset[]): string | { he
function _renderDashboardPresetCard(preset: ScenePreset): string {
const targetCount = (preset.targets || []).length;
const subtitle = [
const metaParts = [
targetCount > 0 ? `${targetCount} ${t('scenes.targets_count')}` : null,
].filter(Boolean).join(' \u00b7 ');
preset.description ? escapeHtml(preset.description) : null,
].filter(Boolean);
const short = (preset.id || '').replace(/^scn_/, '').slice(0, 2).toUpperCase() || 'SC';
const activateLabel = t('scenes.activate') || 'Activate';
const pStyle = cardColorStyle(preset.id);
return `<div class="dashboard-target dashboard-scene-preset dashboard-card-link" data-scene-id="${preset.id}" data-action="navigate-scene" data-id="${preset.id}"${pStyle ? ` style="${pStyle}"` : ''}>
<div class="dashboard-target-info">
<span class="dashboard-target-icon">${ICON_SCENE}</span>
<div>
<div class="dashboard-target-name">${escapeHtml(preset.name)}</div>
${preset.description ? `<div class="dashboard-target-subtitle">${escapeHtml(preset.description)}</div>` : ''}
<div class="dashboard-target-subtitle">${subtitle}</div>
<div class="mod-head">
<div class="mod-id">
<span class="mod-badge">SCN \u00b7 ${escapeHtml(short)}</span>
<div class="mod-name"><span>${escapeHtml(preset.name)}</span></div>
${metaParts.length ? `<div class="mod-meta">${metaParts.join(' \u00b7 ')}</div>` : ''}
</div>
<div class="mod-leds" aria-hidden="true">
<span class="led"></span>
</div>
</div>
<div class="dashboard-target-actions">
<button class="dashboard-action-btn start" data-action="activate-scene" data-id="${preset.id}" title="${t('scenes.activate')}">${ICON_START}</button>
<div class="mod-foot">
<div class="mod-patch"><span class="patch-dot"></span><span>PRESET</span></div>
<button class="mod-btn mod-btn-go" data-action="activate-scene" data-id="${preset.id}" title="${activateLabel}" onclick="event.stopPropagation();">${ICON_START} <span>${activateLabel}</span></button>
</div>
</div>`;
}
@@ -7,7 +7,7 @@ import { API_BASE, fetchWithAuth } from '../core/api.ts';
import { Modal } from '../core/modal.ts';
import { showToast, showConfirm } from '../core/ui.ts';
import { t } from '../core/i18n.ts';
import { ICON_UNDO, ICON_DOWNLOAD } from '../core/icons.ts';
import { ICON_UNDO, ICON_DOWNLOAD, ICON_SQUARE, ICON_CIRCLE } from '../core/icons.ts';
import { IconSelect } from '../core/icon-select.ts';
import { openAuthedWs } from '../core/ws-auth.ts';
@@ -66,6 +66,8 @@ export async function saveExternalUrl(): Promise<void> {
// ─── Settings-modal tab switching ───────────────────────────
const SETTINGS_ACTIVE_TAB_KEY = 'settings_active_tab';
export function switchSettingsTab(tabId: string): void {
document.querySelectorAll('.settings-tab-btn').forEach(btn => {
btn.classList.toggle('active', (btn as HTMLElement).dataset.settingsTab === tabId);
@@ -73,6 +75,8 @@ export function switchSettingsTab(tabId: string): void {
document.querySelectorAll('.settings-panel').forEach(panel => {
panel.classList.toggle('active', panel.id === `settings-panel-${tabId}`);
});
// Remember so the next openSettingsModal() re-opens this tab.
try { localStorage.setItem(SETTINGS_ACTIVE_TAB_KEY, tabId); } catch { /* storage blocked */ }
// Lazy-render the appearance tab content
if (tabId === 'appearance' && typeof window.renderAppearanceTab === 'function') {
window.renderAppearanceTab();
@@ -256,6 +260,13 @@ const settingsModal = new Modal('settings-modal');
let _logLevelIconSelect: IconSelect | null = null;
let _autoBackupIntervalIconSelect: IconSelect | null = null;
let _shutdownActionIconSelect: IconSelect | null = null;
type ShutdownAction = 'stop_targets' | 'nothing';
const _SHUTDOWN_ACTIONS: readonly ShutdownAction[] = ['stop_targets', 'nothing'] as const;
function _isShutdownAction(v: string): v is ShutdownAction {
return (_SHUTDOWN_ACTIONS as readonly string[]).includes(v);
}
/** Build interval items (hour-tiles) for auto-backup and update check pickers.
* Labels match the existing native-<option> text verbatim so no new i18n keys are needed.
@@ -271,6 +282,24 @@ export function _getHourIntervalItems(): { value: string; icon: string; label: s
];
}
/** Build shutdown-action items lazily so t() has locale data loaded. */
function _getShutdownActionItems(): { value: string; icon: string; label: string; desc: string }[] {
return [
{
value: 'stop_targets',
icon: ICON_SQUARE,
label: t('settings.shutdown_action.opt.stop'),
desc: t('settings.shutdown_action.opt.stop_desc'),
},
{
value: 'nothing',
icon: ICON_CIRCLE,
label: t('settings.shutdown_action.opt.nothing'),
desc: t('settings.shutdown_action.opt.nothing_desc'),
},
];
}
/** Build log-level items lazily so t() has locale data loaded. */
function _getLogLevelItems(): { value: string; icon: string; label: string; desc: string }[] {
return [
@@ -285,8 +314,14 @@ function _getLogLevelItems(): { value: string; icon: string; label: string; desc
export function openSettingsModal(): void {
(document.getElementById('settings-error') as HTMLElement).style.display = 'none';
// Reset to first tab
switchSettingsTab('general');
// Restore last-opened tab (from localStorage) if the tab still exists;
// fall back to 'general' otherwise. Callers that want a specific tab
// (e.g. donation link → about, update badge → updates) call
// switchSettingsTab() themselves *after* opening.
let saved = 'general';
try { saved = localStorage.getItem(SETTINGS_ACTIVE_TAB_KEY) || 'general'; } catch { /* ignore */ }
if (!document.getElementById(`settings-panel-${saved}`)) saved = 'general';
switchSettingsTab(saved);
settingsModal.open();
@@ -315,11 +350,25 @@ export function openSettingsModal(): void {
}
}
// Initialize shutdown-action icon select
if (!_shutdownActionIconSelect) {
const sel = document.getElementById('settings-shutdown-action') as HTMLSelectElement | null;
if (sel) {
_shutdownActionIconSelect = new IconSelect({
target: sel,
items: _getShutdownActionItems(),
columns: 2,
onChange: () => setShutdownAction(),
});
}
}
loadApiKeysList();
loadExternalUrl();
loadAutoBackupSettings();
loadBackupList();
loadLogLevel();
loadShutdownAction();
}
export function closeSettingsModal(): void {
@@ -659,3 +708,43 @@ export async function setLogLevel(): Promise<void> {
}
}
// ─── Shutdown action ──────────────────────────────────────────
export async function loadShutdownAction(): Promise<void> {
try {
const resp = await fetchWithAuth('/system/shutdown-action');
if (!resp.ok) return;
const data = await resp.json();
const action: ShutdownAction = _isShutdownAction(data.action) ? data.action : 'stop_targets';
if (_shutdownActionIconSelect) {
_shutdownActionIconSelect.setValue(action);
} else {
const select = document.getElementById('settings-shutdown-action') as HTMLSelectElement | null;
if (select) select.value = action;
}
} catch (err) {
console.error('Failed to load shutdown action:', err);
}
}
export async function setShutdownAction(): Promise<void> {
const select = document.getElementById('settings-shutdown-action') as HTMLSelectElement | null;
if (!select) return;
const value = select.value;
if (!_isShutdownAction(value)) return;
try {
const resp = await fetchWithAuth('/system/shutdown-action', {
method: 'PUT',
body: JSON.stringify({ action: value }),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.detail || `HTTP ${resp.status}`);
}
showToast(t('settings.shutdown_action.saved'), 'success');
} catch (err) {
console.error('Failed to set shutdown action:', err);
showToast(t('settings.shutdown_action.save_error') + ': ' + err.message, 'error');
}
}
@@ -55,7 +55,9 @@ export function switchTab(name: string, { updateHash = true, skipLoad = false }:
// Use window.* to avoid circular imports with feature modules
if (!skipLoad && isAuthed) callTabLoader(name);
} else {
if (typeof window.stopPerfPolling === 'function') window.stopPerfPolling();
// Perf poll keeps running across all tabs so the transport-bar
// Uptime / CPU / Mem cells stay live. Only stopped on auth loss
// or when the tab is hidden (visibilitychange handler).
if (typeof window.stopUptimeTimer === 'function') window.stopUptimeTimer();
// Clean up WebSockets when leaving targets tab
if (name !== 'targets') {
+10
View File
@@ -22,6 +22,14 @@ interface Window {
setApiKey: (key: string | null) => void;
_authRequired: boolean | undefined;
// ─── Transport bar ───
/** Server-process uptime seed for the transport-bar ticker. Set by
* api.ts on every /health response; read by the inline ticker in
* index.html. ``recordedAtPerf`` is a ``performance.now()`` reading,
* not Date.now(), so the extrapolation is immune to wall-clock jumps
* (NTP step, DST). */
__serverUptime: { uptimeSec: number; recordedAtPerf: number } | undefined;
// ─── Visual effects (called from inline <script>) ───
_updateBgAnimAccent: (accent: string) => void;
_updateBgAnimTheme: (dark: boolean) => void;
@@ -397,6 +405,8 @@ startTargetOverlay: (...args: any[]) => any;
closeLogOverlay: (...args: any[]) => any;
loadLogLevel: (...args: any[]) => any;
setLogLevel: (...args: any[]) => any;
loadShutdownAction: (...args: any[]) => any;
setShutdownAction: (...args: any[]) => any;
saveExternalUrl: (...args: any[]) => any;
getBaseOrigin: (...args: any[]) => any;
+74 -3
View File
@@ -500,7 +500,7 @@
"tags.placeholder": "Add tag...",
"section.expand_all": "Expand all sections",
"section.collapse_all": "Collapse all sections",
"streams.title": "Sources",
"streams.title": "Inputs",
"integrations.title": "Integrations",
"streams.description": "Sources define the capture pipeline. A raw source captures from a display using a capture template. A processed source applies postprocessing to another source. Assign sources to devices.",
"streams.group.raw": "Sources",
@@ -672,7 +672,7 @@
"streams.video_asset": "Video Asset:",
"streams.video_asset.select": "Select video asset…",
"streams.video_asset.search": "Search video assets…",
"targets.title": "Targets",
"targets.title": "Channels",
"targets.description": "Targets bridge color strip sources to output devices. Each target references a device and a color strip source.",
"targets.subtab.wled": "LED",
"targets.subtab.led": "LED",
@@ -767,8 +767,18 @@
"overlay.stopped": "Overlay visualization stopped",
"overlay.error.start": "Failed to start overlay",
"overlay.error.stop": "Failed to stop overlay",
"sidebar.workspaces": "Workspaces",
"sidebar.load": "Load",
"sidebar.fps": "FPS",
"transport.status.ready": "Ready",
"transport.status.armed": "Armed · {n} live",
"transport.meta.uptime": "Uptime",
"transport.meta.cpu": "CPU",
"transport.meta.mem": "Mem",
"transport.meta.poll": "Poll",
"transport.meta.poll_hint": "Poll interval (click to cycle: 1s → 2s → 5s → 10s)",
"dashboard.title": "Dashboard",
"dashboard.section.targets": "Targets",
"dashboard.section.targets": "Channels",
"dashboard.section.running": "Running",
"dashboard.section.stopped": "Stopped",
"dashboard.no_targets": "No targets configured",
@@ -786,16 +796,69 @@
"dashboard.section.integrations": "Integrations",
"dashboard.integrations.entities": "entities",
"dashboard.integrations.no_sources": "No integration sources configured",
"dashboard.perf.active_patches": "Active Patches",
"dashboard.perf.total_fps": "Total FPS",
"dashboard.perf.devices": "Devices",
"dashboard.perf.cpu": "CPU",
"dashboard.perf.ram": "RAM",
"dashboard.perf.gpu": "GPU",
"dashboard.perf.temp": "Temperature",
"dashboard.perf.temp.install_lhm": "Windows has no built-in CPU temperature API. Install LibreHardwareMonitor and enable \"Publish to WMI\" to see live readings here.",
"dashboard.perf.unavailable": "unavailable",
"dashboard.perf.color": "Chart color",
"dashboard.perf.mode.system": "System",
"dashboard.perf.mode.app": "App",
"dashboard.perf.mode.both": "Both",
"dashboard.poll_interval": "Refresh interval",
"dashboard.customize.title": "Customize Dashboard",
"dashboard.customize.presets": "Presets",
"dashboard.customize.preset.studio": "Studio",
"dashboard.customize.preset.operator": "Operator",
"dashboard.customize.preset.showrunner": "Showrunner",
"dashboard.customize.preset.diagnostics": "Diagnostics",
"dashboard.customize.preset.tv": "TV",
"dashboard.customize.modified": "Modified",
"dashboard.customize.global": "Global",
"dashboard.customize.width": "Width",
"dashboard.customize.width.full": "Full",
"dashboard.customize.width.centered": "Centered",
"dashboard.customize.width.narrow": "Narrow",
"dashboard.customize.anim": "Animations",
"dashboard.customize.anim.full": "Full",
"dashboard.customize.anim.reduced": "Reduced",
"dashboard.customize.anim.off": "Off",
"dashboard.customize.perf_mode": "Perf mode",
"dashboard.customize.sections": "Sections",
"dashboard.customize.perf_cells": "Performance Strip",
"dashboard.customize.fixed_top": "Pinned to top",
"dashboard.customize.drag_help": "Drag rows to reorder, or use the ↑/↓ buttons.",
"dashboard.customize.cell_drag_help": "Drag a row to change cell order in the Performance strip on the dashboard.",
"dashboard.customize.window": "Sample window",
"dashboard.customize.scale": "Y-axis scale",
"dashboard.customize.mode_short": "MODE",
"dashboard.customize.window_short": "WIN",
"dashboard.customize.scale_short": "SCL",
"dashboard.customize.density.comfortable": "Comfortable",
"dashboard.customize.density.compact": "Compact",
"dashboard.customize.density.dense": "Dense",
"dashboard.customize.collapse_default.on": "Start collapsed",
"dashboard.customize.collapse_default.off": "Start expanded",
"dashboard.customize.show": "Show",
"dashboard.customize.hide": "Hide",
"dashboard.customize.mode.inherit": "Inherit",
"dashboard.customize.mode.system": "Sys",
"dashboard.customize.mode.app": "App",
"dashboard.customize.mode.both": "Both",
"dashboard.customize.yscale.auto": "Auto",
"dashboard.customize.yscale.fixed": "Fixed",
"dashboard.customize.yscale.log": "Log",
"dashboard.customize.export": "Export",
"dashboard.customize.import": "Import",
"dashboard.customize.reset": "Reset",
"dashboard.customize.reset_confirm": "Reset dashboard layout to the Studio preset?",
"dashboard.customize.exported": "Layout exported",
"dashboard.customize.imported": "Layout imported",
"dashboard.customize.import_failed": "Failed to import layout",
"automations.title": "Automations",
"automations.empty": "No automations configured. Create one to automate scene activation.",
"automations.add": "Add Automation",
@@ -1725,6 +1788,14 @@
"settings.log_level.desc.warning": "Potential problems",
"settings.log_level.desc.error": "Failures only",
"settings.log_level.desc.critical": "Fatal errors only",
"settings.shutdown_action.label": "Shutdown action",
"settings.shutdown_action.hint": "What happens to LED targets when the server shuts down. \"Stop targets\" runs the normal stop sequence so devices with auto-restore restore their prior state. \"Nothing\" leaves the lights showing the last frame.",
"settings.shutdown_action.saved": "Shutdown action saved",
"settings.shutdown_action.save_error": "Failed to save shutdown action",
"settings.shutdown_action.opt.stop": "Stop targets",
"settings.shutdown_action.opt.stop_desc": "Run the normal stop sequence (per-device auto-restore applies)",
"settings.shutdown_action.opt.nothing": "Nothing",
"settings.shutdown_action.opt.nothing_desc": "Leave lights showing the last frame",
"settings.auto_backup.label": "Auto-Backup",
"settings.auto_backup.hint": "Automatically create periodic backups of all configuration. Old backups are pruned when the maximum count is reached.",
"settings.auto_backup.enable": "Enable auto-backup",
+74 -3
View File
@@ -502,7 +502,7 @@
"tags.placeholder": "Добавить тег...",
"section.expand_all": "Развернуть все секции",
"section.collapse_all": "Свернуть все секции",
"streams.title": "Источники",
"streams.title": "Входы",
"integrations.title": "Интеграции",
"streams.description": "Источники определяют конвейер захвата. Сырой источник захватывает экран с помощью шаблона захвата. Обработанный источник применяет постобработку к другому источнику. Назначайте источники устройствам.",
"streams.group.raw": "Источники",
@@ -656,7 +656,7 @@
"streams.video_asset": "Видео:",
"streams.video_asset.select": "Выберите видео…",
"streams.video_asset.search": "Поиск видео…",
"targets.title": "Цели",
"targets.title": "Каналы",
"targets.description": "Цели связывают источники цветовых полос с устройствами вывода. Каждая цель ссылается на устройство и источник цветовой полосы.",
"targets.subtab.wled": "LED",
"targets.subtab.led": "LED",
@@ -751,8 +751,18 @@
"overlay.stopped": "Визуализация наложения остановлена",
"overlay.error.start": "Не удалось запустить наложение",
"overlay.error.stop": "Не удалось остановить наложение",
"sidebar.workspaces": "Разделы",
"sidebar.load": "Нагр.",
"sidebar.fps": "FPS",
"transport.status.ready": "Готов",
"transport.status.armed": "Активно · {n}",
"transport.meta.uptime": "Время",
"transport.meta.cpu": "CPU",
"transport.meta.mem": "Память",
"transport.meta.poll": "Опрос",
"transport.meta.poll_hint": "Интервал опроса (клик: 1с → 2с → 5с → 10с)",
"dashboard.title": "Обзор",
"dashboard.section.targets": "Цели",
"dashboard.section.targets": "Каналы",
"dashboard.section.running": "Запущенные",
"dashboard.section.stopped": "Остановленные",
"dashboard.no_targets": "Нет настроенных целей",
@@ -767,16 +777,69 @@
"dashboard.section.sync_clocks": "Синхронные часы",
"dashboard.targets": "Цели",
"dashboard.section.performance": "Производительность системы",
"dashboard.perf.active_patches": "Активные каналы",
"dashboard.perf.total_fps": "Общий FPS",
"dashboard.perf.devices": "Устройства",
"dashboard.perf.cpu": "ЦП",
"dashboard.perf.ram": "ОЗУ",
"dashboard.perf.gpu": "ГП",
"dashboard.perf.temp": "Температура",
"dashboard.perf.temp.install_lhm": "В Windows нет встроенного API для температуры CPU. Установите LibreHardwareMonitor и включите «Publish to WMI», чтобы видеть живые показания.",
"dashboard.perf.unavailable": "недоступно",
"dashboard.perf.color": "Цвет графика",
"dashboard.perf.mode.system": "Система",
"dashboard.perf.mode.app": "Приложение",
"dashboard.perf.mode.both": "Оба",
"dashboard.poll_interval": "Интервал обновления",
"dashboard.customize.title": "Настройка панели",
"dashboard.customize.presets": "Пресеты",
"dashboard.customize.preset.studio": "Студия",
"dashboard.customize.preset.operator": "Оператор",
"dashboard.customize.preset.showrunner": "Шоу",
"dashboard.customize.preset.diagnostics": "Диагностика",
"dashboard.customize.preset.tv": "ТВ",
"dashboard.customize.modified": "Изменено",
"dashboard.customize.global": "Общие",
"dashboard.customize.width": "Ширина",
"dashboard.customize.width.full": "Полная",
"dashboard.customize.width.centered": "По центру",
"dashboard.customize.width.narrow": "Узкая",
"dashboard.customize.anim": "Анимации",
"dashboard.customize.anim.full": "Полные",
"dashboard.customize.anim.reduced": "Снижены",
"dashboard.customize.anim.off": "Выкл",
"dashboard.customize.perf_mode": "Режим перф.",
"dashboard.customize.sections": "Секции",
"dashboard.customize.perf_cells": "Системный мониторинг",
"dashboard.customize.fixed_top": "Закреплено сверху",
"dashboard.customize.drag_help": "Перетащите строки или используйте ↑/↓.",
"dashboard.customize.cell_drag_help": "Перетащите строку, чтобы изменить порядок ячеек в полосе производительности.",
"dashboard.customize.window": "Окно выборки",
"dashboard.customize.scale": "Шкала Y",
"dashboard.customize.mode_short": "РЕЖ",
"dashboard.customize.window_short": "ОКН",
"dashboard.customize.scale_short": "ШКЛ",
"dashboard.customize.density.comfortable": "Просторно",
"dashboard.customize.density.compact": "Компактно",
"dashboard.customize.density.dense": "Плотно",
"dashboard.customize.collapse_default.on": "Свёрнуто по умолчанию",
"dashboard.customize.collapse_default.off": "Развёрнуто по умолчанию",
"dashboard.customize.show": "Показать",
"dashboard.customize.hide": "Скрыть",
"dashboard.customize.mode.inherit": "Наслед.",
"dashboard.customize.mode.system": "Сис",
"dashboard.customize.mode.app": "Прил",
"dashboard.customize.mode.both": "Оба",
"dashboard.customize.yscale.auto": "Авто",
"dashboard.customize.yscale.fixed": "Фикс.",
"dashboard.customize.yscale.log": "Лог.",
"dashboard.customize.export": "Экспорт",
"dashboard.customize.import": "Импорт",
"dashboard.customize.reset": "Сбросить",
"dashboard.customize.reset_confirm": "Сбросить настройки панели к пресету «Студия»?",
"dashboard.customize.exported": "Настройки экспортированы",
"dashboard.customize.imported": "Настройки импортированы",
"dashboard.customize.import_failed": "Не удалось импортировать настройки",
"automations.title": "Автоматизации",
"automations.empty": "Автоматизации не настроены. Создайте автоматизацию для автоматической активации сцен.",
"automations.add": "Добавить автоматизацию",
@@ -1541,6 +1604,14 @@
"settings.log_level.desc.warning": "Возможные проблемы",
"settings.log_level.desc.error": "Только ошибки",
"settings.log_level.desc.critical": "Только критические ошибки",
"settings.shutdown_action.label": "Действие при выключении",
"settings.shutdown_action.hint": "Что происходит с LED-целями при остановке сервера. «Остановить цели» — обычная последовательность остановки, устройства с авто-восстановлением восстановят прежнее состояние. «Ничего» — оставить свет таким, каким он был на последнем кадре.",
"settings.shutdown_action.saved": "Действие при выключении сохранено",
"settings.shutdown_action.save_error": "Не удалось сохранить действие при выключении",
"settings.shutdown_action.opt.stop": "Остановить цели",
"settings.shutdown_action.opt.stop_desc": "Обычная остановка (учитывается авто-восстановление устройств)",
"settings.shutdown_action.opt.nothing": "Ничего",
"settings.shutdown_action.opt.nothing_desc": "Оставить свет на последнем кадре",
"settings.auto_backup.label": "Авто-бэкап",
"settings.auto_backup.hint": "Автоматическое создание периодических резервных копий конфигурации. Старые копии удаляются при превышении максимального количества.",
"settings.auto_backup.enable": "Включить авто-бэкап",
+74 -3
View File
@@ -502,7 +502,7 @@
"tags.placeholder": "添加标签...",
"section.expand_all": "全部展开",
"section.collapse_all": "全部折叠",
"streams.title": "",
"streams.title": "输入",
"integrations.title": "集成",
"streams.description": "源定义采集管线。原始源使用采集模板从显示器采集。处理源对另一个源应用后处理。将源分配给设备。",
"streams.group.raw": "源",
@@ -656,7 +656,7 @@
"streams.video_asset": "视频素材:",
"streams.video_asset.select": "选择视频素材…",
"streams.video_asset.search": "搜索视频素材…",
"targets.title": "目标",
"targets.title": "通道",
"targets.description": "目标将色带源桥接到输出设备。每个目标引用一个设备和一个色带源。",
"targets.subtab.wled": "LED",
"targets.subtab.led": "LED",
@@ -751,8 +751,18 @@
"overlay.stopped": "叠加层可视化已停止",
"overlay.error.start": "启动叠加层失败",
"overlay.error.stop": "停止叠加层失败",
"sidebar.workspaces": "工作区",
"sidebar.load": "负载",
"sidebar.fps": "帧率",
"transport.status.ready": "就绪",
"transport.status.armed": "运行中 · {n}",
"transport.meta.uptime": "在线",
"transport.meta.cpu": "CPU",
"transport.meta.mem": "内存",
"transport.meta.poll": "轮询",
"transport.meta.poll_hint": "轮询间隔(点击:1秒 → 2秒 → 5秒 → 10秒)",
"dashboard.title": "仪表盘",
"dashboard.section.targets": "目标",
"dashboard.section.targets": "通道",
"dashboard.section.running": "运行中",
"dashboard.section.stopped": "已停止",
"dashboard.no_targets": "尚未配置目标",
@@ -767,16 +777,69 @@
"dashboard.section.sync_clocks": "同步时钟",
"dashboard.targets": "目标",
"dashboard.section.performance": "系统性能",
"dashboard.perf.active_patches": "活动通道",
"dashboard.perf.total_fps": "总帧率",
"dashboard.perf.devices": "设备",
"dashboard.perf.cpu": "CPU",
"dashboard.perf.ram": "内存",
"dashboard.perf.gpu": "GPU",
"dashboard.perf.temp": "温度",
"dashboard.perf.temp.install_lhm": "Windows 没有内置的 CPU 温度 API。请安装 LibreHardwareMonitor 并启用“Publish to WMI”以在此处查看实时读数。",
"dashboard.perf.unavailable": "不可用",
"dashboard.perf.color": "图表颜色",
"dashboard.perf.mode.system": "系统",
"dashboard.perf.mode.app": "应用",
"dashboard.perf.mode.both": "全部",
"dashboard.poll_interval": "刷新间隔",
"dashboard.customize.title": "自定义仪表盘",
"dashboard.customize.presets": "预设",
"dashboard.customize.preset.studio": "工作室",
"dashboard.customize.preset.operator": "操作员",
"dashboard.customize.preset.showrunner": "演出",
"dashboard.customize.preset.diagnostics": "诊断",
"dashboard.customize.preset.tv": "电视",
"dashboard.customize.modified": "已修改",
"dashboard.customize.global": "全局",
"dashboard.customize.width": "宽度",
"dashboard.customize.width.full": "全宽",
"dashboard.customize.width.centered": "居中",
"dashboard.customize.width.narrow": "窄",
"dashboard.customize.anim": "动画",
"dashboard.customize.anim.full": "完整",
"dashboard.customize.anim.reduced": "减少",
"dashboard.customize.anim.off": "关闭",
"dashboard.customize.perf_mode": "性能模式",
"dashboard.customize.sections": "分区",
"dashboard.customize.perf_cells": "性能面板",
"dashboard.customize.fixed_top": "固定在顶部",
"dashboard.customize.drag_help": "拖动行重新排序,或使用 ↑/↓ 按钮。",
"dashboard.customize.cell_drag_help": "拖动行可更改仪表盘性能条中单元格的顺序。",
"dashboard.customize.window": "采样窗口",
"dashboard.customize.scale": "Y 轴刻度",
"dashboard.customize.mode_short": "模式",
"dashboard.customize.window_short": "窗口",
"dashboard.customize.scale_short": "刻度",
"dashboard.customize.density.comfortable": "宽松",
"dashboard.customize.density.compact": "紧凑",
"dashboard.customize.density.dense": "密集",
"dashboard.customize.collapse_default.on": "默认折叠",
"dashboard.customize.collapse_default.off": "默认展开",
"dashboard.customize.show": "显示",
"dashboard.customize.hide": "隐藏",
"dashboard.customize.mode.inherit": "继承",
"dashboard.customize.mode.system": "系统",
"dashboard.customize.mode.app": "应用",
"dashboard.customize.mode.both": "两者",
"dashboard.customize.yscale.auto": "自动",
"dashboard.customize.yscale.fixed": "固定",
"dashboard.customize.yscale.log": "对数",
"dashboard.customize.export": "导出",
"dashboard.customize.import": "导入",
"dashboard.customize.reset": "重置",
"dashboard.customize.reset_confirm": "将仪表盘布局重置为「工作室」预设?",
"dashboard.customize.exported": "布局已导出",
"dashboard.customize.imported": "布局已导入",
"dashboard.customize.import_failed": "导入布局失败",
"automations.title": "自动化",
"automations.empty": "尚未配置自动化。创建一个以自动激活场景。",
"automations.add": "添加自动化",
@@ -1541,6 +1604,14 @@
"settings.log_level.desc.warning": "潜在问题",
"settings.log_level.desc.error": "仅显示错误",
"settings.log_level.desc.critical": "仅显示致命错误",
"settings.shutdown_action.label": "关机时执行",
"settings.shutdown_action.hint": "服务器关闭时对 LED 目标的处理方式。「停止目标」执行正常的停止流程,启用自动恢复的设备会恢复先前状态。「无」会让灯保持显示最后一帧。",
"settings.shutdown_action.saved": "已保存关机动作",
"settings.shutdown_action.save_error": "保存关机动作失败",
"settings.shutdown_action.opt.stop": "停止目标",
"settings.shutdown_action.opt.stop_desc": "执行正常停止流程(按设备应用自动恢复)",
"settings.shutdown_action.opt.nothing": "无",
"settings.shutdown_action.opt.nothing_desc": "让灯保持最后一帧",
"settings.auto_backup.label": "自动备份",
"settings.auto_backup.hint": "自动定期创建所有配置的备份。当达到最大数量时,旧备份会被自动清理。",
"settings.auto_backup.enable": "启用自动备份",
+119 -7
View File
@@ -38,17 +38,38 @@
<header>
<div class="header-title">
<span id="server-status" class="status-badge"></span>
<div class="brand-stack">
<h1 data-i18n="app.title">LED Grab</h1>
<span id="server-version"><span id="version-number"></span></span>
</div>
<span class="demo-badge" id="demo-badge" style="display:none" data-i18n="demo.badge">DEMO</span>
</div>
<div class="tab-bar" role="tablist">
<button class="tab-btn" data-tab="dashboard" onclick="switchTab('dashboard')" role="tab" aria-selected="true" aria-controls="tab-dashboard" id="tab-btn-dashboard" title="Ctrl+1"><svg class="icon" viewBox="0 0 24 24"><rect width="7" height="9" x="3" y="3" rx="1"/><rect width="7" height="5" x="14" y="3" rx="1"/><rect width="7" height="9" x="14" y="12" rx="1"/><rect width="7" height="5" x="3" y="16" rx="1"/></svg> <span data-i18n="dashboard.title">Dashboard</span></button>
<button class="tab-btn" data-tab="automations" onclick="switchTab('automations')" role="tab" aria-selected="false" aria-controls="tab-automations" id="tab-btn-automations" title="Ctrl+2"><svg class="icon" viewBox="0 0 24 24"><rect width="8" height="4" x="8" y="2" rx="1" ry="1"/><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/><path d="M12 11h4"/><path d="M12 16h4"/><path d="M8 11h.01"/><path d="M8 16h.01"/></svg> <span data-i18n="automations.title">Automations</span></button>
<button class="tab-btn" data-tab="targets" onclick="switchTab('targets')" role="tab" aria-selected="false" aria-controls="tab-targets" id="tab-btn-targets" title="Ctrl+3"><svg class="icon" viewBox="0 0 24 24"><path d="M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z"/></svg> <span data-i18n="targets.title">Targets</span></button>
<button class="tab-btn" data-tab="streams" onclick="switchTab('streams')" role="tab" aria-selected="false" aria-controls="tab-streams" id="tab-btn-streams" title="Ctrl+4"><svg class="icon" viewBox="0 0 24 24"><path d="m17 2-5 5-5-5"/><rect width="20" height="15" x="2" y="7" rx="2"/></svg> <span data-i18n="streams.title">Sources</span></button>
<button class="tab-btn" data-tab="integrations" onclick="switchTab('integrations')" role="tab" aria-selected="false" aria-controls="tab-integrations" id="tab-btn-integrations" title="Ctrl+5"><svg class="icon" viewBox="0 0 24 24"><path d="M12 22v-5"/><path d="M15 8V2"/><path d="M17 8a1 1 0 0 1 1 1v4a4 4 0 0 1-4 4h-4a4 4 0 0 1-4-4V9a1 1 0 0 1 1-1z"/><path d="M9 8V2"/></svg> <span data-i18n="integrations.title">Integrations</span></button>
<button class="tab-btn" data-tab="graph" onclick="switchTab('graph')" role="tab" aria-selected="false" aria-controls="tab-graph" id="tab-btn-graph" title="Ctrl+6"><svg class="icon" viewBox="0 0 24 24"><circle cx="5" cy="6" r="3"/><circle cx="19" cy="6" r="3"/><circle cx="12" cy="18" r="3"/><path d="M7.5 7.5 10.5 16"/><path d="M16.5 7.5 13.5 16"/></svg> <span data-i18n="graph.title">Graph</span></button>
<div class="transport-center">
<span class="transport-status" id="transport-status" aria-live="polite">
<span class="dot"></span>
<span data-i18n="transport.status.ready">Ready</span>
</span>
</div>
<div class="transport-meta">
<div class="meta-cell" aria-hidden="true">
<span class="k" data-i18n="transport.meta.uptime">Uptime</span>
<span class="v" id="transport-uptime"></span>
</div>
<span class="meta-sep"></span>
<div class="meta-cell" aria-hidden="true">
<span class="k" data-i18n="transport.meta.cpu">CPU</span>
<span class="v" id="transport-cpu"></span>
</div>
<div class="meta-cell" aria-hidden="true">
<span class="k" data-i18n="transport.meta.mem">Mem</span>
<span class="v" id="transport-mem"></span>
</div>
<span class="meta-sep"></span>
<div class="meta-cell meta-cell-interactive" id="transport-poll" role="button" tabindex="0" data-i18n-title="transport.meta.poll_hint" title="Click to change poll interval">
<span class="k" data-i18n="transport.meta.poll">Poll</span>
<span class="v" id="transport-poll-value"></span>
</div>
<span class="meta-sep"></span>
</div>
<div class="header-toolbar">
<a href="/docs" target="_blank" class="header-link" data-i18n-title="app.api_docs" title="API Docs">API</a>
@@ -107,6 +128,21 @@
</header>
<div id="update-banner" class="update-banner" style="display:none"></div>
<div id="donation-banner" class="donation-banner" style="display:none"></div>
<div class="app-body">
<aside class="sidebar" aria-label="Primary">
<div class="sidebar-section">
<div class="sidebar-label"><span data-i18n="sidebar.workspaces">Workspaces</span></div>
<div class="tab-bar" role="tablist">
<button class="tab-btn" data-tab="dashboard" onclick="switchTab('dashboard')" role="tab" aria-selected="true" aria-controls="tab-dashboard" id="tab-btn-dashboard" title="Ctrl+1"><svg class="icon" viewBox="0 0 24 24"><rect width="7" height="9" x="3" y="3" rx="1"/><rect width="7" height="5" x="14" y="3" rx="1"/><rect width="7" height="9" x="14" y="12" rx="1"/><rect width="7" height="5" x="3" y="16" rx="1"/></svg> <span data-i18n="dashboard.title">Dashboard</span></button>
<button class="tab-btn" data-tab="automations" onclick="switchTab('automations')" role="tab" aria-selected="false" aria-controls="tab-automations" id="tab-btn-automations" title="Ctrl+2"><svg class="icon" viewBox="0 0 24 24"><rect width="8" height="4" x="8" y="2" rx="1" ry="1"/><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/><path d="M12 11h4"/><path d="M12 16h4"/><path d="M8 11h.01"/><path d="M8 16h.01"/></svg> <span data-i18n="automations.title">Automations</span></button>
<button class="tab-btn" data-tab="targets" onclick="switchTab('targets')" role="tab" aria-selected="false" aria-controls="tab-targets" id="tab-btn-targets" title="Ctrl+3"><svg class="icon" viewBox="0 0 24 24"><path d="M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z"/></svg> <span data-i18n="targets.title">Targets</span></button>
<button class="tab-btn" data-tab="streams" onclick="switchTab('streams')" role="tab" aria-selected="false" aria-controls="tab-streams" id="tab-btn-streams" title="Ctrl+4"><svg class="icon" viewBox="0 0 24 24"><path d="m17 2-5 5-5-5"/><rect width="20" height="15" x="2" y="7" rx="2"/></svg> <span data-i18n="streams.title">Sources</span></button>
<button class="tab-btn" data-tab="integrations" onclick="switchTab('integrations')" role="tab" aria-selected="false" aria-controls="tab-integrations" id="tab-btn-integrations" title="Ctrl+5"><svg class="icon" viewBox="0 0 24 24"><path d="M12 22v-5"/><path d="M15 8V2"/><path d="M17 8a1 1 0 0 1 1 1v4a4 4 0 0 1-4 4h-4a4 4 0 0 1-4-4V9a1 1 0 0 1 1-1z"/><path d="M9 8V2"/></svg> <span data-i18n="integrations.title">Integrations</span></button>
<button class="tab-btn" data-tab="graph" onclick="switchTab('graph')" role="tab" aria-selected="false" aria-controls="tab-graph" id="tab-btn-graph" title="Ctrl+6"><svg class="icon" viewBox="0 0 24 24"><circle cx="5" cy="6" r="3"/><circle cx="19" cy="6" r="3"/><circle cx="12" cy="18" r="3"/><path d="M7.5 7.5 10.5 16"/><path d="M16.5 7.5 13.5 16"/></svg> <span data-i18n="graph.title">Graph</span></button>
</div>
</div>
</aside>
<main class="app-main">
<div class="container">
<div class="tabs">
<div class="tab-panel" id="tab-dashboard" role="tabpanel" aria-labelledby="tab-btn-dashboard">
@@ -186,6 +222,8 @@
</div>
</footer>
</div>
</main>
</div>
<button id="scroll-to-top" class="scroll-to-top" onclick="window.scrollTo({top:0,behavior:'smooth'})" aria-label="Scroll to top">
<svg class="icon" viewBox="0 0 24 24"><path d="m18 15-6-6-6 6"/></svg>
@@ -506,6 +544,80 @@
// Initialize on load
updateAuthUI();
// Transport-bar uptime ticker — shows the SERVER's process uptime,
// not the browser session. api.ts populates window.__serverUptime
// from /health on initial load and on every connection re-check;
// until that lands we fall back to "—" so a refresh doesn't briefly
// flash 00:00:00. After 99h the format widens to D HH:MM:SS so the
// counter stays meaningful for long-running services.
// The drift between fetch and now is computed against
// performance.now() (monotonic) so an NTP step / DST change /
// user clock-set on the host doesn't visibly jump the counter.
(function() {
const el = document.getElementById('transport-uptime');
if (!el) return;
function pad(n) { return n < 10 ? '0' + n : String(n); }
function render() {
const ref = window.__serverUptime;
if (!ref) { el.textContent = '—'; return; }
const elapsedSinceFetch = (performance.now() - ref.recordedAtPerf) / 1000;
const secs = Math.max(0, Math.floor(ref.uptimeSec + elapsedSinceFetch));
const d = Math.floor(secs / 86400);
const h = Math.floor((secs % 86400) / 3600);
const m = Math.floor((secs % 3600) / 60);
const s = secs % 60;
el.textContent = d > 0
? `${d}d ${pad(h)}:${pad(m)}:${pad(s)}`
: `${pad(h)}:${pad(m)}:${pad(s)}`;
}
render();
setInterval(render, 1000);
})();
// Transport-bar poll-interval control — cycles through 1/2/5/10s
// presets on click. Affects dashboard refresh + perf polling, so
// it belongs in the global transport bar rather than the Dashboard
// toolbar.
(function() {
const PRESETS = [1000, 2000, 5000, 10000];
const KEY = 'dashboard_poll_interval';
const root = document.getElementById('transport-poll');
const valEl = document.getElementById('transport-poll-value');
if (!root || !valEl) return;
function render(ms) {
const s = Math.round(ms / 1000);
valEl.textContent = `${s}s`;
}
function apply(ms) {
localStorage.setItem(KEY, String(ms));
render(ms);
// Call the existing global hook if loaded (it also restarts
// auto-refresh + perf polling with the new interval).
if (typeof window.changeDashboardPollInterval === 'function') {
window.changeDashboardPollInterval(String(Math.round(ms / 1000)));
}
}
render(parseInt(localStorage.getItem(KEY), 10) || 2000);
function cycle(dir) {
const cur = parseInt(localStorage.getItem(KEY), 10) || 2000;
let idx = PRESETS.indexOf(cur);
if (idx < 0) idx = 1; // default to 2s if unknown
idx = (idx + (dir || 1) + PRESETS.length) % PRESETS.length;
apply(PRESETS[idx]);
}
root.addEventListener('click', function(e) { e.stopPropagation(); cycle(1); });
root.addEventListener('keydown', function(e) {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); cycle(1); }
else if (e.key === 'ArrowRight' || e.key === 'ArrowUp') { e.preventDefault(); cycle(1); }
else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') { e.preventDefault(); cycle(-1); }
});
})();
// Modal functions
function togglePasswordVisibility() {
const input = document.getElementById('api-key-input');
@@ -57,6 +57,19 @@
</select>
</div>
<!-- Shutdown action section -->
<div class="form-group">
<div class="label-row">
<label data-i18n="settings.shutdown_action.label">Shutdown action</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="settings.shutdown_action.hint">What happens to LED targets when the server shuts down. "Stop targets" runs the normal stop sequence so devices with auto-restore restore their prior state. "Nothing" leaves the lights showing the last frame.</small>
<select id="settings-shutdown-action">
<option value="stop_targets">Stop targets</option>
<option value="nothing">Nothing</option>
</select>
</div>
<!-- Server Logs button (opens overlay) -->
<div class="form-group">
<div class="label-row">
@@ -3,6 +3,11 @@
from __future__ import annotations
import os
import platform
import subprocess
import threading
import time
from typing import Optional
from .types import MemorySnapshot, ProcessSnapshot, ThermalSnapshot
@@ -24,6 +29,14 @@ class PsutilMetricsProvider:
self._process = psutil_module.Process(os.getpid())
self._process.cpu_percent(interval=None)
self._cpu_count = int(psutil_module.cpu_count(logical=True) or 1)
# psutil has no sensors_temperatures() on Windows, so fall back to a
# throttled WMI/LHM reader running in a daemon thread. Disabled in
# tests via LEDGRAB_DISABLE_WIN_TEMP.
self._windows_temp: Optional[_WindowsCpuTemp] = (
_WindowsCpuTemp()
if platform.system() == "Windows" and not os.environ.get("LEDGRAB_DISABLE_WIN_TEMP")
else None
)
def cpu_percent(self) -> float:
return float(self._psutil.cpu_percent(interval=None))
@@ -80,8 +93,137 @@ class PsutilMetricsProvider:
except Exception:
pass
# Windows fallback: psutil exposes no CPU temperature there, so the
# reading would always be None without this. Other platforms keep
# the psutil result as-is.
if cpu_temp is None and self._windows_temp is not None:
cpu_temp = self._windows_temp.get()
return ThermalSnapshot(
battery_percent=battery_pct,
battery_temp_c=battery_temp,
cpu_temp_c=cpu_temp,
)
# ── Windows CPU temperature helper ───────────────────────────────────────
# Windows has no user-space API for real per-core CPU temperature without
# a vendor driver or third-party monitoring service, so we only try sources
# that reflect the actual CPU die rather than a motherboard/chassis zone:
#
# 1. LibreHardwareMonitor / OpenHardwareMonitor WMI — °C. Only usable when
# the monitoring app is running, but reads Intel DTS / AMD SMN directly
# so the reading actually tracks load.
# 2. ``MSAcpi_ThermalZoneTemperature`` WMI — Kelvin × 10. Some OEM boards
# wire this to the CPU; many require admin or expose a chassis zone
# instead. Only used as a last resort.
#
# The ``\Thermal Zone Information(*)\Temperature`` perf counter is
# deliberately NOT queried: on most consumer desktops it returns ACPI
# TZxx zones that are pinned at ~2730 °C regardless of CPU load — a
# misleading stable reading is worse than no reading at all.
#
# Emits a single numeric line on stdout and exits.
_WIN_TEMP_POWERSHELL = (
"$ErrorActionPreference='SilentlyContinue';"
"foreach ($ns in 'root/LibreHardwareMonitor','root/OpenHardwareMonitor') {"
" $lhm = Get-CimInstance -Namespace $ns -ClassName Sensor"
" -Filter \"SensorType='Temperature'\";"
" if ($lhm) {"
" $cpu = $lhm | Where-Object { $_.Parent -match 'cpu' -or $_.Name -match 'CPU' }"
" | Sort-Object Value -Descending | Select-Object -First 1;"
" if ($cpu) { '{0:N2}' -f $cpu.Value; exit }"
" }"
"}"
"$acpi = Get-CimInstance -Namespace root/wmi -ClassName MSAcpi_ThermalZoneTemperature;"
"if ($acpi) {"
" $t = ($acpi | Measure-Object -Property CurrentTemperature -Maximum).Maximum;"
" if ($t) { '{0:N2}' -f ($t / 10.0 - 273.15); exit }"
"}"
)
def _query_windows_cpu_temp() -> Optional[float]:
"""Run the PowerShell WMI probe once and parse the single-line result.
Returns None on any failure. Rejects wildly out-of-range values to
guard against sensors that report raw (un-scaled) Kelvin or 0.
"""
if platform.system() != "Windows":
return None
try:
creationflags = getattr(subprocess, "CREATE_NO_WINDOW", 0)
result = subprocess.run(
["powershell", "-NoProfile", "-NonInteractive", "-Command", _WIN_TEMP_POWERSHELL],
capture_output=True,
text=True,
timeout=4.0,
creationflags=creationflags,
)
except (OSError, subprocess.TimeoutExpired):
return None
if result.returncode != 0:
return None
line = (result.stdout or "").strip().splitlines()
if not line:
return None
try:
# Locale may use comma as decimal separator (e.g. ru-RU).
temp = float(line[0].replace(",", ".").strip())
except ValueError:
return None
if -20.0 <= temp <= 150.0:
return temp
return None
class _WindowsCpuTemp:
"""Throttled background reader for Windows CPU temperature.
Spawning PowerShell costs hundreds of ms per call, so we refresh in a
daemon thread at most once every ``REFRESH_INTERVAL_S`` seconds and
return the most recent cached value from ``get()``. After
``MAX_FAILURES`` consecutive empty results we self-disable to avoid
launching PowerShell forever on hosts without any usable sensor.
"""
REFRESH_INTERVAL_S = 5.0
MAX_FAILURES = 3
def __init__(self) -> None:
self._cached_c: Optional[float] = None
self._last_refresh: float = 0.0
self._refreshing: bool = False
self._disabled: bool = False
self._failures: int = 0
self._lock = threading.Lock()
def get(self) -> Optional[float]:
if self._disabled:
return None
now = time.monotonic()
with self._lock:
due = now - self._last_refresh >= self.REFRESH_INTERVAL_S
should_start = due and not self._refreshing
if should_start:
self._refreshing = True
if should_start:
threading.Thread(target=self._refresh, daemon=True).start()
return self._cached_c
def _refresh(self) -> None:
try:
value = _query_windows_cpu_temp()
finally:
now = time.monotonic()
with self._lock:
self._last_refresh = now
self._refreshing = False
if value is not None:
self._cached_c = value
self._failures = 0
else:
self._failures += 1
if self._failures >= self.MAX_FAILURES:
self._disabled = True
+4 -1
View File
@@ -21,7 +21,10 @@ from ledgrab.utils.metrics import android_provider as android_mod
@pytest.fixture(autouse=True)
def _reset_provider_cache():
def _reset_provider_cache(monkeypatch):
# Disable the Windows CPU-temp background reader so tests don't spawn
# PowerShell when run on a Windows host.
monkeypatch.setenv("LEDGRAB_DISABLE_WIN_TEMP", "1")
reset_metrics_provider()
yield
reset_metrics_provider()
+136
View File
@@ -0,0 +1,136 @@
"""Tests for /api/v1/preferences/dashboard-layout endpoints."""
import pytest
from ledgrab.config import get_config
@pytest.fixture(scope="module")
def client():
"""TestClient with auth header read at fixture-build time.
The auth API key is resolved here (not at module import) so any
config-singleton mutation that happens during pytest collection
notably ``server/tests/e2e/conftest.py`` reassigning the global
config to a different test key during collection of e2e tests
cannot leave us holding a stale Bearer header that yields 401.
"""
from fastapi.testclient import TestClient
from ledgrab.main import app
api_key = next(iter(get_config().auth.api_keys.values()), "")
with TestClient(app, raise_server_exceptions=False) as c:
if api_key:
c.headers["Authorization"] = f"Bearer {api_key}"
yield c
def _minimal_layout() -> dict:
return {
"version": 1,
"sections": [
{
"key": "perf",
"visible": True,
"collapsedDefault": False,
"density": "comfortable",
"options": {},
},
],
"perfCells": [
{
"key": "cpu",
"visible": True,
"mode": "inherit",
"span": 1,
"window": 120,
"yScale": "auto",
"precision": 1,
"showSubtitle": True,
"showRefLine": True,
},
],
"global": {
"width": "full",
"accent": "target",
"animations": "full",
"emptyState": "hide",
"toolbarPosition": "top",
"autoCollapseRunningEmpty": False,
"showTutorial": True,
"perfMode": "both",
"pollMs": 1000,
},
}
def test_get_dashboard_layout_default_empty(client):
"""When no layout has been saved, GET returns an empty object."""
# Clear first so this test is order-independent.
client.delete("/api/v1/preferences/dashboard-layout")
resp = client.get("/api/v1/preferences/dashboard-layout")
assert resp.status_code == 200
assert resp.json() == {}
def test_put_then_get_dashboard_layout(client):
"""PUT a layout, GET it back unchanged."""
layout = _minimal_layout()
put = client.put("/api/v1/preferences/dashboard-layout", json=layout)
assert put.status_code == 200
assert put.json() == {"ok": True}
got = client.get("/api/v1/preferences/dashboard-layout")
assert got.status_code == 200
body = got.json()
assert body["version"] == 1
assert body["sections"][0]["key"] == "perf"
assert body["perfCells"][0]["key"] == "cpu"
assert body["global"]["perfMode"] == "both"
def test_put_rejects_missing_version(client):
"""Body without numeric version field is rejected with 422."""
bad = {"sections": []}
resp = client.put("/api/v1/preferences/dashboard-layout", json=bad)
assert resp.status_code == 422
def test_put_rejects_non_object(client):
"""Bare arrays / strings / numbers are rejected by FastAPI body validation."""
resp = client.put(
"/api/v1/preferences/dashboard-layout",
json=["not", "an", "object"],
)
assert resp.status_code in (400, 422)
def test_delete_clears_layout(client):
"""DELETE wipes the saved layout so subsequent GET returns empty."""
client.put("/api/v1/preferences/dashboard-layout", json=_minimal_layout())
deleted = client.delete("/api/v1/preferences/dashboard-layout")
assert deleted.status_code == 200
after = client.get("/api/v1/preferences/dashboard-layout")
assert after.status_code == 200
assert after.json() == {}
def test_layout_round_trip_preserves_unknown_fields(client):
"""Frontend may add new keys (e.g. v1.1 sections) — backend must
pass them through verbatim, not strip them."""
layout = _minimal_layout()
layout["futureField"] = {"foo": "bar"}
layout["sections"].append(
{
"key": "audio-meters",
"visible": True,
"collapsedDefault": False,
"density": "comfortable",
"options": {"sensitivity": 0.7},
}
)
client.put("/api/v1/preferences/dashboard-layout", json=layout)
got = client.get("/api/v1/preferences/dashboard-layout").json()
assert got["futureField"] == {"foo": "bar"}
assert any(s["key"] == "audio-meters" for s in got["sections"])