/** * 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 `
${name}
${stateLine}
`; } 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 `
State
${stateObj.state}
`; } /* ── 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 = `
Brightness
${pct}%
Presets
`; 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 += `
Colour temperature
${ct} K
`; } 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 = `
Target
${target}°
${min}° – ${max}°
`; 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
${idx >= 0 ? idx + 1 : '·'}
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 `
Position
${pos}%
Controls
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 `
${artUrl ? `` : '
'}
${title || 'Nothing playing'}
${artist}
${duration > 0 ? `
${fmt(position)}-${fmt(Math.max(0, duration - position))}
` : ''}
Volume · ${vol}%
${vol}%
`; } /* ── 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 = `
${name}
${value}
${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 ` ${curtainW > 1 ? ` ` : ''} ${curtainW > 1 ? ` ` : ''} `; } /* ── 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');