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
+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