/**
* 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 = `
`;
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 `
`;
}
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 ``;
}
/* ── 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 = `
`;
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 += `
`;
}
if (hasColor) {
const swatches = ['#FF5A40','#F0A050','#F2D04B','#3DDC97','#4C9BF0','#7B5BE0','#E45AD8','#FFFFFF'];
html += `
Colour
${swatches.map(c => ``).join('')}
`;
}
if (hasEffects) {
const effects = stateObj.attributes.effect_list || [];
const cur = stateObj.attributes.effect || '';
html += `
Effects
${effects.slice(0, 12).map(e => ``).join('')}
`;
}
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 = `
`;
if (modes.length > 0) {
html += `
Mode
${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 ``;
}).join('')}
`;
}
// 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 += `
Fan speed · auto
${autoModes.map(([label, val]) => ``).join('')}
`;
}
// 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 += `
Fan speed · manual
1 — ${manualModes.length}
`;
}
}
// Airflow
if (swingModes.length > 0) {
const swingLabels = { off: 'Off', default: 'Off', vertical: 'Swing', horizontal: 'Horiz.', both: 'Both', swing_ud: 'Swing', swing: 'Swing' };
html += `
Up–down airflow
${swingModes.map(m => {
const label = swingLabels[m] || (m.startsWith('fixed_') ? `Pos ${m.split('_')[1]}` : m);
return ``;
}).join('')}
`;
}
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 `
Presets
${presets.map(([l, v]) => ``).join('')}
`;
}
/* ── 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 `
${duration > 0 ? `
${fmt(position)}-${fmt(Math.max(0, duration - position))}
` : ''}
`;
}
/* ── 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 `
Status
${status.charAt(0).toUpperCase() + status.slice(1)}
${battery !== undefined ? ` · ${battery}%` : ''}
${fanModes.length > 0 ? `
Mode
${fanModes.map(m => ``).join('')}
` : ''}
Controls
`;
}
/* ── 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 = `Entity not found: ${entityId}
`;
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 = ``;
} else if (domain === 'cover') {
const pos = stateObj.attributes.current_position ?? 0;
middleBand = buildCurtainSvg(pos, on, accent);
} else if (domain === 'climate') {
middleBand = ``;
} else if (domain === 'media_player') {
const artUrl = stateObj.attributes.entity_picture;
if (artUrl) middleBand = ``;
}
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 = `
${middleBand ? `
${middleBand}
` : '
'}
${subtitle ? `
${subtitle}
` : ''}
`;
// 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 `
`;
}
/* ── 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');