diff --git a/TODO.md b/TODO.md index 46a2a16..a087383 100644 --- a/TODO.md +++ b/TODO.md @@ -114,6 +114,29 @@ Phases are independent and CSS-only where possible — backend untouched. tabs. - [x] Graph editor — toolbar gets a gradient background + hairline + rack shadow + backdrop blur. Canvas and nodes untouched. +- [x] `.template-card` — Lumenworks treatment (channel stripe on left, + corner bracket top-right, hairline border, hover lift + stripe + glow). Brings Inputs (streams / capture / pp / cspt / pattern + templates) and Integrations (HA / MQTT / weather / value / + sync-clock / game-integration cards) up to the same visual + language as `.card` and `.dashboard-target`. +- [x] `cards.css` — channel mapping extended to `.template-card`. + Direct attr hooks for `data-stream-id`/`data-template-id`/`data-pp-template-id` + (cyan), `data-cspt-id`/`data-pattern-template-id` (signal), + `data-audio-template-id`/`data-apt-id` (magenta). Section-scoped + hooks via `[data-card-section="…"]` for cards that share a + generic `data-id` (HA / MQTT / weather / value → cyan; + game-integrations → amber; sync-clocks → violet; HA-light-targets + → signal). No JS changes — uses the section markup `CardSection` + already emits. +- [x] Graph editor nodes — body fill `--lux-bg-1` with hairline stroke, + hover bold-line, selected/running stroke `--ch-signal` with + drop-shadow glow. Title font switched from DM Sans to + `--font-display`; subtitle to mono uppercase wide-tracking. + Port-drop-target glow recoloured to `--ch-signal`. Port labels + adopt the mono caption treatment. Grid dots use `--lux-line`. + Running gradient stops switched from `--primary-color`/`--success-color` + to channel palette (signal → cyan → signal). ### Phase 5 — Modal restyle diff --git a/server/src/ledgrab/static/css/cards.css b/server/src/ledgrab/static/css/cards.css index 789af28..050c1ff 100644 --- a/server/src/ledgrab/static/css/cards.css +++ b/server/src/ledgrab/static/css/cards.css @@ -207,6 +207,46 @@ section { .card[data-card-type="offline"], .card.ch-coral { --ch: var(--ch-coral, var(--danger-color)); } +/* ── Channel mapping for `.template-card` ── + * Cards rendered by `wrapCard({ type: 'template-card' })` are used by the + * Inputs and Integrations tabs (plus a few other CardSection consumers). + * Many of those use a generic `data-id` attribute, so we scope by the + * parent section's `data-card-section` instead of relying on a unique + * data-attr per row. Direct attribute hooks come first for the cards that + * already carry a domain-specific id. + */ + +/* Direct attribute hooks (Inputs tab — known per-domain attrs) */ +.template-card[data-stream-id], +.template-card[data-template-id], +.template-card[data-pp-template-id] { --ch: var(--ch-cyan, var(--info-color)); } + +.template-card[data-cspt-id], +.template-card[data-pattern-template-id] { --ch: var(--ch-signal, var(--primary-color)); } + +.template-card[data-audio-template-id], +.template-card[data-apt-id] { --ch: var(--ch-magenta, #ff4ade); } + +/* Section-scoped hooks (cards that share `data-id` and need their channel + * resolved via the surrounding section). Matches `
` emitted by `CardSection.render`. */ + +/* Network / data-input integrations → cyan (input language) */ +[data-card-section="ha-sources"] .template-card[data-id], +[data-card-section="mqtt-sources"] .template-card[data-id], +[data-card-section="weather-sources"] .template-card[data-id], +[data-card-section="value-sources"] .template-card[data-id] { --ch: var(--ch-cyan, var(--info-color)); } + +/* Game integrations → amber (events / surfaces) */ +[data-card-section="game-integrations"] .template-card[data-id], +.template-card[data-gi-id] { --ch: var(--ch-amber, var(--warning-color)); } + +/* Sync clocks → violet (timing / orchestration, mirrors automation/scenes) */ +[data-card-section="sync-clocks"] .template-card[data-id] { --ch: var(--ch-violet, #8b7eff); } + +/* HA light targets → signal (output target, mirrors led-targets) */ +[data-card-section="ha-light-targets"] .template-card[data-ha-target-id] { --ch: var(--ch-signal, var(--primary-color)); } + /* ── Card glare effect ── */ .card-glare::after, .template-card.card-glare::after, diff --git a/server/src/ledgrab/static/css/graph-editor.css b/server/src/ledgrab/static/css/graph-editor.css index ecb73c0..d6e3bfa 100644 --- a/server/src/ledgrab/static/css/graph-editor.css +++ b/server/src/ledgrab/static/css/graph-editor.css @@ -410,8 +410,8 @@ html:has(#tab-graph.active) { /* ── Grid background ── */ .graph-grid-dot { - fill: var(--border-color); - opacity: 0.3; + fill: var(--lux-line, var(--border-color)); + opacity: 0.32; } /* ── Node styles ── */ @@ -430,21 +430,24 @@ html:has(#tab-graph.active) { } .graph-node-body { - fill: var(--card-bg); - stroke: none; - rx: 8; - ry: 8; - transition: stroke 0.15s; + fill: var(--lux-bg-1, var(--card-bg)); + stroke: var(--lux-line, var(--border-color)); + stroke-width: 1; + rx: 6; + ry: 6; + transition: stroke 0.15s, stroke-width 0.15s, filter 0.2s ease; } .graph-node:hover .graph-node-body { - stroke: var(--text-secondary); + stroke: var(--lux-line-bold, var(--text-secondary)); stroke-width: 1; + filter: drop-shadow(0 4px 14px rgba(0, 0, 0, 0.25)); } .graph-node.selected .graph-node-body { - stroke: var(--primary-color); + stroke: var(--ch-signal, var(--primary-color)); stroke-width: 2; + filter: drop-shadow(0 0 8px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 50%, transparent)); } .graph-node-color-bar { @@ -459,37 +462,45 @@ html:has(#tab-graph.active) { } .graph-node-title { - fill: var(--text-color); + fill: var(--lux-ink, var(--text-color)); font-size: 12px; font-weight: 600; - font-family: 'DM Sans', sans-serif; + /* Body font, not display — Big Shoulders is condensed and reads as + * "stretched" at 12 px in a node label. Display font is for hero + * headers only. */ + font-family: var(--font-body, 'Manrope', 'DM Sans', sans-serif); + letter-spacing: 0; } .graph-node-subtitle { - fill: var(--text-secondary); - font-size: 10px; - font-family: 'DM Sans', sans-serif; + fill: var(--lux-ink-dim, var(--text-secondary)); + font-size: 9.5px; + font-weight: 600; + font-family: var(--font-mono, monospace); + letter-spacing: 0.04em; + text-transform: uppercase; } .graph-node-icon { - stroke: var(--text-muted); + stroke: var(--lux-ink-mute, var(--text-muted)); fill: none; - stroke-width: 2; + stroke-width: 1.5; stroke-linecap: round; stroke-linejoin: round; - opacity: 0.5; + opacity: 0.55; } .graph-node.running .graph-node-icon { - stroke: var(--primary-color); - opacity: 0.85; + stroke: var(--ch-signal, var(--primary-color)); + opacity: 0.95; } -/* ── Running indicator (animated gradient border) ── */ +/* ── Running indicator (animated gradient border + signal-flow glow) ── */ .graph-node.running .graph-node-body { stroke: url(#running-gradient); stroke-width: 2; + filter: drop-shadow(0 0 12px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 50%, transparent)); } @keyframes graph-running-rotate { @@ -534,13 +545,16 @@ html:has(#tab-graph.active) { /* Port labels — hidden by default, shown on node hover, positioned outside node */ .graph-port-label { font-size: 9px; - font-weight: 600; - fill: var(--text-color); + font-weight: 700; + font-family: var(--font-mono, monospace); + letter-spacing: 0.08em; + text-transform: uppercase; + fill: var(--lux-ink-dim, var(--text-color)); pointer-events: none; opacity: 0; transition: opacity 0.15s; paint-order: stroke fill; - stroke: var(--bg-color); + stroke: var(--lux-bg-0, var(--bg-color)); stroke-width: 3px; stroke-linejoin: round; } @@ -569,9 +583,9 @@ html:has(#tab-graph.active) { .graph-port-drop-target { r: 7 !important; - stroke: var(--primary-color) !important; + stroke: var(--ch-signal, var(--primary-color)) !important; stroke-width: 3 !important; - filter: drop-shadow(0 0 6px var(--primary-color)); + filter: drop-shadow(0 0 6px var(--ch-signal, var(--primary-color))); } /* ── Edges ── */ diff --git a/server/src/ledgrab/static/css/streams.css b/server/src/ledgrab/static/css/streams.css index f565b74..014959b 100644 --- a/server/src/ledgrab/static/css/streams.css +++ b/server/src/ledgrab/static/css/streams.css @@ -9,19 +9,53 @@ } .template-card { - background: var(--card-bg); - border: 1px solid var(--border-color); - border-radius: var(--radius-md); - padding: 16px; - transition: box-shadow 0.2s ease, transform 0.2s ease; + --ch: var(--ch-cyan, var(--info-color)); /* default channel — overridden per data-attr below */ + 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; + transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease; display: flex; flex-direction: column; position: relative; + overflow: hidden; +} + +/* Channel stripe on left edge — colour-coded per entity type via --ch override */ +.template-card::before { + content: ''; + position: absolute; + left: 0; top: 0; bottom: 0; + width: 3px; + background: var(--ch); + box-shadow: 0 0 10px color-mix(in srgb, var(--ch) 40%, transparent); + pointer-events: none; + z-index: 1; + transition: width 0.2s ease, box-shadow 0.2s ease; +} + +/* Corner bracket — silkscreened panel feel in the top-right */ +.template-card::after { + content: ''; + position: absolute; + top: 8px; right: 8px; + width: 12px; height: 12px; + border-top: var(--lux-hairline, 1px) solid var(--lux-line-bold, var(--border-color)); + border-right: var(--lux-hairline, 1px) solid var(--lux-line-bold, var(--border-color)); + pointer-events: none; + opacity: 0.7; + z-index: 1; } .template-card:hover { - box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15); + box-shadow: var(--lux-shadow-rack, 0 8px 24px var(--shadow-color)); transform: translateY(-2px); + border-color: var(--lux-line-bold, var(--border-color)); +} + +.template-card:hover::before { + width: 4px; + box-shadow: 0 0 14px color-mix(in srgb, var(--ch) 70%, transparent); } .add-template-card { diff --git a/server/src/ledgrab/static/js/core/graph-nodes.ts b/server/src/ledgrab/static/js/core/graph-nodes.ts index f61781f..b6d5b60 100644 --- a/server/src/ledgrab/static/js/core/graph-nodes.ts +++ b/server/src/ledgrab/static/js/core/graph-nodes.ts @@ -133,9 +133,54 @@ export function renderNodes(group: SVGGElement, nodeMap: Map, for (const node of nodeMap.values()) { const g = renderNode(node, callbacks); group.appendChild(g); + // Now that the is in the live SVG, `getComputedTextLength()` + // returns real values — fit the title/subtitle to the visible + // text area and append "…" if they overflow. + _fitNodeText(g, node.width); } } +/** Available text width per node — clip rect is x=14..(width-48) wide and + * text starts at x=16, so the usable run is `width - 50`. The 2 px slack + * on the right keeps the ellipsis from kissing the clip edge. */ +function _availableTextWidth(nodeWidth: number): number { + return Math.max(0, nodeWidth - 52); +} + +/** Replace the text of an SVG `` element with the longest prefix of + * its `data-full-text` that fits within `maxWidth`, suffixed with "…". + * No-op if the full text already fits. */ +function _fitTextToWidth(el: SVGTextElement, maxWidth: number): void { + const full = el.getAttribute('data-full-text') || el.textContent || ''; + el.textContent = full; + if (maxWidth <= 0) { el.textContent = ''; return; } + let len = 0; + try { len = el.getComputedTextLength(); } catch { return; } + if (len <= maxWidth) return; + + // Binary search for the longest character prefix that fits with "…". + let lo = 0, hi = full.length; + while (lo < hi) { + const mid = Math.ceil((lo + hi) / 2); + el.textContent = full.slice(0, mid).trimEnd() + '…'; + try { + if (el.getComputedTextLength() <= maxWidth) lo = mid; + else hi = mid - 1; + } catch { + return; + } + } + el.textContent = (full.slice(0, lo).trimEnd() || '') + '…'; +} + +function _fitNodeText(nodeG: Element, nodeWidth: number): void { + const maxW = _availableTextWidth(nodeWidth); + const title = nodeG.querySelector('.graph-node-title'); + const subtitle = nodeG.querySelector('.graph-node-subtitle'); + if (title) _fitTextToWidth(title, maxW); + if (subtitle) _fitTextToWidth(subtitle, maxW); +} + /** * Render a single node. */ @@ -342,23 +387,30 @@ function renderNode(node: GraphNode, callbacks: NodeCallbacks): SVGElement { clipPath.appendChild(svgEl('rect', { x: 14, y: 0, width: width - 48, height })); g.appendChild(clipPath); - // Title (shift left edge for icon to have room) + // Title (shift left edge for icon to have room). + // Full text is stashed on `data-full-text` so the post-mount fit pass + // can measure with `getComputedTextLength()` and binary-search the + // longest prefix that fits, appending "…" instead of relying on the + // clip-path (which silently chops mid-glyph with no ellipsis cue). const title = svgEl('text', { class: 'graph-node-title', x: 16, y: 24, 'clip-path': `url(#${clipId})`, + 'data-full-text': name, }); title.textContent = name; g.appendChild(title); // Subtitle (type) if (subtype) { + const subText = subtype.replace(/_/g, ' '); const sub = svgEl('text', { class: 'graph-node-subtitle', x: 16, y: 42, 'clip-path': `url(#${clipId})`, + 'data-full-text': subText, }); - sub.textContent = subtype.replace(/_/g, ' '); + sub.textContent = subText; g.appendChild(sub); } diff --git a/server/src/ledgrab/static/js/features/graph-editor.ts b/server/src/ledgrab/static/js/features/graph-editor.ts index e796079..9ddf4f8 100644 --- a/server/src/ledgrab/static/js/features/graph-editor.ts +++ b/server/src/ledgrab/static/js/features/graph-editor.ts @@ -1140,9 +1140,9 @@ function _graphHTML(): string { - - - + + + diff --git a/server/src/ledgrab/static/js/features/perf-charts.ts b/server/src/ledgrab/static/js/features/perf-charts.ts index c2934c2..8a4bce6 100644 --- a/server/src/ledgrab/static/js/features/perf-charts.ts +++ b/server/src/ledgrab/static/js/features/perf-charts.ts @@ -17,6 +17,10 @@ import { getOrderedPerfCells, isPerfCellVisible, getGlobalConfig, saveDashboardL const MAX_SAMPLES = 120; const CHART_KEYS = ['cpu', 'ram', 'gpu', 'temp', 'fps'] as const; +/** Every cell key the user can color-customize, including the + * patches / devices cells that don't have sparklines but still + * carry a header accent stripe. */ +const ALL_COLORABLE_KEYS = ['patches', 'fps', 'devices', 'cpu', 'ram', 'gpu', 'temp'] as const; const PERF_MODE_KEY = 'perfMetricsMode'; const SPARK_W = 600; // SVG viewBox width (scales with preserveAspectRatio) const SPARK_H = 64; @@ -28,20 +32,24 @@ const HOST_ONLY_KEYS = new Set(['temp', 'fps']); perf cards share the same language as the rest of the app. Overrides per-user in localStorage still honoured by `_getColor`. */ const METRIC_CSS_VARS: Record = { - cpu: '--ch-coral', - ram: '--ch-violet', - gpu: '--ch-signal', - temp: '--ch-amber', - fps: '--ch-cyan', + patches: '--ch-magenta', + fps: '--ch-cyan', + devices: '--ch-signal', + cpu: '--ch-coral', + ram: '--ch-violet', + gpu: '--ch-signal', + temp: '--ch-amber', }; /** Fallback hex used only if CSS-var resolution fails (e.g. detached node). */ const METRIC_FALLBACK: Record = { - cpu: '#FF6B6B', - ram: '#A855F7', - gpu: '#10B981', - temp: '#FCD34D', - fps: '#00D8FF', + patches: '#EC4899', + fps: '#00D8FF', + devices: '#10B981', + cpu: '#FF6B6B', + ram: '#A855F7', + gpu: '#10B981', + temp: '#FCD34D', }; type PerfMode = 'system' | 'app' | 'both'; @@ -142,14 +150,25 @@ export function setPerfMode(mode: PerfMode): void { /** Returns the static HTML for the perf section. */ export function renderPerfSection(): string { _syncMode(); - for (const key of CHART_KEYS) { + for (const key of ALL_COLORABLE_KEYS) { registerColorPicker(`perf-${key}`, hex => _onChartColorChange(key, hex)); } + /** Color-picker widget rendered next to each cell's label. Even + * cells without sparklines (patches/devices) get one — it drives + * the card's `--perf-accent` CSS var for the header stripe. */ + const colorWidget = (key: string) => createColorPicker({ + id: `perf-${key}`, + currentColor: _getColor(key), + onPick: undefined, + anchor: 'left', + showReset: true, + }); + const sparkCard = (key: string, labelKey: string, hiddenByEnv: boolean) => ` -
+
- ${t(labelKey)} ${createColorPicker({ id: `perf-${key}`, currentColor: _getColor(key), onPick: undefined, anchor: 'left', showReset: true })} + ${t(labelKey)} ${colorWidget(key)}
@@ -162,9 +181,9 @@ export function renderPerfSection(): string {
`; const patchesCell = ` -
+
- ${t('dashboard.perf.active_patches') || 'Active Patches'} + ${t('dashboard.perf.active_patches') || 'Active Patches'} ${colorWidget('patches')}
@@ -177,9 +196,9 @@ export function renderPerfSection(): string {
`; const fpsCell = ` -
+
- ${t('dashboard.perf.total_fps') || 'Total FPS'} + ${t('dashboard.perf.total_fps') || 'Total FPS'} ${colorWidget('fps')}
@@ -191,9 +210,9 @@ export function renderPerfSection(): string {
`; const devicesCell = ` -
+
- ${t('dashboard.perf.devices') || 'Devices'} + ${t('dashboard.perf.devices') || 'Devices'} ${colorWidget('devices')}
@@ -790,16 +809,19 @@ function _metricLabel(key: string): string { return key.toUpperCase(); } +let _tooltipBound = false; function _initSparkTooltip(): void { + if (_tooltipBound) return; + _tooltipBound = true; const intervalMs = dashboardPollInterval || 2000; - // Event-delegate from .perf-charts-grid so re-renders of the perf - // section don't require re-binding per spark. - const grid = document.querySelector('.perf-charts-grid'); - if (!grid) return; - - grid.addEventListener('mousemove', (rawEv) => { + // Bound on `document.body` instead of `.perf-charts-grid` so the + // listener survives `rerenderPerfGrid()` replacing the grid element. + // The handler bails out unless the cursor is actually over a spark, + // so the hot-path cost is just one `closest()` call per mousemove. + document.body.addEventListener('mousemove', (rawEv) => { const ev = rawEv as MouseEvent; const target = ev.target as HTMLElement; + if (!target || !target.closest) { _hideTooltip(); return; } const spark = target.closest('.perf-chart-spark') as HTMLElement | null; if (!spark) { _hideTooltip(); return; } const card = spark.closest('.perf-chart-card') as HTMLElement | null; @@ -808,19 +830,30 @@ function _initSparkTooltip(): void { if (!key || !_history[key]) { _hideTooltip(); return; } const rect = spark.getBoundingClientRect(); - const sys = _history[key]; - const app = _appHistory[key]; - if (sys.length < 2) { _hideTooltip(); return; } + const sysFull = _history[key]; + const appFull = _appHistory[key]; + if (sysFull.length < 2) { _hideTooltip(); return; } + + // Tooltip must read from the same slice the spark draws — otherwise + // the cursor x → sample mapping skews after the user changes the + // window setting. Effective window in seconds × samples-per-sec + // (clamped to MAX_SAMPLES) gives the visible slice length. + const cfg = (() => { try { return getGlobalConfig(); } catch { return null; } })(); + const winSec = (() => { try { return effectivePerfWindow(key); } catch { return 120; } })(); + const samplesPerSec = cfg ? Math.max(0.5, 1000 / Math.max(50, cfg.pollMs)) : 1; + const sliceN = Math.min(MAX_SAMPLES, Math.max(2, Math.round(winSec * samplesPerSec))); + const sys = sysFull.slice(-sliceN); + const app = appFull.slice(-sliceN); // Samples right-align in the spark (new tick arrives at the right // edge), so cursor x → index in the last-N window. const relX = Math.max(0, Math.min(rect.width, ev.clientX - rect.left)); const fraction = rect.width > 0 ? relX / rect.width : 0; - // The visible series maps to the rightmost sys.length samples in - // a MAX_SAMPLES-wide viewBox — compute which actual sample the - // cursor x corresponds to. - const visibleStart = MAX_SAMPLES - sys.length; - const globalIdx = Math.round(fraction * (MAX_SAMPLES - 1)); + // The visible series maps to the rightmost `sys.length` samples in + // a `sliceN`-wide spark — compute which actual sample the cursor x + // corresponds to. + const visibleStart = sliceN - sys.length; + const globalIdx = Math.round(fraction * (sliceN - 1)); const localIdx = Math.max(0, Math.min(sys.length - 1, globalIdx - visibleStart)); const sysValue = sys[localIdx]; const appValue = app && app.length > localIdx ? app[localIdx] : null; @@ -862,8 +895,10 @@ function _initSparkTooltip(): void { _tooltipMarkerEl.style.setProperty('--marker-color', color); } }); - - grid.addEventListener('mouseleave', _hideTooltip); + // Hide whenever the mouse leaves the body (out of viewport) — when + // moving between sparks within the body, the mousemove handler above + // re-positions the tip on the next sample. + document.body.addEventListener('mouseleave', _hideTooltip); } function _hideTooltip(): void {