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:
@@ -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
|
||||
|
||||
@@ -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
Generated
+48
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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}")
|
||||
|
||||
|
||||
|
||||
@@ -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}
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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';
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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,114 +315,62 @@ 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;
|
||||
}
|
||||
}
|
||||
|
||||
@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;
|
||||
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%;
|
||||
}
|
||||
}
|
||||
|
||||
/* 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 {
|
||||
from { opacity: 0; transform: translateY(12px); }
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 ── */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,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 */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}>▶</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
@@ -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;
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Включить авто-бэкап",
|
||||
|
||||
@@ -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": "启用自动备份",
|
||||
|
||||
@@ -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 ~27–30 °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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"])
|
||||
Reference in New Issue
Block a user