Dashboard Studio 2.1.0: every card redesigned for desktop, tablet and phone

- One card system in three device bands, applied automatically: compact with
  hover on desktop; larger type and 44px controls on tablets and wall panels;
  touch sizes in a tighter frame on phones; narrow-card layouts by container
- Answers as the largest thing, in regular-weight tabular figures; plain
  outline icons instead of tinted chips; pale status pills; flat buttons and
  sliders without shadows or gradients
- Energy: power flow (sun, house, battery, grid and the flows between them)
- Garage: state in large type with one wide button; vacuum: state and a
  battery bar
- The first heading on a tab is the page's h1

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-26 23:56:31 +01:00
parent 3d958d0411
commit f4c98e27af
7 changed files with 484 additions and 56 deletions
+31 -39
View File
@@ -109,7 +109,7 @@
{ k: "align", t: "seg", l: "Alignment", o: ["left", "center", "right"], d: "left" },
],
render: (c) => `<div class="heading" style="text-align:${esc(c.align)}">
<h2 class="h-main" style="font-size:${Number(c.size) || 30}px">${esc(c.text)}</h2>
<h2 class="h-main" style="--h-size:${Number(c.size) || 30}px;font-size:${Number(c.size) || 30}px">${esc(c.text)}</h2>
${c.subtitle ? `<div class="wt wrap">${esc(c.subtitle)}</div>` : ""}</div>`,
};
@@ -800,33 +800,30 @@
const has = { solar: !!c.solar, grid: !!c.grid, battery: !!c.battery };
const lvl = Number(S("batteryLevel")?.state);
const flow = (on, d, w, cls) => {
if (!on) return "";
const speed = Math.max(0.5, Math.min(5, 2400 / (w + 150))); // seconds per cycle: more power, faster dots
const width = w > 1 ? Math.max(1.5, Math.min(5, 1.2 + Math.log10(w + 1))) : 1;
return `<path d="${d}" class="ef-line ${cls}"/>${w > 1 ? `<path d="${d}" class="ef-flow ${cls}" style="stroke-width:${width.toFixed(1)};animation-duration:${speed.toFixed(2)}s"/>` : ""}`;
};
const lines = [
flow(has.solar, "M50 14 Q86 14 86 50", sToH, "solar"),
flow(has.solar && has.grid, "M50 14 Q14 14 14 50", sToG, "solar"),
flow(has.solar && has.battery, "M50 14 L50 86", sToB, "solar"),
flow(has.grid, "M14 50 L86 50", gToH, "grid"),
flow(has.grid && has.battery, "M14 50 Q14 86 50 86", gToB, "grid"),
flow(has.battery, "M50 86 Q86 86 86 50", bToH, "battery"),
].join("");
const node = (key, ico, label, value, extra = "") => `<div class="ef-node ef-${key}"><span class="ef-ico">${Icons.svg(ico)}</span>
<span class="ef-val num">${esc(value)}</span><span class="ef-label">${esc(label)}${extra}</span></div>`;
const totals = (c.today || []).map((e) => ha.states[e] && `<span class="ef-total"><b class="num">${esc(fmtState(ha.states[e]))}</b> ${esc(friendly(ha.states[e]))}</span>`).filter(Boolean).join("");
// Where the power is going now: the four places in one row, then each flow that runs, largest first.
// They balance: sun + grid in + battery out = house + battery in + grid out.
const fig = (w) => { const [n, u] = fmtW(w).split(" "); return `<span class="pf-fig num">${esc(n)}<small>${esc(u || "")}</small></span>`; };
const node = (key, ico, label, figure, sub) => `<div class="pf-node pf-${key}"><span class="pf-lbl">${Icons.svg(ico)}${esc(label)}</span>${figure}<span class="pf-sub">${esc(sub)}</span></div>`;
const nodes = [
has.solar ? node("sun", "sun", t("Sun"), fig(solar), solar > 1 ? t("Producing") : t("Not producing")) : "",
node("home", "home", t("House"), fig(home), t("In use")),
has.battery ? node("battery", "battery", t("Battery"), isFinite(lvl) ? `<span class="pf-fig num">${Math.round(lvl)}<small>%</small></span>` : fig(Math.abs(batt)),
charge > 1 ? t("Charging {0}", fmtW(charge)) : discharge > 1 ? t("Supplying {0}", fmtW(discharge)) : t("Idle")) : "",
has.grid ? node("grid", "plug", t("Grid"), fig(exp > imp ? exp : imp), exp > imp ? t("Sending back") : imp > 1 ? t("Drawing") : t("Idle")) : "",
].filter(Boolean);
const flows = [
["sun", t("Sun to the house"), sToH], ["sun", t("Sun to the battery"), sToB], ["sun", t("Sun to the grid"), sToG],
["grid", t("Grid to the house"), gToH], ["grid", t("Grid to the battery"), gToB], ["battery", t("Battery to the house"), bToH],
].filter(([, , w]) => w > 1).sort((a, b) => b[2] - a[2]);
const top = Math.max(1, ...flows.map(([, , w]) => w));
const totals = (c.today || []).map((e) => ha.states[e] && `<span class="pf-total"><b class="num">${esc(fmtState(ha.states[e]))}</b> ${esc(friendly(ha.states[e]))}</span>`).filter(Boolean).join("");
return `<div class="energy ${has.battery ? "" : "no-battery"} ${has.solar ? "" : "no-solar"}">
<div class="ef-diagram" role="img" aria-label="${esc(t("Solar {0}, grid {1}, home {2}", fmtW(solar), grid >= 0 ? fmtW(imp) : "−" + fmtW(exp), fmtW(home)))}">
<svg viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">${lines}</svg>
${has.solar ? node("solar", "sun", t("Solar"), fmtW(solar)) : ""}
${has.grid ? node("grid", "plug", exp > imp ? t("To the grid") : t("From the grid"), fmtW(exp > imp ? exp : imp)) : ""}
${node("home", "home", t("Home"), fmtW(home))}
${has.battery ? node("battery", "battery", charge > 1 ? t("Charging") : discharge > 1 ? t("Discharging") : t("Battery"), isFinite(lvl) ? `${Math.round(lvl)}%` : fmtW(Math.abs(batt)), isFinite(lvl) && Math.abs(batt) > 1 ? ` · ${fmtW(Math.abs(batt))}` : "") : ""}
</div>
${totals ? `<div class="ef-totals">${totals}</div>` : ""}</div>`;
return `<div class="pf">
<div class="pf-head"><span class="wt">${t("Energy now")}</span></div>
<div class="pf-nodes n${nodes.length}">${nodes.join("")}</div>
<div class="pf-flows">${flows.length ? flows.map(([k, label, w]) => `<div class="pf-flow pf-${k}"><span class="pf-flow-lbl">${esc(label)}</span><span class="pf-flow-v num">${esc(fmtW(w))}</span><i style="width:${((w / top) * 100).toFixed(1)}%"></i></div>`).join("")
: `<div class="pf-none">${t("No power is flowing right now.")}</div>`}</div>
${totals ? `<div class="pf-totals">${totals}</div>` : ""}</div>`;
},
};
@@ -927,11 +924,6 @@
const mapUrl = c.map ? (domainOf(c.map) === "image" ? ha.states[c.map]?.attributes.entity_picture : `api/camera/${encodeURIComponent(c.map)}?t=${Date.now()}`) : "";
const speeds = c.fan !== false && Array.isArray(a.fan_speed_list) ? a.fan_speed_list : [];
// battery ring around the robot
const R = 46, C = 2 * Math.PI * R, frac = hasBatt ? Math.max(0, Math.min(1, batt / 100)) : 0;
const ring = `<svg class="vac-ring" viewBox="0 0 100 100" aria-hidden="true"><circle cx="50" cy="50" r="${R}" class="track"/>
${hasBatt ? `<circle cx="50" cy="50" r="${R}" class="fill ${batt <= 20 ? "low" : ""}" stroke-dasharray="${(C * frac).toFixed(1)} ${C.toFixed(1)}"/>` : ""}</svg>`;
const stat = (label, value, ico) => `<div class="vac-stat">${Icons.svg(ico)}<span class="vac-stat-v num">${esc(value)}</span><span class="vac-stat-l">${esc(label)}</span></div>`;
const stats = [
hasBatt ? stat(t("Battery"), `${Math.round(batt)}%`, "battery") : "",
@@ -948,9 +940,9 @@
${hasBatt ? `<span class="vac-batt">${Icons.svg(charging ? "bolt" : "battery")}<span class="num">${Math.round(batt)}%</span></span>` : ""}</div>
<div class="vac-hero">
${mapUrl ? `<img class="vac-map" src="${esc(mapUrl)}" ${domainOf(c.map) === "camera" ? `data-cam="${esc(c.map)}" data-refresh="${Number(c.refresh) || 5}"` : ""} alt="${t("Cleaning map")}">`
: `<div class="vac-robot ${cleaning ? "is-cleaning" : ""}">${ring}<span class="vac-pulse" aria-hidden="true"></span><span class="vac-robot-ico">${Icons.svg("vacuum")}</span>
${charging ? `<span class="vac-charge" aria-hidden="true">${Icons.svg("bolt")}</span>` : ""}</div>
<div class="vac-hero-text"><span class="vac-headline">${esc(headline)}</span>${hasBatt ? `<span class="wt">${t("Battery {0}%", Math.round(batt))}</span>` : ""}</div>`}
: `<div class="vac-hero-text"><span class="vac-headline">${esc(headline)}</span>
${hasBatt ? `<span class="vac-meter ${batt < 20 ? "low" : ""}" role="meter" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${Math.round(batt)}" aria-label="${t("Battery")}"><i style="width:${Math.round(batt)}%"></i></span>
<span class="wt num">${charging ? t("Battery {0}%, charging", Math.round(batt)) : t("Battery {0}%", Math.round(batt))}</span>` : ""}</div>`}
</div>
${stats ? `<div class="vac-stats">${stats}</div>` : ""}
${speeds.length ? `<div class="seg vac-fan" role="group" aria-label="${t("Suction power")}">${speeds.map((f) => `<button class="wbtn" aria-pressed="${f === a.fan_speed}" data-act="vac-fan" data-e="${e}" data-v="${esc(f)}">${esc(cap(f))}</button>`).join("")}</div>` : ""}
@@ -1512,11 +1504,11 @@
const by = changedBy(s, ha);
const since = `${open ? t("Open") : st(st0)} · ${relTime(s.last_changed)}${by ? ` · ${t("by {0}", by)}` : ""}`;
const ico = { garage: "garage", gate: "gate", door: "door" }[c.kind] || "garage";
const when = `${relTime(s.last_changed)}${by ? ` · ${t("by {0}", by)}` : ""}`;
return `<div class="gt ${open ? "open" : ""} ${moving ? "moving" : ""}">
<div class="gt-head"><span class="name">${esc(name)}</span><span class="gt-state">${esc(open ? t("Open") : d === "button" ? "" : st(st0))}</span></div>
<button class="gt-btn wbtn" data-act="gate" data-e="${esc(c.entity)}" data-v="${c.confirm === false ? "" : "1"}" aria-label="${esc(`${label}: ${name}`)}">
<span class="gt-ring">${Icons.svg(open && c.kind === "door" ? "door-open" : ico)}</span><span class="gt-lbl">${esc(label)}</span></button>
<div class="gt-since">${esc(since)}</div></div>`;
<div class="gt-head"><span class="gt-ico">${Icons.svg(open && c.kind === "door" ? "door-open" : ico)}</span><span class="name">${esc(name)}</span></div>
<div class="gt-now"><span class="gt-big">${esc(d === "button" ? name : open ? t("Open") : st(st0))}</span><span class="gt-since" title="${esc(since)}">${esc(when)}</span></div>
<button class="gt-btn wbtn" data-act="gate" data-e="${esc(c.entity)}" data-v="${c.confirm === false ? "" : "1"}" aria-label="${esc(`${label}: ${name}`)}"><span class="gt-lbl">${esc(label)}</span></button></div>`;
},
};