Dashboard Studio 1.10.0: phone dashboards, room card, changelog

- Phone dashboards: a 4-column layout designed at phone width, with
  conversion both ways; on phones the top bar is hidden and comes back
  with a touch-and-hold on an empty spot
- Room card built from Home Assistant areas (area, device and entity
  registries): climate, lights on with an all-lights toggle, thermostat,
  open doors and windows, motion, media, and device buttons
- CHANGELOG.md for Home Assistant's Changelog tab, served at
  /api/changelog and shown under About > What's new

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-26 00:06:01 +01:00
parent aa75a572c5
commit 78aa85a82f
11 changed files with 348 additions and 7 deletions
+112 -4
View File
@@ -30,6 +30,7 @@
this.connected = false;
this.onChange = () => {};
this.onStatus = () => {};
this.onRegistry = () => {};
this.ready = new Promise((r) => (this._markReady = r));
}
connect() {
@@ -38,6 +39,7 @@
const ws = (this.ws = new WebSocket(url));
ws.onmessage = (ev) => this.handle(JSON.parse(ev.data));
ws.onclose = () => {
this._reg = null;
if (this.connected) this.ready = new Promise((r) => (this._markReady = r));
this.connected = false;
this.onStatus(false);
@@ -65,6 +67,7 @@
});
this.connected = true;
this._markReady();
this.registry().then(() => this.onRegistry()).catch(() => {}); // rooms for the room card
this.onStatus(true);
this.onChange(null);
return;
@@ -101,6 +104,25 @@
});
return res?.response || {};
}
/** Areas (rooms) and which entities are in them, from the area, entity and device registries. */
registry() {
this._reg ||= Promise.all([
this.send({ type: "config/area_registry/list" }),
this.send({ type: "config/entity_registry/list" }),
this.send({ type: "config/device_registry/list" }),
]).then(([areas, ents, devs]) => {
const devArea = Object.fromEntries((devs || []).map((d) => [d.id, d.area_id]));
const byArea = {};
for (const e of ents || []) {
if (e.disabled_by || e.hidden_by || e.entity_category) continue; // skip config/diagnostic and hidden entities
const area = e.area_id || devArea[e.device_id];
if (area) (byArea[area] ||= []).push(e.entity_id);
}
this.reg = { areas: (areas || []).sort((a, b) => a.name.localeCompare(b.name)), byArea };
return this.reg;
}).catch((err) => { this._reg = null; throw err; });
return this._reg;
}
/** Today's and the coming days' forecast, via weather.get_forecasts with return_response. */
async forecast(entityId, type = "daily") {
await this.ready;
@@ -205,7 +227,7 @@
const lights = first("light", 3);
if (lights[0]) put("light", 4, 1, 4, 2, { entity: lights[0] });
const sensors = ids.filter((e) => domainOf(e) === "sensor" && numeric(e) && !/battery|range|odometer|charging|afvalwijzer|washer|dryer|dishwasher|remaining|progress/.test(e)).slice(0, 2);
const sensors = ids.filter((e) => domainOf(e) === "sensor" && numeric(e) && !/battery|range|odometer|charging|afvalwijzer|washer|dryer|dishwasher|remaining|progress|vacuum|robot|filter|cleaning|cleaned/.test(e)).slice(0, 2);
sensors.forEach((e, i) => put("value", i * 3, 3, 3, 2, { entity: e }));
const tiles = [...lights.slice(1, 2), ...first("switch", 1)];
tiles.forEach((e, i) => put("entity", 6 + i * 3, 3, 3, 2, { entity: e }));
@@ -358,7 +380,8 @@
const pad = window.innerWidth < 560 ? 12 : 20;
const avail = canvasEl.clientWidth - pad * 2;
const width = g.maxWidth ? Math.min(avail, Number(g.maxWidth)) : avail;
const compact = !editMode && width < 560;
// Phone dashboards are drawn exactly as designed; others reflow into two columns on narrow screens.
const compact = !editMode && width < 560 && !current.mobile;
const cols = compact ? 2 : Math.max(1, Number(g.cols) || 12);
const gap = compact ? Math.min(Number(g.gap) || 0, 12) : Number(g.gap) || 0;
const rh = Number(g.rowHeight) || 72;
@@ -483,8 +506,10 @@
}
}
const phoneScreen = matchMedia("(max-width: 600px)");
function renderGrid() {
if (!current) return;
document.body.classList.toggle("phone-ui", !!current.mobile && !editMode && phoneScreen.matches);
renderNav();
applyTheme();
computeMetrics();
@@ -633,6 +658,11 @@
${list.map((e, i) => `<div class="ent-item"><input class="input" type="text" list="${lid}" ${data} data-idx="${i}" value="${esc(e)}" autocomplete="off" spellcheck="false" aria-label="${esc(f.l)} ${i + 1}"><button class="btn icon" data-ent-del="${i}" ${data} aria-label="${esc(tr("Remove {0}", e))}">${Icons.svg("close")}</button></div>`).join("")}
<input class="input" type="text" list="${lid}" ${data} data-idx="new" placeholder="${tr("Add an entity…")}" autocomplete="off" spellcheck="false" aria-label="Add to ${esc(f.l)}"></div>`;
}
case "area": {
const areas = ha.reg?.areas;
if (!areas) return `<div class="field"><span class="field-label">${esc(f.l)}</span><div class="muted small">${tr("Rooms couldn’t be loaded from Home Assistant. Add the entities below instead.")}</div></div>`;
return `<div class="field">${label}<select class="input" id="${id}" ${data}><option value="">${tr("Choose a room")}</option>${areas.map((a) => `<option value="${esc(a.area_id)}" ${a.area_id === value ? "selected" : ""}>${esc(a.name)}</option>`).join("")}</select></div>`;
}
case "pos9": // a 3×3 grid for placing content in the card
return `<div class="field"><span class="field-label" id="${id}">${esc(f.l)}</span><div class="pos9" role="group" aria-labelledby="${id}">
${f.o.map((o) => `<button type="button" ${data} data-v="${o}" aria-pressed="${o === value}" aria-label="${esc(optLabel(o))}" title="${esc(optLabel(o))}"><i></i></button>`).join("")}</div></div>`;
@@ -731,6 +761,8 @@
${fieldHTML({ k: "blur", t: "number", l: "Blur behind tiles (px)", ph: "For photo backgrounds" }, t.blur, "theme")}
</div>
<div class="section"><div class="section-title">${tr("Grid")}</div>
${fieldHTML({ k: "mobile", t: "check", l: "Designed for phones" }, !!current.mobile, "layout")}
<p class="muted small" style="margin:-4px 2px 12px">${tr("A 4-column layout you design at phone size. On a phone it fills the screen without the top bar; touch and hold an empty spot to bring the bar back.")}</p>
<div class="row">${GRID_FIELDS.slice(0, 2).map((f) => fieldHTML(f, current.grid[f.k], "grid")).join("")}</div>
<div class="row">${GRID_FIELDS.slice(2).map((f) => fieldHTML(f, current.grid[f.k], "grid")).join("")}</div></div>
<div class="section"><div class="section-title">${tr("Kiosk")}</div>
@@ -746,7 +778,8 @@
<div class="section"><div class="section-title">${tr("About")}</div>
<div class="about"><img src="static/favicon.png?v=${esc(APP_VERSION)}" alt="" width="40" height="40">
<div><div style="font-weight:600">Dashboard Studio</div><div class="muted small">${tr("Version {0}", esc(APP_VERSION))}</div></div></div>
<a class="btn" href="${REPO_URL}" target="_blank" rel="noopener" style="width:100%;margin-top:10px;text-decoration:none">${Icons.svg("globe")}${tr("Source code and changes")}</a>
<div class="insp-actions"><button class="btn" data-whatsnew>${Icons.svg("sparkles")}${tr("What’s new")}</button>
<a class="btn" href="${REPO_URL}" target="_blank" rel="noopener" style="text-decoration:none">${Icons.svg("globe")}${tr("Source code")}</a></div>
</div>
<p class="hint">${tr("Select a widget to edit it.")}</p>`;
}
@@ -846,6 +879,7 @@
if (isChange || t.tagName === "BUTTON") renderInspector();
return;
}
if (path === "layout") { convertLayout(!!v); renderInspector(); return; }
if (path === "nav") {
mutate(() => { current.nav[k] = v; });
return;
@@ -946,6 +980,31 @@
}
}
/** Switch a dashboard between the 12-column layout and the 4-column phone layout, converting every tab. */
function convertLayout(toMobile) {
if (!!current.mobile === toMobile) return;
mutate(() => {
const from = Number(current.grid.cols) || 12, to = toMobile ? 4 : 12, f = to / from;
current.mobile = toMobile;
current.grid.cols = to;
current.grid.maxWidth = toMobile ? 440 : 1440;
for (const t of current.tabs) {
const ws = [...t.widgets].sort((a, b) => a.y - b.y || a.x - b.x);
for (const w of ws) {
if (toMobile) { // phone: small tiles become half width, anything a third wide or more full width
w.w = w.w >= from / 3 ? 4 : 2;
w.x = w.w === 4 ? 0 : w.x * f >= 1.5 ? 2 : 0;
} else {
w.w = Math.max(1, Math.min(to, Math.round(w.w * f)));
w.x = Math.min(to - w.w, Math.round(w.x * f));
}
}
const placed = []; // re-pack top-down so nothing overlaps
for (const w of ws) { while (placed.some((o) => overlap(w, o))) w.y++; placed.push(w); }
}
});
}
function findSpot(w, h, widgets = tab().widgets) {
const cols = current.grid.cols;
for (let y = 0; y < 500; y++)
@@ -956,7 +1015,7 @@
function addWidget(type) {
const wd = makeWidget(type);
wd.w = Math.min(wd.w, current.grid.cols);
wd.w = current.mobile ? (wd.w >= 3 ? 4 : 2) : Math.min(wd.w, current.grid.cols);
Object.assign(wd, findSpot(wd.w, wd.h));
mutate(() => tab().widgets.push(wd));
select(wd.id);
@@ -1086,6 +1145,7 @@
const track = a.closest(".hs").querySelector(".hs-track");
track.scrollBy({ left: (act === "hs-next" ? 1 : -1) * track.clientWidth * 0.8, behavior: reducedMotion() ? "auto" : "smooth" });
}
else if (act === "svc-many") { const [d, sv] = a.dataset.v.split("."); ha.callService(d, sv, {}, a.dataset.ids.split(",")); }
else if (act === "photo-next") {
const c = caches[a.closest(".w").dataset.id];
(c?.bg?.next || c?.next)?.(); // the clock keeps its slideshow under .bg
@@ -1217,6 +1277,28 @@
card.removeEventListener("change", update);
}
/** The changelog in a dialog. A small Markdown subset: ## headings, - bullets, **bold** and *italic*. */
async function whatsNew() {
let md = "";
try { md = await fetch(new URL("api/changelog", BASE)).then((r) => r.text()); } catch { /* shown as empty below */ }
const inline = (t) => esc(t).replace(/\*\*(.+?)\*\*/g, "<b>$1</b>").replace(/\*(.+?)\*/g, "<i>$1</i>");
let html = "", inList = false;
for (const line of md.split("\n")) {
if (/^- /.test(line)) { if (!inList) { html += "<ul>"; inList = true; } html += `<li>${inline(line.slice(2))}</li>`; continue; }
if (inList) { html += "</ul>"; inList = false; }
if (/^## /.test(line)) html += `<h4>${inline(line.slice(3))}${line.slice(3).trim() === APP_VERSION ? ` <span class="muted small">· ${tr("this version")}</span>` : ""}</h4>`;
}
if (inList) html += "</ul>";
const card = $("#modal .modal-card");
const done = openModal(`<h3>${tr("What’s new")}</h3><div class="changelog">${html || `<p>${tr("The changelog couldn’t be loaded.")}</p>`}</div>
<div class="modal-actions"><button class="btn primary" data-x="close">${tr("Close")}</button></div>`, (e, close) => { if (e.target.closest("[data-x]")) close(); });
card.classList.add("wide");
card.querySelector(".changelog").scrollIntoView({ block: "start" }); // start at the newest version, not at the Close button
card.scrollTop = 0;
await done;
card.classList.remove("wide");
}
function toast(msg, err) {
const el = document.createElement("div");
el.className = "toast" + (err ? " err" : "");
@@ -1324,6 +1406,25 @@
document.addEventListener("click", (e) => { if (swallowClick) { swallowClick = false; e.stopPropagation(); e.preventDefault(); } }, true);
document.addEventListener("visibilitychange", () => { if (kiosk && document.visibilityState === "visible") requestWakeLock(); });
// ------------------------------------------------------------ phone dashboards
// The top bar is hidden; touching and holding an empty spot brings it back for a while.
let peekTimer = 0, holdTimer2 = 0;
canvasEl.addEventListener("pointerdown", (e) => {
if (!document.body.classList.contains("phone-ui") || e.target.closest(".w")) return;
holdTimer2 = setTimeout(() => {
document.body.classList.add("peek");
clearTimeout(peekTimer);
peekTimer = setTimeout(() => document.body.classList.remove("peek"), 10000);
navigator.vibrate?.(10);
}, 600);
});
for (const ev of ["pointerup", "pointercancel", "pointermove"]) canvasEl.addEventListener(ev, (e) => { if (ev !== "pointermove" || e.movementX || e.movementY) clearTimeout(holdTimer2); });
function phoneHint() {
if (!document.body.classList.contains("phone-ui") || lsGet("ds.peekHint")) return;
lsSet("ds.peekHint", "1");
toast(tr("Touch and hold an empty spot to show the menu."));
}
// ------------------------------------------------------------ chrome wiring
function setCurrent(id) {
current = store.dashboards.find((d) => d.id === id) || store.dashboards[0];
@@ -1453,6 +1554,7 @@
if (childEdit) { childIndex = Number(childEdit.dataset.childEdit); renderInspector(); return; }
if (childBtn) { childOp(childBtn.dataset.childOp, Number(childBtn.dataset.i)); return; }
if (e.target.closest("[data-child-back]")) { childIndex = null; renderInspector(); return; }
if (e.target.closest("[data-whatsnew]")) { whatsNew(); return; }
const ruleAdd = e.target.closest("[data-rule-add]"), ruleDel = e.target.closest("[data-rule-del]");
if (ruleAdd || ruleDel) {
const w = tab().widgets.find((x) => x.id === selectedId);
@@ -1560,6 +1662,7 @@
}
setInterval(checkVersion, 5 * 60 * 1000);
ha.onChange = onStateChange;
ha.onRegistry = () => { renderGrid(); if (editMode) renderInspector(); };
/** Translate the static page: elements marked data-i18n, plus every aria-label and title. */
function translateStatic() {
@@ -1597,6 +1700,11 @@
const exists = (id) => id && store.dashboards.some((d) => d.id === id);
setCurrent([hashId, dev?.dashboard, forUser, lsGet("ds.current")].find(exists));
if (location.hash.includes("kiosk") || (dev?.kiosk && !hashId)) enterKiosk({ fullscreen: false });
setTimeout(phoneHint, 1500);
// After an update, point at what changed (once per device).
const seen = lsGet("ds.seenVersion");
if (APP_VERSION !== "dev" && seen && seen !== APP_VERSION) setTimeout(() => toast(tr("Updated to {0}. See what’s new under About in the settings.", APP_VERSION)), 2000);
if (APP_VERSION !== "dev") lsSet("ds.seenVersion", APP_VERSION);
}
boot();
+13
View File
@@ -272,6 +272,19 @@
"Battery": "Accu", "Cleaned area": "Schoongemaakt oppervlak", "Cleaning time": "Schoonmaaktijd",
"More sensors, e.g. filter or brush life": "Meer sensoren, bijv. levensduur van filter of borstel",
"Resume": "Hervat", "Start": "Start", "Dock": "Dock", "Locate": "Zoek",
"Designed for phones": "Ontworpen voor telefoons",
"A 4-column layout you design at phone size. On a phone it fills the screen without the top bar; touch and hold an empty spot to bring the bar back.": "Een indeling met 4 kolommen die je op telefoongrootte ontwerpt. Op een telefoon vult hij het scherm zonder de bovenbalk; houd een lege plek ingedrukt om de balk terug te halen.",
"Touch and hold an empty spot to show the menu.": "Houd een lege plek ingedrukt om het menu te tonen.",
"Room": "Kamer", "Room name": "Naam van de kamer", "Temperature sensor": "Temperatuursensor", "Humidity sensor": "Vochtigheidssensor",
"Extra entities (or all of them, without a room)": "Extra entiteiten (of allemaal, zonder kamer)", "Show devices": "Toon apparaten",
"Background image URL (optional)": "URL van achtergrondafbeelding (optioneel)", "Automatic": "Automatisch",
"Choose a room or add entities in the inspector.": "Kies een kamer of voeg entiteiten toe in de inspector.",
"Turn off all lights in {0}": "Zet alle lampen in {0} uit", "Turn on all lights in {0}": "Zet alle lampen in {0} aan",
"{0} on": "{0} aan", "Lights off": "Lampen uit", "open": "open", "Playing": "Speelt",
"Choose a room": "Kies een kamer", "Rooms couldn’t be loaded from Home Assistant. Add the entities below instead.": "Kamers konden niet uit Home Assistant worden geladen. Voeg hieronder de entiteiten toe.",
"What’s new": "Wat is er nieuw", "Source code": "Broncode", "this version": "deze versie",
"The changelog couldn’t be loaded.": "De lijst met wijzigingen kon niet worden geladen.",
"Updated to {0}. See what’s new under About in the settings.": "Bijgewerkt naar {0}. Bekijk wat er nieuw is onder Over in de instellingen.",
"Today": "Vandaag", "Tomorrow": "Morgen", "All day": "Hele dag", "Busy": "Bezet",
};
+1
View File
@@ -47,6 +47,7 @@
"eye-off": "M3 3l18 18M10.6 5.1A10.6 10.6 0 0 1 12 5c6.5 0 10 7 10 7a17.7 17.7 0 0 1-3.2 4.1M6.6 6.6A17.6 17.6 0 0 0 2 12s3.5 7 10 7a9.7 9.7 0 0 0 5.4-1.6M9.9 9.9a3 3 0 0 0 4.2 4.2",
hscroll: "M3 6h5v12H3zM10 6h5v12h-5zM17 6h4M17 18h4M21 6v12",
swipe: "M6 4h12a1 1 0 0 1 1 1v11a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1zM9 21h.01M12 21h.01M15 21h.01",
sofa: "M5 11V8a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v3M3 12a2 2 0 0 1 4 0v2h10v-2a2 2 0 0 1 4 0v5H3zM5 17v2M19 17v2",
calendar: "M4 6a1 1 0 0 1 1-1h14a1 1 0 0 1 1 1v14a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1zM4 10h16M8 3v4M16 3v4M8 14h.01M12 14h.01M16 14h.01M8 17h.01M12 17h.01",
clock: "M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM12 7v5l3 2",
chart: "M3 3v18h18M7 15l4-4 3 3 6-6",
+34
View File
@@ -218,6 +218,10 @@ textarea.input { resize: vertical; min-height: 76px; font-family: ui-monospace,
.assign-kiosk { display: flex; align-items: center; gap: 6px; font-size: 13px; white-space: nowrap; }
.assign-kiosk input { width: 18px; height: 18px; accent-color: var(--accent); margin: 0; }
@media (max-width: 560px) { .assign-row[data-dev] { flex-wrap: wrap; } .assign-row[data-dev] .input { flex-basis: 100%; } }
.changelog { margin-top: 8px; }
.changelog h4 { margin: 16px 0 4px; font-size: 15px; }
.changelog ul { margin: 0; padding-left: 20px; color: var(--text); }
.changelog li { margin: 4px 0; line-height: 1.45; }
.attr-table { width: 100%; border-collapse: collapse; font-size: 13px; margin-top: 14px; }
.attr-table td { border-top: 1px solid var(--separator); padding: 7px 4px; vertical-align: top; word-break: break-word; }
.attr-table td:first-child { color: var(--text-2); width: 40%; }
@@ -584,6 +588,29 @@ body.kiosk #tabnav { top: 6px; max-height: calc(100vh - 20px); }
.light-members { flex: 1; min-height: 0; margin: 0 -6px; }
.light:has(.light-members) { justify-content: flex-start; }
/* room */
.room { position: relative; display: flex; flex-direction: column; gap: 12px; height: 100%; min-height: 0; }
.room.on-image { margin: -16px; padding: 16px; border-radius: inherit; color: #fff; --d-text-2: rgba(255, 255, 255, 0.86); --d-fill: rgba(255, 255, 255, 0.18);
background: linear-gradient(to bottom, rgba(0, 0, 0, 0.55), rgba(0, 0, 0, 0.15) 45%, rgba(0, 0, 0, 0.6)), var(--room-img) center / cover; }
.room-head { display: flex; align-items: center; gap: 12px; min-width: 0; }
.room-name { font-size: 17px; }
.room-chips { display: flex; flex-wrap: wrap; gap: 6px; }
.room-chip { display: inline-flex; align-items: center; gap: 6px; min-height: 32px; padding: 0 12px; border-radius: 99px; background: var(--d-fill); font-size: 13px; font-weight: 600; max-width: 100%; }
.room-chip span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.room-chip .i { font-size: 16px; flex: none; }
.room-chip.on { background: var(--d-text); color: var(--d-tile); }
.room-chip.warn { color: var(--warn); background: color-mix(in srgb, var(--warn) 16%, transparent); }
.room-devices { flex: 1; min-height: 0; overflow-y: auto; display: grid; grid-template-columns: repeat(auto-fill, minmax(112px, 1fr)); align-content: start; gap: 8px; }
.room-dev { display: flex; align-items: center; gap: 8px; padding: 8px 10px; min-height: 48px; border-radius: 12px; background: var(--d-fill); min-width: 0; }
.room-dev.on { background: var(--d-text); color: var(--d-tile); --d-text-2: color-mix(in srgb, var(--d-tile) 70%, transparent); }
.room-dev-ico { font-size: 18px; display: flex; flex: none; }
.room-dev-text { display: flex; flex-direction: column; min-width: 0; }
.room-dev-name { font-size: 13px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.room-dev-state { font-size: 12px; color: var(--d-text-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.w.lit-lamp .room-dev.on { background: rgba(255, 255, 255, 0.7); color: var(--lit-text); --d-text-2: var(--lit-text-2); }
.w.lit-lamp .room-chip.on { background: rgba(61, 44, 10, 0.85); color: #fff6e6; }
@container (max-height: 200px) { .room-devices { display: none; } }
/* vacuum: compact on small cards, a robot with stats on bigger ones */
.vac { display: flex; flex-direction: column; height: 100%; gap: 12px; }
.vac-head { display: flex; align-items: center; gap: 12px; min-width: 0; }
@@ -676,6 +703,13 @@ body.kiosk #tabnav { top: 6px; max-height: calc(100vh - 20px); }
@container (max-width: 340px) { .car-img { display: none; } .car.has-img .car-head, .car.has-img .car-main { max-width: none; } .car-odo { display: none; } }
@container (max-height: 200px) { .car-actions { display: none; } }
/* ============================================================ phone dashboards */
body.phone-ui:not(.peek) #topbar { display: none; }
body.phone-ui #stage { padding-top: calc(env(safe-area-inset-top) + 6px); }
body.phone-ui.peek #stage { padding-top: 72px; }
body.phone-ui.peek #topbar { animation: peek-in .2s ease-out; }
@keyframes peek-in { from { opacity: 0; transform: translateY(-8px); } }
/* ============================================================ kiosk */
body.kiosk #topbar, body.kiosk #palette, body.kiosk #inspector { display: none !important; }
body.kiosk #stage { padding-top: 6px; }
+67
View File
@@ -477,6 +477,73 @@
lit: (c, { ha, cache }) => (ha.states[mediaSelected(mediaIds(c), ha, cache || {})]?.state === "playing" ? "on" : false),
};
// ---------------------------------------------------------------- room
// Everything in one Home Assistant area at a glance: climate, what's on or open, and quick controls.
function roomEntities(c, ha) {
const ids = [...(ha?.reg?.byArea?.[c.area] || []), ...(c.entities || [])];
return [...new Set(ids)].filter((e) => ha?.states?.[e]);
}
const TOGGLES = ["light", "switch", "fan", "input_boolean", "cover", "lock"];
W.room = {
label: "Room", icon: "sofa", size: [4, 4],
fields: [
{ k: "area", t: "area", l: "Room" },
{ k: "name", t: "text", l: "Name", ph: "Room name" },
{ k: "icon", t: "icon", l: "Icon", d: "sofa" },
{ k: "temperature", t: "entity", l: "Temperature sensor", domains: ["sensor"], ph: "Automatic" },
{ k: "humidity", t: "entity", l: "Humidity sensor", domains: ["sensor"] },
{ k: "entities", t: "entities", l: "Extra entities (or all of them, without a room)" },
{ k: "devices", t: "check", l: "Show devices", d: true },
{ k: "image", t: "text", l: "Background image URL (optional)", ph: "/local/living-room.jpg" },
],
entities: (c, ha) => [...roomEntities(c, ha), c.temperature, c.humidity].filter(Boolean),
render(c, { ha }) {
const area = ha.reg?.areas?.find((a) => a.area_id === c.area);
const ids = roomEntities(c, ha);
if (!area && !ids.length) return `<div class="empty">${t("Choose a room or add entities in the inspector.")}</div>`;
const S = (e) => ha.states[e], dom = (e) => domainOf(e), dc = (e) => S(e).attributes.device_class;
const name = c.name || area?.name || t("Room");
const pick = (explicit, fromArea, cls) => (explicit && S(explicit) ? explicit : fromArea && S(fromArea) ? fromArea : ids.find((e) => dom(e) === "sensor" && dc(e) === cls));
const temp = pick(c.temperature, area?.temperature_entity_id, "temperature");
const hum = pick(c.humidity, area?.humidity_entity_id, "humidity");
// Count and show the actual lamps: leave out light groups, whose lamps are already listed.
const isGroup = (e) => Array.isArray(S(e).attributes.entity_id);
const lights = ids.filter((e) => dom(e) === "light" && !isGroup(e));
const lightsOn = lights.filter((e) => S(e).state === "on");
const open = ids.filter((e) => dom(e) === "binary_sensor" && ["door", "window", "opening", "garage_door"].includes(dc(e)) && S(e).state === "on");
const motion = ids.filter((e) => dom(e) === "binary_sensor" && ["motion", "occupancy", "presence"].includes(dc(e)) && S(e).state === "on");
const playing = ids.filter((e) => dom(e) === "media_player" && S(e).state === "playing");
const climate = ids.find((e) => dom(e) === "climate");
// "Living Room Lamp" → "Lamp" inside the Living Room card
const prefix = new RegExp(`^${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*`, "i");
const short = (e) => friendly(S(e)).replace(prefix, "") || friendly(S(e));
const chips = [];
if (lights.length) chips.push(`<button class="room-chip wbtn ${lightsOn.length ? "on" : ""}" data-act="svc-many" data-v="light.${lightsOn.length ? "turn_off" : "turn_on"}" data-ids="${esc((lightsOn.length ? lightsOn : lights).join(","))}" aria-label="${esc(lightsOn.length ? t("Turn off all lights in {0}", name) : t("Turn on all lights in {0}", name))}">${Icons.svg("bulb")}<span>${lightsOn.length ? t("{0} on", lightsOn.length) : t("Lights off")}</span></button>`);
if (climate) { const cs = S(climate); chips.push(`<button class="room-chip wbtn" data-act="more" data-e="${esc(climate)}">${Icons.svg("thermometer")}<span>${esc(st(cs.state))}${cs.attributes.temperature != null ? ` · ${esc(cs.attributes.temperature)}°` : ""}</span></button>`); }
for (const e of open) chips.push(`<button class="room-chip wbtn warn" data-act="more" data-e="${esc(e)}">${icon(S(e))}<span>${esc(short(e))} ${t("open")}</span></button>`);
if (motion.length) chips.push(`<span class="room-chip">${Icons.svg("person")}<span>${t("Motion")}</span></span>`);
for (const e of playing) chips.push(`<button class="room-chip wbtn" data-act="more" data-e="${esc(e)}">${Icons.svg("music")}<span>${esc(S(e).attributes.media_title || t("Playing"))}</span></button>`);
const devices = c.devices !== false ? ids.filter((e) => TOGGLES.includes(dom(e)) && !isGroup(e)).slice(0, 12) : [];
const climateText = [temp && fmtState(S(temp)), hum && fmtState(S(hum))].filter(Boolean).join(" · ");
return `<div class="room ${c.image ? "on-image" : ""}" ${c.image ? `style="--room-img:url('${esc(c.image).replace(/'/g, "%27")}')"` : ""}>
<div class="room-head"><span class="badge">${icon(null, c.icon || "sofa")}</span>
<span class="tile-text"><span class="name room-name">${esc(name)}</span>${climateText ? `<span class="state num">${esc(climateText)}</span>` : ""}</span></div>
${chips.length ? `<div class="room-chips">${chips.join("")}</div>` : ""}
${devices.length ? `<div class="room-devices">${devices.map((e) => `<button class="room-dev wbtn ${isOn(S(e)) ? "on" : ""}" data-act="toggle" data-e="${esc(e)}" aria-pressed="${isOn(S(e))}">
<span class="room-dev-ico">${icon(S(e))}</span><span class="room-dev-text"><span class="room-dev-name">${esc(short(e))}</span><span class="room-dev-state">${esc(fmtState(S(e)))}</span></span></button>`).join("")}</div>` : ""}
</div>`;
},
lit: (c, { ha }) => {
const ids = roomEntities(c, ha);
return ids.some((e) => domainOf(e) === "light" && ha.states[e].state === "on") ? "lamp" : false;
},
};
// ---------------------------------------------------------------- vacuum
// One layout that grows with the card: compact on small cards; a robot with a battery ring,
// stats and labelled buttons on bigger ones (see the container queries in style.css).