feat(ui): live card-color picker, monotonic uptime ticker tweaks, default preset uses base palette

Three adjacent UI fixes that surfaced while soaking the Lumenworks
redesign:

- ``card-colors.ts`` now writes the user's picked color through to
  *every* card representing the same entity (e.g. the targets-tab card
  AND its dashboard mirror), not just the one that owns the picker.
  Sets the ``--ch`` custom property on each match instead of a literal
  ``border-left``, which avoided the double-stripe (custom border +
  Lumenworks ``::before`` channel stripe) the old approach produced
  and reaches mirrors the picker callback's ``.closest()`` lookup
  couldn't.
- ``appearance.ts`` "default" preset now *clears* its colour overrides
  instead of stamping the historic muted greys (#1a1a1a / #2d2d2d /
  #f5f5f5 / #ffffff). With the redesign's pure-black / pure-white base
  palette in ``base.css``, "default" should mean "use the base" — the
  preset swatch in Appearance now matches what ships out of the box.
  Existing users with "default" selected will see a one-time visual
  shift to the new neutrals on next reload; this is intentional.
- ``dashboard.css`` mod-metric label row gets explicit sizing for the
  small status glyphs (clock / check / warning) so they sit beside the
  mono-caps label without competing with the big value. Errors cell
  picks up the coral channel tint when the count is non-zero.
This commit is contained in:
2026-04-25 15:11:09 +03:00
parent 3f80ef2101
commit e0ff40f4f5
3 changed files with 86 additions and 11 deletions
@@ -347,6 +347,29 @@
letter-spacing: 0.18em; letter-spacing: 0.18em;
text-transform: uppercase; text-transform: uppercase;
color: var(--lux-ink-mute, var(--text-secondary)); 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 { .mod-metric .v {
@@ -366,6 +389,12 @@
color: var(--ch); 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 { .mod-metric .v small {
font-family: var(--font-mono, monospace); font-family: var(--font-mono, monospace);
font-size: 0.65rem; font-size: 0.65rem;
@@ -25,6 +25,18 @@ import { ICON_TRASH } from './icons.ts';
const STORAGE_KEY = 'cardColors'; const STORAGE_KEY = 'cardColors';
const DEFAULT_SWATCH = '#808080'; const DEFAULT_SWATCH = '#808080';
/** Data attributes used as the entity-id key on card elements across the
* app. setCardColor() walks all of these so a single picker click updates
* every card representing the same entity (e.g. the targets-tab card AND
* its dashboard mirror), not just the one that owns the picker. */
const CARD_ID_ATTRS: readonly string[] = [
'data-target-id', 'data-device-id', 'data-automation-id',
'data-sync-clock-id', 'data-stream-id', 'data-template-id',
'data-pattern-template-id', 'data-pp-template-id', 'data-cspt-id',
'data-audio-template-id', 'data-audio-source-id', 'data-gi-id',
'data-scene-id', 'data-id',
];
function _getAll(): Record<string, string> { function _getAll(): Record<string, string> {
try { return JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}') || {}; } try { return JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}') || {}; }
catch { return {}; } catch { return {}; }
@@ -38,15 +50,30 @@ export function setCardColor(id: string, hex: string): void {
const m = _getAll(); const m = _getAll();
if (hex) m[id] = hex; else delete m[id]; if (hex) m[id] = hex; else delete m[id];
localStorage.setItem(STORAGE_KEY, JSON.stringify(m)); 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. * Returns the inline style fragment for a card's accent override.
* Empty string when no color is set. * 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 { export function cardColorStyle(entityId: string): string {
const c = getCardColor(entityId); 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}`; const pickerId = `cc-${entityId}`;
registerColorPicker(pickerId, (hex) => { registerColorPicker(pickerId, (hex) => {
// setCardColor handles the DOM update on every card representing
// this entity (including dashboard mirrors). Nothing else to do.
setCardColor(entityId, hex); 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 }); return createColorPicker({ id: pickerId, currentColor: color, onPick: undefined, anchor: 'left', showReset: true, resetColor: DEFAULT_SWATCH });
@@ -65,13 +65,17 @@ const STYLE_PRESETS: readonly StylePreset[] = [
fontHeading: "'Orbitron', sans-serif", fontHeading: "'Orbitron', sans-serif",
accent: '#4CAF50', accent: '#4CAF50',
fontUrl: '', 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: { dark: {
bgColor: '#1a1a1a', bgSecondary: '#242424', cardBg: '#2d2d2d', bgColor: '#000000', bgSecondary: '#0a0b0d', cardBg: '#101216',
textColor: '#e0e0e0', textSecondary: '#999', textMuted: '#777', textColor: '#e0e0e0', textSecondary: '#999', textMuted: '#777',
borderColor: '#404040', inputBg: '#1a1a2e', borderColor: '#404040', inputBg: '#1a1a2e',
}, },
light: { light: {
bgColor: '#f5f5f5', bgSecondary: '#eee', cardBg: '#ffffff', bgColor: '#ffffff', bgSecondary: '#fafbfc', cardBg: '#f5f6f8',
textColor: '#333333', textSecondary: '#595959', textMuted: '#767676', textColor: '#333333', textSecondary: '#595959', textMuted: '#767676',
borderColor: '#e0e0e0', inputBg: '#f0f0f0', borderColor: '#e0e0e0', inputBg: '#f0f0f0',
}, },
@@ -500,9 +504,27 @@ export function getActiveBgEffect(): string {
/** Apply theme color CSS variables for the current active theme (dark/light). */ /** Apply theme color CSS variables for the current active theme (dark/light). */
function _applyThemeVars(preset: StylePreset): void { 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 theme = document.documentElement.getAttribute('data-theme') || 'dark';
const vars = theme === 'dark' ? preset.dark : preset.light; const vars = theme === 'dark' ? preset.dark : preset.light;
const root = document.documentElement.style;
root.setProperty('--bg-color', vars.bgColor); root.setProperty('--bg-color', vars.bgColor);
root.setProperty('--bg-secondary', vars.bgSecondary); root.setProperty('--bg-secondary', vars.bgSecondary);