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.
This commit is contained in:
2026-04-24 15:46:47 +03:00
parent c44bb38c43
commit 539e43195f
33 changed files with 3145 additions and 358 deletions
+171
View File
@@ -1,5 +1,176 @@
# LedGrab TODO
## 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.
### 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.
## BLE LED Controller Support (SP110E / Triones / Zengge / Govee)
Add support for Bluetooth Low Energy LED controllers driven by mobile apps like "LED Hue", HappyLighting, iLightsIn. Whole-strip ambient-color output only — these protocols don't support per-pixel streaming.
File diff suppressed because it is too large Load Diff
+48
View File
@@ -14,6 +14,9 @@
"marked": "^17.0.5"
},
"devDependencies": {
"@fontsource-variable/big-shoulders-display": "^5.2.5",
"@fontsource-variable/jetbrains-mono": "^5.2.8",
"@fontsource-variable/manrope": "^5.2.8",
"esbuild": "^0.27.4",
"typescript": "^5.9.3"
}
@@ -434,6 +437,33 @@
"node": ">=18"
}
},
"node_modules/@fontsource-variable/big-shoulders-display": {
"version": "5.2.5",
"resolved": "https://registry.npmjs.org/@fontsource-variable/big-shoulders-display/-/big-shoulders-display-5.2.5.tgz",
"integrity": "sha512-ZH2w9u6018xbSf8vPZ42P/KxpQHfIsKnxSnMtLFgwui1zIS05vzlijAWRcaRQoY2pXu4Z3SVa88OANsmq6mkvA==",
"dev": true,
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource-variable/jetbrains-mono": {
"version": "5.2.8",
"resolved": "https://registry.npmjs.org/@fontsource-variable/jetbrains-mono/-/jetbrains-mono-5.2.8.tgz",
"integrity": "sha512-WBA9elru6Jdp5df2mES55wuOO0WIrn3kpXnI4+W2ek5u3ZgLS9XS4gmIlcQhiZOWEKl95meYdvK7xI+ETLCq/Q==",
"dev": true,
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource-variable/manrope": {
"version": "5.2.8",
"resolved": "https://registry.npmjs.org/@fontsource-variable/manrope/-/manrope-5.2.8.tgz",
"integrity": "sha512-nc9lOuCRz73UHnovDE2bwXUdghE2SEOc7Aii0qGe3CLyE03W1a7VnY5Z6euRiapiKbCkGS+eXbY3s/kvWeGeSw==",
"dev": true,
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@kurkle/color": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
@@ -704,6 +734,24 @@
"dev": true,
"optional": true
},
"@fontsource-variable/big-shoulders-display": {
"version": "5.2.5",
"resolved": "https://registry.npmjs.org/@fontsource-variable/big-shoulders-display/-/big-shoulders-display-5.2.5.tgz",
"integrity": "sha512-ZH2w9u6018xbSf8vPZ42P/KxpQHfIsKnxSnMtLFgwui1zIS05vzlijAWRcaRQoY2pXu4Z3SVa88OANsmq6mkvA==",
"dev": true
},
"@fontsource-variable/jetbrains-mono": {
"version": "5.2.8",
"resolved": "https://registry.npmjs.org/@fontsource-variable/jetbrains-mono/-/jetbrains-mono-5.2.8.tgz",
"integrity": "sha512-WBA9elru6Jdp5df2mES55wuOO0WIrn3kpXnI4+W2ek5u3ZgLS9XS4gmIlcQhiZOWEKl95meYdvK7xI+ETLCq/Q==",
"dev": true
},
"@fontsource-variable/manrope": {
"version": "5.2.8",
"resolved": "https://registry.npmjs.org/@fontsource-variable/manrope/-/manrope-5.2.8.tgz",
"integrity": "sha512-nc9lOuCRz73UHnovDE2bwXUdghE2SEOc7Aii0qGe3CLyE03W1a7VnY5Z6euRiapiKbCkGS+eXbY3s/kvWeGeSw==",
"dev": true
},
"@kurkle/color": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
+3
View File
@@ -16,6 +16,9 @@
"author": "",
"license": "ISC",
"devDependencies": {
"@fontsource-variable/big-shoulders-display": "^5.2.5",
"@fontsource-variable/jetbrains-mono": "^5.2.8",
"@fontsource-variable/manrope": "^5.2.8",
"esbuild": "^0.27.4",
"typescript": "^5.9.3"
},
+1
View File
@@ -2,6 +2,7 @@
@import './fonts.css';
@import './base.css';
@import './layout.css';
@import './sidebar.css';
@import './components.css';
@import './cards.css';
@import './modal.css';
+72 -3
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;
@@ -96,6 +114,30 @@
--hover-bg: rgba(255, 255, 255, 0.05);
--input-bg: #1a1a2e;
color-scheme: dark;
/* ── Lumenworks dark palette ── */
--lux-bg-0: #0a0b0d;
--lux-bg-1: #101216;
--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: #00ff7a; /* capture / targets — primary */
--ch-signal-dim: #00b85a;
--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 */
@@ -120,6 +162,31 @@
--primary-color-on-light-bg: #2e7d32;
--primary-text: #2e7d32;
color-scheme: light;
/* ── Lumenworks light palette — tuned for WCAG AA on white.
Channel colors darkened vs dark theme so they read against
near-white surfaces. ── */
--lux-bg-0: #f5f6f8;
--lux-bg-1: #ffffff;
--lux-bg-2: #fafbfc;
--lux-bg-3: #eef1f5;
--lux-line: #dee3ea;
--lux-line-bold:#c4ccd6;
--lux-ink: #0f1419;
--lux-ink-dim: #4c5866;
--lux-ink-mute: #6b7684;
--lux-ink-faint:#a5afbc;
--ch-signal: #008f3f;
--ch-signal-dim: #006b2f;
--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 +204,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 {
+159 -121
View File
@@ -2,41 +2,67 @@ 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;
}
/* 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 +70,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 +134,81 @@ 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: linear-gradient(180deg,
var(--lux-bg-1, var(--card-bg)) 0%,
var(--lux-bg-2, var(--card-bg)) 100%);
border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color));
border-radius: var(--lux-r-md, 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 — color coded by entity type via --ch override */
.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;
}
/* 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[data-card-type="integration"],
.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)); }
/* ── Card glare effect ── */
.card-glare::after,
.template-card.card-glare::after,
@@ -175,113 +265,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 {
+59 -23
View File
@@ -36,19 +36,26 @@
}
.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,18 +69,28 @@
}
.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 {
@@ -161,14 +178,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 +222,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 */
+141 -41
View File
@@ -5,16 +5,30 @@
}
.dashboard-section-header {
font-size: 0.8rem;
font-family: var(--font-mono, monospace);
font-size: 0.68rem;
font-weight: 600;
margin-bottom: 6px;
color: var(--text-secondary);
margin-bottom: 10px;
color: var(--lux-ink-dim, var(--text-secondary));
display: flex;
align-items: center;
gap: 6px;
gap: 8px;
text-transform: uppercase;
letter-spacing: 0.5px;
letter-spacing: 0.22em;
user-select: none;
padding-bottom: 6px;
border-bottom: var(--lux-hairline, 1px) dashed var(--lux-line, var(--border-color));
position: relative;
}
.dashboard-section-header::before {
content: '';
position: absolute;
left: 0; bottom: -1px;
width: 40px;
height: 1px;
background: var(--ch-signal, var(--primary-color));
box-shadow: 0 0 6px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 50%, transparent);
}
.dashboard-section-toggle {
display: flex;
@@ -91,27 +105,77 @@
}
.dashboard-target {
--ch: var(--ch-signal, var(--primary-color));
position: relative;
overflow: hidden;
display: grid;
grid-template-columns: 1fr auto auto;
align-items: center;
gap: 12px;
padding: 6px 12px;
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 10px 14px 10px 18px;
background: linear-gradient(180deg,
var(--lux-bg-1, var(--card-bg)) 0%,
var(--lux-bg-2, var(--card-bg)) 100%);
border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color));
border-radius: var(--lux-r-md, 6px);
margin-bottom: 4px;
transition: box-shadow 0.2s ease, transform 0.2s ease;
transition: box-shadow 0.2s ease, transform 0.2s ease, border-color 0.2s ease;
}
/* Channel stripe on left edge */
.dashboard-target::before {
content: '';
position: absolute;
left: 0; top: 0; bottom: 0;
width: 3px;
background: var(--ch);
box-shadow: 0 0 8px color-mix(in srgb, var(--ch) 40%, transparent);
opacity: 0.6;
transition: opacity 0.2s ease, box-shadow 0.2s ease;
}
.dashboard-target:hover {
box-shadow: 0 2px 8px var(--shadow-color);
box-shadow: var(--lux-shadow-rack, 0 2px 8px var(--shadow-color));
border-color: var(--lux-line-bold, var(--border-color));
transform: translateX(1px);
}
.dashboard-target:hover::before {
opacity: 1;
box-shadow: 0 0 14px color-mix(in srgb, var(--ch) 70%, transparent);
}
.dashboard-card-link:hover {
border-color: var(--primary-color);
box-shadow: 0 4px 12px var(--shadow-color);
border-color: color-mix(in srgb, var(--ch) 40%, var(--lux-line, var(--border-color)));
box-shadow: 0 4px 14px var(--shadow-color);
}
/* Running rows glow brighter and emit a signal-flow strip along the bottom */
.dashboard-target[data-target-id]:has([data-fps-text])::before,
.dashboard-target.is-running::before {
opacity: 1;
width: 4px;
box-shadow:
0 0 14px color-mix(in srgb, var(--ch) 70%, transparent),
0 0 4px color-mix(in srgb, var(--ch) 90%, transparent);
}
.dashboard-target[data-target-id]:has([data-fps-text])::after,
.dashboard-target.is-running::after {
content: '';
position: absolute;
left: 4px; bottom: 0;
width: calc(100% - 4px);
height: 1px;
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;
opacity: 0.7;
pointer-events: none;
}
.dashboard-target-info {
@@ -134,14 +198,16 @@
}
.dashboard-target-name {
font-size: 0.85rem;
font-size: 0.88rem;
font-weight: 600;
letter-spacing: -0.005em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: flex;
align-items: center;
gap: 4px;
color: var(--lux-ink, var(--text-color));
}
.dashboard-target-name-text {
@@ -159,11 +225,14 @@
}
.dashboard-target-subtitle {
font-size: 0.7rem;
color: var(--text-secondary);
font-family: var(--font-mono, monospace);
font-size: 0.64rem;
letter-spacing: 0.05em;
color: var(--lux-ink-mute, var(--text-secondary));
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-top: 2px;
}
.dashboard-target-metrics {
@@ -179,19 +248,22 @@
}
.dashboard-metric-value {
font-size: 0.8rem;
font-weight: 600;
color: var(--primary-text-color);
line-height: 1.2;
font-family: var(--font-mono, monospace);
font-size: 0.82rem;
font-weight: 600;
color: var(--ch, var(--primary-text-color));
line-height: 1.2;
white-space: nowrap;
font-variant-numeric: tabular-nums;
letter-spacing: 0.02em;
}
.dashboard-metric-label {
font-size: 0.6rem;
color: var(--text-secondary);
font-family: var(--font-mono, monospace);
font-size: 0.55rem;
color: var(--lux-ink-mute, var(--text-secondary));
text-transform: uppercase;
letter-spacing: 0.3px;
letter-spacing: 0.15em;
}
.dashboard-fps-metric {
@@ -386,36 +458,61 @@
/* ── Per-metric accent colors ── */
.perf-chart-card {
--perf-accent: var(--primary-color);
--perf-accent-glow: transparent;
background: var(--card-bg);
border: 1px solid var(--border-color);
border-top: 3px solid var(--perf-accent);
border-radius: 6px;
padding: 10px 0 0;
--perf-accent: var(--ch-signal, var(--primary-color));
--perf-accent-glow: color-mix(in srgb, var(--perf-accent) 18%, transparent);
background: linear-gradient(180deg,
var(--lux-bg-1, var(--card-bg)) 0%,
var(--lux-bg-2, var(--card-bg)) 100%);
border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color));
border-radius: var(--lux-r-md, 6px);
padding: 12px 0 0;
min-width: 0;
position: relative;
overflow: hidden;
transition: box-shadow var(--duration-normal) ease;
transition: box-shadow var(--duration-normal) ease, border-color var(--duration-normal) ease;
}
/* Channel stripe on left edge (replaces the old border-top accent) */
.perf-chart-card::before {
content: '';
position: absolute;
left: 0; top: 0; bottom: 0;
width: 3px;
background: var(--perf-accent);
box-shadow: 0 0 10px var(--perf-accent-glow);
}
/* Corner bracket in the top-right */
.perf-chart-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));
opacity: 0.7;
pointer-events: none;
}
.perf-chart-card:hover {
box-shadow: 0 0 16px var(--perf-accent-glow);
box-shadow: 0 4px 20px var(--perf-accent-glow), var(--lux-shadow-rack, 0 0 0 transparent);
border-color: var(--lux-line-bold, var(--border-color));
}
.perf-chart-card[data-metric="cpu"] {
--perf-accent: #FF6B6B;
--perf-accent-glow: rgba(255, 107, 107, 0.12);
--perf-accent: var(--ch-coral, #FF6B6B);
}
.perf-chart-card[data-metric="ram"] {
--perf-accent: #A855F7;
--perf-accent-glow: rgba(168, 85, 247, 0.12);
--perf-accent: var(--ch-violet, #A855F7);
}
.perf-chart-card[data-metric="gpu"] {
--perf-accent: #10B981;
--perf-accent-glow: rgba(16, 185, 129, 0.12);
--perf-accent: var(--ch-signal, #10B981);
}
.perf-chart-card[data-metric="temp"] {
--perf-accent: var(--ch-amber, #FFB800);
}
.perf-chart-wrap {
@@ -443,10 +540,11 @@
}
.perf-chart-label {
font-size: 0.75rem;
font-family: var(--font-mono, monospace);
font-size: 0.62rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.3px;
letter-spacing: 0.22em;
color: var(--perf-accent);
display: flex;
align-items: center;
@@ -481,13 +579,15 @@
/* ── Value display ── */
.perf-chart-value {
font-size: 0.85rem;
font-size: 0.92rem;
font-weight: 700;
color: var(--perf-accent);
font-family: var(--font-mono, monospace);
display: flex;
align-items: baseline;
gap: 6px;
font-variant-numeric: tabular-nums;
text-shadow: 0 0 12px color-mix(in srgb, var(--perf-accent) 40%, transparent);
}
/* App value shown as subdued tag in "both" mode */
+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;
}
@@ -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 {
+191 -43
View File
@@ -1,26 +1,84 @@
:root {
--transport-height: 60px;
}
header {
display: flex;
justify-content: space-between;
display: grid;
grid-template-columns: var(--sidebar-width, 248px) 1fr 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). */
.header-title::before {
content: '';
width: 26px;
height: 26px;
flex-shrink: 0;
border-radius: 4px;
background: var(--ch-signal, var(--primary-color));
box-shadow:
var(--lux-signal-glow, 0 0 14px color-mix(in srgb, var(--primary-color) 40%, transparent)),
inset 0 0 0 1px rgba(0, 0, 0, 0.3);
position: relative;
}
.header-title::after {
content: '';
position: absolute;
left: calc(18px + 7px);
top: 50%;
transform: translateY(-50%);
width: 12px;
height: 12px;
background: var(--lux-bg-0, var(--bg-color));
border-radius: 1px;
pointer-events: none;
}
h1 {
font-family: 'Orbitron', sans-serif;
font-size: 1.15rem;
font-size: 0.95rem;
font-weight: 700;
letter-spacing: 0.06em;
letter-spacing: 0.2em;
text-transform: uppercase;
-webkit-text-stroke: 0.5px var(--primary-color);
paint-order: stroke fill;
@@ -37,6 +95,7 @@ h1 {
background-clip: text;
-webkit-text-fill-color: transparent;
animation: titleShimmer 6s ease-in-out infinite;
line-height: 1;
}
@keyframes titleShimmer {
@@ -44,6 +103,55 @@ h1 {
50% { background-position: 0% 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: 8px;
padding: 6px 12px;
background: var(--lux-bg-2, var(--bg-secondary));
border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color));
border-radius: var(--lux-r-sm, 3px);
color: var(--lux-ink-dim, var(--text-secondary));
font-size: 0.68rem;
letter-spacing: 0.08em;
text-transform: uppercase;
white-space: nowrap;
transition: color 0.2s, border-color 0.2s, background 0.2s;
}
.transport-status.is-armed {
color: var(--ch-signal, var(--primary-color));
border-color: color-mix(in srgb, var(--ch-signal, var(--primary-color)) 40%, transparent);
background: color-mix(in srgb, var(--ch-signal, var(--primary-color)) 8%, transparent);
}
.transport-status .dot {
width: 6px; height: 6px;
border-radius: 50%;
background: currentColor;
box-shadow: 0 0 6px currentColor;
animation: pulse 1.4s ease-in-out infinite;
}
.transport-status:not(.is-armed) .dot {
background: var(--lux-ink-faint, var(--text-muted));
box-shadow: none;
animation: none;
}
h2 {
margin-bottom: 20px;
color: var(--text-color);
@@ -54,16 +162,16 @@ h2 {
display: flex;
align-items: center;
gap: 2px;
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--lux-bg-1, var(--card-bg));
border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color));
border-radius: var(--lux-r-sm, 3px);
padding: 3px 4px;
}
.header-toolbar-sep {
width: 1px;
height: 18px;
background: var(--border-color);
height: 16px;
background: var(--lux-line, var(--border-color));
margin: 0 3px;
flex-shrink: 0;
}
@@ -156,14 +264,16 @@ h2 {
}
#server-version {
font-family: 'Orbitron', sans-serif;
font-size: 0.65rem;
font-weight: 700;
color: var(--text-secondary);
background: var(--border-color);
padding: 2px 8px;
border-radius: 10px;
letter-spacing: 0.03em;
font-family: var(--font-mono, 'Orbitron', sans-serif);
font-size: 0.55rem;
font-weight: 600;
color: var(--lux-ink-mute, var(--text-secondary));
background: transparent;
border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color));
padding: 2px 6px;
border-radius: 2px;
letter-spacing: 0.12em;
text-transform: uppercase;
transition: background 0.3s, color 0.3s, box-shadow 0.3s;
}
@@ -270,17 +380,35 @@ h2 {
to { transform: translateY(0); opacity: 1; }
}
/* #server-status is overlaid on the brand mark as a small LED pip that
changes color based on connection. Inline element rendered via text node
('●'); we hide the glyph and use ::before for a clean circular dot so
sizing is consistent across fonts. */
.status-badge {
font-size: 1rem;
position: absolute;
left: calc(18px + 13px);
top: 50%;
transform: translate(-50%, -50%);
width: 6px;
height: 6px;
border-radius: 50%;
color: transparent; /* hide the '●' text glyph */
font-size: 0;
background: var(--lux-bg-0, var(--bg-color));
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.4), 0 0 6px var(--ch-signal, var(--primary-color));
z-index: 2;
animation: pulse 2s infinite;
pointer-events: none;
}
.status-badge.online {
color: var(--primary-color);
background: var(--ch-signal, var(--primary-color));
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.4), 0 0 8px var(--ch-signal, var(--primary-color));
}
.status-badge.offline {
color: var(--danger-color);
background: var(--ch-coral, var(--danger-color));
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.4), 0 0 8px var(--ch-coral, var(--danger-color));
}
@keyframes pulse {
@@ -441,7 +569,8 @@ h2 {
color: var(--danger-color);
}
/* Tabs */
/* ── Tabs (base styles; sidebar.css re-specializes for vertical rail;
mobile.css reverts to a fixed bottom bar on phones) ── */
.tab-bar {
display: flex;
align-items: center;
@@ -458,7 +587,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 {
@@ -825,29 +957,45 @@ h2 {
text-align: center;
}
/* 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;
header {
grid-template-columns: var(--sidebar-width, 56px) 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;
}
.tab-btn .icon {
width: 20px;
height: 20px;
}
}
@media (max-width: 900px) {
header {
flex-direction: column;
gap: 8px;
text-align: center;
.transport-center {
padding: 0 12px;
}
.container {
padding: 12px;
padding: 16px;
}
}
@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;
}
.container {
padding: 10px;
}
}
+102 -16
View File
@@ -158,53 +158,122 @@
display: none;
}
/* ── Bottom Tab Bar ── */
/* ── Bottom Tab Bar — Lumenworks mobile shell ── */
.sidebar .tab-bar,
.tab-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: var(--z-sticky);
background: var(--card-bg);
background: linear-gradient(180deg,
var(--lux-bg-1, var(--card-bg)) 0%,
var(--lux-bg-0, var(--card-bg)) 100%);
border-bottom: none;
border-top: 1px solid var(--border-color);
border-top: var(--lux-hairline, 1px) solid var(--lux-line-bold, var(--border-color));
margin-bottom: 0;
display: flex;
flex-direction: row;
flex-wrap: nowrap;
justify-content: space-around;
padding: 0;
padding-bottom: env(safe-area-inset-bottom, 0px);
box-shadow: 0 -2px 8px var(--shadow-color);
box-shadow: 0 -2px 12px rgba(0, 0, 0, 0.3);
gap: 0;
width: auto;
height: auto;
overflow: visible;
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
}
/* Top channel-accent rule — matches the transport bar bottom rule so
the two bars feel like bookends of the mobile layout. */
.sidebar .tab-bar::before,
.tab-bar::before {
content: '';
position: absolute;
left: 0; right: 0; top: 0;
height: 1px;
background: linear-gradient(90deg,
transparent 0%,
color-mix(in srgb, var(--ch-signal, var(--primary-color)) 28%, transparent) 15%,
color-mix(in srgb, var(--ch-cyan, var(--info-color)) 24%, transparent) 50%,
color-mix(in srgb, var(--ch-magenta, #ff4ade) 20%, transparent) 85%,
transparent 100%);
opacity: 0.8;
pointer-events: none;
}
.sidebar .tab-btn,
.tab-btn {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
padding: 8px 4px 6px;
font-size: 0.65rem;
justify-content: center;
gap: 3px;
padding: 7px 4px 6px;
font-family: var(--font-mono, inherit);
font-size: 0.55rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--lux-ink-mute, var(--text-secondary));
border-bottom: none;
border-top: 2px solid transparent;
border-radius: 0;
background: transparent;
margin-bottom: 0;
position: relative;
grid-template-columns: none;
box-shadow: none;
}
.sidebar .tab-btn.active,
.tab-btn.active {
color: var(--ch-signal, var(--primary-color));
border-bottom-color: transparent;
border-top-color: var(--primary-color);
border-top-color: var(--ch-signal, var(--primary-color));
background: linear-gradient(180deg,
color-mix(in srgb, var(--ch-signal, var(--primary-color)) 14%, transparent) 0%,
transparent 60%);
box-shadow: none;
}
/* LED pip above the icon on the active tab (replaces the left-stripe
since the sidebar's box-shadow doesn't carry here). */
.sidebar .tab-btn.active::before,
.tab-btn.active::before {
content: '';
position: absolute;
top: 4px; left: 50%;
transform: translateX(-50%);
width: 4px;
height: 4px;
border-radius: 50%;
background: var(--ch-signal, var(--primary-color));
box-shadow: 0 0 6px var(--ch-signal, var(--primary-color));
}
.sidebar .tab-btn .icon,
.tab-btn .icon {
width: 20px;
height: 20px;
display: block;
color: inherit;
}
.sidebar .tab-btn.active .icon,
.tab-btn.active .icon {
color: var(--ch-signal, var(--primary-color));
}
.sidebar .tab-btn > span[data-i18n],
.tab-btn > span[data-i18n] {
font-size: 0.6rem;
font-family: var(--font-mono, inherit);
font-size: 0.55rem;
letter-spacing: 0.08em;
text-transform: uppercase;
line-height: 1.2;
max-width: 100%;
overflow: hidden;
@@ -215,13 +284,19 @@
/* Tab badge repositioned to top-right of icon */
.tab-badge {
position: absolute;
top: 2px;
right: calc(50% - 18px);
font-size: 0.55rem;
top: 6px;
right: calc(50% - 20px);
font-family: var(--font-mono, monospace);
font-size: 0.48rem;
font-weight: 700;
padding: 0 4px;
min-width: 14px;
line-height: 1.2;
min-width: 12px;
line-height: 1.3;
margin-left: 0;
background: var(--ch-signal, var(--primary-color));
color: var(--lux-bg-0, var(--primary-contrast));
border-radius: 2px;
letter-spacing: 0.02em;
}
/* Body padding for fixed bottom bar */
@@ -276,6 +351,12 @@
margin: 0;
}
/* Hide the bottom-right corner bracket on fullscreen mobile modals —
there's no "panel" to decorate. Top channel rule stays. */
.modal-content::after {
display: none;
}
.modal-content-wide {
min-width: 0;
width: 100%;
@@ -284,11 +365,16 @@
}
.modal-header {
padding: 12px 14px 10px;
padding: 14px 14px 12px 20px;
}
.modal-header::before {
left: 8px;
height: 18px;
}
.modal-header h2 {
font-size: 1.15rem;
font-size: 1.05rem;
}
.modal-body {
+124 -11
View File
@@ -6,12 +6,15 @@
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.8);
background: radial-gradient(1200px 800px at 50% 40%,
rgba(0, 0, 0, 0.7) 0%,
rgba(0, 0, 0, 0.88) 100%);
z-index: var(--z-modal);
align-items: center;
justify-content: center;
animation: fadeIn 0.2s ease-out;
backdrop-filter: blur(2px);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
}
/* Confirm dialog must stack above all other modals */
@@ -784,18 +787,103 @@
}
.modal-content {
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
--modal-ch: var(--ch-signal, var(--primary-color));
background: linear-gradient(180deg,
var(--lux-bg-1, var(--card-bg)) 0%,
var(--lux-bg-2, var(--card-bg)) 100%);
border: var(--lux-hairline, 1px) solid var(--lux-line-bold, var(--border-color));
border-radius: var(--lux-r-lg, var(--radius-lg));
max-width: 500px;
width: 90%;
max-height: calc(100vh - 40px);
display: flex;
flex-direction: column;
box-shadow: 0 8px 32px var(--shadow-color);
box-shadow:
0 0 0 1px rgba(255, 255, 255, 0.02),
0 20px 60px rgba(0, 0, 0, 0.5),
0 8px 32px var(--shadow-color);
animation: slideUp 0.3s cubic-bezier(0.16, 1, 0.3, 1);
position: relative;
overflow: hidden;
}
/* Channel accent rule across the top edge of every modal. Type-specific
modals can override `--modal-ch` to get a different stripe color. */
.modal-content::before {
content: '';
position: absolute;
left: 0; right: 0; top: 0;
height: 2px;
background: linear-gradient(90deg,
transparent 0%,
var(--modal-ch) 20%,
var(--modal-ch) 80%,
transparent 100%);
opacity: 0.9;
box-shadow: 0 0 12px color-mix(in srgb, var(--modal-ch) 50%, transparent);
pointer-events: none;
z-index: 2;
}
/* Corner bracket — silkscreened panel feel, bottom-right this time so
it doesn't clash with the header-actions row in the top-right. */
.modal-content::after {
content: '';
position: absolute;
right: 10px; bottom: 10px;
width: 12px; height: 12px;
border-bottom: var(--lux-hairline, 1px) solid var(--lux-line-bold, var(--border-color));
border-right: var(--lux-hairline, 1px) solid var(--lux-line-bold, var(--border-color));
opacity: 0.5;
pointer-events: none;
z-index: 1;
}
/* Per-modal channel colors — map well-known modal IDs to channel lanes.
Modals not listed keep the default green stripe. */
#target-editor-modal .modal-content,
#add-device-modal .modal-content,
#device-settings-modal .modal-content,
#ha-light-editor-modal .modal-content,
#calibration-modal .modal-content { --modal-ch: var(--ch-signal, var(--primary-color)); }
#stream-modal .modal-content,
#test-stream-modal .modal-content,
#capture-template-modal .modal-content,
#test-template-modal .modal-content,
#pp-template-modal .modal-content,
#test-pp-template-modal .modal-content,
#cspt-modal .modal-content,
#css-editor-modal .modal-content,
#test-css-source-modal .modal-content,
#pattern-template-modal .modal-content,
#gradient-editor-modal .modal-content,
#value-source-editor-modal .modal-content,
#test-value-source-modal .modal-content,
#asset-editor-modal .modal-content,
#asset-upload-modal .modal-content,
#ha-source-editor-modal .modal-content,
#mqtt-source-editor-modal .modal-content,
#sync-clock-editor-modal .modal-content,
#weather-source-editor-modal .modal-content { --modal-ch: var(--ch-cyan, var(--info-color)); }
#audio-source-editor-modal .modal-content,
#audio-template-modal .modal-content,
#audio-processing-template-modal .modal-content,
#test-audio-source-modal .modal-content,
#test-audio-template-modal .modal-content { --modal-ch: var(--ch-magenta, #ff4ade); }
#automation-editor-modal .modal-content,
#scene-preset-editor-modal .modal-content,
#game-integration-editor-modal .modal-content { --modal-ch: var(--ch-violet, #8b7eff); }
#settings-modal .modal-content,
#api-key-modal .modal-content,
#setup-required-modal .modal-content,
#notification-history-modal .modal-content { --modal-ch: var(--ch-amber, var(--warning-color)); }
#confirm-modal .modal-content { --modal-ch: var(--ch-coral, var(--danger-color)); }
#template-modal .modal-content {
max-width: 500px !important;
width: 100% !important;
@@ -817,21 +905,42 @@
}
.modal-header {
padding: 24px 24px 16px 24px;
border-bottom: 1px solid var(--border-color);
padding: 22px 24px 14px 24px;
border-bottom: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color));
display: flex;
align-items: center;
justify-content: space-between;
position: relative;
}
/* Tiny channel-color square to the left of the title, consistent with
the sidebar's section-label marker. */
.modal-header::before {
content: '';
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
width: 3px;
height: 22px;
background: var(--modal-ch, var(--ch-signal, var(--primary-color)));
border-radius: 2px;
box-shadow: 0 0 10px color-mix(in srgb, var(--modal-ch, var(--ch-signal, var(--primary-color))) 50%, transparent);
opacity: 0.8;
}
.modal-header h2 {
margin: 0;
font-size: 1.5rem;
color: var(--text-color);
font-family: var(--font-body, inherit);
font-size: 1.15rem;
font-weight: 700;
letter-spacing: -0.01em;
color: var(--lux-ink, var(--text-color));
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
min-width: 0;
padding-left: 4px;
}
.modal-header-actions {
@@ -1191,10 +1300,14 @@
}
.modal-footer {
padding: 16px 24px 24px 24px;
padding: 16px 24px 20px 24px;
display: flex;
justify-content: flex-end;
gap: 8px;
border-top: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color));
background: linear-gradient(180deg,
transparent 0%,
color-mix(in srgb, var(--lux-bg-0, transparent) 30%, transparent) 100%);
}
.modal-footer .btn-icon {
+305
View File
@@ -0,0 +1,305 @@
/* 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.78rem;
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.6rem;
font-weight: 600;
padding: 1px 6px;
border-radius: 8px;
min-width: 18px;
line-height: 1.3;
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 0;
justify-content: center;
justify-items: center;
gap: 2px;
}
.sidebar .tab-btn > span[data-i18n] {
font-size: 0.55rem;
letter-spacing: 0.05em;
text-transform: uppercase;
color: inherit;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
}
.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;
}
}
+47 -21
View File
@@ -606,36 +606,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 +693,26 @@ body.pp-filter-dragging .pp-filter-drag-handle {
}
.subtab-section-header {
font-size: 1rem;
font-family: var(--font-mono, monospace);
font-size: 0.72rem;
font-weight: 600;
color: var(--text-secondary);
margin: 0 0 12px 0;
color: var(--lux-ink-dim, var(--text-secondary));
margin: 0 0 14px 0;
padding-bottom: 8px;
text-transform: uppercase;
letter-spacing: 0.2em;
border-bottom: var(--lux-hairline, 1px) dashed 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 +754,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.
@@ -45,6 +45,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() };
}
@@ -510,6 +528,7 @@ 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);
// Check if we can do an in-place metrics update (same targets, not first load)
const newRunningIds = running.map(t => t.id).sort().join(',');
@@ -327,6 +327,21 @@ function _setValueEl(el: HTMLElement, html: string): void {
}
}
function _updateSidebarMeter(cpuPercent: number, appCpuPercent: number): void {
const cpuLabel = document.getElementById('sidebar-meter-cpu');
const cpuBar = document.getElementById('sidebar-meter-cpu-bar');
if (cpuLabel) cpuLabel.textContent = `${cpuPercent.toFixed(0)}%`;
if (cpuBar) (cpuBar as HTMLElement).style.width = `${Math.min(100, Math.max(0, cpuPercent))}%`;
// "FPS" bar — use app CPU share as a live utilization readout. When the
// dashboard later exposes an aggregate FPS ratio, swap this for that.
const fpsLabel = document.getElementById('sidebar-meter-fps');
const fpsBar = document.getElementById('sidebar-meter-fps-bar');
const appPct = cpuPercent > 0 ? Math.min(100, (appCpuPercent / cpuPercent) * 100) : 0;
if (fpsLabel) fpsLabel.textContent = `${appCpuPercent.toFixed(1)}%`;
if (fpsBar) (fpsBar as HTMLElement).style.width = `${appPct}%`;
}
async function _fetchPerformance(): Promise<void> {
try {
const resp = await fetch(`${API_BASE}/system/performance`, { headers: getHeaders() });
@@ -335,6 +350,7 @@ async function _fetchPerformance(): Promise<void> {
// CPU — app_cpu_percent is in the same scale as cpu_percent (per-core %)
_pushSample('cpu', data.cpu_percent, data.app_cpu_percent);
_updateSidebarMeter(data.cpu_percent ?? 0, data.app_cpu_percent ?? 0);
const cpuEl = document.getElementById('perf-cpu-value');
if (cpuEl) {
_setValueEl(cpuEl, _formatValue(
+8 -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,13 @@
"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",
"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",
+8 -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,13 @@
"overlay.stopped": "Визуализация наложения остановлена",
"overlay.error.start": "Не удалось запустить наложение",
"overlay.error.stop": "Не удалось остановить наложение",
"sidebar.workspaces": "Разделы",
"sidebar.load": "Нагр.",
"sidebar.fps": "FPS",
"transport.status.ready": "Готов",
"transport.status.armed": "Активно · {n}",
"dashboard.title": "Обзор",
"dashboard.section.targets": "Цели",
"dashboard.section.targets": "Каналы",
"dashboard.section.running": "Запущенные",
"dashboard.section.stopped": "Остановленные",
"dashboard.no_targets": "Нет настроенных целей",
+8 -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,13 @@
"overlay.stopped": "叠加层可视化已停止",
"overlay.error.start": "启动叠加层失败",
"overlay.error.stop": "停止叠加层失败",
"sidebar.workspaces": "工作区",
"sidebar.load": "负载",
"sidebar.fps": "帧率",
"transport.status.ready": "就绪",
"transport.status.armed": "运行中 · {n}",
"dashboard.title": "仪表盘",
"dashboard.section.targets": "目标",
"dashboard.section.targets": "通道",
"dashboard.section.running": "运行中",
"dashboard.section.stopped": "已停止",
"dashboard.no_targets": "尚未配置目标",
+35 -7
View File
@@ -42,13 +42,11 @@
<span id="server-version"><span id="version-number"></span></span>
<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="header-toolbar">
<a href="/docs" target="_blank" class="header-link" data-i18n-title="app.api_docs" title="API Docs">API</a>
@@ -107,6 +105,34 @@
</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>
<div class="sidebar-foot">
<div class="cpu-meter" id="sidebar-meter" aria-hidden="true">
<div>
<div class="cpu-meter-row"><span data-i18n="sidebar.load">Load</span><b id="sidebar-meter-cpu">--%</b></div>
<div class="cpu-bar"><i id="sidebar-meter-cpu-bar" style="width:0"></i></div>
</div>
<div>
<div class="cpu-meter-row"><span data-i18n="sidebar.fps">FPS</span><b id="sidebar-meter-fps">--</b></div>
<div class="cpu-bar cpu-bar-fps"><i id="sidebar-meter-fps-bar" style="width:0"></i></div>
</div>
</div>
<div class="sidebar-version"><span id="sidebar-version-text"></span></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 +212,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>