Dashboard Studio 2.3.0: light, light group and room cards redesigned

- Light card: the card is the lamp; brightness fills it in the lamp's colour,
  drag anywhere to dim, large brightness figure, switch and colour row
- Light groups: the group fader on top, a fader row per lamp in its colour
- Room card: name with what's going on, all-lights switch, large climate
  figures, open doors in amber, device tiles that glow by brightness
- Room: new "Hide these entities" field

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-27 11:44:17 +01:00
parent 05df0561b0
commit 2e929b55bb
7 changed files with 179 additions and 101 deletions
+3
View File
@@ -2169,6 +2169,9 @@
if (!t.dataset.act) return;
interacting = true;
t.style.setProperty("--pct", `${t.value}%`);
// a light's fader: the fill and the figure follow the finger
const fd = t.closest(".fader");
if (fd) { fd.style.setProperty("--pct", `${t.value}%`); fd.classList.add("on"); const big = fd.querySelector(".lt-big, .lf-pct, .rd-pct"); if (big) big.textContent = `${t.value}%`; }
});
gridEl.addEventListener("change", (e) => {
const t = e.target, eid = t.dataset.e;
+7
View File
@@ -776,6 +776,13 @@
"Draw": "Gelijk",
"in {0} min": "over {0} min",
"Chance to win: {0} {1}%, draw {2}%, {3} {4}%": "Winstkans: {0} {1}%, gelijk {2}%, {3} {4}%",
"Colour": "Kleur",
"All off": "Alles uit",
"{0} of {1} lights on": "{0} van {1} lampen aan",
"Playing {0}": "Speelt {0}",
"are open": "staan open",
"is open": "staat open",
"Hide these entities": "Deze entiteiten verbergen",
"Today": "Vandaag", "Tomorrow": "Morgen", "All day": "Hele dag", "Busy": "Bezet",
};
+84 -55
View File
@@ -372,7 +372,6 @@ body.editing .w:hover .w-resize, body.editing .w.selected .w-resize { opacity: 1
.gauge { display: flex; flex-direction: column; align-items: center; height: 100%; width: 100%; }
.gauge svg { flex: 1; width: 100%; min-height: 0; }
.light { display: flex; flex-direction: column; height: 100%; gap: 10px; justify-content: space-between; }
.tile-row { display: flex; align-items: center; gap: 12px; min-width: 0; }
/* Brightness: a wide pill slider like Control Center, filled with the lamp's color. */
.slider {
@@ -679,29 +678,11 @@ body.kiosk #tabnav { top: 6px; max-height: calc(100vh - 20px); }
.pos9 button[aria-pressed="true"] i { width: 14px; height: 10px; border-radius: 3px; background: var(--accent); opacity: 1; }
/* light card */
.light-head { display: flex; align-items: center; gap: 12px; min-width: 0; }
.light-badge { cursor: pointer; }
.light-toggle { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.light-switch { flex: none; display: flex; }
.lm-switch { display: flex; }
.swatches { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; flex: none; }
.swatch { width: 26px; height: 26px; border-radius: 50%; background: var(--sw); box-shadow: inset 0 0 0 1px rgba(0, 0, 0, .12); flex: none; display: grid; place-items: center; font-size: 15px; }
.swatches.compact .swatch { width: 22px; height: 22px; }
.swatch.more { background: var(--d-fill); }
.swatch:hover { transform: scale(1.1); }
.light.is-group { justify-content: flex-start; gap: 10px; }
.light-members2 { flex: 1; min-height: 0; overflow-y: auto; display: flex; flex-direction: column; gap: 4px; margin: 0 -4px; padding: 0 4px; }
.lm-row { display: grid; grid-template-columns: 36px minmax(0, 1fr) auto auto; align-items: center; gap: 10px; padding: 6px 8px; border-radius: 12px; background: var(--d-fill); }
.lm-dot { width: 36px; height: 36px; border-radius: 50%; display: grid; place-items: center; font-size: 18px; background: color-mix(in srgb, var(--d-text) 8%, transparent); }
.lm-row.on .lm-dot { background: var(--tint, var(--d-lit)); color: #2a1d05; }
.lm-main { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
.lm-name { font-size: 14px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.lm-state { font-size: 12px; color: var(--d-text-2); }
.lm-slider { height: 8px; border-radius: 4px; }
.lm-slider::-webkit-slider-thumb { height: 16px; width: 16px; border-radius: 50%; background: #fff; box-shadow: 0 1px 3px rgba(0,0,0,.35); }
.lm-slider::-moz-range-thumb { height: 16px; width: 16px; border-radius: 50%; background: #fff; }
.lm-pct { font-size: 12px; color: var(--d-text-2); min-width: 32px; text-align: right; }
.w.lit-lamp .lm-row { background: rgba(255, 255, 255, 0.45); }
.w.lit-lamp .swatch.more { background: rgba(255, 255, 255, 0.55); }
/* light popup */
.lp-head { display: flex; align-items: center; gap: 12px; }
@@ -721,8 +702,6 @@ body.kiosk #tabnav { top: 6px; max-height: calc(100vh - 20px); }
.swatch.custom input { position: absolute; inset: 0; opacity: 0; cursor: pointer; }
/* light groups */
.light-members { flex: 1; min-height: 0; margin: 0 -6px; }
.light:has(.light-members) { justify-content: flex-start; }
/* energy flow */
.energy { display: flex; flex-direction: column; height: 100%; gap: 10px; }
@@ -885,27 +864,7 @@ body.kiosk #tabnav { top: 6px; max-height: calc(100vh - 20px); }
.nt-empty .i { font-size: 28px; color: var(--ok); }
/* 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; } /* taste-skill-ignore: a neutral scrim over the photo */
.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 */
/* the more room, the more it shows */
@@ -1480,7 +1439,6 @@ body.in-haven #btn-new { display: none; }
.w-body { padding: var(--c-pad); }
.pad { padding: var(--c-pad); }
.clock-bg, .clock-dim { margin: calc(-1 * var(--c-pad)); }
.room.on-image { margin: calc(-1 * var(--c-pad)); padding: var(--c-pad); }
/* one title style, one name style, one meta style */
.wt { font-size: var(--c-fs-title); font-weight: 600; color: var(--d-text); }
@@ -1536,11 +1494,6 @@ body.in-haven #btn-new { display: none; }
/* light: brightness as one wide bar, colours in one row */
.swatch { width: 24px; height: 24px; }
.swatch.more { color: var(--d-text-2); }
.lm-dot { background: transparent; width: 28px; height: 28px; font-size: var(--c-ico); color: var(--d-text-2); }
.lm-row.on .lm-dot { background: transparent; color: color-mix(in srgb, var(--tint, #e3a12f) 70%, #8a5a00); }
.w.lit-lamp .lm-row.on .lm-dot { color: var(--lit-text); }
.lm-row { grid-template-columns: 28px minmax(0, 1fr) auto auto; }
.lm-slider::-webkit-slider-thumb { box-shadow: none; border: 1px solid rgba(0, 0, 0, .2); }
/* thermostat */
.climate .target { font-size: clamp(32px, min(26cqh, 22cqw), 72px); }
@@ -1610,11 +1563,6 @@ body.in-haven #btn-new { display: none; }
.pp-where { font-size: var(--c-fs-meta); }
/* room: a plain mark, chips and devices without a filled icon */
.room-name { font-size: var(--c-fs-name); }
.room-chip { min-height: 32px; font-size: var(--c-fs-meta); }
.room-dev { min-height: var(--c-row); border-radius: var(--c-r); }
.room-dev-name { font-size: var(--c-fs-meta); }
.room-dev.on { background: color-mix(in srgb, var(--d-text) 88%, transparent); }
/* vacuum: the state in words, the battery as a bar */
@@ -1767,7 +1715,6 @@ body.in-haven #btn-new { display: none; }
background: var(--slider-fill, var(--d-text)); box-shadow: -2000px 0 0 2000px var(--slider-fill, var(--d-text)); }
.slider::-moz-range-thumb { width: 4px; height: 100px; border: 0; border-radius: 0; background: var(--slider-fill, var(--d-text)); }
.slider::-moz-range-progress { height: 100%; background: var(--slider-fill, var(--d-text)); }
.lm-slider { height: 10px; border-radius: 5px; }
/* the night dimmer only exists while it dims */
body:not([style*="--night-dim: 0."]):not([style*="--night-dim: 1"]) #night-dim { display: none; }
/* a heading keeps its set size, but a phone's title stays phone-sized */
@@ -1783,7 +1730,6 @@ body:not([style*="--night-dim: 0."]):not([style*="--night-dim: 1"]) #night-dim {
/* each slider knows its track height, and its fill-painting thumb is exactly that tall */
.slider { --trk: calc(var(--c-ctl) + 8px); height: var(--trk); }
.slider.thin { --trk: 28px; }
.lm-slider, .cv-slider { --trk: 10px; }
.slider::-webkit-slider-thumb, .lm-slider::-webkit-slider-thumb { height: var(--trk); }
.slider::-moz-range-thumb { height: var(--trk); }
/* solar: today's hours in the sun's colour, the hours gone faded */
@@ -1793,7 +1739,6 @@ body:not([style*="--night-dim: 0."]):not([style*="--night-dim: 1"]) #night-dim {
.sf-h:not(.best) i { opacity: .7; }
.sf-h.past:not(.best) i { opacity: .3; }
/* light: the brightness bar never gives way; the colours stay one row you can swipe */
.light .slider { flex: none; }
.swatches { flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none; }
.swatches::-webkit-scrollbar { display: none; }
.sf-h:not(.best) i { opacity: 1; }
@@ -1972,3 +1917,87 @@ body.guest .guest-hide { display: none; }
.sp-play { color: var(--d-text); }
@container (max-height: 230px) { .sp-odds, .sp-place, .sp-play { display: none; } }
@container (max-height: 170px) { .sp-meta, .sp-head { display: none; } }
/* light: the card is the lamp. Off it's the theme's card; on, it takes the lamp's colour and the brightness
fills it from the left in a deeper shade. Drag anywhere across it to dim. */
.lt { display: flex; flex-direction: column; height: 100%; min-height: 0; gap: 8px; }
.fader { position: relative; overflow: hidden; isolation: isolate; --fill: color-mix(in srgb, var(--tint, #ffc46b) 72%, #e8962a); }
.fader-fill { position: absolute; inset: 0 auto 0 0; width: var(--pct, 0%); background: var(--fill); z-index: -1; transition: width .25s ease; }
.fader.on .fader-fill { opacity: 1; }
.fader-input { -webkit-appearance: none; appearance: none; position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; opacity: 0; cursor: ew-resize; z-index: 1; touch-action: pan-y; }
button.fader-input { cursor: pointer; }
.fader-input:focus-visible { opacity: 1; background: transparent; outline: 2px solid var(--accent); outline-offset: -3px; border-radius: inherit; }
.fader > :not(.fader-fill):not(.fader-input) { position: relative; z-index: 2; pointer-events: none; }
.fader .wbtn:not(.fader-input), .fader .swatches { pointer-events: auto; }
@media (prefers-reduced-motion: reduce) { .fader-fill { transition: none; } }
/* the single light fills the whole card */
.lt-main { flex: 1; min-height: 0; margin: calc(-1 * var(--c-pad)); padding: var(--c-pad); border-radius: inherit; display: flex; flex-direction: column; justify-content: space-between; gap: 8px; }
.lt.group .lt-main { flex: none; min-height: 112px; margin-bottom: 0; border-radius: inherit; border-bottom-left-radius: 0; border-bottom-right-radius: 0; }
.lt-top { display: flex; align-items: center; gap: 10px; min-width: 0; }
.lt-ico { width: 32px; height: 32px; display: grid; place-items: center; font-size: calc(var(--c-ico) + 4px); color: var(--d-text-2); flex: none; border-radius: 50%; }
.lt.on .lt-ico { color: var(--lit-text); }
.lt-title { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.lt-title .name { font-size: var(--c-fs-name); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.lt-sub { font-size: var(--c-fs-meta); color: var(--d-text-2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.light-switch { flex: none; display: flex; }
.lt-foot { display: flex; align-items: flex-end; justify-content: space-between; gap: 12px; min-width: 0; }
.lt-big { font-size: clamp(30px, min(26cqh, 16cqw), 64px); font-weight: 400; letter-spacing: -0.03em; line-height: .95; }
.lt:not(.on) .lt-big { color: var(--d-text-2); }
.lt-foot .swatches { flex: 0 1 auto; min-width: 0; margin-left: auto; justify-content: flex-start; }
.lt-foot .swatch { width: 22px; height: 22px; }
.w.lit-lamp .swatch.more { color: var(--lit-text); }
/* a group: the group's fader on top, then a fader per light, each in its own colour */
.lt-rows { flex: 1; min-height: 0; overflow-y: auto; display: flex; flex-direction: column; gap: 6px; margin-top: 4px; }
.lf-row { flex: none; display: grid; grid-template-columns: minmax(0, 1fr) auto auto; align-items: center; gap: 10px; min-height: var(--c-row); padding: 0 6px 0 14px; border-radius: var(--c-r); background: var(--d-fill); }
.w.lit-lamp .lf-row { background: rgba(255, 255, 255, .45); }
.lf-name { font-size: var(--c-fs-body); font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.lf-pct { font-size: var(--c-fs-meta); color: var(--d-text-2); }
.lf-row.on .lf-pct { color: inherit; }
.lf-row .switch { transform: scale(.85); }
/* not much room: the colours go (the bulb opens them) */
@container (max-height: 150px) { .lt-foot .swatches { display: none; } }
@container (max-width: 260px) { .lt-foot .swatches { display: none; } }
/* room: the name and what's going on, the climate in large figures, then its devices as tiles that light up */
.rm { display: flex; flex-direction: column; gap: 12px; height: 100%; min-height: 0; }
.rm.on-image { margin: calc(-1 * var(--c-pad)); padding: var(--c-pad); 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; } /* taste-skill-ignore: a neutral scrim over the photo */
.rm-head { display: flex; align-items: center; gap: 10px; min-width: 0; }
.rm-ico { font-size: calc(var(--c-ico) + 4px); color: var(--d-text-2); display: flex; flex: none; }
.rm-title { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 1px; }
.rm-name { font-size: calc(var(--c-fs-name) + 3px); font-weight: 650; letter-spacing: -0.01em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.rm-said { font-size: var(--c-fs-meta); color: var(--d-text-2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.rm-climate { display: flex; align-items: baseline; gap: 18px; flex-wrap: wrap; }
.rm-temp { font-size: clamp(30px, 11cqw, 48px); font-weight: 400; letter-spacing: -0.03em; line-height: 1; }
.rm-temp small, .rm-hum small { font-size: .45em; color: var(--d-text-2); margin-left: 2px; letter-spacing: 0; }
.rm-hum { display: inline-flex; align-items: baseline; gap: 4px; font-size: var(--c-fs-mid); }
.rm-hum .i { font-size: 16px; color: var(--d-text-2); align-self: center; }
.rm-heat { display: inline-flex; align-items: center; gap: 6px; font-size: var(--c-fs-meta); font-weight: 600; padding: 0 12px; min-height: 30px; border-radius: 99px; background: var(--d-fill); margin-left: auto; align-self: center; }
.rm-heat .i { font-size: 15px; }
.rm-warn { display: flex; align-items: center; gap: 8px; font-size: var(--c-fs-meta); font-weight: 600; color: var(--warn); }
.rm-warn .i { font-size: 16px; flex: none; }
.rm-devs { flex: 1; min-height: 0; overflow-y: auto; display: grid; grid-template-columns: repeat(auto-fill, minmax(118px, 1fr)); align-content: start; gap: 8px; }
.rd { position: relative; overflow: hidden; isolation: isolate; display: grid; grid-template-columns: auto minmax(0, 1fr); grid-template-rows: auto auto; column-gap: 8px; align-items: center;
min-height: calc(var(--c-row) + 8px); padding: 8px 12px; border-radius: var(--c-r); background: var(--d-fill); text-align: left; --fill: color-mix(in srgb, var(--tint, #ffc46b) 72%, #e8962a); }
.rd-fill { position: absolute; inset: 0 auto 0 0; width: 0; z-index: -1; }
.rd.on { background: color-mix(in srgb, var(--d-text) 88%, transparent); color: var(--d-tile); --d-text-2: color-mix(in srgb, var(--d-tile) 70%, transparent); }
.rd.light.on { background: color-mix(in srgb, var(--tint, #ffc46b) 32%, var(--d-lit, #ffedc7)); color: var(--lit-text); --d-text-2: var(--lit-text-2); }
.rd.light.on .rd-fill { width: var(--pct, 0%); background: var(--fill); }
.rd-ico { grid-row: span 2; display: flex; font-size: var(--c-ico); }
.rd-name { font-size: var(--c-fs-meta); font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.rd-state { font-size: 12px; color: var(--d-text-2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
@container (max-height: 220px) { .rm-devs { display: none; } }
@container (max-height: 150px) { .rm-climate { display: none; } }
@media (max-width: 640px) { .rm-devs { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
/* very narrow light and room cards: the name gets its own line, nothing is cut off */
@container (max-width: 200px) {
.lt-top { flex-wrap: wrap; row-gap: 6px; }
.lt-title { order: 3; flex-basis: 100%; }
.lt-ico { width: 28px; height: 28px; }
.lf-row { grid-template-columns: minmax(0, 1fr) auto; padding-right: 10px; }
.lf-row .light-switch { display: none; }
.rm-climate { gap: 6px 12px; }
.rm-heat { margin-left: 0; }
.rm-head .rm-ico { display: none; }
}
@container (max-width: 200px) and (max-height: 190px) { .lt-sub { display: none; } .lt-big { font-size: 28px; } .lt-main { gap: 4px; } }
+70 -39
View File
@@ -218,6 +218,27 @@
const k = Number(a.color_temp_kelvin);
return isFinite(k) && k > 0 ? (k < 3300 ? "#ffc46b" : k < 5000 ? "#ffe9cf" : "#dbe9ff") : "#ffd58a";
}
const dimmable = (s) => (s?.attributes?.supported_color_modes || []).some((m) => m !== "onoff");
const pctOf = (s) => (s?.state === "on" ? Math.round((s.attributes.brightness || 255) / 2.55) : 0);
/** The large figure: the brightness, or On / Off for a light that only switches. */
const bigText = (s, short) => (s.state !== "on" ? t("Off") : dimmable(s) ? `${pctOf(s)}%` : short ? "" : t("On"));
/** What colour it is, in words: a colour temperature, "Colour", or Off. */
function lightNote(s) {
const a = s.attributes;
if (s.state !== "on") return t("Off");
if (Array.isArray(a.rgb_color) && a.color_mode && a.color_mode !== "color_temp") return t("Colour");
const k = Number(a.color_temp_kelvin);
return isFinite(k) && k > 0 ? (k < 3300 ? t("Warm white") : k < 5000 ? t("Neutral white") : t("Cool white")) : t("On");
}
/** A light as a fader: the brightness fills it in the lamp's colour; drag across it to dim, or tap to switch a light that can't dim. */
function fader(eid, s, name, inner, cls) {
const on = s.state === "on", pct = pctOf(s);
const control = dimmable(s)
? `<input class="fader-input" type="range" min="1" max="100" value="${pct || 1}" data-act="brightness" data-e="${esc(eid)}" aria-label="${esc(t("{0} brightness", name))}">`
: `<button class="fader-input wbtn" data-act="toggle" data-e="${esc(eid)}" aria-label="${esc(name)}" aria-pressed="${on}"></button>`;
return `<div class="fader ${cls} ${on ? "on" : ""}" style="--pct:${on ? (dimmable(s) ? pct : 100) : 0}%;${lightTint(s) ? `--tint:${lightTint(s)}` : ""}"><i class="fader-fill" aria-hidden="true"></i>${control}${inner}</div>`;
}
function swatches(eid, s, { compact = false } = {}) {
const list = [...(canTemp(s) ? WHITES : []), ...(canColor(s) ? COLORS : [])];
if (!list.length) return "";
@@ -236,36 +257,32 @@
],
// A light group lists its lights in the entity_id attribute; follow them too.
entities: (c, ha) => [c.entity, ...(ha?.states[c.entity]?.attributes.entity_id || [])].filter(Boolean),
// The card is the lamp: when it's on, the brightness fills the card from the left in the lamp's colour,
// and dragging anywhere across it dims. A group adds a row per light, each its own little fader.
render(c, { ha }) {
const s = ha.states[c.entity];
if (!s) return missing(c.entity, "a light");
const members = c.members !== false && Array.isArray(s.attributes.entity_id) ? s.attributes.entity_id.filter((e) => ha.states[e]) : [];
const pct = s.state === "on" ? Math.round((s.attributes.brightness || 255) / 2.55) : 0;
const dimmable = (s.attributes.supported_color_modes || []).some((m) => m !== "onoff");
const name = friendly(s, c.name);
const onCount = members.filter((e) => ha.states[e].state === "on").length;
const state = members.length ? (onCount ? t("{0} of {1} on", onCount, members.length) : t("Off")) : fmtState(s);
const head = `<div class="light-head">
<button class="badge light-badge wbtn" data-act="light-popup" data-e="${esc(c.entity)}" aria-label="${esc(t("Settings for {0}", name))}">${icon(s)}</button>
<button class="tile-text light-toggle wbtn" data-act="toggle" data-e="${esc(c.entity)}" aria-pressed="${isOn(s)}"><span class="name">${esc(name)}</span><span class="state">${esc(state)}${pct && members.length ? ` · ${pct}%` : ""}</span></button>
<button class="light-switch wbtn" role="switch" aria-checked="${isOn(s)}" data-act="toggle" data-e="${esc(c.entity)}" aria-label="${esc(name)}"><span class="switch"></span></button></div>`;
const slider = dimmable ? `<input class="slider" type="range" min="1" max="100" value="${pct || 1}" style="--pct:${pct}%" data-act="brightness" data-e="${esc(c.entity)}" aria-label="${esc(t("{0} brightness", name))}">` : "";
const colors = c.colors !== false ? swatches(c.entity, s, { compact: !!members.length }) : "";
const sub = members.length ? (onCount ? t("{0} of {1} on", onCount, members.length) : t("All off")) : lightNote(s);
const colors = c.colors !== false && !members.length ? swatches(c.entity, s, { compact: true }) : "";
// In a group, every light gets a row: its color (tap for its own settings), name, brightness and switch.
const rows = members.map((eid) => {
const m = ha.states[eid], on = m.state === "on", mp = on ? Math.round((m.attributes.brightness || 255) / 2.55) : 0;
const tint = lightTint(m), mdim = (m.attributes.supported_color_modes || []).some((x) => x !== "onoff");
const m = ha.states[eid];
const short = friendly(m).replace(new RegExp(`^${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*`, "i"), "") || friendly(m);
return `<div class="lm-row ${on ? "on" : ""}">
<button class="lm-dot wbtn" style="${tint ? `--tint:${tint}` : ""}" data-act="light-popup" data-e="${esc(eid)}" aria-label="${esc(t("Settings for {0}", friendly(m)))}">${icon(m)}</button>
<div class="lm-main"><span class="lm-name">${esc(short)}</span>
${mdim ? `<input class="slider lm-slider" type="range" min="1" max="100" value="${mp || 1}" style="--pct:${mp}%" data-act="brightness" data-e="${esc(eid)}" aria-label="${esc(t("{0} brightness", friendly(m)))}">` : `<span class="lm-state">${esc(fmtState(m))}</span>`}</div>
<span class="lm-pct num">${on && mdim ? `${mp}%` : ""}</span>
<button class="lm-switch wbtn" role="switch" aria-checked="${on}" data-act="toggle" data-e="${esc(eid)}" aria-label="${esc(friendly(m))}"><span class="switch"></span></button>
</div>`;
return fader(eid, m, short, `<span class="lf-name">${esc(short)}</span><span class="lf-pct num">${esc(bigText(m, true))}</span>
<button class="light-switch wbtn" role="switch" aria-checked="${m.state === "on"}" data-act="toggle" data-e="${esc(eid)}" aria-label="${esc(friendly(m))}"><span class="switch"></span></button>`, "lf-row");
}).join("");
return `<div class="light ${members.length ? "is-group" : ""}">${head}${slider}${colors}${rows ? `<div class="light-members2" role="group" aria-label="${t("Lights in {0}", esc(name))}">${rows}</div>` : ""}</div>`;
const main = fader(c.entity, s, name, `
<div class="lt-top">
<button class="lt-ico wbtn" data-act="light-popup" data-e="${esc(c.entity)}" aria-label="${esc(t("Settings for {0}", name))}">${icon(s)}</button>
<span class="lt-title"><span class="name">${esc(name)}</span><span class="lt-sub">${esc(sub)}</span></span>
<button class="light-switch wbtn" role="switch" aria-checked="${isOn(s)}" data-act="toggle" data-e="${esc(c.entity)}" aria-label="${esc(name)}"><span class="switch"></span></button>
</div>
<div class="lt-foot"><span class="lt-big num">${esc(bigText(s))}</span>${colors}</div>`, "lt-main");
return `<div class="lt ${members.length ? "group" : ""} ${isOn(s) ? "on" : ""}">${main}${rows ? `<div class="lt-rows" role="group" aria-label="${t("Lights in {0}", esc(name))}">${rows}</div>` : ""}</div>`;
},
lit: (c, { ha }) => litKind(ha.states[c.entity]),
lamp: (c, { ha }) => lampColor(ha.states[c.entity]),
@@ -830,8 +847,8 @@
// ---------------------------------------------------------------- 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 ids = [...(ha?.reg?.byArea?.[c.area] || []), ...(c.entities || [])], hidden = new Set(c.hidden || []);
return [...new Set(ids)].filter((e) => ha?.states?.[e] && !hidden.has(e));
}
const TOGGLES = ["light", "switch", "fan", "input_boolean", "cover", "lock"];
@@ -845,6 +862,7 @@
{ 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: "hidden", t: "entities", l: "Hide these entities" },
{ 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),
@@ -870,28 +888,41 @@
// "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>`);
// What is going on, in one line: lights, motion, what's playing. Open doors and windows get their own amber line.
const said = [
lights.length ? (lightsOn.length ? t("{0} of {1} lights on", lightsOn.length, lights.length) : t("Lights off")) : "",
motion.length ? t("Motion") : "",
...playing.map((e) => S(e).attributes.media_title ? t("Playing {0}", S(e).attributes.media_title) : t("Playing")),
].filter(Boolean).join(" · ");
const cs = climate && S(climate);
const fig = (e, unitFallback) => { const v = S(e); const n = Number(v.state); return isFinite(n) ? `${Math.round(n * 10) / 10}<small>${esc(v.attributes.unit_of_measurement || unitFallback)}</small>` : esc(fmtState(v)); };
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(" · ");
const tiles = devices.map((e) => {
const v = S(e), on = isOn(v), light = dom(e) === "light", pct = light ? pctOf(v) : 0;
const state = light ? bigText(v) : fmtState(v);
return `<button class="rd ${on ? "on" : ""} ${light ? "light" : ""} wbtn" data-act="toggle" data-e="${esc(e)}" aria-pressed="${on}"
style="${light && on ? `--pct:${dimmable(v) ? pct : 100}%;--tint:${lightTint(v)}` : ""}"><i class="rd-fill" aria-hidden="true"></i>
<span class="rd-ico">${icon(v)}</span><span class="rd-name">${esc(short(e))}</span><span class="rd-state num">${esc(state)}</span></button>`;
}).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>` : ""}
return `<div class="rm ${c.image ? "on-image" : ""}" ${c.image ? `style="--room-img:url('${esc(c.image).replace(/'/g, "%27")}')"` : ""}>
<div class="rm-head">
<span class="rm-ico">${icon(null, c.icon || "sofa")}</span>
<span class="rm-title"><span class="rm-name">${esc(name)}</span>${said ? `<span class="rm-said">${esc(said)}</span>` : ""}</span>
${lights.length ? `<button class="light-switch wbtn" role="switch" aria-checked="${lightsOn.length > 0}" 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))}"><span class="switch"></span></button>` : ""}
</div>
${temp || hum || cs ? `<div class="rm-climate">
${temp ? `<span class="rm-temp num">${fig(temp, "°")}</span>` : ""}
${hum ? `<span class="rm-hum num">${Icons.svg("droplet")}${fig(hum, "%")}</span>` : ""}
${cs ? `<button class="rm-heat 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>` : ""}</div>` : ""}
${open.length ? `<div class="rm-warn">${Icons.svg("door-open")}<span>${esc(open.map((e) => short(e)).join(", "))} ${open.length > 1 ? t("are open") : t("is open")}</span></div>` : ""}
${tiles ? `<div class="rm-devs">${tiles}</div>` : ""}
</div>`;
},
lit: (c, { ha }) => {
const ids = roomEntities(c, ha);
return ids.some((e) => domainOf(e) === "light" && ha.states[e].state === "on") ? "lamp" : false;
},
// The room keeps the theme's surface: its lamps glow on their own tiles instead.
lit: () => false,
};
// ---------------------------------------------------------------- vacuum