From a5fecf6680c527af2e1597cae574901a16a64a6a Mon Sep 17 00:00:00 2001 From: Matt Madison <160317626+MadisonMedia@users.noreply.github.com> Date: Sat, 14 Nov 2026 11:41:12 +0100 Subject: [PATCH] Stack side buttons with column-reverse and load the icon picker for 0.2.0b3 Four scenes beside the control no longer form a mirrored 2x2 block; the stack is a flex column reversed in CSS, without per-button order values. The card editor and the setup page load Home Assistant's icon picker through the card helpers and render it once defined. Settings that no longer exist are dropped from a card's config on save. --- CHANGELOG.md | 14 ++++++++++ .../frontend/crescendo-card.js | 28 ++++++++----------- .../frontend/crescendo-panel.js | 7 ++++- .../frontend/crescendo-shared.js | 15 ++++++++++ .../cloudless_crescendo/manifest.json | 2 +- tests/frontend/harness.html | 8 ++++++ 6 files changed, 56 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8dae4da..a993f48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## 0.2.0b3 + +Restart Home Assistant after updating, then reload the browser or app once so the card loads anew. + +### Fixed + +- Four scene buttons beside the control stood in a 2×2 block, mirrored. They now stack in one column like the other buttons. +- The setup page showed plain text fields for icons when Home Assistant had not loaded its icon picker yet. The setup page and the card editor now load the picker themselves and show it as soon as it is there. +- Settings of earlier versions that no longer exist, such as the light glow, are dropped from a card's configuration when it is saved. + +### Changed + +- Buttons beside the control are stacked with plain CSS instead of per-button ordering, which is lighter on slow devices. + ## 0.2.0b2 Restart Home Assistant after updating, then reload the browser or app once so the card loads anew. diff --git a/custom_components/cloudless_crescendo/frontend/crescendo-card.js b/custom_components/cloudless_crescendo/frontend/crescendo-card.js index c98f829..7daab16 100644 --- a/custom_components/cloudless_crescendo/frontend/crescendo-card.js +++ b/custom_components/cloudless_crescendo/frontend/crescendo-card.js @@ -20,6 +20,7 @@ const { langOf, lightA, lightColor, + loadIconPicker, lumOf, rampColor, rgbHue, @@ -51,8 +52,6 @@ const AUTO_POS = { fader: "right", scenes: "top" }; const DEFAULTS = { design: "gradient", buttons_on: true, - show_value: false, - show_duration: false, button_position: "auto", slider: "wide", color: "stripes", @@ -66,7 +65,6 @@ const DEFAULTS = { button_names: true, details: "button", colors_always: false, - tap_duration: 1, show_title: true, show_header_value: true, power: "header", @@ -76,6 +74,9 @@ const DEFAULTS = { reset_place: "slider", }; +// Settings of earlier versions that no longer do anything; they are dropped from a card's config when it is saved. +const RETIRED = ["glow", "show_duration", "show_value", "tap_duration"]; + // Below this, a slider or a column switches off instead of hanging at 1 or 2 %. const OFF_SNAP = 3; @@ -151,13 +152,13 @@ button { font: inherit; color: inherit; cursor: pointer; } [data-pos="left"] .body { grid-template-columns: auto minmax(0, 1fr); } [data-pos="left"] .btns { order: -1; } [data-pos="right"] .body { grid-template-columns: minmax(0, 1fr) auto; } -[data-pos="left"] .presets, [data-pos="right"] .presets, [data-pos="left"] .scenes, [data-pos="right"] .scenes { grid-template-columns: minmax(78px, 112px); } .btns { min-width: 0; align-self: start; } /* Beside the control, neither side leaves a gap: the buttons grow to the control's height, and columns, a fader or channels grow to the buttons'. */ :is([data-pos="left"], [data-pos="right"]) .btns { align-self: stretch; display: grid; } -:is([data-pos="left"], [data-pos="right"]) :is(.presets, .scenes) { grid-auto-rows: 1fr; } -/* Beside the control the buttons stand like a fader: the last one at the top, the power button at the bottom. */ -:is([data-pos="left"], [data-pos="right"]) :is(.presets, .scenes) > * { order: calc(1000 - var(--o, 0)); } +/* Beside the control the buttons stand like a fader: the last one at the top, the power button at the bottom. + The markup keeps its order, so the keyboard still moves from the power button up through the buttons. */ +:is([data-pos="left"], [data-pos="right"]) :is(.presets, .scenes) { display: flex; flex-direction: column-reverse; min-width: 78px; max-width: 112px; } +:is([data-pos="left"], [data-pos="right"]) :is(.presets, .scenes) > * { flex: 1 1 0; min-width: 0; } .reg:has(> .bars), .reg:has(> .fader-row), .reg:has(> .mix) { grid-template-rows: 1fr; } .presets { display: grid; grid-template-columns: repeat(auto-fit, minmax(70px, 1fr)); gap: 8px; } .preset { border: 1px solid var(--cr-line); background: transparent; border-radius: 10px; padding: 9px 8px; display: grid; gap: 1px; text-align: center; justify-items: center; min-height: 40px; align-content: center; } @@ -284,9 +285,9 @@ button { font: inherit; color: inherit; cursor: pointer; } @container (min-width: 470px) { /* Matching the design: on a wide card the buttons stand beside the control, on a narrow one below it. */ [data-pos="auto"] .body { grid-template-columns: minmax(0, 1fr) minmax(120px, 170px); } - [data-pos="auto"] .presets, [data-pos="auto"] .scenes { grid-template-columns: minmax(0, 1fr); grid-auto-rows: 1fr; } + [data-pos="auto"] :is(.presets, .scenes) { display: flex; flex-direction: column-reverse; } + [data-pos="auto"] :is(.presets, .scenes) > * { flex: 1 1 0; min-width: 0; } [data-pos="auto"] .btns { align-self: stretch; display: grid; } - [data-pos="auto"] :is(.presets, .scenes) > * { order: calc(1000 - var(--o, 0)); } .fader { min-height: 340px; } .bars { grid-template-rows: minmax(210px, 1fr) auto; } .mix { grid-template-rows: auto minmax(230px, 1fr) auto; } @@ -748,9 +749,6 @@ class CrescendoCard extends HTMLElement { el.classList.toggle("four", list.length === 4 && !tile); el.innerHTML = tile + list.map((b) => this._buttonHtml(b, sub, us, override, dark, true)).join(""); }); - $$(".js-presets, .js-scenes", (el) => - [...el.children].forEach((b, i) => b.style.setProperty("--o", b.classList.contains("power-tile") ? -1 : i)), - ); $$(".js-lanes", (el) => { el.hidden = !cfg.show_lanes || !us.length; el.innerHTML = @@ -2009,6 +2007,7 @@ class CrescendoCardEditor extends HTMLElement { // An icon picker reports while it is used; rebuilding the form then would lose the choice, so it stays as it is. _set(patch, render = true) { this._config = { ...this._config, ...patch }; + for (const k of RETIRED) delete this._config[k]; const out = { ...this._config }; for (const [k, v] of Object.entries(DEFAULTS)) if (out[k] === v) delete out[k]; for (const [k, v] of Object.entries(out)) if (v === undefined) delete out[k]; @@ -2045,7 +2044,7 @@ class CrescendoCardEditor extends HTMLElement { // Home Assistant loads its icon picker on demand; the form appears again once it is there. if (!customElements.get("ha-icon-picker") && !this._waitingForPicker) { this._waitingForPicker = true; - customElements.whenDefined("ha-icon-picker").then(() => this._render()); + loadIconPicker().then((ready) => ready && this._render()); } const iconField = (b, i) => customElements.get("ha-icon-picker") @@ -2276,7 +2275,6 @@ class CrescendoCardEditor extends HTMLElement { // Home Assistant's icon picker reports its choice only on itself, without bubbling, so each one gets its own listener. this.shadowRoot.querySelectorAll("ha-icon-picker").forEach((el) => { el.hass = this._hass; - el.value = el.getAttribute("value") || ""; el.addEventListener("value-changed", (e) => this._onChange(e)); }); } @@ -2330,8 +2328,6 @@ class CrescendoCardEditor extends HTMLElement { this._set({ [t.id]: t.value }); break; case "buttons_on": - case "show_value": - case "show_duration": case "group": case "show_lanes": case "columns_operable": diff --git a/custom_components/cloudless_crescendo/frontend/crescendo-panel.js b/custom_components/cloudless_crescendo/frontend/crescendo-panel.js index 6851809..284260a 100644 --- a/custom_components/cloudless_crescendo/frontend/crescendo-panel.js +++ b/custom_components/cloudless_crescendo/frontend/crescendo-panel.js @@ -29,6 +29,7 @@ const { roomLight, seriesVar, tr, + loadIconPicker, units, } = shared; @@ -787,9 +788,13 @@ class CrescendoPanel extends HTMLElement { // Home Assistant's icon picker reports its choice only on itself, without bubbling, so each one gets its own listener. this.shadowRoot.querySelectorAll("#btnTable ha-icon-picker").forEach((el) => { el.hass = this._hass; - el.value = el.getAttribute("value") || ""; el.addEventListener("value-changed", (e) => this._onChange(e)); }); + // Home Assistant loads its icon picker on demand; the table appears again once it is there. + if (!customElements.get("ha-icon-picker") && !this._waitingForPicker) { + this._waitingForPicker = true; + loadIconPicker().then((ready) => ready && this._o && this.shadowRoot.getElementById("btnTable") && this._renderButtons()); + } } _renderSettings() { diff --git a/custom_components/cloudless_crescendo/frontend/crescendo-shared.js b/custom_components/cloudless_crescendo/frontend/crescendo-shared.js index e8aa6a3..cc32b69 100644 --- a/custom_components/cloudless_crescendo/frontend/crescendo-shared.js +++ b/custom_components/cloudless_crescendo/frontend/crescendo-shared.js @@ -293,6 +293,21 @@ export function stripeLayers(options, list, override, dark, vertical = false) { export const friendly = (hass, entityId) => hass?.states?.[entityId]?.attributes?.friendly_name || entityId; // Lights with the same mode and the same values at every stop count as one unit. +// Home Assistant loads its icon picker on demand. Opening the button card's editor pulls it in; then it is +// there for our own forms. Resolves once the picker is defined, however it arrived. +export async function loadIconPicker() { + if (customElements.get("ha-icon-picker")) return true; + try { + const helpers = await window.loadCardHelpers?.(); + const card = helpers?.createCardElement({ type: "button", entity: "light.none" }); + await card?.constructor?.getConfigElement?.(); + } catch { + // Without the helpers the picker arrives whenever Home Assistant loads it for something else. + } + await customElements.whenDefined("ha-icon-picker"); + return true; +} + export function units(options, hass, group, count = true) { const list = options.lights.map((light, slot) => ({ light, slot, ids: [light.entity_id] })); if (!group) return list.map((u) => ({ ...u, name: friendly(hass, u.light.entity_id) })); diff --git a/custom_components/cloudless_crescendo/manifest.json b/custom_components/cloudless_crescendo/manifest.json index c59fb81..121540f 100644 --- a/custom_components/cloudless_crescendo/manifest.json +++ b/custom_components/cloudless_crescendo/manifest.json @@ -24,5 +24,5 @@ "iot_class": "calculated", "issue_tracker": "https://github.com/MadisonMedia/cloudless-crescendo/issues", "requirements": [], - "version": "0.2.0b2" + "version": "0.2.0b3" } diff --git a/tests/frontend/harness.html b/tests/frontend/harness.html index 590e0fc..8770f74 100644 --- a/tests/frontend/harness.html +++ b/tests/frontend/harness.html @@ -483,6 +483,10 @@ const flat = await newCard({ button_position: "bottom", power: "buttons" }); const rowOrder = [...flat.shadowRoot.querySelectorAll(".js-presets > *")].sort((a, b) => a.getBoundingClientRect().left - b.getBoundingClientRect().left); report("buttons below the control: power first, then from dim to bright", rowOrder[0].classList.contains("power-tile") && rowOrder.at(-1).dataset.pos === "100", rowOrder.map((b) => b.dataset.pos || "power").join(" ")); + const fourScenes = await newCard({ design: "scenes", button_position: "right", buttons: [25, 50, 75, 100].map((p) => ({ name: `S${p}`, position: p, duration: 1 })) }); + const sceneLefts = new Set([...fourScenes.shadowRoot.querySelectorAll(".js-scenes .preset")].map((b) => Math.round(b.getBoundingClientRect().left))); + const sceneTops = [...fourScenes.shadowRoot.querySelectorAll(".js-scenes .preset")].map((b) => b.getBoundingClientRect().top); + report("four scenes beside the control stand in one column, brightest on top", sceneLefts.size === 1 && sceneTops[3] < sceneTops[0], `${sceneLefts.size} columns`); // A button takes its share of the time for 0 to 100 %: the object's (here 1 s) or the card's own. const sped = await newCard({}, atPosition(62)); calls.length = 0; @@ -586,6 +590,10 @@ await wait(20); report("no glow setting", !ed("#glow")); report("a picked icon is saved and the picker stays", editor._config.buttons?.[1]?.icon === "mdi:sofa" && ed('ha-icon-picker[data-i="1"]') === fakePicker, JSON.stringify(editor._config.buttons?.[1])); + editor.setConfig({ ...editor._config, glow: false }); + ed("#snap").click(); + await wait(30); + report("retired settings are dropped when the card is saved", !("glow" in editor._config)); const pick = async (design) => { ed(`[data-design="${design}"]`).click(); await wait(30); }; await pick("dial"); report("no horizontal slider setting for the dial", !ed("#slider") && !!ed("#dial_wedge"));