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 <noreply@anthropic.com>
This commit is contained in:
2026-10-31 17:30:03 +01:00
parent 0eef8593c8
commit 3b2cbbc96c
7 changed files with 503 additions and 10 deletions
+43 -7
View File
@@ -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 = `<div class="empty">${tr("This widget couldn’t be drawn.")}</div>`; }
try { html = (size && Widgets.__phoneRender(w.type, w.cfg, ctx, size)) || def.render(w.cfg, ctx); } catch (e) { console.error(e); html = `<div class="empty">${tr("This widget couldn’t be drawn.")}</div>`; }
const hiddenNow = editMode && !child && !isVisible(w);
el.classList.toggle("cond-hidden", hiddenNow);
el.innerHTML = `<div class="w-body ${def.flush ? "flush" : ""}">${html}</div>` +
@@ -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) : ""}
<div class="section"><div class="section-title">${tr("Size and position")}</div>
<div class="row">${[["x", "Column"], ["y", "Row"], ["w", "Width"], ["h", "Height"]].map(([k, l]) => `<div class="field"><label for="pos-${k}">${tr(l)}</label><input class="input" id="pos-${k}" type="number" min="${k === "w" || k === "h" ? 1 : 0}" data-path="pos" data-k="${k}" data-num="1" value="${w[k]}"></div>`).join("")}</div></div>
${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") : ""}
<div class="row">${(current.mobile ? [["x", "Column"], ["y", "Row"]] : [["x", "Column"], ["y", "Row"], ["w", "Width"], ["h", "Height"]]).map(([k, l]) => `<div class="field"><label for="pos-${k}">${tr(l)}</label><input class="input" id="pos-${k}" type="number" min="${k === "w" || k === "h" ? 1 : 0}" data-path="pos" data-k="${k}" data-num="1" value="${w[k]}"></div>`).join("")}</div></div>
${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") : ""}
<div class="section"><div class="section-title">${tr("Style")}</div>${STYLE_FIELDS.map((f) => fieldHTML(f, w.style?.[f.k], "style")).join("")}</div>
${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);
+4 -1
View File
@@ -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",
+86
View File
@@ -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; }
+346
View File
@@ -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 = `<div class="empty">${t("Couldn’t load events. {0}", esc(cache.error))}</div>`; 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) => `<button class="light-switch wbtn" role="switch" aria-checked="${!!on}" data-act="toggle" data-e="${esc(eid)}" aria-label="${esc(label)}"><span class="switch"></span></button>`;
const rnd = (act, eid, icn, label, v, extra = "") => `<button class="phc-btn wbtn" data-act="${act}" ${eid ? `data-e="${esc(eid)}"` : ""} ${v != null ? `data-v="${esc(v)}"` : ""} ${extra} aria-label="${esc(label)}" title="${esc(label)}">${Icons.svg(icn)}</button>`;
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: `<span class="phc-clock" data-clock data-h24="${c.h24 ? 1 : 0}" data-sec="0" data-date="${c.date ? 1 : 0}">${clockHTML(c.h24, false, c.date)}</span>`,
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: `<span ${active && a.finishes_at ? `data-countdown="${esc(a.finishes_at)}" data-dur="${dur}"` : ""}>${hms(left)}</span>`,
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: `<button class="phc-btn wbtn" data-act="svc-many" data-v="cover.open_cover" data-ids="${all}" aria-label="${t("Open")}">${Icons.svg("arrow-up")}</button><button class="phc-btn wbtn" data-act="svc-many" data-v="cover.close_cover" data-ids="${all}" aria-label="${t("Close")}">${Icons.svg("arrow-down")}</button>` };
},
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 ? `<button class="light-switch wbtn" role="switch" aria-checked="${on.length > 0}" data-act="svc-many" data-v="light.${on.length ? "turn_off" : "turn_on"}" data-ids="${esc((on.length ? on : lights).join(","))}" aria-label="${esc(on.length ? t("Turn off all lights in {0}", name) : t("Turn on all lights in {0}", name))}"><span class="switch"></span></button>` : "" };
},
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: `<button class="phc-btn wide wbtn" data-act="gate" data-e="${esc(c.entity)}" data-v="${c.confirm === false ? "" : "1"}" aria-label="${esc(`${label}: ${name}`)}">${Icons.svg(d === "button" ? "play" : d === "lock" ? (open ? "lock" : "unlock") : open ? "arrow-down" : "arrow-up")}<span>${esc(label)}</span></button>` };
},
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: `<span class="phc-bin" style="color:${first[0].color}">${Icons.svg("bin")}</span>`, 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 ? `<button class="phc-tap wbtn" data-act="${esc(sum.tap.act)}" ${sum.tap.e ? `data-e="${esc(sum.tap.e)}"` : ""} ${sum.tap.v != null ? `data-v="${esc(sum.tap.v)}"` : ""} aria-label="${esc(sum.name)}"></button>` : "";
const fig = sum.value != null && sum.value !== ""
? `<span class="phc-val num">${typeof sum.value === "string" && sum.value.startsWith("<") ? sum.value : esc(sum.value)}${sum.unit === "°" ? "°" : sum.unit ? `<small>${esc(sum.unit)}</small>` : ""}</span>`
: sum.word ? `<span class="phc-val phc-word">${esc(sum.word)}</span>` : "";
const meter = sum.meter != null ? `<span class="phc-meter" aria-hidden="true"><i style="width:${Math.max(0, Math.min(100, sum.meter)).toFixed(0)}%"></i></span>` : "";
const art = sum.art ? `<img class="phc-art" src="${esc(sum.art)}" alt="">` : `<span class="phc-ico">${sum.ico || ""}</span>`;
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 `<div class="${cls}"${style}>${tap}${art}<span class="phc-text"><span class="phc-name">${esc(sum.name)}</span>${sum.sub ? `<span class="phc-sub">${esc(sum.sub)}</span>` : ""}</span>
${fig}${sum.ctl ? `<span class="phc-ctl">${sum.ctl}</span>` : ""}</div>`;
}
if (size === "m") {
return `<div class="${cls}"${style}>${tap}<div class="phc-top">${art}<span class="phc-text"><span class="phc-name">${esc(sum.name)}</span>${sum.sub ? `<span class="phc-sub">${esc(sum.sub)}</span>` : ""}</span>${sum.ctl ? `<span class="phc-ctl">${sum.ctl}</span>` : ""}</div>
${fig || meter ? `<div class="phc-bottom">${fig}${meter}</div>` : ""}</div>`;
}
return `<div class="${cls}"${style}>${tap}<div class="phc-top">${art}${sum.ctl ? `<span class="phc-ctl">${sum.ctl}</span>` : ""}</div>
<div class="phc-bottom">${fig}${meter}<span class="phc-name">${esc(sum.name)}</span>${sum.sub ? `<span class="phc-sub">${esc(sum.sub)}</span>` : ""}</div></div>`;
}
// 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 `<div class="lt phc-light ${isOn(s) ? "on" : ""}">${fader(c.entity, s, name, `<button class="lt-ico wbtn" data-act="light-popup" data-e="${esc(c.entity)}" aria-label="${esc(t("Settings for {0}", name))}">${icon(s)}</button>
<span class="lf-name">${esc(name)}</span><span class="lf-pct num">${esc(bigText(s, true))}</span>${sw(c.entity, isOn(s), name)}`, "lf-row")}</div>`;
}
/** 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 {