Files
Kaloyan DanchevandClaude Opus 4.6 89ff17a3bc
CI / Identify a merged version PR (push) Canceled after 0s
CI / Rust checks, rendering, and native package (push) Canceled after 0s
CI / Publish release and Homebrew feed (push) Canceled after 0s
Release PR / Maintain Version Packages PR (push) Canceled after 0s
Add HA Lovelace cards replicating Luma glass UI
Custom card bundle (luma-card, luma-weather-card) with slide-in
panels for light/climate/cover/media/vacuum, matching the TV
launcher's design tokens. Includes theme, dashboard YAML for all
8 rooms, and deployment handoff doc.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-27 23:11:43 +02:00

1014 lines
42 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Luma Home — Custom Lovelace Cards
* Replicates the 10-foot Luma TV UI as HA dashboard cards.
* Cards: luma-card, luma-room-card
* Panels: slide-in from right, matching Luma panel design.
*/
const LUMA_VERSION = '1.0.0';
/* ── Design tokens (from design-system/tokens.json) ──────────── */
const T = {
card: {
fill: 'rgba(34,38,46,0.88)',
border: 'rgba(255,255,255,0.10)',
radius: 22,
padding: 18,
iconOff: 'rgba(255,255,255,0.10)',
barTrack: 'rgba(255,255,255,0.14)',
},
panel: {
fill: 'rgba(30,34,42,0.98)',
width: 420,
radius: 28,
control: 'rgba(255,255,255,0.10)',
label: 'rgba(255,255,255,0.72)',
},
accent: {
light: '#F5B85A',
heat: '#F06A4A',
cool: '#4C9BF0',
switch: '#7EB8DA',
curtain: '#DCD2B4',
led: '#E07A5F',
media: '#9B7DDB',
vacuum: '#60C090',
warning: '#F2B84B',
},
shadow: {
card: '0 2px 8px rgba(0,0,0,0.25)',
panel: '0 24px 60px rgba(0,0,0,0.50)',
},
};
/* ── Helpers ───────────────────────────────────────────────────── */
function accentForDomain(entity_id, state) {
if (!entity_id) return T.accent.switch;
const d = entity_id.split('.')[0];
if (d === 'light') return T.accent.light;
if (d === 'climate') {
const s = state?.state;
if (s === 'heat') return T.accent.heat;
if (s === 'cool') return T.accent.cool;
return T.accent.heat;
}
if (d === 'cover') return T.accent.curtain;
if (d === 'media_player') return T.accent.media;
if (d === 'vacuum') return T.accent.vacuum;
if (d === 'switch') return T.accent.switch;
return T.accent.switch;
}
function iconForEntity(entity_id, type, stateObj) {
if (type === 'weather') return 'mdi:weather-partly-cloudy';
if (type === 'media') return 'mdi:music';
if (type === 'vacuum') return 'mdi:robot-vacuum';
if (type === 'curtain') return 'mdi:curtains';
if (type === 'action') return 'mdi:play-circle-outline';
const d = entity_id?.split('.')[0];
if (d === 'light') return 'mdi:lightbulb';
if (d === 'climate') return 'mdi:thermometer';
if (d === 'switch') return 'mdi:power-plug';
if (d === 'cover') return 'mdi:curtains';
return 'mdi:toggle-switch';
}
function isOn(stateObj) {
if (!stateObj) return false;
const s = stateObj.state;
return s === 'on' || s === 'heat' || s === 'cool' || s === 'auto' ||
s === 'heat_cool' || s === 'dry' || s === 'fan_only' ||
s === 'playing' || s === 'cleaning' || s === 'open' || s === 'opening';
}
function hexToRgb(hex) {
hex = hex.replace('#', '');
return {
r: parseInt(hex.slice(0, 2), 16),
g: parseInt(hex.slice(2, 4), 16),
b: parseInt(hex.slice(4, 6), 16),
};
}
/* ── Panel overlay (singleton) ─────────────────────────────────── */
let panelOverlay = null;
function closeLumaPanel() {
if (!panelOverlay) return;
panelOverlay.classList.remove('open');
setTimeout(() => {
panelOverlay.remove();
panelOverlay = null;
}, 250);
}
function openLumaPanel(hass, entityId, cardType, cardName) {
closeLumaPanel();
const stateObj = hass.states[entityId];
if (!stateObj) return;
const accent = accentForDomain(entityId, stateObj);
const rgb = hexToRgb(accent);
const overlay = document.createElement('div');
overlay.className = 'luma-panel-overlay';
overlay.innerHTML = `
<div class="luma-panel-backdrop"></div>
<div class="luma-panel">
<div class="luma-panel-inner"></div>
</div>
`;
overlay.querySelector('.luma-panel-backdrop').addEventListener('click', closeLumaPanel);
document.body.appendChild(overlay);
panelOverlay = overlay;
const inner = overlay.querySelector('.luma-panel-inner');
const domain = entityId.split('.')[0];
// Header
const friendly = stateObj.attributes.friendly_name || cardName || entityId;
const on = isOn(stateObj);
inner.innerHTML = buildPanelHeader(friendly, stateObj, accent, rgb, cardType, domain) +
buildPanelSections(hass, entityId, stateObj, cardType, domain, accent, rgb);
// Wire up controls
wirePanel(inner, hass, entityId, stateObj, cardType, domain, accent, rgb);
requestAnimationFrame(() => overlay.classList.add('open'));
}
function buildPanelHeader(name, stateObj, accent, rgb, cardType, domain) {
const on = isOn(stateObj);
const iconBg = on ? accent : 'rgba(255,255,255,0.10)';
const iconColor = on ? '#15181E' : 'rgba(255,255,255,0.80)';
let stateLine = stateObj.state;
if (domain === 'climate') {
const cur = stateObj.attributes.current_temperature;
const mode = stateObj.state;
const label = { heat: 'Heating', cool: 'Cooling', auto: 'Auto', dry: 'Dry', fan_only: 'Fan', off: 'Off' }[mode] || mode;
stateLine = cur ? `${label} · ${cur}° inside` : label;
} else if (domain === 'light') {
const bri = stateObj.attributes.brightness;
if (on && bri !== undefined) {
stateLine = `${Math.round(bri / 255 * 100)}%`;
} else {
stateLine = on ? 'On' : 'Off';
}
} else if (domain === 'cover') {
const pos = stateObj.attributes.current_position;
stateLine = pos !== undefined ? `${pos}%` : stateObj.state;
} else if (domain === 'media_player') {
const title = stateObj.attributes.media_title;
stateLine = title ? `Playing · ${title}` : stateObj.state;
}
const icon = iconForEntity(stateObj.entity_id, cardType, stateObj);
return `
<div class="panel-header">
<div class="panel-icon" style="background:${iconBg}">
<ha-icon icon="${icon}" style="color:${iconColor};--mdc-icon-size:30px"></ha-icon>
</div>
<div class="panel-title">${name}</div>
<div class="panel-state" style="color:${accent}">${stateLine}</div>
</div>
`;
}
function buildPanelSections(hass, entityId, stateObj, cardType, domain, accent, rgb) {
if (domain === 'light') return buildLightPanel(hass, entityId, stateObj, accent, rgb);
if (domain === 'climate') return buildClimatePanel(hass, entityId, stateObj, accent, rgb, cardType);
if (domain === 'cover') return buildCoverPanel(hass, entityId, stateObj, accent, rgb);
if (domain === 'media_player') return buildMediaPanel(hass, entityId, stateObj, accent, rgb);
if (domain === 'vacuum') return buildVacuumPanel(hass, entityId, stateObj, accent, rgb);
return `<div class="panel-section"><div class="panel-section-label">State</div><div style="color:#fff;font-size:20px">${stateObj.state}</div></div>`;
}
/* ── Light panel ──────────────────────────────────────────────── */
function buildLightPanel(hass, entityId, stateObj, accent, rgb) {
const bri = stateObj.attributes.brightness || 0;
const pct = Math.round(bri / 255 * 100);
const on = isOn(stateObj);
const modes = stateObj.attributes.supported_color_modes || [];
const hasColorTemp = modes.includes('color_temp');
const hasColor = modes.includes('hs') || modes.includes('rgb') || modes.includes('xy');
const hasEffects = (stateObj.attributes.effect_list || []).length > 0;
let html = `
<div class="panel-section">
<div class="panel-section-label">Brightness</div>
<div class="panel-slider-wrap">
<input type="range" class="panel-slider" data-action="brightness"
min="0" max="255" value="${bri}" style="--accent:${accent};--fill:${pct}%">
<div class="panel-slider-value">${pct}%</div>
</div>
</div>
<div class="panel-section">
<div class="panel-section-label">Presets</div>
<div class="panel-btn-row">
<button class="panel-btn ${!on ? 'active' : ''}" data-action="preset" data-value="off">Off</button>
<button class="panel-btn ${on && pct >= 23 && pct <= 27 ? 'active' : ''}" data-action="preset" data-value="25">25%</button>
<button class="panel-btn ${on && pct >= 48 && pct <= 52 ? 'active' : ''}" data-action="preset" data-value="50">50%</button>
<button class="panel-btn ${on && pct >= 98 ? 'active' : ''}" data-action="preset" data-value="100">100%</button>
</div>
</div>
`;
if (hasColorTemp) {
const ct = stateObj.attributes.color_temp_kelvin || 4000;
const minK = stateObj.attributes.min_color_temp_kelvin || 2000;
const maxK = stateObj.attributes.max_color_temp_kelvin || 6500;
const ctPct = Math.round((ct - minK) / (maxK - minK) * 100);
html += `
<div class="panel-section">
<div class="panel-section-label">Colour temperature</div>
<div class="panel-slider-wrap">
<input type="range" class="panel-slider warm" data-action="color_temp"
min="${minK}" max="${maxK}" value="${ct}" style="--accent:#FFB46B;--fill:${ctPct}%">
<div class="panel-slider-value">${ct} K</div>
</div>
</div>
`;
}
if (hasColor) {
const swatches = ['#FF5A40','#F0A050','#F2D04B','#3DDC97','#4C9BF0','#7B5BE0','#E45AD8','#FFFFFF'];
html += `
<div class="panel-section">
<div class="panel-section-label">Colour</div>
<div class="panel-swatch-row">
${swatches.map(c => `<button class="panel-swatch" data-action="color" data-value="${c}" style="background:${c}"></button>`).join('')}
</div>
</div>
`;
}
if (hasEffects) {
const effects = stateObj.attributes.effect_list || [];
const cur = stateObj.attributes.effect || '';
html += `
<div class="panel-section">
<div class="panel-section-label">Effects</div>
<div class="panel-effect-grid">
${effects.slice(0, 12).map(e => `<button class="panel-effect-btn ${e === cur ? 'active' : ''}" data-action="effect" data-value="${e}">${e.length > 8 ? e.slice(0, 7) + '…' : e}</button>`).join('')}
</div>
</div>
`;
}
return html;
}
/* ── Climate panel ────────────────────────────────────────────── */
function buildClimatePanel(hass, entityId, stateObj, accent, rgb, cardType) {
const target = stateObj.attributes.temperature || 22;
const min = stateObj.attributes.min_temp || 16;
const max = stateObj.attributes.max_temp || 30;
const pct = Math.round((target - min) / (max - min) * 100);
const modes = (stateObj.attributes.hvac_modes || []).filter(m => m !== 'off');
const curMode = stateObj.state;
const fanModes = stateObj.attributes.fan_modes || [];
const curFan = stateObj.attributes.fan_mode || 'auto';
const swingModes = stateObj.attributes.swing_modes || [];
const curSwing = stateObj.attributes.swing_mode || 'off';
const modeIcons = { heat: 'mdi:fire', cool: 'mdi:snowflake', auto: 'mdi:autorenew', heat_cool: 'mdi:autorenew', dry: 'mdi:water-percent', fan_only: 'mdi:fan' };
const modeLabels = { heat: 'Heat', cool: 'Cool', auto: 'Auto', heat_cool: 'Auto', dry: 'Dry', fan_only: 'Fan' };
let html = `
<div class="panel-section">
<div class="panel-section-label">Target</div>
<div class="panel-slider-wrap">
<input type="range" class="panel-slider" data-action="temperature"
min="${min}" max="${max}" step="0.5" value="${target}" style="--accent:${accent};--fill:${pct}%">
<div class="panel-slider-value">${target}°</div>
</div>
<div class="panel-help">${min}° – ${max}°</div>
</div>
`;
if (modes.length > 0) {
html += `
<div class="panel-section">
<div class="panel-section-label">Mode</div>
<div class="panel-icon-row">
${modes.map(m => {
const mAccent = m === 'heat' || m === 'heat_cool' || m === 'auto' ? T.accent.heat :
m === 'cool' ? T.accent.cool :
m === 'dry' ? '#e0c060' : m === 'fan_only' ? '#80d0a0' : '#bbb';
return `<button class="panel-icon-btn ${curMode === m ? 'active' : ''}" data-action="hvac_mode" data-value="${m}" style="--btn-accent:${mAccent}">
<ha-icon icon="${modeIcons[m] || 'mdi:thermostat'}" style="--mdc-icon-size:24px"></ha-icon>
<span>${modeLabels[m] || m}</span>
</button>`;
}).join('')}
</div>
</div>
`;
}
// Fan speed auto
if (fanModes.length > 0) {
const autoModes = [['Auto', 'auto'], ['Quiet', 'low'], ['Turbo', 'high']].filter(
([, v]) => fanModes.includes(v)
);
if (autoModes.length > 0) {
html += `
<div class="panel-section">
<div class="panel-section-label">Fan speed · auto</div>
<div class="panel-btn-row">
${autoModes.map(([label, val]) => `<button class="panel-btn ${curFan === val ? 'active' : ''}" data-action="fan_mode" data-value="${val}">${label}</button>`).join('')}
</div>
</div>
`;
}
// Fan speed manual
const manualModes = ['low', 'medium-low', 'medium', 'medium-high', 'high'].filter(m => fanModes.includes(m));
if (manualModes.length >= 2) {
const idx = manualModes.indexOf(curFan);
const fanPct = idx >= 0 ? Math.round(idx / (manualModes.length - 1) * 100) : 50;
html += `
<div class="panel-section">
<div class="panel-section-label">Fan speed · manual</div>
<div class="panel-slider-wrap">
<input type="range" class="panel-slider" data-action="fan_manual"
min="0" max="${manualModes.length - 1}" value="${Math.max(idx, 0)}"
style="--accent:${accent};--fill:${fanPct}%"
data-modes="${manualModes.join(',')}">
<div class="panel-slider-value">${idx >= 0 ? idx + 1 : '·'}</div>
</div>
<div class="panel-help">1 — ${manualModes.length}</div>
</div>
`;
}
}
// Airflow
if (swingModes.length > 0) {
const swingLabels = { off: 'Off', default: 'Off', vertical: 'Swing', horizontal: 'Horiz.', both: 'Both', swing_ud: 'Swing', swing: 'Swing' };
html += `
<div class="panel-section">
<div class="panel-section-label">Up–down airflow</div>
<div class="panel-icon-row">
${swingModes.map(m => {
const label = swingLabels[m] || (m.startsWith('fixed_') ? `Pos ${m.split('_')[1]}` : m);
return `<button class="panel-icon-btn small ${curSwing === m ? 'active' : ''}" data-action="swing_mode" data-value="${m}" style="--btn-accent:${accent}">
<ha-icon icon="${m === 'off' || m === 'default' ? 'mdi:close' : 'mdi:arrow-up-down'}" style="--mdc-icon-size:22px"></ha-icon>
<span>${label}</span>
</button>`;
}).join('')}
</div>
</div>
`;
}
return html;
}
/* ── Cover panel ──────────────────────────────────────────────── */
function buildCoverPanel(hass, entityId, stateObj, accent, rgb) {
const pos = stateObj.attributes.current_position ?? 0;
const presets = [['Open', 100], ['60%', 60], ['45%', 45], ['30%', 30], ['Closed', 0]];
return `
<div class="panel-section">
<div class="panel-section-label">Position</div>
<div class="panel-slider-wrap">
<input type="range" class="panel-slider" data-action="cover_position"
min="0" max="100" value="${pos}" style="--accent:${accent};--fill:${pos}%">
<div class="panel-slider-value">${pos}%</div>
</div>
</div>
<div class="panel-section">
<div class="panel-section-label">Controls</div>
<div class="panel-btn-row">
<button class="panel-btn" data-action="cover_cmd" data-value="open"><ha-icon icon="mdi:arrow-up" style="--mdc-icon-size:20px"></ha-icon> Open</button>
<button class="panel-btn" data-action="cover_cmd" data-value="stop"><ha-icon icon="mdi:stop" style="--mdc-icon-size:20px"></ha-icon> Stop</button>
<button class="panel-btn" data-action="cover_cmd" data-value="close"><ha-icon icon="mdi:arrow-down" style="--mdc-icon-size:20px"></ha-icon> Close</button>
</div>
</div>
<div class="panel-section">
<div class="panel-section-label">Presets</div>
<div class="panel-btn-row">
${presets.map(([l, v]) => `<button class="panel-btn ${Math.abs(pos - v) < 3 ? 'active' : ''}" data-action="cover_position_btn" data-value="${v}">${l}</button>`).join('')}
</div>
</div>
`;
}
/* ── Media panel ──────────────────────────────────────────────── */
function buildMediaPanel(hass, entityId, stateObj, accent, rgb) {
const title = stateObj.attributes.media_title || '';
const artist = stateObj.attributes.media_artist || '';
const artUrl = stateObj.attributes.entity_picture;
const isPlaying = stateObj.state === 'playing';
const vol = Math.round((stateObj.attributes.volume_level || 0) * 100);
const duration = stateObj.attributes.media_duration || 0;
const position = stateObj.attributes.media_position || 0;
const progress = duration > 0 ? Math.round(position / duration * 100) : 0;
const fmt = s => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, '0')}`;
return `
<div class="panel-section media-now">
${artUrl ? `<img class="media-art" src="${artUrl}" />` : '<div class="media-art-placeholder"><ha-icon icon="mdi:music" style="--mdc-icon-size:40px;color:rgba(255,255,255,0.3)"></ha-icon></div>'}
<div class="media-info">
<div class="media-title">${title || 'Nothing playing'}</div>
<div class="media-artist">${artist}</div>
</div>
</div>
${duration > 0 ? `
<div class="panel-section">
<div class="media-progress-bar" style="--fill:${progress}%"></div>
<div class="media-times"><span>${fmt(position)}</span><span>-${fmt(Math.max(0, duration - position))}</span></div>
</div>
` : ''}
<div class="panel-section">
<div class="media-controls">
<button class="media-btn" data-action="media_cmd" data-value="previous"><ha-icon icon="mdi:skip-previous" style="--mdc-icon-size:28px"></ha-icon></button>
<button class="media-btn play" data-action="media_cmd" data-value="${isPlaying ? 'pause' : 'play'}" style="background:${accent}">
<ha-icon icon="mdi:${isPlaying ? 'pause' : 'play'}" style="--mdc-icon-size:32px;color:#15181E"></ha-icon>
</button>
<button class="media-btn" data-action="media_cmd" data-value="next"><ha-icon icon="mdi:skip-next" style="--mdc-icon-size:28px"></ha-icon></button>
</div>
</div>
<div class="panel-section">
<div class="panel-section-label">Volume · ${vol}%</div>
<div class="panel-slider-wrap">
<input type="range" class="panel-slider" data-action="volume"
min="0" max="100" value="${vol}" style="--accent:${accent};--fill:${vol}%">
<div class="panel-slider-value">${vol}%</div>
</div>
</div>
`;
}
/* ── Vacuum panel ─────────────────────────────────────────────── */
function buildVacuumPanel(hass, entityId, stateObj, accent, rgb) {
const status = stateObj.state;
const battery = stateObj.attributes.battery_level;
const fan = stateObj.attributes.fan_speed || '';
const fanModes = stateObj.attributes.fan_speed_list || [];
return `
<div class="panel-section">
<div class="panel-section-label">Status</div>
<div style="color:#fff;font-size:18px;font-weight:600;margin-bottom:8px">
${status.charAt(0).toUpperCase() + status.slice(1)}
${battery !== undefined ? ` · ${battery}%` : ''}
</div>
</div>
${fanModes.length > 0 ? `
<div class="panel-section">
<div class="panel-section-label">Mode</div>
<div class="panel-btn-row wrap">
${fanModes.map(m => `<button class="panel-btn ${fan === m ? 'active' : ''}" data-action="vacuum_fan" data-value="${m}">${m}</button>`).join('')}
</div>
</div>
` : ''}
<div class="panel-section">
<div class="panel-section-label">Controls</div>
<div class="panel-btn-row">
<button class="panel-btn" data-action="vacuum_cmd" data-value="start"><ha-icon icon="mdi:play" style="--mdc-icon-size:20px"></ha-icon> Start</button>
<button class="panel-btn" data-action="vacuum_cmd" data-value="pause"><ha-icon icon="mdi:pause" style="--mdc-icon-size:20px"></ha-icon> Pause</button>
<button class="panel-btn" data-action="vacuum_cmd" data-value="return_to_base"><ha-icon icon="mdi:home" style="--mdc-icon-size:20px"></ha-icon> Dock</button>
</div>
</div>
`;
}
/* ── Wire panel interactions ──────────────────────────────────── */
function wirePanel(inner, hass, entityId, stateObj, cardType, domain, accent, rgb) {
// Sliders
inner.querySelectorAll('.panel-slider').forEach(slider => {
const action = slider.dataset.action;
const valueEl = slider.closest('.panel-slider-wrap')?.querySelector('.panel-slider-value');
slider.addEventListener('input', () => {
const v = parseFloat(slider.value);
const min = parseFloat(slider.min);
const max = parseFloat(slider.max);
const pct = Math.round((v - min) / (max - min) * 100);
slider.style.setProperty('--fill', pct + '%');
if (action === 'brightness') { if (valueEl) valueEl.textContent = Math.round(v / 255 * 100) + '%'; }
else if (action === 'temperature') { if (valueEl) valueEl.textContent = v + '°'; }
else if (action === 'color_temp') { if (valueEl) valueEl.textContent = v + ' K'; }
else if (action === 'cover_position') { if (valueEl) valueEl.textContent = v + '%'; }
else if (action === 'volume') { if (valueEl) valueEl.textContent = v + '%'; }
else if (action === 'fan_manual') { if (valueEl) valueEl.textContent = (v + 1); }
});
let debounce = null;
slider.addEventListener('change', () => {
clearTimeout(debounce);
debounce = setTimeout(() => {
const v = parseFloat(slider.value);
if (action === 'brightness') {
hass.callService('light', 'turn_on', { entity_id: entityId, brightness: v });
} else if (action === 'temperature') {
hass.callService('climate', 'set_temperature', { entity_id: entityId, temperature: v });
} else if (action === 'color_temp') {
hass.callService('light', 'turn_on', { entity_id: entityId, color_temp_kelvin: v });
} else if (action === 'cover_position') {
hass.callService('cover', 'set_cover_position', { entity_id: entityId, position: v });
} else if (action === 'volume') {
hass.callService('media_player', 'volume_set', { entity_id: entityId, volume_level: v / 100 });
} else if (action === 'fan_manual') {
const modes = slider.dataset.modes.split(',');
hass.callService('climate', 'set_fan_mode', { entity_id: entityId, fan_mode: modes[v] });
}
}, 200);
});
});
// Buttons
inner.querySelectorAll('[data-action]').forEach(btn => {
if (btn.tagName === 'INPUT') return;
btn.addEventListener('click', () => {
const action = btn.dataset.action;
const value = btn.dataset.value;
if (action === 'preset') {
if (value === 'off') { hass.callService('light', 'turn_off', { entity_id: entityId }); }
else { hass.callService('light', 'turn_on', { entity_id: entityId, brightness_pct: parseInt(value) }); }
} else if (action === 'hvac_mode') {
hass.callService('climate', 'set_hvac_mode', { entity_id: entityId, hvac_mode: value });
} else if (action === 'fan_mode') {
hass.callService('climate', 'set_fan_mode', { entity_id: entityId, fan_mode: value });
} else if (action === 'swing_mode') {
hass.callService('climate', 'set_swing_mode', { entity_id: entityId, swing_mode: value });
} else if (action === 'color') {
const h = hexToRgb(value);
hass.callService('light', 'turn_on', { entity_id: entityId, rgb_color: [h.r, h.g, h.b] });
} else if (action === 'effect') {
hass.callService('light', 'turn_on', { entity_id: entityId, effect: value });
} else if (action === 'cover_cmd') {
const svc = { open: 'open_cover', close: 'close_cover', stop: 'stop_cover' }[value];
if (svc) hass.callService('cover', svc, { entity_id: entityId });
} else if (action === 'cover_position_btn') {
hass.callService('cover', 'set_cover_position', { entity_id: entityId, position: parseInt(value) });
} else if (action === 'media_cmd') {
const svcMap = { play: 'media_play', pause: 'media_pause', previous: 'media_previous_track', next: 'media_next_track' };
hass.callService('media_player', svcMap[value] || value, { entity_id: entityId });
} else if (action === 'vacuum_cmd') {
hass.callService('vacuum', value, { entity_id: entityId });
} else if (action === 'vacuum_fan') {
hass.callService('vacuum', 'set_fan_speed', { entity_id: entityId, fan_speed: value });
}
// Update active states after a delay
setTimeout(() => {
if (panelOverlay) {
const freshState = hass.states[entityId];
if (freshState) {
const inner2 = panelOverlay.querySelector('.luma-panel-inner');
if (inner2) {
inner2.innerHTML = buildPanelHeader(freshState.attributes.friendly_name || entityId, freshState, accent, rgb, cardType, domain) +
buildPanelSections(hass, entityId, freshState, cardType, domain, accent, rgb);
wirePanel(inner2, hass, entityId, freshState, cardType, domain, accent, rgb);
}
}
}
}, 600);
});
});
}
/* ── luma-card custom element ─────────────────────────────────── */
class LumaCard extends HTMLElement {
set hass(hass) {
this._hass = hass;
if (!this._config) return;
this._render();
}
setConfig(config) {
if (!config.entity) throw new Error('Missing entity');
this._config = config;
}
_render() {
const config = this._config;
const hass = this._hass;
const entityId = config.entity;
const stateObj = hass.states[entityId];
if (!stateObj) {
this.innerHTML = `<ha-card><div style="padding:16px;color:#888">Entity not found: ${entityId}</div></ha-card>`;
return;
}
const cardType = config.type_hint || entityId.split('.')[0];
const on = isOn(stateObj);
const accent = accentForDomain(entityId, stateObj);
const rgb = hexToRgb(accent);
const domain = entityId.split('.')[0];
const name = config.name || stateObj.attributes.friendly_name || entityId;
const icon = config.icon || iconForEntity(entityId, cardType, stateObj);
// Value text
let value = '';
if (domain === 'light') {
const bri = stateObj.attributes.brightness;
value = on ? (bri !== undefined ? Math.round(bri / 255 * 100) + '%' : 'On') : 'Off';
} else if (domain === 'climate') {
const t = stateObj.attributes.temperature;
const cur = stateObj.attributes.current_temperature;
value = on ? `${t || '–'}°` : 'Off';
} else if (domain === 'cover') {
const pos = stateObj.attributes.current_position;
value = pos >= 99 ? 'Open' : pos < 1 ? 'Closed' : `${pos}%`;
} else if (domain === 'media_player') {
value = stateObj.state === 'playing' ? '▶' : stateObj.state === 'paused' ? '❚❚' : 'Off';
} else if (domain === 'vacuum') {
value = stateObj.state.charAt(0).toUpperCase() + stateObj.state.slice(1);
} else if (domain === 'switch') {
value = on ? 'On' : 'Off';
} else if (domain === 'scene' || domain === 'script') {
value = '';
} else {
value = stateObj.state;
}
// Subtitle
let subtitle = '';
if (domain === 'light' && on) {
const cm = stateObj.attributes.color_mode;
if (cm === 'color_temp') {
const k = stateObj.attributes.color_temp_kelvin;
subtitle = k ? `White · ${k} K` : '';
} else if (['hs', 'rgb', 'xy'].includes(cm)) {
subtitle = 'Colour';
} else {
const bri = stateObj.attributes.brightness;
subtitle = bri !== undefined ? `On · ${Math.round(bri / 255 * 100)}%` : 'On';
}
} else if (domain === 'climate') {
const cur = stateObj.attributes.current_temperature;
const mode = { heat: 'Heating', cool: 'Cooling', auto: 'Auto', dry: 'Dry', fan_only: 'Fan', off: 'Off' }[stateObj.state] || stateObj.state;
subtitle = cur ? `${mode} · ${cur}°` : mode;
} else if (domain === 'media_player' && stateObj.state === 'playing') {
subtitle = stateObj.attributes.media_artist || '';
} else if (domain === 'vacuum') {
const batt = stateObj.attributes.battery_level;
subtitle = batt !== undefined ? `Battery ${batt}%` : '';
}
// Middle band
let middleBand = '';
if (domain === 'light' && on) {
const bri = stateObj.attributes.brightness || 0;
const pct = Math.round(bri / 255 * 100);
middleBand = `<div class="luma-bar"><div class="luma-bar-fill" style="width:${pct}%;background:${accent}"></div></div>`;
} else if (domain === 'cover') {
const pos = stateObj.attributes.current_position ?? 0;
middleBand = buildCurtainSvg(pos, on, accent);
} else if (domain === 'climate') {
middleBand = `<div class="luma-bar"><div class="luma-bar-fill" style="width:${Math.min(100, Math.max(0, (stateObj.attributes.current_temperature || 20 - 15) / 20 * 100))}%;background:${accent}"></div></div>`;
} else if (domain === 'media_player') {
const artUrl = stateObj.attributes.entity_picture;
if (artUrl) middleBand = `<div class="luma-media-band"><img src="${artUrl}" class="luma-media-art"/></div>`;
}
const glowStyle = on ? `background: radial-gradient(circle at 42px 42px, rgba(${rgb.r},${rgb.g},${rgb.b},0.35), transparent 72%);` : '';
if (!this.shadowRoot) {
this.attachShadow({ mode: 'open' });
const style = document.createElement('style');
style.textContent = CARD_STYLES;
this.shadowRoot.appendChild(style);
const wrap = document.createElement('div');
wrap.className = 'luma-card-wrap';
this.shadowRoot.appendChild(wrap);
}
const isToggle = domain === 'switch' || domain === 'scene' || domain === 'script' ||
(domain === 'light' && !stateObj.attributes.supported_color_modes?.length);
const wrap = this.shadowRoot.querySelector('.luma-card-wrap');
wrap.innerHTML = `
<div class="luma-card ${on ? 'on' : 'off'}" style="${glowStyle}">
<div class="luma-top-row">
<div class="luma-icon-circle" style="background:${on ? accent : T.card.iconOff}">
<ha-icon icon="${icon}" style="color:${on ? '#15181E' : 'rgba(255,255,255,0.80)'};--mdc-icon-size:24px"></ha-icon>
</div>
<div class="luma-name">${name}</div>
<div class="luma-value" style="color:${on ? accent : 'rgba(255,255,255,0.60)'}">${value}</div>
</div>
${middleBand ? `<div class="luma-middle">${middleBand}</div>` : '<div class="luma-middle-empty"></div>'}
${subtitle ? `<div class="luma-subtitle">${subtitle}</div>` : ''}
</div>
`;
// Click handler
wrap.onclick = () => {
if (isToggle) {
const svc = domain === 'scene' ? 'scene' : domain === 'script' ? 'script' : domain;
const action = domain === 'scene' ? 'turn_on' : domain === 'script' ? 'turn_on' : 'toggle';
hass.callService(svc, action, { entity_id: entityId });
} else {
openLumaPanel(hass, entityId, cardType, name);
}
};
}
getCardSize() { return 3; }
static getConfigElement() { return document.createElement('div'); }
static getStubConfig() { return { entity: '' }; }
}
function buildCurtainSvg(pos, isOpen, accent) {
const openFrac = pos / 100;
const halfW = 86;
const curtainW = Math.round(halfW * (1 - openFrac));
const rgb = hexToRgb(accent);
const curtainColor = isOpen ? `rgba(${rgb.r},${rgb.g},${rgb.b},0.50)` : 'rgba(255,255,255,0.18)';
const edgeColor = isOpen ? accent : 'rgba(255,255,255,0.30)';
return `
<svg viewBox="0 0 180 60" class="luma-curtain-svg">
<rect x="0" y="0" width="180" height="60" rx="4" fill="rgba(255,255,255,0.06)" stroke="rgba(255,255,255,0.12)" stroke-width="1"/>
<rect x="2" y="2" width="176" height="3" rx="1.5" fill="rgba(255,255,255,0.20)"/>
<line x1="60" y1="5" x2="60" y2="58" stroke="rgba(255,255,255,0.22)" stroke-width="1"/>
<line x1="120" y1="5" x2="120" y2="58" stroke="rgba(255,255,255,0.22)" stroke-width="1"/>
${curtainW > 1 ? `<rect x="2" y="5" width="${curtainW}" height="53" rx="2" fill="${curtainColor}"/>
<rect x="${curtainW - 3}" y="5" width="3" height="53" fill="${edgeColor}"/>` : ''}
${curtainW > 1 ? `<rect x="${178 - curtainW}" y="5" width="${curtainW}" height="53" rx="2" fill="${curtainColor}"/>
<rect x="${178 - curtainW}" y="5" width="3" height="53" fill="${edgeColor}"/>` : ''}
</svg>
`;
}
/* ── CSS ─────────────────────────────────────────────────────── */
const CARD_STYLES = `
:host {
--luma-card-fill: ${T.card.fill};
--luma-card-border: ${T.card.border};
--luma-card-radius: ${T.card.radius}px;
display: block;
}
.luma-card-wrap { cursor: pointer; }
.luma-card {
position: relative;
background: var(--luma-card-fill);
border: 2px solid var(--luma-card-border);
border-radius: var(--luma-card-radius);
padding: 18px;
min-height: 140px;
box-shadow: ${T.shadow.card};
transition: transform 120ms ease-out, box-shadow 120ms ease-out;
overflow: hidden;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
.luma-card:hover {
transform: scale(1.02);
box-shadow: 0 8px 24px rgba(0,0,0,0.35);
border-color: rgba(255,255,255,0.20);
}
.luma-card:active { transform: scale(0.98); }
.luma-top-row {
display: flex;
align-items: center;
gap: 14px;
}
.luma-icon-circle {
width: 48px; height: 48px; border-radius: 50%;
display: flex; align-items: center; justify-content: center;
flex-shrink: 0;
}
.luma-name {
flex: 1; font-size: 19px; font-weight: 600; color: #fff;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
letter-spacing: -0.02em;
}
.luma-value {
font-size: 20px; font-weight: 700; white-space: nowrap;
letter-spacing: -0.02em;
}
.luma-middle { margin: 12px 0 10px; flex: 1; display: flex; align-items: center; }
.luma-middle-empty { flex: 1; min-height: 12px; }
.luma-bar {
width: 100%; height: 12px; border-radius: 6px;
background: ${T.card.barTrack};
}
.luma-bar-fill {
height: 100%; border-radius: 6px;
transition: width 300ms ease;
}
.luma-subtitle {
font-size: 14px; font-weight: 500; color: rgba(255,255,255,0.72);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.luma-curtain-svg { width: 100%; height: auto; }
.luma-media-band { display: flex; align-items: center; gap: 12px; }
.luma-media-art {
width: 64px; height: 64px; border-radius: 8px; object-fit: cover;
flex-shrink: 0;
}
`;
/* ── Panel overlay CSS (injected once into document) ─────────── */
const PANEL_CSS = `
.luma-panel-overlay {
position: fixed; inset: 0; z-index: 99999;
display: flex; justify-content: flex-end;
pointer-events: none; opacity: 0;
transition: opacity 200ms ease-out;
font-family: Inter, system-ui, -apple-system, sans-serif;
}
.luma-panel-overlay.open { opacity: 1; pointer-events: auto; }
.luma-panel-backdrop {
position: absolute; inset: 0;
background: rgba(0,0,0,0.45);
}
.luma-panel {
position: relative;
width: ${T.panel.width}px; max-width: 90vw;
height: 100vh;
background: ${T.panel.fill};
border-radius: ${T.panel.radius}px 0 0 ${T.panel.radius}px;
box-shadow: ${T.shadow.panel};
overflow-y: auto; overflow-x: hidden;
transform: translateX(100%);
transition: transform 200ms ease-out;
-webkit-overflow-scrolling: touch;
}
.luma-panel-overlay.open .luma-panel { transform: translateX(0); }
.luma-panel-inner { padding: 48px 40px 40px; }
/* Panel header */
.panel-header { margin-bottom: 36px; }
.panel-icon {
width: 56px; height: 56px; border-radius: 50%;
display: flex; align-items: center; justify-content: center;
margin-bottom: 12px;
}
.panel-title { font-size: 30px; font-weight: 700; color: #fff; margin-bottom: 4px; }
.panel-state { font-size: 18px; font-weight: 600; }
/* Sections */
.panel-section { margin-bottom: 28px; }
.panel-section-label {
font-size: 17px; font-weight: 600; color: ${T.panel.label};
margin-bottom: 12px;
}
/* Slider */
.panel-slider-wrap { position: relative; }
.panel-slider {
-webkit-appearance: none; appearance: none;
width: 100%; height: 56px;
background: linear-gradient(to right, var(--accent) 0%, var(--accent) var(--fill), rgba(255,255,255,0.12) var(--fill));
border-radius: 16px; outline: none;
cursor: pointer;
}
.panel-slider::-webkit-slider-thumb {
-webkit-appearance: none; appearance: none;
width: 4px; height: 40px; background: rgba(255,255,255,0.5);
border-radius: 2px; cursor: pointer;
}
.panel-slider::-moz-range-thumb {
width: 4px; height: 40px; background: rgba(255,255,255,0.5);
border-radius: 2px; border: 0; cursor: pointer;
}
.panel-slider.warm {
background: linear-gradient(to right, #FFB46B 0%, #FFB46B var(--fill), rgba(255,255,255,0.12) var(--fill));
}
.panel-slider-value {
position: absolute; left: 16px; top: 50%; transform: translateY(-50%);
font-size: 24px; font-weight: 700; color: #fff; pointer-events: none;
}
.panel-help {
font-size: 14px; color: rgba(255,255,255,0.60); margin-top: 6px;
}
/* Button row */
.panel-btn-row {
display: flex; gap: 10px;
}
.panel-btn-row.wrap { flex-wrap: wrap; }
.panel-btn {
flex: 1; min-width: 0;
height: 52px; border-radius: 14px;
background: ${T.panel.control}; border: 2px solid transparent;
color: rgba(255,255,255,0.72); font-size: 17px; font-weight: 600;
cursor: pointer; display: flex; align-items: center; justify-content: center; gap: 6px;
transition: background 120ms, border-color 120ms;
}
.panel-btn:hover { background: rgba(255,255,255,0.16); }
.panel-btn.active {
background: rgba(var(--r,245),var(--g,184),var(--b,90),0.22);
border-color: var(--accent, ${T.accent.light});
color: #fff;
}
/* Icon button row (mode circles) */
.panel-icon-row {
display: flex; gap: 14px; justify-content: center;
}
.panel-icon-btn {
width: 56px; display: flex; flex-direction: column; align-items: center; gap: 6px;
background: none; border: none; cursor: pointer; color: rgba(255,255,255,0.60);
}
.panel-icon-btn .panel-icon-btn-circle,
.panel-icon-btn ha-icon {
width: 48px; height: 48px; border-radius: 50%;
background: ${T.panel.control};
display: flex; align-items: center; justify-content: center;
transition: background 120ms;
}
.panel-icon-btn ha-icon {
color: rgba(255,255,255,0.80);
}
.panel-icon-btn.active ha-icon {
background: rgba(var(--r,240),var(--g,106),var(--b,74),0.22);
color: #fff;
}
.panel-icon-btn.active { color: #fff; }
.panel-icon-btn span { font-size: 12px; font-weight: 500; }
.panel-icon-btn.small { width: 48px; }
.panel-icon-btn.small ha-icon { width: 40px; height: 40px; }
/* Swatches */
.panel-swatch-row { display: flex; gap: 10px; justify-content: center; }
.panel-swatch {
width: 40px; height: 40px; border-radius: 50%; border: 2px solid transparent;
cursor: pointer; transition: transform 120ms;
}
.panel-swatch:hover { transform: scale(1.15); border-color: rgba(255,255,255,0.4); }
/* Effects grid */
.panel-effect-grid {
display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px;
}
.panel-effect-btn {
height: 44px; border-radius: 12px;
background: ${T.panel.control}; border: 2px solid transparent;
color: rgba(255,255,255,0.60); font-size: 12px; font-weight: 500;
cursor: pointer; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
padding: 0 6px;
}
.panel-effect-btn.active {
border-color: var(--accent, ${T.accent.led});
color: #fff;
}
/* Media */
.media-now {
display: flex; gap: 16px; align-items: center;
margin-bottom: 20px;
}
.media-art {
width: 80px; height: 80px; border-radius: 12px; object-fit: cover; flex-shrink: 0;
}
.media-art-placeholder {
width: 80px; height: 80px; border-radius: 12px; background: rgba(255,255,255,0.06);
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
}
.media-info { flex: 1; min-width: 0; }
.media-title { font-size: 20px; font-weight: 700; color: #fff; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.media-artist { font-size: 15px; color: rgba(255,255,255,0.72); margin-top: 2px; }
.media-progress-bar {
height: 4px; border-radius: 2px;
background: rgba(255,255,255,0.14);
position: relative; overflow: hidden;
}
.media-progress-bar::after {
content: ''; position: absolute; left: 0; top: 0; bottom: 0;
width: var(--fill); background: rgba(255,255,255,0.70);
border-radius: 2px;
}
.media-times {
display: flex; justify-content: space-between;
font-size: 11px; color: rgba(255,255,255,0.45); margin-top: 4px;
}
.media-controls {
display: flex; align-items: center; justify-content: center; gap: 24px;
}
.media-btn {
width: 48px; height: 48px; border-radius: 50%;
background: rgba(255,255,255,0.08); border: none;
display: flex; align-items: center; justify-content: center;
cursor: pointer; color: rgba(255,255,255,0.60);
transition: background 120ms;
}
.media-btn:hover { background: rgba(255,255,255,0.16); }
.media-btn.play { width: 56px; height: 56px; }
`;
/* ── Inject panel CSS once ───────────────────────────────────── */
if (!document.getElementById('luma-panel-styles')) {
const s = document.createElement('style');
s.id = 'luma-panel-styles';
s.textContent = PANEL_CSS;
document.head.appendChild(s);
}
/* ── Register ─────────────────────────────────────────────────── */
customElements.define('luma-card', LumaCard);
window.customCards = window.customCards || [];
window.customCards.push({
type: 'luma-card',
name: 'Luma Card',
description: 'Glass-effect device card matching the Luma TV launcher design.',
preview: true,
});
console.info(`%c LUMA-CARDS %c v${LUMA_VERSION} `, 'background:#F5B85A;color:#15181E;font-weight:700', 'background:#22262E;color:#F5B85A');