Dashboard Studio 1.12.1: car card redesigned from scratch

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-26 15:46:25 +01:00
parent 7abf7379d8
commit 2ea437af31
6 changed files with 164 additions and 104 deletions
+5
View File
@@ -380,6 +380,11 @@
"Each save is kept here, up to 30 versions. Changes within five minutes count as one.": "Elke opslag wordt hier bewaard, tot 30 versies. Wijzigingen binnen vijf minuten tellen als één.",
"No earlier versions yet. They appear after your next changes.": "Nog geen eerdere versies. Ze verschijnen na je volgende wijzigingen.",
"Restored the version from {0}. Press ⌘Z to undo.": "De versie van {0} is teruggezet. Druk op ⌘Z om ongedaan te maken.",
"Charges to {0}%": "Laadt tot {0}%",
"Low battery": "Accu bijna leeg",
"Low fuel": "Bijna leeg",
"Charge": "Laden",
"Inside": "Binnen",
"Today": "Vandaag", "Tomorrow": "Morgen", "All day": "Hele dag", "Busy": "Bezet",
};
+80 -55
View File
@@ -974,63 +974,88 @@ body.kiosk #tabnav { top: 6px; max-height: calc(100vh - 20px); }
/* pill button with icon and label (media "Turn on") */
.car-act { display: inline-flex; align-items: center; gap: 8px; min-height: 44px; padding: 0 14px; border-radius: 12px; background: var(--d-fill); font-size: 14px; font-weight: 500; }
.car-act .i { font-size: 18px; }
/* car: a round gauge with the range in the middle and the car in front */
.car3 { --car-top: color-mix(in srgb, var(--d-accent) 55%, var(--d-text)); --car-bottom: color-mix(in srgb, var(--d-accent) 70%, #000);
display: grid; grid-template-areas: "top" "gauge" "info"; grid-template-rows: auto minmax(0, 1fr) auto; gap: 8px; height: 100%; min-height: 0; }
.car3-top { grid-area: top; display: flex; align-items: center; gap: 8px; min-width: 0; }
.car3-top .name { font-size: 17px; flex: 1; min-width: 0; }
.car3-status { font-size: 12px; font-weight: 600; padding: 3px 9px; border-radius: 99px; background: var(--d-fill); color: var(--d-text-2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 62%; }
.car3-status.charging { color: var(--ok); background: color-mix(in srgb, var(--ok) 16%, transparent); }
.cg { grid-area: gauge; position: relative; min-height: 0; height: 100%; max-height: 260px; max-width: 100%; justify-self: center; align-self: center; aspect-ratio: 1 / .92; }
.cg > svg { position: absolute; inset: 0; width: 100%; height: 100%; }
.cg > svg path { fill: none; stroke-width: 12; stroke-linecap: round; }
.cg-track { stroke: var(--d-fill); }
.cg-fill { stroke: var(--d-text); }
.cg.charging .cg-fill { stroke: var(--ok); animation: cg-pulse 2.4s ease-in-out infinite; }
.cg.low .cg-fill { stroke: var(--crit); }
@keyframes cg-pulse { 50% { opacity: .65; } }
.cg-limit { stroke: var(--d-text-2); stroke-width: 3; stroke-linecap: round; }
.cg-center { position: absolute; left: 0; right: 0; top: 22%; display: flex; flex-direction: column; align-items: center; }
.cg-range { font-size: clamp(26px, 11cqh, 44px); font-weight: 700; letter-spacing: -0.02em; line-height: 1; }
.cg-range small { font-size: .42em; font-weight: 600; color: var(--d-text-2); margin-left: 3px; letter-spacing: 0; }
.cg-pct { margin-top: 4px; font-size: 14px; font-weight: 600; color: var(--d-text-2); }
.cg.charging .cg-pct { color: var(--ok); }
.cg-car { position: absolute; left: 6%; right: 6%; bottom: 2%; display: flex; justify-content: center; }
.cg-car img { max-width: 100%; max-height: 72px; object-fit: contain; filter: drop-shadow(0 6px 10px rgba(0, 0, 0, .3)); }
.car-art2 { width: 100%; max-height: 74px; }
.car-shadow { fill: rgba(0, 0, 0, .28); }
.car-body2 { fill: url(#carBody); }
.car-glass2 { fill: url(#carGlass); }
.car-line { stroke: rgba(255, 255, 255, .25); stroke-width: 1; }
.car-light { fill: #fff6d6; }
.car-tire { fill: #17181b; } .car-rim { fill: #8e9097; }
.car3.is-charging .cg-car::after { content: ""; position: absolute; left: 20%; right: 20%; bottom: 0; height: 40%; border-radius: 50%; background: radial-gradient(closest-side, color-mix(in srgb, var(--ok) 45%, transparent), transparent); z-index: -1; }
.car3-info { grid-area: info; display: flex; flex-direction: column; gap: 8px; min-width: 0; }
.cchips { display: flex; gap: 6px; flex-wrap: wrap; justify-content: center; }
.cchip { display: inline-flex; align-items: center; gap: 5px; font-size: 12px; font-weight: 600; padding: 3px 9px; border-radius: 99px; background: var(--d-fill); white-space: nowrap; }
.cchip .i { font-size: 14px; color: var(--d-text-2); }
.cbs { display: flex; gap: 6px; }
.cb { flex: 1; min-width: 0; display: flex; flex-direction: column; align-items: center; gap: 3px; padding: 8px 4px; border-radius: 14px; background: var(--d-fill); }
.cb-ico { width: 32px; height: 32px; border-radius: 50%; display: grid; place-items: center; font-size: 18px; transition: background-color .2s; }
.cb[aria-pressed="true"] .cb-ico { background: var(--d-text); color: var(--d-tile); }
.cb-label { font-size: 12px; font-weight: 600; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cb-sub { font-size: 11px; color: var(--d-text-2); white-space: nowrap; }
/* short: gauge small on the left, numbers and buttons beside it */
@container (max-height: 250px) {
.car3 { grid-template-areas: "top top" "gauge info"; grid-template-columns: auto 1fr; grid-template-rows: auto 1fr; column-gap: 14px; }
.cg { width: auto; height: 100%; max-width: none; aspect-ratio: 1; }
.cg-car { display: none; }
.cg-center { top: 30%; }
.car3-info { justify-content: center; }
.cchips { justify-content: flex-start; }
/* car: the car itself fills up with the charge level */
.cv { --cv-c: var(--d-accent); display: grid; grid-template-areas: "head" "hero" "art" "stats" "ctrls"; grid-template-rows: auto auto minmax(0, 1fr) auto auto; row-gap: 10px; height: 100%; min-height: 0; min-width: 0; }
.cv.charging { --cv-c: var(--ok); }
.cv.low { --cv-c: var(--crit); }
.cv.fuel:not(.low) { --cv-c: var(--warn); }
.cv-head { grid-area: head; display: flex; align-items: center; gap: 8px; min-width: 0; }
.cv-name { flex: 1; min-width: 0; font-size: 15px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cv-status { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; font-weight: 600; color: var(--d-text-2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 64%; }
.cv-status i { width: 7px; height: 7px; border-radius: 50%; background: currentColor; flex: none; }
.cv-status.ok { color: var(--ok); } .cv-status.crit { color: var(--crit); } .cv-status.accent { color: var(--d-accent); }
.cv-status.ok i { animation: cv-blink 1.6s ease-in-out infinite; }
@keyframes cv-blink { 50% { opacity: .25; } }
.cv-hero { grid-area: hero; display: flex; align-items: flex-end; gap: 14px; min-width: 0; }
.cv-big { font-size: clamp(40px, 19cqw, 68px); font-weight: 700; letter-spacing: -0.045em; line-height: .86; }
.cv-big span { font-size: .42em; font-weight: 600; letter-spacing: 0; color: var(--d-text-2); margin-left: 2px; }
.cv-side { min-width: 0; padding-bottom: 2px; }
.cv-range { font-size: clamp(17px, 7cqw, 24px); font-weight: 650; letter-spacing: -0.02em; line-height: 1.05; white-space: nowrap; }
.cv-range small { font-size: .62em; font-weight: 600; color: var(--d-text-2); margin-left: 3px; letter-spacing: 0; }
.cv-sub { margin-top: 3px; font-size: 12px; font-weight: 500; color: var(--d-text-2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.cv.charging .cv-sub { color: var(--ok); }
.cv-art { grid-area: art; min-height: 0; display: flex; flex-direction: column; justify-content: center; gap: 10px; }
.cv-svg { display: block; width: 100%; height: 100%; max-height: 150px; min-height: 0; color: var(--cv-c); overflow: visible; }
.cv-shadow { fill: var(--d-text); opacity: .12; }
.cv.charging .cv-shadow { fill: var(--ok); opacity: .35; }
.cv-shell { fill: color-mix(in srgb, var(--d-text) 9%, transparent); }
.cv-edge { fill: none; stroke: color-mix(in srgb, var(--d-text) 28%, transparent); stroke-width: 1.2; stroke-linejoin: round; }
.cv-glass { fill: color-mix(in srgb, var(--d-tile) 62%, transparent); stroke: color-mix(in srgb, var(--d-text) 22%, transparent); stroke-width: 1; stroke-linejoin: round; }
.cv-crease { stroke: color-mix(in srgb, var(--d-text) 14%, transparent); stroke-width: 1; fill: none; }
.cv-lamp { stroke: color-mix(in srgb, var(--d-text) 55%, transparent); stroke-width: 2.4; stroke-linecap: round; }
.cv-tail { stroke: var(--crit); stroke-width: 2.4; stroke-linecap: round; opacity: .8; }
.cv-limit { stroke: var(--d-text); stroke-opacity: .55; stroke-width: 1.6; stroke-dasharray: 3 2.5; }
.cv-port { fill: var(--cv-c); stroke: var(--d-tile); stroke-width: 1.5; }
.cv.charging .cv-port { animation: cv-blink 1.6s ease-in-out infinite; }
.cv-tyre { fill: color-mix(in srgb, var(--d-text) 82%, var(--d-tile)); }
.cv-rim { fill: color-mix(in srgb, var(--d-text) 30%, var(--d-tile)); }
.cv-hub { fill: color-mix(in srgb, var(--d-text) 70%, var(--d-tile)); }
.cv-sweep { fill: #fff; opacity: .35; animation: cv-sweep 2.2s ease-in-out infinite; }
@keyframes cv-sweep { from { transform: translateX(0); } to { transform: translateX(310px); } }
.cv-img { min-height: 0; flex: 1; display: flex; align-items: center; justify-content: center; }
.cv-img img { max-width: 100%; max-height: 100%; object-fit: contain; filter: drop-shadow(0 8px 12px rgba(0, 0, 0, .25)); }
/* the slim battery bar: shown with a photo, and instead of the drawing on short cards */
.cv-bar { position: relative; height: 8px; border-radius: 99px; background: color-mix(in srgb, var(--d-text) 10%, transparent); display: none; flex: none; }
.cv-bar i { position: absolute; inset: 0 auto 0 0; border-radius: inherit; background: var(--cv-c); }
.cv-bar b { position: absolute; top: -3px; bottom: -3px; width: 2px; margin-left: -1px; border-radius: 1px; background: var(--d-text-2); }
.cv.has-img .cv-bar { display: block; }
.cv-stats { grid-area: stats; display: none; grid-template-columns: repeat(auto-fit, minmax(96px, 1fr)); gap: 6px; }
.cv-stat { display: flex; flex-direction: column; gap: 2px; padding: 8px 10px; border-radius: 12px; background: var(--d-fill); min-width: 0; }
.cv-stat span { font-size: 11px; color: var(--d-text-2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.cv-stat b { font-size: 14px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.cv-ctrls { grid-area: ctrls; display: grid; grid-auto-flow: column; grid-auto-columns: minmax(0, 1fr); gap: 6px; }
.cv-ctrl { display: flex; flex-direction: column; align-items: center; gap: 5px; min-width: 0; padding: 4px 2px; border-radius: 12px; }
.cv-ico { width: 40px; height: 40px; border-radius: 50%; display: grid; place-items: center; font-size: 19px; background: var(--d-fill); transition: background-color .2s, color .2s; }
.cv-ctrl.on .cv-ico { background: var(--cv-c); color: #fff; }
.cv-ctrl.warn .cv-ico { background: color-mix(in srgb, var(--warn) 22%, transparent); color: var(--warn); }
.cv-lbl { font-size: 11px; font-weight: 600; color: var(--d-text-2); max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
@media (prefers-reduced-motion: reduce) { .cv-sweep { display: none; } .cv-status.ok i, .cv.charging .cv-port { animation: none; } }
/* tall: room for a row of stats */
@container (min-height: 420px) { .cv-stats { display: grid; } }
/* short: the drawing gives way to a slim bar */
@container (max-height: 260px) {
.cv { row-gap: 8px; grid-template-rows: auto auto auto minmax(0, 1fr) auto; }
.cv-svg, .cv-img { display: none; }
.cv-bar { display: block; }
.cv-ctrls { align-self: end; }
.cv-ico { width: 34px; height: 34px; font-size: 17px; }
}
@container (max-height: 160px) { .cbs { display: none; } }
/* wide: gauge on the left, the rest on the right */
@container (min-width: 480px) and (min-height: 251px) {
.car3 { grid-template-areas: "top top" "gauge info"; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); grid-template-rows: auto 1fr; column-gap: 20px; }
.car3-info { justify-content: center; }
@container (max-height: 160px) { .cv-ctrls { display: none; } .cv-big { font-size: 40px; } }
/* wide: the car on the left, numbers and controls on the right */
@container (min-width: 460px) and (min-height: 261px) {
.cv { grid-template-areas: "art head" "art hero" "art stats" "art ctrls"; grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr); grid-template-rows: auto auto 1fr auto; column-gap: 22px; }
.cv-hero { align-self: start; }
.cv-svg { max-height: 190px; }
.cv-stats { display: grid; align-self: center; }
}
@container (max-width: 300px) { .cb-label, .cb-sub { display: none; } .cb { padding: 6px 2px; } }
@container (max-width: 230px) {
.cv-lbl { display: none; }
.cv-hero { flex-direction: column; align-items: flex-start; gap: 6px; }
.cv-ctrls { gap: 4px; }
.cv-ico { width: 34px; height: 34px; font-size: 16px; }
.cv-status { max-width: 58%; }
}
@container (max-width: 170px) { .cv-ico { width: 28px; height: 28px; font-size: 14px; } .cv-status { font-size: 0; gap: 0; } .cv-status i { width: 8px; height: 8px; } }
/* ============================================================ phone dashboards */
body.phone-ui:not(.peek) #topbar { display: none; }
+73 -47
View File
@@ -1134,17 +1134,32 @@
// ---------------------------------------------------------------- car
const truthy = (s) => !!s && (["on", "true", "charging", "plugged_in", "connected"].includes(String(s.state).toLowerCase()));
// Built around a round gauge, like an instrument cluster: charge as an arc with the charge limit marked,
// the range large in the middle, and the car in front. Sizes adapt through container queries.
const CAR_ART = `<svg class="car-art2" viewBox="0 0 200 78" aria-hidden="true">
<defs><linearGradient id="carBody" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="var(--car-top)"/><stop offset="1" stop-color="var(--car-bottom)"/></linearGradient>
<linearGradient id="carGlass" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#cfe3ff" stop-opacity=".55"/><stop offset="1" stop-color="#7a8cab" stop-opacity=".35"/></linearGradient></defs>
<ellipse cx="100" cy="70" rx="86" ry="5" class="car-shadow"/>
<path class="car-body2" d="M14 56c0-6 4-9 12-11l30-6c10-10 22-15 40-16h24c14 0 26 5 36 13l14 5c12 2 18 6 19 12v5c0 3-2 4-5 4H16c-1.5 0-2-2-2-6z"/>
<path class="car-glass2" d="M64 39c8-8 18-12 32-12h12v12zM112 27h8c11 0 20 4 28 11l-36 1z"/>
<path class="car-line" d="M30 50h150"/><circle cx="182" cy="52" r="2.2" class="car-light"/>
<circle cx="52" cy="62" r="12" class="car-tire"/><circle cx="52" cy="62" r="6" class="car-rim"/>
<circle cx="154" cy="62" r="12" class="car-tire"/><circle cx="154" cy="62" r="6" class="car-rim"/></svg>`;
// The car is the battery: a side view that fills up from the back with the charge level, the charge
// limit marked in it, and the numbers above it. Sizes adapt through container queries.
const CAR_BODY = "M16 68H40A18 18 0 0 1 76 68H166A18 18 0 0 1 202 68H224C230 68 233 65 233 60L232 54C231 49 226 46 218 45L172 40C160 32 148 24 134 21C118 18 98 18 84 20C66 23 46 32 30 40L16 43C10 44 7 48 7 54V61C7 65 10 68 16 68Z";
const CAR_GLASS = "M164 40C154 32 144 26 132 24C120 22 104 22 92 23L94 40ZM88 23C74 25 60 31 48 40H90Z";
const CAR_X0 = 7, CAR_W = 226; // where the body starts and how long it is, for the fill
function carArt(id, pct, limPct, o) {
const fx = CAR_X0 + CAR_W * Math.max(0, Math.min(100, pct ?? 0)) / 100;
const lx = limPct != null ? CAR_X0 + CAR_W * Math.max(0, Math.min(100, limPct)) / 100 : null;
const wheel = (cx) => `<circle cx="${cx}" cy="70" r="15" class="cv-tyre"/><circle cx="${cx}" cy="70" r="9.5" class="cv-rim"/><circle cx="${cx}" cy="70" r="3" class="cv-hub"/>`;
return `<svg class="cv-svg" viewBox="0 0 240 92" aria-hidden="true">
<defs><clipPath id="cvb-${id}"><path d="${CAR_BODY}"/></clipPath><clipPath id="cvf-${id}"><rect x="0" y="0" width="${fx.toFixed(1)}" height="92"/></clipPath>
<linearGradient id="cvg-${id}" x1="0" x2="1"><stop offset="0" stop-color="currentColor" stop-opacity=".55"/><stop offset="1" stop-color="currentColor"/></linearGradient></defs>
<ellipse cx="121" cy="86" rx="110" ry="3.5" class="cv-shadow"/>
<path d="${CAR_BODY}" class="cv-shell"/>
<g clip-path="url(#cvb-${id})">
<g clip-path="url(#cvf-${id})"><rect x="0" y="0" width="240" height="92" fill="url(#cvg-${id})"/>${o.charging ? `<rect class="cv-sweep" x="-70" y="0" width="70" height="92"/>` : ""}</g>
${lx != null ? `<line x1="${lx.toFixed(1)}" y1="0" x2="${lx.toFixed(1)}" y2="92" class="cv-limit"/>` : ""}
</g>
<path d="${CAR_GLASS}" class="cv-glass"/>
<path d="${CAR_BODY}" class="cv-edge"/>
<path d="M34 52L212 49" class="cv-crease"/>
<path d="M220 49.5L230 53" class="cv-lamp"/><path d="M8.5 49L17 47" class="cv-tail"/>
${o.plugged ? `<circle cx="24" cy="51" r="3.4" class="cv-port"/>` : ""}
${wheel(58)}${wheel(184)}</svg>`;
}
W.car = {
label: "Car", icon: "car", size: [3, 4],
@@ -1166,61 +1181,72 @@
{ k: "odometer", t: "entity", l: "Odometer", domains: ["sensor"] },
],
entities: (c) => ["level", "range", "charging", "power", "plug", "limit", "toFull", "chargeSwitch", "lock", "climate", "location", "odometer"].map((k) => c[k]).filter(Boolean),
render(c, { ha }) {
render(c, { ha, w }) {
const S = (k) => (c[k] ? ha.states[c[k]] : null);
const level = S("level"), range = S("range"), lock = S("lock"), clim = S("climate"), loc = S("location");
if (!c.level && !c.range && !c.lock) return `<div class="empty">${t("Set the car’s level, range or lock entity in the inspector.")}</div>`;
const pct = level && isFinite(Number(level.state)) ? Math.round(Number(level.state)) : null;
const power = S("power"), plug = S("plug"), limit = S("limit"), odo = S("odometer"), toFull = S("toFull"), chg = S("chargeSwitch");
const charging = truthy(S("charging")) || (!c.charging && power && Number(power.state) > 0) || (chg && domainOf(c.chargeSwitch) === "switch" && chg.state === "on");
const plugged = charging || truthy(plug);
const fuel = c.kind === "fuel", low = pct != null && pct <= 20;
const limPct = limit && isFinite(Number(limit.state)) ? Number(limit.state) : null;
const fuel = c.kind === "fuel";
const charging = !fuel && (truthy(S("charging")) || (!c.charging && power && Number(power.state) > 0) || (chg && domainOf(c.chargeSwitch) === "switch" && chg.state === "on"));
const plugged = !fuel && (charging || truthy(plug));
const low = pct != null && pct <= 20;
const limPct = !fuel && limit && isFinite(Number(limit.state)) ? Number(limit.state) : null;
const unit = range ? range.attributes.unit_of_measurement || "km" : "";
// status line and the "full at" time
let status = "";
if (charging) status = `${t("Charging")}${power && Number(power.state) > 0 ? " · " + fmtState(power) : ""}`;
else if (plugged) status = t("Plugged in");
// status, top right
let status = "", tone = "";
if (charging) { status = t("Charging"); tone = "ok"; }
else if (plugged) { status = t("Plugged in"); tone = "accent"; }
else if (low) { status = fuel ? t("Low fuel") : t("Low battery"); tone = "crit"; }
else if (loc) status = loc.state === "home" ? t("Parked at home") : loc.state === "not_home" ? t("Away") : st(loc.state);
let fullAt = "";
// the line under the range: when it will be full, or how far it charges
let sub = "";
if (charging && toFull) {
const v = toFull.state, mins = /^\d{4}-\d{2}-\d{2}T/.test(v) ? (new Date(v) - Date.now()) / 60000 : Number(v) * (String(toFull.attributes.unit_of_measurement || "min").toLowerCase().startsWith("h") ? 60 : 1);
if (isFinite(mins) && mins > 0) fullAt = t("Full at {0}", new Date(Date.now() + mins * 60000).toLocaleTimeString(LOCALE, { hour: "2-digit", minute: "2-digit" }));
if (isFinite(mins) && mins > 0) sub = t("Full at {0}", new Date(Date.now() + mins * 60000).toLocaleTimeString(LOCALE, { hour: "2-digit", minute: "2-digit" }));
}
if (!sub && charging && power && Number(power.state) > 0) sub = fmtState(power);
if (!sub && limPct != null) sub = t("Charges to {0}%", Math.round(limPct));
if (!sub) sub = range ? t("Range") : "";
// the gauge: 240° arc, level filled, charge limit as a tick
const A0 = Math.PI * (150 / 180), SPAN = Math.PI * (240 / 180), R = 80;
const ang = (p) => A0 + SPAN * Math.max(0, Math.min(1, p / 100));
const pt = (p, r = R) => [100 + r * Math.cos(ang(p)), 100 + r * Math.sin(ang(p))];
const arc = (p0, p1) => { const [x0, y0] = pt(p0), [x1, y1] = pt(p1); return `M${x0.toFixed(1)} ${y0.toFixed(1)} A${R} ${R} 0 ${ang(p1) - ang(p0) > Math.PI ? 1 : 0} 1 ${x1.toFixed(1)} ${y1.toFixed(1)}`; };
const tick = limPct != null && !fuel ? (() => { const [x0, y0] = pt(limPct, R - 11), [x1, y1] = pt(limPct, R + 11); return `<line x1="${x0.toFixed(1)}" y1="${y0.toFixed(1)}" x2="${x1.toFixed(1)}" y2="${y1.toFixed(1)}" class="cg-limit"/>`; })() : "";
const gauge = `<div class="cg ${charging ? "charging" : ""} ${low ? "low" : ""}" role="meter" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${pct ?? 0}" aria-label="${esc(fuel ? t("Fuel level") : t("Battery level"))}">
<svg viewBox="0 0 200 200" aria-hidden="true"><path d="${arc(0, 100)}" class="cg-track"/>${pct ? `<path d="${arc(0, pct)}" class="cg-fill"/>` : ""}${tick}</svg>
<div class="cg-center">${range ? `<span class="cg-range num">${esc(fmtNum(range.state))}<small>${esc(range.attributes.unit_of_measurement || "km")}</small></span>` : ""}
${pct != null ? `<span class="cg-pct num">${charging ? Icons.svg("bolt", "inline") : ""}${pct}%${low ? ` · ${t("Low")}` : ""}</span>` : ""}</div>
<div class="cg-car">${c.image ? `<img src="${esc(c.image)}" alt="">` : CAR_ART}</div></div>`;
const hero = pct != null
? `<div class="cv-big num">${pct}<span>%</span></div>
<div class="cv-side">${range ? `<div class="cv-range num">${esc(fmtNum(range.state))}<small>${esc(unit)}</small></div>` : ""}${sub ? `<div class="cv-sub">${charging ? Icons.svg("bolt", "inline") : ""}${esc(sub)}</div>` : ""}</div>`
: range ? `<div class="cv-big num">${esc(fmtNum(range.state))}<span>${esc(unit)}</span></div>${sub && sub !== t("Range") ? `<div class="cv-side"><div class="cv-sub">${esc(sub)}</div></div>` : ""}` : "";
const btn = (act, eid, on, ico, label, sub) => `<button class="cb wbtn" data-act="${act}" data-e="${esc(eid)}" aria-label="${esc(sub ? `${label}, ${sub}` : label)}" title="${esc(label)}" ${act === "toggle" ? `aria-pressed="${on}"` : ""}>
<span class="cb-ico">${Icons.svg(ico)}</span><span class="cb-label">${esc(label)}</span>${sub ? `<span class="cb-sub num">${esc(sub)}</span>` : ""}</button>`;
const buttons = [];
if (lock) { const locked = lock.state === "locked"; buttons.push(btn("toggle", c.lock, !locked, locked ? "lock" : "unlock", locked ? t("Locked") : t("Unlocked"))); }
const bar = pct != null ? `<div class="cv-bar" role="presentation"><i style="width:${Math.max(0, Math.min(100, pct))}%"></i>${limPct != null ? `<b style="left:${Math.max(0, Math.min(100, limPct))}%"></b>` : ""}</div>` : "";
const art = c.image ? `<div class="cv-img"><img src="${esc(c.image)}" alt=""></div>` : carArt(esc(w?.id || "x"), pct, limPct, { charging, plugged });
// controls: each shows what it controls and its state
const ctrl = (act, eid, ico, label, { on = false, warn = false, svc = "" } = {}) =>
`<button class="cv-ctrl wbtn ${on ? "on" : ""} ${warn ? "warn" : ""}" data-act="${act}" data-e="${esc(eid)}" ${svc ? `data-v="${svc}"` : ""} ${act === "toggle" ? `aria-pressed="${on}"` : ""} aria-label="${esc(label)}" title="${esc(label)}">
<span class="cv-ico">${Icons.svg(ico)}</span><span class="cv-lbl">${esc(label)}</span></button>`;
const ctrls = [];
if (lock) { const locked = lock.state === "locked"; ctrls.push(ctrl("toggle", c.lock, locked ? "lock" : "unlock", locked ? t("Locked") : t("Unlocked"), { warn: !locked })); }
if (clim) {
const on = isOn(clim) || clim.state === "on", inside = clim.attributes.current_temperature;
buttons.push(btn("toggle", c.climate, on, on ? "fan" : "snowflake", on ? t("Climate on") : t("Climate"), inside != null ? `${inside}° ${t("inside")}` : ""));
ctrls.push(ctrl("toggle", c.climate, on ? "fan" : "snowflake", inside != null ? `${fmtNum(inside)}°` : on ? t("Climate on") : t("Climate"), { on }));
}
if (chg && !fuel) {
const isBtn = domainOf(c.chargeSwitch) === "button";
buttons.push(isBtn ? `<button class="cb wbtn" data-act="svc" data-e="${esc(c.chargeSwitch)}" data-v="button.press"><span class="cb-ico">${Icons.svg("bolt")}</span><span class="cb-label">${esc(friendly(chg))}</span></button>`
: btn("toggle", c.chargeSwitch, charging, "bolt", charging ? t("Stop charging") : t("Start charging")));
ctrls.push(domainOf(c.chargeSwitch) === "button"
? ctrl("svc", c.chargeSwitch, "bolt", t("Charge"), { svc: "button.press" })
: ctrl("toggle", c.chargeSwitch, "bolt", charging ? t("Stop") : t("Charge"), { on: charging }));
}
if (loc) buttons.push(btn("more", c.location, false, "pin", fmtState(loc)));
if (loc) ctrls.push(ctrl("more", c.location, "pin", fmtState(loc)));
const chips = [fullAt && `<span class="cchip">${Icons.svg("clock")}${esc(fullAt)}</span>`, odo && `<span class="cchip">${Icons.svg("road")}${esc(fmtState(odo))}</span>`].filter(Boolean).join("");
const stat = (k, v) => `<div class="cv-stat"><span>${esc(k)}</span><b class="num">${esc(v)}</b></div>`;
const stats = [odo && stat(t("Odometer"), fmtState(odo)), limPct != null && stat(t("Charge limit"), `${Math.round(limPct)}%`),
charging && power && stat(t("Charging power"), fmtState(power)), clim?.attributes.current_temperature != null && stat(t("Inside"), `${fmtNum(clim.attributes.current_temperature)}°`)].filter(Boolean).join("");
return `<div class="car3 ${charging ? "is-charging" : ""}">
<div class="car3-top"><span class="name">${esc(c.name || t("Car"))}</span>${status ? `<span class="car3-status ${charging ? "charging" : ""}">${esc(status)}</span>` : ""}</div>
${gauge}
<div class="car3-info">${chips ? `<div class="cchips">${chips}</div>` : ""}${buttons.length ? `<div class="cbs">${buttons.join("")}</div>` : ""}</div>
const cls = [charging && "charging", plugged && "plugged", low && "low", fuel && "fuel", c.image && "has-img"].filter(Boolean).join(" ");
return `<div class="cv ${cls}">
<div class="cv-head"><span class="cv-name">${esc(c.name || t("Car"))}</span>${status ? `<span class="cv-status ${tone}"><i aria-hidden="true"></i>${esc(status)}</span>` : ""}</div>
<div class="cv-hero" role="group" aria-label="${esc([pct != null && `${fuel ? t("Fuel level") : t("Battery level")} ${pct}%`, range && `${fmtNum(range.state)} ${unit}`].filter(Boolean).join(", "))}">${hero}</div>
<div class="cv-art">${art}${bar}</div>
${stats ? `<div class="cv-stats">${stats}</div>` : ""}
${ctrls.length ? `<div class="cv-ctrls">${ctrls.join("")}</div>` : ""}
</div>`;
},
};