From e0ff40f4f559c3e13f5445042189d592c08cb447 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Sat, 25 Apr 2026 15:11:09 +0300 Subject: [PATCH] feat(ui): live card-color picker, monotonic uptime ticker tweaks, default preset uses base palette MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three adjacent UI fixes that surfaced while soaking the Lumenworks redesign: - ``card-colors.ts`` now writes the user's picked color through to *every* card representing the same entity (e.g. the targets-tab card AND its dashboard mirror), not just the one that owns the picker. Sets the ``--ch`` custom property on each match instead of a literal ``border-left``, which avoided the double-stripe (custom border + Lumenworks ``::before`` channel stripe) the old approach produced and reaches mirrors the picker callback's ``.closest()`` lookup couldn't. - ``appearance.ts`` "default" preset now *clears* its colour overrides instead of stamping the historic muted greys (#1a1a1a / #2d2d2d / #f5f5f5 / #ffffff). With the redesign's pure-black / pure-white base palette in ``base.css``, "default" should mean "use the base" — the preset swatch in Appearance now matches what ships out of the box. Existing users with "default" selected will see a one-time visual shift to the new neutrals on next reload; this is intentional. - ``dashboard.css`` mod-metric label row gets explicit sizing for the small status glyphs (clock / check / warning) so they sit beside the mono-caps label without competing with the big value. Errors cell picks up the coral channel tint when the count is non-zero. --- server/src/ledgrab/static/css/dashboard.css | 29 ++++++++++++++ .../src/ledgrab/static/js/core/card-colors.ts | 40 +++++++++++++++---- .../ledgrab/static/js/features/appearance.ts | 28 +++++++++++-- 3 files changed, 86 insertions(+), 11 deletions(-) diff --git a/server/src/ledgrab/static/css/dashboard.css b/server/src/ledgrab/static/css/dashboard.css index 41573b4..82c9d4a 100644 --- a/server/src/ledgrab/static/css/dashboard.css +++ b/server/src/ledgrab/static/css/dashboard.css @@ -347,6 +347,29 @@ letter-spacing: 0.18em; text-transform: uppercase; color: var(--lux-ink-mute, var(--text-secondary)); + display: inline-flex; + align-items: center; + gap: 4px; +} + +/* Label-row icon sizing — the status glyph (clock/check/warning) sits + next to the mono caps label, not the big value. Tiny + muted so it + reads as an accent, not a competing element. */ +.mod-metric .k > .icon, +.mod-metric .k > svg { + width: 10px; + height: 10px; + flex-shrink: 0; + opacity: 0.85; +} + +/* Errors cell: the label icon shifts to coral when there are errors + (matching the big-value tint). */ +.mod-metric.has-errors .k, +.mod-metric.has-errors .k > .icon, +.mod-metric.has-errors .k > svg { + color: var(--ch-coral, var(--danger-color)); + opacity: 1; } .mod-metric .v { @@ -366,6 +389,12 @@ color: var(--ch); } +/* Errors cell — coral tint when > 0 so non-zero counts draw the eye + without needing an inline glyph that'd eat horizontal space. */ +.mod-metric .v.has-errors { + color: var(--ch-coral, var(--danger-color)); +} + .mod-metric .v small { font-family: var(--font-mono, monospace); font-size: 0.65rem; diff --git a/server/src/ledgrab/static/js/core/card-colors.ts b/server/src/ledgrab/static/js/core/card-colors.ts index d514297..f43f126 100644 --- a/server/src/ledgrab/static/js/core/card-colors.ts +++ b/server/src/ledgrab/static/js/core/card-colors.ts @@ -25,6 +25,18 @@ import { ICON_TRASH } from './icons.ts'; const STORAGE_KEY = 'cardColors'; const DEFAULT_SWATCH = '#808080'; +/** Data attributes used as the entity-id key on card elements across the + * app. setCardColor() walks all of these so a single picker click updates + * every card representing the same entity (e.g. the targets-tab card AND + * its dashboard mirror), not just the one that owns the picker. */ +const CARD_ID_ATTRS: readonly string[] = [ + 'data-target-id', 'data-device-id', 'data-automation-id', + 'data-sync-clock-id', 'data-stream-id', 'data-template-id', + 'data-pattern-template-id', 'data-pp-template-id', 'data-cspt-id', + 'data-audio-template-id', 'data-audio-source-id', 'data-gi-id', + 'data-scene-id', 'data-id', +]; + function _getAll(): Record { try { return JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}') || {}; } catch { return {}; } @@ -38,15 +50,30 @@ export function setCardColor(id: string, hex: string): void { const m = _getAll(); if (hex) m[id] = hex; else delete m[id]; localStorage.setItem(STORAGE_KEY, JSON.stringify(m)); + + // Live-update every card representing this entity. The card stripe is + // the ::before pseudo-element backed by --ch (see cards.css), so we + // override --ch inline rather than setting border-left — that avoids + // the double-stripe (custom border + primary --ch) the old approach + // produced, and reaches dashboard mirrors that the picker callback's + // .closest() lookup couldn't. + const escaped = CSS.escape(id); + const selector = CARD_ID_ATTRS.map(a => `[${a}="${escaped}"]`).join(','); + document.querySelectorAll(selector).forEach(el => { + const card = el as HTMLElement; + if (hex) card.style.setProperty('--ch', hex); + else card.style.removeProperty('--ch'); + }); } /** - * Returns inline style string for card border-left. - * Empty string when no color is set. + * Returns the inline style fragment for a card's accent override. + * Sets the --ch CSS variable so the existing ::before channel stripe + * picks up the user's color. Empty string when no color is set. */ export function cardColorStyle(entityId: string): string { const c = getCardColor(entityId); - return c ? `border-left: 3px solid ${c}` : ''; + return c ? `--ch: ${c}` : ''; } /** @@ -59,12 +86,9 @@ export function cardColorButton(entityId: string, cardAttr: string): string { const pickerId = `cc-${entityId}`; registerColorPicker(pickerId, (hex) => { + // setCardColor handles the DOM update on every card representing + // this entity (including dashboard mirrors). Nothing else to do. setCardColor(entityId, hex); - // Find the card that contains this picker (not a global querySelector - // which could match a dashboard compact card first) - const wrapper = document.getElementById(`cp-wrap-${pickerId}`); - const card = wrapper?.closest(`[${cardAttr}]`) as HTMLElement | null; - if (card) card.style.borderLeft = hex ? `3px solid ${hex}` : ''; }); return createColorPicker({ id: pickerId, currentColor: color, onPick: undefined, anchor: 'left', showReset: true, resetColor: DEFAULT_SWATCH }); diff --git a/server/src/ledgrab/static/js/features/appearance.ts b/server/src/ledgrab/static/js/features/appearance.ts index 274b1bd..67beb02 100644 --- a/server/src/ledgrab/static/js/features/appearance.ts +++ b/server/src/ledgrab/static/js/features/appearance.ts @@ -65,13 +65,17 @@ const STYLE_PRESETS: readonly StylePreset[] = [ fontHeading: "'Orbitron', sans-serif", accent: '#4CAF50', fontUrl: '', + // Color values mirror base.css so the preview swatch in Appearance + // matches what _applyThemeVars produces (which clears overrides for + // 'default' and lets base.css through — pure black on dark, pure + // white on light). dark: { - bgColor: '#1a1a1a', bgSecondary: '#242424', cardBg: '#2d2d2d', + bgColor: '#000000', bgSecondary: '#0a0b0d', cardBg: '#101216', textColor: '#e0e0e0', textSecondary: '#999', textMuted: '#777', borderColor: '#404040', inputBg: '#1a1a2e', }, light: { - bgColor: '#f5f5f5', bgSecondary: '#eee', cardBg: '#ffffff', + bgColor: '#ffffff', bgSecondary: '#fafbfc', cardBg: '#f5f6f8', textColor: '#333333', textSecondary: '#595959', textMuted: '#767676', borderColor: '#e0e0e0', inputBg: '#f0f0f0', }, @@ -500,9 +504,27 @@ export function getActiveBgEffect(): string { /** Apply theme color CSS variables for the current active theme (dark/light). */ function _applyThemeVars(preset: StylePreset): void { + const root = document.documentElement.style; + + if (preset.id === 'default') { + // Default preset = base.css palette (pure-black on dark, pure-white + // on light). Clear any inline overrides left behind by a previous + // preset so the base values come through, instead of stamping the + // muted greys this preset historically carried. + root.removeProperty('--bg-color'); + root.removeProperty('--bg-secondary'); + root.removeProperty('--card-bg'); + root.removeProperty('--text-color'); + root.removeProperty('--text-primary'); + root.removeProperty('--text-secondary'); + root.removeProperty('--text-muted'); + root.removeProperty('--border-color'); + root.removeProperty('--input-bg'); + return; + } + const theme = document.documentElement.getAttribute('data-theme') || 'dark'; const vars = theme === 'dark' ? preset.dark : preset.light; - const root = document.documentElement.style; root.setProperty('--bg-color', vars.bgColor); root.setProperty('--bg-secondary', vars.bgSecondary);