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 {