Compare commits

..

19 Commits

Author SHA1 Message Date
alexei.dolgolyov 0804f54537 chore: release v0.5.0
Build Release / create-release (push) Successful in 3s
Build Android APK / build-android (push) Failing after 2m16s
Build Release / build-linux (push) Successful in 3m54s
Build Release / build-docker (push) Successful in 7m30s
Build Release / build-windows (push) Successful in 8m37s
Lint & Test / test (push) Successful in 8m45s
2026-04-25 15:21:02 +03:00
alexei.dolgolyov 66f921c07f Merge branch 'feat/lumenworks-ui-redesign'
Lint & Test / test (push) Successful in 2m36s
Lumenworks studio-console redesign + per-account dashboard customization
+ Inputs/Integrations/Graph treatment + transport-bar uptime + server
shutdown action.

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

Pre-merge audit:
- 886/886 pytest passed twice in a row
- ruff + tsc clean
- frontend bundle rebuilt at static/dist
- python package reinstalled in editable mode (dev WebUI now reports
  0.4.2 instead of stale 0.3.0 dist-info)
2026-04-25 15:12:27 +03:00
alexei.dolgolyov 80f01d4813 chore: harden test isolation, gitignore stale src/data, mark shutdown action done
- ``tests/test_preferences_api.py`` no longer captures the auth API
  key at module-import time. The new ``client`` fixture resolves it
  inside its body and bakes the Bearer header into ``TestClient.headers``,
  so the e2e conftest swapping the global config singleton during
  collection cannot leave the test holding a stale 401-bound header.
  Same proven pattern as ``test_audio_processing_templates_api.py``.
- ``.gitignore`` now anchors ``/server/src/data/`` defensively. If the
  server is launched from ``server/src/`` (uncommon but possible during
  ad-hoc debugging), its relative ``data/`` resolves there. Templates
  now live in SQLite (``capture_templates`` / ``pattern_templates`` /
  ``postprocessing_templates`` tables); any stale ``*.json`` that
  lands in that directory is a runtime export and must not be
  committed.
- Three such stale exports were untracked at the start of the
  pre-merge audit and have been deleted from the working tree.
- ``TODO.md`` flips the shutdown-action checklist to done and notes
  that real-hardware verification (WLED + serial after Ctrl+C) is
  still pending.
2026-04-25 15:11:39 +03:00
alexei.dolgolyov b1ee3c3942 fix(ui): channel stripe paints only on custom-color or running cards
Reported during pre-merge review of the Lumenworks redesign:
non-dashboard cards (Inputs, Integrations, Targets) all showed
aggressive cyan / green left stripes "regardless of custom color set
or not", and even the ``+`` Add card carried a stripe.

Root cause: ``.template-card`` defaulted to ``--ch: cyan`` and
``::before`` painted it unconditionally; ``.add-template-card``
inherits ``.template-card`` so it picked the stripe up too.

Fix: gate the ``::before`` channel stripe behind opt-in selectors.
It now paints only when the card carries ``data-has-color="1"``
(the user picked a personal colour via the picker, set by ``wrapCard``)
or has the ``card-running`` class (the "patched and live" indicator).
Dashboard module rows are unchanged — their ``::before`` already
runs at ``opacity: 0.6`` and was approved as the visual benchmark.

``add-template-card::before`` is hidden unconditionally with
``!important`` since the Add card is not an entity and should never
carry a channel hue.
2026-04-25 15:11:24 +03:00
alexei.dolgolyov e0ff40f4f5 feat(ui): live card-color picker, monotonic uptime ticker tweaks, default preset uses base palette
Three adjacent UI fixes that surfaced while soaking the Lumenworks
redesign:

- ``card-colors.ts`` now writes the user's picked color through to
  *every* card representing the same entity (e.g. the targets-tab card
  AND its dashboard mirror), not just the one that owns the picker.
  Sets the ``--ch`` custom property on each match instead of a literal
  ``border-left``, which avoided the double-stripe (custom border +
  Lumenworks ``::before`` channel stripe) the old approach produced
  and reaches mirrors the picker callback's ``.closest()`` lookup
  couldn't.
- ``appearance.ts`` "default" preset now *clears* its colour overrides
  instead of stamping the historic muted greys (#1a1a1a / #2d2d2d /
  #f5f5f5 / #ffffff). With the redesign's pure-black / pure-white base
  palette in ``base.css``, "default" should mean "use the base" — the
  preset swatch in Appearance now matches what ships out of the box.
  Existing users with "default" selected will see a one-time visual
  shift to the new neutrals on next reload; this is intentional.
- ``dashboard.css`` mod-metric label row gets explicit sizing for the
  small status glyphs (clock / check / warning) so they sit beside the
  mono-caps label without competing with the big value. Errors cell
  picks up the coral channel tint when the count is non-zero.
2026-04-25 15:11:09 +03:00
alexei.dolgolyov 3f80ef2101 feat: server shutdown action with public cancel_task lifecycle method
Lets users choose what happens to LED targets when the server shuts
down. Default ("stop_targets") runs the existing per-device stop
sequence, so devices with auto-restore replay their prior state.
"Nothing" cancels the capture tasks without sending restore frames,
so the LEDs keep displaying their last frame on shutdown.

Backend:
- New setting ``shutdown_action`` persisted in db.settings
  (``stop_targets`` default | ``nothing``) with GET/PUT
  ``/api/v1/system/shutdown-action`` endpoints
- ``ProcessorManager.stop_all(restore_devices: bool = True)`` now
  picks the path based on the flag — ``proc.stop()`` for the normal
  branch, public ``proc.cancel_task()`` for the "nothing" branch.
- ``TargetProcessor.cancel_task()`` (new, on the abstract base) cancels
  the loop task and *awaits* its termination so no half-written frame
  is in flight when the process exits. Replaces an earlier draft that
  reached into the private ``_task`` attribute via ``getattr``.
- Lifespan in ``main.py`` reads the setting at shutdown and forwards
  the flag; falls back to ``stop_targets`` on any read error.
- ``/health`` exposes ``uptime_seconds`` (process-wide monotonic clock
  captured at first import of ``api.routes.system``) so the WebUI can
  show the *server's* uptime instead of the browser session's.

Browser launch:
- ``__main__._open_browser`` now polls ``/health`` for up to 30 s
  instead of sleeping a flat 2 s, so the tab opens once the server
  actually accepts requests.

Frontend:
- New "Shutdown action" picker in Settings → General, rendered via
  IconSelect with ICON_SQUARE / ICON_CIRCLE (added to ``core/icons.ts``
  + ``circle`` path to ``icon-paths.ts``).
- Transport-bar uptime ticker reads ``window.__serverUptime`` (typed
  in ``global.d.ts``); shows "—" until the first /health response
  lands so refresh doesn't briefly flash 00:00:00. After 99 h the
  format widens to "Dd HH:MM:SS".
- New i18n keys for the action picker (label, hint, opt.stop /
  opt.nothing + descriptions, saved / save_error toasts) in en/ru/zh.

No data migration needed — the setting is additive and defaults to
the existing behavior.
2026-04-25 15:10:48 +03:00
alexei.dolgolyov 2bae304107 fix(ui): single-row header + readable sidebar labels at narrow widths
At ≤1100px the header grid only declared 3 tracks for 4 children, so
the toolbar wrapped to a second row, doubling the header height. Add a
4th track, tighten the meta cluster, and hide non-essential toolbar
items (API link, tour-restart) so everything fits in one row. At
≤900px drop CPU/Mem cells (Uptime + Poll remain) so the toolbar still
fits beside the meta cluster.

Sidebar tab captions on the 56 px icon rail were ellipsis-truncated to
"DASHBO…" / "AUTOMA…" / "INTEGR…". Switch to a 2-line clamp with
tighter font/tracking so each label renders in full.
2026-04-25 13:54:18 +03:00
alexei.dolgolyov dd415e2813 fix(ui): cards on pure black/white, decoupled from bg-anim
Three related fixes after the Phase-4 migration landed:

- `--card-bg` flipped from `#101216` / `#f5f6f8` to pure `#000000` /
  `#ffffff` in base.css. Off-pure greys read as muddy when sitting on a
  pure-black/white page background; pure values keep card surfaces flush
  with the rest of the chrome and let the channel stripe + corner
  bracket carry all the visual differentiation.

- Removed the `[data-bg-anim="on"] .card { background: rgba(...) }`
  block that turned every entity card translucent whenever the WebGL
  background was enabled. Card backgrounds are now stable across the
  toggle — the shader bleeds through `body { background: transparent }`
  only, not through cards. The same card now reads identically with the
  shader on or off.

- WebGL shader base colour (`_bgColor` in bg-anim.ts and bg-shaders.ts)
  was using the legacy mid-grey `#1a1a1a` / `#f5f5f5`. That added a
  constant grey haze under the additive accent glow that didn't exist
  on the surrounding pure-black/white page. Switched to `[0,0,0]` /
  `[1,1,1]` so the shader composes against the same base as the page.

- Reverted two leftovers from the Phase-4 commit where I had migrated
  `.template-card` and `.graph-node-body` away from `var(--card-bg)`
  toward `var(--lux-bg-1, …)`. Those backgrounds now live on
  `var(--card-bg)` again, matching every other migrated card.
2026-04-25 02:42:57 +03:00
alexei.dolgolyov b43e1cf375 feat(ui): Lumenworks treatment for Inputs / Integrations / Graph tabs
Brings the remaining tabs in line with the Channels-tab visual language:

- .template-card now mirrors .card and .dashboard-target — channel stripe
  on the left edge with glow, silkscreened corner bracket top-right,
  hairline border on --lux-bg-1, hover lift + stripe widen-and-glow.
  Covers streams, capture / pp / cspt / pattern / audio templates and
  every Integrations card (HA / MQTT / weather / value / sync clocks /
  game integrations).

- Channel mapping extended in cards.css. Direct attribute hooks for the
  per-domain ids; section-scoped hooks via [data-card-section="…"] for
  the 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.render already emits.

- Graph editor nodes pick up the studio-console palette: --lux-bg-1
  fill with hairline stroke, hover bold-line, selected/running stroke
  --ch-signal with drop-shadow glow. Title font moved off Big Shoulders
  Display (which read as "stretched" at 12 px) onto --font-body
  (Manrope); subtitle keeps the mono-uppercase caption treatment with a
  conservative letter-spacing. Running gradient now rides the channel
  palette (signal → cyan → signal) rather than the legacy primary /
  success colours. Port labels and grid dots adopt --lux-line tokens.

- Graph node titles get real text-overflow:ellipsis behaviour. SVG
  <text> can't do that natively, so renderNodes runs a post-mount fit
  pass that binary-searches the longest character prefix that fits
  inside the clip rect (with 2 px slack), suffixed with "…". Trailing
  whitespace is stripped before the ellipsis so we never get "Foo …".
  Full text is stashed on data-full-text so the fit can be re-run on
  re-renders.

Also bundles two perf-charts fixes from the same session:

- Hover regression — listener was bound to .perf-charts-grid, which
  rerenderPerfGrid() replaces. Moved to document.body with a guard, and
  the cursor → sample math now uses the same sliceN as the spark
  rendering so the tooltip stays accurate when the user changes the
  window setting.

- Color picker on every perf cell. Patches / Total FPS / Devices now
  expose the same color picker as the spark cells; defaults added to
  METRIC_CSS_VARS. Each card gets an inline --perf-accent on render so
  saved colours apply immediately, including across rerenderPerfGrid.
2026-04-25 02:27:38 +03:00
alexei.dolgolyov 56853b7123 feat(dashboard): per-account customizable dashboard with slide-in panel
Open-registry section/perf-cell schema persisted server-side under
db.get_setting('dashboard_layout'); localStorage cache for instant
first-paint, server sync after auth. 5 built-in presets
(Studio/Operator/Showrunner/Diagnostics/TV); JSON export/import.

Slide-in Customize panel toggles section + perf-cell visibility,
reorders via hand-rolled HTML5 drag (with up/down buttons for
keyboard/TV-remote use), changes density per section, and exposes
global Width / Animations / Perf-mode / Window with per-cell Inherit
overrides.

Window setting now drives the actual sparkline slice (30s/1m/2m/5m at
configurable poll interval) instead of always rendering 120 fixed
samples. Perf-grid edits re-render in place — sparklines repaint from
persistent module-level history, value labels replay from cached
last-fetch payload, so there is no flicker frame and no zero-data
window between layout change and next poll. initPerfCharts now fires
an immediate fetch on init so reload no longer shows "—" until the
first interval tick.

Reset confirmation uses the project's themed showConfirm modal
instead of the browser dialog. Reserved registry keys (audio-meters,
alerts, led-preview, source-thumbs, pinned, flow) are forward-
compatible so v1.1 cards slot in without a schema bump.

Backend exposes GET/PUT/DELETE /api/v1/preferences/dashboard-layout
treating the body as opaque JSON with a numeric version gate; covered
by 6 round-trip / validation / unknown-field tests.
2026-04-25 01:43:14 +03:00
alexei.dolgolyov 70c95d1c09 feat(ui): item-card restyle, perf hover tooltips, FPS ceiling
Item cards (Automations, Channels, Inputs, Integrations):
- `.card-title` — bumped to weight 700, -0.01em tracking, solid --lux-ink
  for better presence against the flat card bg.
- `.card-subtitle` / `.card-meta` — mono font, 0.04em tracking, tighter
  gap so rule chips pack in a readable row.
- `.stream-card-prop` rule chips — rectangular 2px radius + hairline
  border + flat dark bg (was rounded 10px grey pill). Channel-signal
  icon tint; hover fades in a channel-green wash with matching border.
- `.badge` generic — rectangular 2px radius, mono 0.62rem, 0.12em
  tracking, hairline border slot for variants.
  - `.badge-automation-active` — channel-signal tinted bg + border +
    soft outer glow so the "ACTIVE" state reads at a glance.
  - `.badge-automation-inactive` / `-disabled` — transparent with a
    hairline outline so they sit quietly alongside the active variant.
- `.device-url-badge` — switched from rounded pill to rectangular
  hairline mono chip; hover shifts to filled bg + bolder border +
  brighter ink.
- `.card-actions` — 1px hairline top divider, 6px gap.
- `.btn-icon` — 7/10px padding, 1rem icon, hairline border, channel-
  signal glow on hover (replaces the old scale(1.1) jiggle).
  - `.btn-icon.btn-warning` — amber ink + hairline + amber hover glow
    (drives the "disable" action in the automation card).
  - `.btn-icon.btn-success` — signal-green ink + hairline + green hover
    glow ("enable" action).

Cross-link navigation highlight:
- `cardHighlight` keyframes were using an undefined `--primary-rgb` var,
  so the outer glow fell back to 59/130/246 (the Tailwind blue default).
  Rewritten with `var(--ch-signal)` + color-mix so the highlight tracks
  the accent picker and reads as signal-green. Added double-layer
  box-shadow (ring + 32px/10px bloom) so the highlight is obvious on
  the flat dark/light card surfaces. Added .dashboard-target to the
  selector + `isolation: isolate` so the glow isn't clipped inside
  overflow: hidden containers (perf strip cells, tree-nav panels).

Perf strip (follow-up polish):
- Total FPS cell shows `/<N>` ceiling suffix next to the live value —
  sum of fps_target across running targets, styled like the Patches
  "/12". A dashed horizontal reference line at that ceiling is rendered
  on the sparkline so the live value reads as "percentage of max
  achievable throughput." Y-axis ceiling grows to targetSum * 1.1 so
  the dashed line never clips.
- Removed the empty `.perf-chart-app` pill in the FPS cell (no app
  variant). Added `:empty { display: none }` as a safety so any other
  unpopulated cell doesn't render a ghost pill.
- Hover tooltips on all sparks — single floating `.perf-chart-tooltip`
  in <body> with fixed positioning; event-delegated from the perf
  grid so re-renders don't need rebinding. Shows metric label + sys
  value + app value (in both-mode) + "−Ns ago" age line derived from
  the poll interval. Vertical marker line follows the cursor over the
  spark; `cursor: crosshair` on the spark container signals interact-
  ability. `pointer-events: none` shifted from the spark container
  down to the inner SVG so hover events land on the container.

Grid:
- Perf strip capped at 4 cols even on widescreen; wraps to 2 rows ×
  4 when the full 7 cells are present. Responsive breakpoints at
  1100 / 760 / 480 px.
- Big value font uses `clamp(1.8rem, 2.8vw, 2.8rem)` so readouts
  like "18.9/31.8 GB" fit a 1fr cell at desktop while still scaling
  down on narrow viewports. `white-space: nowrap; flex-wrap: nowrap;
  overflow: hidden; text-overflow: clip` prevents mid-text wrapping.
- `.perf-chart-spark` uses `margin-top: auto` so sparkline baselines
  align across cells regardless of whether a subtitle is present
  (CPU/GPU model name, FPS min/max).

Dashboard target meta:
- Integrations card stripe reverted to the default signal color so it
  matches the overall accent picker; the health-dot inside the card
  carries the connection state. Removed the per-integration channel
  override in both cards.css and dashboard.css.

Section headers:
- `.dashboard-section-header` / `.subtab-section-header` underline
  switched from dashed to solid; channel-green 40px accent rule on
  the left remains.
- Section count badge (`.dashboard-section-count`) restyled to match
  the rest of the badge family (mono tabular-nums, 2px radius, hairline
  border, --lux-bg-3 fill).

Build: tsc --noEmit clean; CSS bundle stable at ~216 KB.
2026-04-24 21:59:30 +03:00
alexei.dolgolyov e5a2af9821 feat(ui): dashboard polish, richer perf strip, transport-bar controls
Dashboard perf strip:
- Unified rack-module shell with hairline-divided cells (mockup parity)
  replacing 3 separate perf cards. Cells auto-wrap to 2 rows of 4 on
  widescreen; responsive breakpoints at 1100 / 760 / 480 px.
- Active Patches cell (first) shows running/total channel count plus up
  to 4 live FPS readouts with channel-colored stripes; bottom-right
  radial glow anchors the "live channel bank" corner.
- Total FPS cell — aggregate throughput across running targets, mono
  "fps" unit suffix, session-peak-scaled sparkline with a 60 FPS floor.
- Devices cell — online/total count + per-device dot strip (green when
  online with signal-glow, coral when offline, tooltip with name +
  latency), fed from /devices/batch/states (added to the dashboard
  batch poll).
- Value font uses clamp(1.8rem, 2.8vw, 2.8rem) + white-space: nowrap so
  long readouts (RAM "18.9/31.8 GB", GPU "50% · 37°C") scale down
  instead of wrapping.
- Sparklines anchor to the cell bottom via margin-top: auto so baselines
  align across cells regardless of subtitle presence.
- App-load tag ("APP 3.1%") moved to a pinned top-right position per
  card, accent-colored pill; replaces the subdued inline badge.
- Perf mode toggle (System / App / Both) triggers an immediate poll so
  positioning updates without waiting for the next tick.
- Chart.js removed from perf-charts — inline SVG sparklines with
  drop-shadow filter for the "lit instrument" feel. Chart.js still used
  for per-target FPS charts via chart-utils (now owns the registration).
- Fixed history seed bug: app_ram is MB in the server history payload,
  not percent — convert to percent using sample's ram_total before
  pushing into _appHistory.ram. Skip seeding app_gpu_mem since the
  history schema has no gpu_memory_total.
- Temperature card reveals with an explanatory hint when the backend
  reports cpu_temp_hint_key (e.g. Windows without LibreHardwareMonitor)
  instead of silently hiding; .perf-chart-card-hint neutralizes the big
  display font so the message reads as plain body copy.

Transport bar:
- LED brand mark — 28 px, double-layer signal glow (0 22px + 0 8px),
  brandPulse animation. Brand-stack wraps the title + version so
  "LED GRAB" sits above "V0.3.0" on a single line each.
- Transport status chip — bigger (9/18 padding), mono uppercase,
  inner+outer signal glow when .is-armed.
- Transport meta cells — Uptime (JS-local session ticker), CPU (app
  CPU share), Mem (app RAM, G/M format) as stacked KEY/VALUE mono
  readouts with hairline separators.
- New interactive Poll cell cycles through 1/2/5/10s presets on click;
  replaces the range slider that used to live in the Dashboard toolbar
  (it controlled the whole app, not just the Dashboard).
- Header icon buttons — hairline-bordered 30 px squares with channel-
  glow on hover, replacing the pill container.
- Perf poll moved to global bootstrap so transport CPU / Mem stay live
  across all tabs (was paused when leaving the Dashboard).
- Connection pip (#server-status) hidden; the brand mark itself turns
  coral when offline via :has() selector on .header-title.

Dashboard cards:
- renderDashboardTarget now emits full rack-module markup with CH badge,
  name, meta, LED cluster, 3-cell metric grid (FPS / Uptime / Errors),
  and patch-label + stop button. Running cards get the signal-flow
  strip at the bottom. data-fps-text / data-uptime-text / data-errors-
  text hooks preserved so _updateRunningMetrics updates in place.
- LED count surfaced in the target card meta line (e.g. "LED · WLED ·
  144 LED · GRADIENT") when the linked device reports led_count > 0.
- Integrations (HA + MQTT) picked up .mod-head markup — compact module
  layout with online/offline patch indicator. Integration card stripe
  uses the default signal color (not cyan or amber).
- Scene presets, sync clocks, automations gain the same compact module
  treatment. Automations/scenes dropped into a dashboard-autostart-grid
  so they share the visual language.
- Perf mode toggle, stream sub-tabs, cs-count / tree-count /
  tab-badge / dashboard-section-count badges all use the mono
  rectangular style with tabular-nums.

Command palette:
- Flat background (no gradient), channel-accent rule across the top,
  mono placeholder / group headers / footer, active result gets a
  channel-green left stripe.

Modals:
- Popover + backdrop get a stronger radial dim + 6 px blur.
- Per-modal-ID channel lanes (target→green, source→cyan, audio→magenta,
  automation/scene→violet, settings→amber, confirm→coral) via --modal-ch
  override.
- Modal header picks up a vertical channel stripe + hairline divider;
  footer gets hairline top + subtle wash.

Components:
- Inputs use hairline borders + tabular-nums mono for number fields;
  focus state has channel-green ring + soft glow.
- Buttons switch to mono-uppercase with signal-glow on primary,
  coral-glow on danger, hairline border on secondary.
- Card background flattened — removed gradient wash in favor of solid
  --lux-bg-1 for both dark (#0e1014) and light (#f6f8fb).
- Page background: pure black for dark, pure white for light.

Color-picker:
- Always detaches to <body> with fixed positioning when its swatch sits
  inside an overflow: hidden / auto / clip ancestor (perf strip, modal
  bodies, tree-dd panels). Prevents the popover getting clipped.

Settings modal:
- Remembers the last-opened tab via localStorage key
  settings_active_tab; falls back to 'general' if the tab id no longer
  exists. Explicit overrides (donation → about, update badge →
  updates) still work because callers invoke switchSettingsTab after
  openSettingsModal.

Microcopy:
- Sidebar / transport localization for en/ru/zh:
  sidebar.workspaces · transport.meta.{uptime,cpu,mem,poll,poll_hint}
  · transport.status.{ready,armed} · dashboard.perf.{active_patches,
  total_fps,devices}

Backend (coordinated with frontend):
- /system/performance now returns cpu_temp_hint_key when no live CPU
  temperature is available, so the Temperature card can render an
  actionable explainer instead of being hidden. Frontend respects the
  key via t() lookup.

Section headers:
- Underline switched from dashed to solid; channel-green accent rule
  (40 px) on the left remains.

Build / tests:
- ruff clean on touched Python files.
- tsc --noEmit clean.
- Python metrics-provider tests: 18 passed.
- CSS bundle ~214 KB.
2026-04-24 20:28:44 +03:00
alexei.dolgolyov 539e43195f feat(ui): Lumenworks studio-console WebUI redesign
Full-app UI/UX refresh committing to a tech-instrument / studio-console
aesthetic inspired by hardware synths, Eurorack panels, and DAW layouts.

Design tokens and fonts:
- Embed Manrope (body), JetBrains Mono (labels/metrics), Big Shoulders
  Display (numeric readouts) as local .woff2 variable fonts with
  latin + latin-ext + cyrillic + cyrillic-ext subsets via unicode-range.
- New Lumenworks token layer in base.css: --lux-bg-0..3, --lux-line(-bold),
  --lux-ink(-dim/-mute/-faint), --ch-signal/-cyan/-magenta/-amber/-coral/
  -violet channel palette, --lux-signal-glow, --lux-shadow-rack, all
  theme-aware for dark + light. Existing tokens untouched for compat.

Shell (header + sidebar):
- Header rebuilt as a 3-column CSS-grid transport bar (brand | center |
  toolbar) with a glowing LED brand mark rendered via pseudo-elements on
  .header-title. Gradient channel-color rule under the bottom border.
- New sidebar.css introduces a vertical channel-strip nav. Active tab
  gets a glowing left stripe + radial tint + LED pip. .sidebar-foot
  contains a live CPU/FPS meter plate.
- Sidebar collapses to a 56 px icon rail at <=1100 px and hides via
  display:contents at <=600 px so mobile.css's fixed bottom tab-bar
  flows through unchanged.

Cards and dashboard:
- .card gets channel stripe (data-card-type + .ch-* utilities auto-map
  from data-target-id / data-stream-id / data-automation-id etc.), corner
  bracket, gradient background, subtle rack shadow.
- .card-running replaces the old @property --border-angle conic-gradient
  rotating border with a lightweight signalFlow linear-gradient strip on
  the bottom edge (cheaper paint, no GPU layer compositing per card).
- Skeleton loaders rewritten: left hairline + corner bracket + gradient
  shimmer instead of the old text-color opacity pulse.
- .dashboard-target rows pick up the same channel-stripe + signalFlow
  treatment. Section headers use mono micro-caps with a channel-green
  underline accent consistent across the app.
- .perf-chart-card: channel stripe replaces old border-top; per-metric
  accents moved to the channel palette (CPU=coral, RAM=violet, GPU=green,
  temp=amber). Metric values use tabular-nums + a soft glow.

Live bindings (no new endpoints):
- _updateSidebarMeter: binds the sidebar Load + FPS bars to the existing
  /system/performance poll.
- _updateTransportStatus: toggles the transport chip between "Ready" and
  "Armed - N live" whenever the dashboard's running-target set is
  recomputed.

Tree-nav + sub-tabs:
- tree-nav.css trigger pill gets a channel-stripe left edge that glows
  when open; panel has a gradient channel-accent rule across the top;
  group headers use silkscreened micro-caps; active leaf has a pulsing
  LED pip + channel tint.
- .stream-tab-btn / .subtab-section-header adopt the same mono-caps +
  channel-underline language for consistency.
- Graph editor toolbar gets gradient + hairline + rack shadow + backdrop
  blur. Canvas and nodes untouched.

Modals (40+ modals share modal.css):
- Radial-dim + 6 px blur backdrop. Content gets a gradient background,
  hairline border, deep rack shadow, top channel-accent rule driven by
  --modal-ch, bottom-right corner bracket (hidden on mobile fullscreen).
- Per-modal-ID channel lanes: target editors = green, source/input
  editors = cyan, audio = magenta, automation/scene/game = violet,
  settings/auth = amber, confirm = coral.
- Modal headers: vertical channel stripe left of the title + hairline
  divider. Modal footers: hairline top border + subtle gradient wash.

Forms:
- Inputs use hairline borders; number inputs switch to mono + tabular-nums
  for column alignment. Focus state: channel-green ring + soft glow.
- Buttons use mono-uppercase type with signal-glow on primary and coral-
  glow on danger.

Mobile (<=600 px):
- Fixed bottom .tab-bar gets the full Lumenworks treatment: gradient fill,
  top channel-accent rule matching the transport bar, backdrop blur.
  Active tab has an LED pip above the icon + channel tint + icon recolor.
- Fullscreen modals: corner bracket hidden, header stripe slimmed.

Microcopy (en / ru / zh):
- "Targets" -> "Channels" / "Каналы" / "通道"
- "Sources" -> "Inputs"    / "Входы"   / "输入"
- Internal tab keys (dashboard/automations/targets/streams/integrations/
  graph) kept stable so no JS or localStorage migration is needed.
- Added: sidebar.workspaces, sidebar.load, sidebar.fps,
  transport.status.ready, transport.status.armed.

Compatibility:
- All existing class hooks preserved (.tab-bar, .tab-btn, .card,
  .card-running, .tree-dd-*, .cs-*, .perf-chart-card, .modal-content,
  .dashboard-target, etc.). No JS or API changes required for the new
  look to take effect.
- Tour selectors survive (header .header-title, #tab-btn-*, onclick
  markers on theme/settings/search, #cp-wrap-accent, etc.).
- Mobile <=600 px bottom tab-bar keeps working via display:contents
  fall-through in the new sidebar.

Build: tsc --noEmit clean; npm run build clean. CSS bundle grew from
~177 KB to ~201 KB for the full new visual system. Fonts loaded lazily
per unicode-range subset (~98 KB critical path for English).

Phased plan + deferred follow-ups (dashboard hero strip, legacy-token
cleanup) recorded at the top of TODO.md.

Reference mockup: server/docs/ui-redesign-mockup.html.
2026-04-24 15:46:47 +03:00
alexei.dolgolyov c44bb38c43 docs(release): refresh v0.4.2 notes with fix(release) and refactor commits
Build Release / create-release (push) Successful in 3s
Build Android APK / build-android (push) Failing after 2m5s
Build Release / build-linux (push) Successful in 4m53s
Build Release / build-docker (push) Successful in 5m39s
Build Release / build-windows (push) Successful in 6m55s
Lint & Test / test (push) Successful in 7m13s
2026-04-22 20:20:30 +03:00
alexei.dolgolyov be2d5e1670 refactor(color-strips): move Key Colors test from lightbox into test-css-source modal
Lint & Test / test (push) Successful in 6m37s
Removes the inlined FPS select and auto-refresh button from the shared
image lightbox and rehosts the Key Colors live preview inside the
dedicated test-css-source modal alongside the other CSS test views.

- Drop initLightbox() / lightbox-fps-select IconSelect — the lightbox no
  longer owns streaming controls.
- Add #css-test-kc-view (canvas + meta) and .css-test-kc-* styles.
- Reroute _testKeyColorsSource() through the existing modal session
  lifecycle so KC, CSPT, and standard CSS tests share teardown paths.
2026-04-22 20:18:46 +03:00
alexei.dolgolyov 5db6eddcf8 fix(release): ship prebuilt assets and bump fallback version
Two release-blocking bugs traced to the same root cause: the unanchored
`data/` rule in .gitignore matched server/src/ledgrab/data/, which is
where shipped package assets live (prebuilt sounds, game adapters).
The files were never `git add`-able without -f, so they never reached
the v0.4.2 tag and CI builds couldn't include them.

- .gitignore: anchor /data/ and /server/data/ so nested package data
  dirs are not ignored.
- Track previously-excluded shipped assets:
  - server/src/ledgrab/data/prebuilt_sounds/{alert,bell,chime,ping,pop}.wav
  - server/src/ledgrab/data/game_adapters/{minecraft,rocket_league,valorant}.yaml
- Bump _FALLBACK_VERSION 0.3.0 -> 0.4.2 to match pyproject.toml.
  The Windows installer strips ledgrab-*.dist-info, so
  importlib.metadata falls back to this literal — which is why
  v0.4.2 reports v0.3.0 in the WebUI.
- Patch _FALLBACK_VERSION at bundle time in build-common.sh and
  build-dist.ps1 so future drift is auto-corrected by the build.
2026-04-22 20:17:10 +03:00
alexei.dolgolyov a8a4296a56 chore: release v0.4.2
Build Android APK / build-android (push) Failing after 1m48s
Build Release / create-release (push) Successful in 3s
Build Release / build-linux (push) Successful in 3m58s
Build Release / build-docker (push) Successful in 5m6s
Build Release / build-windows (push) Successful in 5m54s
Lint & Test / test (push) Successful in 6m14s
2026-04-22 19:48:37 +03:00
alexei.dolgolyov 9ce1dc33bf feat(ui): restyle enhanced header locale picker as LED-accent badge 2026-04-22 19:48:08 +03:00
alexei.dolgolyov 03d2e6b1f2 ci(release): publish .sha256 sidecars alongside release assets
Lint & Test / test (push) Successful in 2m4s
The in-app update service (`ledgrab.core.update.update_service`) refuses
to install any downloaded artifact that has no published sha256 — either
as a sibling `<asset>.sha256` asset on the Gitea release, or embedded in
the release body. The release workflow uploaded the ZIP, setup.exe, and
Linux tarball but never published checksums, so every auto-update 500'd
with "Update checksum unavailable; install aborted".

Generate sha256sum sidecars for the Windows ZIP, Windows setup.exe, and
Linux tar.gz and upload them next to the primary asset on each tagged
release. Existing v0.4.x releases stay broken — ship v0.4.2 (or manually
upload sidecars to v0.4.1) to unblock in-app updates.
2026-04-22 19:40:46 +03:00
87 changed files with 9317 additions and 1267 deletions
+37 -19
View File
@@ -191,11 +191,21 @@ jobs:
echo "Uploaded: $NAME"
}
# Publish an asset plus its .sha256 sidecar. The in-app update
# service refuses to install without a published checksum, so
# every artifact needs its hash uploaded alongside.
upload_with_sha256() {
local FILE="$1"
upload_asset "$FILE"
(cd "$(dirname "$FILE")" && sha256sum "$(basename "$FILE")" > "$(basename "$FILE").sha256")
upload_asset "$FILE.sha256"
}
ZIP_FILE=$(ls build/LedGrab-*.zip | head -1)
[ -f "$ZIP_FILE" ] && upload_asset "$ZIP_FILE"
[ -f "$ZIP_FILE" ] && upload_with_sha256 "$ZIP_FILE"
SETUP_FILE=$(ls build/LedGrab-*-setup.exe 2>/dev/null | head -1)
[ -f "$SETUP_FILE" ] && upload_asset "$SETUP_FILE"
[ -f "$SETUP_FILE" ] && upload_with_sha256 "$SETUP_FILE"
# ── Linux tarball ──────────────────────────────────────────
build-linux:
@@ -242,26 +252,34 @@ jobs:
run: |
RELEASE_ID="${{ needs.create-release.outputs.release_id }}"
BASE_URL="${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}"
upload_asset() {
local FILE="$1"
local NAME
NAME=$(basename "$FILE")
EXISTING_ID=$(curl -s "$BASE_URL/releases/$RELEASE_ID/assets" \
-H "Authorization: token $GITEA_TOKEN" \
| python3 -c "import sys,json; assets=json.load(sys.stdin); print(next((str(a['id']) for a in assets if a['name']=='$NAME'),''))" 2>/dev/null)
if [ -n "$EXISTING_ID" ]; then
curl -s -X DELETE "$BASE_URL/releases/$RELEASE_ID/assets/$EXISTING_ID" \
-H "Authorization: token $GITEA_TOKEN"
echo "Replaced existing asset: $NAME"
fi
curl -s -X POST \
"$BASE_URL/releases/$RELEASE_ID/assets?name=$NAME" \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/octet-stream" \
--data-binary "@$FILE"
echo "Uploaded: $NAME"
}
TAR_FILE=$(ls build/LedGrab-*.tar.gz | head -1)
TAR_NAME=$(basename "$TAR_FILE")
# Delete existing asset with same name to prevent duplicates on re-run
EXISTING_ID=$(curl -s "$BASE_URL/releases/$RELEASE_ID/assets" \
-H "Authorization: token $GITEA_TOKEN" \
| python3 -c "import sys,json; assets=json.load(sys.stdin); print(next((str(a['id']) for a in assets if a['name']=='$TAR_NAME'),''))" 2>/dev/null)
if [ -n "$EXISTING_ID" ]; then
curl -s -X DELETE "$BASE_URL/releases/$RELEASE_ID/assets/$EXISTING_ID" \
-H "Authorization: token $GITEA_TOKEN"
echo "Replaced existing asset: $TAR_NAME"
if [ -f "$TAR_FILE" ]; then
upload_asset "$TAR_FILE"
(cd "$(dirname "$TAR_FILE")" && sha256sum "$(basename "$TAR_FILE")" > "$(basename "$TAR_FILE").sha256")
upload_asset "$TAR_FILE.sha256"
fi
curl -s -X POST \
"$BASE_URL/releases/$RELEASE_ID/assets?name=$TAR_NAME" \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/octet-stream" \
--data-binary "@$TAR_FILE"
echo "Uploaded: $TAR_NAME"
# ── Docker image ───────────────────────────────────────────
build-docker:
needs: create-release
+11 -2
View File
@@ -62,8 +62,17 @@ htmlcov/
logs/
*.log.*
# Runtime data
data/
# Runtime data — anchor to repo root so nested package data dirs
# (server/src/ledgrab/data/prebuilt_sounds, game_adapters) are NOT ignored.
# An unanchored `data/` rule silently broke the v0.4.2 release by keeping
# 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
+28 -12
View File
@@ -1,18 +1,27 @@
## v0.4.1 (2026-04-22)
## v0.5.0 (2026-04-25)
This release ships the **Lumenworks studio-console** — a top-to-bottom WebUI redesign — plus a customizable per-account dashboard, a server-shutdown control, and a handful of dark/light/narrow-screen polish fixes.
### Features
- **Lumenworks studio-console WebUI redesign** — new visual language across the entire WebUI: studio-console layout, refined typography, accent system, and motion. ([539e431](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/539e431))
- Extend the Lumenworks treatment to the **Inputs**, **Integrations**, and **Graph** tabs so the redesign is consistent across all top-level views. ([b43e1cf](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/b43e1cf))
- **Per-account customizable dashboard** with a slide-in configuration panel — each user can pick their own widget layout, persisted per account. ([56853b7](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/56853b7))
- Dashboard polish: richer performance strip, transport-bar controls, and additional readouts on the main view. ([e5a2af9](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/e5a2af9))
- Item-card restyle with hover-driven performance tooltips and a configurable FPS ceiling. ([70c95d1](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/70c95d1))
- **Live card-color picker** — pick a custom color per card and see it apply instantly; default preset now uses the base palette. Monotonic uptime ticker no longer jitters on clock adjustments. ([e0ff40f](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/e0ff40f))
- **Server shutdown action** exposed in the WebUI, backed by a public `cancel_task` lifecycle method so long-running tasks unwind cleanly. ([3f80ef2](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/3f80ef2))
### Bug Fixes
- Installer now bundles `cryptography` and `just-playback`, sets the `TCL` environment for Tk, and removes the stale `debug.bat` shim ([4f7794c](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/4f7794c))
- Channel stripe on item cards now only paints when the card has a custom color or is running — no more stray accents on idle defaults. ([b1ee3c3](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/b1ee3c3))
- Cards render correctly on pure black and pure white backgrounds, and are decoupled from the animated background so they stay legible regardless of the bg-anim setting. ([dd415e2](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/dd415e2))
- Single-row header layout and readable sidebar labels at narrow widths — fixes wrapping and label truncation on smaller windows. ([2bae304](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/2bae304))
---
### Development / Internal
#### CI/Build
- Scope the Android keystore env correctly and fail loudly when a release build is attempted without a signing key ([35b75a2](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/35b75a2))
#### Documentation
- Drop the stale WLED-rename task and document the Android signing secrets ([a0d63a3](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a0d63a3))
- Remove WLED-specific language from the auto-generated release notes template ([4ed099d](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/4ed099d))
#### Chores
- Harden test isolation, add `.gitignore` rule for stale `src/data/`, and mark the shutdown action done in the task tracker. ([80f01d4](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/80f01d4))
---
@@ -21,9 +30,16 @@
| Hash | Message | Author |
|------|---------|--------|
| [4f7794c](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/4f7794c) | fix(installer): bundle cryptography + just-playback, set TCL env, clean stale debug.bat | alexei.dolgolyov |
| [a0d63a3](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a0d63a3) | docs(release): drop stale WLED-rename task, document android signing secrets | alexei.dolgolyov |
| [35b75a2](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/35b75a2) | ci(android): fix keystore env scoping, fail loudly on release without key | alexei.dolgolyov |
| [4ed099d](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/4ed099d) | docs(release): drop WLED-specific language from auto-generated release notes | alexei.dolgolyov |
| [80f01d4](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/80f01d4) | chore: harden test isolation, gitignore stale src/data, mark shutdown action done | alexei.dolgolyov |
| [b1ee3c3](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/b1ee3c3) | fix(ui): channel stripe paints only on custom-color or running cards | alexei.dolgolyov |
| [e0ff40f](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/e0ff40f) | feat(ui): live card-color picker, monotonic uptime ticker tweaks, default preset uses base palette | alexei.dolgolyov |
| [3f80ef2](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/3f80ef2) | feat: server shutdown action with public cancel_task lifecycle method | alexei.dolgolyov |
| [2bae304](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/2bae304) | fix(ui): single-row header + readable sidebar labels at narrow widths | alexei.dolgolyov |
| [dd415e2](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/dd415e2) | fix(ui): cards on pure black/white, decoupled from bg-anim | alexei.dolgolyov |
| [b43e1cf](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/b43e1cf) | feat(ui): Lumenworks treatment for Inputs / Integrations / Graph tabs | alexei.dolgolyov |
| [56853b7](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/56853b7) | feat(dashboard): per-account customizable dashboard with slide-in panel | alexei.dolgolyov |
| [70c95d1](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/70c95d1) | feat(ui): item-card restyle, perf hover tooltips, FPS ceiling | alexei.dolgolyov |
| [e5a2af9](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/e5a2af9) | feat(ui): dashboard polish, richer perf strip, transport-bar controls | alexei.dolgolyov |
| [539e431](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/539e431) | feat(ui): Lumenworks studio-console WebUI redesign | alexei.dolgolyov |
</details>
+254
View File
@@ -1,5 +1,259 @@
# LedGrab TODO
## Server shutdown action
Let user choose what happens to LED targets on server shutdown.
- [x] Backend storage: `shutdown_action` in `db.settings` (`"stop_targets"` default | `"nothing"`)
- [x] Backend route: `GET/PUT /api/v1/system/shutdown-action` in `system_settings.py`
- [x] Backend schema: `ShutdownActionResponse/Request` in `schemas/system.py`
- [x] Backend wiring: lifespan shutdown in `main.py` reads action, passes `restore_devices` flag to `processor_manager.stop_all()`
- [x] `processor_manager.stop_all(restore_devices: bool = True)` — when False, calls public `proc.cancel_task()` (defined on `TargetProcessor`) which awaits cancellation without restoring device state; skips `_restore_device_idle_state` loop. No reach into private `_task` attribute.
- [x] Frontend: hidden `<select>` + IconSelect in `settings.html` General tab (icons via `ICON_SQUARE` / `ICON_CIRCLE` from `core/icons.ts`)
- [x] Frontend: load/save handlers in `features/settings.ts`, wired into `openSettingsModal()`
- [x] i18n: en / ru / zh keys for label, hint, item descriptions
- [ ] Real-hardware test pending — verify that "nothing" actually leaves a WLED + a serial device on the last frame after `Ctrl+C`/SIGTERM.
## WebUI Redesign — "Lumenworks" Studio-Console Aesthetic
Full-app UI/UX refresh. Design direction committed to by user 2026-04-24.
Mockup lives at [server/docs/ui-redesign-mockup.html](server/docs/ui-redesign-mockup.html).
Phases are independent and CSS-only where possible — backend untouched.
### Phase 1 — Design tokens & font embed
- [x] Embed variable fonts (`server/src/ledgrab/static/fonts/`):
Manrope (latin + latin-ext + cyrillic + cyrillic-ext),
JetBrains Mono (same 4 subsets),
Big Shoulders Display (latin + latin-ext). Total +201 KB gzipped,
served via `unicode-range` so only latin paints on first load.
- [x] `fonts.css` — declare `@font-face` entries for all new families with
proper `unicode-range` subsetting; keep DM Sans + Orbitron registered
for legacy-token callers during migration.
- [x] `base.css` — add additive Lumenworks tokens:
`--font-display/--font-brand/--font-body`, `--lux-r-*`, `--lux-hairline`,
`--lux-rule`. Both `[data-theme="dark"]` and `[data-theme="light"]`
define `--lux-bg-0…3`, `--lux-line/-bold`, `--lux-ink/-dim/-mute/-faint`,
`--ch-signal/-cyan/-magenta/-amber/-coral/-violet`, `--lux-signal-glow`,
`--lux-shadow-rack`. Existing tokens untouched — no visual regression.
### Phase 2 — Shell (header → transport bar + channel-strip sidebar)
- [x] `index.html``.tab-bar` moved out of `<header>` into a new
`<aside class="sidebar">`; wrapped content in `.app-body` 2-col grid
(sidebar | main). `.transport-center` section added between
`.header-title` and `.header-toolbar` with a placeholder `.transport-status`
chip ("Ready" → "Armed · N live" wired in Phase 3). All tab-button IDs,
`data-tab` attributes, and `onclick="switchTab(…)"` handlers preserved.
- [x] `layout.css``<header>` rebuilt as the transport bar: 3-column grid
(brand | center | toolbar), 60 px fixed height, sticky, gradient bottom
rule with channel-color wash. `.header-title::before/::after` render
the glowing LED brand mark; `#server-status` repositioned as the LED
core pip. `#server-version` restyled as a mono-type console badge.
- [x] `sidebar.css` (new) — vertical channel-strip navigation. Active tab
gets a glowing left stripe + radial tint. `.sidebar-foot` contains
a `.cpu-meter` plate with two live bars (Load, FPS) ready to be
JS-bound in Phase 3. Collapses to a 56 px icon rail at ≤1100 px;
hides entirely at ≤600 px via `display: contents` so `.tab-bar`
falls through to `mobile.css`'s fixed-bottom strip unchanged.
- [x] `all.css` — new sidebar import after layout.
- [x] `base.css` — body font-family switched to `var(--font-body)` which
resolves to Manrope (with DM Sans + system fallbacks). Added
`font-feature-settings` for stylistic set + alternate 1.
- [x] Locale additions: `sidebar.workspaces`, `sidebar.load`, `sidebar.fps`,
`transport.status.ready`, `transport.status.armed` in en/ru/zh.
- [x] Tutorial + auth selectors (`header .header-title`, `#tab-btn-*`,
`.tab-bar` querySelector, `a.header-link[href="/docs"]`, onclick
markers on theme/settings/search) all survive the move.
- [ ] JS: bind `.cpu-meter` + `.transport-status` chip to existing
`performance` WebSocket / poller. Done as part of Phase 3.
- [ ] Tablet-range visual polish pass once other phases render (some tabs
currently have their own internal sticky headers that may overlap
the transport bar on narrow viewports).
### Phase 3 — Dashboard hero + module redesign
- [x] `cards.css``.card` gets rack-module treatment: channel stripe on
left edge (color-coded via `data-card-type` + `.ch-*` utility classes),
`::after` corner bracket in top-right, mono-typed metric labels
planned for Phase 4. Running cards glow the stripe brighter + emit a
`signalFlow` keyframe strip along the bottom edge.
- [x] Removed the `@property --border-angle` rotating conic-gradient border
(retired the WebKit mask workaround + light-theme variant + fallback
for `@supports not (mask-composite: exclude)`). Replaced with the
signal-flow strip — one animated linear-gradient on a 2 px line, no
GPU layer compositing per card.
- [x] `dashboard.css``.dashboard-target` rows pick up the same channel
stripe + signal-flow treatment. Section headers now use mono caps
with a channel-green underline accent. Metric values use mono with
tabular numerics; labels use silkscreened micro-caps.
- [x] Skeleton-card rewritten: left hairline + corner bracket so it reads
as "loading module" instead of a generic flashing block.
`skeletonShimmer` gradient replaces the old opacity-pulse on
`--text-color`.
- [x] `_updateSidebarMeter` binds CPU% (Load) and app-CPU share (FPS)
to the sidebar meter plate on every perf poll.
- [x] `_updateTransportStatus` updates the transport chip ("Ready" →
"Armed · N live") whenever the dashboard's running-target set is
recomputed.
- [ ] `.hero` 4-cell readout row (Active Patches / Throughput / CPU /
Latency + inline sparklines) — CSS tokens + layout are ready; HTML
render deferred until the dashboard JS is refactored to emit it
(Phase 3b, non-blocking).
### Phase 4 — Other tabs adopt module language
- [x] `tree-nav.css` — trigger pill gets a channel stripe on its left edge
(glows + widens when open). Trigger title uses mono-uppercase with
wide letter-spacing. Dropdown panel has a gradient channel-accent
rule across its top edge. Group headers use silkscreened micro-caps
with a small square marker instead of the old bold-uppercase. Active
leaf has a pulsing LED pip on the left and a channel tint behind it.
Count badges switched to mono tabular-nums in 2-px-radius pills.
- [x] `.subtab-section-header` — channel-green underline accent + mono
micro-caps. Consistent with the dashboard-section pattern so the
whole app shares one section-header language.
- [x] `.stream-tab-btn` sub-tabs — mono uppercase with wide tracking,
active tab shows channel-green underline + glowing count badge.
- [x] `.perf-chart-card` — channel stripe on the left (replaces old
`border-top` accent). Per-metric accents swapped to channel palette
(`--ch-coral` for CPU, `--ch-violet` for RAM, `--ch-signal` for GPU,
`--ch-amber` for temp). Corner bracket added. Metric values pick up
`tabular-nums` + a soft glow.
- [x] `cards.css` — channel-color mapping extended to attributes the JS
already emits (`data-target-id` → green, `data-stream-id` → cyan,
`data-audio-source-id` → magenta, `data-automation-id` /
`data-scene-id` → violet). No JS changes required; cards pick up
their correct stripe automatically on the Targets/Sources/Automations
tabs.
- [x] Graph editor — toolbar gets a gradient background + hairline +
rack shadow + backdrop blur. Canvas and nodes untouched.
- [x] `.template-card` — Lumenworks treatment (channel stripe on left,
corner bracket top-right, hairline border, hover lift + stripe
glow). Brings Inputs (streams / capture / pp / cspt / pattern
templates) and Integrations (HA / MQTT / weather / value /
sync-clock / game-integration cards) up to the same visual
language as `.card` and `.dashboard-target`.
- [x] `cards.css` — channel mapping extended to `.template-card`.
Direct attr hooks for `data-stream-id`/`data-template-id`/`data-pp-template-id`
(cyan), `data-cspt-id`/`data-pattern-template-id` (signal),
`data-audio-template-id`/`data-apt-id` (magenta). Section-scoped
hooks via `[data-card-section="…"]` for cards that share a
generic `data-id` (HA / MQTT / weather / value → cyan;
game-integrations → amber; sync-clocks → violet; HA-light-targets
→ signal). No JS changes — uses the section markup `CardSection`
already emits.
- [x] Graph editor nodes — body fill `--lux-bg-1` with hairline stroke,
hover bold-line, selected/running stroke `--ch-signal` with
drop-shadow glow. Title font switched from DM Sans to
`--font-display`; subtitle to mono uppercase wide-tracking.
Port-drop-target glow recoloured to `--ch-signal`. Port labels
adopt the mono caption treatment. Grid dots use `--lux-line`.
Running gradient stops switched from `--primary-color`/`--success-color`
to channel palette (signal → cyan → signal).
### Phase 5 — Modal restyle
- [x] `modal.css` — backdrop gains a radial dim + 6 px blur for stronger
separation. `.modal-content` gets a gradient background + hairline +
deep rack shadow. Channel-accent rule across the top edge driven by
`--modal-ch` (per-modal override). Corner bracket bottom-right on
desktop. `.modal-header` gains a vertical channel-color stripe to
the left of the title; `.modal-footer` picks up a hairline divider.
- [x] Per-modal channel mapping by modal ID:
- Target editors → green
- Input/Source editors → cyan
- Audio editors → magenta
- Automation / Scene / Game editors → violet
- Settings / API key / Setup / Notifications → amber
- Confirm dialog → coral
- [x] `components.css` — inputs use hairline borders, tabular-nums mono
for `input[type="number"]`, channel-green focus ring + glow. Buttons
use mono-uppercase type, signal-glow on primary, coral-glow on
danger. `<select>` audit deferred (project already enforces via
CLAUDE.md rule + IconSelect/EntitySelect wrappers).
### Phase 6 — Mobile dedicated shell
- [x] `mobile.css` (existing file, not forked) — fixed-bottom `.tab-bar`
promoted to full Lumenworks treatment: gradient background + hairline
divider at top + channel-accent rule matching the transport-bar
bottom. Active tab gets an LED pip above the icon and a channel-tint
background. Tab labels + badges use mono uppercase to match the
rest of the app. Phone (≤600 px): modal corner-bracket hidden
(fullscreen modals), modal-header stripe slimmed to 18 px.
- [x] Phase 2's layout.css already strips the transport-center on phones
and collapses the sidebar via `display: contents`, so the mobile
shell automatically routes the tab-bar to the bottom without a
separate JS hook.
- [WONTDO] Fork into `mobile-shell.css` — keeping changes in `mobile.css`
since the cascade was already organized by viewport. A rename adds
churn without improving maintainability.
### Phase 7 — Microcopy + retire legacy
- [x] Locale rename: `targets.title` + `dashboard.section.targets`
"Channels" (en) / "Каналы" (ru) / "通道" (zh);
`streams.title` → "Inputs" / "Входы" / "输入".
Automations kept as-is (Automations + Scenes is a meaningful
distinction; "Patches" would conflate them). Internal tab keys
(`dashboard` / `automations` / `targets` / `streams` / `integrations`
/ `graph`) unchanged so no JS or localStorage migration needed.
- [x] Ambient WebGL background — default is already `off`; kept the
toggle button and localStorage preference so users who want the
shader can turn it on. No entry-point change needed: `data-bg-anim`
is initialized from localStorage with `off` fallback.
- [DEFERRED] Delete DM Sans + legacy color tokens — would cascade through
every file that reads `--primary-color` / `--text-color` etc. Safer
as a separate cleanup PR after the new design has soaked.
- [WONTDO] Delete `mobile.css` — Phase 6 kept the filename.
## Dashboard Customization
Per-account dashboard layout — slide-in Customize panel lets users
toggle section / perf-cell visibility, reorder via drag, change density,
pick presets, and import/export the layout as JSON. Server-synced via
`db.get_setting('dashboard_layout')` so settings follow the user.
- [x] `js/features/dashboard-layout.ts` — schema (open registry of section
/ perf-cell keys so v1.1 cards slot in with no migration), defaults,
5 built-in presets (Studio/Operator/Showrunner/Diagnostics/TV),
localStorage cache + server sync, legacy-key migration from
`dashboard_collapsed`, `perfMetricsMode`, `perfChartColor_*`.
- [x] `api/routes/preferences.py` — `GET/PUT/DELETE
/api/v1/preferences/dashboard-layout`. Treats payload as opaque
(frontend owns the schema); validates only that body is an object
with a numeric `version`. 6 pytest tests in
`tests/test_preferences_api.py` cover round-trip, default-empty,
validation, delete, and unknown-field passthrough.
- [x] `js/features/dashboard.ts` — sections rendered into a fragment map,
then assembled in layout-driven order; perf section stays pinned
top (chart-persistence reasons) but its visibility is layout-
driven. Layout-change subscription invalidates the in-place-update
optimization so density / order / visibility changes always
rebuild section HTML.
- [x] `js/features/perf-charts.ts` — `renderPerfSection()` iterates
`getOrderedPerfCells()`; existing legacy `setPerfMode` writes
through to the layout so the global toggle and the customize
panel stay in sync.
- [x] `js/features/dashboard-customize.ts` + `css/dashboard-customize.css`
— slide-in panel, hand-rolled HTML5 drag-and-drop reorder, ↑/↓
buttons for keyboard / TV remote, debounced (300 ms) autosave,
live preview while open. Reset / export / import actions.
- [x] i18n keys for `dashboard.customize.*` in en/ru/zh.
- [ ] (v1.1) Audio meters section — peak / RMS / BPM bars per audio
source. Schema key `audio-meters` already reserved.
- [ ] (v1.1) Alerts section — quiet by default, loud on issues.
Reserved key `alerts`.
- [ ] (v1.1) Live LED preview strip per running device. Reserved
key `led-preview`.
- [ ] (v1.1) Source thumbnails grid (1 fps multiviewer). Reserved
key `source-thumbs`.
- [ ] (v1.2) Pinned section (user-curated mix of targets / scenes /
devices). Reserved key `pinned`.
- [ ] (v1.2) Patch/flow map — read-only mini graph of routing.
Reserved key `flow`.
## BLE LED Controller Support (SP110E / Triones / Zengge / Govee)
Add support for Bluetooth Low Energy LED controllers driven by mobile apps like "LED Hue", HappyLighting, iLightsIn. Whole-strip ambient-color output only — these protocols don't support per-pixel streaming.
+1 -1
View File
@@ -40,7 +40,7 @@ android {
// in CI). See ledgrabVersionCode above. Was stuck at 1 before —
// sideload updates silently refused to install.
versionCode = ledgrabVersionCode
versionName = "0.4.1"
versionName = "0.5.0"
ndk {
// All three ABIs: arm64-v8a (real TV hardware), x86_64 (modern
+10
View File
@@ -69,6 +69,16 @@ copy_app_files() {
# Clean up source maps and __pycache__
find "$APP_DIR" -name "*.map" -delete 2>/dev/null || true
find "$APP_DIR" -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
# Patch the fallback version in the bundled __init__.py. Bundled installs
# strip ledgrab-*.dist-info from site-packages, so importlib.metadata
# falls back to this literal at runtime — and a stale literal is what
# silently shipped v0.4.2 reporting "0.3.0" in the WebUI.
local bundled_init="$APP_DIR/src/ledgrab/__init__.py"
if [ -f "$bundled_init" ] && [ -n "${VERSION_CLEAN:-}" ]; then
sed -i "s/_FALLBACK_VERSION = \"[^\"]*\"/_FALLBACK_VERSION = \"${VERSION_CLEAN}\"/" "$bundled_init"
echo " Patched _FALLBACK_VERSION -> ${VERSION_CLEAN}"
fi
}
# ── Site-packages cleanup ────────────────────────────────────
+11
View File
@@ -196,6 +196,17 @@ New-Item -ItemType Directory -Path (Join-Path $DistDir "logs") -Force | Out-Null
Get-ChildItem -Path $srcDest -Recurse -Filter "*.map" | Remove-Item -Force -ErrorAction SilentlyContinue
Get-ChildItem -Path $srcDest -Recurse -Directory -Filter "__pycache__" | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue
# Patch the fallback version in the bundled __init__.py so the WebUI always
# reports the release version — the installer strips ledgrab-*.dist-info from
# site-packages (above), so importlib.metadata falls back to this literal.
$bundledInit = Join-Path $srcDest "ledgrab\__init__.py"
if (Test-Path $bundledInit) {
$initContent = Get-Content $bundledInit -Raw
$patched = [regex]::Replace($initContent, '_FALLBACK_VERSION\s*=\s*"[^"]*"', "_FALLBACK_VERSION = `"$VersionClean`"")
Set-Content -Path $bundledInit -Value $patched -NoNewline
Write-Host " Patched _FALLBACK_VERSION -> $VersionClean"
}
# ── Create launcher ────────────────────────────────────────────
Write-Host "[8/8] Creating launcher..."
File diff suppressed because it is too large Load Diff
+48
View File
@@ -14,6 +14,9 @@
"marked": "^17.0.5"
},
"devDependencies": {
"@fontsource-variable/big-shoulders-display": "^5.2.5",
"@fontsource-variable/jetbrains-mono": "^5.2.8",
"@fontsource-variable/manrope": "^5.2.8",
"esbuild": "^0.27.4",
"typescript": "^5.9.3"
}
@@ -434,6 +437,33 @@
"node": ">=18"
}
},
"node_modules/@fontsource-variable/big-shoulders-display": {
"version": "5.2.5",
"resolved": "https://registry.npmjs.org/@fontsource-variable/big-shoulders-display/-/big-shoulders-display-5.2.5.tgz",
"integrity": "sha512-ZH2w9u6018xbSf8vPZ42P/KxpQHfIsKnxSnMtLFgwui1zIS05vzlijAWRcaRQoY2pXu4Z3SVa88OANsmq6mkvA==",
"dev": true,
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource-variable/jetbrains-mono": {
"version": "5.2.8",
"resolved": "https://registry.npmjs.org/@fontsource-variable/jetbrains-mono/-/jetbrains-mono-5.2.8.tgz",
"integrity": "sha512-WBA9elru6Jdp5df2mES55wuOO0WIrn3kpXnI4+W2ek5u3ZgLS9XS4gmIlcQhiZOWEKl95meYdvK7xI+ETLCq/Q==",
"dev": true,
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource-variable/manrope": {
"version": "5.2.8",
"resolved": "https://registry.npmjs.org/@fontsource-variable/manrope/-/manrope-5.2.8.tgz",
"integrity": "sha512-nc9lOuCRz73UHnovDE2bwXUdghE2SEOc7Aii0qGe3CLyE03W1a7VnY5Z6euRiapiKbCkGS+eXbY3s/kvWeGeSw==",
"dev": true,
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@kurkle/color": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
@@ -704,6 +734,24 @@
"dev": true,
"optional": true
},
"@fontsource-variable/big-shoulders-display": {
"version": "5.2.5",
"resolved": "https://registry.npmjs.org/@fontsource-variable/big-shoulders-display/-/big-shoulders-display-5.2.5.tgz",
"integrity": "sha512-ZH2w9u6018xbSf8vPZ42P/KxpQHfIsKnxSnMtLFgwui1zIS05vzlijAWRcaRQoY2pXu4Z3SVa88OANsmq6mkvA==",
"dev": true
},
"@fontsource-variable/jetbrains-mono": {
"version": "5.2.8",
"resolved": "https://registry.npmjs.org/@fontsource-variable/jetbrains-mono/-/jetbrains-mono-5.2.8.tgz",
"integrity": "sha512-WBA9elru6Jdp5df2mES55wuOO0WIrn3kpXnI4+W2ek5u3ZgLS9XS4gmIlcQhiZOWEKl95meYdvK7xI+ETLCq/Q==",
"dev": true
},
"@fontsource-variable/manrope": {
"version": "5.2.8",
"resolved": "https://registry.npmjs.org/@fontsource-variable/manrope/-/manrope-5.2.8.tgz",
"integrity": "sha512-nc9lOuCRz73UHnovDE2bwXUdghE2SEOc7Aii0qGe3CLyE03W1a7VnY5Z6euRiapiKbCkGS+eXbY3s/kvWeGeSw==",
"dev": true
},
"@kurkle/color": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
+3
View File
@@ -16,6 +16,9 @@
"author": "",
"license": "ISC",
"devDependencies": {
"@fontsource-variable/big-shoulders-display": "^5.2.5",
"@fontsource-variable/jetbrains-mono": "^5.2.8",
"@fontsource-variable/manrope": "^5.2.8",
"esbuild": "^0.27.4",
"typescript": "^5.9.3"
},
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "ledgrab"
version = "0.4.1"
version = "0.5.0"
description = "Ambient lighting system that captures screen content and drives LED strips in real time"
authors = [
{name = "Alexei Dolgolyov", email = "dolgolyov.alexei@gmail.com"}
+7 -3
View File
@@ -2,10 +2,14 @@
from importlib.metadata import version, PackageNotFoundError
# Fallback version — kept in sync with pyproject.toml.
# Fallback version — kept in sync with pyproject.toml. MUST match the
# version declared there on every release. The Windows installer build
# (build/build-dist.ps1) also patches this literal to the resolved build
# version, so any drift here is corrected for bundled distributions.
# Used when the package isn't pip-installed (e.g. embedded via Chaquopy
# on Android, where the source is included directly via source sets).
_FALLBACK_VERSION = "0.3.0"
# on Android, where the source is included directly via source sets, or
# in the Windows bundle where the installed dist-info is stripped).
_FALLBACK_VERSION = "0.4.2"
try:
__version__ = version("ledgrab")
+21 -3
View File
@@ -12,6 +12,8 @@ import threading
import time
import webbrowser
from pathlib import Path
from urllib.error import URLError
from urllib.request import urlopen
def _fix_embedded_tcl_paths() -> None:
@@ -54,9 +56,25 @@ def _run_server(server: uvicorn.Server) -> None:
loop.run_until_complete(server.serve())
def _open_browser(port: int, delay: float = 2.0) -> None:
"""Open the UI in the default browser after a short delay."""
time.sleep(delay)
def _wait_for_server(port: int, timeout: float = 30.0, interval: float = 0.25) -> bool:
"""Poll /health until the server responds or *timeout* seconds elapse."""
url = f"http://localhost:{port}/health"
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
try:
with urlopen(url, timeout=1) as resp: # noqa: S310 - localhost only
if 200 <= resp.status < 500:
return True
except (URLError, ConnectionError, OSError, TimeoutError):
pass
time.sleep(interval)
return False
def _open_browser(port: int) -> None:
"""Open the UI in the default browser once the server is ready."""
if not _wait_for_server(port):
logger.warning("Server did not become ready in time; opening browser anyway")
webbrowser.open(f"http://localhost:{port}")
+2
View File
@@ -31,6 +31,7 @@ from .routes.game_integration import router as game_integration_router
from .routes.audio_processing_templates import router as audio_processing_templates_router
from .routes.audio_filters import router as audio_filters_router
from .routes.pattern_templates import router as pattern_templates_router
from .routes.preferences import router as preferences_router
router = APIRouter()
router.include_router(system_router)
@@ -62,5 +63,6 @@ router.include_router(game_integration_router)
router.include_router(audio_processing_templates_router)
router.include_router(audio_filters_router)
router.include_router(pattern_templates_router)
router.include_router(preferences_router)
__all__ = ["router"]
@@ -0,0 +1,75 @@
"""User preferences routes — currently dashboard layout only.
The dashboard layout schema is owned by the frontend (open registry of
section/cell keys); the backend treats the value as an opaque JSON blob,
validates it's a dict with a `version` field, and persists it under the
`dashboard_layout` settings key.
"""
from typing import Any
from fastapi import APIRouter, Body, Depends, HTTPException
from ledgrab.api.auth import AuthRequired
from ledgrab.api.dependencies import get_database
from ledgrab.storage.database import Database
from ledgrab.utils import get_logger
logger = get_logger(__name__)
router = APIRouter()
_DASHBOARD_LAYOUT_KEY = "dashboard_layout"
@router.get(
"/api/v1/preferences/dashboard-layout",
tags=["Preferences"],
)
async def get_dashboard_layout(
_: AuthRequired,
db: Database = Depends(get_database),
) -> dict[str, Any]:
"""Read the saved dashboard layout. Returns an empty object when no
layout has been saved yet — the frontend falls back to its built-in
default in that case."""
value = db.get_setting(_DASHBOARD_LAYOUT_KEY)
return value if value is not None else {}
@router.put(
"/api/v1/preferences/dashboard-layout",
tags=["Preferences"],
)
async def put_dashboard_layout(
_: AuthRequired,
body: dict[str, Any] = Body(...),
db: Database = Depends(get_database),
) -> dict[str, bool]:
"""Save the dashboard layout. The body must be a JSON object with a
numeric `version` field; everything else is treated as opaque payload
that the frontend will validate on read."""
if not isinstance(body, dict):
raise HTTPException(status_code=422, detail="Body must be a JSON object")
if not isinstance(body.get("version"), int):
raise HTTPException(
status_code=422,
detail="Layout must include a numeric 'version' field",
)
db.set_setting(_DASHBOARD_LAYOUT_KEY, body)
return {"ok": True}
@router.delete(
"/api/v1/preferences/dashboard-layout",
tags=["Preferences"],
)
async def delete_dashboard_layout(
_: AuthRequired,
db: Database = Depends(get_database),
) -> dict[str, bool]:
"""Delete the saved layout — frontend will revert to the default
on next load. Used by the 'Reset' button when the user wants
to clear the server-side override entirely."""
db.set_setting(_DASHBOARD_LAYOUT_KEY, {})
return {"ok": True}
+19
View File
@@ -7,6 +7,7 @@ import asyncio
import platform
import subprocess
import sys
import time
from datetime import datetime, timezone
from typing import Optional
@@ -92,6 +93,13 @@ def _get_cpu_name() -> str | None:
_cpu_name: str | None = _get_cpu_name()
# Captured at first import of this module. Process-wide elapsed time is
# the closest the server has to "app start" without instrumenting main.py;
# the system module is imported during router setup, before the server
# accepts requests, so the drift is negligible. Used by /health to expose
# uptime_seconds for the transport-bar ticker.
_APP_START_MONOTONIC: float = time.monotonic()
router = APIRouter()
@@ -122,6 +130,7 @@ async def health_check(request: Request):
setup_required=setup_required,
repo_url=REPO_URL,
donate_url=DONATE_URL,
uptime_seconds=time.monotonic() - _APP_START_MONOTONIC,
)
@@ -316,6 +325,15 @@ def get_system_performance(_: AuthRequired):
except Exception as e:
logger.debug("NVML query failed: %s", e)
# Windows has no user-space CPU die temperature source without a kernel
# driver. We rely on LibreHardwareMonitor / OpenHardwareMonitor publishing
# WMI sensors when the user runs them. When no reading arrives, surface
# that explicitly so the dashboard can show a "here's how to enable it"
# hint instead of silently hiding the card.
cpu_temp_hint_key: str | None = None
if thermals.cpu_temp_c is None and platform.system() == "Windows":
cpu_temp_hint_key = "dashboard.perf.temp.install_lhm"
return PerformanceResponse(
cpu_name=_cpu_name,
cpu_percent=metrics.cpu_percent(),
@@ -328,6 +346,7 @@ def get_system_performance(_: AuthRequired):
battery_percent=thermals.battery_percent,
battery_temp_c=thermals.battery_temp_c,
cpu_temp_c=thermals.cpu_temp_c,
cpu_temp_hint_key=cpu_temp_hint_key,
timestamp=datetime.now(timezone.utc),
)
@@ -19,6 +19,9 @@ from ledgrab.api.schemas.system import (
LogLevelResponse,
MQTTSettingsRequest,
MQTTSettingsResponse,
ShutdownAction,
ShutdownActionRequest,
ShutdownActionResponse,
)
from ledgrab.config import get_config
from ledgrab.storage.database import Database
@@ -150,6 +153,55 @@ async def update_external_url(
return ExternalUrlResponse(external_url=url)
# ---------------------------------------------------------------------------
# Shutdown action setting
# ---------------------------------------------------------------------------
_VALID_SHUTDOWN_ACTIONS: tuple[str, ...] = ("stop_targets", "nothing")
_DEFAULT_SHUTDOWN_ACTION: ShutdownAction = "stop_targets"
def load_shutdown_action(db: Database | None = None) -> ShutdownAction:
"""Load the configured shutdown action. Returns the default if unset or corrupt."""
if db is None:
from ledgrab.api.dependencies import get_database
db = get_database()
data = db.get_setting("shutdown_action")
if not data:
return _DEFAULT_SHUTDOWN_ACTION
value = data.get("action")
if value in _VALID_SHUTDOWN_ACTIONS:
return value # type: ignore[return-value]
return _DEFAULT_SHUTDOWN_ACTION
@router.get(
"/api/v1/system/shutdown-action",
response_model=ShutdownActionResponse,
tags=["System"],
)
async def get_shutdown_action(_: AuthRequired, db: Database = Depends(get_database)):
"""Get the configured server shutdown action."""
return ShutdownActionResponse(action=load_shutdown_action(db))
@router.put(
"/api/v1/system/shutdown-action",
response_model=ShutdownActionResponse,
tags=["System"],
)
async def update_shutdown_action(
_: AuthRequired,
body: ShutdownActionRequest,
db: Database = Depends(get_database),
):
"""Set what happens to LED targets when the server shuts down."""
db.set_setting("shutdown_action", {"action": body.action})
logger.info("Shutdown action updated: %s", body.action)
return ShutdownActionResponse(action=body.action)
# ---------------------------------------------------------------------------
# Live log viewer WebSocket
# ---------------------------------------------------------------------------
+39
View File
@@ -26,6 +26,10 @@ class HealthResponse(BaseModel):
)
repo_url: str = Field(default="", description="Source code repository URL")
donate_url: str = Field(default="", description="Donation page URL")
uptime_seconds: float = Field(
default=0.0,
description="Process uptime in seconds since the server started.",
)
class VersionResponse(BaseModel):
@@ -98,6 +102,15 @@ class PerformanceResponse(BaseModel):
default=None,
description="Hottest CPU/SoC thermal zone in °C (null if unsupported)",
)
cpu_temp_hint_key: str | None = Field(
default=None,
description=(
"i18n key for an explainer shown in the Temperature card when "
"cpu_temp_c is null and the platform has a known workaround "
"(e.g. install LibreHardwareMonitor on Windows). Null on "
"platforms where unavailable simply means 'not reported'."
),
)
timestamp: datetime = Field(description="Measurement timestamp")
@@ -191,6 +204,32 @@ class ExternalUrlRequest(BaseModel):
external_url: str = Field(default="", description="External base URL. Empty string to clear.")
# ─── Shutdown action schemas ───────────────────────────────────
ShutdownAction = Literal["stop_targets", "nothing"]
class ShutdownActionResponse(BaseModel):
"""Current server shutdown action setting."""
action: ShutdownAction = Field(
description=(
"What happens to LED targets when the server shuts down. "
"`stop_targets` runs the normal stop sequence (per-device "
"auto_shutdown decides whether prior state is restored). "
"`nothing` skips device-touching teardown — lights freeze on "
"their last frame regardless of per-device auto_shutdown."
),
)
class ShutdownActionRequest(BaseModel):
"""Update the server shutdown action setting."""
action: ShutdownAction = Field(description="New shutdown action.")
# ─── Log level schemas ─────────────────────────────────────────
@@ -770,8 +770,16 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
# ===== LIFECYCLE =====
async def stop_all(self):
"""Stop processing and health monitoring for all targets and devices."""
async def stop_all(self, restore_devices: bool = True):
"""Stop processing and health monitoring for all targets and devices.
When ``restore_devices`` is False, processor tasks are cancelled
directly instead of going through ``proc.stop()`` (which sends
per-device auto_shutdown restore frames), and the global
idle-state restore loop is skipped. Used by the "Nothing"
shutdown action so lights freeze on their last frame regardless
of per-device auto_shutdown.
"""
await self._metrics_history.stop()
await self.stop_health_monitoring()
@@ -781,18 +789,35 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
if rs.restart_task and not rs.restart_task.done():
rs.restart_task.cancel()
# Stop all processors
for target_id, proc in list(self._processors.items()):
if proc.is_running:
try:
await proc.stop()
except Exception as e:
logger.error(f"Error stopping target {target_id}: {e}")
if restore_devices:
# Stop all processors (per-device auto_shutdown decides whether
# the prior device state is restored).
for target_id, proc in list(self._processors.items()):
if proc.is_running:
try:
await proc.stop()
except Exception as e:
logger.error(f"Error stopping target {target_id}: {e}")
# Restore idle state for devices that have auto-restore enabled
# (serial devices already dark from processor close; WLED restored by snapshot)
for device_id in self._devices:
await self._restore_device_idle_state(device_id)
# Restore idle state for devices that have auto-restore enabled
# (serial devices already dark from processor close; WLED restored by snapshot)
for device_id in self._devices:
await self._restore_device_idle_state(device_id)
else:
# "Nothing" mode: cancel processor capture tasks without sending
# restore frames so the LEDs keep displaying the last frame.
# ``cancel_task`` (defined on ``TargetProcessor``) awaits the
# cancellation so the loop's current iteration completes — no
# half-written frame on the wire when the process exits.
for target_id, proc in list(self._processors.items()):
try:
await proc.cancel_task()
except Exception as e:
logger.error(f"Error cancelling task for target {target_id}: {e}")
logger.info(
"Shutdown action 'nothing': skipped device restore for %d target(s)",
len(self._processors),
)
# Close any cached idle LED clients (WLED only; serial has no cached clients)
for did in list(self._idle_clients):
@@ -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
@@ -0,0 +1,78 @@
# Minecraft community adapter
# Requires a server-side mod that sends game state via webhook
# (e.g., GameStateIntegration mod or custom Fabric/Forge mod)
#
# Configure your mod to POST JSON to:
# http://<WLED_IP>:8080/api/v1/game-integrations/<ID>/event
name: minecraft
game: Minecraft
protocol: webhook
mappings:
- source_path: player.health
event: health
min: 0
max: 20
trigger: on_change
- source_path: player.armor
event: armor
min: 0
max: 20
trigger: on_change
- source_path: player.food_level
event: energy
min: 0
max: 20
trigger: on_change
- source_path: player.experience_level
event: speed
min: 0
max: 100
trigger: on_change
- source_path: player.deaths
event: death
trigger: on_increase
min: 0
max: 100
- source_path: stats.kills
event: kill
trigger: on_increase
min: 0
max: 100
auth:
type: header
header: X-Minecraft-Auth
setup_instructions: |
## Minecraft Integration Setup
This adapter requires a server-side mod that sends game state data as JSON.
**Recommended mods:**
- [GameStateIntegration](https://github.com/example/gsi-mod) (Fabric)
- Custom Forge mod using `PlayerTickEvent`
**Expected JSON format:**
```json
{
"player": {
"health": 20.0,
"armor": 10,
"food_level": 18,
"experience_level": 30
},
"stats": {
"kills": 5
}
}
```
Configure the mod to POST to the event endpoint with the auth token
in the `X-Minecraft-Auth` header.
@@ -0,0 +1,99 @@
# Rocket League community adapter
# Uses the SOS (Rocket League Overlay System) plugin
# https://gitlab.com/bakkesplugins/sos/sos-plugin
#
# SOS sends game state via WebSocket, but you can use a bridge
# to forward events as HTTP POST to:
# http://<WLED_IP>:8080/api/v1/game-integrations/<ID>/event
name: rocket_league
game: Rocket League
protocol: webhook
mappings:
- source_path: player.boost
event: energy
min: 0
max: 100
trigger: on_change
- source_path: player.speed
event: speed
min: 0
max: 2300
trigger: on_value
- source_path: match.goals_scored
event: kill
trigger: on_increase
min: 0
max: 20
- source_path: match.goals_conceded
event: death
trigger: on_increase
min: 0
max: 20
- source_path: match.time_remaining
event: objective_progress
min: 0
max: 300
trigger: on_value
- source_path: game.started
event: match_start
trigger: on_change
min: 0
max: 1
- source_path: game.ended
event: match_end
trigger: on_change
min: 0
max: 1
- source_path: team.score_blue
event: team_a
min: 0
max: 10
trigger: on_change
- source_path: team.score_orange
event: team_b
min: 0
max: 10
trigger: on_change
setup_instructions: |
## Rocket League Integration Setup
This adapter works with the SOS (Rocket League Overlay System) plugin.
**Setup:**
1. Install BakkesMod: https://bakkesmod.com
2. Install the SOS plugin from the BakkesMod plugin manager
3. Use a WebSocket-to-HTTP bridge to forward SOS events
**Bridge tool:**
A small script that connects to SOS WebSocket (ws://localhost:49122)
and forwards events as HTTP POST to the WLED event endpoint.
**Expected JSON format:**
```json
{
"player": {
"boost": 75,
"speed": 1500
},
"match": {
"goals_scored": 2,
"goals_conceded": 1,
"time_remaining": 180
},
"team": {
"score_blue": 2,
"score_orange": 1
}
}
```
@@ -0,0 +1,85 @@
# Valorant community adapter
# Uses Overwolf/Insights API or third-party overlay tool
# that exposes game state via webhook
#
# Configure your overlay to POST JSON to:
# http://<WLED_IP>:8080/api/v1/game-integrations/<ID>/event
name: valorant
game: Valorant
protocol: webhook
mappings:
- source_path: player.health
event: health
min: 0
max: 100
trigger: on_change
- source_path: player.shield
event: shield
min: 0
max: 50
trigger: on_change
- source_path: player.money
event: gold
min: 0
max: 9000
trigger: on_change
- source_path: match.kills
event: kill
trigger: on_increase
min: 0
max: 50
- source_path: match.deaths
event: death
trigger: on_increase
min: 0
max: 50
- source_path: match.round_phase
event: round_start
trigger: on_change
min: 0
max: 1
- source_path: match.spike_planted
event: objective_captured
trigger: on_change
min: 0
max: 1
auth:
type: header
header: X-Valorant-Auth
setup_instructions: |
## Valorant Integration Setup
Valorant does not have a native Game State Integration API.
You need a third-party tool to capture and forward game data.
**Options:**
- Overwolf with a game events plugin
- Insights.gg capture API
- Custom screen-reading overlay
**Expected JSON format:**
```json
{
"player": {
"health": 100,
"shield": 50,
"money": 3900
},
"match": {
"kills": 12,
"deaths": 5,
"round_phase": 1,
"spike_planted": 0
}
}
```
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+15 -2
View File
@@ -412,9 +412,22 @@ async def lifespan(app: FastAPI):
except Exception as e:
logger.error(f"Error stopping OS notification listener: {e}")
# Stop all processing
# Stop all processing.
# The shutdown action setting controls whether per-device restore
# frames are sent: "stop_targets" (default) runs the normal stop
# sequence; "nothing" cancels capture tasks so the LEDs freeze on
# their last frame.
try:
await processor_manager.stop_all()
from ledgrab.api.routes.system_settings import load_shutdown_action
action = load_shutdown_action(db)
except Exception as e:
logger.error(f"Error reading shutdown action setting, defaulting to stop_targets: {e}")
action = "stop_targets"
logger.info("Shutdown action: %s", action)
try:
await processor_manager.stop_all(restore_devices=action != "nothing")
logger.info("Stopped all processors")
except Exception as e:
logger.error(f"Error stopping processors: {e}")
+2
View File
@@ -2,12 +2,14 @@
@import './fonts.css';
@import './base.css';
@import './layout.css';
@import './sidebar.css';
@import './components.css';
@import './cards.css';
@import './modal.css';
@import './calibration.css';
@import './advanced-calibration.css';
@import './dashboard.css';
@import './dashboard-customize.css';
@import './streams.css';
@import './patterns.css';
@import './automations.css';
+11 -7
View File
@@ -1,19 +1,23 @@
/* ===== AUTOMATIONS ===== */
.badge-automation-active {
background: var(--success-color);
color: #fff;
background: color-mix(in srgb, var(--ch-signal, var(--primary-color)) 16%, transparent);
border-color: color-mix(in srgb, var(--ch-signal, var(--primary-color)) 45%, transparent);
color: var(--ch-signal, var(--primary-color));
box-shadow: 0 0 8px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 20%, transparent);
}
.badge-automation-inactive {
background: var(--border-color);
color: var(--text-color);
background: transparent;
border-color: var(--lux-line, var(--border-color));
color: var(--lux-ink-dim, var(--text-color));
}
.badge-automation-disabled {
background: var(--border-color);
color: var(--text-muted);
opacity: 0.7;
background: transparent;
border-color: var(--lux-line, var(--border-color));
color: var(--lux-ink-mute, var(--text-muted));
opacity: 0.8;
}
.automation-status-disabled {
+87 -23
View File
@@ -16,7 +16,25 @@
--danger-color: #f44336;
--warning-color: #ff9800;
--info-color: #2196F3;
--font-mono: 'Cascadia Code', 'Fira Code', 'JetBrains Mono', 'SF Mono', 'Consolas', 'Liberation Mono', monospace;
--font-mono: 'JetBrains Mono', 'Cascadia Code', 'Fira Code', 'SF Mono', 'Consolas', 'Liberation Mono', monospace;
/* ── Lumenworks design tokens (additive; active alongside legacy tokens
during phased migration). Typography + spatial system for the
studio-console redesign. Channel colors defined in the theme
blocks below so they can shift with light/dark mode. ──────── */
--font-display: 'Big Shoulders Display', 'Orbitron', 'Manrope', sans-serif;
--font-brand: 'Orbitron', sans-serif;
--font-body: 'Manrope', 'DM Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
--lux-r-sm: 3px;
--lux-r-md: 6px;
--lux-r-lg: 10px;
--lux-r-xl: 14px;
/* Hairline + bold dividers — thinner than the legacy 1px --border-color
to get the "silkscreened panel" feel. */
--lux-hairline: 1px;
--lux-rule: 2px;
/* Spacing scale */
--space-xs: 4px;
@@ -81,9 +99,9 @@
/* Dark theme (default) */
[data-theme="dark"] {
--bg-color: #1a1a1a;
--bg-secondary: #242424;
--card-bg: #2d2d2d;
--bg-color: #000000;
--bg-secondary: #0a0b0d;
--card-bg: #000000;
--text-color: #e0e0e0;
--text-primary: #e0e0e0;
--text-secondary: #999;
@@ -96,12 +114,40 @@
--hover-bg: rgba(255, 255, 255, 0.05);
--input-bg: #1a1a2e;
color-scheme: dark;
/* ── Lumenworks dark palette — page is pure black, cards elevate ── */
--lux-bg-0: #000000;
--lux-bg-1: #0e1014;
--lux-bg-2: #15181d;
--lux-bg-3: #1c2027;
--lux-line: #232831;
--lux-line-bold:#2e3440;
--lux-ink: #e6ebf2;
--lux-ink-dim: #8b95a5;
--lux-ink-mute: #5b6473;
--lux-ink-faint:#3a414c;
/* Channel palette — consistent across tabs for entity types.
--ch-signal tracks --primary-color so the accent color picker
propagates through the brand mark, running stripes, transport
chip, active tabs, etc. Other channels are fixed hues used for
non-primary entity types. */
--ch-signal: var(--primary-color);
--ch-signal-dim: var(--primary-text-color, var(--primary-color));
--ch-cyan: #00d8ff; /* data / sources / screen */
--ch-magenta: #ff4ade; /* audio / FFT */
--ch-amber: #ffb800; /* autostart / pending */
--ch-coral: #ff5e5e; /* offline / error / alarm */
--ch-violet: #8b7eff; /* graph / scenes / automations */
--lux-signal-glow: 0 0 14px color-mix(in srgb, var(--ch-signal) 40%, transparent);
--lux-shadow-rack: 0 1px 0 rgba(255, 255, 255, 0.03), 0 8px 24px rgba(0, 0, 0, 0.5);
}
/* Light theme */
[data-theme="light"] {
--bg-color: #f5f5f5;
--bg-secondary: #eee;
--bg-color: #ffffff;
--bg-secondary: #fafbfc;
--card-bg: #ffffff;
--text-color: #333333;
--text-primary: #333333;
@@ -120,6 +166,32 @@
--primary-color-on-light-bg: #2e7d32;
--primary-text: #2e7d32;
color-scheme: light;
/* ── Lumenworks light palette — page is pure white, cards slightly
off-white so the stripe + hairline border still read against
the page. WCAG AA tuned. ── */
--lux-bg-0: #ffffff;
--lux-bg-1: #f6f8fb;
--lux-bg-2: #eef1f5;
--lux-bg-3: #e4e8ee;
--lux-line: #dee3ea;
--lux-line-bold:#c4ccd6;
--lux-ink: #0f1419;
--lux-ink-dim: #4c5866;
--lux-ink-mute: #6b7684;
--lux-ink-faint:#a5afbc;
/* --ch-signal tracks --primary-color so the accent picker propagates. */
--ch-signal: var(--primary-color);
--ch-signal-dim: var(--primary-text-color, var(--primary-color));
--ch-cyan: #006b88;
--ch-magenta: #b01a99;
--ch-amber: #a56a00;
--ch-coral: #d8392e;
--ch-violet: #5b4fd0;
--lux-signal-glow: 0 0 12px color-mix(in srgb, var(--ch-signal) 28%, transparent);
--lux-shadow-rack: 0 1px 0 rgba(255, 255, 255, 0.6), 0 6px 18px rgba(0, 0, 0, 0.08);
}
/* Default to dark theme */
@@ -137,10 +209,12 @@ html {
}
body {
font-family: 'DM Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
font-family: var(--font-body, 'Manrope', 'DM Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif);
background: var(--bg-color);
color: var(--text-color);
line-height: 1.6;
line-height: 1.55;
font-feature-settings: "ss01", "cv11";
-webkit-font-smoothing: antialiased;
}
html.modal-open {
@@ -167,21 +241,11 @@ html.modal-open {
background: transparent;
}
/* When bg-anim is active, make entity cards slightly translucent
so the shader bleeds through. Only target cards — NOT modals,
pickers, tab bars, headers, or other chrome. */
[data-bg-anim="on"][data-theme="dark"] .card,
[data-bg-anim="on"][data-theme="dark"] .template-card,
[data-bg-anim="on"][data-theme="dark"] .add-device-card,
[data-bg-anim="on"][data-theme="dark"] .dashboard-target {
background: rgba(45, 45, 45, 0.88);
}
[data-bg-anim="on"][data-theme="light"] .card,
[data-bg-anim="on"][data-theme="light"] .template-card,
[data-bg-anim="on"][data-theme="light"] .add-device-card,
[data-bg-anim="on"][data-theme="light"] .dashboard-target {
background: rgba(255, 255, 255, 0.85);
}
/* Card backgrounds are intentionally stable across the dynamic-bg
toggle — the shader bleeds through the page background only.
(Previously a translucent override let the shader show through
cards too, but it made the same card look different depending on
whether the user had the WebGL background enabled.) */
/* Blur behind header via pseudo-element — applying backdrop-filter directly
to header would create a containing block and break position:fixed on
the .tab-bar nested inside it (mobile bottom nav). */
+234 -138
View File
@@ -2,41 +2,68 @@ section {
margin-bottom: 40px;
}
/* ── Skeleton loading placeholders ── */
@keyframes skeletonPulse {
0%, 100% { opacity: 0.06; }
50% { opacity: 0.12; }
/* ── Skeleton loading placeholders — subtle shimmer, not a text-color flash */
@keyframes skeletonShimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
.skeleton-card {
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
padding: 16px 20px 20px;
background: var(--lux-bg-1, var(--card-bg));
border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color));
border-radius: var(--lux-r-md, var(--radius-md));
padding: 18px 20px 16px;
display: flex;
flex-direction: column;
gap: 12px;
min-height: 140px;
position: relative;
overflow: hidden;
/* keep solid — same flat black/white language as real cards */
}
/* Small corner bracket + left hairline so the skeleton reads as a module
placeholder, not a blank box. */
.skeleton-card::before {
content: '';
position: absolute;
left: 0; top: 0; bottom: 0;
width: 3px;
background: var(--lux-line, var(--border-color));
opacity: 0.5;
}
.skeleton-card::after {
content: '';
position: absolute;
top: 8px; right: 8px;
width: 12px; height: 12px;
border-top: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color));
border-right: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color));
opacity: 0.6;
}
.skeleton-line {
height: 14px;
border-radius: 4px;
background: var(--text-color);
animation: skeletonPulse 1.5s ease-in-out infinite;
height: 12px;
border-radius: 2px;
background: linear-gradient(90deg,
var(--lux-bg-2, var(--bg-secondary)) 0%,
var(--lux-bg-3, var(--border-color)) 50%,
var(--lux-bg-2, var(--bg-secondary)) 100%);
background-size: 200% 100%;
animation: skeletonShimmer 2.2s ease-in-out infinite;
}
.skeleton-line-title {
width: 60%;
height: 18px;
width: 55%;
height: 16px;
}
.skeleton-line-short {
width: 40%;
width: 35%;
}
.skeleton-line-medium {
width: 75%;
width: 72%;
}
.skeleton-actions {
@@ -44,15 +71,19 @@ section {
gap: 8px;
margin-top: auto;
padding-top: 12px;
border-top: 1px solid var(--border-color);
border-top: var(--lux-hairline, 1px) dashed var(--lux-line, var(--border-color));
}
.skeleton-btn {
height: 32px;
height: 30px;
flex: 1;
border-radius: var(--radius-sm);
background: var(--text-color);
animation: skeletonPulse 1.5s ease-in-out infinite;
border-radius: var(--lux-r-sm, var(--radius-sm));
background: linear-gradient(90deg,
var(--lux-bg-2, var(--bg-secondary)) 0%,
var(--lux-bg-3, var(--border-color)) 50%,
var(--lux-bg-2, var(--bg-secondary)) 100%);
background-size: 200% 100%;
animation: skeletonShimmer 2.2s ease-in-out infinite;
}
.displays-grid,
@@ -104,21 +135,130 @@ section {
}
.card {
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
padding: 16px 20px 20px;
--ch: var(--ch-signal, var(--primary-color)); /* channel accent (override per type) */
background: var(--lux-bg-1, var(--card-bg));
border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color));
border-radius: var(--lux-r-md, var(--radius-md));
padding: 18px 20px 16px;
position: relative;
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* Channel stripe on left edge — opt-in only:
* [data-has-color="1"] → user picked a personal color via the picker
* .card-running → "patched and live" indicator
* Idle cards without a personal color stay clean (no stripe), matching
* the pre-redesign behavior where the left border meant "I marked this".
* The dashboard module rows keep their always-on stripe (at 0.6 opacity)
* because the dashboard was approved as-is. */
.card::before {
content: '';
position: absolute;
left: 0; top: 0; bottom: 0;
width: 3px;
background: var(--ch);
box-shadow: 0 0 10px color-mix(in srgb, var(--ch) 40%, transparent);
pointer-events: none;
z-index: 1;
display: none;
}
.card[data-has-color="1"]::before,
.card.card-running::before {
display: block;
}
/* Corner bracket — silkscreened panel feel in the top-right */
.card::after {
content: '';
position: absolute;
top: 8px; right: 8px;
width: 12px; height: 12px;
border-top: var(--lux-hairline, 1px) solid var(--lux-line-bold, var(--border-color));
border-right: var(--lux-hairline, 1px) solid var(--lux-line-bold, var(--border-color));
pointer-events: none;
opacity: 0.7;
z-index: 1;
}
.card:hover {
box-shadow: 0 8px 24px var(--shadow-color);
box-shadow: var(--lux-shadow-rack, 0 8px 24px var(--shadow-color));
transform: translateY(-2px);
border-color: var(--lux-line-bold, var(--border-color));
}
/* Channel color variants — cards can opt in via class or data-attr.
Implicit mappings via attributes the JS already emits (no JS changes
required). Explicit classes provided as an escape hatch. */
.card[data-card-type="led"],
.card[data-card-type="target"],
.card[data-target-id],
.card.ch-signal { --ch: var(--ch-signal, var(--primary-color)); }
.card[data-card-type="screen"],
.card[data-card-type="source"],
.card[data-stream-id],
.card.ch-cyan { --ch: var(--ch-cyan, var(--info-color)); }
.card[data-card-type="audio"],
.card[data-audio-source-id],
.card[data-audio-template-id],
.card.ch-magenta { --ch: var(--ch-magenta, #ff4ade); }
.card[data-card-type="automation"],
.card[data-card-type="scene"],
.card[data-automation-id],
.card[data-scene-id],
.card.ch-violet { --ch: var(--ch-violet, #8b7eff); }
.card.ch-amber { --ch: var(--ch-amber, var(--warning-color)); }
.card[data-card-type="offline"],
.card.ch-coral { --ch: var(--ch-coral, var(--danger-color)); }
/* ── Channel mapping for `.template-card` ──
* Cards rendered by `wrapCard({ type: 'template-card' })` are used by the
* Inputs and Integrations tabs (plus a few other CardSection consumers).
* Many of those use a generic `data-id` attribute, so we scope by the
* parent section's `data-card-section` instead of relying on a unique
* data-attr per row. Direct attribute hooks come first for the cards that
* already carry a domain-specific id.
*/
/* Direct attribute hooks (Inputs tab — known per-domain attrs) */
.template-card[data-stream-id],
.template-card[data-template-id],
.template-card[data-pp-template-id] { --ch: var(--ch-cyan, var(--info-color)); }
.template-card[data-cspt-id],
.template-card[data-pattern-template-id] { --ch: var(--ch-signal, var(--primary-color)); }
.template-card[data-audio-template-id],
.template-card[data-apt-id] { --ch: var(--ch-magenta, #ff4ade); }
/* Section-scoped hooks (cards that share `data-id` and need their channel
* resolved via the surrounding section). Matches `<div class="subtab-section"
* data-card-section="…">` emitted by `CardSection.render`. */
/* Network / data-input integrations → cyan (input language) */
[data-card-section="ha-sources"] .template-card[data-id],
[data-card-section="mqtt-sources"] .template-card[data-id],
[data-card-section="weather-sources"] .template-card[data-id],
[data-card-section="value-sources"] .template-card[data-id] { --ch: var(--ch-cyan, var(--info-color)); }
/* Game integrations → amber (events / surfaces) */
[data-card-section="game-integrations"] .template-card[data-id],
.template-card[data-gi-id] { --ch: var(--ch-amber, var(--warning-color)); }
/* Sync clocks → violet (timing / orchestration, mirrors automation/scenes) */
[data-card-section="sync-clocks"] .template-card[data-id] { --ch: var(--ch-violet, #8b7eff); }
/* HA light targets → signal (output target, mirrors led-targets) */
[data-card-section="ha-light-targets"] .template-card[data-ha-target-id] { --ch: var(--ch-signal, var(--primary-color)); }
/* ── Card glare effect ── */
.card-glare::after,
.template-card.card-glare::after,
@@ -175,113 +315,61 @@ section {
);
}
/* ── Running target: rotating gradient border ── */
@property --border-angle {
syntax: '<angle>';
initial-value: 0deg;
inherits: false;
}
/* ── Running module: channel stripe intensifies + signal-flow strip at the
bottom edge ("patched and live" indicator). Lightweight replacement
for the old rotating conic-gradient border — ~1 animated gradient on
a 2 px line, no GPU layer compositing needed per card. */
.card-running {
border-color: transparent;
background: linear-gradient(
calc(var(--border-angle) + 45deg),
var(--card-bg) 0%,
color-mix(in srgb, var(--primary-color) 12%, var(--card-bg)) 40%,
var(--card-bg) 60%,
color-mix(in srgb, var(--primary-color) 8%, var(--card-bg)) 85%,
var(--card-bg) 100%
);
}
/* When card has a custom color stripe, keep it and shift the animated border away from the left edge */
.card-running[data-has-color]::before {
inset: 0 0 0 3px;
border-left: none;
border-radius: 0 8px 8px 0;
border-color: color-mix(in srgb, var(--ch) 35%, var(--lux-line, var(--border-color)));
box-shadow:
0 0 0 1px color-mix(in srgb, var(--ch) 20%, transparent),
0 6px 20px rgba(0, 0, 0, 0.25);
}
.card-running::before {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
border: 2px solid transparent;
background:
conic-gradient(
from var(--border-angle),
var(--primary-color),
rgba(255,255,255,0.1) 25%,
var(--primary-color) 50%,
rgba(255,255,255,0.1) 75%,
var(--primary-color)
) border-box;
-webkit-mask:
linear-gradient(#fff 0 0) padding-box,
linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask:
linear-gradient(#fff 0 0) padding-box,
linear-gradient(#fff 0 0);
mask-composite: exclude;
pointer-events: none;
z-index: 2;
animation: rotateBorder 4s linear infinite;
/* Promote to its own GPU layer so the rotating conic-gradient does not
force repaints of the whole card. */
will-change: transform;
width: 4px;
box-shadow:
0 0 14px color-mix(in srgb, var(--ch) 65%, transparent),
0 0 4px color-mix(in srgb, var(--ch) 80%, transparent);
}
/* Signal-flow strip — running cards replace the corner bracket with a
moving gradient along the bottom edge (the "patched and live"
indicator). Idle cards keep the corner bracket. */
.card-running::after {
top: auto; right: auto;
left: 4px; bottom: 0;
width: calc(100% - 4px);
height: 2px;
border: none;
opacity: 0.7;
background:
linear-gradient(90deg,
transparent 0%,
color-mix(in srgb, var(--ch) 85%, transparent) 50%,
transparent 100%);
background-size: 30% 100%;
background-repeat: no-repeat;
animation: signalFlow 2.4s linear infinite;
}
@keyframes signalFlow {
0% { background-position: -30% 0; }
100% { background-position: 130% 0; }
}
/* Honor user preference for reduced motion — base.css globally clamps
animation durations, but the rotating border is decorative and we'd rather
not run it at all for these users. */
@media (prefers-reduced-motion: reduce) {
.card-running::before {
.card-running::after {
animation: none;
background-position: 50% 0;
background-size: 60% 100%;
}
}
/* TODO(perf): pause animation when the card scrolls off-screen via an
IntersectionObserver toggling `animation-play-state: paused`. Not done in
CSS-only pass — would require a JS hook in card lifecycle. */
/* Fallback for browsers without mask-composite support (older Firefox) */
@supports not (mask-composite: exclude) {
.card-running::before {
-webkit-mask: none;
mask: none;
background: none;
border: 2px solid var(--primary-color);
opacity: 0.7;
}
}
@keyframes rotateBorder {
to { --border-angle: 360deg; }
}
[data-theme="light"] .card-running {
background: linear-gradient(
calc(var(--border-angle) + 45deg),
var(--card-bg) 0%,
color-mix(in srgb, var(--primary-color) 18%, var(--card-bg)) 40%,
var(--card-bg) 60%,
color-mix(in srgb, var(--primary-color) 14%, var(--card-bg)) 85%,
var(--card-bg) 100%
);
}
[data-theme="light"] .card-running::before {
background:
conic-gradient(
from var(--border-angle),
var(--primary-color),
rgba(0,0,0,0.12) 25%,
var(--primary-color) 50%,
rgba(0,0,0,0.12) 75%,
var(--primary-color)
) border-box;
}
/* Keep the corner bracket visible when NOT running (default),
and replace it with the signal flow when running (above).
No extra work needed — `.card::after` rules below cover this. */
/* ── Card entrance animation ── */
@keyframes cardEnter {
@@ -546,18 +634,21 @@ body.cs-drag-active .card-drag-handle {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
margin-bottom: 12px;
padding-right: 60px;
}
.card-title {
font-family: var(--font-body, inherit);
font-size: 1.05rem;
font-weight: 600;
font-weight: 700;
letter-spacing: -0.01em;
min-width: 0;
display: flex;
align-items: center;
gap: 8px;
overflow: hidden;
color: var(--lux-ink, var(--text-color));
}
.card-title-text {
@@ -577,17 +668,18 @@ body.cs-drag-active .card-drag-handle {
.device-url-badge {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 0.7rem;
font-weight: 400;
color: var(--text-secondary);
background: var(--border-color);
gap: 5px;
font-size: 0.68rem;
font-weight: 500;
color: var(--lux-ink-dim, var(--text-secondary));
background: var(--lux-bg-0, var(--border-color));
border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color));
padding: 2px 8px;
border-radius: 10px;
letter-spacing: 0.03em;
font-family: monospace;
border-radius: 2px;
letter-spacing: 0.04em;
font-family: var(--font-mono, monospace);
text-decoration: none;
transition: background 0.2s;
transition: background 0.2s, border-color 0.2s, color 0.2s;
white-space: nowrap;
flex-shrink: 1;
overflow: hidden;
@@ -600,7 +692,9 @@ body.cs-drag-active .card-drag-handle {
}
.device-url-badge:hover {
background: var(--text-muted);
background: var(--lux-bg-2, var(--text-muted));
border-color: var(--lux-line-bold, var(--border-color));
color: var(--lux-ink, var(--text-color));
}
.device-url-icon {
@@ -614,17 +708,19 @@ body.cs-drag-active .card-drag-handle {
.card-subtitle {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 15px;
gap: 8px;
margin-bottom: 14px;
flex-wrap: wrap;
}
.card-meta {
font-size: 0.8rem;
color: var(--text-secondary);
font-family: var(--font-mono, monospace);
font-size: 0.7rem;
color: var(--lux-ink-mute, var(--text-secondary));
display: inline-flex;
align-items: center;
gap: 4px;
gap: 5px;
letter-spacing: 0.04em;
}
.card-meta .icon {
+104 -30
View File
@@ -23,32 +23,40 @@
.card-actions {
display: flex;
gap: 8px;
gap: 6px;
margin-top: auto;
padding-top: 12px;
border-top: 1px solid var(--border-color);
border-top: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color));
align-items: center;
}
.card-actions .btn-icon {
padding: 6px 8px;
font-size: 1.1rem;
padding: 7px 10px;
min-width: 36px;
font-size: 0.95rem;
}
.btn {
padding: 8px 16px;
border: none;
border-radius: var(--radius-sm);
padding: 9px 18px;
border: var(--lux-hairline, 1px) solid transparent;
border-radius: var(--lux-r-sm, var(--radius-sm));
cursor: pointer;
font-size: 0.9rem;
font-family: var(--font-mono, inherit);
font-size: 0.78rem;
font-weight: 600;
transition: opacity 0.2s ease, transform 0.15s ease, box-shadow 0.2s ease;
letter-spacing: 0.08em;
text-transform: uppercase;
transition: opacity 0.2s ease, transform 0.15s ease, box-shadow 0.2s ease, filter 0.15s ease;
flex: 1 1 auto;
min-width: 100px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
}
.btn:hover {
opacity: 0.9;
filter: brightness(1.08);
}
.btn:active:not(:disabled) {
@@ -62,30 +70,77 @@
}
.btn-primary {
background: var(--primary-color);
color: var(--primary-contrast);
background: var(--ch-signal, var(--primary-color));
color: var(--lux-bg-0, var(--primary-contrast));
border-color: var(--ch-signal, var(--primary-color));
box-shadow: 0 0 14px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 30%, transparent);
}
.btn-danger {
background: var(--danger-color);
color: white;
background: var(--ch-coral, var(--danger-color));
color: #fff;
border-color: var(--ch-coral, var(--danger-color));
box-shadow: 0 0 14px color-mix(in srgb, var(--ch-coral, var(--danger-color)) 30%, transparent);
}
.btn-secondary {
background: var(--border-color);
color: var(--text-color);
background: var(--lux-bg-2, var(--border-color));
color: var(--lux-ink-dim, var(--text-color));
border-color: var(--lux-line-bold, var(--border-color));
}
.btn-secondary:hover {
color: var(--lux-ink, var(--text-color));
background: var(--lux-bg-3, var(--border-color));
}
.btn-icon {
min-width: auto;
padding: 8px 12px;
font-size: 1.2rem;
padding: 7px 10px;
font-size: 1rem;
flex: 0 0 auto;
border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color));
background: transparent;
color: var(--lux-ink-dim, var(--text-color));
transition: color 0.15s, border-color 0.15s, background 0.15s, box-shadow 0.15s;
}
.btn-icon:hover {
transform: scale(1.1);
transform: none;
opacity: 1;
color: var(--lux-ink, var(--text-color));
background: var(--lux-bg-2, var(--bg-secondary));
border-color: color-mix(in srgb, var(--ch-signal, var(--primary-color)) 35%, var(--lux-line-bold, var(--border-color)));
filter: none;
box-shadow: 0 0 8px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 18%, transparent);
}
/* Variant: warning / success for enable/disable action buttons. Keep
flat hairline borders; just shift the color + hover glow. */
.btn-icon.btn-warning {
color: var(--ch-amber, var(--warning-color));
border-color: color-mix(in srgb, var(--ch-amber, var(--warning-color)) 35%, transparent);
background: transparent;
box-shadow: none;
}
.btn-icon.btn-warning:hover {
background: color-mix(in srgb, var(--ch-amber, var(--warning-color)) 12%, transparent);
color: var(--ch-amber, var(--warning-color));
border-color: color-mix(in srgb, var(--ch-amber, var(--warning-color)) 50%, transparent);
box-shadow: 0 0 10px color-mix(in srgb, var(--ch-amber, var(--warning-color)) 25%, transparent);
}
.btn-icon.btn-success {
color: var(--ch-signal, var(--primary-color));
border-color: color-mix(in srgb, var(--ch-signal, var(--primary-color)) 40%, transparent);
background: transparent;
box-shadow: none;
}
.btn-icon.btn-success:hover {
background: color-mix(in srgb, var(--ch-signal, var(--primary-color)) 14%, transparent);
color: var(--ch-signal, var(--primary-color));
border-color: color-mix(in srgb, var(--ch-signal, var(--primary-color)) 55%, transparent);
box-shadow: 0 0 12px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 30%, transparent);
}
.btn-icon:active:not(:disabled) {
@@ -161,14 +216,29 @@ input[type="number"],
input[type="password"],
select {
width: 100%;
padding: 10px;
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
background: var(--bg-color);
color: var(--text-color);
font-size: 1rem;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
transition: border-color 0.25s ease, box-shadow 0.25s ease, opacity 0.2s ease;
padding: 9px 12px;
border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color));
border-radius: var(--lux-r-sm, var(--radius-sm));
background: var(--lux-bg-0, var(--bg-color));
color: var(--lux-ink, var(--text-color));
font-size: 0.95rem;
font-family: var(--font-body, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif);
transition: border-color 0.2s ease, box-shadow 0.2s ease, background 0.2s ease, opacity 0.2s ease;
}
/* Numeric fields use mono for alignment */
input[type="number"] {
font-family: var(--font-mono, monospace);
font-variant-numeric: tabular-nums;
letter-spacing: 0.02em;
}
input[type="text"]:hover,
input[type="url"]:hover,
input[type="number"]:hover,
input[type="password"]:hover,
select:hover {
border-color: var(--lux-line-bold, var(--border-color));
}
input[type="number"]:disabled,
@@ -190,10 +260,14 @@ input[type="password"] {
}
input:focus,
select:focus {
select:focus,
textarea:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(76, 175, 80, 0.15);
border-color: var(--ch-signal, var(--primary-color));
box-shadow:
0 0 0 3px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 18%, transparent),
0 0 16px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 20%, transparent);
background: var(--lux-bg-1, var(--bg-color));
}
/* Inline validation states */
@@ -0,0 +1,517 @@
/* Dashboard Customize Panel
* Slide-in panel on the right edge. Doesn't cover the full viewport so
* users see live previews of changes as they toggle settings.
*/
.dash-cust-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.18);
backdrop-filter: blur(2px);
-webkit-backdrop-filter: blur(2px);
z-index: calc(var(--z-modal, 1000) - 5);
opacity: 0;
pointer-events: none;
transition: opacity 200ms ease;
}
.dash-cust-backdrop.is-open {
opacity: 1;
pointer-events: auto;
}
.dash-cust-panel {
position: fixed;
top: 60px; /* below transport bar */
right: 0;
bottom: 0;
width: min(440px, 92vw);
background: var(--lux-bg-1, var(--card-bg));
border-left: var(--lux-rule, 1px) solid var(--lux-line, var(--border-color));
box-shadow: var(--lux-shadow-rack, -8px 0 32px rgba(0, 0, 0, 0.35));
z-index: var(--z-modal, 1000);
transform: translateX(100%);
transition: transform 240ms cubic-bezier(0.2, 0.7, 0.3, 1);
display: flex;
flex-direction: column;
overflow: hidden;
}
.dash-cust-panel.is-open {
transform: translateX(0);
}
@media (prefers-reduced-motion: reduce) {
.dash-cust-panel { transition: none; }
.dash-cust-backdrop { transition: none; }
}
.dash-cust-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 18px;
border-bottom: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color));
flex: 0 0 auto;
}
.dash-cust-header h2 {
margin: 0;
font-family: var(--font-display, var(--font-mono, monospace));
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.28em;
text-transform: uppercase;
color: var(--lux-ink, var(--text-color));
position: relative;
}
.dash-cust-header h2::after {
content: '';
position: absolute;
left: 0;
bottom: -8px;
width: 32px;
height: 1px;
background: var(--ch-signal, var(--primary-color));
box-shadow: 0 0 6px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 50%, transparent);
}
.dash-cust-close {
background: transparent;
border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color));
color: var(--lux-ink-dim, var(--text-secondary));
width: 28px;
height: 28px;
border-radius: 4px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 150ms ease, color 150ms ease, border-color 150ms ease;
}
.dash-cust-close:hover {
color: var(--lux-ink, var(--text-color));
border-color: var(--ch-signal, var(--primary-color));
background: color-mix(in srgb, var(--ch-signal, var(--primary-color)) 12%, transparent);
}
.dash-cust-body {
flex: 1 1 auto;
overflow-y: auto;
padding: 14px 16px 18px;
display: flex;
flex-direction: column;
gap: 12px;
/* Prevent scroll chaining: when the panel's scroll reaches its top
* or bottom, the wheel/touch scroll should NOT propagate to the
* underlying dashboard page. */
overscroll-behavior: contain;
}
/* Section blocks */
.dash-cust-section {
display: flex;
flex-direction: column;
gap: 6px;
}
.dash-cust-section + .dash-cust-section {
margin-top: 4px;
}
.dash-cust-h3 {
margin: 0 0 2px;
font-family: var(--font-mono, monospace);
font-size: 0.66rem;
font-weight: 700;
letter-spacing: 0.24em;
text-transform: uppercase;
color: var(--lux-ink-dim, var(--text-secondary));
display: flex;
align-items: center;
gap: 10px;
padding-bottom: 4px;
border-bottom: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color));
}
.dash-cust-modified {
font-size: 0.55rem;
letter-spacing: 0.18em;
color: var(--ch-amber, var(--warning-color));
margin-left: auto;
font-weight: 600;
padding: 1px 6px;
border: 1px solid color-mix(in srgb, var(--ch-amber, var(--warning-color)) 50%, transparent);
border-radius: 2px;
}
/* Preset chips */
.dash-cust-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.dash-cust-chip {
background: var(--lux-bg-2, var(--bg-secondary));
color: var(--lux-ink-dim, var(--text-secondary));
border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color));
padding: 6px 12px;
border-radius: 3px;
font-family: var(--font-mono, monospace);
font-size: 0.7rem;
font-weight: 600;
letter-spacing: 0.08em;
cursor: pointer;
transition: background 150ms ease, color 150ms ease, border-color 150ms ease;
}
.dash-cust-chip:hover {
color: var(--lux-ink, var(--text-color));
border-color: var(--lux-line-bold, var(--text-secondary));
}
.dash-cust-chip.is-active {
background: color-mix(in srgb, var(--ch-signal, var(--primary-color)) 14%, transparent);
color: var(--lux-ink, var(--text-color));
border-color: var(--ch-signal, var(--primary-color));
box-shadow: 0 0 0 1px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 25%, transparent),
0 0 12px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 30%, transparent);
}
/* Rows + lists */
.dash-cust-list {
display: flex;
flex-direction: column;
gap: 4px;
}
.dash-cust-row {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
background: var(--lux-bg-2, var(--bg-secondary));
border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color));
border-radius: 3px;
transition: background 150ms ease, border-color 150ms ease, transform 100ms ease;
}
.dash-cust-row.is-dragging {
opacity: 0.55;
transform: scale(0.98);
}
.dash-cust-row.is-drop-target {
border-color: var(--ch-signal, var(--primary-color));
background: color-mix(in srgb, var(--ch-signal, var(--primary-color)) 8%, transparent);
}
.dash-cust-row-fixed {
background: color-mix(in srgb, var(--lux-line, var(--border-color)) 30%, transparent);
}
.dash-cust-row-drag {
cursor: grab;
}
.dash-cust-row-drag:active {
cursor: grabbing;
}
.dash-cust-row-label {
flex: 1 1 auto;
font-family: var(--font-body, inherit);
font-size: 0.78rem;
color: var(--lux-ink, var(--text-color));
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dash-cust-row-label .dash-cust-pin {
display: inline-flex;
align-items: center;
margin-right: 6px;
color: var(--lux-ink-mute, var(--text-muted));
}
.dash-cust-grip {
color: var(--lux-ink-mute, var(--text-muted));
width: 14px;
height: 14px;
flex: 0 0 14px;
display: flex;
align-items: center;
justify-content: center;
}
.dash-cust-row-drag:hover .dash-cust-grip {
color: var(--lux-ink, var(--text-color));
}
/* Density buttons */
.dash-cust-density-group {
display: inline-flex;
border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color));
border-radius: 3px;
overflow: hidden;
}
.dash-cust-density {
background: transparent;
border: 0;
padding: 2px 6px;
color: var(--lux-ink-dim, var(--text-secondary));
font-family: var(--font-mono, monospace);
font-size: 0.6rem;
font-weight: 700;
cursor: pointer;
transition: background 150ms ease, color 150ms ease;
}
.dash-cust-density:not(:last-child) {
border-right: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color));
}
.dash-cust-density:hover {
color: var(--lux-ink, var(--text-color));
}
.dash-cust-density.is-active {
background: color-mix(in srgb, var(--ch-signal, var(--primary-color)) 18%, transparent);
color: var(--lux-ink, var(--text-color));
}
/* Eye / toggle button */
.dash-cust-eye, .dash-cust-arrow {
background: transparent;
border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color));
color: var(--lux-ink-mute, var(--text-muted));
width: 26px;
height: 26px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 3px;
cursor: pointer;
flex: 0 0 26px;
transition: background 150ms ease, color 150ms ease, border-color 150ms ease;
}
.dash-cust-eye:hover, .dash-cust-arrow:hover {
color: var(--lux-ink, var(--text-color));
border-color: var(--lux-line-bold, var(--text-secondary));
}
.dash-cust-eye.is-on {
color: var(--ch-signal, var(--primary-color));
border-color: color-mix(in srgb, var(--ch-signal, var(--primary-color)) 50%, var(--lux-line, var(--border-color)));
}
.dash-cust-arrow.is-active {
color: var(--ch-amber, var(--warning-color));
border-color: color-mix(in srgb, var(--ch-amber, var(--warning-color)) 50%, var(--lux-line, var(--border-color)));
}
.dash-cust-arrow {
font-family: var(--font-mono, monospace);
font-size: 0.85rem;
font-weight: 700;
}
/* Segmented controls (global options) */
.dash-cust-row .dash-cust-label {
flex: 0 0 auto;
font-family: var(--font-mono, monospace);
font-size: 0.66rem;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--lux-ink-dim, var(--text-secondary));
min-width: 80px;
}
.dash-cust-seg {
display: inline-flex;
border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color));
border-radius: 3px;
overflow: hidden;
flex: 1 1 auto;
}
.dash-cust-seg-btn {
flex: 1 1 auto;
background: transparent;
border: 0;
padding: 5px 8px;
color: var(--lux-ink-dim, var(--text-secondary));
font-family: var(--font-mono, monospace);
font-size: 0.65rem;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
cursor: pointer;
transition: background 150ms ease, color 150ms ease;
}
.dash-cust-seg-btn:not(:last-child) {
border-right: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color));
}
.dash-cust-seg-btn:hover {
color: var(--lux-ink, var(--text-color));
}
.dash-cust-seg-btn.is-active {
background: color-mix(in srgb, var(--ch-signal, var(--primary-color)) 18%, transparent);
color: var(--lux-ink, var(--text-color));
}
/* Mini selects (perf cell options).
* The project's components.css applies `select { width: 100%; padding: 9px 12px }`
* globally we override both with higher specificity so the selects size to
* their content rather than blowing the row out past the panel edge. */
.dash-cust-panel select.dash-cust-mini-select {
width: auto;
background: var(--lux-bg-1, var(--card-bg));
border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color));
color: var(--lux-ink, var(--text-color));
font-family: var(--font-mono, monospace);
font-size: 0.66rem;
padding: 3px 18px 3px 6px;
border-radius: 3px;
cursor: pointer;
flex: 1 1 auto;
min-width: 0;
height: 24px;
line-height: 1;
}
.dash-cust-panel select.dash-cust-mini-select:focus {
outline: none;
border-color: var(--ch-signal, var(--primary-color));
}
/* Two-line perf-cell row.
* Top line carries the label + reorder + visibility controls so the cell
* name is *always* readable. Bottom line carries the per-cell options
* (mode / window / scale) labelled with tiny mono captions. */
.dash-cust-cell-row {
flex-direction: column;
align-items: stretch;
gap: 6px;
padding: 8px 10px;
}
.dash-cust-cell-top {
display: flex;
align-items: center;
gap: 8px;
}
.dash-cust-cell-opts {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 6px;
}
.dash-cust-cell-opt {
display: flex;
align-items: center;
gap: 4px;
min-width: 0;
}
.dash-cust-cell-opt-k {
font-family: var(--font-mono, monospace);
font-size: 0.55rem;
font-weight: 700;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--lux-ink-mute, var(--text-muted));
flex: 0 0 auto;
}
/* Help / actions */
.dash-cust-help {
margin: 0;
font-size: 0.65rem;
color: var(--lux-ink-mute, var(--text-muted));
font-style: italic;
}
.dash-cust-actions {
border-top: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color));
padding-top: 14px;
flex-direction: row;
flex-wrap: wrap;
gap: 8px;
}
.dash-cust-actions .btn {
font-size: 0.7rem;
padding: 6px 12px;
}
/* Width-mode hooks: applied to dashboard-content, not the panel */
#dashboard-content[data-layout-width="centered"] {
max-width: 1280px;
margin-left: auto;
margin-right: auto;
}
#dashboard-content[data-layout-width="narrow"] {
max-width: 960px;
margin-left: auto;
margin-right: auto;
}
#dashboard-content[data-layout-anim="off"] *,
#dashboard-content[data-layout-anim="off"] *::before,
#dashboard-content[data-layout-anim="off"] *::after {
animation-duration: 0ms !important;
transition-duration: 0ms !important;
}
#dashboard-content[data-layout-anim="reduced"] *,
#dashboard-content[data-layout-anim="reduced"] *::before,
#dashboard-content[data-layout-anim="reduced"] *::after {
animation-duration: 60ms !important;
transition-duration: 80ms !important;
}
/* Density variants per section */
.dashboard-section[data-density="compact"] .dashboard-section-content {
gap: 10px;
}
.dashboard-section[data-density="compact"] .dashboard-section-header {
margin-bottom: 10px;
padding-bottom: 6px;
}
.dashboard-section[data-density="dense"] .dashboard-section-content {
gap: 6px;
}
.dashboard-section[data-density="dense"] .dashboard-section-header {
margin-bottom: 6px;
padding-bottom: 4px;
font-size: 0.72rem;
}
.dashboard-section[data-density="dense"] .dashboard-target {
padding: 8px 10px;
}
/* Mobile collapse */
@media (max-width: 720px) {
.dash-cust-panel {
top: 56px;
width: 100vw;
max-width: 100vw;
}
}
File diff suppressed because it is too large Load Diff
+100 -3
View File
@@ -1,6 +1,7 @@
/* Local font faces — no external CDN dependency */
/* DM Sans — latin-ext */
/* ── DM Sans (legacy body font — kept during redesign transition) ── */
@font-face {
font-family: 'DM Sans';
font-style: normal;
@@ -10,7 +11,6 @@
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* DM Sans — latin */
@font-face {
font-family: 'DM Sans';
font-style: normal;
@@ -20,7 +20,8 @@
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* Orbitron 700 — latin */
/* ── Orbitron (brand mark only) ── */
@font-face {
font-family: 'Orbitron';
font-style: normal;
@@ -29,3 +30,99 @@
src: url('../fonts/orbitron-700-latin.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* Manrope new primary body font (variable, 200..800)
Covers Latin, Latin-ext, Cyrillic, Cyrillic-ext. CJK falls through to
system stack via the body font-family cascade. */
@font-face {
font-family: 'Manrope';
font-style: normal;
font-weight: 200 800;
font-display: swap;
src: url('../fonts/manrope-cyrillic-ext.woff2') format('woff2');
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
@font-face {
font-family: 'Manrope';
font-style: normal;
font-weight: 200 800;
font-display: swap;
src: url('../fonts/manrope-cyrillic.woff2') format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
@font-face {
font-family: 'Manrope';
font-style: normal;
font-weight: 200 800;
font-display: swap;
src: url('../fonts/manrope-latin-ext.woff2') format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
font-family: 'Manrope';
font-style: normal;
font-weight: 200 800;
font-display: swap;
src: url('../fonts/manrope-latin.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* JetBrains Mono new monospace (variable, 100..800)
Used for technical labels, badges, metrics, code. Cyrillic-capable so
badge text (`CH·01 · WLED`) reads in RU locale. */
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 100 800;
font-display: swap;
src: url('../fonts/jetbrains-mono-cyrillic-ext.woff2') format('woff2');
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 100 800;
font-display: swap;
src: url('../fonts/jetbrains-mono-cyrillic.woff2') format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 100 800;
font-display: swap;
src: url('../fonts/jetbrains-mono-latin-ext.woff2') format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 100 800;
font-display: swap;
src: url('../fonts/jetbrains-mono-latin.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* Big Shoulders Display new display font (variable, 100..900)
Reserved for huge numeric readouts on the dashboard hero + module metric
cells. Latin + Latin-ext only; Cyrillic numerics would rarely occur in
that position so the system stack is an acceptable fallback. */
@font-face {
font-family: 'Big Shoulders Display';
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url('../fonts/big-shoulders-display-latin-ext.woff2') format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
font-family: 'Big Shoulders Display';
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url('../fonts/big-shoulders-display-latin.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
+46 -28
View File
@@ -31,11 +31,15 @@ html:has(#tab-graph.active) {
display: flex;
gap: 4px;
z-index: 20;
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 8px;
background: linear-gradient(180deg,
var(--lux-bg-1, var(--card-bg)) 0%,
var(--lux-bg-2, var(--card-bg)) 100%);
border: var(--lux-hairline, 1px) solid var(--lux-line-bold, var(--border-color));
border-radius: var(--lux-r-md, 6px);
padding: 4px;
box-shadow: 0 2px 8px var(--shadow-color);
box-shadow: var(--lux-shadow-rack, 0 2px 8px var(--shadow-color));
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
}
.graph-toolbar-drag {
@@ -406,8 +410,8 @@ html:has(#tab-graph.active) {
/* ── Grid background ── */
.graph-grid-dot {
fill: var(--border-color);
opacity: 0.3;
fill: var(--lux-line, var(--border-color));
opacity: 0.32;
}
/* ── Node styles ── */
@@ -427,20 +431,23 @@ html:has(#tab-graph.active) {
.graph-node-body {
fill: var(--card-bg);
stroke: none;
rx: 8;
ry: 8;
transition: stroke 0.15s;
stroke: var(--lux-line, var(--border-color));
stroke-width: 1;
rx: 6;
ry: 6;
transition: stroke 0.15s, stroke-width 0.15s, filter 0.2s ease;
}
.graph-node:hover .graph-node-body {
stroke: var(--text-secondary);
stroke: var(--lux-line-bold, var(--text-secondary));
stroke-width: 1;
filter: drop-shadow(0 4px 14px rgba(0, 0, 0, 0.25));
}
.graph-node.selected .graph-node-body {
stroke: var(--primary-color);
stroke: var(--ch-signal, var(--primary-color));
stroke-width: 2;
filter: drop-shadow(0 0 8px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 50%, transparent));
}
.graph-node-color-bar {
@@ -455,37 +462,45 @@ html:has(#tab-graph.active) {
}
.graph-node-title {
fill: var(--text-color);
fill: var(--lux-ink, var(--text-color));
font-size: 12px;
font-weight: 600;
font-family: 'DM Sans', sans-serif;
/* Body font, not display Big Shoulders is condensed and reads as
* "stretched" at 12 px in a node label. Display font is for hero
* headers only. */
font-family: var(--font-body, 'Manrope', 'DM Sans', sans-serif);
letter-spacing: 0;
}
.graph-node-subtitle {
fill: var(--text-secondary);
font-size: 10px;
font-family: 'DM Sans', sans-serif;
fill: var(--lux-ink-dim, var(--text-secondary));
font-size: 9.5px;
font-weight: 600;
font-family: var(--font-mono, monospace);
letter-spacing: 0.04em;
text-transform: uppercase;
}
.graph-node-icon {
stroke: var(--text-muted);
stroke: var(--lux-ink-mute, var(--text-muted));
fill: none;
stroke-width: 2;
stroke-width: 1.5;
stroke-linecap: round;
stroke-linejoin: round;
opacity: 0.5;
opacity: 0.55;
}
.graph-node.running .graph-node-icon {
stroke: var(--primary-color);
opacity: 0.85;
stroke: var(--ch-signal, var(--primary-color));
opacity: 0.95;
}
/* ── Running indicator (animated gradient border) ── */
/* ── Running indicator (animated gradient border + signal-flow glow) ── */
.graph-node.running .graph-node-body {
stroke: url(#running-gradient);
stroke-width: 2;
filter: drop-shadow(0 0 12px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 50%, transparent));
}
@keyframes graph-running-rotate {
@@ -530,13 +545,16 @@ html:has(#tab-graph.active) {
/* Port labels — hidden by default, shown on node hover, positioned outside node */
.graph-port-label {
font-size: 9px;
font-weight: 600;
fill: var(--text-color);
font-weight: 700;
font-family: var(--font-mono, monospace);
letter-spacing: 0.08em;
text-transform: uppercase;
fill: var(--lux-ink-dim, var(--text-color));
pointer-events: none;
opacity: 0;
transition: opacity 0.15s;
paint-order: stroke fill;
stroke: var(--bg-color);
stroke: var(--lux-bg-0, var(--bg-color));
stroke-width: 3px;
stroke-linejoin: round;
}
@@ -565,9 +583,9 @@ html:has(#tab-graph.active) {
.graph-port-drop-target {
r: 7 !important;
stroke: var(--primary-color) !important;
stroke: var(--ch-signal, var(--primary-color)) !important;
stroke-width: 3 !important;
filter: drop-shadow(0 0 6px var(--primary-color));
filter: drop-shadow(0 0 6px var(--ch-signal, var(--primary-color)));
}
/* ── Edges ── */
+502 -95
View File
@@ -1,47 +1,271 @@
:root {
--transport-height: 60px;
}
header {
display: flex;
justify-content: space-between;
display: grid;
grid-template-columns: var(--sidebar-width, 248px) 1fr auto auto;
align-items: center;
padding: 8px 20px;
height: var(--transport-height, 60px);
padding: 0 16px 0 0;
position: sticky;
top: 0;
z-index: var(--z-sticky);
background: var(--bg-color);
border-bottom: 2px solid var(--border-color);
background: linear-gradient(180deg,
var(--lux-bg-1, var(--bg-color)) 0%,
var(--lux-bg-0, var(--bg-color)) 100%);
border-bottom: var(--lux-hairline, 1px) solid var(--lux-line-bold, var(--border-color));
}
/* Accent rule subtle bottom glow under the transport bar.
Uses ::before because ::after is reserved by base.css for the
ambient-background blur overlay. */
header::before {
content: '';
position: absolute;
left: 0; right: 0; bottom: -1px;
height: 1px;
background: linear-gradient(90deg,
transparent 0%,
color-mix(in srgb, var(--ch-signal, var(--primary-color)) 30%, transparent) 15%,
color-mix(in srgb, var(--ch-cyan, var(--primary-color)) 25%, transparent) 50%,
color-mix(in srgb, var(--ch-magenta, var(--primary-color)) 20%, transparent) 85%,
transparent 100%);
opacity: 0.8;
pointer-events: none;
z-index: 1;
}
.header-title {
display: flex;
align-items: center;
gap: 8px;
gap: 10px;
padding: 0 18px;
height: 100%;
border-right: var(--lux-hairline, 1px) solid var(--lux-line-bold, var(--border-color));
position: relative;
}
/* Glowing LED brand mark. Rendered as a ::before on .header-title so no
HTML change is required. The existing #server-status pulse dot sits
inside as the "core" of the mark (see status-badge rule below). */
/* LED brand mark 28 px glowing square with inset dark core.
Glow intensity pulses subtly to reinforce the "live instrument" feel. */
.header-title::before {
content: '';
width: 28px;
height: 28px;
flex-shrink: 0;
border-radius: 4px;
background: var(--ch-signal, var(--primary-color));
box-shadow:
0 0 22px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 55%, transparent),
0 0 8px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 90%, transparent),
inset 0 0 0 1px rgba(0, 0, 0, 0.35);
position: relative;
animation: brandPulse 4s ease-in-out infinite;
}
.header-title::after {
content: '';
position: absolute;
left: calc(18px + 8px);
top: 50%;
transform: translateY(-50%);
width: 12px;
height: 12px;
background: var(--lux-bg-0, var(--bg-color));
border-radius: 2px;
box-shadow: 0 0 0 2px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 40%, transparent);
pointer-events: none;
}
@keyframes brandPulse {
0%, 100% {
box-shadow:
0 0 22px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 55%, transparent),
0 0 8px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 90%, transparent),
inset 0 0 0 1px rgba(0, 0, 0, 0.35);
}
50% {
box-shadow:
0 0 30px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 70%, transparent),
0 0 12px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 95%, transparent),
inset 0 0 0 1px rgba(0, 0, 0, 0.35);
}
}
/* Brand stack — title on one line, version under it, no wrap. */
.brand-stack {
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
gap: 3px;
line-height: 1;
min-width: 0;
}
h1 {
font-family: 'Orbitron', sans-serif;
font-size: 1.15rem;
font-weight: 700;
letter-spacing: 0.06em;
font-size: 1.25rem;
font-weight: 900;
letter-spacing: 0.18em;
text-transform: uppercase;
-webkit-text-stroke: 0.5px var(--primary-color);
white-space: nowrap;
-webkit-text-stroke: 0.4px color-mix(in srgb, var(--primary-color) 60%, transparent);
paint-order: stroke fill;
background: linear-gradient(
120deg,
var(--primary-color) 0%,
var(--primary-text-color) 35%,
var(--primary-color) 50%,
var(--primary-text-color) 65%,
var(--primary-color) 100%
90deg,
var(--lux-ink, #e6ebf2) 0%,
var(--ch-signal, var(--primary-color)) 50%,
var(--lux-ink, #e6ebf2) 100%
);
background-size: 250% 100%;
background-size: 220% 100%;
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
animation: titleShimmer 6s ease-in-out infinite;
animation: titleShimmer 8s linear infinite;
line-height: 1;
margin: 0;
filter: drop-shadow(0 0 12px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 25%, transparent));
}
.brand-stack #server-version {
font-size: 0.6rem;
padding: 2px 7px;
letter-spacing: 0.25em;
align-self: flex-start;
}
@keyframes titleShimmer {
0%, 100% { background-position: 100% 50%; }
50% { background-position: 0% 50%; }
to { background-position: -220% 50%; }
}
/* Transport center: reserved area for armed-status / master-stop /
quick-search shortcut. Populated by JS in Phase 3; empty for now. */
.transport-center {
display: flex;
align-items: center;
gap: 8px;
padding: 0 18px;
font-family: var(--font-mono, monospace);
font-size: 0.7rem;
color: var(--lux-ink-dim, var(--text-secondary));
min-width: 0;
overflow: hidden;
}
.transport-status {
display: inline-flex;
align-items: center;
gap: 10px;
padding: 9px 18px;
background: var(--lux-bg-2, var(--bg-secondary));
border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color));
border-radius: var(--lux-r-sm, 3px);
color: var(--lux-ink-dim, var(--text-secondary));
font-family: var(--font-mono, inherit);
font-size: 0.8rem;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
white-space: nowrap;
transition: color 0.2s, border-color 0.2s, background 0.2s, box-shadow 0.2s;
}
.transport-status.is-armed {
color: var(--ch-signal, var(--primary-color));
border-color: color-mix(in srgb, var(--ch-signal, var(--primary-color)) 45%, transparent);
background: color-mix(in srgb, var(--ch-signal, var(--primary-color)) 10%, transparent);
box-shadow:
inset 0 0 14px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 12%, transparent),
0 0 18px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 20%, transparent);
text-shadow: 0 0 10px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 40%, transparent);
}
.transport-status .dot {
width: 7px; height: 7px;
border-radius: 50%;
background: currentColor;
box-shadow: 0 0 8px currentColor, 0 0 3px currentColor;
animation: pulse 1.4s ease-in-out infinite;
flex-shrink: 0;
}
.transport-status:not(.is-armed) .dot {
background: var(--lux-ink-faint, var(--text-muted));
box-shadow: none;
animation: none;
}
/* Transport meta — Uptime / CPU / Mem readouts as vertical KEY/VALUE stacks */
.transport-meta {
display: flex;
align-items: center;
gap: 16px;
padding: 0 6px 0 16px;
font-family: var(--font-mono, monospace);
}
.meta-cell {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 3px;
line-height: 1;
min-width: 0;
}
.meta-cell .k {
font-size: 0.6rem;
font-weight: 600;
letter-spacing: 0.22em;
text-transform: uppercase;
color: var(--lux-ink-faint, var(--text-muted));
}
.meta-cell .v {
font-size: 0.9rem;
font-weight: 600;
color: var(--lux-ink, var(--text-color));
font-variant-numeric: tabular-nums;
letter-spacing: 0.02em;
white-space: nowrap;
}
/* Interactive meta-cell clickable variant used by the Poll control.
Lightweight hover + focus states so it reads as actionable without
looking like a button. */
.meta-cell-interactive {
cursor: pointer;
padding: 4px 8px;
margin: 0 -2px;
border-radius: var(--lux-r-sm, 3px);
border: var(--lux-hairline, 1px) solid transparent;
outline: none;
transition: background 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease;
user-select: none;
}
.meta-cell-interactive:hover {
background: var(--lux-bg-2, var(--bg-secondary));
border-color: var(--lux-line, var(--border-color));
}
.meta-cell-interactive:focus-visible {
border-color: color-mix(in srgb, var(--ch-signal, var(--primary-color)) 50%, transparent);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 20%, transparent);
}
.meta-cell-interactive:active {
transform: translateY(0.5px);
}
.meta-cell-interactive .v {
color: var(--ch-signal, var(--primary-color));
text-shadow: 0 0 10px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 25%, transparent);
}
.meta-sep {
width: 1px;
height: 24px;
background: var(--lux-line, var(--border-color));
flex-shrink: 0;
}
h2 {
@@ -53,18 +277,17 @@ h2 {
.header-toolbar {
display: flex;
align-items: center;
gap: 2px;
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 3px 4px;
gap: 4px;
background: transparent;
border: none;
padding: 0;
}
.header-toolbar-sep {
width: 1px;
height: 18px;
background: var(--border-color);
margin: 0 3px;
height: 20px;
background: var(--lux-line, var(--border-color));
margin: 0 6px;
flex-shrink: 0;
}
@@ -100,15 +323,72 @@ h2 {
background: var(--bg-secondary);
}
#server-version {
/* Header locale picker (post IconSelect enhancement)
The hidden <select.header-locale> is enhanced into a trigger button at
runtime. Inside the toolbar, re-skin it to match .header-btn so it reads
as a peer of the icon buttons, with the 2-letter code rendered as a small
LED-style accent badge in Orbitron same display font as the brand mark. */
.header-toolbar .icon-select-trigger {
width: auto;
gap: 6px;
padding: 3px 6px;
border: none;
border-radius: 5px;
background: transparent;
color: var(--text-secondary);
font-size: 0.75rem;
font-weight: 600;
line-height: 1;
transition: color 0.2s, background 0.2s;
}
.header-toolbar .icon-select-trigger:hover {
color: var(--text-color);
background: var(--bg-secondary);
border-color: transparent;
}
.header-toolbar .icon-select-trigger-icon {
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;
letter-spacing: 0.06em;
color: var(--primary-color);
padding: 3px 5px;
border-radius: 4px;
background: color-mix(in srgb, var(--primary-color) 14%, transparent);
line-height: 1;
}
.header-toolbar .icon-select-trigger-icon > span {
font-weight: inherit;
}
.header-toolbar .icon-select-trigger-label {
flex: 0 1 auto;
font-weight: 500;
font-size: 0.72rem;
letter-spacing: 0.01em;
margin: 0;
}
.header-toolbar .icon-select-trigger-arrow {
font-size: 0.6rem;
opacity: 0.55;
margin-left: 1px;
}
#server-version {
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;
}
@@ -215,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 {
@@ -386,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;
@@ -403,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 {
@@ -469,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 */
@@ -627,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;
}
@@ -637,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;
@@ -648,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); }
@@ -655,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 {
@@ -676,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 {
@@ -727,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 {
@@ -763,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;
}
}
+112 -16
View File
@@ -140,6 +140,16 @@
max-width: 48px;
}
/* Collapse the enhanced locale trigger to just the 2-letter badge on
narrow screens, matching the compact footprint of the icon buttons. */
.header-toolbar .icon-select-trigger-label {
display: none;
}
.header-toolbar .icon-select-trigger {
gap: 4px;
padding: 3px 5px;
}
h1 {
font-size: 1.1rem;
}
@@ -148,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;
@@ -205,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 */
@@ -266,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%;
@@ -274,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 {
+189 -63
View File
@@ -6,12 +6,15 @@
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.8);
background: radial-gradient(1200px 800px at 50% 40%,
rgba(0, 0, 0, 0.7) 0%,
rgba(0, 0, 0, 0.88) 100%);
z-index: var(--z-modal);
align-items: center;
justify-content: center;
animation: fadeIn 0.2s ease-out;
backdrop-filter: blur(2px);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
}
/* Confirm dialog must stack above all other modals */
@@ -274,6 +277,71 @@
display: block;
}
/* Key Colors test view — frame with region overlays */
.css-test-kc-wrap {
position: relative;
width: 100%;
border-radius: 4px;
overflow: hidden;
background: #000;
min-height: 120px;
display: flex;
align-items: center;
justify-content: center;
}
.css-test-kc-canvas {
display: block;
width: 100%;
height: auto;
max-height: 70vh;
object-fit: contain;
}
.css-test-kc-meta {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 6px 12px;
padding: 8px 0 2px;
font-size: 0.8rem;
color: var(--text-color-muted, #aaa);
}
.css-test-kc-swatch {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 2px 6px 2px 2px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid var(--border-color, #333);
border-radius: 999px;
font-size: 0.78rem;
color: var(--text-color, #e0e0e0);
}
.css-test-kc-swatch-chip {
display: inline-block;
width: 16px;
height: 16px;
border-radius: 50%;
border: 1px solid rgba(255, 255, 255, 0.25);
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.3);
}
.css-test-kc-swatch-hex {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.72rem;
opacity: 0.65;
}
.css-test-kc-mode {
opacity: 0.7;
font-variant: small-caps;
letter-spacing: 0.04em;
}
.css-test-status {
text-align: center;
padding: 8px 0;
@@ -719,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;
@@ -752,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 {
@@ -1126,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 {
@@ -1246,58 +1424,6 @@
background: rgba(255, 255, 255, 0.3);
}
.lightbox-refresh-btn {
position: absolute;
top: 16px;
right: 64px;
background: rgba(255, 255, 255, 0.15);
border: none;
color: white;
font-size: 1.2rem;
width: 40px;
height: 40px;
border-radius: 50%;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s;
z-index: 1;
}
.lightbox-refresh-btn:hover {
background: rgba(255, 255, 255, 0.3);
}
.lightbox-refresh-btn.active {
background: var(--primary-color);
}
.lightbox-fps-select {
position: absolute;
top: 16px;
right: 116px;
background: rgba(0, 0, 0, 0.65);
color: #fff;
border: 1px solid rgba(255, 255, 255, 0.25);
border-radius: 6px;
padding: 4px 6px;
font-size: 0.8rem;
cursor: pointer;
z-index: 1;
appearance: none;
-webkit-appearance: none;
text-align: center;
}
.lightbox-fps-select:hover {
background: rgba(255, 255, 255, 0.15);
}
.lightbox-fps-select:focus {
outline: 1px solid var(--primary-color);
}
.lightbox-stats {
position: absolute;
bottom: 8px;
+42 -16
View File
@@ -42,21 +42,30 @@
}
.stream-card-prop {
display: inline-block;
font-size: 0.75rem;
color: var(--text-secondary);
background: var(--border-color);
padding: 2px 8px;
border-radius: 10px;
display: inline-flex;
align-items: center;
gap: 5px;
font-family: var(--font-mono, monospace);
font-size: 0.68rem;
color: var(--lux-ink-dim, var(--text-secondary));
background: var(--lux-bg-0, var(--border-color));
border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color));
padding: 3px 8px;
border-radius: 2px;
letter-spacing: 0.04em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 180px;
max-width: 220px;
vertical-align: middle;
line-height: 1.3;
}
.stream-card-prop .icon {
color: var(--primary-text-color);
color: var(--ch-signal, var(--primary-color));
width: 11px;
height: 11px;
flex-shrink: 0;
}
.stream-card-prop-full {
@@ -65,18 +74,19 @@
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-size: 0.7rem;
font-size: 0.66rem;
}
.stream-card-link {
cursor: pointer;
text-decoration: none;
transition: background 0.2s, color 0.2s;
transition: background 0.2s, color 0.2s, border-color 0.2s;
}
.stream-card-link:hover {
background: var(--primary-color);
color: var(--primary-contrast);
background: color-mix(in srgb, var(--ch-signal, var(--primary-color)) 15%, transparent);
color: var(--lux-ink, var(--text-color));
border-color: color-mix(in srgb, var(--ch-signal, var(--primary-color)) 40%, var(--lux-line, var(--border-color)));
}
.stream-card-link:hover .icon {
@@ -84,15 +94,31 @@
}
@keyframes cardHighlight {
0%, 100% { box-shadow: none; }
25%, 75% { box-shadow: 0 0 0 3px var(--primary-color), 0 0 20px rgba(var(--primary-rgb, 59, 130, 246), 0.3); }
0%, 100% {
box-shadow:
0 0 0 0 color-mix(in srgb, var(--ch-signal, var(--primary-color)) 0%, transparent),
0 0 0 0 transparent;
}
25%, 75% {
box-shadow:
0 0 0 2px var(--ch-signal, var(--primary-color)),
0 0 32px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 55%, transparent),
0 0 10px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 80%, transparent);
}
}
.card-highlight,
.template-card.card-highlight {
animation: cardHighlight 2s ease-in-out;
.template-card.card-highlight,
.dashboard-target.card-highlight {
animation: cardHighlight 2.2s ease-in-out;
position: relative;
z-index: 11;
/* Nudge the card forward during the highlight so the outer glow
isn't clipped by a containing overflow: hidden (strip cells,
tree-nav panels). Box-shadow is never clipped by the element's
own overflow but *is* clipped by parent overflow in stacking
contexts where the card doesn't escape. */
isolation: isolate;
}
/* Dim overlay behind highlighted card */
+314
View File
@@ -0,0 +1,314 @@
/* Lumenworks sidebar (channel-strip nav)
Primary navigation for desktop/tablet. Contains the 6 top-level
tabs (.tab-bar kept for JS compatibility with switchTab), a live
meter plate at the bottom, and collapses to a 56px icon rail
between 1100px and 600px. On phones (<=600px) the sidebar hides
entirely and mobile.css reverts .tab-bar to a fixed bottom strip.
*/
/* ── App shell: header on top, 2-column body below ── */
.app-body {
display: grid;
grid-template-columns: var(--sidebar-width, 248px) 1fr;
gap: 0;
align-items: stretch;
min-height: calc(100vh - var(--transport-height, 64px));
}
.app-main {
min-width: 0; /* allow children to shrink instead of overflow */
position: relative;
}
/* ── Sidebar container ── */
.sidebar {
position: sticky;
top: var(--transport-height, 64px);
height: calc(100vh - var(--transport-height, 64px));
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: thin;
border-right: var(--lux-hairline) solid var(--lux-line-bold, var(--border-color));
background: linear-gradient(180deg, var(--lux-bg-1, var(--bg-secondary)) 0%, var(--lux-bg-0, var(--bg-color)) 100%);
padding: 16px 0 20px;
display: flex;
flex-direction: column;
gap: 20px;
z-index: calc(var(--z-sticky) - 1);
}
.sidebar-section {
padding: 0 12px;
}
.sidebar-label {
display: flex;
align-items: center;
gap: 8px;
font-family: var(--font-mono, monospace);
font-size: 0.55rem;
letter-spacing: 0.25em;
text-transform: uppercase;
color: var(--lux-ink-mute, var(--text-secondary));
padding: 0 8px 8px;
border-bottom: 1px dashed var(--lux-line, var(--border-color));
margin-bottom: 8px;
font-weight: 500;
}
.sidebar-label::before { content: '['; color: var(--lux-ink-faint, var(--text-muted)); }
.sidebar-label::after { content: ']'; color: var(--lux-ink-faint, var(--text-muted)); margin-left: auto; }
/* ── Tab-bar (kept as vertical nav inside sidebar on desktop) ── */
.sidebar .tab-bar {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 1px;
width: 100%;
}
.sidebar .tab-btn {
display: grid;
grid-template-columns: 18px 1fr auto;
gap: 12px;
align-items: center;
padding: 9px 10px;
margin: 0;
background: transparent;
border: none;
border-radius: var(--lux-r-sm, 3px);
font-family: var(--font-mono, monospace);
font-size: 0.82rem;
font-weight: 500;
letter-spacing: 0.04em;
color: var(--lux-ink-dim, var(--text-secondary));
cursor: pointer;
position: relative;
text-align: left;
transition: color 0.15s ease, background 0.15s ease, box-shadow 0.15s ease;
}
.sidebar .tab-btn:hover {
color: var(--lux-ink, var(--text-color));
background: var(--lux-bg-2, var(--bg-secondary));
border-bottom-color: transparent;
}
.sidebar .tab-btn.active {
color: var(--lux-ink, var(--text-color));
background: linear-gradient(90deg,
color-mix(in srgb, var(--ch-signal, var(--primary-color)) 14%, transparent) 0%,
transparent 80%);
box-shadow: inset 2px 0 0 var(--ch-signal, var(--primary-color));
border-bottom-color: transparent;
}
.sidebar .tab-btn.active::before {
content: '';
position: absolute;
left: 0; top: 50%;
transform: translateY(-50%);
width: 2px;
height: 60%;
background: var(--ch-signal, var(--primary-color));
box-shadow: var(--lux-signal-glow, 0 0 6px currentColor);
border-radius: 2px;
}
.sidebar .tab-btn .icon {
width: 16px;
height: 16px;
color: inherit;
flex-shrink: 0;
}
.sidebar .tab-btn.active .icon {
color: var(--ch-signal, var(--primary-color));
}
.sidebar .tab-btn > span[data-i18n] {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sidebar .tab-btn .tab-badge {
background: var(--lux-bg-3, var(--border-color));
color: var(--lux-ink-mute, var(--text-secondary));
font-family: var(--font-mono, monospace);
font-size: 0.62rem;
font-weight: 600;
padding: 1px 7px;
border-radius: 10px;
min-width: 20px;
line-height: 1.4;
text-align: center;
}
.sidebar .tab-btn.active .tab-badge {
background: var(--ch-signal, var(--primary-color));
color: var(--lux-bg-0, #000);
}
/* ── Sidebar footer: live CPU/FPS meter plate ── */
.sidebar-foot {
margin-top: auto;
padding: 14px 20px 8px;
border-top: 1px dashed var(--lux-line, var(--border-color));
display: flex;
flex-direction: column;
gap: 10px;
}
.cpu-meter {
display: flex;
flex-direction: column;
gap: 10px;
font-family: var(--font-mono, monospace);
}
.cpu-meter-row {
display: flex;
justify-content: space-between;
align-items: baseline;
font-size: 0.58rem;
letter-spacing: 0.15em;
text-transform: uppercase;
color: var(--lux-ink-mute, var(--text-secondary));
}
.cpu-meter-row b {
color: var(--lux-ink, var(--text-color));
font-weight: 500;
font-variant-numeric: tabular-nums;
font-size: 0.7rem;
letter-spacing: 0;
}
.cpu-bar {
height: 3px;
background: var(--lux-bg-3, var(--border-color));
border-radius: 2px;
overflow: hidden;
position: relative;
}
.cpu-bar > i {
display: block;
height: 100%;
background: linear-gradient(90deg, var(--ch-signal, var(--primary-color)), var(--ch-cyan, var(--info-color)));
box-shadow: 0 0 6px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 50%, transparent);
transition: width 0.4s ease;
width: 0;
}
.cpu-bar-fps > i {
background: linear-gradient(90deg, var(--ch-cyan, var(--info-color)), var(--ch-magenta, #ff4ade));
box-shadow: 0 0 6px color-mix(in srgb, var(--ch-cyan, var(--info-color)) 50%, transparent);
}
.sidebar-version {
font-family: var(--font-mono, monospace);
font-size: 0.55rem;
letter-spacing: 0.2em;
text-transform: uppercase;
color: var(--lux-ink-faint, var(--text-muted));
text-align: center;
padding-top: 6px;
border-top: 1px dashed var(--lux-line, var(--border-color));
}
/* ── Responsive: icon rail at tablet-desktop, hidden at phone ── */
@media (max-width: 1100px) {
:root { --sidebar-width: 56px; }
.sidebar {
padding: 14px 0 20px;
gap: 16px;
}
.sidebar-section {
padding: 0 6px;
}
.sidebar-label,
.sidebar-version {
display: none;
}
.sidebar-foot {
padding: 10px 6px;
}
.sidebar .tab-btn {
grid-template-columns: 1fr;
padding: 10px 2px;
justify-content: center;
justify-items: center;
gap: 3px;
}
/* Two-line caption with tight tracking single-line ellipsis truncates
longer labels like "Automations"/"Integrations" to "AUTOMA…" which
isn't recoverable; two short lines are uglier per word but legible. */
.sidebar .tab-btn > span[data-i18n] {
font-size: 0.46rem;
letter-spacing: 0.02em;
line-height: 1.1;
text-transform: uppercase;
color: inherit;
max-width: 100%;
white-space: normal;
overflow-wrap: anywhere;
text-align: center;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.sidebar .tab-btn .icon {
width: 20px;
height: 20px;
}
.sidebar .tab-btn .tab-badge {
position: absolute;
top: 4px;
right: 4px;
font-size: 0.55rem;
min-width: 14px;
padding: 0 4px;
}
.cpu-meter-row {
font-size: 0.48rem;
letter-spacing: 0.08em;
}
.cpu-meter-row b {
font-size: 0.6rem;
}
}
@media (max-width: 600px) {
/* On phones, sidebar disappears and mobile.css reverts .tab-bar to
a fixed bottom strip. The .app-body grid becomes a single column. */
:root { --sidebar-width: 0px; }
.app-body {
grid-template-columns: 1fr;
}
.sidebar {
/* Hide sidebar chrome; .tab-bar inside still gets fixed-bottom
styling from mobile.css regardless of its container. */
position: static;
height: auto;
border-right: none;
padding: 0;
background: transparent;
overflow: visible;
display: contents;
}
.sidebar-foot,
.sidebar-label {
display: none !important;
}
}
+108 -32
View File
@@ -9,19 +9,63 @@
}
.template-card {
--ch: var(--ch-cyan, var(--info-color)); /* default channel — overridden per data-attr below */
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
padding: 16px;
transition: box-shadow 0.2s ease, transform 0.2s ease;
border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color));
border-radius: var(--lux-r-md, var(--radius-md));
padding: 18px 20px 16px;
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
display: flex;
flex-direction: column;
position: relative;
overflow: hidden;
}
/* Channel stripe on left edge opt-in only (mirrors .card::before in
* cards.css). Idle template-cards without a custom color stay clean.
* The Add card never gets a stripe (it's not an entity). */
.template-card::before {
content: '';
position: absolute;
left: 0; top: 0; bottom: 0;
width: 3px;
background: var(--ch);
box-shadow: 0 0 10px color-mix(in srgb, var(--ch) 40%, transparent);
pointer-events: none;
z-index: 1;
transition: width 0.2s ease, box-shadow 0.2s ease;
display: none;
}
.template-card[data-has-color="1"]::before,
.template-card.card-running::before {
display: block;
}
.add-template-card::before { display: none !important; }
/* Corner bracket — silkscreened panel feel in the top-right */
.template-card::after {
content: '';
position: absolute;
top: 8px; right: 8px;
width: 12px; height: 12px;
border-top: var(--lux-hairline, 1px) solid var(--lux-line-bold, var(--border-color));
border-right: var(--lux-hairline, 1px) solid var(--lux-line-bold, var(--border-color));
pointer-events: none;
opacity: 0.7;
z-index: 1;
}
.template-card:hover {
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
box-shadow: var(--lux-shadow-rack, 0 8px 24px var(--shadow-color));
transform: translateY(-2px);
border-color: var(--lux-line-bold, var(--border-color));
}
.template-card:hover::before {
width: 4px;
box-shadow: 0 0 14px color-mix(in srgb, var(--ch) 70%, transparent);
}
.add-template-card {
@@ -93,13 +137,19 @@
}
.badge {
padding: 4px 8px;
border-radius: 4px;
font-size: 0.75rem;
font-weight: bold;
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 2px;
border: var(--lux-hairline, 1px) solid transparent;
font-family: var(--font-mono, inherit);
font-size: 0.62rem;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
white-space: nowrap;
flex-shrink: 0;
line-height: 1.4;
}
.template-description {
@@ -606,36 +656,44 @@ body.pp-filter-dragging .pp-filter-drag-handle {
background: none;
border: none;
padding: 8px 14px;
font-size: 0.9rem;
font-weight: 500;
color: var(--text-secondary);
font-family: var(--font-mono, inherit);
font-size: 0.72rem;
font-weight: 600;
color: var(--lux-ink-mute, var(--text-secondary));
cursor: pointer;
border-bottom: 2px solid transparent;
text-transform: uppercase;
letter-spacing: 0.12em;
transition: color 0.2s ease, border-color 0.25s ease;
}
.stream-tab-btn:hover {
color: var(--text-color);
color: var(--lux-ink, var(--text-color));
}
.stream-tab-btn.active {
color: var(--primary-text-color);
border-bottom-color: var(--primary-color);
color: var(--ch-signal, var(--primary-color));
border-bottom-color: var(--ch-signal, var(--primary-color));
text-shadow: 0 0 12px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 30%, transparent);
}
.stream-tab-count {
background: var(--border-color);
color: var(--text-secondary);
font-size: 0.7rem;
font-weight: 600;
background: var(--lux-bg-3, var(--border-color));
color: var(--lux-ink-dim, var(--text-secondary));
font-family: var(--font-mono, monospace);
font-size: 0.56rem;
font-weight: 700;
padding: 1px 6px;
border-radius: 8px;
border-radius: 2px;
margin-left: 4px;
letter-spacing: 0.04em;
font-variant-numeric: tabular-nums;
}
.stream-tab-btn.active .stream-tab-count {
background: var(--primary-color);
color: var(--primary-contrast);
background: var(--ch-signal, var(--primary-color));
color: var(--lux-bg-0, var(--primary-contrast));
box-shadow: 0 0 6px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 40%, transparent);
}
.cs-expand-collapse-group {
@@ -685,11 +743,26 @@ body.pp-filter-dragging .pp-filter-drag-handle {
}
.subtab-section-header {
font-size: 1rem;
font-weight: 600;
color: var(--text-secondary);
margin: 0 0 12px 0;
padding-bottom: 8px;
font-family: var(--font-mono, monospace);
font-size: 0.82rem;
font-weight: 700;
color: var(--lux-ink-dim, var(--text-secondary));
margin: 0 0 16px 0;
padding-bottom: 10px;
text-transform: uppercase;
letter-spacing: 0.25em;
border-bottom: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color));
position: relative;
}
.subtab-section-header::before {
content: '';
position: absolute;
left: 0; bottom: -1px;
width: 48px;
height: 1px;
background: var(--ch-signal, var(--primary-color));
box-shadow: 0 0 6px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 50%, transparent);
}
.subtab-section-header.cs-header {
@@ -731,13 +804,16 @@ body.pp-filter-dragging .pp-filter-drag-handle {
}
.cs-count {
background: var(--border-color);
color: var(--text-secondary);
border-radius: 10px;
padding: 0 7px;
font-size: 0.75rem;
background: var(--lux-bg-3, var(--border-color));
color: var(--lux-ink-dim, var(--text-secondary));
border-radius: 2px;
padding: 2px 7px;
font-family: var(--font-mono, monospace);
font-size: 0.6rem;
font-weight: 600;
flex-shrink: 0;
font-variant-numeric: tabular-nums;
letter-spacing: 0.04em;
}
.cs-collapsed .cs-filter-wrap,
+142 -56
View File
@@ -22,30 +22,53 @@
min-width: 0;
}
/* ── Trigger bar ── */
/* ── Trigger bar — module selector pill ── */
.tree-dd-trigger {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 10px;
gap: 8px;
padding: 7px 12px;
cursor: pointer;
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--bg-secondary);
border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color));
border-radius: var(--lux-r-sm, 3px);
background: var(--lux-bg-1, var(--bg-secondary));
user-select: none;
font-size: 0.82rem;
color: var(--text-color);
transition: border-color 0.15s, background 0.15s;
font-family: var(--font-mono, monospace);
font-size: 0.72rem;
font-weight: 500;
letter-spacing: 0.06em;
color: var(--lux-ink, var(--text-color));
transition: border-color 0.15s, background 0.15s, box-shadow 0.15s;
position: relative;
overflow: hidden;
}
/* Channel stripe on the left edge of the trigger */
.tree-dd-trigger::before {
content: '';
position: absolute;
left: 0; top: 0; bottom: 0;
width: 2px;
background: var(--ch-signal, var(--primary-color));
opacity: 0.4;
transition: opacity 0.15s, box-shadow 0.15s;
}
.tree-dd-trigger:hover {
border-color: var(--primary-color);
background: color-mix(in srgb, var(--primary-color) 6%, var(--bg-secondary));
border-color: var(--lux-line-bold, var(--border-color));
background: var(--lux-bg-2, var(--bg-secondary));
}
.tree-dd-trigger:hover::before,
.tree-dd-trigger.open::before {
opacity: 1;
box-shadow: 0 0 8px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 60%, transparent);
}
.tree-dd-trigger.open {
border-color: var(--primary-color);
border-color: color-mix(in srgb, var(--ch-signal, var(--primary-color)) 35%, var(--lux-line, var(--border-color)));
background: var(--lux-bg-2, var(--bg-secondary));
}
.tree-dd-trigger-icon {
@@ -60,18 +83,24 @@
.tree-dd-trigger-title {
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.1em;
white-space: nowrap;
color: var(--lux-ink, var(--text-color));
}
.tree-dd-trigger-count {
background: var(--primary-color);
color: var(--primary-contrast);
font-size: 0.6rem;
font-weight: 600;
padding: 0 5px;
border-radius: 8px;
min-width: 16px;
background: var(--ch-signal, var(--primary-color));
color: var(--lux-bg-0, var(--primary-contrast));
font-family: var(--font-mono, monospace);
font-size: 0.55rem;
font-weight: 700;
padding: 1px 6px;
border-radius: 2px;
min-width: 18px;
text-align: center;
letter-spacing: 0.04em;
box-shadow: 0 0 6px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 40%, transparent);
}
.tree-dd-chevron {
@@ -94,24 +123,43 @@
padding-left: 8px;
}
/* ── Dropdown panel ── */
/* ── Dropdown panel — rack-selector popover ── */
.tree-dd-panel {
display: none;
position: absolute;
top: 100%;
left: 0;
min-width: 240px;
max-width: 340px;
min-width: 260px;
max-width: 360px;
max-height: 70vh;
overflow-y: auto;
background: var(--bg-color);
border: 1px solid var(--border-color);
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25);
background: linear-gradient(180deg,
var(--lux-bg-1, var(--bg-color)) 0%,
var(--lux-bg-2, var(--bg-color)) 100%);
border: var(--lux-hairline, 1px) solid var(--lux-line-bold, var(--border-color));
border-radius: var(--lux-r-md, 6px);
box-shadow: var(--lux-shadow-rack, 0 8px 24px rgba(0, 0, 0, 0.25));
z-index: 100;
padding: 4px 0;
margin-top: 4px;
padding: 6px 0;
margin-top: 6px;
scrollbar-width: thin;
}
/* Channel accent rule at the top of the panel */
.tree-dd-panel::before {
content: '';
position: absolute;
left: 0; right: 0; top: 0;
height: 1px;
background: linear-gradient(90deg,
transparent 0%,
var(--ch-signal, var(--primary-color)) 20%,
var(--ch-cyan, var(--primary-color)) 50%,
var(--ch-magenta, var(--primary-color)) 80%,
transparent 100%);
opacity: 0.5;
pointer-events: none;
}
.tree-dd-panel.open {
@@ -123,14 +171,26 @@
.tree-dd-group-header {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px 3px;
font-size: 0.68rem;
font-weight: 700;
color: var(--text-muted);
gap: 8px;
padding: 8px 14px 4px;
font-family: var(--font-mono, monospace);
font-size: 0.56rem;
font-weight: 600;
color: var(--lux-ink-mute, var(--text-muted));
text-transform: uppercase;
letter-spacing: 0.04em;
letter-spacing: 0.22em;
user-select: none;
position: relative;
}
/* Small square dot prefix — reads like a silkscreened section marker. */
.tree-dd-group-header::before {
content: '';
width: 4px;
height: 4px;
background: var(--lux-ink-faint, var(--text-muted));
border-radius: 1px;
flex-shrink: 0;
}
.tree-dd-group-header.tree-dd-depth-1 {
@@ -184,12 +244,15 @@
.tree-dd-leaf {
display: flex;
align-items: center;
gap: 6px;
padding: 5px 12px 5px 20px;
gap: 8px;
padding: 7px 14px 7px 22px;
cursor: pointer;
font-size: 0.8rem;
color: var(--text-secondary);
transition: color 0.1s, background 0.1s;
font-family: var(--font-body, inherit);
font-size: 0.82rem;
font-weight: 500;
color: var(--lux-ink-dim, var(--text-secondary));
transition: color 0.1s, background 0.1s, box-shadow 0.1s;
position: relative;
}
/* Indent leaves inside nested groups */
@@ -203,19 +266,38 @@
}
.tree-dd-leaf:hover {
color: var(--text-color);
background: var(--bg-secondary);
color: var(--lux-ink, var(--text-color));
background: var(--lux-bg-3, var(--bg-secondary));
}
/* Active leaf: LED pip on the left + channel glow + brighter text */
.tree-dd-leaf.active {
color: var(--primary-text-color);
background: color-mix(in srgb, var(--primary-color) 12%, transparent);
color: var(--lux-ink, var(--text-color));
background: linear-gradient(90deg,
color-mix(in srgb, var(--ch-signal, var(--primary-color)) 14%, transparent) 0%,
transparent 80%);
font-weight: 600;
box-shadow: inset 2px 0 0 var(--ch-signal, var(--primary-color));
}
.tree-dd-leaf.active::before {
content: '';
position: absolute;
left: 8px;
top: 50%;
transform: translateY(-50%);
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--ch-signal, var(--primary-color));
box-shadow: 0 0 6px var(--ch-signal, var(--primary-color));
animation: pulse 2s ease-in-out infinite;
}
.tree-dd-leaf.active .tree-count {
background: var(--primary-color);
color: var(--primary-contrast);
background: var(--ch-signal, var(--primary-color));
color: var(--lux-bg-0, var(--primary-contrast));
box-shadow: 0 0 6px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 50%, transparent);
}
.tree-dd-leaf .tree-node-icon {
@@ -238,22 +320,26 @@
/* ── Count badge (shared) ── */
.tree-count {
background: var(--border-color);
color: var(--text-secondary);
font-size: 0.6rem;
.tree-count,
.tree-dd-group-count {
background: var(--lux-bg-3, var(--border-color));
color: var(--lux-ink-dim, var(--text-secondary));
font-family: var(--font-mono, monospace);
font-size: 0.56rem;
font-weight: 600;
padding: 0 5px;
border-radius: 8px;
padding: 1px 6px;
border-radius: 2px;
flex-shrink: 0;
min-width: 16px;
min-width: 18px;
text-align: center;
letter-spacing: 0.04em;
font-variant-numeric: tabular-nums;
}
/* ── Group separator ── */
/* ── Group separator — hairline-dashed between top-level groups ── */
.tree-dd-group + .tree-dd-group {
border-top: 1px solid var(--border-color);
margin-top: 2px;
padding-top: 2px;
border-top: 1px dashed var(--lux-line, var(--border-color));
margin-top: 4px;
padding-top: 4px;
}
Binary file not shown.
+26 -4
View File
@@ -22,7 +22,7 @@ import {
toggleHint, lockBody, unlockBody, closeLightbox,
showToast, showUndoToast, showConfirm, closeConfirmModal,
openFullImageLightbox, showOverlaySpinner, hideOverlaySpinner,
setFieldError, clearFieldError, setupBlurValidation, initLightbox,
setFieldError, clearFieldError, setupBlurValidation,
} from './core/ui.ts';
// Layer 3: displays, tutorials
@@ -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();
@@ -754,9 +770,6 @@ document.addEventListener('DOMContentLoaded', async () => {
// Initialize command palette
initCommandPalette();
// Enhance lightbox FPS <select> with IconSelect
initLightbox();
// Setup form handler
const addDeviceForm = queryEl('add-device-form');
if (addDeviceForm) addDeviceForm.addEventListener('submit', handleAddDevice);
@@ -789,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';
@@ -800,6 +818,10 @@ document.addEventListener('DOMContentLoaded', async () => {
startEventsWS();
startEntityEventListeners();
startAutoRefresh();
// Perf poll starts globally so the transport-bar CPU / Mem cells stay
// live regardless of which tab is active. Tab-hidden pauses it via the
// visibilitychange handler in perf-charts.ts.
startPerfPolling();
// Initialize update checker (banner + WS listener)
initUpdateListener();
+13
View File
@@ -305,6 +305,19 @@ export async function loadServerInfo() {
if (data.repo_url) serverRepoUrl = data.repo_url;
if (data.donate_url) serverDonateUrl = data.donate_url;
// Seed the transport-bar uptime ticker with the server's actual
// uptime. Survives page reloads and tracks the *server* process,
// not this browser session. The inline ticker reads this from
// ``window.__serverUptime`` and falls back to "—" if absent.
// ``recordedAtPerf`` uses ``performance.now()`` so wall-clock
// changes (NTP step, DST) don't make the counter jump.
if (typeof data.uptime_seconds === 'number') {
window.__serverUptime = {
uptimeSec: data.uptime_seconds,
recordedAtPerf: performance.now(),
};
}
// Demo mode detection
if (data.demo_mode && !demoMode) {
demoMode = true;
+7 -2
View File
@@ -114,7 +114,10 @@ let _particleBuf: Float32Array | null = null; // pre-allocated Float32Array for
let _raf: number | null = null;
let _startTime = 0;
let _accent = [76 / 255, 175 / 255, 80 / 255];
let _bgColor = [26 / 255, 26 / 255, 26 / 255];
// Base canvas colour — must match `--bg-color` (pure black / white in the
// Lumenworks theme). Using mid-greys here washes the additive glow with a
// constant tint that doesn't exist on the surrounding page background.
let _bgColor = [0, 0, 0];
let _isLight = 0.0;
// Particle state (CPU-side, positions in 0..1 UV space)
@@ -262,7 +265,9 @@ export function updateBgAnimAccent(hex: string): void {
}
export function updateBgAnimTheme(isDark: boolean): void {
_bgColor = isDark ? [26 / 255, 26 / 255, 26 / 255] : [245 / 255, 245 / 255, 245 / 255];
// Match the page's `--bg-color` (pure black/white) — see comment on
// the `_bgColor` declaration above.
_bgColor = isDark ? [0, 0, 0] : [1, 1, 1];
_isLight = isDark ? 0.0 : 1.0;
}
@@ -320,7 +320,10 @@ let _uBg: WebGLUniformLocation | null = null;
let _uLight: WebGLUniformLocation | null = null;
let _accent = [76 / 255, 175 / 255, 80 / 255];
let _bgColor = [26 / 255, 26 / 255, 26 / 255];
// Base canvas colour — must match `--bg-color` (pure black / white in
// the Lumenworks theme). Using mid-greys here washes the additive glow
// with a constant tint that doesn't exist on the surrounding page bg.
let _bgColor = [0, 0, 0];
let _isLight = 0.0;
// ─── GL helpers ──────────────────────────────────────────────
@@ -471,7 +474,8 @@ export function updateShaderAccent(hex: string): void {
/** Update theme brightness (called on theme toggle). */
export function updateShaderTheme(isDark: boolean): void {
_bgColor = isDark ? [26 / 255, 26 / 255, 26 / 255] : [245 / 255, 245 / 255, 245 / 255];
// Match the page's `--bg-color` token (pure black/white).
_bgColor = isDark ? [0, 0, 0] : [1, 1, 1];
_isLight = isDark ? 0.0 : 1.0;
}
@@ -25,6 +25,18 @@ import { ICON_TRASH } from './icons.ts';
const STORAGE_KEY = 'cardColors';
const DEFAULT_SWATCH = '#808080';
/** Data attributes used as the entity-id key on card elements across the
* app. setCardColor() walks all of these so a single picker click updates
* every card representing the same entity (e.g. the targets-tab card AND
* its dashboard mirror), not just the one that owns the picker. */
const CARD_ID_ATTRS: readonly string[] = [
'data-target-id', 'data-device-id', 'data-automation-id',
'data-sync-clock-id', 'data-stream-id', 'data-template-id',
'data-pattern-template-id', 'data-pp-template-id', 'data-cspt-id',
'data-audio-template-id', 'data-audio-source-id', 'data-gi-id',
'data-scene-id', 'data-id',
];
function _getAll(): Record<string, string> {
try { return JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}') || {}; }
catch { return {}; }
@@ -38,15 +50,30 @@ export function setCardColor(id: string, hex: string): void {
const m = _getAll();
if (hex) m[id] = hex; else delete m[id];
localStorage.setItem(STORAGE_KEY, JSON.stringify(m));
// Live-update every card representing this entity. The card stripe is
// the ::before pseudo-element backed by --ch (see cards.css), so we
// override --ch inline rather than setting border-left — that avoids
// the double-stripe (custom border + primary --ch) the old approach
// produced, and reaches dashboard mirrors that the picker callback's
// .closest() lookup couldn't.
const escaped = CSS.escape(id);
const selector = CARD_ID_ATTRS.map(a => `[${a}="${escaped}"]`).join(',');
document.querySelectorAll(selector).forEach(el => {
const card = el as HTMLElement;
if (hex) card.style.setProperty('--ch', hex);
else card.style.removeProperty('--ch');
});
}
/**
* Returns inline style string for card border-left.
* Empty string when no color is set.
* Returns the inline style fragment for a card's accent override.
* Sets the --ch CSS variable so the existing ::before channel stripe
* picks up the user's color. Empty string when no color is set.
*/
export function cardColorStyle(entityId: string): string {
const c = getCardColor(entityId);
return c ? `border-left: 3px solid ${c}` : '';
return c ? `--ch: ${c}` : '';
}
/**
@@ -59,12 +86,9 @@ export function cardColorButton(entityId: string, cardAttr: string): string {
const pickerId = `cc-${entityId}`;
registerColorPicker(pickerId, (hex) => {
// setCardColor handles the DOM update on every card representing
// this entity (including dashboard mirrors). Nothing else to do.
setCardColor(entityId, hex);
// Find the card that contains this picker (not a global querySelector
// which could match a dashboard compact card first)
const wrapper = document.getElementById(`cp-wrap-${pickerId}`);
const card = wrapper?.closest(`[${cardAttr}]`) as HTMLElement | null;
if (card) card.style.borderLeft = hex ? `3px solid ${hex}` : '';
});
return createColorPicker({ id: pickerId, currentColor: color, onPick: undefined, anchor: 'left', showReset: true, resetColor: DEFAULT_SWATCH });
@@ -3,11 +3,14 @@
*
* Both dashboard.js and targets.js need nearly identical Chart.js line charts
* for FPS visualization. This module provides a single factory so the config
* lives in one place.
*
* Requires Chart.js to be registered globally (done by perf-charts.js).
* lives in one place and owns the global Chart.js registration.
*/
import { Chart, registerables } from 'chart.js';
Chart.register(...registerables);
// Expose globally for legacy code paths that still reference window.Chart.
window.Chart = Chart;
const DEFAULT_MAX_SAMPLES = 120;
/** Left-pad an array with nulls so it always has `maxSamples` entries. */
@@ -28,7 +31,7 @@ function _padLeft(arr: number[], maxSamples: number): (number | null)[] {
* @returns {Chart|null}
*/
export function createFpsSparkline(canvasId: string, actualHistory: number[], currentHistory: number[], fpsTarget: number, opts: any = {}) {
const canvas = document.getElementById(canvasId);
const canvas = document.getElementById(canvasId) as HTMLCanvasElement | null;
if (!canvas) return null;
const maxSamples = opts.maxSamples || DEFAULT_MAX_SAMPLES;
@@ -69,6 +69,25 @@ function _rgbToHex(rgb: string) {
return '#' + m.slice(0, 3).map(n => parseInt(n).toString(16).padStart(2, '0')).join('');
}
/** True if any ancestor between `el` and <body> has overflow:hidden / clip
* / auto on x or y. Used by the picker toggle to decide whether it must
* detach the popover to <body> with fixed positioning so it isn't
* clipped. */
function _hasOverflowClipAncestor(el: Element): boolean {
let cur: Element | null = el.parentElement;
while (cur && cur !== document.body) {
const cs = getComputedStyle(cur);
const ox = cs.overflowX;
const oy = cs.overflowY;
if (ox === 'hidden' || ox === 'clip' || ox === 'auto' ||
oy === 'hidden' || oy === 'clip' || oy === 'auto') {
return true;
}
cur = cur.parentElement;
}
return false;
}
window._cpToggle = function (id) {
// Close all other pickers first (and drop their card elevation)
document.querySelectorAll('.color-picker-popover').forEach((p: Element) => {
@@ -108,6 +127,32 @@ window._cpToggle = function (id) {
pop.style.animation = 'none';
pop.style.zIndex = '10000';
pop.classList.add('cp-fixed');
} else {
// Desktop: detach to body with fixed positioning when the swatch sits
// inside an overflow:hidden ancestor (e.g. the perf-chart strip,
// modal body, tree-dd panel). Otherwise the popover is clipped.
const swatchEl = document.getElementById(`cp-swatch-${id}`);
const hasClippingAncestor = swatchEl && _hasOverflowClipAncestor(swatchEl);
if (hasClippingAncestor && pop.parentElement !== document.body) {
(pop as any)._cpOrigParent = pop.parentElement;
(pop as any)._cpOrigNext = pop.nextSibling;
document.body.appendChild(pop);
const swRect = swatchEl!.getBoundingClientRect();
pop.style.position = 'fixed';
pop.style.top = `${swRect.bottom + 8}px`;
// Anchor on the left edge of the swatch, but clamp so the
// popover doesn't run off the right edge of the viewport.
const popWidth = 240; // approx; refined after first paint
let left = swRect.left;
if (left + popWidth > window.innerWidth - 12) {
left = Math.max(12, window.innerWidth - popWidth - 12);
}
pop.style.left = `${left}px`;
pop.style.right = 'auto';
pop.style.margin = '0';
pop.style.zIndex = '10000';
pop.classList.add('cp-fixed');
}
}
// Mark active dot
@@ -133,9 +133,54 @@ export function renderNodes(group: SVGGElement, nodeMap: Map<string, GraphNode>,
for (const node of nodeMap.values()) {
const g = renderNode(node, callbacks);
group.appendChild(g);
// Now that the <g> is in the live SVG, `getComputedTextLength()`
// returns real values — fit the title/subtitle to the visible
// text area and append "…" if they overflow.
_fitNodeText(g, node.width);
}
}
/** Available text width per node clip rect is x=14..(width-48) wide and
* text starts at x=16, so the usable run is `width - 50`. The 2 px slack
* on the right keeps the ellipsis from kissing the clip edge. */
function _availableTextWidth(nodeWidth: number): number {
return Math.max(0, nodeWidth - 52);
}
/** Replace the text of an SVG `<text>` element with the longest prefix of
* its `data-full-text` that fits within `maxWidth`, suffixed with "…".
* No-op if the full text already fits. */
function _fitTextToWidth(el: SVGTextElement, maxWidth: number): void {
const full = el.getAttribute('data-full-text') || el.textContent || '';
el.textContent = full;
if (maxWidth <= 0) { el.textContent = ''; return; }
let len = 0;
try { len = el.getComputedTextLength(); } catch { return; }
if (len <= maxWidth) return;
// Binary search for the longest character prefix that fits with "…".
let lo = 0, hi = full.length;
while (lo < hi) {
const mid = Math.ceil((lo + hi) / 2);
el.textContent = full.slice(0, mid).trimEnd() + '…';
try {
if (el.getComputedTextLength() <= maxWidth) lo = mid;
else hi = mid - 1;
} catch {
return;
}
}
el.textContent = (full.slice(0, lo).trimEnd() || '') + '…';
}
function _fitNodeText(nodeG: Element, nodeWidth: number): void {
const maxW = _availableTextWidth(nodeWidth);
const title = nodeG.querySelector<SVGTextElement>('.graph-node-title');
const subtitle = nodeG.querySelector<SVGTextElement>('.graph-node-subtitle');
if (title) _fitTextToWidth(title, maxW);
if (subtitle) _fitTextToWidth(subtitle, maxW);
}
/**
* Render a single node.
*/
@@ -342,23 +387,30 @@ function renderNode(node: GraphNode, callbacks: NodeCallbacks): SVGElement {
clipPath.appendChild(svgEl('rect', { x: 14, y: 0, width: width - 48, height }));
g.appendChild(clipPath);
// Title (shift left edge for icon to have room)
// Title (shift left edge for icon to have room).
// Full text is stashed on `data-full-text` so the post-mount fit pass
// can measure with `getComputedTextLength()` and binary-search the
// longest prefix that fits, appending "…" instead of relying on the
// clip-path (which silently chops mid-glyph with no ellipsis cue).
const title = svgEl('text', {
class: 'graph-node-title',
x: 16, y: 24,
'clip-path': `url(#${clipId})`,
'data-full-text': name,
});
title.textContent = name;
g.appendChild(title);
// Subtitle (type)
if (subtype) {
const subText = subtype.replace(/_/g, ' ');
const sub = svgEl('text', {
class: 'graph-node-subtitle',
x: 16, y: 42,
'clip-path': `url(#${clipId})`,
'data-full-text': subText,
});
sub.textContent = subtype.replace(/_/g, ' ');
sub.textContent = subText;
g.appendChild(sub);
}
@@ -23,6 +23,7 @@ export const flaskConical = '<path d="M14 2v6a2 2 0 0 0 .245.96l5.51 10.08A2 2 0
export const pencil = '<path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"/><path d="m15 5 4 4"/>';
export const play = '<path d="M5 5a2 2 0 0 1 3.008-1.728l11.997 6.998a2 2 0 0 1 .003 3.458l-12 7A2 2 0 0 1 5 19z"/>';
export const square = '<rect width="18" height="18" x="3" y="3" rx="2"/>';
export const circle = '<circle cx="12" cy="12" r="9"/>';
export const pause = '<rect x="14" y="3" width="5" height="18" rx="1"/><rect x="5" y="3" width="5" height="18" rx="1"/>';
export const settings = '<path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915"/><circle cx="12" cy="12" r="3"/>';
export const ruler = '<path d="M21.3 15.3a2.4 2.4 0 0 1 0 3.4l-2.6 2.6a2.4 2.4 0 0 1-3.4 0L2.7 8.7a2.41 2.41 0 0 1 0-3.4l2.6-2.6a2.41 2.41 0 0 1 3.4 0Z"/><path d="m14.5 12.5 2-2"/><path d="m11.5 9.5 2-2"/><path d="m8.5 6.5 2-2"/><path d="m17.5 15.5 2-2"/>';
@@ -342,6 +342,8 @@ export const ICON_GITHUB = _svg(P.github);
export const ICON_CHEVRON_UP = _svg(P.chevronUp);
export const ICON_CHEVRON_DOWN = _svg(P.chevronDown);
export const ICON_PLUS = _svg(P.plus);
export const ICON_SQUARE = _svg(P.square);
export const ICON_CIRCLE = _svg(P.circle);
export const ICON_GIT_MERGE = _svg(P.gitMerge);
export const ICON_COPY = _svg(P.copy);
-24
View File
@@ -5,30 +5,6 @@
import { confirmResolve, setConfirmResolve } from './state.ts';
import { API_BASE, getHeaders } from './api.ts';
import { t } from './i18n.ts';
import { IconSelect } from './icon-select.ts';
let _lightboxFpsIconSelect: IconSelect | null = null;
/** Enhance the lightbox FPS <select> with an IconSelect. Idempotent. */
export function initLightbox(): void {
if (_lightboxFpsIconSelect) return;
const sel = document.getElementById('lightbox-fps-select') as HTMLSelectElement | null;
if (!sel) return;
_lightboxFpsIconSelect = new IconSelect({
target: sel,
items: [
{ value: '1', icon: '<span style="font-weight:700">1</span>', label: '1 fps' },
{ value: '2', icon: '<span style="font-weight:700">2</span>', label: '2 fps' },
{ value: '3', icon: '<span style="font-weight:700">3</span>', label: '3 fps' },
{ value: '5', icon: '<span style="font-weight:700">5</span>', label: '5 fps' },
],
columns: 2,
onChange: (val: string) => {
const fn = (window as any).onLightboxFpsChange;
if (typeof fn === 'function') fn(val);
},
});
}
/** Returns true on touch devices where auto-focus would pop up the virtual keyboard */
export function isTouchDevice() {
@@ -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);
@@ -7,7 +7,7 @@ import { fetchWithAuth, escapeHtml } from '../../core/api.ts';
import { logError } from '../../core/log.ts';
import { colorStripSourcesCache } from '../../core/state.ts';
import { t } from '../../core/i18n.ts';
import { showToast, openLightbox, closeLightbox } from '../../core/ui.ts';
import { showToast } from '../../core/ui.ts';
import { createFpsSparkline } from '../../core/chart-utils.ts';
import {
getColorStripIcon,
@@ -97,6 +97,10 @@ let _cssTestTransientConfig: any = null;
const _CSS_TEST_LED_KEY = 'css_test_led_count';
const _CSS_TEST_FPS_KEY = 'css_test_fps';
const _CSS_TEST_KC_FPS_KEY = 'css_test_kc_fps';
const _CSS_TEST_KC_FPS_DEFAULT = 5;
const _CSS_TEST_KC_FPS_MIN = 1;
const _CSS_TEST_KC_FPS_MAX = 30;
let _cssTestWs: WebSocket | null = null;
let _cssTestRaf: number | null = null;
let _cssTestLatestRgb: Uint8Array | null = null;
@@ -109,6 +113,7 @@ let _cssTestNotificationIds: string[] = []; // notification source IDs to fire (
let _cssTestCSPTMode: boolean = false; // true when testing a CSPT template
let _cssTestCSPTId: string | null = null; // CSPT template ID when in CSPT mode
let _cssTestIsApiInput: boolean = false;
let _cssTestIsKeyColors: boolean = false;
let _cssTestFpsTimestamps: number[] = []; // raw timestamps for current-second FPS calculation
let _cssTestFpsActualHistory: number[] = []; // rolling FPS samples for sparkline
let _cssTestFpsChart: any = null;
@@ -125,6 +130,11 @@ function _getCssTestFps() {
return (stored >= 1 && stored <= 60) ? stored : 20;
}
function _getKCTestFps() {
const stored = parseInt(localStorage.getItem(_CSS_TEST_KC_FPS_KEY) ?? '', 10);
return (stored >= _CSS_TEST_KC_FPS_MIN && stored <= _CSS_TEST_KC_FPS_MAX) ? stored : _CSS_TEST_KC_FPS_DEFAULT;
}
function _populateCssTestSourceSelector(preselectId: any) {
const sources = (colorStripSourcesCache.data || []) as any[];
const nonProcessed = sources.filter(s => s.source_type !== 'processed');
@@ -162,81 +172,139 @@ export function testColorStrip(sourceId: string) {
}
let _kcTestWs: WebSocket | null = null;
const _kcTestCanvas = document.createElement('canvas');
const BORDER_COLORS = ['#ff6b6b', '#4ecdc4', '#45b7d1', '#96e6a1', '#dda0dd', '#f9ca24', '#ff9ff3', '#54a0ff'];
function _testKeyColorsSource(sourceId: string) {
// Show lightbox with spinner
const lightbox = document.getElementById('image-lightbox')!;
const spinner = lightbox.querySelector('.lightbox-spinner') as HTMLElement | null;
const content = lightbox.querySelector('.lightbox-content') as HTMLElement | null;
if (content) content.style.width = '90vw'; // Fill viewport for KC preview
const img = document.getElementById('lightbox-image') as HTMLImageElement;
img.src = '';
img.style.display = 'none'; // Hide until first frame arrives
if (spinner) spinner.style.display = '';
document.getElementById('lightbox-stats')!.style.display = 'none';
lightbox.classList.add('active');
_cssTestCSPTMode = false;
_cssTestCSPTId = null;
_cssTestIsApiInput = false;
_cssTestIsKeyColors = true;
_cssTestSourceId = sourceId;
// Close any previous WS
// Close any previous sessions
if (_cssTestWs) { _cssTestWs.close(); _cssTestWs = null; }
if (_kcTestWs) { _kcTestWs.close(); _kcTestWs = null; }
if (_cssTestRaf) { cancelAnimationFrame(_cssTestRaf); _cssTestRaf = null; }
_cssTestLatestRgb = null;
_cssTestMeta = null;
_cssTestLayerData = null;
const modal = document.getElementById('test-css-source-modal') as HTMLElement | null;
if (!modal) return;
modal.style.display = 'flex';
modal.onclick = (e) => { if (e.target === modal) closeTestCssSourceModal(); };
// Show only the KC view; hide all others
(document.getElementById('css-test-strip-view') as HTMLElement).style.display = 'none';
(document.getElementById('css-test-rect-view') as HTMLElement).style.display = 'none';
(document.getElementById('css-test-layers-view') as HTMLElement).style.display = 'none';
(document.getElementById('css-test-kc-view') as HTMLElement).style.display = '';
(document.getElementById('css-test-fps-chart-group') as HTMLElement).style.display = 'none';
// CSPT input selector is not relevant for KC
const csptGroup = document.getElementById('css-test-cspt-input-group') as HTMLElement | null;
if (csptGroup) csptGroup.style.display = 'none';
// LED count doesn't apply to KC — hide LED group; keep FPS input visible
(document.getElementById('css-test-led-fps-group') as HTMLElement).style.display = '';
(document.getElementById('css-test-led-group') as HTMLElement).style.display = 'none';
const fpsInput = document.getElementById('css-test-fps-input') as HTMLInputElement | null;
if (fpsInput) {
fpsInput.min = String(_CSS_TEST_KC_FPS_MIN);
fpsInput.max = String(_CSS_TEST_KC_FPS_MAX);
fpsInput.value = String(_getKCTestFps());
fpsInput.onkeydown = (e) => { if (e.key === 'Enter') { e.preventDefault(); applyCssTestSettings(); } };
}
// Widen modal to give the frame room to breathe
const modalContent = modal.querySelector('.modal-content') as HTMLElement | null;
if (modalContent) modalContent.style.maxWidth = '900px';
// Clear any stale KC state
const canvas = document.getElementById('css-test-kc-canvas') as HTMLCanvasElement | null;
if (canvas) {
const ctx = canvas.getContext('2d');
if (ctx) ctx.clearRect(0, 0, canvas.width, canvas.height);
}
const metaEl = document.getElementById('css-test-kc-meta') as HTMLElement | null;
if (metaEl) metaEl.innerHTML = '';
const statusEl = document.getElementById('css-test-status') as HTMLElement;
statusEl.textContent = t('color_strip.test.connecting');
statusEl.style.display = '';
_kcTestConnect(sourceId, _getKCTestFps());
}
function _kcTestConnect(sourceId: string, fps: number) {
if (_kcTestWs) { _kcTestWs.close(); _kcTestWs = null; }
// Build WS URL
const gen = ++_cssTestGeneration;
const loc = window.location;
const wsProto = loc.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${wsProto}//${loc.host}/api/v1/color-strip-sources/${sourceId}/key-colors/test/ws?fps=5&preview_width=960`;
const clamped = Math.max(_CSS_TEST_KC_FPS_MIN, Math.min(_CSS_TEST_KC_FPS_MAX, fps));
const wsUrl = `${wsProto}//${loc.host}/api/v1/color-strip-sources/${sourceId}/key-colors/test/ws?fps=${clamped}&preview_width=960`;
openAuthedWs(wsUrl).then((ws) => {
if (gen !== _cssTestGeneration) { ws.close(); return; }
_kcTestWs = ws;
ws.onmessage = (ev) => {
if (gen !== _cssTestGeneration) return;
try {
const data = JSON.parse(ev.data);
if (data.type === 'frame') {
_renderKCTestFrame(data);
const statusEl = document.getElementById('css-test-status') as HTMLElement | null;
if (statusEl) statusEl.style.display = 'none';
}
} catch (err) { logError('color-strips.test.kcWs.message', err); }
};
ws.onerror = () => {
showToast('Key Colors test connection failed', 'error');
closeLightbox();
if (gen !== _cssTestGeneration) return;
const statusEl = document.getElementById('css-test-status') as HTMLElement;
statusEl.textContent = t('color_strip.test.error');
statusEl.style.display = '';
};
ws.onclose = () => {
ws.onclose = (ev) => {
if (gen !== _cssTestGeneration) return;
_kcTestWs = null;
};
// Stop WS when lightbox closes
const origClose = (window as any).closeLightbox;
lightbox.onclick = (e) => {
if ((e.target as HTMLElement).closest('.lightbox-content')) return;
if (_kcTestWs) { _kcTestWs.close(); _kcTestWs = null; }
closeLightbox();
if (ev.reason) {
const statusEl = document.getElementById('css-test-status') as HTMLElement;
statusEl.textContent = ev.reason;
statusEl.style.display = '';
}
};
}).catch(() => {
showToast('Key Colors test connection failed', 'error');
closeLightbox();
if (gen !== _cssTestGeneration) return;
const statusEl = document.getElementById('css-test-status') as HTMLElement;
statusEl.textContent = t('color_strip.test.error');
statusEl.style.display = '';
});
}
function _renderKCTestFrame(data: any) {
const rects = data.rectangles || [];
const mode = data.interpolation_mode || 'average';
const canvas = document.getElementById('css-test-kc-canvas') as HTMLCanvasElement | null;
const metaEl = document.getElementById('css-test-kc-meta') as HTMLElement | null;
if (!canvas) return;
// Draw frame + rectangles onto offscreen canvas
const tmpImg = new Image();
tmpImg.onload = () => {
_kcTestCanvas.width = tmpImg.naturalWidth;
_kcTestCanvas.height = tmpImg.naturalHeight;
const ctx = _kcTestCanvas.getContext('2d')!;
canvas.width = tmpImg.naturalWidth;
canvas.height = tmpImg.naturalHeight;
const ctx = canvas.getContext('2d')!;
ctx.drawImage(tmpImg, 0, 0);
rects.forEach((r: any, i: number) => {
const x = r.x * _kcTestCanvas.width;
const y = r.y * _kcTestCanvas.height;
const w = r.width * _kcTestCanvas.width;
const h = r.height * _kcTestCanvas.height;
const x = r.x * canvas.width;
const y = r.y * canvas.height;
const w = r.width * canvas.width;
const h = r.height * canvas.height;
const borderColor = BORDER_COLORS[i % BORDER_COLORS.length];
ctx.fillStyle = r.color.hex + '33';
@@ -258,36 +326,19 @@ function _renderKCTestFrame(data: any) {
ctx.lineWidth = 1;
ctx.strokeRect(x + w - 24, y + 2, 22, 22);
});
// Update lightbox image directly (use data URL for full-size display)
const lbImg = document.getElementById('lightbox-image') as HTMLImageElement;
if (lbImg) {
lbImg.src = _kcTestCanvas.toDataURL('image/jpeg', 0.9);
lbImg.style.display = '';
lbImg.style.maxWidth = '100%';
lbImg.style.width = '100%';
}
// Hide spinner after first frame
const spinner = document.querySelector('#image-lightbox .lightbox-spinner') as HTMLElement | null;
if (spinner) spinner.style.display = 'none';
// Update swatches
const statsEl = document.getElementById('lightbox-stats')!;
const swatches = rects.map((r: any) =>
`<div style="display:inline-flex;align-items:center;gap:6px;margin:4px 8px;">
<span style="display:inline-block;width:20px;height:20px;background:${r.color.hex};border:1px solid #888;border-radius:3px;"></span>
<span>${escapeHtml(r.name)}</span>
<small style="opacity:0.6;">${r.color.hex}</small>
</div>`
).join('');
statsEl.innerHTML = `
<div style="display:flex;flex-wrap:wrap;justify-content:center;">${swatches}</div>
<div style="margin-top:4px;opacity:0.6;text-align:center;">Mode: ${mode} | ${rects.length} region${rects.length !== 1 ? 's' : ''}</div>
`;
statsEl.style.display = '';
};
tmpImg.src = data.image;
if (metaEl) {
const swatches = rects.map((r: any) =>
`<span class="css-test-kc-swatch">
<span class="css-test-kc-swatch-chip" style="background:${r.color.hex}"></span>
<span>${escapeHtml(r.name)}</span>
<span class="css-test-kc-swatch-hex">${r.color.hex}</span>
</span>`
).join('');
metaEl.innerHTML = `${swatches}<span class="css-test-kc-mode">${mode} · ${rects.length}&nbsp;region${rects.length !== 1 ? 's' : ''}</span>`;
}
}
export async function testCSPT(templateId: string) {
@@ -310,10 +361,12 @@ export async function testCSPT(templateId: string) {
function _openTestModal(sourceId: string) {
// Clean up any previous session fully
if (_cssTestWs) { _cssTestWs.close(); _cssTestWs = null; }
if (_kcTestWs) { _kcTestWs.close(); _kcTestWs = null; }
if (_cssTestRaf) { cancelAnimationFrame(_cssTestRaf); _cssTestRaf = null; }
_cssTestLatestRgb = null;
_cssTestMeta = null;
_cssTestIsComposite = false;
_cssTestIsKeyColors = false;
_cssTestLayerData = null;
const modal = document.getElementById('test-css-source-modal') as HTMLElement | null;
@@ -326,6 +379,8 @@ function _openTestModal(sourceId: string) {
(document.getElementById('css-test-strip-view') as HTMLElement).style.display = 'none';
(document.getElementById('css-test-rect-view') as HTMLElement).style.display = 'none';
(document.getElementById('css-test-layers-view') as HTMLElement).style.display = 'none';
const kcView = document.getElementById('css-test-kc-view') as HTMLElement | null;
if (kcView) kcView.style.display = 'none';
// Clear all test canvases to prevent stale frames from previous sessions
modal.querySelectorAll('canvas').forEach(c => {
const ctx = c.getContext('2d');
@@ -363,8 +418,12 @@ function _openTestModal(sourceId: string) {
const fpsVal = _getCssTestFps();
const fpsInput = document.getElementById('css-test-fps-input') as HTMLInputElement | null;
fpsInput!.value = fpsVal as any;
fpsInput!.onkeydown = (e) => { if (e.key === 'Enter') { e.preventDefault(); applyCssTestSettings(); } };
if (fpsInput) {
fpsInput.min = '1';
fpsInput.max = '60';
fpsInput.value = String(fpsVal);
fpsInput.onkeydown = (e) => { if (e.key === 'Enter') { e.preventDefault(); applyCssTestSettings(); } };
}
_cssTestConnect(sourceId, ledCount, fpsVal);
}
@@ -621,6 +680,18 @@ function _cssTestUpdateBrightness(values: any) {
export function applyCssTestSettings() {
if (!_cssTestSourceId) return;
// Key Colors test: FPS only — different range and storage key
if (_cssTestIsKeyColors) {
const fpsInput = document.getElementById('css-test-fps-input') as HTMLInputElement | null;
let fps = parseInt(fpsInput?.value ?? '', 10);
if (isNaN(fps) || fps < _CSS_TEST_KC_FPS_MIN) fps = _CSS_TEST_KC_FPS_MIN;
if (fps > _CSS_TEST_KC_FPS_MAX) fps = _CSS_TEST_KC_FPS_MAX;
if (fpsInput) fpsInput.value = String(fps);
localStorage.setItem(_CSS_TEST_KC_FPS_KEY, String(fps));
_kcTestConnect(_cssTestSourceId, fps);
return;
}
const ledInput = document.getElementById('css-test-led-input') as HTMLInputElement | null;
let leds = parseInt(ledInput?.value ?? '', 10);
if (isNaN(leds) || leds < 1) leds = 1;
@@ -1060,11 +1131,13 @@ function _cssTestStopFpsSampling() {
export function closeTestCssSourceModal() {
if (_cssTestWs) { _cssTestWs.close(); _cssTestWs = null; }
if (_kcTestWs) { _kcTestWs.close(); _kcTestWs = null; }
if (_cssTestRaf) { cancelAnimationFrame(_cssTestRaf); _cssTestRaf = null; }
_cssTestLatestRgb = null;
_cssTestMeta = null;
_cssTestSourceId = null;
_cssTestIsComposite = false;
_cssTestIsKeyColors = false;
_cssTestLayerData = null;
_cssTestNotificationIds = [];
_cssTestIsApiInput = false;
@@ -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;
}
+391 -119
View File
@@ -6,17 +6,26 @@ import { apiKey, _dashboardLoading, set_dashboardLoading, dashboardPollInterval,
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, fetchMetricsHistory } from '../core/api.ts';
import { t } from '../core/i18n.ts';
import { showToast, showConfirm, formatUptime, formatCompact, setTabRefreshing } from '../core/ui.ts';
import { renderPerfSection, renderPerfModeToggle, setPerfMode, initPerfCharts, startPerfPolling, stopPerfPolling } from './perf-charts.ts';
import { renderPerfSection, renderPerfModeToggle, setPerfMode, initPerfCharts, startPerfPolling, stopPerfPolling, updateActivePatches, updateTotalFps, updateDevices, rerenderPerfGrid } from './perf-charts.ts';
import { startAutoRefresh, updateTabBadge } from './tabs.ts';
import { isActiveTab } from '../core/tab-registry.ts';
import {
ICON_TARGET, ICON_AUTOMATION, ICON_CLOCK, ICON_WARNING, ICON_OK,
ICON_STOP, ICON_STOP_PLAIN, ICON_START, ICON_PAUSE, ICON_HELP, ICON_SCENE,
ICON_PLUG, ICON_HOME, ICON_RADIO,
ICON_PLUG, ICON_HOME, ICON_RADIO, ICON_SETTINGS,
} from '../core/icons.ts';
import { loadScenePresets, renderScenePresetsSection, initScenePresetDelegation } from './scene-presets.ts';
import { cardColorStyle } from '../core/card-colors.ts';
import { createFpsSparkline } from '../core/chart-utils.ts';
import { getOrderedSections, isSectionVisible, getSection, subscribeDashboardLayout, getGlobalConfig } from './dashboard-layout.ts';
function _applyGlobalLayoutAttrs(): void {
const c = document.getElementById('dashboard-content');
if (!c) return;
const g = getGlobalConfig();
c.dataset.layoutWidth = g.width;
c.dataset.layoutAnim = g.animations;
}
import type { Device, OutputTarget, ColorStripSource, ScenePreset, SyncClock, Automation, HomeAssistantConnectionStatus, HomeAssistantStatusResponse, MQTTConnectionStatus, MQTTStatusResponse } from '../types.ts';
const DASHBOARD_COLLAPSED_KEY = 'dashboard_collapsed';
@@ -45,6 +54,24 @@ function _pushFps(targetId: string, actual: number, current: number): void {
if (_fpsCurrentHistory[targetId].length > MAX_FPS_SAMPLES) _fpsCurrentHistory[targetId].shift();
}
/** Update the transport status chip in the top bar to reflect how many
* targets are currently running. "Ready" when idle, "Armed · N live"
* when one or more targets are processing. Safe to call any time. */
function _updateTransportStatus(runningCount: number): void {
const chip = document.getElementById('transport-status');
if (!chip) return;
const label = chip.querySelector('span:last-child');
if (!label) return;
if (runningCount > 0) {
chip.classList.add('is-armed');
const tmpl = t('transport.status.armed');
label.textContent = tmpl.includes('{n}') ? tmpl.replace('{n}', String(runningCount)) : `${tmpl} · ${runningCount}`;
} else {
chip.classList.remove('is-armed');
label.textContent = t('transport.status.ready');
}
}
function _setUptimeBase(targetId: string, seconds: number): void {
_uptimeBase[targetId] = { seconds, timestamp: Date.now() };
}
@@ -72,7 +99,11 @@ function _startUptimeTimer(): void {
if (!el) continue;
const seconds = _getInterpolatedUptime(id);
if (seconds != null) {
el.innerHTML = `${ICON_CLOCK} ${formatUptime(seconds)}`;
// Pure text — the .mod-metric "UPTIME" label already
// carries the icon meaning, and dropping it gives the
// value enough room for "4m 32s" / "1h 17m" without
// clipping inside the fixed-width metric cell.
el.textContent = formatUptime(seconds);
}
}
}, 1000);
@@ -194,7 +225,24 @@ function _updateRunningMetrics(enrichedRunning: any[]): void {
}
const errorsEl = cached?.errors || document.querySelector(`[data-errors-text="${CSS.escape(target.id)}"]`);
if (errorsEl) { errorsEl.innerHTML = `${errors > 0 ? ICON_WARNING : ICON_OK} ${formatCompact(errors)}`; (errorsEl as HTMLElement).title = String(errors); }
if (errorsEl) {
// Plain numeric in the big value — cleaner at display-font
// size. The status glyph (✓ / ⚠) sits next to the small
// label at the top of the cell; swap it here too so it
// reflects the live error count without flicker.
errorsEl.textContent = formatCompact(errors);
errorsEl.classList.toggle('has-errors', errors > 0);
(errorsEl as HTMLElement).title = String(errors);
const cell = document.querySelector(`[data-errors-cell="${CSS.escape(target.id)}"]`);
if (cell) {
const labelEl = cell.querySelector('.k');
if (labelEl) {
const labelText = labelEl.querySelector('[data-i18n]')?.textContent || t('dashboard.errors');
labelEl.innerHTML = `${errors > 0 ? ICON_WARNING : ICON_OK} <span data-i18n="dashboard.errors">${labelText}</span>`;
}
cell.classList.toggle('has-errors', errors > 0);
}
}
// Update health dot — prefer streaming reachability when processing
const isLed = target.target_type === 'led' || target.target_type === 'wled';
@@ -247,12 +295,23 @@ function _updateSyncClocksInPlace(syncClocks: SyncClock[]): void {
const card = document.querySelector(`[data-sync-clock-id="${CSS.escape(c.id)}"]`);
if (!card) continue;
const speedEl = card.querySelector('.dashboard-clock-speed');
if (speedEl) speedEl.textContent = `${c.speed}x`;
const btn = card.querySelector('.dashboard-target-actions .dashboard-action-btn');
if (speedEl) speedEl.textContent = `${c.speed}×`;
card.classList.toggle('is-running', c.is_running);
const led = card.querySelector('.mod-leds .led');
if (led) led.className = c.is_running ? 'led on blink' : 'led';
const patch = card.querySelector('.mod-patch');
if (patch) {
const dot = patch.querySelector('.patch-dot');
if (dot) dot.className = c.is_running ? 'patch-dot is-live' : 'patch-dot';
const label = patch.querySelector('span:last-child');
if (label) label.textContent = c.is_running ? 'TICKING' : 'PAUSED';
}
const btn = card.querySelector('.mod-foot .mod-btn');
if (btn) {
btn.className = `dashboard-action-btn ${c.is_running ? 'stop' : 'start'}`;
btn.setAttribute('onclick', c.is_running ? `dashboardPauseClock('${c.id}')` : `dashboardResumeClock('${c.id}')`);
btn.innerHTML = c.is_running ? ICON_PAUSE : ICON_START;
btn.className = c.is_running ? 'mod-btn mod-btn-stop' : 'mod-btn mod-btn-go';
btn.setAttribute('onclick', `event.stopPropagation(); ${c.is_running ? `dashboardPauseClock('${c.id}')` : `dashboardResumeClock('${c.id}')`}`);
const label = c.is_running ? (t('sync_clock.action.pause') || 'Pause') : (t('sync_clock.action.resume') || 'Resume');
btn.innerHTML = `${c.is_running ? ICON_PAUSE : ICON_START} <span>${label}</span>`;
}
}
}
@@ -266,14 +325,24 @@ function _renderIntegrationCard(conn: HomeAssistantConnectionStatus): string {
const subtitle = conn.connected
? `${conn.entity_count} ${t('dashboard.integrations.entities')}`
: t('ha_source.disconnected');
const short = (conn.source_id || '').replace(/^src_/, '').slice(0, 2).toUpperCase() || 'HA';
const ledCls = conn.connected ? 'led on blink' : 'led';
const patchLabel = conn.connected ? 'ONLINE' : 'OFFLINE';
const patchLive = conn.connected ? ' is-live' : '';
return `<div class="dashboard-target dashboard-autostart dashboard-card-link" data-integration-id="${conn.source_id}" onclick="if(!event.target.closest('button')){navigateToCard('integrations','home_assistant','ha-sources','data-id','${conn.source_id}')}">
<div class="dashboard-target-info">
<span class="dashboard-target-icon">${ICON_HOME}</span>
<div>
<div class="dashboard-target-name"><span class="dashboard-target-name-text">${escapeHtml(conn.name)}</span>${statusDot}</div>
<div class="dashboard-target-subtitle">${escapeHtml(subtitle)}</div>
return `<div class="dashboard-target dashboard-autostart dashboard-card-link ${conn.connected ? 'is-running' : ''}" data-integration-id="${conn.source_id}" onclick="if(!event.target.closest('button')){navigateToCard('integrations','home_assistant','ha-sources','data-id','${conn.source_id}')}">
<div class="mod-head">
<div class="mod-id">
<span class="mod-badge">HA · ${escapeHtml(short)}</span>
<div class="mod-name"><span>${escapeHtml(conn.name)}</span>${statusDot}</div>
<div class="mod-meta">${escapeHtml(subtitle)}</div>
</div>
<div class="mod-leds" aria-hidden="true">
<span class="${ledCls}"></span>
</div>
</div>
<div class="mod-foot">
<div class="mod-patch"><span class="patch-dot${patchLive}"></span><span>${patchLabel}</span></div>
</div>
</div>`;
}
@@ -283,20 +352,44 @@ function _renderMQTTIntegrationCard(conn: MQTTConnectionStatus): string {
const healthTitle = conn.connected ? t('mqtt_source.connected') : t('mqtt_source.disconnected');
const statusDot = `<span class="health-dot ${healthClass}" title="${escapeHtml(healthTitle)}" role="status"></span>`;
const subtitle = conn.connected ? escapeHtml(conn.broker) : t('mqtt_source.disconnected');
const short = (conn.source_id || '').replace(/^src_/, '').slice(0, 2).toUpperCase() || 'MQ';
const ledCls = conn.connected ? 'led on blink' : 'led';
const patchLabel = conn.connected ? 'ONLINE' : 'OFFLINE';
const patchLive = conn.connected ? ' is-live' : '';
return `<div class="dashboard-target dashboard-autostart dashboard-card-link" data-integration-id="${conn.source_id}" onclick="if(!event.target.closest('button')){navigateToCard('integrations','mqtt','mqtt-sources','data-id','${conn.source_id}')}">
<div class="dashboard-target-info">
<span class="dashboard-target-icon">${ICON_RADIO}</span>
<div>
<div class="dashboard-target-name"><span class="dashboard-target-name-text">${escapeHtml(conn.name)}</span>${statusDot}</div>
<div class="dashboard-target-subtitle">${escapeHtml(subtitle)}</div>
return `<div class="dashboard-target dashboard-autostart dashboard-card-link ${conn.connected ? 'is-running' : ''}" data-integration-id="${conn.source_id}" onclick="if(!event.target.closest('button')){navigateToCard('integrations','mqtt','mqtt-sources','data-id','${conn.source_id}')}">
<div class="mod-head">
<div class="mod-id">
<span class="mod-badge">MQTT · ${escapeHtml(short)}</span>
<div class="mod-name"><span>${escapeHtml(conn.name)}</span>${statusDot}</div>
<div class="mod-meta">${subtitle}</div>
</div>
<div class="mod-leds" aria-hidden="true">
<span class="${ledCls}"></span>
</div>
</div>
<div class="mod-foot">
<div class="mod-patch"><span class="patch-dot${patchLive}"></span><span>${patchLabel}</span></div>
</div>
</div>`;
}
function _updateIntegrationsInPlace(haStatus: HomeAssistantStatusResponse, mqttStatus?: MQTTStatusResponse): void {
// Update health dots and subtitles for each integration card
const applyState = (card: Element, connected: boolean, patchLabel: string): void => {
card.classList.toggle('is-running', connected);
const led = card.querySelector('.mod-leds .led');
if (led) {
led.className = connected ? 'led on blink' : 'led';
}
const patch = card.querySelector('.mod-patch');
if (patch) {
const dot = patch.querySelector('.patch-dot');
if (dot) dot.className = connected ? 'patch-dot is-live' : 'patch-dot';
const label = patch.querySelector('span:last-child');
if (label) label.textContent = patchLabel;
}
};
for (const conn of haStatus.connections) {
const card = document.querySelector(`[data-integration-id="${CSS.escape(conn.source_id)}"]`);
if (!card) continue;
@@ -307,14 +400,14 @@ function _updateIntegrationsInPlace(haStatus: HomeAssistantStatusResponse, mqttS
? `${t('ha_source.connected')}${conn.entity_count} ${t('dashboard.integrations.entities')}`
: t('ha_source.disconnected'));
}
const subtitle = card.querySelector('.dashboard-target-subtitle');
if (subtitle) {
subtitle.textContent = conn.connected
const meta = card.querySelector('.mod-meta');
if (meta) {
meta.textContent = conn.connected
? `${conn.entity_count} ${t('dashboard.integrations.entities')}`
: t('ha_source.disconnected');
}
applyState(card, conn.connected, conn.connected ? 'ONLINE' : 'OFFLINE');
}
// Update MQTT integration cards
if (mqttStatus) {
for (const conn of mqttStatus.connections) {
const card = document.querySelector(`[data-integration-id="${CSS.escape(conn.source_id)}"]`);
@@ -324,10 +417,11 @@ function _updateIntegrationsInPlace(haStatus: HomeAssistantStatusResponse, mqttS
dot.className = `health-dot ${conn.connected ? 'health-online' : 'health-offline'}`;
dot.setAttribute('title', conn.connected ? t('mqtt_source.connected') : t('mqtt_source.disconnected'));
}
const subtitle = card.querySelector('.dashboard-target-subtitle');
if (subtitle) {
subtitle.textContent = conn.connected ? conn.broker : t('mqtt_source.disconnected');
const meta = card.querySelector('.mod-meta');
if (meta) {
meta.textContent = conn.connected ? conn.broker : t('mqtt_source.disconnected');
}
applyState(card, conn.connected, conn.connected ? 'ONLINE' : 'OFFLINE');
}
}
// Update section count badge
@@ -345,41 +439,41 @@ function renderDashboardSyncClock(clock: SyncClock): string {
? `dashboardPauseClock('${clock.id}')`
: `dashboardResumeClock('${clock.id}')`;
const toggleTitle = clock.is_running ? t('sync_clock.action.pause') : t('sync_clock.action.resume');
const subtitle = [
`<span class="dashboard-clock-speed">${clock.speed}x</span>`,
const metaParts = [
`<span class="dashboard-clock-speed">${clock.speed}×</span>`,
clock.description ? escapeHtml(clock.description) : '',
].filter(Boolean).join(' · ');
].filter(Boolean);
const short = (clock.id || '').replace(/^sc_/, '').slice(0, 2).toUpperCase() || 'CK';
const ledCls = clock.is_running ? 'led on blink' : 'led';
const patchLabel = clock.is_running ? 'TICKING' : 'PAUSED';
const patchLive = clock.is_running ? ' is-live' : '';
const btnCls = clock.is_running ? 'mod-btn mod-btn-stop' : 'mod-btn mod-btn-go';
const btnLabel = clock.is_running ? (t('sync_clock.action.pause') || 'Pause') : (t('sync_clock.action.resume') || 'Resume');
const scStyle = cardColorStyle(clock.id);
return `<div class="dashboard-target dashboard-autostart dashboard-card-link" data-sync-clock-id="${clock.id}" onclick="if(!event.target.closest('button')){navigateToCard('streams','sync','sync-clocks','data-id','${clock.id}')}"${scStyle ? ` style="${scStyle}"` : ''}>
<div class="dashboard-target-info">
<span class="dashboard-target-icon">${ICON_CLOCK}</span>
<div>
<div class="dashboard-target-name">${escapeHtml(clock.name)}</div>
${subtitle ? `<div class="dashboard-target-subtitle">${subtitle}</div>` : ''}
return `<div class="dashboard-target dashboard-autostart dashboard-card-link ${clock.is_running ? 'is-running' : ''}" data-sync-clock-id="${clock.id}" onclick="if(!event.target.closest('button')){navigateToCard('streams','sync','sync-clocks','data-id','${clock.id}')}"${scStyle ? ` style="${scStyle}"` : ''}>
<div class="mod-head">
<div class="mod-id">
<span class="mod-badge">CLK · ${escapeHtml(short)}</span>
<div class="mod-name"><span>${escapeHtml(clock.name)}</span></div>
${metaParts.length ? `<div class="mod-meta">${metaParts.join(' · ')}</div>` : ''}
</div>
<div class="mod-leds" aria-hidden="true">
<span class="${ledCls}"></span>
</div>
</div>
<div class="dashboard-target-actions">
<button class="dashboard-action-btn ${clock.is_running ? 'stop' : 'start'}" onclick="${toggleAction}" title="${toggleTitle}">
${clock.is_running ? ICON_PAUSE : ICON_START}
</button>
<button class="dashboard-action-btn" onclick="dashboardResetClock('${clock.id}')" title="${t('sync_clock.action.reset')}">
${ICON_CLOCK}
</button>
<div class="mod-foot">
<div class="mod-patch"><span class="patch-dot${patchLive}"></span><span>${patchLabel}</span></div>
<button class="${btnCls}" onclick="event.stopPropagation(); ${toggleAction}" title="${toggleTitle}">${clock.is_running ? ICON_PAUSE : ICON_START} <span>${btnLabel}</span></button>
<button class="mod-btn" onclick="event.stopPropagation(); dashboardResetClock('${clock.id}')" title="${t('sync_clock.action.reset')}">${ICON_CLOCK}</button>
</div>
</div>`;
}
function _renderPollIntervalSelect(): string {
const sec = Math.round(dashboardPollInterval / 1000);
return `<span class="dashboard-poll-wrap"><input type="range" class="dashboard-poll-slider" min="1" max="10" value="${sec}" oninput="changeDashboardPollInterval(this.value)" title="${t('dashboard.poll_interval')}"><span class="dashboard-poll-value">${sec}s</span></span>`;
}
let _pollDebounce: ReturnType<typeof setTimeout> | undefined = undefined;
/** Called from the transport-bar poll cycler (and any legacy callers
* that might still reference `window.changeDashboardPollInterval`). */
export function changeDashboardPollInterval(value: string | number): void {
const label = document.querySelector('.dashboard-poll-value');
if (label) label.textContent = `${value}s`;
clearTimeout(_pollDebounce);
_pollDebounce = setTimeout(() => {
const ms = parseInt(String(value), 10) * 1000;
@@ -391,8 +485,22 @@ export function changeDashboardPollInterval(value: string | number): void {
}
function _getCollapsedSections(): Record<string, boolean> {
try { return JSON.parse(localStorage.getItem(DASHBOARD_COLLAPSED_KEY) ?? '{}') || {}; }
catch { return {}; }
let userOverrides: Record<string, boolean> = {};
try { userOverrides = JSON.parse(localStorage.getItem(DASHBOARD_COLLAPSED_KEY) ?? '{}') || {}; }
catch { /* ignore */ }
// Layered: layout's `collapsedDefault` is the floor; the user's
// per-session toggle overrides it. Lets users start every section
// collapsed via Customize without losing in-session expand/collapse.
const merged: Record<string, boolean> = {};
for (const s of getOrderedSections()) {
merged[s.key] = userOverrides[s.key] ?? s.collapsedDefault;
}
// Subsections like 'running' / 'stopped' aren't in the layout — preserve
// user overrides as-is.
for (const k of Object.keys(userOverrides)) {
if (!(k in merged)) merged[k] = userOverrides[k];
}
return merged;
}
export function toggleDashboardSection(sectionKey: string): void {
@@ -439,11 +547,17 @@ function _sectionHeader(sectionKey: string, label: string, count: number | strin
const collapsed = _getCollapsedSections();
const isCollapsed = !!collapsed[sectionKey];
const chevronStyle = isCollapsed ? '' : ' style="transform:rotate(90deg)"';
// Only render the count pill when there's an actual count to show.
// The Performance header passes '' (no item count makes sense here)
// and was rendering an empty grey badge next to the title.
const countHtml = (count !== '' && count != null)
? `<span class="dashboard-section-count">${count}</span>`
: '';
return `<div class="dashboard-section-header" data-dashboard-section="${sectionKey}">
<span class="dashboard-section-toggle" onclick="toggleDashboardSection('${sectionKey}')">
<span class="dashboard-section-chevron"${chevronStyle}>&#9654;</span>
${label}
<span class="dashboard-section-count">${count}</span>
${countHtml}
</span>
${extraHtml}
</div>`;
@@ -464,7 +578,7 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
try {
// Fire all requests in a single batch to avoid sequential RTTs
const [targets, automationsResp, devicesArr, cssArr, batchStatesResp, batchMetricsResp, scenePresets, syncClocksResp, haStatusResp, mqttStatusResp] = await Promise.all([
const [targets, automationsResp, devicesArr, cssArr, batchStatesResp, batchMetricsResp, scenePresets, syncClocksResp, haStatusResp, mqttStatusResp, deviceStatesResp] = await Promise.all([
outputTargetsCache.fetch().catch((): any[] => []),
fetchWithAuth('/automations').catch(() => null),
devicesCache.fetch().catch((): any[] => []),
@@ -475,8 +589,21 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
fetchWithAuth('/sync-clocks').catch(() => null),
fetchWithAuth('/home-assistant/status').catch(() => null),
fetchWithAuth('/mqtt/status').catch(() => null),
fetchWithAuth('/devices/batch/states').catch(() => null),
]);
// Devices cell — online/offline count + dot strip. Independent of
// the running-target set: shows every configured device regardless
// of whether any target is currently streaming to it.
if (deviceStatesResp && deviceStatesResp.ok) {
try {
const payload = await deviceStatesResp.json();
const statesObj = payload.states || {};
const deviceStateList = Object.values(statesObj) as any[];
updateDevices(deviceStateList);
} catch { /* ignore parse errors */ }
}
const automationsData = automationsResp && automationsResp.ok ? await automationsResp.json() : { automations: [] };
const automations = automationsData.automations || [];
const devicesMap = {};
@@ -510,6 +637,39 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
const running = enriched.filter(t => t.state && t.state.processing);
const stopped = enriched.filter(t => !t.state || !t.state.processing);
updateTabBadge('targets', running.length);
_updateTransportStatus(running.length);
updateActivePatches(
running.map(r => ({
id: r.id,
name: r.name,
fps: r.state?.fps_actual != null ? r.state.fps_actual
: r.state?.fps_current != null ? r.state.fps_current
: undefined,
})),
enriched.length,
);
// Aggregate throughput across all running targets — fills the
// Total FPS cell in the perf strip. `fpsTargetSum` is drawn as
// a dashed reference line ("max achievable throughput").
const fpsValues: number[] = [];
let fpsSum = 0;
let fpsTargetSum = 0;
for (const r of running) {
const fps = r.state?.fps_actual != null ? r.state.fps_actual
: r.state?.fps_current != null ? r.state.fps_current
: null;
if (fps != null) {
fpsValues.push(fps);
fpsSum += fps;
}
const tgt = r.state?.fps_target
?? (r.settings || {}).fps
?? r.update_rate;
if (typeof tgt === 'number' && tgt > 0) fpsTargetSum += tgt;
}
const fpsMin = fpsValues.length > 0 ? Math.min(...fpsValues) : null;
const fpsMax = fpsValues.length > 0 ? Math.max(...fpsValues) : null;
updateTotalFps(fpsSum, fpsMin, fpsMax, fpsTargetSum);
// Check if we can do an in-place metrics update (same targets, not first load)
const newRunningIds = running.map(t => t.id).sort().join(',');
@@ -539,6 +699,14 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
return;
}
// Build each section's HTML into a map so we can render in
// user-defined order (layout-driven). Sections with no content
// (e.g. `automations` when there are zero automations) produce
// null and are skipped, unless the user explicitly toggled
// them to show via Customize (we don't yet plumb a "show
// empty CTA" mode here; that's a v1.1 follow-up).
const sectionFragments: Record<string, string> = {};
// Integrations section (HA + MQTT sources)
const totalIntSources = haStatus.total_sources + mqttStatus.total_sources;
const totalIntConnected = haStatus.connected_count + mqttStatus.connected_count;
@@ -546,7 +714,7 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
const haCards = haStatus.connections.map(c => _renderIntegrationCard(c)).join('');
const mqttCards = mqttStatus.connections.map(c => _renderMQTTIntegrationCard(c)).join('');
const intGrid = `<div class="dashboard-integrations-grid">${haCards}${mqttCards}</div>`;
dynamicHtml += `<div class="dashboard-section">
sectionFragments['integrations'] = `<div class="dashboard-section" data-section="integrations">
${_sectionHeader('integrations', t('dashboard.section.integrations'), `${totalIntConnected}/${totalIntSources}`)}
${_sectionContent('integrations', intGrid)}
</div>`;
@@ -558,10 +726,11 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
updateTabBadge('automations', activeAutomations.length);
const sceneMap = new Map(scenePresets.map(s => [s.id, s]));
const automationItems = [...activeAutomations, ...inactiveAutomations].map(a => renderDashboardAutomation(a, sceneMap)).join('');
const automationGrid = `<div class="dashboard-autostart-grid">${automationItems}</div>`;
dynamicHtml += `<div class="dashboard-section">
sectionFragments['automations'] = `<div class="dashboard-section" data-section="automations">
${_sectionHeader('automations', t('dashboard.section.automations'), automations.length)}
${_sectionContent('automations', automationItems)}
${_sectionContent('automations', automationGrid)}
</div>`;
}
@@ -569,7 +738,7 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
if (scenePresets.length > 0) {
const sceneSec = renderScenePresetsSection(scenePresets);
if (sceneSec && typeof sceneSec === 'object') {
dynamicHtml += `<div class="dashboard-section">
sectionFragments['scenes'] = `<div class="dashboard-section" data-section="scenes">
${_sectionHeader('scenes', t('dashboard.section.scenes'), scenePresets.length, sceneSec.headerExtra)}
${_sectionContent('scenes', sceneSec.content)}
</div>`;
@@ -580,7 +749,7 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
if (syncClocks.length > 0) {
const clockCards = syncClocks.map(c => renderDashboardSyncClock(c)).join('');
const clockGrid = `<div class="dashboard-autostart-grid">${clockCards}</div>`;
dynamicHtml += `<div class="dashboard-section">
sectionFragments['sync-clocks'] = `<div class="dashboard-section" data-section="sync-clocks">
${_sectionHeader('sync-clocks', t('dashboard.section.sync_clocks'), syncClocks.length)}
${_sectionContent('sync-clocks', clockGrid)}
</div>`;
@@ -609,31 +778,62 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
</div>`;
}
dynamicHtml += `<div class="dashboard-section">
sectionFragments['targets'] = `<div class="dashboard-section" data-section="targets">
${_sectionHeader('targets', t('dashboard.section.targets'), targets.length)}
${_sectionContent('targets', targetsInner)}
</div>`;
}
// Now assemble in layout-driven order, skipping invisible
// sections and the perf section (which is always rendered
// separately at the top for chart-persistence reasons).
for (const section of getOrderedSections()) {
if (section.key === 'perf') continue;
if (!section.visible) continue;
const html = sectionFragments[section.key];
if (html) dynamicHtml += html;
}
}
// First load: build everything in one innerHTML to avoid flicker
// First load: build everything in one innerHTML to avoid flicker.
// Poll-interval control was moved to the transport bar (it's global,
// not dashboard-specific) — toolbar now keeps the tutorial help
// button + the new "Customize" gear that opens the layout panel.
const isFirstLoad = !container.querySelector('.dashboard-perf-persistent');
const pollSelect = _renderPollIntervalSelect();
const toolbar = `<div class="stream-tab-bar"><span class="cs-expand-collapse-group">${pollSelect}<button class="tutorial-trigger-btn" onclick="startDashboardTutorial()" title="${t('tour.restart')}">${ICON_HELP}</button></span></div>`;
const perfVisible = isSectionVisible('perf');
const customizeBtn = `<button class="tutorial-trigger-btn" onclick="openDashboardCustomize()" title="${t('dashboard.customize.title')}" aria-label="${t('dashboard.customize.title')}">${ICON_SETTINGS}</button>`;
const tutorialBtn = `<button class="tutorial-trigger-btn" onclick="startDashboardTutorial()" title="${t('tour.restart')}">${ICON_HELP}</button>`;
const toolbar = `<div class="stream-tab-bar"><span class="cs-expand-collapse-group">${customizeBtn}${tutorialBtn}</span></div>`;
if (isFirstLoad) {
container.innerHTML = `${toolbar}<div class="dashboard-perf-persistent dashboard-section">
${_sectionHeader('perf', t('dashboard.section.performance'), '', renderPerfModeToggle())}
${_sectionContent('perf', renderPerfSection())}
</div>
<div class="dashboard-dynamic">${dynamicHtml}</div>`;
await initPerfCharts();
const perfBlock = perfVisible
? `<div class="dashboard-perf-persistent dashboard-section" data-section="perf">
${_sectionHeader('perf', t('dashboard.section.performance'), '', renderPerfModeToggle())}
${_sectionContent('perf', renderPerfSection())}
</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>
<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>
<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-metric" title="${t('dashboard.uptime')}">
<div class="dashboard-metric-value" data-uptime-text="${target.id}">${ICON_CLOCK} ${uptime}</div>
<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="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 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-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>
<div class="mod-leds" aria-hidden="true">
<span class="${ledCls}"></span>
</div>
${statusBadge}
</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 class="mod-foot">
<div class="mod-patch"><span class="patch-dot${patchLive}"></span><span>${patchLabel}</span></div>
<button class="${btnCls}" onclick="event.stopPropagation(); dashboardToggleAutomation('${automation.id}', ${!automation.enabled})" title="${automation.enabled ? t('automations.action.disable') : t('automations.status.active')}">${automation.enabled ? ICON_STOP_PLAIN : ICON_START} <span>${btnLabel}</span></button>
</div>
</div>`;
}
@@ -944,6 +1176,46 @@ document.addEventListener('languageChanged', () => {
loadDashboard();
});
// Live-preview: re-render the dashboard whenever the customize panel
// changes the saved layout. Uses a debounce so dragging or rapid
// toggling doesn't thrash the DOM. The perf strip is preserved across
// re-renders (DOM persistence), so toggling its visibility is the
// only re-init path that needs `forceFullRender`.
let _layoutChangeRenderTimer: ReturnType<typeof setTimeout> | undefined;
subscribeDashboardLayout(() => {
if (!apiKey) return;
if (!_isDashboardActive()) return;
clearTimeout(_layoutChangeRenderTimer);
_layoutChangeRenderTimer = setTimeout(() => {
// Invalidate the in-place-update optimization in `loadDashboard`
// — section HTML must be rebuilt when sections reorder, change
// density, or toggle visibility. Without this reset the
// optimization would skip the rebuild entirely when the running-
// target set hasn't changed.
_lastRunningIds = [];
_lastSyncClockIds = '';
const perfInDom = !!document.querySelector('.dashboard-perf-persistent');
const perfShouldBe = isSectionVisible('perf');
if (perfShouldBe !== perfInDom) {
// Visibility flipped — full rebuild needed (charts re-init from
// server ring buffer + immediate fetch in `initPerfCharts`).
const container = document.getElementById('dashboard-content');
if (container) container.innerHTML = '';
} else if (perfShouldBe) {
// Perf still visible: in-place re-render of just the
// `.perf-charts-grid` so cell visibility / order / mode /
// window / yScale changes paint immediately without the
// full-dashboard innerHTML wipe (which previously caused a
// frame of jump and a window of "—" / "0" values).
rerenderPerfGrid();
}
loadDashboard(true);
}, 60);
});
// Pause uptime timer when browser tab is hidden, resume when visible
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
@@ -1140,9 +1140,9 @@ function _graphHTML(): string {
<svg class="graph-svg" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="running-gradient" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="var(--primary-color)"/>
<stop offset="50%" stop-color="var(--success-color)"/>
<stop offset="100%" stop-color="var(--primary-color)"/>
<stop offset="0%" stop-color="var(--ch-signal, var(--primary-color))"/>
<stop offset="50%" stop-color="var(--ch-cyan, var(--info-color))"/>
<stop offset="100%" stop-color="var(--ch-signal, var(--primary-color))"/>
</linearGradient>
</defs>
<rect class="graph-bg" width="100%" height="100%" fill="transparent"/>
File diff suppressed because it is too large Load Diff
@@ -129,23 +129,28 @@ export function renderScenePresetsSection(presets: ScenePreset[]): string | { he
function _renderDashboardPresetCard(preset: ScenePreset): string {
const targetCount = (preset.targets || []).length;
const subtitle = [
const metaParts = [
targetCount > 0 ? `${targetCount} ${t('scenes.targets_count')}` : null,
].filter(Boolean).join(' \u00b7 ');
preset.description ? escapeHtml(preset.description) : null,
].filter(Boolean);
const short = (preset.id || '').replace(/^scn_/, '').slice(0, 2).toUpperCase() || 'SC';
const activateLabel = t('scenes.activate') || 'Activate';
const pStyle = cardColorStyle(preset.id);
return `<div class="dashboard-target dashboard-scene-preset dashboard-card-link" data-scene-id="${preset.id}" data-action="navigate-scene" data-id="${preset.id}"${pStyle ? ` style="${pStyle}"` : ''}>
<div class="dashboard-target-info">
<span class="dashboard-target-icon">${ICON_SCENE}</span>
<div>
<div class="dashboard-target-name">${escapeHtml(preset.name)}</div>
${preset.description ? `<div class="dashboard-target-subtitle">${escapeHtml(preset.description)}</div>` : ''}
<div class="dashboard-target-subtitle">${subtitle}</div>
<div class="mod-head">
<div class="mod-id">
<span class="mod-badge">SCN \u00b7 ${escapeHtml(short)}</span>
<div class="mod-name"><span>${escapeHtml(preset.name)}</span></div>
${metaParts.length ? `<div class="mod-meta">${metaParts.join(' \u00b7 ')}</div>` : ''}
</div>
<div class="mod-leds" aria-hidden="true">
<span class="led"></span>
</div>
</div>
<div class="dashboard-target-actions">
<button class="dashboard-action-btn start" data-action="activate-scene" data-id="${preset.id}" title="${t('scenes.activate')}">${ICON_START}</button>
<div class="mod-foot">
<div class="mod-patch"><span class="patch-dot"></span><span>PRESET</span></div>
<button class="mod-btn mod-btn-go" data-action="activate-scene" data-id="${preset.id}" title="${activateLabel}" onclick="event.stopPropagation();">${ICON_START} <span>${activateLabel}</span></button>
</div>
</div>`;
}
@@ -7,7 +7,7 @@ import { API_BASE, fetchWithAuth } from '../core/api.ts';
import { Modal } from '../core/modal.ts';
import { showToast, showConfirm } from '../core/ui.ts';
import { t } from '../core/i18n.ts';
import { ICON_UNDO, ICON_DOWNLOAD } from '../core/icons.ts';
import { ICON_UNDO, ICON_DOWNLOAD, ICON_SQUARE, ICON_CIRCLE } from '../core/icons.ts';
import { IconSelect } from '../core/icon-select.ts';
import { openAuthedWs } from '../core/ws-auth.ts';
@@ -66,6 +66,8 @@ export async function saveExternalUrl(): Promise<void> {
// ─── Settings-modal tab switching ───────────────────────────
const SETTINGS_ACTIVE_TAB_KEY = 'settings_active_tab';
export function switchSettingsTab(tabId: string): void {
document.querySelectorAll('.settings-tab-btn').forEach(btn => {
btn.classList.toggle('active', (btn as HTMLElement).dataset.settingsTab === tabId);
@@ -73,6 +75,8 @@ export function switchSettingsTab(tabId: string): void {
document.querySelectorAll('.settings-panel').forEach(panel => {
panel.classList.toggle('active', panel.id === `settings-panel-${tabId}`);
});
// Remember so the next openSettingsModal() re-opens this tab.
try { localStorage.setItem(SETTINGS_ACTIVE_TAB_KEY, tabId); } catch { /* storage blocked */ }
// Lazy-render the appearance tab content
if (tabId === 'appearance' && typeof window.renderAppearanceTab === 'function') {
window.renderAppearanceTab();
@@ -256,6 +260,13 @@ const settingsModal = new Modal('settings-modal');
let _logLevelIconSelect: IconSelect | null = null;
let _autoBackupIntervalIconSelect: IconSelect | null = null;
let _shutdownActionIconSelect: IconSelect | null = null;
type ShutdownAction = 'stop_targets' | 'nothing';
const _SHUTDOWN_ACTIONS: readonly ShutdownAction[] = ['stop_targets', 'nothing'] as const;
function _isShutdownAction(v: string): v is ShutdownAction {
return (_SHUTDOWN_ACTIONS as readonly string[]).includes(v);
}
/** Build interval items (hour-tiles) for auto-backup and update check pickers.
* Labels match the existing native-<option> text verbatim so no new i18n keys are needed.
@@ -271,6 +282,24 @@ export function _getHourIntervalItems(): { value: string; icon: string; label: s
];
}
/** Build shutdown-action items lazily so t() has locale data loaded. */
function _getShutdownActionItems(): { value: string; icon: string; label: string; desc: string }[] {
return [
{
value: 'stop_targets',
icon: ICON_SQUARE,
label: t('settings.shutdown_action.opt.stop'),
desc: t('settings.shutdown_action.opt.stop_desc'),
},
{
value: 'nothing',
icon: ICON_CIRCLE,
label: t('settings.shutdown_action.opt.nothing'),
desc: t('settings.shutdown_action.opt.nothing_desc'),
},
];
}
/** Build log-level items lazily so t() has locale data loaded. */
function _getLogLevelItems(): { value: string; icon: string; label: string; desc: string }[] {
return [
@@ -285,8 +314,14 @@ function _getLogLevelItems(): { value: string; icon: string; label: string; desc
export function openSettingsModal(): void {
(document.getElementById('settings-error') as HTMLElement).style.display = 'none';
// Reset to first tab
switchSettingsTab('general');
// Restore last-opened tab (from localStorage) if the tab still exists;
// fall back to 'general' otherwise. Callers that want a specific tab
// (e.g. donation link → about, update badge → updates) call
// switchSettingsTab() themselves *after* opening.
let saved = 'general';
try { saved = localStorage.getItem(SETTINGS_ACTIVE_TAB_KEY) || 'general'; } catch { /* ignore */ }
if (!document.getElementById(`settings-panel-${saved}`)) saved = 'general';
switchSettingsTab(saved);
settingsModal.open();
@@ -315,11 +350,25 @@ export function openSettingsModal(): void {
}
}
// Initialize shutdown-action icon select
if (!_shutdownActionIconSelect) {
const sel = document.getElementById('settings-shutdown-action') as HTMLSelectElement | null;
if (sel) {
_shutdownActionIconSelect = new IconSelect({
target: sel,
items: _getShutdownActionItems(),
columns: 2,
onChange: () => setShutdownAction(),
});
}
}
loadApiKeysList();
loadExternalUrl();
loadAutoBackupSettings();
loadBackupList();
loadLogLevel();
loadShutdownAction();
}
export function closeSettingsModal(): void {
@@ -659,3 +708,43 @@ export async function setLogLevel(): Promise<void> {
}
}
// ─── Shutdown action ──────────────────────────────────────────
export async function loadShutdownAction(): Promise<void> {
try {
const resp = await fetchWithAuth('/system/shutdown-action');
if (!resp.ok) return;
const data = await resp.json();
const action: ShutdownAction = _isShutdownAction(data.action) ? data.action : 'stop_targets';
if (_shutdownActionIconSelect) {
_shutdownActionIconSelect.setValue(action);
} else {
const select = document.getElementById('settings-shutdown-action') as HTMLSelectElement | null;
if (select) select.value = action;
}
} catch (err) {
console.error('Failed to load shutdown action:', err);
}
}
export async function setShutdownAction(): Promise<void> {
const select = document.getElementById('settings-shutdown-action') as HTMLSelectElement | null;
if (!select) return;
const value = select.value;
if (!_isShutdownAction(value)) return;
try {
const resp = await fetchWithAuth('/system/shutdown-action', {
method: 'PUT',
body: JSON.stringify({ action: value }),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.detail || `HTTP ${resp.status}`);
}
showToast(t('settings.shutdown_action.saved'), 'success');
} catch (err) {
console.error('Failed to set shutdown action:', err);
showToast(t('settings.shutdown_action.save_error') + ': ' + err.message, 'error');
}
}
@@ -55,7 +55,9 @@ export function switchTab(name: string, { updateHash = true, skipLoad = false }:
// Use window.* to avoid circular imports with feature modules
if (!skipLoad && isAuthed) callTabLoader(name);
} else {
if (typeof window.stopPerfPolling === 'function') window.stopPerfPolling();
// Perf poll keeps running across all tabs so the transport-bar
// Uptime / CPU / Mem cells stay live. Only stopped on auth loss
// or when the tab is hidden (visibilitychange handler).
if (typeof window.stopUptimeTimer === 'function') window.stopUptimeTimer();
// Clean up WebSockets when leaving targets tab
if (name !== 'targets') {
+10
View File
@@ -22,6 +22,14 @@ interface Window {
setApiKey: (key: string | null) => void;
_authRequired: boolean | undefined;
// ─── Transport bar ───
/** Server-process uptime seed for the transport-bar ticker. Set by
* api.ts on every /health response; read by the inline ticker in
* index.html. ``recordedAtPerf`` is a ``performance.now()`` reading,
* not Date.now(), so the extrapolation is immune to wall-clock jumps
* (NTP step, DST). */
__serverUptime: { uptimeSec: number; recordedAtPerf: number } | undefined;
// ─── Visual effects (called from inline <script>) ───
_updateBgAnimAccent: (accent: string) => void;
_updateBgAnimTheme: (dark: boolean) => void;
@@ -397,6 +405,8 @@ startTargetOverlay: (...args: any[]) => any;
closeLogOverlay: (...args: any[]) => any;
loadLogLevel: (...args: any[]) => any;
setLogLevel: (...args: any[]) => any;
loadShutdownAction: (...args: any[]) => any;
setShutdownAction: (...args: any[]) => any;
saveExternalUrl: (...args: any[]) => any;
getBaseOrigin: (...args: any[]) => any;
+74 -3
View File
@@ -500,7 +500,7 @@
"tags.placeholder": "Add tag...",
"section.expand_all": "Expand all sections",
"section.collapse_all": "Collapse all sections",
"streams.title": "Sources",
"streams.title": "Inputs",
"integrations.title": "Integrations",
"streams.description": "Sources define the capture pipeline. A raw source captures from a display using a capture template. A processed source applies postprocessing to another source. Assign sources to devices.",
"streams.group.raw": "Sources",
@@ -672,7 +672,7 @@
"streams.video_asset": "Video Asset:",
"streams.video_asset.select": "Select video asset…",
"streams.video_asset.search": "Search video assets…",
"targets.title": "Targets",
"targets.title": "Channels",
"targets.description": "Targets bridge color strip sources to output devices. Each target references a device and a color strip source.",
"targets.subtab.wled": "LED",
"targets.subtab.led": "LED",
@@ -767,8 +767,18 @@
"overlay.stopped": "Overlay visualization stopped",
"overlay.error.start": "Failed to start overlay",
"overlay.error.stop": "Failed to stop overlay",
"sidebar.workspaces": "Workspaces",
"sidebar.load": "Load",
"sidebar.fps": "FPS",
"transport.status.ready": "Ready",
"transport.status.armed": "Armed · {n} live",
"transport.meta.uptime": "Uptime",
"transport.meta.cpu": "CPU",
"transport.meta.mem": "Mem",
"transport.meta.poll": "Poll",
"transport.meta.poll_hint": "Poll interval (click to cycle: 1s → 2s → 5s → 10s)",
"dashboard.title": "Dashboard",
"dashboard.section.targets": "Targets",
"dashboard.section.targets": "Channels",
"dashboard.section.running": "Running",
"dashboard.section.stopped": "Stopped",
"dashboard.no_targets": "No targets configured",
@@ -786,16 +796,69 @@
"dashboard.section.integrations": "Integrations",
"dashboard.integrations.entities": "entities",
"dashboard.integrations.no_sources": "No integration sources configured",
"dashboard.perf.active_patches": "Active Patches",
"dashboard.perf.total_fps": "Total FPS",
"dashboard.perf.devices": "Devices",
"dashboard.perf.cpu": "CPU",
"dashboard.perf.ram": "RAM",
"dashboard.perf.gpu": "GPU",
"dashboard.perf.temp": "Temperature",
"dashboard.perf.temp.install_lhm": "Windows has no built-in CPU temperature API. Install LibreHardwareMonitor and enable \"Publish to WMI\" to see live readings here.",
"dashboard.perf.unavailable": "unavailable",
"dashboard.perf.color": "Chart color",
"dashboard.perf.mode.system": "System",
"dashboard.perf.mode.app": "App",
"dashboard.perf.mode.both": "Both",
"dashboard.poll_interval": "Refresh interval",
"dashboard.customize.title": "Customize Dashboard",
"dashboard.customize.presets": "Presets",
"dashboard.customize.preset.studio": "Studio",
"dashboard.customize.preset.operator": "Operator",
"dashboard.customize.preset.showrunner": "Showrunner",
"dashboard.customize.preset.diagnostics": "Diagnostics",
"dashboard.customize.preset.tv": "TV",
"dashboard.customize.modified": "Modified",
"dashboard.customize.global": "Global",
"dashboard.customize.width": "Width",
"dashboard.customize.width.full": "Full",
"dashboard.customize.width.centered": "Centered",
"dashboard.customize.width.narrow": "Narrow",
"dashboard.customize.anim": "Animations",
"dashboard.customize.anim.full": "Full",
"dashboard.customize.anim.reduced": "Reduced",
"dashboard.customize.anim.off": "Off",
"dashboard.customize.perf_mode": "Perf mode",
"dashboard.customize.sections": "Sections",
"dashboard.customize.perf_cells": "Performance Strip",
"dashboard.customize.fixed_top": "Pinned to top",
"dashboard.customize.drag_help": "Drag rows to reorder, or use the ↑/↓ buttons.",
"dashboard.customize.cell_drag_help": "Drag a row to change cell order in the Performance strip on the dashboard.",
"dashboard.customize.window": "Sample window",
"dashboard.customize.scale": "Y-axis scale",
"dashboard.customize.mode_short": "MODE",
"dashboard.customize.window_short": "WIN",
"dashboard.customize.scale_short": "SCL",
"dashboard.customize.density.comfortable": "Comfortable",
"dashboard.customize.density.compact": "Compact",
"dashboard.customize.density.dense": "Dense",
"dashboard.customize.collapse_default.on": "Start collapsed",
"dashboard.customize.collapse_default.off": "Start expanded",
"dashboard.customize.show": "Show",
"dashboard.customize.hide": "Hide",
"dashboard.customize.mode.inherit": "Inherit",
"dashboard.customize.mode.system": "Sys",
"dashboard.customize.mode.app": "App",
"dashboard.customize.mode.both": "Both",
"dashboard.customize.yscale.auto": "Auto",
"dashboard.customize.yscale.fixed": "Fixed",
"dashboard.customize.yscale.log": "Log",
"dashboard.customize.export": "Export",
"dashboard.customize.import": "Import",
"dashboard.customize.reset": "Reset",
"dashboard.customize.reset_confirm": "Reset dashboard layout to the Studio preset?",
"dashboard.customize.exported": "Layout exported",
"dashboard.customize.imported": "Layout imported",
"dashboard.customize.import_failed": "Failed to import layout",
"automations.title": "Automations",
"automations.empty": "No automations configured. Create one to automate scene activation.",
"automations.add": "Add Automation",
@@ -1725,6 +1788,14 @@
"settings.log_level.desc.warning": "Potential problems",
"settings.log_level.desc.error": "Failures only",
"settings.log_level.desc.critical": "Fatal errors only",
"settings.shutdown_action.label": "Shutdown action",
"settings.shutdown_action.hint": "What happens to LED targets when the server shuts down. \"Stop targets\" runs the normal stop sequence so devices with auto-restore restore their prior state. \"Nothing\" leaves the lights showing the last frame.",
"settings.shutdown_action.saved": "Shutdown action saved",
"settings.shutdown_action.save_error": "Failed to save shutdown action",
"settings.shutdown_action.opt.stop": "Stop targets",
"settings.shutdown_action.opt.stop_desc": "Run the normal stop sequence (per-device auto-restore applies)",
"settings.shutdown_action.opt.nothing": "Nothing",
"settings.shutdown_action.opt.nothing_desc": "Leave lights showing the last frame",
"settings.auto_backup.label": "Auto-Backup",
"settings.auto_backup.hint": "Automatically create periodic backups of all configuration. Old backups are pruned when the maximum count is reached.",
"settings.auto_backup.enable": "Enable auto-backup",
+74 -3
View File
@@ -502,7 +502,7 @@
"tags.placeholder": "Добавить тег...",
"section.expand_all": "Развернуть все секции",
"section.collapse_all": "Свернуть все секции",
"streams.title": "Источники",
"streams.title": "Входы",
"integrations.title": "Интеграции",
"streams.description": "Источники определяют конвейер захвата. Сырой источник захватывает экран с помощью шаблона захвата. Обработанный источник применяет постобработку к другому источнику. Назначайте источники устройствам.",
"streams.group.raw": "Источники",
@@ -656,7 +656,7 @@
"streams.video_asset": "Видео:",
"streams.video_asset.select": "Выберите видео…",
"streams.video_asset.search": "Поиск видео…",
"targets.title": "Цели",
"targets.title": "Каналы",
"targets.description": "Цели связывают источники цветовых полос с устройствами вывода. Каждая цель ссылается на устройство и источник цветовой полосы.",
"targets.subtab.wled": "LED",
"targets.subtab.led": "LED",
@@ -751,8 +751,18 @@
"overlay.stopped": "Визуализация наложения остановлена",
"overlay.error.start": "Не удалось запустить наложение",
"overlay.error.stop": "Не удалось остановить наложение",
"sidebar.workspaces": "Разделы",
"sidebar.load": "Нагр.",
"sidebar.fps": "FPS",
"transport.status.ready": "Готов",
"transport.status.armed": "Активно · {n}",
"transport.meta.uptime": "Время",
"transport.meta.cpu": "CPU",
"transport.meta.mem": "Память",
"transport.meta.poll": "Опрос",
"transport.meta.poll_hint": "Интервал опроса (клик: 1с → 2с → 5с → 10с)",
"dashboard.title": "Обзор",
"dashboard.section.targets": "Цели",
"dashboard.section.targets": "Каналы",
"dashboard.section.running": "Запущенные",
"dashboard.section.stopped": "Остановленные",
"dashboard.no_targets": "Нет настроенных целей",
@@ -767,16 +777,69 @@
"dashboard.section.sync_clocks": "Синхронные часы",
"dashboard.targets": "Цели",
"dashboard.section.performance": "Производительность системы",
"dashboard.perf.active_patches": "Активные каналы",
"dashboard.perf.total_fps": "Общий FPS",
"dashboard.perf.devices": "Устройства",
"dashboard.perf.cpu": "ЦП",
"dashboard.perf.ram": "ОЗУ",
"dashboard.perf.gpu": "ГП",
"dashboard.perf.temp": "Температура",
"dashboard.perf.temp.install_lhm": "В Windows нет встроенного API для температуры CPU. Установите LibreHardwareMonitor и включите «Publish to WMI», чтобы видеть живые показания.",
"dashboard.perf.unavailable": "недоступно",
"dashboard.perf.color": "Цвет графика",
"dashboard.perf.mode.system": "Система",
"dashboard.perf.mode.app": "Приложение",
"dashboard.perf.mode.both": "Оба",
"dashboard.poll_interval": "Интервал обновления",
"dashboard.customize.title": "Настройка панели",
"dashboard.customize.presets": "Пресеты",
"dashboard.customize.preset.studio": "Студия",
"dashboard.customize.preset.operator": "Оператор",
"dashboard.customize.preset.showrunner": "Шоу",
"dashboard.customize.preset.diagnostics": "Диагностика",
"dashboard.customize.preset.tv": "ТВ",
"dashboard.customize.modified": "Изменено",
"dashboard.customize.global": "Общие",
"dashboard.customize.width": "Ширина",
"dashboard.customize.width.full": "Полная",
"dashboard.customize.width.centered": "По центру",
"dashboard.customize.width.narrow": "Узкая",
"dashboard.customize.anim": "Анимации",
"dashboard.customize.anim.full": "Полные",
"dashboard.customize.anim.reduced": "Снижены",
"dashboard.customize.anim.off": "Выкл",
"dashboard.customize.perf_mode": "Режим перф.",
"dashboard.customize.sections": "Секции",
"dashboard.customize.perf_cells": "Системный мониторинг",
"dashboard.customize.fixed_top": "Закреплено сверху",
"dashboard.customize.drag_help": "Перетащите строки или используйте ↑/↓.",
"dashboard.customize.cell_drag_help": "Перетащите строку, чтобы изменить порядок ячеек в полосе производительности.",
"dashboard.customize.window": "Окно выборки",
"dashboard.customize.scale": "Шкала Y",
"dashboard.customize.mode_short": "РЕЖ",
"dashboard.customize.window_short": "ОКН",
"dashboard.customize.scale_short": "ШКЛ",
"dashboard.customize.density.comfortable": "Просторно",
"dashboard.customize.density.compact": "Компактно",
"dashboard.customize.density.dense": "Плотно",
"dashboard.customize.collapse_default.on": "Свёрнуто по умолчанию",
"dashboard.customize.collapse_default.off": "Развёрнуто по умолчанию",
"dashboard.customize.show": "Показать",
"dashboard.customize.hide": "Скрыть",
"dashboard.customize.mode.inherit": "Наслед.",
"dashboard.customize.mode.system": "Сис",
"dashboard.customize.mode.app": "Прил",
"dashboard.customize.mode.both": "Оба",
"dashboard.customize.yscale.auto": "Авто",
"dashboard.customize.yscale.fixed": "Фикс.",
"dashboard.customize.yscale.log": "Лог.",
"dashboard.customize.export": "Экспорт",
"dashboard.customize.import": "Импорт",
"dashboard.customize.reset": "Сбросить",
"dashboard.customize.reset_confirm": "Сбросить настройки панели к пресету «Студия»?",
"dashboard.customize.exported": "Настройки экспортированы",
"dashboard.customize.imported": "Настройки импортированы",
"dashboard.customize.import_failed": "Не удалось импортировать настройки",
"automations.title": "Автоматизации",
"automations.empty": "Автоматизации не настроены. Создайте автоматизацию для автоматической активации сцен.",
"automations.add": "Добавить автоматизацию",
@@ -1541,6 +1604,14 @@
"settings.log_level.desc.warning": "Возможные проблемы",
"settings.log_level.desc.error": "Только ошибки",
"settings.log_level.desc.critical": "Только критические ошибки",
"settings.shutdown_action.label": "Действие при выключении",
"settings.shutdown_action.hint": "Что происходит с LED-целями при остановке сервера. «Остановить цели» — обычная последовательность остановки, устройства с авто-восстановлением восстановят прежнее состояние. «Ничего» — оставить свет таким, каким он был на последнем кадре.",
"settings.shutdown_action.saved": "Действие при выключении сохранено",
"settings.shutdown_action.save_error": "Не удалось сохранить действие при выключении",
"settings.shutdown_action.opt.stop": "Остановить цели",
"settings.shutdown_action.opt.stop_desc": "Обычная остановка (учитывается авто-восстановление устройств)",
"settings.shutdown_action.opt.nothing": "Ничего",
"settings.shutdown_action.opt.nothing_desc": "Оставить свет на последнем кадре",
"settings.auto_backup.label": "Авто-бэкап",
"settings.auto_backup.hint": "Автоматическое создание периодических резервных копий конфигурации. Старые копии удаляются при превышении максимального количества.",
"settings.auto_backup.enable": "Включить авто-бэкап",
+74 -3
View File
@@ -502,7 +502,7 @@
"tags.placeholder": "添加标签...",
"section.expand_all": "全部展开",
"section.collapse_all": "全部折叠",
"streams.title": "",
"streams.title": "输入",
"integrations.title": "集成",
"streams.description": "源定义采集管线。原始源使用采集模板从显示器采集。处理源对另一个源应用后处理。将源分配给设备。",
"streams.group.raw": "源",
@@ -656,7 +656,7 @@
"streams.video_asset": "视频素材:",
"streams.video_asset.select": "选择视频素材…",
"streams.video_asset.search": "搜索视频素材…",
"targets.title": "目标",
"targets.title": "通道",
"targets.description": "目标将色带源桥接到输出设备。每个目标引用一个设备和一个色带源。",
"targets.subtab.wled": "LED",
"targets.subtab.led": "LED",
@@ -751,8 +751,18 @@
"overlay.stopped": "叠加层可视化已停止",
"overlay.error.start": "启动叠加层失败",
"overlay.error.stop": "停止叠加层失败",
"sidebar.workspaces": "工作区",
"sidebar.load": "负载",
"sidebar.fps": "帧率",
"transport.status.ready": "就绪",
"transport.status.armed": "运行中 · {n}",
"transport.meta.uptime": "在线",
"transport.meta.cpu": "CPU",
"transport.meta.mem": "内存",
"transport.meta.poll": "轮询",
"transport.meta.poll_hint": "轮询间隔(点击:1秒 → 2秒 → 5秒 → 10秒)",
"dashboard.title": "仪表盘",
"dashboard.section.targets": "目标",
"dashboard.section.targets": "通道",
"dashboard.section.running": "运行中",
"dashboard.section.stopped": "已停止",
"dashboard.no_targets": "尚未配置目标",
@@ -767,16 +777,69 @@
"dashboard.section.sync_clocks": "同步时钟",
"dashboard.targets": "目标",
"dashboard.section.performance": "系统性能",
"dashboard.perf.active_patches": "活动通道",
"dashboard.perf.total_fps": "总帧率",
"dashboard.perf.devices": "设备",
"dashboard.perf.cpu": "CPU",
"dashboard.perf.ram": "内存",
"dashboard.perf.gpu": "GPU",
"dashboard.perf.temp": "温度",
"dashboard.perf.temp.install_lhm": "Windows 没有内置的 CPU 温度 API。请安装 LibreHardwareMonitor 并启用“Publish to WMI”以在此处查看实时读数。",
"dashboard.perf.unavailable": "不可用",
"dashboard.perf.color": "图表颜色",
"dashboard.perf.mode.system": "系统",
"dashboard.perf.mode.app": "应用",
"dashboard.perf.mode.both": "全部",
"dashboard.poll_interval": "刷新间隔",
"dashboard.customize.title": "自定义仪表盘",
"dashboard.customize.presets": "预设",
"dashboard.customize.preset.studio": "工作室",
"dashboard.customize.preset.operator": "操作员",
"dashboard.customize.preset.showrunner": "演出",
"dashboard.customize.preset.diagnostics": "诊断",
"dashboard.customize.preset.tv": "电视",
"dashboard.customize.modified": "已修改",
"dashboard.customize.global": "全局",
"dashboard.customize.width": "宽度",
"dashboard.customize.width.full": "全宽",
"dashboard.customize.width.centered": "居中",
"dashboard.customize.width.narrow": "窄",
"dashboard.customize.anim": "动画",
"dashboard.customize.anim.full": "完整",
"dashboard.customize.anim.reduced": "减少",
"dashboard.customize.anim.off": "关闭",
"dashboard.customize.perf_mode": "性能模式",
"dashboard.customize.sections": "分区",
"dashboard.customize.perf_cells": "性能面板",
"dashboard.customize.fixed_top": "固定在顶部",
"dashboard.customize.drag_help": "拖动行重新排序,或使用 ↑/↓ 按钮。",
"dashboard.customize.cell_drag_help": "拖动行可更改仪表盘性能条中单元格的顺序。",
"dashboard.customize.window": "采样窗口",
"dashboard.customize.scale": "Y 轴刻度",
"dashboard.customize.mode_short": "模式",
"dashboard.customize.window_short": "窗口",
"dashboard.customize.scale_short": "刻度",
"dashboard.customize.density.comfortable": "宽松",
"dashboard.customize.density.compact": "紧凑",
"dashboard.customize.density.dense": "密集",
"dashboard.customize.collapse_default.on": "默认折叠",
"dashboard.customize.collapse_default.off": "默认展开",
"dashboard.customize.show": "显示",
"dashboard.customize.hide": "隐藏",
"dashboard.customize.mode.inherit": "继承",
"dashboard.customize.mode.system": "系统",
"dashboard.customize.mode.app": "应用",
"dashboard.customize.mode.both": "两者",
"dashboard.customize.yscale.auto": "自动",
"dashboard.customize.yscale.fixed": "固定",
"dashboard.customize.yscale.log": "对数",
"dashboard.customize.export": "导出",
"dashboard.customize.import": "导入",
"dashboard.customize.reset": "重置",
"dashboard.customize.reset_confirm": "将仪表盘布局重置为「工作室」预设?",
"dashboard.customize.exported": "布局已导出",
"dashboard.customize.imported": "布局已导入",
"dashboard.customize.import_failed": "导入布局失败",
"automations.title": "自动化",
"automations.empty": "尚未配置自动化。创建一个以自动激活场景。",
"automations.add": "添加自动化",
@@ -1541,6 +1604,14 @@
"settings.log_level.desc.warning": "潜在问题",
"settings.log_level.desc.error": "仅显示错误",
"settings.log_level.desc.critical": "仅显示致命错误",
"settings.shutdown_action.label": "关机时执行",
"settings.shutdown_action.hint": "服务器关闭时对 LED 目标的处理方式。「停止目标」执行正常的停止流程,启用自动恢复的设备会恢复先前状态。「无」会让灯保持显示最后一帧。",
"settings.shutdown_action.saved": "已保存关机动作",
"settings.shutdown_action.save_error": "保存关机动作失败",
"settings.shutdown_action.opt.stop": "停止目标",
"settings.shutdown_action.opt.stop_desc": "执行正常停止流程(按设备应用自动恢复)",
"settings.shutdown_action.opt.nothing": "无",
"settings.shutdown_action.opt.nothing_desc": "让灯保持最后一帧",
"settings.auto_backup.label": "自动备份",
"settings.auto_backup.hint": "自动定期创建所有配置的备份。当达到最大数量时,旧备份会被自动清理。",
"settings.auto_backup.enable": "启用自动备份",
+121 -9
View File
@@ -38,17 +38,38 @@
<header>
<div class="header-title">
<span id="server-status" class="status-badge"></span>
<h1 data-i18n="app.title">LED Grab</h1>
<span id="server-version"><span id="version-number"></span></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">
@@ -44,6 +44,14 @@
<canvas id="css-test-layers-axis" class="css-test-strip-axis"></canvas>
</div>
<!-- Key Colors view (frame + region overlays) -->
<div id="css-test-kc-view" style="display:none">
<div class="css-test-kc-wrap">
<canvas id="css-test-kc-canvas" class="css-test-kc-canvas"></canvas>
</div>
<div id="css-test-kc-meta" class="css-test-kc-meta"></div>
</div>
<!-- CSPT test: input source selector (hidden by default) -->
<div id="css-test-cspt-input-group" style="display:none" class="css-test-led-control">
<label for="css-test-cspt-input-select" data-i18n="color_strip.processed.input">Source:</label>
@@ -1,13 +1,6 @@
<!-- Image Lightbox -->
<div id="image-lightbox" class="lightbox" onclick="closeLightbox(event)">
<button class="lightbox-close" onclick="closeLightbox()" title="Close">&#x2715;</button>
<button id="lightbox-auto-refresh" class="lightbox-refresh-btn" onclick="toggleKCTestAutoRefresh()" title="Stream live" style="display:none">&#x25B6;</button>
<select id="lightbox-fps-select" class="lightbox-fps-select" style="display:none" title="Frames per second">
<option value="1">1 fps</option>
<option value="2">2 fps</option>
<option value="3" selected>3 fps</option>
<option value="5">5 fps</option>
</select>
<div class="lightbox-content">
<img id="lightbox-image" src="" alt="Full size preview">
<div id="lightbox-stats" class="lightbox-stats" style="display: none;"></div>
@@ -3,6 +3,11 @@
from __future__ import annotations
import os
import platform
import subprocess
import threading
import time
from typing import Optional
from .types import MemorySnapshot, ProcessSnapshot, ThermalSnapshot
@@ -24,6 +29,14 @@ class PsutilMetricsProvider:
self._process = psutil_module.Process(os.getpid())
self._process.cpu_percent(interval=None)
self._cpu_count = int(psutil_module.cpu_count(logical=True) or 1)
# psutil has no sensors_temperatures() on Windows, so fall back to a
# throttled WMI/LHM reader running in a daemon thread. Disabled in
# tests via LEDGRAB_DISABLE_WIN_TEMP.
self._windows_temp: Optional[_WindowsCpuTemp] = (
_WindowsCpuTemp()
if platform.system() == "Windows" and not os.environ.get("LEDGRAB_DISABLE_WIN_TEMP")
else None
)
def cpu_percent(self) -> float:
return float(self._psutil.cpu_percent(interval=None))
@@ -80,8 +93,137 @@ class PsutilMetricsProvider:
except Exception:
pass
# Windows fallback: psutil exposes no CPU temperature there, so the
# reading would always be None without this. Other platforms keep
# the psutil result as-is.
if cpu_temp is None and self._windows_temp is not None:
cpu_temp = self._windows_temp.get()
return ThermalSnapshot(
battery_percent=battery_pct,
battery_temp_c=battery_temp,
cpu_temp_c=cpu_temp,
)
# ── Windows CPU temperature helper ───────────────────────────────────────
# Windows has no user-space API for real per-core CPU temperature without
# a vendor driver or third-party monitoring service, so we only try sources
# that reflect the actual CPU die rather than a motherboard/chassis zone:
#
# 1. LibreHardwareMonitor / OpenHardwareMonitor WMI — °C. Only usable when
# the monitoring app is running, but reads Intel DTS / AMD SMN directly
# so the reading actually tracks load.
# 2. ``MSAcpi_ThermalZoneTemperature`` WMI — Kelvin × 10. Some OEM boards
# wire this to the CPU; many require admin or expose a chassis zone
# instead. Only used as a last resort.
#
# The ``\Thermal Zone Information(*)\Temperature`` perf counter is
# deliberately NOT queried: on most consumer desktops it returns ACPI
# TZxx zones that are pinned at ~2730 °C regardless of CPU load — a
# misleading stable reading is worse than no reading at all.
#
# Emits a single numeric line on stdout and exits.
_WIN_TEMP_POWERSHELL = (
"$ErrorActionPreference='SilentlyContinue';"
"foreach ($ns in 'root/LibreHardwareMonitor','root/OpenHardwareMonitor') {"
" $lhm = Get-CimInstance -Namespace $ns -ClassName Sensor"
" -Filter \"SensorType='Temperature'\";"
" if ($lhm) {"
" $cpu = $lhm | Where-Object { $_.Parent -match 'cpu' -or $_.Name -match 'CPU' }"
" | Sort-Object Value -Descending | Select-Object -First 1;"
" if ($cpu) { '{0:N2}' -f $cpu.Value; exit }"
" }"
"}"
"$acpi = Get-CimInstance -Namespace root/wmi -ClassName MSAcpi_ThermalZoneTemperature;"
"if ($acpi) {"
" $t = ($acpi | Measure-Object -Property CurrentTemperature -Maximum).Maximum;"
" if ($t) { '{0:N2}' -f ($t / 10.0 - 273.15); exit }"
"}"
)
def _query_windows_cpu_temp() -> Optional[float]:
"""Run the PowerShell WMI probe once and parse the single-line result.
Returns None on any failure. Rejects wildly out-of-range values to
guard against sensors that report raw (un-scaled) Kelvin or 0.
"""
if platform.system() != "Windows":
return None
try:
creationflags = getattr(subprocess, "CREATE_NO_WINDOW", 0)
result = subprocess.run(
["powershell", "-NoProfile", "-NonInteractive", "-Command", _WIN_TEMP_POWERSHELL],
capture_output=True,
text=True,
timeout=4.0,
creationflags=creationflags,
)
except (OSError, subprocess.TimeoutExpired):
return None
if result.returncode != 0:
return None
line = (result.stdout or "").strip().splitlines()
if not line:
return None
try:
# Locale may use comma as decimal separator (e.g. ru-RU).
temp = float(line[0].replace(",", ".").strip())
except ValueError:
return None
if -20.0 <= temp <= 150.0:
return temp
return None
class _WindowsCpuTemp:
"""Throttled background reader for Windows CPU temperature.
Spawning PowerShell costs hundreds of ms per call, so we refresh in a
daemon thread at most once every ``REFRESH_INTERVAL_S`` seconds and
return the most recent cached value from ``get()``. After
``MAX_FAILURES`` consecutive empty results we self-disable to avoid
launching PowerShell forever on hosts without any usable sensor.
"""
REFRESH_INTERVAL_S = 5.0
MAX_FAILURES = 3
def __init__(self) -> None:
self._cached_c: Optional[float] = None
self._last_refresh: float = 0.0
self._refreshing: bool = False
self._disabled: bool = False
self._failures: int = 0
self._lock = threading.Lock()
def get(self) -> Optional[float]:
if self._disabled:
return None
now = time.monotonic()
with self._lock:
due = now - self._last_refresh >= self.REFRESH_INTERVAL_S
should_start = due and not self._refreshing
if should_start:
self._refreshing = True
if should_start:
threading.Thread(target=self._refresh, daemon=True).start()
return self._cached_c
def _refresh(self) -> None:
try:
value = _query_windows_cpu_temp()
finally:
now = time.monotonic()
with self._lock:
self._last_refresh = now
self._refreshing = False
if value is not None:
self._cached_c = value
self._failures = 0
else:
self._failures += 1
if self._failures >= self.MAX_FAILURES:
self._disabled = True
+4 -1
View File
@@ -21,7 +21,10 @@ from ledgrab.utils.metrics import android_provider as android_mod
@pytest.fixture(autouse=True)
def _reset_provider_cache():
def _reset_provider_cache(monkeypatch):
# Disable the Windows CPU-temp background reader so tests don't spawn
# PowerShell when run on a Windows host.
monkeypatch.setenv("LEDGRAB_DISABLE_WIN_TEMP", "1")
reset_metrics_provider()
yield
reset_metrics_provider()
+136
View File
@@ -0,0 +1,136 @@
"""Tests for /api/v1/preferences/dashboard-layout endpoints."""
import pytest
from ledgrab.config import get_config
@pytest.fixture(scope="module")
def client():
"""TestClient with auth header read at fixture-build time.
The auth API key is resolved here (not at module import) so any
config-singleton mutation that happens during pytest collection
notably ``server/tests/e2e/conftest.py`` reassigning the global
config to a different test key during collection of e2e tests
cannot leave us holding a stale Bearer header that yields 401.
"""
from fastapi.testclient import TestClient
from ledgrab.main import app
api_key = next(iter(get_config().auth.api_keys.values()), "")
with TestClient(app, raise_server_exceptions=False) as c:
if api_key:
c.headers["Authorization"] = f"Bearer {api_key}"
yield c
def _minimal_layout() -> dict:
return {
"version": 1,
"sections": [
{
"key": "perf",
"visible": True,
"collapsedDefault": False,
"density": "comfortable",
"options": {},
},
],
"perfCells": [
{
"key": "cpu",
"visible": True,
"mode": "inherit",
"span": 1,
"window": 120,
"yScale": "auto",
"precision": 1,
"showSubtitle": True,
"showRefLine": True,
},
],
"global": {
"width": "full",
"accent": "target",
"animations": "full",
"emptyState": "hide",
"toolbarPosition": "top",
"autoCollapseRunningEmpty": False,
"showTutorial": True,
"perfMode": "both",
"pollMs": 1000,
},
}
def test_get_dashboard_layout_default_empty(client):
"""When no layout has been saved, GET returns an empty object."""
# Clear first so this test is order-independent.
client.delete("/api/v1/preferences/dashboard-layout")
resp = client.get("/api/v1/preferences/dashboard-layout")
assert resp.status_code == 200
assert resp.json() == {}
def test_put_then_get_dashboard_layout(client):
"""PUT a layout, GET it back unchanged."""
layout = _minimal_layout()
put = client.put("/api/v1/preferences/dashboard-layout", json=layout)
assert put.status_code == 200
assert put.json() == {"ok": True}
got = client.get("/api/v1/preferences/dashboard-layout")
assert got.status_code == 200
body = got.json()
assert body["version"] == 1
assert body["sections"][0]["key"] == "perf"
assert body["perfCells"][0]["key"] == "cpu"
assert body["global"]["perfMode"] == "both"
def test_put_rejects_missing_version(client):
"""Body without numeric version field is rejected with 422."""
bad = {"sections": []}
resp = client.put("/api/v1/preferences/dashboard-layout", json=bad)
assert resp.status_code == 422
def test_put_rejects_non_object(client):
"""Bare arrays / strings / numbers are rejected by FastAPI body validation."""
resp = client.put(
"/api/v1/preferences/dashboard-layout",
json=["not", "an", "object"],
)
assert resp.status_code in (400, 422)
def test_delete_clears_layout(client):
"""DELETE wipes the saved layout so subsequent GET returns empty."""
client.put("/api/v1/preferences/dashboard-layout", json=_minimal_layout())
deleted = client.delete("/api/v1/preferences/dashboard-layout")
assert deleted.status_code == 200
after = client.get("/api/v1/preferences/dashboard-layout")
assert after.status_code == 200
assert after.json() == {}
def test_layout_round_trip_preserves_unknown_fields(client):
"""Frontend may add new keys (e.g. v1.1 sections) — backend must
pass them through verbatim, not strip them."""
layout = _minimal_layout()
layout["futureField"] = {"foo": "bar"}
layout["sections"].append(
{
"key": "audio-meters",
"visible": True,
"collapsedDefault": False,
"density": "comfortable",
"options": {"sensitivity": 0.7},
}
)
client.put("/api/v1/preferences/dashboard-layout", json=layout)
got = client.get("/api/v1/preferences/dashboard-layout").json()
assert got["futureField"] == {"foo": "bar"}
assert any(s["key"] == "audio-meters" for s in got["sections"])