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>
1014 lines
42 KiB
JavaScript
1014 lines
42 KiB
JavaScript
/**
|
||
* 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');
|