Redesign panels, add 11 card fixes, new icons and app surfaces
Panel fixes: tall card row_span, room tab styling, vacuum card image+value, AC airflow labels, AC fan speed sections (auto 3-btn + manual 5-step slider), light subtitle fallback, curtain pane dividers, dimmable light presets, LED panel brightness + reorder. New assets: 34 ui-icons (climate modes, cover, effects, battery, led-strip, switch states), 3 logos, 90 app surface PNGs, design system specs (components, ux-mechanics, tokens). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
89ff17a3bc
commit
86191ba5f3
221 files changed
+6017
-1123
No files matched your search
+122
-54
@@ -1,37 +1,36 @@
|
||||
const LUMA_VACUUM_VERSION = '0.1.0';
|
||||
const LUMA_VACUUM_VERSION = '0.2.0';
|
||||
|
||||
class LumaVacuumCard extends HTMLElement {
|
||||
set hass(hass) { this._hass = hass; this._render(); }
|
||||
|
||||
setConfig(config) {
|
||||
if (!config.entity) throw new Error('Entity is required');
|
||||
this._config = { entity: config.entity, name: config.name || '', size: config.size || '1x1',
|
||||
this._config = { entity: config.entity, name: config.name || '', size: config.size || '1x2',
|
||||
accent_color: config.accent_color || '', icon: config.icon || '',
|
||||
image: config.image || '',
|
||||
tap_action: config.tap_action || { action: 'more-info' },
|
||||
hold_action: config.hold_action || { action: 'more-info' }, ...config };
|
||||
this._render();
|
||||
}
|
||||
|
||||
getCardSize() { return 2; }
|
||||
static getStubConfig() { return { entity: '', name: '', size: '1x1' }; }
|
||||
getCardSize() { return 4; }
|
||||
static getStubConfig() { return { entity: '', name: '', size: '1x2' }; }
|
||||
connectedCallback() { if (!this.shadowRoot) this.attachShadow({ mode: 'open' }); this._render(); }
|
||||
|
||||
_getState() { return this._hass?.states?.[this._config?.entity]; }
|
||||
|
||||
_getAccent() {
|
||||
if (this._config?.accent_color) return this._config.accent_color;
|
||||
const s = this._getState();
|
||||
if (!s) return 'rgba(187,187,187,0.5)';
|
||||
switch (s.state) {
|
||||
case 'cleaning': return '#7EB8DA';
|
||||
case 'returning': return '#f0a060';
|
||||
default: return 'rgba(187,187,187,0.5)';
|
||||
}
|
||||
_getPrefix() {
|
||||
return this._config?.entity?.replace(/^vacuum\./, '') || '';
|
||||
}
|
||||
|
||||
_isActive() {
|
||||
const s = this._getState();
|
||||
return s && (s.state === 'cleaning' || s.state === 'returning');
|
||||
_getSensorState(suffix) {
|
||||
const id = `sensor.${this._getPrefix()}_${suffix}`;
|
||||
return this._hass?.states?.[id];
|
||||
}
|
||||
|
||||
_callService(service, data = {}) {
|
||||
if (!this._hass || !this._config) return;
|
||||
this._hass.callService('vacuum', service, { entity_id: this._config.entity, ...data });
|
||||
}
|
||||
|
||||
_handleClick() {
|
||||
@@ -44,61 +43,130 @@ class LumaVacuumCard extends HTMLElement {
|
||||
_render() {
|
||||
if (!this.shadowRoot || !this._config) return;
|
||||
const s = this._getState();
|
||||
const isActive = this._isActive();
|
||||
const accent = this._getAccent();
|
||||
const name = this._config.name || s?.attributes?.friendly_name || '';
|
||||
const icon = this._config.icon || 'mdi:robot-vacuum';
|
||||
const status = s?.state || 'unavailable';
|
||||
const displayStatus = { cleaning: 'Cleaning', returning: 'Returning', docked: 'Docked', idle: 'Idle', paused: 'Paused' }[status] || status;
|
||||
const prefix = this._getPrefix();
|
||||
|
||||
const fillColor = status === 'cleaning' ? 'rgba(126,184,218,0.25)' : status === 'returning' ? 'rgba(240,160,96,0.20)' : 'transparent';
|
||||
const fillPct = status === 'cleaning' ? 60 : status === 'returning' ? 30 : 0;
|
||||
const statusSensor = this._getSensorState('status');
|
||||
const statusText = statusSensor?.state || s?.state || 'unavailable';
|
||||
|
||||
const isCleaning = ['cleaning', 'segment_cleaning', 'zoned_cleaning', 'spot_cleaning',
|
||||
'segment_mopping', 'robot_status_mopping'].includes(statusText);
|
||||
const isReturning = ['returning_home', 'docking'].includes(statusText);
|
||||
|
||||
const errorSensor = this._getSensorState('vacuum_error');
|
||||
const hasError = errorSensor && errorSensor.state !== 'none' && errorSensor.state !== '';
|
||||
|
||||
const batterySensor = this._getSensorState('battery');
|
||||
const battery = batterySensor ? parseInt(batterySensor.state) || 0 : 0;
|
||||
|
||||
const accent = hasError ? '#e05050'
|
||||
: isCleaning ? '#7EB8DA'
|
||||
: isReturning ? '#f0a060'
|
||||
: 'rgba(157,170,184,0.7)';
|
||||
|
||||
const displayStatus = {
|
||||
charging: 'Charged', charging_complete: 'Charged',
|
||||
cleaning: 'Cleaning', segment_cleaning: 'Cleaning', zoned_cleaning: 'Cleaning',
|
||||
returning_home: 'Returning', docking: 'Returning',
|
||||
washing_the_mop: 'Washing mop', going_to_wash_the_mop: 'Washing mop',
|
||||
emptying_the_bin: 'Emptying bin',
|
||||
docked: 'Docked', idle: 'Idle', paused: 'Paused', error: 'Error',
|
||||
}[statusText] || statusText;
|
||||
|
||||
const statusLine = hasError
|
||||
? `⚠ ${errorSensor.state.replace(/_/g, ' ')} · ${battery}%`
|
||||
: `${displayStatus} · ${battery}%`;
|
||||
|
||||
const statusColor = hasError ? '#e05050' : isCleaning ? accent : 'rgba(232,232,232,0.50)';
|
||||
|
||||
const name = this._config.name || s?.attributes?.friendly_name || '';
|
||||
|
||||
const glowHtml = isCleaning
|
||||
? '<div class="glow glow-green"></div>'
|
||||
: hasError
|
||||
? '<div class="glow glow-red"></div>'
|
||||
: '';
|
||||
|
||||
const actions = ['Dock', 'Mop Only', 'V&M Fast', 'After Meals', 'Vac & Mop', 'Custom'];
|
||||
const actionsHtml = actions.map((label, i) => {
|
||||
const cls = i === 5 ? 'action-btn custom' : 'action-btn';
|
||||
return `<button class="${cls}" data-idx="${i}">${label}</button>`;
|
||||
}).join('');
|
||||
|
||||
this.shadowRoot.innerHTML = `
|
||||
<style>
|
||||
:host { display: block; --accent: ${accent}; }
|
||||
.card {
|
||||
position: relative; overflow: hidden; background: rgba(36,40,48,0.75);
|
||||
border: 1px solid rgba(255,255,255,0.10); border-radius: 22px; padding: 16px;
|
||||
cursor: pointer; transition: border-color .15s, box-shadow .15s;
|
||||
height: 100%; min-height: 120px; display: flex; flex-direction: column;
|
||||
justify-content: space-between; box-shadow: 0 2px 6px rgba(0,0,0,.06);
|
||||
border: 1px solid rgba(255,255,255,0.10); border-radius: 22px;
|
||||
height: 100%; min-height: 280px; display: flex; flex-direction: column;
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,.06); padding: 16px;
|
||||
transition: border-color .15s, box-shadow .15s;
|
||||
}
|
||||
.card:hover { border-color: rgba(255,255,255,0.18); box-shadow: 0 8px 28px rgba(0,0,0,.28); }
|
||||
.card.active { border-color: color-mix(in srgb, var(--accent) 25%, transparent); }
|
||||
.fill {
|
||||
position: absolute; left: 0; top: 0; bottom: 0; border-radius: 22px;
|
||||
width: ${fillPct}%; background: ${fillColor}; pointer-events: none;
|
||||
transition: width .3s;
|
||||
|
||||
.glow {
|
||||
position: absolute; top: -20px; right: -20px; width: 200px; height: 200px;
|
||||
border-radius: 50%; pointer-events: none; filter: blur(40px);
|
||||
}
|
||||
.top { display: flex; align-items: center; gap: 12px; position: relative; z-index: 1; }
|
||||
.icon-circle {
|
||||
width: 48px; height: 48px; border-radius: 24px; display: flex;
|
||||
align-items: center; justify-content: center; flex-shrink: 0;
|
||||
background: ${isActive ? 'color-mix(in srgb, var(--accent) 20%, transparent)' : 'rgba(255,255,255,0.08)'};
|
||||
.glow-green { background: rgba(80,224,112,0.35); }
|
||||
.glow-red { background: rgba(224,80,80,0.35); }
|
||||
|
||||
.header { position: relative; z-index: 1; }
|
||||
.name { font-size: 18px; font-weight: 600; letter-spacing: -0.02em; color: #fff; }
|
||||
.status { font-size: 13px; font-weight: 400; letter-spacing: 0.02em; color: ${statusColor}; margin-top: 4px; }
|
||||
|
||||
.product-img {
|
||||
position: absolute; top: 16px; right: 16px; width: 160px; height: 160px;
|
||||
object-fit: contain; z-index: 1; pointer-events: none;
|
||||
}
|
||||
.icon-circle ha-icon { --mdc-icon-size: 28px; color: var(--accent); }
|
||||
.info { flex: 1; min-width: 0; }
|
||||
.type-label { font-size: 12px; font-weight: 400; letter-spacing: 0.04em; color: ${isActive ? 'var(--accent)' : 'rgba(232,232,232,0.45)'}; }
|
||||
.name { font-size: 16px; font-weight: 600; letter-spacing: -0.02em; color: #fff; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: 2px; }
|
||||
.status { font-size: 20px; font-weight: 700; letter-spacing: -0.02em; color: ${isActive ? '#fff' : 'rgba(187,187,187,0.5)'}; position: relative; z-index: 1; }
|
||||
.product-img-placeholder {
|
||||
position: absolute; top: 16px; right: 16px; width: 160px; height: 160px;
|
||||
display: flex; align-items: center; justify-content: center; z-index: 1;
|
||||
}
|
||||
.product-img-placeholder ha-icon { --mdc-icon-size: 80px; color: rgba(187,187,187,0.2); }
|
||||
|
||||
.actions {
|
||||
display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 8px;
|
||||
margin-top: auto; position: relative; z-index: 1;
|
||||
}
|
||||
.action-btn {
|
||||
background: rgba(255,255,255,0.07); border: 1px solid rgba(255,255,255,0.08);
|
||||
border-radius: 12px; color: rgba(232,232,232,0.80); font-size: 13px; font-weight: 500;
|
||||
padding: 14px 8px; cursor: pointer; transition: all .15s; text-align: center;
|
||||
}
|
||||
.action-btn:hover { background: rgba(126,184,218,0.20); border-color: rgba(126,184,218,0.5); color: #fff; }
|
||||
.action-btn.custom { color: rgba(232,232,232,0.55); background: rgba(255,255,255,0.04); }
|
||||
.action-btn.custom:hover { background: rgba(255,255,255,0.10); color: rgba(232,232,232,0.80); }
|
||||
</style>
|
||||
<div class="card ${isActive ? 'active' : ''}">
|
||||
<div class="fill"></div>
|
||||
<div class="top">
|
||||
<div class="icon-circle"><ha-icon icon="${icon}"></ha-icon></div>
|
||||
<div class="info">
|
||||
<div class="type-label">Vacuum</div>
|
||||
<div class="name">${name}</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
${glowHtml}
|
||||
<div class="header">
|
||||
<div class="name">${name}</div>
|
||||
<div class="status">${statusLine}</div>
|
||||
</div>
|
||||
<div class="status">${displayStatus}</div>
|
||||
${this._config.image
|
||||
? `<img class="product-img" src="${this._config.image}" alt="" />`
|
||||
: `<div class="product-img-placeholder"><ha-icon icon="mdi:robot-vacuum"></ha-icon></div>`}
|
||||
<div class="actions">${actionsHtml}</div>
|
||||
</div>`;
|
||||
this.shadowRoot.querySelector('.card').addEventListener('click', () => this._handleClick());
|
||||
|
||||
this.shadowRoot.querySelector('.header').addEventListener('click', () => this._handleClick());
|
||||
|
||||
this.shadowRoot.querySelectorAll('.action-btn').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const idx = parseInt(btn.dataset.idx);
|
||||
switch (idx) {
|
||||
case 0: this._callService('return_to_base'); break;
|
||||
case 5: this._handleClick(); break;
|
||||
default: this._handleClick(); break;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('luma-vacuum-card', LumaVacuumCard);
|
||||
window.customCards = window.customCards || [];
|
||||
window.customCards.push({ type: 'luma-vacuum-card', name: 'Luma Vacuum', description: 'Luma-style robot vacuum card with status display.', preview: true });
|
||||
window.customCards.push({ type: 'luma-vacuum-card', name: 'Luma Vacuum', description: 'Luma-style vacuum card with product image, battery, status, and 6-button action grid.', preview: true });
|
||||
console.info(`%c LUMA-VACUUM-CARD %c v${LUMA_VACUUM_VERSION} `, 'background:#7EB8DA;color:#111;font-weight:bold', 'background:#161a22;color:#7EB8DA');
|
||||
Reference in new issue
Block a user