diff --git a/dashboard_studio/CHANGELOG.md b/dashboard_studio/CHANGELOG.md index bee70cc..203571f 100644 --- a/dashboard_studio/CHANGELOG.md +++ b/dashboard_studio/CHANGELOG.md @@ -1,5 +1,12 @@ # 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 - **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. diff --git a/dashboard_studio/DOCS.md b/dashboard_studio/DOCS.md index 92f3a4a..c495b06 100644 --- a/dashboard_studio/DOCS.md +++ b/dashboard_studio/DOCS.md @@ -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. - **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**. -- **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. ## Widgets @@ -130,7 +130,7 @@ Cards inside a container work like anywhere else, and appliance cards inside the ### 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. @@ -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: -- 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) -- 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 -- buttons for the lamps, switches, fans, covers and locks in the room +- 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) +- 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) +- 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 diff --git a/dashboard_studio/config.yaml b/dashboard_studio/config.yaml index 112bb63..4042e3d 100644 --- a/dashboard_studio/config.yaml +++ b/dashboard_studio/config.yaml @@ -1,5 +1,5 @@ name: Dashboard Studio -version: "2.2.1" +version: "2.3.0" slug: dashboard_studio 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 diff --git a/dashboard_studio/web/app.js b/dashboard_studio/web/app.js index 05d2c23..3a14637 100644 --- a/dashboard_studio/web/app.js +++ b/dashboard_studio/web/app.js @@ -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; diff --git a/dashboard_studio/web/i18n.js b/dashboard_studio/web/i18n.js index e48fd65..16241cf 100644 --- a/dashboard_studio/web/i18n.js +++ b/dashboard_studio/web/i18n.js @@ -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", }; diff --git a/dashboard_studio/web/style.css b/dashboard_studio/web/style.css index 363efb5..ec72ba3 100644 --- a/dashboard_studio/web/style.css +++ b/dashboard_studio/web/style.css @@ -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; } } diff --git a/dashboard_studio/web/widgets.js b/dashboard_studio/web/widgets.js index f2b2843..c80782f 100644 --- a/dashboard_studio/web/widgets.js +++ b/dashboard_studio/web/widgets.js @@ -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) + ? `` + : ``; + return `
${control}${inner}
`; + } + 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 = `
- - -
`; - const slider = dimmable ? `` : ""; - 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 `
- -
${esc(short)} - ${mdim ? `` : `${esc(fmtState(m))}`}
- ${on && mdim ? `${mp}%` : ""} - -
`; + return fader(eid, m, short, `${esc(short)}${esc(bigText(m, true))} + `, "lf-row"); }).join(""); - return `
${head}${slider}${colors}${rows ? `
${rows}
` : ""}
`; + + const main = fader(c.entity, s, name, ` +
+ + ${esc(name)}${esc(sub)} + +
+
${esc(bigText(s))}${colors}
`, "lt-main"); + return `
${main}${rows ? `
${rows}
` : ""}
`; }, 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(``); - if (climate) { const cs = S(climate); chips.push(``); } - for (const e of open) chips.push(``); - if (motion.length) chips.push(`${Icons.svg("person")}${t("Motion")}`); - for (const e of playing) chips.push(``); + + // 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}${esc(v.attributes.unit_of_measurement || unitFallback)}` : 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 ``; + }).join(""); - return `
-
${icon(null, c.icon || "sofa")} - ${esc(name)}${climateText ? `${esc(climateText)}` : ""}
- ${chips.length ? `
${chips.join("")}
` : ""} - ${devices.length ? `
${devices.map((e) => ``).join("")}
` : ""} + return `
+
+ ${icon(null, c.icon || "sofa")} + ${esc(name)}${said ? `${esc(said)}` : ""} + ${lights.length ? `` : ""} +
+ ${temp || hum || cs ? `
+ ${temp ? `${fig(temp, "°")}` : ""} + ${hum ? `${Icons.svg("droplet")}${fig(hum, "%")}` : ""} + ${cs ? `` : ""}
` : ""} + ${open.length ? `
${Icons.svg("door-open")}${esc(open.map((e) => short(e)).join(", "))} ${open.length > 1 ? t("are open") : t("is open")}
` : ""} + ${tiles ? `
${tiles}
` : ""}
`; }, - 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