From 3b2cbbc96c61efd619d8e03e1a81aa63d31a470b Mon Sep 17 00:00:00 2001 From: veenm Date: Sat, 31 Oct 2026 17:30:03 +0100 Subject: [PATCH] Dashboard Studio 2.5.0: phone sizes Small, Medium, Large and Row with a phone design per card Co-Authored-By: Claude Opus 5.5 --- dashboard_studio/CHANGELOG.md | 7 + dashboard_studio/DOCS.md | 17 +- dashboard_studio/config.yaml | 2 +- dashboard_studio/web/app.js | 50 ++++- dashboard_studio/web/i18n.js | 5 +- dashboard_studio/web/style.css | 86 ++++++++ dashboard_studio/web/widgets.js | 346 ++++++++++++++++++++++++++++++++ 7 files changed, 503 insertions(+), 10 deletions(-) diff --git a/dashboard_studio/CHANGELOG.md b/dashboard_studio/CHANGELOG.md index 90ccfb0..def8cce 100644 --- a/dashboard_studio/CHANGELOG.md +++ b/dashboard_studio/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## 2.5.0 + +- **Phone sizes:** on a phone dashboard every card now comes in four sizes, like widgets on a phone: **Small** (half width, square), **Medium** (full width, two rows), **Large** (full width, four rows) and **Row** (full width, one line). Pick one under **Size on the phone** in the inspector, or drag the corner: it snaps to the nearest size. +- **A phone design per size:** Small cards and Rows show what matters at a glance: an icon, the one figure or word that counts (the temperature, how many are home, when the bins go out, the score), the name, and one control where it helps, such as a switch, play and pause, + and −, open and close, or Clean now. Tap anywhere else for what the card normally does. A light as a Row is a fader you can drag to dim. +- **Medium and Large** show the full card, tuned for two or four rows on a phone: the forecast drops its days, Formula One its standings and the washer its extra facts at Medium. Entity tiles, buttons and timers get a larger figure instead of empty space. +- Turning on **Designed for phones** now gives each card the size that fits it best; new cards on a phone dashboard start at the right size too. Dashboards that aren't designed for phones are unchanged. + ## 2.4.2 - **Litter box, only when needed:** the card now stays out of sight until the waste bin is full or something is wrong, and then says so (*Bin full*, or *Check the litter box*) with the alert in amber. Untick **Only show when the bin is full or something is wrong** to see it all the time. Existing litter box cards get the new behaviour. diff --git a/dashboard_studio/DOCS.md b/dashboard_studio/DOCS.md index 76503d5..1173466 100644 --- a/dashboard_studio/DOCS.md +++ b/dashboard_studio/DOCS.md @@ -276,7 +276,22 @@ In the builder, hidden widgets stay visible but faded, with a **Hidden now** lab ## Phone dashboards -Turn on **Designed for phones** under **Grid** in the dashboard settings. The dashboard switches to a 4-column layout that you design at phone width, so what you see while editing is what the phone shows. Existing widgets are converted: small tiles become half width, larger cards full width, and nothing overlaps. Turning it off converts back to 12 columns. +Turn on **Designed for phones** under **Grid** in the dashboard settings. The dashboard switches to a 4-column layout that you design at phone width, so what you see while editing is what the phone shows. Existing widgets are converted: small tiles become Small, the rest Medium, Large or a Row by their height, and nothing overlaps. Turning it off converts back to 12 columns. + +### Phone sizes + +On a phone dashboard every card comes in four sizes, like widgets on a phone. Pick one under **Size on the phone** in the inspector, or drag the card's corner: it snaps to the nearest size. + +| Size | Space | What it shows | +| --- | --- | --- | +| **Small** | half the width, square | an icon, the one figure or word that counts, the name, and one control | +| **Medium** | full width, two rows | the card itself, with what doesn't fit two rows left out | +| **Large** | full width, four rows | the whole card | +| **Row** | full width, one line | the icon and name, the figure, and one control | + +Small cards and Rows show each card at a glance: a thermostat its target with − and +, a media player what's playing with play and pause, blinds how far open with open and close, a room its temperature with a switch for its lights, the bins when they go out, a match its score, a calendar the next event, a litter box what it's doing with **Clean now**. Tap anywhere else on the card for what it normally does, such as switching a light or opening the details. A light as a Row is a fader: drag across it to dim. Medium and Large show the full card; entity tiles, buttons and timers get a larger figure there instead of empty space. + +Not every card has every size: a heading is a Row or Medium, photos and web pages have no Row, and scroll cards are Medium or Large. Cards inside a scroll or swipe card keep their own design. On a phone, such a dashboard fills the whole screen, without the top bar with the dashboard picker and the screen switcher. To bring the bar back, tap the round **⋯** button in the top right corner (or touch and hold an empty spot); it hides again after 15 seconds or when you tap **⋯** again. On a computer, a phone dashboard shows as a phone-width column. Dashboards that aren't designed for phones keep reflowing into two columns on phones, with the top bar. With the phone on its side, a phone dashboard shows two columns of its layout next to each other. diff --git a/dashboard_studio/config.yaml b/dashboard_studio/config.yaml index 2b96e1e..b0cdac6 100644 --- a/dashboard_studio/config.yaml +++ b/dashboard_studio/config.yaml @@ -1,5 +1,5 @@ name: Dashboard Studio -version: "2.4.2" +version: "2.5.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 7a123e1..08814a6 100644 --- a/dashboard_studio/web/app.js +++ b/dashboard_studio/web/app.js @@ -250,6 +250,25 @@ return d; } + // ------------------------------------------------------------ phone sizes + // On a phone dashboard every card comes in four sizes, like widgets on a phone: Small (half width, square), + // Medium and Large (full width, two and four rows) and Row (full width, one line). Each has its own design. + const PHONE_SIZES = { s: [2, 2], m: [4, 2], l: [4, 4], row: [4, 1] }; + const phoneSizes = (type) => Widgets[type]?.phone?.sizes || Object.keys(PHONE_SIZES); + /** A widget's phone size, from its width and height. */ + const phoneSizeOf = (w) => (w.w <= 2 ? "s" : w.h <= 1 ? "row" : w.h >= 4 ? "l" : "m"); + /** The size of this card nearest to a width and height. */ + function snapPhone(type, w, h) { + const d = ([pw, ph]) => Math.abs(pw - w) * 2 + Math.abs(ph - h); + const k = [...phoneSizes(type)].sort((a, b) => d(PHONE_SIZES[a]) - d(PHONE_SIZES[b]))[0]; + return { w: PHONE_SIZES[k][0], h: PHONE_SIZES[k][1] }; + } + /** A card's size on a phone, from its size on a computer: small tiles Small, the rest by their height. */ + function phoneFit(type, w, h, cols = 12) { + const k = w <= cols / 4 && h <= 3 ? "s" : h <= 1 ? "row" : h >= 4 ? "l" : "m"; + return phoneSizes(type).includes(k) ? { w: PHONE_SIZES[k][0], h: PHONE_SIZES[k][1] } : snapPhone(type, ...PHONE_SIZES[k]); + } + function makeWidget(type, x = 0, y = 0) { const def = Widgets[type]; const cfg = {}; @@ -1070,8 +1089,12 @@ for (const [prop, val] of [["--lamp", lamp], ["--w-bg", st.bg], ["--w-color", st.color], ["--w-accent", st.accent]]) { if (val) el.style.setProperty(prop, val); else el.style.removeProperty(prop); } + // phone dashboards: each card's design for its size (Small, Medium, Large or Row) + const size = current?.mobile && !child ? phoneSizeOf(w) : null; + for (const k of Object.keys(PHONE_SIZES)) el.classList.toggle(`ph-${k}`, size === k); + ctx.phone = size; let html; - try { html = def.render(w.cfg, ctx); } catch (e) { console.error(e); html = `
${tr("This widget couldn’t be drawn.")}
`; } + try { html = (size && Widgets.__phoneRender(w.type, w.cfg, ctx, size)) || def.render(w.cfg, ctx); } catch (e) { console.error(e); html = `
${tr("This widget couldn’t be drawn.")}
`; } const hiddenNow = editMode && !child && !isVisible(w); el.classList.toggle("cond-hidden", hiddenNow); el.innerHTML = `
${html}
` + @@ -1143,7 +1166,7 @@ const eid = b.dataset.e, type = SUGGEST[domainOf(eid)]; const wd = makeWidget(type); if (Widgets[type].fields.some((f) => f.k === "entities")) wd.cfg.entities = [eid]; else wd.cfg.entity = eid; - wd.w = current.mobile ? (wd.w >= 3 ? 4 : 2) : Math.min(wd.w, current.grid.cols); + if (current.mobile) Object.assign(wd, phoneFit(type, wd.w, wd.h)); else wd.w = Math.min(wd.w, current.grid.cols); Object.assign(wd, findSpot(wd.w, wd.h)); mutate(() => tab().widgets.push(wd)); $(".sg-list").innerHTML = draw(); @@ -1310,7 +1333,8 @@ ${def.fields.filter((f) => !f.when || f.when(w.cfg)).map((f) => fieldHTML(f, w.cfg[f.k], "cfg")).join("")} ${def.container ? childrenHTML(w) : ""}
${tr("Size and position")}
-
${[["x", "Column"], ["y", "Row"], ["w", "Width"], ["h", "Height"]].map(([k, l]) => `
`).join("")}
+ ${current.mobile ? fieldHTML({ k: "size", t: "seg", l: "Size on the phone", o: phoneSizes(w.type), labels: { s: tr("Small"), m: tr("Medium"), l: tr("Large"), row: tr("Row") } }, phoneSizeOf(w), "phsize") : ""} +
${(current.mobile ? [["x", "Column"], ["y", "Row"]] : [["x", "Column"], ["y", "Row"], ["w", "Width"], ["h", "Height"]]).map(([k, l]) => `
`).join("")}
${current.tabs.length > 1 ? fieldHTML({ k: "tab", t: "select", l: "Tab", o: current.tabs.map((t) => t.id), labels: Object.fromEntries(current.tabs.map((t) => [t.id, t.name])) }, tab().id, "move") : ""}
${tr("Style")}
${STYLE_FIELDS.map((f) => fieldHTML(f, w.style?.[f.k], "style")).join("")}
${visibilityHTML(w)} @@ -1542,6 +1566,13 @@ return; } if (path === "layout") { convertLayout(!!v); renderInspector(); return; } + if (path === "phsize") { + const w = selectedWidget(), [pw, ph] = PHONE_SIZES[v] || []; + if (!w || !pw) return; + mutate(() => { Object.assign(w, { w: pw, h: ph, x: Math.min(w.x, current.grid.cols - pw) }); resolveCollisions(w); }); + renderInspector(); + return; + } if (path === "nav") { mutate(() => { current.nav[k] = v; }); if (k === "awayTab" || k === "awayPerson") { awayState = null; if (isChange) renderInspector(); } @@ -1696,9 +1727,10 @@ for (const t of current.tabs) { const ws = [...t.widgets].sort((a, b) => a.y - b.y || a.x - b.x); for (const w of ws) { - if (toMobile) { // phone: small tiles become half width, anything a third wide or more full width - w.w = w.w >= from / 3 ? 4 : 2; - w.x = w.w === 4 ? 0 : w.x * f >= 1.5 ? 2 : 0; + if (toMobile) { // phone: small tiles become Small, the rest Medium, Large or a Row by their height + const x0 = w.x; + Object.assign(w, phoneFit(w.type, w.w, w.h, from)); + w.x = w.w === 4 ? 0 : x0 * f >= 1.5 ? 2 : 0; } else { w.w = Math.max(1, Math.min(to, Math.round(w.w * f))); w.x = Math.min(to - w.w, Math.round(w.x * f)); @@ -1734,7 +1766,7 @@ function addWidget(type) { const wd = makeWidget(type); - wd.w = current.mobile ? (wd.w >= 3 ? 4 : 2) : Math.min(wd.w, current.grid.cols); + if (current.mobile) Object.assign(wd, phoneFit(wd.type, wd.w, wd.h)); else wd.w = Math.min(wd.w, current.grid.cols); Object.assign(wd, findSpot(wd.w, wd.h)); mutate(() => tab().widgets.push(wd)); select(wd.id); @@ -1994,6 +2026,10 @@ drag.el.style.height = `${Math.max(rh, drag.r0.height + dy)}px`; n.w = Math.max(1, Math.min(cols - o.x, o.w + Math.round(dx / (colW + gap)))); n.h = Math.max(1, o.h + Math.round(dy / (rh + gap))); + if (current.mobile) { // phone dashboards: the nearest of the card's sizes + Object.assign(n, snapPhone(drag.w.type, o.w + (dx / (colW + gap)), o.h + (dy / (rh + gap)))); + n.x = Math.min(n.x, cols - n.w); + } } placeEl(drag.ph, n); showGuides(n, drag.w); diff --git a/dashboard_studio/web/i18n.js b/dashboard_studio/web/i18n.js index b72cf8d..a6d0043 100644 --- a/dashboard_studio/web/i18n.js +++ b/dashboard_studio/web/i18n.js @@ -302,7 +302,10 @@ "Clean button": "Schoonmaakknop", "Auto clean switch": "Schakelaar automatisch schoonmaken", "Choose the litter box’s status entity in the inspector.": "Kies de status-entiteit van de kattenbak in de inspector.", "Emptying": "Aan het legen", "Levelling": "Aan het egaliseren", "Sanitizing": "Aan het ontsmetten", "Sleeping": "Slaapt", "Ready": "Klaar voor gebruik", "Bin full": "Afvalbak vol", "Check the litter box": "Kijk even bij de kattenbak", "Cat inside": "Kat in de bak", "Needs attention": "Heeft aandacht nodig", - "visits today": "bezoekjes vandaag", "visits": "bezoekjes", "visit": "bezoekje", "Only show when the bin is full or something is wrong": "Alleen tonen als de afvalbak vol is of er iets mis is", "Clean": "Schoonmaken", "Auto": "Auto", "in the box today": "in de bak vandaag", "cat’s weight": "gewicht kat", "Clean now": "Nu schoonmaken", "Auto clean": "Automatisch schoonmaken", + "visits today": "bezoekjes vandaag", "visits": "bezoekjes", "visit": "bezoekje", + "Size on the phone": "Formaat op de telefoon", "Small": "Klein", "Medium": "Middel", "Now {0}°": "Nu {0}°", "Nobody home": "Niemand thuis", + "Tap to watch": "Tik om te kijken", "Returning": "Terug naar het dock", "Sun {0}": "Zon {0}", "Grid {0}": "Net {0}", "Sending back {0}": "Teruglevering {0}", + "{0} open": "{0} open", "Nothing on the way": "Niets onderweg", "Partly open": "Half open", "{0} of {1} open": "{0} van {1} open", "Nothing planned": "Niets gepland", "Only show when the bin is full or something is wrong": "Alleen tonen als de afvalbak vol is of er iets mis is", "Clean": "Schoonmaken", "Auto": "Auto", "in the box today": "in de bak vandaag", "cat’s weight": "gewicht kat", "Clean now": "Nu schoonmaken", "Auto clean": "Automatisch schoonmaken", "Starts later": "Start later", "Action needed": "Actie nodig", "Error": "Fout", "Stopping": "Stoppen", "Door locked": "Deur vergrendeld", "Ready at {0}": "Klaar om {0}", "Check the appliance": "Kijk even bij het apparaat", "Something went wrong": "Er ging iets mis", "Stop": "Stop", "Hide this card when none of its cards are showing": "Verberg deze kaart als geen van de kaarten erin zichtbaar is", diff --git a/dashboard_studio/web/style.css b/dashboard_studio/web/style.css index 3127754..4fba83f 100644 --- a/dashboard_studio/web/style.css +++ b/dashboard_studio/web/style.css @@ -2056,3 +2056,89 @@ button.fader-input { cursor: pointer; } .lb-acts { margin-top: 6px; } } @container (min-height: 260px) and (min-width: 180px) { .lb-ring { display: grid; height: min(50%, 170px); } } + +/* ================================================================ phone dashboards: Small cards and Rows + Each card's summary: an icon, the one figure that matters, a name, and at most one control. + A tap anywhere does what the card does (phc-tap lies under everything); controls sit on top. */ +.phc { position: relative; height: 100%; min-width: 0; color: var(--d-text); } +.phc-tap { position: absolute; inset: calc(-1 * var(--c-pad)); z-index: 0; background: none; border-radius: inherit; } +.phc-top, .phc-bottom, .phc-text, .phc-ico, .phc-art, .phc-val { position: relative; z-index: 1; pointer-events: none; } +.phc-ctl { position: relative; z-index: 2; display: flex; align-items: center; gap: 6px; flex: none; } +.phc-ico { display: flex; font-size: var(--c-ico); color: var(--d-text-2); } +.phc.on .phc-ico { color: var(--phc-tint, var(--d-accent)); } +.w.lit-lamp .phc .phc-ico, .w.lit-on .phc .phc-ico { color: inherit; } +/* plain and outline surfaces keep the card's own background: the icon lights up instead */ +.surface-plain .w.lit-lamp .phc .phc-ico, .surface-outline .w.lit-lamp .phc .phc-ico { color: color-mix(in srgb, var(--lamp, #e3a12f) 70%, #b77a10); } +.surface-plain .w.lit-on .phc .phc-ico, .surface-outline .w.lit-on .phc .phc-ico { color: var(--accent-w); } +.phc-art { width: 40px; height: 40px; border-radius: 8px; object-fit: cover; flex: none; } +.phc-btn { width: var(--c-ctl); height: var(--c-ctl); border-radius: 50%; display: grid; place-items: center; background: var(--d-fill); font-size: 17px; flex: none; } +.phc-btn.wide { width: auto; padding: 0 14px; border-radius: 99px; font-size: var(--c-fs-meta); font-weight: 600; } +.phc-btn:disabled { opacity: .45; } +.phc-btn.wide { display: inline-flex; gap: 6px; } +.phc-s .phc-btn.wide { width: 34px; padding: 0; justify-content: center; } +.phc-s .phc-btn.wide span { display: none; } +.phc-bin { display: flex; } +.phc-val { font-size: 30px; font-weight: 400; letter-spacing: -0.03em; line-height: 1.05; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; } +.phc-val small { font-size: .5em; color: var(--d-text-2); margin-left: 3px; letter-spacing: 0; } +.phc-word { font-size: 21px; letter-spacing: -0.02em; } +.phc-name { font-size: var(--c-fs-body); font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.phc-sub { font-size: var(--c-fs-meta); color: var(--d-text-2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.phc-meter { display: block; height: 4px; border-radius: 2px; background: var(--d-fill); margin: 5px 0 6px; overflow: hidden; } +.phc-meter i { display: block; height: 100%; border-radius: inherit; background: var(--phc-tint, var(--d-accent)); } +.tone-ok .phc-val { color: var(--ok); } .tone-ok .phc-meter i { background: var(--ok); } +.tone-warn .phc-val { color: var(--warn); } .tone-warn .phc-meter i { background: var(--warn); } +.tone-crit .phc-val { color: var(--crit); } .tone-crit .phc-meter i { background: var(--crit); } +.tone-accent .phc-val { color: var(--d-accent); } +.w.lit-lamp .phc .phc-val, .w.lit-on .phc .phc-val, .w.lit-lamp .phc-val small, .w.lit-lamp .phc-sub { color: inherit; } +.phc-clock .time { font: inherit; letter-spacing: inherit; } +.phc-clock .date { font-size: var(--c-fs-meta); color: var(--d-text-2); letter-spacing: 0; margin-top: 2px; } + +/* Small (and Large): the icon and control on top, the figure and name at the bottom */ +.phc-s, .phc-l { display: flex; flex-direction: column; justify-content: space-between; gap: 6px; } +.phc-s .phc-top, .phc-l .phc-top { display: flex; align-items: flex-start; justify-content: space-between; gap: 6px; min-height: var(--c-ctl); } +.phc-s .phc-ctl .phc-btn { width: 34px; height: 34px; font-size: 16px; } +.phc-s .phc-bottom, .phc-l .phc-bottom { display: flex; flex-direction: column; min-width: 0; } +.phc-s .phc-sub, .phc-l .phc-sub { white-space: normal; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; } +.phc-s .phc-val + .phc-name, .phc-s .phc-meter + .phc-name { margin-top: 2px; } + +/* Row: one line: icon, name and what it says, the figure, a control */ +.phc-row { display: flex; align-items: center; gap: 12px; } +.phc-row .phc-text { flex: 1; min-width: 0; display: flex; flex-direction: column; } +.phc-row .phc-val { flex: none; max-width: 45%; font-size: 24px; } +.phc-row .phc-word { font-size: 17px; } +.phc-row .phc-art { width: 36px; height: 36px; } +.phc-row .phc-clock .date { display: none; } +.w.ph-row .w-body { padding-top: 0; padding-bottom: 0; justify-content: center; } + +/* a light as a Row: the fader the group card uses, filling the card */ +.w.ph-row .w-body:has(.phc-light) { padding: 0; } +.phc-light { height: 100%; } +.phc-light .lf-row { height: 100%; grid-template-columns: auto minmax(0, 1fr) auto auto; padding: 0 10px 0 12px; border-radius: inherit; } +.phc-light .lt-ico { position: relative; z-index: 2; } +.phc-l .phc-ico { font-size: 34px; } +.phc-l .phc-val { font-size: 64px; } +.phc-l .phc-word { font-size: 34px; } +.phc-l .phc-name { font-size: 18px; } +.phc-l .phc-sub { font-size: var(--c-fs-body); } +.phc-l .phc-meter { height: 6px; border-radius: 3px; } + +/* Medium: the icon, name and control on one line, the figure large below */ +.phc-m { display: flex; flex-direction: column; justify-content: space-between; gap: 6px; } +.phc-m .phc-top { display: flex; align-items: center; gap: 12px; min-width: 0; } +.phc-m .phc-text { flex: 1; min-width: 0; display: flex; flex-direction: column; } +.phc-m .phc-bottom { display: flex; flex-direction: column; min-width: 0; } +.phc-m .phc-val { font-size: 42px; } +.phc-m .phc-word { font-size: 26px; } + +/* Medium cards drawn in full: what doesn't fit two rows on a phone makes way */ +.w.ph-m .appl-fact ~ .appl-fact { display: none; } +.w.ph-m .f1-tabs, .w.ph-m .f1-list { display: none; } +.w.ph-m .fc-days { display: none; } +.w.ph-m .graph .legend > span ~ span { display: none; } +/* cards that draw edge to edge (camera, photos) still get their padding as a summary */ +.w-body.flush:has(> .phc) { padding: var(--c-pad); } +.w.ph-row .w-body.flush:has(> .phc) { padding: 0 var(--c-pad); } +/* nothing to show but a name (a button): the name, larger, in the middle */ +.phc-m:not(:has(.phc-bottom)) { justify-content: center; } +.phc-m:not(:has(.phc-bottom)) .phc-name { font-size: 20px; } +.phc-l:not(:has(.phc-val)) .phc-name { font-size: 24px; } diff --git a/dashboard_studio/web/widgets.js b/dashboard_studio/web/widgets.js index 698aed5..4971dd6 100644 --- a/dashboard_studio/web/widgets.js +++ b/dashboard_studio/web/widgets.js @@ -1926,6 +1926,8 @@ } await cache.loading; const list = el.querySelector(".cal"); + // the phone's small versions show the next event from the cache: draw them again once it's there + if (el.isConnected && !list && cache.drawnAt !== cache.at) { cache.drawnAt = cache.at; ctx.rerender(); } if (!el.isConnected || !list) return; if (cache.error) { list.innerHTML = `
${t("Couldn’t load events. {0}", esc(cache.error))}
`; return; } @@ -2213,6 +2215,350 @@ }, }; + // ================================================================ phone versions + // Phone dashboards give every card four sizes, like widgets on a phone: Small (half width, square), + // Medium and Large (full width, two and four rows high) and Row (full width, one line). + // Medium and Large draw the card itself (tuned for phones in style.css). Small and Row draw the card's + // summary: an icon, a name, the one figure that matters and at most one control. A card can ask for its + // own drawing at a size instead ("card"), or leave a size out when it has no sensible form there. + // + // A summary is { ico, name, value, unit, word, sub, tone, on, tint, meter, ctl, tap }: + // value + unit a figure, drawn large (word: the same, but words, such as "Open" or "Cleaning…") + // tone "ok" | "warn" | "crit" | "accent" colours the figure; on lights the card like a lit tile + // meter 0–100, a thin bar under the figure (brightness, battery, a gauge) + // ctl HTML for one control (a switch, a round button, two at most) + // tap { act, e, v } for a tap anywhere else on the card + const ico = (name) => Icons.svg(name); + const numOf = (s, decimals) => (s && s.state !== "" && isFinite(Number(s.state)) ? fmtNum(s.state, decimals) : null); + const sw = (eid, on, label) => ``; + const rnd = (act, eid, icn, label, v, extra = "") => ``; + const more = (eid) => (eid ? { act: "more", e: eid } : null); + + const SUMMARY = { + entity(c, { ha }) { + const s = ha.states[c.entity]; + if (!s) return null; + const d = domainOf(c.entity), tap = c.tap === "auto" || !c.tap ? (TOGGLE_DOMAINS.has(d) ? "toggle" : "more-info") : c.tap; + const n = numOf(s); + return { ico: icon(s, c.icon), name: friendly(s, c.name), value: n, unit: n != null ? s.attributes.unit_of_measurement || "" : "", + word: n == null ? fmtState(s) : "", sub: c.showChanged ? relTime(s.last_changed) : "", + on: isOn(s), tint: domainOf(c.entity) === "light" ? lightTint(s) : "", + tap: tap === "none" ? null : { act: tap === "toggle" ? "toggle" : "more", e: c.entity } }; + }, + value(c, { ha }) { + const s = ha.states[c.entity]; + if (!s) return null; + const v = Number(s.state), lvl = isFinite(v) && has(c.crit) && v > Number(c.crit) ? "crit" : isFinite(v) && has(c.warn) && v > Number(c.warn) ? "warn" : ""; + const n = numOf(s, c.decimals); + return { ico: icon(s, c.icon), name: friendly(s, c.name), value: n, word: n == null ? cap(s.state) : "", unit: c.unit || s.attributes.unit_of_measurement || "", + tone: lvl, sub: lvl ? (lvl === "crit" ? t("Critical") : t("High")) : "", tap: more(c.entity) }; + }, + gauge(c, { ha }) { + const s = ha.states[c.entity]; + if (!s) return null; + const v = Number(s.state), min = Number(c.min) || 0, max = Number(c.max) || 100; + const tone = !isFinite(v) ? "" : has(c.red) && v >= Number(c.red) ? "crit" : has(c.yellow) && v >= Number(c.yellow) ? "warn" : has(c.green) && v >= Number(c.green) ? "ok" : ""; + return { ico: icon(s), name: friendly(s, c.name), value: numOf(s) ?? "–", unit: s.attributes.unit_of_measurement || "", tone, + meter: isFinite(v) ? Math.max(0, Math.min(100, ((v - min) / (max - min || 1)) * 100)) : null, tap: more(c.entity) }; + }, + climate(c, { ha }) { + const s = ha.states[c.entity]; + if (!s) return null; + const a = s.attributes, step = Number(c.step) || 0.5; + return { ico: icon(s), name: friendly(s, c.name), value: a.temperature != null ? fmtNum(a.temperature) : "–", unit: "°", + sub: [a.current_temperature != null && t("Now {0}°", fmtNum(a.current_temperature)), st(s.state)].filter(Boolean).join(" · "), + on: s.state !== "off", tap: more(c.entity), + ctl: rnd("temp", c.entity, "minus", t("Lower to {0}°", (a.temperature ?? 0) - step), -step) + rnd("temp", c.entity, "plus", t("Raise to {0}°", (a.temperature ?? 0) + step), step) }; + }, + graph(c, { ha }) { + const s = ha.states[(c.entities || [])[0]]; + if (!s) return null; + const n = numOf(s); + return { ico: ico("chart"), name: c.title || friendly(s), value: n, word: n == null ? fmtState(s) : "", unit: n != null ? s.attributes.unit_of_measurement || "" : "", + sub: (c.entities || []).length > 1 ? (c.entities.slice(1).map((e) => ha.states[e] && fmtState(ha.states[e])).filter(Boolean).join(" · ")) : "", tap: more(c.entities[0]) }; + }, + clock(c, { ha }) { + const s = c.weather && ha.states[c.weather]; + return { ico: ico("clock"), name: s ? `${st(s.state)} · ${s.attributes.temperature ?? "–"}°` : "", + value: `${clockHTML(c.h24, false, c.date)}`, + tap: more(c.weather) }; + }, + weather(c, { ha }) { + const s = ha.states[c.entity]; + if (!s) return null; + return { ico: ico(WX[s.state] || "cloud"), name: friendly(s, c.name), value: s.attributes.temperature ?? "–", unit: "°", sub: st(s.state), tap: more(c.entity) }; + }, + forecast(c, { ha, cache }) { + const s = ha.states[c.entity]; + if (!s) return null; + const d = cache.daily?.[0]; + return { ico: ico(WX[s.state] || "cloud"), name: friendly(s), value: s.attributes.temperature ?? "–", unit: "°", + sub: d ? t("High {0}° · Low {1}°", Math.round(d.temperature), Math.round(d.templow ?? d.temperature)) : st(s.state), tap: more(c.entity) }; + }, + list(c, { ha }) { + const ids = (c.entities || []).filter((e) => ha.states[e]); + if (!ids.length) return null; + const on = ids.filter((e) => isOn(ha.states[e])); + return { ico: ico("list"), name: c.title || t("Devices"), value: on.length, unit: `/ ${ids.length}`, sub: on.length ? on.map((e) => friendly(ha.states[e])).join(", ") : t("All off"), on: on.length > 0 }; + }, + button: (c) => ({ ico: icon(null, c.icon || "sparkles"), name: c.label || t("Run"), tap: { act: "button" } }), + media(c, { ha, cache }) { + const ids = mediaIds(c).filter((e) => ha.states[e]); + if (!ids.length) return null; + const sel = mediaSelected(ids, ha, cache), s = ha.states[sel], a = s.attributes; + const off = ["off", "standby", "unavailable"].includes(s.state), playing = s.state === "playing"; + return { ico: icon(s), name: friendly(s), sub: off ? st(s.state) : [a.media_title, a.media_artist].filter(Boolean).join(" · ") || st(s.state), + on: playing, tap: more(sel), art: c.art !== false && !off ? a.entity_picture : "", + ctl: off ? rnd("svc", sel, "power", t("Turn on"), "media_player.turn_on") : rnd("svc", sel, playing ? "pause" : "play", playing ? t("Pause") : t("Play"), "media_player.media_play_pause") }; + }, + todo(c, { ha, cache }) { + const s = ha.states[c.entity]; + if (!s) return null; + const open = (cache.items || []).filter((i) => i.status !== "completed"); + return { ico: ico("checkbox"), name: c.title || friendly(s), value: numOf(s) ?? open.length, sub: open.slice(0, 3).map((i) => i.summary).join(", ") || t("Nothing on the list."), tap: more(c.entity) }; + }, + people(c, { ha }) { + const ids = W.people.entities(c, ha).filter((e) => ha.states[e]); + if (!ids.length) return null; + const home = ids.filter((e) => ha.states[e].state === "home"); + return { ico: ico("people"), name: t("Home"), value: home.length, unit: `/ ${ids.length}`, on: home.length > 0, + sub: home.length ? home.map((e) => friendly(ha.states[e]).split(" ")[0]).join(", ") : t("Nobody home") }; + }, + timer(c, { ha }) { + const s = ha.states[c.entity]; + if (!s) return null; + const a = s.attributes, dur = toSecs(a.duration), active = s.state === "active"; + const left = active && a.finishes_at ? (new Date(a.finishes_at) - Date.now()) / 1000 : s.state === "paused" ? toSecs(a.remaining) : dur; + const first = String(c.presets || "").split(/[,\s]+/).map(Number).find((m) => m > 0); + return { ico: ico("timer"), name: friendly(s, c.name), on: active, sub: st(s.state), + value: `${hms(left)}`, + ctl: active ? rnd("timer", c.entity, "pause", t("Pause"), "pause") : s.state === "paused" ? rnd("timer", c.entity, "play", t("Resume"), "start") + : dur ? rnd("timer", c.entity, "play", t("Start"), "start") : first ? rnd("timer", c.entity, "play", `${first} ${t("min")}`, "start", `data-d="${first}"`) : "" }; + }, + covers(c, { ha }) { + const ids = (c.entities || []).filter((e) => ha.states[e]); + if (!ids.length) return null; + const pos = ids.map((e) => coverPos(ha.states[e])), avg = Math.round(pos.reduce((x, y) => x + y, 0) / pos.length); + const one = ids.length === 1, s = ha.states[ids[0]], all = esc(ids.join(",")); + const moving = ids.find((e) => ["opening", "closing"].includes(ha.states[e].state)); + return { ico: ico("blinds"), name: c.title || (one ? friendly(s) : t("Blinds")), value: avg, unit: "%", meter: avg, on: avg > 0, + sub: moving ? st(ha.states[moving].state) : one ? (avg >= 100 ? t("Open") : avg <= 0 ? t("Closed") : t("Partly open")) : t("{0} of {1} open", pos.filter((p) => p > 0).length, ids.length), + tap: one ? more(ids[0]) : null, + ctl: `` }; + }, + alarm(c, { ha }) { + const s = ha.states[c.entity]; + if (!s) return null; + const armed = s.state.startsWith("armed"), word = { disarmed: t("Off"), armed_home: t("On · home"), armed_away: t("On · away"), armed_night: t("On · night"), armed_vacation: t("On · vacation"), arming: t("Turning on…"), pending: t("Entry delay…"), triggered: t("Alarm!") }[s.state] || st(s.state); + return { ico: ico("shield"), name: friendly(s), word, tone: s.state === "triggered" ? "crit" : armed ? "ok" : s.state === "pending" ? "warn" : "", on: armed, tap: more(c.entity) }; + }, + notifications(c, { ha }) { + const notes = c.notes !== false ? Object.values(ha.notifications || {}) : []; + const ups = c.updates !== false ? Object.values(ha.states).filter((s) => s.entity_id.startsWith("update.") && s.state === "on") : []; + const n = notes.length + ups.length; + return { ico: ico("bell"), name: t("Notifications"), value: n, tone: n ? "accent" : "", + sub: n ? [...ups.map((u) => u.attributes.title || friendly(u)), ...notes.map((x) => x.title || x.message)].slice(0, 2).join(" · ") : t("All caught up") }; + }, + energy(c, { ha }) { + if (!c.solar && !c.grid && !c.battery) return null; + const S = (k) => (c[k] ? ha.states[c[k]] : null); + const solar = Math.max(0, watts(S("solar")) ?? 0); + let grid = watts(S("grid")) ?? 0; if (c.invertGrid) grid = -grid; + let batt = watts(S("battery")) ?? 0; if (c.invertBattery) batt = -batt; + const home = Math.max(0, watts(S("home")) ?? solar + Math.max(0, grid) + Math.max(0, -batt) - Math.max(0, -grid) - Math.max(0, batt)); + const [v, u] = fmtW(home).split(" "); + return { ico: ico("bolt"), name: t("House"), value: v, unit: u, + sub: [c.solar && t("Sun {0}", fmtW(solar)), c.grid && (grid < -1 ? t("Sending back {0}", fmtW(-grid)) : t("Grid {0}", fmtW(Math.max(0, grid))))].filter(Boolean).join(" · ") }; + }, + room(c, { ha }) { + const area = ha.reg?.areas?.find((a) => a.area_id === c.area), ids = roomEntities(c, ha); + if (!area && !ids.length) return null; + const S = (e) => ha.states[e], name = c.name || area?.name || t("Room"); + const temp = [c.temperature, area?.temperature_entity_id].find((e) => e && S(e)) || ids.find((e) => domainOf(e) === "sensor" && S(e).attributes.device_class === "temperature"); + const lights = ids.filter((e) => domainOf(e) === "light" && !Array.isArray(S(e).attributes.entity_id)), on = lights.filter((e) => S(e).state === "on"); + const open = ids.filter((e) => domainOf(e) === "binary_sensor" && ["door", "window", "opening", "garage_door"].includes(S(e).attributes.device_class) && S(e).state === "on"); + return { ico: icon(null, c.icon || "sofa"), name, value: temp ? numOf(S(temp)) : null, unit: temp ? "°" : "", on: on.length > 0, tone: open.length ? "warn" : "", + sub: open.length ? t("{0} open", open.length) : lights.length ? (on.length ? t("{0} of {1} lights on", on.length, lights.length) : t("Lights off")) : "", + ctl: lights.length ? `` : "" }; + }, + vacuum(c, { ha }) { + const s = ha.states[c.entity]; + if (!s) return null; + const a = s.attributes, cleaning = s.state === "cleaning", batt = Number(c.battery ? ha.states[c.battery]?.state : a.battery_level); + return { ico: ico("vacuum"), name: friendly(s, c.name), word: cleaning ? t("Cleaning…") : s.state === "returning" ? t("Returning") : st(s.state), + sub: isFinite(batt) ? t("Battery {0}%", Math.round(batt)) : "", meter: isFinite(batt) ? batt : null, on: cleaning, tone: s.state === "error" ? "crit" : "", tap: more(c.entity), + ctl: rnd("svc", c.entity, cleaning ? "pause" : "play", cleaning ? t("Pause") : t("Start"), `vacuum.${cleaning ? "pause" : "start"}`) }; + }, + appliance(c, { ha }) { + if (c.kind === "litterbox") { + if (!c.status) return null; + const { phase, word } = litterPhase(c, ha), v = ha.states[c.visits]; + return { ico: ico(phase === "attention" ? "shield" : "paw"), name: c.name || t("Litter box"), word, tone: phase === "attention" ? "warn" : phase === "cleaning" ? "accent" : "", + sub: v && isFinite(Number(v.state)) ? `${Math.round(Number(v.state))} ${Number(v.state) === 1 ? t("visit") : t("visits")}` : "", + ctl: c.cleanBtn && ha.states[c.cleanBtn] ? rnd("svc", c.cleanBtn, "sparkles", t("Clean now"), domainOf(c.cleanBtn) === "script" ? "script.turn_on" : "button.press", phase === "cleaning" ? "disabled" : "") : "" }; + } + if (!c.status && !c.power && !c.remaining) return null; + const phase = appliancePhase(c, ha), left = ["running", "paused"].includes(phase) ? remainingMinutes(c, ha) : null; + const label = { running: t("In progress"), done: t("Finished"), paused: t("Paused"), off: t("Off"), scheduled: t("Starts later"), attention: t("Action needed"), error: t("Error"), stopping: t("Stopping") }[phase]; + const ii = { washer: "washer", dryer: "dryer", dishwasher: "dishwasher", other: "plug" }[c.kind] || "washer"; + return { ico: ico(phase === "done" ? "check" : ii), name: c.name || t("Appliance"), word: left != null ? fmtDuration(left) : label, sub: left != null ? label : "", + on: ["running", "done"].includes(phase), tone: phase === "done" ? "ok" : phase === "attention" ? "warn" : phase === "error" ? "crit" : phase === "running" ? "accent" : "", + meter: Number.isFinite(Number(ha.states[c.progress]?.state)) && phase === "running" ? Number(ha.states[c.progress].state) : null }; + }, + camera(c, { ha }) { + const s = ha.states[c.entity]; + return s ? { ico: ico("camera"), name: friendly(s), sub: t("Tap to watch"), tap: more(c.entity) } : null; + }, + car(c, { ha }) { + const S = (k) => (c[k] ? ha.states[c[k]] : null), level = S("level"), range = S("range"), lock = S("lock"); + if (!level && !range && !lock) return null; + const pct = numOf(level), charging = c.kind !== "fuel" && (truthy(S("charging")) || (S("chargeSwitch")?.state === "on" && domainOf(c.chargeSwitch) === "switch")); + const locked = lock?.state === "locked"; + return { ico: ico("car"), name: c.name || t("Car"), value: pct != null ? Math.round(Number(level.state)) : null, unit: pct != null ? "%" : "", + meter: pct != null ? Number(level.state) : null, tone: pct != null && Number(level.state) <= 20 ? "warn" : charging ? "ok" : "", on: charging, + sub: [charging && t("Charging"), range && `${fmtNum(range.state)} ${range.attributes.unit_of_measurement || "km"}`].filter(Boolean).join(" · "), + ctl: lock ? rnd("toggle", c.lock, locked ? "lock" : "unlock", locked ? t("Locked, tap to unlock") : t("Unlocked, tap to lock")) : "" }; + }, + parcels(c, { ha }) { + const list = (c.entities || []).map((e) => ha.states[e]).filter(Boolean); + if (!list.length) return null; + const coming = list.flatMap((s) => parcelsOf(s)).filter((p) => !p.done).sort((a, b) => (a.date?.getTime() ?? 9e15) - (b.date?.getTime() ?? 9e15)); + const today = coming.filter((p) => p.date && dayLabel(p.date) === t("Today")).length; + return { ico: ico("package"), name: c.title || t("Parcels"), value: coming.length, tone: today ? "accent" : "", + sub: today ? t("{0} today", today) : coming[0] ? `${coming[0].name}${coming[0].date ? ` · ${dayLabel(coming[0].date)}` : ""}` : t("Nothing on the way"), tap: more(c.entities[0]) }; + }, + air(c, { ha }) { + const sensors = (c.entities?.length ? c.entities : W.air.entities(c, ha)).map((e) => ha.states[e]).filter((s) => s && AIR[s.attributes.device_class] && isFinite(Number(s.state))); + if (!sensors.length) return null; + const lv = sensors.map((s) => ({ s, lvl: AIR[s.attributes.device_class].lv(Number(s.state)) })).sort((a, b) => b.lvl - a.lvl), worst = lv[0]; + const co2 = sensors.find((s) => AIR[s.attributes.device_class].k === "co2"); + return { ico: ico("air"), name: c.title || t("Air quality"), word: t(["Good", "Fair", "Poor"][worst.lvl]), tone: ["ok", "warn", "crit"][worst.lvl], + sub: worst.lvl ? friendly(worst.s) : co2 ? `CO₂ ${fmtState(co2)}` : "", tap: more(worst.s.entity_id) }; + }, + usage(c, { ha, cache }) { + const s = ha.states[c.entity]; + if (!s) return null; + const liters = (c.kind || "water") === "water" && s.attributes.unit_of_measurement === "m³"; + const days = (cache.days || []).map((d) => (liters ? d.v * 1000 : d.v)), today = days[days.length - 1], yest = days[days.length - 2]; + const unit = liters ? "L" : s.attributes.unit_of_measurement || ""; + return { ico: ico({ water: "droplet", gas: "flame", electricity: "bolt" }[c.kind] || "droplet"), name: c.title || friendly(s), + value: today != null ? fmtNum(today) : "–", unit, sub: yest != null ? t("yesterday {0}", `${fmtNum(yest)} ${unit}`) : t("Today so far"), tap: more(c.entity) }; + }, + solar(c, { ha }) { + const today = ha.states[c.today], tomorrow = ha.states[c.tomorrow]; + if (!today && !tomorrow) return null; + const s = today || tomorrow; + return { ico: ico("solar"), name: c.title || t("Solar forecast"), value: numOf(s) ?? "–", unit: s.attributes.unit_of_measurement || "kWh", + sub: today && tomorrow ? `${t("Tomorrow")} ${fmtNum(tomorrow.state)} ${tomorrow.attributes.unit_of_measurement || "kWh"}` : today ? t("Today") : t("Tomorrow"), tap: more(c.today || c.tomorrow) }; + }, + gate(c, { ha }) { + const s = ha.states[c.entity]; + if (!s) return null; + const d = domainOf(c.entity), open = ["open", "opening", "unlocked", "unlocking", "on"].includes(s.state), moving = ["opening", "closing", "locking", "unlocking"].includes(s.state); + const name = c.name || friendly(s), label = d === "button" ? t("Press") : moving ? st(s.state) : open ? (d === "lock" ? t("Lock") : t("Close")) : d === "lock" ? t("Unlock") : t("Open"); + const k = { garage: "garage", gate: "gate", door: "door" }[c.kind] || "garage"; + return { ico: ico(open && c.kind === "door" ? "door-open" : k), name, word: d === "button" ? "" : open ? t("Open") : st(s.state), tone: open ? "warn" : "", on: open, sub: relTime(s.last_changed), + // a round button with an arrow on a Small card, the word beside it in a Row + ctl: `` }; + }, + sports(c, { ha }) { + const s = ha.states[c.entity]; + if (!s) return null; + const a = s.attributes, state = String(s.state).toUpperCase(); + if (!a.opponent_name) return { ico: ico("trophy"), name: a.team_name || friendly(s), sub: t("No match planned.") }; + const home = a.team_homeaway === "away" ? "opponent" : "team", away = home === "team" ? "opponent" : "team"; + const ab = (p) => a[`${p}_abbr`] || String(a[`${p}_name`] || "").slice(0, 3).toUpperCase(); + const kick = a.date ? new Date(a.date) : null; + return { ico: ico("trophy"), name: `${ab(home)} – ${ab(away)}`, on: state === "IN", tone: state === "IN" ? "accent" : "", + value: state === "PRE" ? (kick ? kick.toLocaleTimeString(LOCALE, { hour: "2-digit", minute: "2-digit" }) : "–") : `${a[`${home}_score`] ?? 0}–${a[`${away}_score`] ?? 0}`, + sub: state === "PRE" ? (kick ? dayLabel(kick) : "") : state === "IN" ? a.clock || t("Live") : t("Full time"), tap: more(c.entity) }; + }, + f1(c, { ha }) { + const next = ha.states[c.next]; + if (!next) return null; + const a = next.attributes, iso = a.race_start || a.race_date || a.race_start_local, at = iso ? new Date(iso) : null; + let when = at ? dayLabel(at) : ""; + if (at && at > Date.now()) { const m = Math.round((at - Date.now()) / 60000), dd = Math.floor(m / 1440); when = dd ? t("in {0} d {1} h", dd, Math.floor((m % 1440) / 60)) : t("in {0} h {1} min", Math.floor(m / 60), m % 60); } + return { ico: ico("flag"), name: a.race_name || a.raceName || t("Next race"), word: when, sub: [a.circuit_locality || a.Circuit?.Location?.locality, a.circuit_country || a.Circuit?.Location?.country].filter(Boolean).join(" · "), tap: more(c.next) }; + }, + calendar(c, { ha, cache }) { + if (!c.entities?.length) return null; + const now = new Date(), events = []; + for (const eid of c.entities) for (const ev of cache.res?.[eid]?.events || []) { const s = parseWhen(ev.start), e = parseWhen(ev.end); if (e >= now) events.push({ ...ev, s, allDay: typeof ev.start === "string" ? !ev.start.includes("T") : !!ev.start?.date }); } + events.sort((x, y) => x.s - y.s); + const ev = events[0]; + // the next event as the name; its time today as the figure, else the day + const today = ev && dayLabel(ev.s) === t("Today"); + return { ico: ico("calendar"), name: ev ? ev.summary : c.title || t("Upcoming"), tap: more(c.entities[0]), + value: ev && today && !ev.allDay ? ev.s.toLocaleTimeString(LOCALE, { hour: "2-digit", minute: "2-digit" }) : null, + word: ev ? (today && !ev.allDay ? "" : dayLabel(ev.s)) : cache.res ? t("Nothing planned") : t("Loading…"), sub: c.title || "" }; + }, + waste(c, { ha }) { + const items = wasteItems(c, ha).filter((i) => i.days <= (Number(c.within) || 31)); + if (!items.length) return null; + const first = items.filter((i) => i.days === items[0].days); + return { ico: `${Icons.svg("bin")}`, name: c.title || t("Waste collection"), word: whenLabel(first[0].days), + sub: first.map((i) => i.name).join(" · "), tone: first[0].days <= 1 ? "warn" : "", tap: more(first[0].eid) }; + }, + }; + + /** Draw a summary as a Small, Medium or Large card, or a Row. */ + function phoneCard(sum, size) { + const tap = sum.tap ? `` : ""; + const fig = sum.value != null && sum.value !== "" + ? `${typeof sum.value === "string" && sum.value.startsWith("<") ? sum.value : esc(sum.value)}${sum.unit === "°" ? "°" : sum.unit ? `${esc(sum.unit)}` : ""}` + : sum.word ? `${esc(sum.word)}` : ""; + const meter = sum.meter != null ? `` : ""; + const art = sum.art ? `` : `${sum.ico || ""}`; + const cls = `phc phc-${size} ${sum.on ? "on" : ""} ${sum.tone ? `tone-${sum.tone}` : ""}`; + const style = sum.tint ? ` style="--phc-tint:${esc(sum.tint)}"` : ""; + if (size === "row") { + return `
${tap}${art}${esc(sum.name)}${sum.sub ? `${esc(sum.sub)}` : ""} + ${fig}${sum.ctl ? `${sum.ctl}` : ""}
`; + } + if (size === "m") { + return `
${tap}
${art}${esc(sum.name)}${sum.sub ? `${esc(sum.sub)}` : ""}${sum.ctl ? `${sum.ctl}` : ""}
+ ${fig || meter ? `
${fig}${meter}
` : ""}
`; + } + return `
${tap}
${art}${sum.ctl ? `${sum.ctl}` : ""}
+
${fig}${meter}${esc(sum.name)}${sum.sub ? `${esc(sum.sub)}` : ""}
`; + } + + // Which sizes each card has on a phone, and which it draws itself instead of as a summary. + const PHONE = { + heading: { sizes: ["row", "m"], row: "card", s: "card" }, + entity: { m: "sum", l: "sum" }, + button: { m: "sum", l: "sum" }, + timer: { m: "sum" }, + light: { s: "card" }, + text: { s: "card", row: "card" }, + image: { sizes: ["s", "m", "l"], s: "card" }, + iframe: { sizes: ["s", "m", "l"], s: "card" }, + camera: { s: "card" }, + hscroll: { sizes: ["m", "l"] }, + swipe: { sizes: ["s", "m", "l"], s: "card" }, + }; + for (const [k, def] of Object.entries(W)) def.phone = { sizes: ["s", "m", "l", "row"], ...(PHONE[k] || {}) }; + + // A light as one row: the fader the group card uses for each lamp, so you can still dim it. + function lightRow(c, ha) { + const s = ha.states[c.entity]; + if (!s) return null; + const name = friendly(s, c.name); + return `
${fader(c.entity, s, name, ` + ${esc(name)}${esc(bigText(s, true))}${sw(c.entity, isOn(s), name)}`, "lf-row")}
`; + } + + /** The phone version of a card at a size, or null to draw the card as it is. */ + W.__phoneRender = (type, c, ctx, size) => { + const def = W[type]; + if (!def) return null; + if ((size === "m" || size === "l") ? def.phone[size] !== "sum" : def.phone[size] === "card") return null; + if (type === "light" && size === "row") return lightRow(c, ctx.ha); + const sum = SUMMARY[type]?.(c, ctx); + return sum ? phoneCard(sum, size) : null; + }; + window.Widgets = W; function beep() { try {