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
+7
View File
@@ -1,5 +1,12 @@
# Changelog # Changelog
## 2.3.0
- **Light card, redesigned:** the card is the lamp. On, it takes the lamp's color and the brightness fills it from the left; drag anywhere across it to dim. The brightness is the large figure, the switch sits top right, and the bulb opens colors and settings.
- **Light groups:** the group's own fader on top, and every lamp below as a fader of its own in its own color, with its switch.
- **Room card, redesigned:** the name with what's going on in one line and a switch for all its lights, the temperature in large figures, open doors and windows in amber, and its devices as tiles where lamps glow by their brightness.
- **Hide entities in a room:** the new **Hide these entities** field leaves out anything you don't want on the room card.
## 2.2.1 ## 2.2.1
- **Sports:** the card now looks like a match centre: crests in the teams' colours, league position and record, the time until kick-off, each side's chance to win as a bar, the last play while live, and the venue. - **Sports:** the card now looks like a match centre: crests in the teams' colours, league position and record, the time until kick-off, each side's chance to win as a bar, the last play while live, and the venue.
+7 -6
View File
@@ -83,7 +83,7 @@ More editing tools:
- **Light and dark versions:** turn on **Switch to a dark or light version with the device** under a theme to use a matching dark version of a light theme when the device is in dark mode, and the other way round. *System* always does this. - **Light and dark versions:** turn on **Switch to a dark or light version with the device** under a theme to use a matching dark version of a light theme when the device is in dark mode, and the other way round. *System* always does this.
- **Theme:** *System* follows your device's light or dark appearance. The other 23 themes are fixed: Light, Dark, Nord, Dusk, Forest, Graphite, Indigo, Rose, Ocean, Sunrise, Lavender, Mint, Sand, Midnight (true black, for OLED screens), Espresso, Ember, Lagoon, Paper, Arctic, Moss, Sakura, Concrete, and High contrast for maximum readability. Text meets accessibility contrast levels in every theme. Changing any color switches the theme to *Custom*. - **Theme:** *System* follows your device's light or dark appearance. The other 23 themes are fixed: Light, Dark, Nord, Dusk, Forest, Graphite, Indigo, Rose, Ocean, Sunrise, Lavender, Mint, Sand, Midnight (true black, for OLED screens), Espresso, Ember, Lagoon, Paper, Arctic, Moss, Sakura, Concrete, and High contrast for maximum readability. Text meets accessibility contrast levels in every theme. Changing any color switches the theme to *Custom*.
- **Tile style:** *Cards* puts every widget on a tile. *Outlined* draws a thin border instead. *Plain* removes the cards completely, so widgets sit straight on the background. You can also turn off the background for a single widget under **Style → No background**. - **Tile style:** *Cards* puts every widget on a tile. *Outlined* draws a thin border instead. *Plain* removes the cards completely, so widgets sit straight on the background. You can also turn off the background for a single widget under **Style → No background**.
- **Lights that are on** glow: the tile turns warm, or takes on the bulb's color for color lights. In Plain style, the light's icon and brightness bar glow instead. - **Lights that are on** glow: the light card turns warm, or takes on the bulb's color for color lights, and its brightness fills it from the left in a deeper shade. Drag anywhere across a light card to dim it; the switch at the top right turns it on or off, and tapping the bulb opens its colors and settings. A light that can only switch on and off turns on or off when you tap the card.
- **Icons:** widgets pick an icon automatically. To change one, click the icon next to the field to open the icon picker (over 130 icons for rooms, appliances, tools and devices, searchable in English and Dutch), type an icon name, or paste an emoji. - **Icons:** widgets pick an icon automatically. To change one, click the icon next to the field to open the icon picker (over 130 icons for rooms, appliances, tools and devices, searchable in English and Dutch), type an icon name, or paste an emoji.
## Widgets ## Widgets
@@ -130,7 +130,7 @@ Cards inside a container work like anywhere else, and appliance cards inside the
### Light groups ### Light groups
When the light card points at a light group, it becomes a control panel for the whole group: a switch and brightness slider for all lights at once, a row of white tones and colors (shown when the lights support them), and every light in the group with its own switch and brightness slider. Tap a light's colored dot to open its own panel with brightness, color temperature, colors and a free color picker. When you pick a group, the card grows to four rows so the list fits. Turn off **For a light group, show each light** to hide the list. When the light card points at a light group, the group's own fader sits on top (drag it to dim all the lights at once, the switch turns them all on or off), and under it every light in the group is a fader of its own, filled in that lamp's color by its brightness, with its own switch. Drag a row to dim just that lamp. Tap the bulb at the top for the group's colors and settings. When you pick a group, the card grows to four rows so the list fits. Turn off **For a light group, show each light** to hide the list.
Tapping a light anywhere else on the dashboard (for example in a room card) opens the same light panel. Tapping a light anywhere else on the dashboard (for example in a room card) opens the same light panel.
@@ -210,11 +210,12 @@ The card shows the sun, the house, the battery and the grid with their power rig
Pick one of your Home Assistant rooms (areas) and the card gathers everything in it: Pick one of your Home Assistant rooms (areas) and the card gathers everything in it:
- the room's temperature and humidity (the sensors set on the area in Home Assistant, or any temperature and humidity sensor in the room; you can also choose them yourself) - under the room's name, what is going on in one line: how many lights are on, motion, and music that's playing; the switch beside it turns all the room's lights off (or on)
- chips for what's going on: how many lights are on (tap to switch them all off, or all on), the thermostat (tap it for the climate popup with a dial, −/+, modes, presets and fan modes), open doors and windows, motion, and music that's playing - the room's temperature in large figures and its humidity (the sensors set on the area in Home Assistant, or any temperature and humidity sensor in the room; you can also choose them yourself), and the thermostat (tap it for the climate popup with a dial, −/+, modes, presets and fan modes)
- buttons for the lamps, switches, fans, covers and locks in the room - open doors and windows, in amber
- tiles for the lamps, switches, fans, covers and locks in the room; tap one to switch it. A lamp that's on glows in its own color, filled by its brightness
Light groups are left out so each lamp counts once. Add extra entities, or skip the room and list the entities yourself. The card glows warm while lights are on, and can have a background photo. Rooms are read from Home Assistant's area, device and entity registries. Light groups are left out so each lamp counts once. Add extra entities, or skip the room and list the entities yourself. **Hide these entities** leaves out anything in the room you don't want on the card (a light in a cupboard, a speaker, a test switch): it isn't counted, shown or switched by the card. The card can have a background photo. Rooms are read from Home Assistant's area, device and entity registries.
### Media players ### Media players
+1 -1
View File
@@ -1,5 +1,5 @@
name: Dashboard Studio name: Dashboard Studio
version: "2.2.1" version: "2.3.0"
slug: dashboard_studio slug: dashboard_studio
description: Drag-and-drop dashboard builder with a free-form grid, live entity widgets and themes. description: Drag-and-drop dashboard builder with a free-form grid, live entity widgets and themes.
url: https://git.melvanveen.nl/veenm/ha-dashboard-studio url: https://git.melvanveen.nl/veenm/ha-dashboard-studio
+3
View File
@@ -2169,6 +2169,9 @@
if (!t.dataset.act) return; if (!t.dataset.act) return;
interacting = true; interacting = true;
t.style.setProperty("--pct", `${t.value}%`); 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) => { gridEl.addEventListener("change", (e) => {
const t = e.target, eid = t.dataset.e; const t = e.target, eid = t.dataset.e;
+7
View File
@@ -776,6 +776,13 @@
"Draw": "Gelijk", "Draw": "Gelijk",
"in {0} min": "over {0} min", "in {0} min": "over {0} min",
"Chance to win: {0} {1}%, draw {2}%, {3} {4}%": "Winstkans: {0} {1}%, gelijk {2}%, {3} {4}%", "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", "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 { display: flex; flex-direction: column; align-items: center; height: 100%; width: 100%; }
.gauge svg { flex: 1; width: 100%; min-height: 0; } .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; } .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. */ /* Brightness: a wide pill slider like Control Center, filled with the lamp's color. */
.slider { .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; } .pos9 button[aria-pressed="true"] i { width: 14px; height: 10px; border-radius: 3px; background: var(--accent); opacity: 1; }
/* light card */ /* 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; } .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; } .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; } .swatches.compact .swatch { width: 22px; height: 22px; }
.swatch.more { background: var(--d-fill); } .swatch.more { background: var(--d-fill); }
.swatch:hover { transform: scale(1.1); } .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); } .w.lit-lamp .swatch.more { background: rgba(255, 255, 255, 0.55); }
/* light popup */ /* light popup */
.lp-head { display: flex; align-items: center; gap: 12px; } .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; } .swatch.custom input { position: absolute; inset: 0; opacity: 0; cursor: pointer; }
/* light groups */ /* light groups */
.light-members { flex: 1; min-height: 0; margin: 0 -6px; }
.light:has(.light-members) { justify-content: flex-start; }
/* energy flow */ /* energy flow */
.energy { display: flex; flex-direction: column; height: 100%; gap: 10px; } .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); } .nt-empty .i { font-size: 28px; color: var(--ok); }
/* room */ /* 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 */ 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 */ /* vacuum: compact on small cards, a robot with stats on bigger ones */
/* the more room, the more it shows */ /* the more room, the more it shows */
@@ -1480,7 +1439,6 @@ body.in-haven #btn-new { display: none; }
.w-body { padding: var(--c-pad); } .w-body { padding: var(--c-pad); }
.pad { padding: var(--c-pad); } .pad { padding: var(--c-pad); }
.clock-bg, .clock-dim { margin: calc(-1 * 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 */ /* one title style, one name style, one meta style */
.wt { font-size: var(--c-fs-title); font-weight: 600; color: var(--d-text); } .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 */ /* light: brightness as one wide bar, colours in one row */
.swatch { width: 24px; height: 24px; } .swatch { width: 24px; height: 24px; }
.swatch.more { color: var(--d-text-2); } .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 */ /* thermostat */
.climate .target { font-size: clamp(32px, min(26cqh, 22cqw), 72px); } .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); } .pp-where { font-size: var(--c-fs-meta); }
/* room: a plain mark, chips and devices without a filled icon */ /* 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 */ /* 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)); } 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-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)); } .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 */ /* the night dimmer only exists while it dims */
body:not([style*="--night-dim: 0."]):not([style*="--night-dim: 1"]) #night-dim { display: none; } 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 */ /* 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 */ /* 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 { --trk: calc(var(--c-ctl) + 8px); height: var(--trk); }
.slider.thin { --trk: 28px; } .slider.thin { --trk: 28px; }
.lm-slider, .cv-slider { --trk: 10px; }
.slider::-webkit-slider-thumb, .lm-slider::-webkit-slider-thumb { height: var(--trk); } .slider::-webkit-slider-thumb, .lm-slider::-webkit-slider-thumb { height: var(--trk); }
.slider::-moz-range-thumb { height: var(--trk); } .slider::-moz-range-thumb { height: var(--trk); }
/* solar: today's hours in the sun's colour, the hours gone faded */ /* 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:not(.best) i { opacity: .7; }
.sf-h.past:not(.best) i { opacity: .3; } .sf-h.past:not(.best) i { opacity: .3; }
/* light: the brightness bar never gives way; the colours stay one row you can swipe */ /* 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 { flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none; }
.swatches::-webkit-scrollbar { display: none; } .swatches::-webkit-scrollbar { display: none; }
.sf-h:not(.best) i { opacity: 1; } .sf-h:not(.best) i { opacity: 1; }
@@ -1972,3 +1917,87 @@ body.guest .guest-hide { display: none; }
.sp-play { color: var(--d-text); } .sp-play { color: var(--d-text); }
@container (max-height: 230px) { .sp-odds, .sp-place, .sp-play { display: none; } } @container (max-height: 230px) { .sp-odds, .sp-place, .sp-play { display: none; } }
@container (max-height: 170px) { .sp-meta, .sp-head { 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); const k = Number(a.color_temp_kelvin);
return isFinite(k) && k > 0 ? (k < 3300 ? "#ffc46b" : k < 5000 ? "#ffe9cf" : "#dbe9ff") : "#ffd58a"; 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 } = {}) { function swatches(eid, s, { compact = false } = {}) {
const list = [...(canTemp(s) ? WHITES : []), ...(canColor(s) ? COLORS : [])]; const list = [...(canTemp(s) ? WHITES : []), ...(canColor(s) ? COLORS : [])];
if (!list.length) return ""; if (!list.length) return "";
@@ -236,36 +257,32 @@
], ],
// A light group lists its lights in the entity_id attribute; follow them too. // 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), 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 }) { render(c, { ha }) {
const s = ha.states[c.entity]; const s = ha.states[c.entity];
if (!s) return missing(c.entity, "a light"); 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 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 name = friendly(s, c.name);
const onCount = members.filter((e) => ha.states[e].state === "on").length; 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 sub = members.length ? (onCount ? t("{0} of {1} on", onCount, members.length) : t("All off")) : lightNote(s);
const head = `<div class="light-head"> const colors = c.colors !== false && !members.length ? swatches(c.entity, s, { compact: true }) : "";
<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 }) : "";
// 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 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 m = ha.states[eid];
const tint = lightTint(m), mdim = (m.attributes.supported_color_modes || []).some((x) => x !== "onoff");
const short = friendly(m).replace(new RegExp(`^${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*`, "i"), "") || friendly(m); const short = friendly(m).replace(new RegExp(`^${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*`, "i"), "") || friendly(m);
return `<div class="lm-row ${on ? "on" : ""}"> return fader(eid, m, short, `<span class="lf-name">${esc(short)}</span><span class="lf-pct num">${esc(bigText(m, true))}</span>
<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> <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");
<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>`;
}).join(""); }).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]), lit: (c, { ha }) => litKind(ha.states[c.entity]),
lamp: (c, { ha }) => lampColor(ha.states[c.entity]), lamp: (c, { ha }) => lampColor(ha.states[c.entity]),
@@ -830,8 +847,8 @@
// ---------------------------------------------------------------- room // ---------------------------------------------------------------- room
// Everything in one Home Assistant area at a glance: climate, what's on or open, and quick controls. // Everything in one Home Assistant area at a glance: climate, what's on or open, and quick controls.
function roomEntities(c, ha) { function roomEntities(c, ha) {
const ids = [...(ha?.reg?.byArea?.[c.area] || []), ...(c.entities || [])]; const ids = [...(ha?.reg?.byArea?.[c.area] || []), ...(c.entities || [])], hidden = new Set(c.hidden || []);
return [...new Set(ids)].filter((e) => ha?.states?.[e]); return [...new Set(ids)].filter((e) => ha?.states?.[e] && !hidden.has(e));
} }
const TOGGLES = ["light", "switch", "fan", "input_boolean", "cover", "lock"]; const TOGGLES = ["light", "switch", "fan", "input_boolean", "cover", "lock"];
@@ -845,6 +862,7 @@
{ k: "humidity", t: "entity", l: "Humidity sensor", domains: ["sensor"] }, { k: "humidity", t: "entity", l: "Humidity sensor", domains: ["sensor"] },
{ k: "entities", t: "entities", l: "Extra entities (or all of them, without a room)" }, { k: "entities", t: "entities", l: "Extra entities (or all of them, without a room)" },
{ k: "devices", t: "check", l: "Show devices", d: true }, { 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" }, { 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), 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 // "Living Room Lamp" → "Lamp" inside the Living Room card
const prefix = new RegExp(`^${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*`, "i"); const prefix = new RegExp(`^${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*`, "i");
const short = (e) => friendly(S(e)).replace(prefix, "") || friendly(S(e)); 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>`); // What is going on, in one line: lights, motion, what's playing. Open doors and windows get their own amber line.
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>`); } const said = [
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>`); lights.length ? (lightsOn.length ? t("{0} of {1} lights on", lightsOn.length, lights.length) : t("Lights off")) : "",
if (motion.length) chips.push(`<span class="room-chip">${Icons.svg("person")}<span>${t("Motion")}</span></span>`); motion.length ? t("Motion") : "",
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>`); ...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 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")}')"` : ""}> return `<div class="rm ${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> <div class="rm-head">
<span class="tile-text"><span class="name room-name">${esc(name)}</span>${climateText ? `<span class="state num">${esc(climateText)}</span>` : ""}</span></div> <span class="rm-ico">${icon(null, c.icon || "sofa")}</span>
${chips.length ? `<div class="room-chips">${chips.join("")}</div>` : ""} <span class="rm-title"><span class="rm-name">${esc(name)}</span>${said ? `<span class="rm-said">${esc(said)}</span>` : ""}</span>
${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))}"> ${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>` : ""}
<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>
${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>`; </div>`;
}, },
lit: (c, { ha }) => { // The room keeps the theme's surface: its lamps glow on their own tiles instead.
const ids = roomEntities(c, ha); lit: () => false,
return ids.some((e) => domainOf(e) === "light" && ha.states[e].state === "on") ? "lamp" : false;
},
}; };
// ---------------------------------------------------------------- vacuum // ---------------------------------------------------------------- vacuum