Dashboard Studio 1.13.0: Tesla-style car card with square and wide designs

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-26 16:00:07 +01:00
parent 2ea437af31
commit a28f8227c6
6 changed files with 223 additions and 147 deletions
+6
View File
@@ -1,5 +1,11 @@
# Changelog # Changelog
## 1.13.0
- **Car card redesigned in the style of the Tesla app,** with a square and a wide design. The square card shows the car from the front, the wide card from the side with the numbers beside it. Both have the battery and range, a green charging bar with the power, time left and charge limit, and plain icon buttons for the doors, climate, charging and location. Tall square cards add rows for the climate, location and odometer.
- **Car color:** paint the car in your own color.
- **Layout:** choose the square or wide design yourself, or let the card pick one from its shape.
## 1.12.1 ## 1.12.1
- **Car card redesigned from scratch:** the car is now the battery. A side view of the car fills up with the charge level (green while charging, red when low) with the charge limit marked, under large numbers for the charge level and range, when it will be full and a status. Round buttons control the doors, climate, charging and location. Short cards show a slim battery bar instead, wide cards put the car beside the stats, and narrow phone cards stack neatly. - **Car card redesigned from scratch:** the car is now the battery. A side view of the car fills up with the charge level (green while charging, red when low) with the charge limit marked, under large numbers for the charge level and range, when it will be full and a status. Round buttons control the doors, climate, charging and location. Short cards show a slim battery bar instead, wide cards put the car beside the stats, and narrow phone cards stack neatly.
+6 -1
View File
@@ -163,7 +163,12 @@ Point the car widget at whichever entities your car integration provides. Each o
Two optional extra fields: **Time until full** (a sensor in minutes or an end time) and **Start/stop charging** (a switch or button). Two optional extra fields: **Time until full** (a sensor in minutes or an end time) and **Start/stop charging** (a switch or button).
The card is designed around 3×4 and is built around the car itself: a side view that fills up from the back with the charge level, green while charging, red when it's low, and orange for a fuel car. A dashed line in the car marks the charge limit. Above it are the charge level and range in large numbers, with when the car will be full (or how far it charges), and a status such as *Charging*, *Plugged in* or *Parked at home*. Round buttons along the bottom lock or unlock the doors (orange while unlocked), switch the climate (showing the temperature inside), start or stop charging, and show the location. On a short card the drawing becomes a slim battery bar, on a wide card the car sits on the left with a row of stats beside it, and a tall card adds stats for the odometer, charge limit, charging power and inside temperature. With your own car photo (by URL), the photo shows with the battery bar underneath. Images you put in Home Assistant's `www` folder are available at `/local/…`. The card follows the style of the Tesla app, with two designs:
- **Square** (for example 3×4): the car from the front in its own color, with the name and a status such as *Charging · Home* or *Parked · Away* at the top left, and a battery icon with the charge level and range at the top right. While the car is plugged in, a green charging bar shows the charging power, the time left and the charge limit. Plain icon buttons along the bottom lock or unlock the doors (orange while unlocked), switch the climate, start or stop charging and show the location. A tall square card adds rows for the climate (with the inside temperature), the location and the odometer; tap a row for details.
- **Wide** (for example 6×3 or 8×4): the car from the side on the right, with the name, the charge level and range in large numbers, and the charging bar on the left. The buttons sit under the car.
**Layout** picks the design: *Automatic* (the default) uses the wide design when the card is clearly wider than it is tall. **Car color** paints the car in any color, for example `#b3261e` for red; it's silver by default. A short card shows the car smaller from the side, and a very short card just the battery and the charging bar. With your own car photo (by URL), the photo replaces the drawing. Images you put in Home Assistant's `www` folder are available at `/local/…`.
## Conditional widgets ## Conditional widgets
+1 -1
View File
@@ -1,5 +1,5 @@
name: Dashboard Studio name: Dashboard Studio
version: "1.12.1" version: "1.13.0"
slug: dashboard_studio slug: dashboard_studio
description: Drag-and-drop dashboard builder with a free-form grid, live entity widgets and themes. 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 url: https://git.melvanveen.nl/veenm/ha-dashboard-studio
+13
View File
@@ -385,6 +385,19 @@
"Low fuel": "Bijna leeg", "Low fuel": "Bijna leeg",
"Charge": "Laden", "Charge": "Laden",
"Inside": "Binnen", "Inside": "Binnen",
"Car color": "Kleur van de auto",
"Silver": "Zilver",
"square": "Vierkant",
"wide": "Breed",
"Parked": "Geparkeerd",
"Climate off": "Klimaat uit",
"{0} h {1} min left": "Nog {0} u {1} min",
"{0} min left": "Nog {0} min",
"Limit {0}%": "Limiet {0}%",
"Locked, tap to unlock": "Op slot, tik om te ontgrendelen",
"Unlocked, tap to lock": "Open, tik om op slot te doen",
"Location: {0}": "Locatie: {0}",
"Inside {0}°": "Binnen {0}°",
"Today": "Vandaag", "Tomorrow": "Morgen", "All day": "Hele dag", "Busy": "Bezet", "Today": "Vandaag", "Tomorrow": "Morgen", "All day": "Hele dag", "Busy": "Bezet",
}; };
+116 -79
View File
@@ -974,88 +974,125 @@ body.kiosk #tabnav { top: 6px; max-height: calc(100vh - 20px); }
/* pill button with icon and label (media "Turn on") */ /* 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 { 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-act .i { font-size: 18px; }
/* car: the car itself fills up with the charge level */ /* car: in the style of the Tesla app. Square cards show the car from the front, wide ones from the side. */
.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; } .tz { --tz-c: var(--d-text); --tz-paint: color-mix(in srgb, var(--d-text) 22%, #b9bec6);
.cv.charging { --cv-c: var(--ok); } display: grid; grid-template-areas: "head" "art" "charge" "ctrls" "rows"; grid-template-columns: minmax(0, 1fr); grid-template-rows: auto minmax(0, 1fr) auto auto auto; row-gap: 10px; height: 100%; min-height: 0; min-width: 0; }
.cv.low { --cv-c: var(--crit); } .tz.charging { --tz-c: var(--ok); }
.cv.fuel:not(.low) { --cv-c: var(--warn); } .tz.low { --tz-c: var(--crit); }
.cv-head { grid-area: head; display: flex; align-items: center; gap: 8px; min-width: 0; } .tz.fuel:not(.low) { --tz-c: var(--warn); }
.cv-name { flex: 1; min-width: 0; font-size: 15px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .tz-head { grid-area: head; display: flex; align-items: flex-start; gap: 10px; min-width: 0; }
.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%; } .tz-title { flex: 1; min-width: 0; }
.cv-status i { width: 7px; height: 7px; border-radius: 50%; background: currentColor; flex: none; } .tz-name { font-size: clamp(17px, 7.5cqw, 24px); font-weight: 650; letter-spacing: -0.02em; line-height: 1.15; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cv-status.ok { color: var(--ok); } .cv-status.crit { color: var(--crit); } .cv-status.accent { color: var(--d-accent); } .tz-status { margin-top: 2px; font-size: 13px; color: var(--d-text-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cv-status.ok i { animation: cv-blink 1.6s ease-in-out infinite; } .tz.charging .tz-status { color: var(--ok); }
@keyframes cv-blink { 50% { opacity: .25; } } .tz-batt { display: grid; grid-template-columns: auto auto; align-items: center; column-gap: 6px; font-size: 14px; font-weight: 600; flex: none; padding-top: 3px; }
.cv-hero { grid-area: hero; display: flex; align-items: flex-end; gap: 14px; min-width: 0; } .tz-brange { grid-column: 1 / -1; justify-self: end; font-size: 12px; font-weight: 500; color: var(--d-text-2); }
.cv-big { font-size: clamp(40px, 19cqw, 68px); font-weight: 700; letter-spacing: -0.045em; line-height: .86; } .tz-bicon { width: 28px; height: 14px; display: block; }
.cv-big span { font-size: .42em; font-weight: 600; letter-spacing: 0; color: var(--d-text-2); margin-left: 2px; } .tz-bshell { fill: none; stroke: var(--d-text-2); stroke-width: 1.5; }
.cv-side { min-width: 0; padding-bottom: 2px; } .tz-blevel { fill: var(--tz-c); }
.cv-range { font-size: clamp(17px, 7cqw, 24px); font-weight: 650; letter-spacing: -0.02em; line-height: 1.05; white-space: nowrap; } .tz-bnub { stroke: var(--d-text-2); stroke-width: 2; stroke-linecap: round; }
.cv-range small { font-size: .62em; font-weight: 600; color: var(--d-text-2); margin-left: 3px; letter-spacing: 0; } .tz-bbolt { fill: var(--d-tile); stroke: var(--d-tile); stroke-width: .6; stroke-linejoin: round; }
.cv-sub { margin-top: 3px; font-size: 12px; font-weight: 500; color: var(--d-text-2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .tz-big { grid-area: big; display: none; flex-direction: column; gap: 2px; }
.cv.charging .cv-sub { color: var(--ok); } .tz-pct { font-size: clamp(40px, 10cqw, 64px); font-weight: 700; letter-spacing: -0.045em; line-height: .9; }
.cv-art { grid-area: art; min-height: 0; display: flex; flex-direction: column; justify-content: center; gap: 10px; } .tz-pct small { font-size: .42em; font-weight: 600; color: var(--d-text-2); margin-left: 2px; letter-spacing: 0; }
.cv-svg { display: block; width: 100%; height: 100%; max-height: 150px; min-height: 0; color: var(--cv-c); overflow: visible; } .tz-rng { font-size: 15px; font-weight: 500; color: var(--d-text-2); }
.cv-shadow { fill: var(--d-text); opacity: .12; } .tz-art { grid-area: art; min-height: 0; display: flex; align-items: center; justify-content: center; }
.cv.charging .cv-shadow { fill: var(--ok); opacity: .35; } .tz-front, .tz-side { width: 100%; height: 100%; min-height: 0; }
.cv-shell { fill: color-mix(in srgb, var(--d-text) 9%, transparent); } .tz-front { max-height: 190px; } .tz-side { display: none; max-height: 200px; }
.cv-edge { fill: none; stroke: color-mix(in srgb, var(--d-text) 28%, transparent); stroke-width: 1.2; stroke-linejoin: round; } .tz-img { max-width: 100%; max-height: 100%; object-fit: contain; filter: drop-shadow(0 10px 14px rgba(0, 0, 0, .3)); }
.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; } .tz-paint { fill: var(--tz-paint); }
.cv-crease { stroke: color-mix(in srgb, var(--d-text) 14%, transparent); stroke-width: 1; fill: none; } .tz-shadow { fill: #000; opacity: .3; }
.cv-lamp { stroke: color-mix(in srgb, var(--d-text) 55%, transparent); stroke-width: 2.4; stroke-linecap: round; } .tz.charging .tz-shadow { fill: var(--ok); opacity: .4; }
.cv-tail { stroke: var(--crit); stroke-width: 2.4; stroke-linecap: round; opacity: .8; } .tz-tyre { fill: #16171a; }
.cv-limit { stroke: var(--d-text); stroke-opacity: .55; stroke-width: 1.6; stroke-dasharray: 3 2.5; } .tz-rim { fill: #3b3e45; } .tz-spokes { stroke: #6d717a; stroke-width: 1.6; } .tz-hub { fill: #9a9ea6; }
.cv-port { fill: var(--cv-c); stroke: var(--d-tile); stroke-width: 1.5; } .tz-crease { fill: none; stroke: #000; stroke-opacity: .16; stroke-width: 1; }
.cv.charging .cv-port { animation: cv-blink 1.6s ease-in-out infinite; } .tz-handle { stroke: #000; stroke-opacity: .25; stroke-width: 1.6; stroke-linecap: round; }
.cv-tyre { fill: color-mix(in srgb, var(--d-text) 82%, var(--d-tile)); } .tz-lamp { fill: #f3f6fb; } .tz-lamp-s { stroke: #f3f6fb; stroke-width: 2.4; stroke-linecap: round; }
.cv-rim { fill: color-mix(in srgb, var(--d-text) 30%, var(--d-tile)); } .tz-tail { stroke: #e0353b; stroke-width: 2.4; stroke-linecap: round; }
.cv-hub { fill: color-mix(in srgb, var(--d-text) 70%, var(--d-tile)); } .tz-intake { fill: #15161a; opacity: .85; }
.cv-sweep { fill: #fff; opacity: .35; animation: cv-sweep 2.2s ease-in-out infinite; } .tz-port { fill: var(--ok); stroke: var(--d-tile); stroke-width: 1.5; }
@keyframes cv-sweep { from { transform: translateX(0); } to { transform: translateX(310px); } } .tz.charging .tz-port, .tz.charging .tz-bar i { animation: tz-pulse 2s ease-in-out infinite; }
.cv-img { min-height: 0; flex: 1; display: flex; align-items: center; justify-content: center; } @keyframes tz-pulse { 50% { opacity: .55; } }
.cv-img img { max-width: 100%; max-height: 100%; object-fit: contain; filter: drop-shadow(0 8px 12px rgba(0, 0, 0, .25)); } .tz-charge { grid-area: charge; display: flex; flex-direction: column; gap: 6px; min-width: 0; }
/* the slim battery bar: shown with a photo, and instead of the drawing on short cards */ .tz-charge.tz-idle { display: none; }
.cv-bar { position: relative; height: 8px; border-radius: 99px; background: color-mix(in srgb, var(--d-text) 10%, transparent); display: none; flex: none; } .tz-cl, .tz-cs { display: flex; justify-content: space-between; gap: 8px; min-width: 0; }
.cv-bar i { position: absolute; inset: 0 auto 0 0; border-radius: inherit; background: var(--cv-c); } .tz-ct { font-size: 13px; font-weight: 600; white-space: nowrap; flex: none; }
.cv-bar b { position: absolute; top: -3px; bottom: -3px; width: 2px; margin-left: -1px; border-radius: 1px; background: var(--d-text-2); } .tz.charging .tz-ct { color: var(--ok); }
.cv.has-img .cv-bar { display: block; } .tz-ct .num { color: var(--d-text-2); font-weight: 500; margin-left: 4px; }
.cv-stats { grid-area: stats; display: none; grid-template-columns: repeat(auto-fit, minmax(96px, 1fr)); gap: 6px; } .tz-cr { min-width: 0; font-size: 13px; color: var(--d-text-2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.cv-stat { display: flex; flex-direction: column; gap: 2px; padding: 8px 10px; border-radius: 12px; background: var(--d-fill); min-width: 0; } .tz-cs { font-size: 11px; color: var(--d-text-2); }
.cv-stat span { font-size: 11px; color: var(--d-text-2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .tz-bar { position: relative; height: 6px; border-radius: 99px; background: color-mix(in srgb, var(--d-text) 12%, transparent); }
.cv-stat b { font-size: 14px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .tz-bar i { position: absolute; inset: 0 auto 0 0; border-radius: inherit; background: var(--tz-c); }
.cv-ctrls { grid-area: ctrls; display: grid; grid-auto-flow: column; grid-auto-columns: minmax(0, 1fr); gap: 6px; } .tz-bar b { position: absolute; top: -3px; bottom: -3px; width: 2px; margin-left: -1px; border-radius: 1px; background: var(--d-text-2); }
.cv-ctrl { display: flex; flex-direction: column; align-items: center; gap: 5px; min-width: 0; padding: 4px 2px; border-radius: 12px; } .tz-ctrls { grid-area: ctrls; display: flex; justify-content: space-around; gap: 4px; }
.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; } .tz-btn { width: 46px; height: 46px; border-radius: 50%; display: grid; place-items: center; font-size: 22px; color: var(--d-text-2); transition: color .2s, background-color .2s; }
.cv-ctrl.on .cv-ico { background: var(--cv-c); color: #fff; } .tz-btn:hover { background: var(--d-fill); }
.cv-ctrl.warn .cv-ico { background: color-mix(in srgb, var(--warn) 22%, transparent); color: var(--warn); } .tz-btn.on { color: var(--d-accent); }
.cv-lbl { font-size: 11px; font-weight: 600; color: var(--d-text-2); max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .tz-btn.ok { color: var(--ok); }
@media (prefers-reduced-motion: reduce) { .cv-sweep { display: none; } .cv-status.ok i, .cv.charging .cv-port { animation: none; } } .tz-btn.warn { color: var(--warn); }
/* tall: room for a row of stats */ .tz-rows { grid-area: rows; display: none; flex-direction: column; border-top: 1px solid var(--d-separator); }
@container (min-height: 420px) { .cv-stats { display: grid; } } .tz-row { display: flex; align-items: center; gap: 12px; min-height: 46px; padding: 0 2px; border-bottom: 1px solid var(--d-separator); text-align: left; width: 100%; }
/* short: the drawing gives way to a slim bar */ .tz-row:last-child { border-bottom: 0; }
@container (max-height: 260px) { .tz-row > .i:first-child { font-size: 19px; color: var(--d-text-2); }
.cv { row-gap: 8px; grid-template-rows: auto auto auto minmax(0, 1fr) auto; } .tz-rt { flex: 1; font-size: 15px; font-weight: 500; }
.cv-svg, .cv-img { display: none; } .tz-rs { font-size: 13px; color: var(--d-text-2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 55%; }
.cv-bar { display: block; } .tz-row > .i:last-child { font-size: 14px; color: var(--d-text-2); }
.cv-ctrls { align-self: end; } @media (prefers-reduced-motion: reduce) { .tz.charging .tz-port, .tz.charging .tz-bar i { animation: none; } }
.cv-ico { width: 34px; height: 34px; font-size: 17px; } /* a standard square card keeps its room for the car */
@container (max-height: 380px) { .tz.tz-sq .tz-cs { display: none; } }
@container (max-height: 380px) and (max-aspect-ratio: 8 / 5) { .tz.tz-auto .tz-cs { display: none; } }
/* tall square cards list the details, like the Tesla app's menu */
@container (min-height: 470px) { .tz:not(.tz-wide) .tz-rows { display: flex; } }
/* short: no drawing, just the numbers and the bar */
@container (max-height: 280px) {
.tz.tz-sq .tz-front { display: none; }
.tz.tz-sq .tz-side { display: block; }
.tz-btn { width: 40px; height: 40px; font-size: 19px; }
} }
@container (max-height: 160px) { .cv-ctrls { display: none; } .cv-big { font-size: 40px; } } @container (max-height: 280px) and (max-aspect-ratio: 8 / 5) {
/* wide: the car on the left, numbers and controls on the right */ .tz.tz-auto .tz-front { display: none; }
@container (min-width: 460px) and (min-height: 261px) { .tz.tz-auto .tz-side { display: block; }
.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: 230px) {
.cv-lbl { display: none; } @container (max-width: 210px) {
.cv-hero { flex-direction: column; align-items: flex-start; gap: 6px; } .tz-head { flex-direction: column; gap: 4px; }
.cv-ctrls { gap: 4px; } .tz-batt { display: flex; flex-wrap: wrap; padding-top: 0; }
.cv-ico { width: 34px; height: 34px; font-size: 16px; } .tz-brange { flex-basis: 100%; }
.cv-status { max-width: 58%; } .tz-cs, .tz-ct .num { display: none; }
.tz-btn { width: 36px; height: 36px; font-size: 18px; }
} }
@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; } } /* wide: the car from the side on the right, the numbers on the left */
.tz.tz-wide { grid-template-areas: "head art" "big art" "charge ctrls"; grid-template-columns: minmax(170px, .8fr) minmax(0, 1.2fr); grid-template-rows: auto minmax(0, 1fr) auto; column-gap: 24px; }
.tz.tz-wide .tz-batt { display: none; }
.tz.tz-wide .tz-big { display: flex; }
.tz.tz-wide .tz-front { display: none; }
.tz.tz-wide .tz-side { display: block; }
.tz.tz-wide .tz-charge { align-self: end; }
.tz.tz-wide .tz-ctrls { justify-content: center; align-self: end; }
.tz.tz-wide .tz-rows { display: none; }
.tz.tz-wide .tz-art { align-self: stretch; }
.tz.tz-wide .tz-big { align-self: start; }
@container (min-aspect-ratio: 8 / 5) {
.tz.tz-auto { grid-template-areas: "head art" "big art" "charge ctrls"; grid-template-columns: minmax(170px, .8fr) minmax(0, 1.2fr); grid-template-rows: auto minmax(0, 1fr) auto; column-gap: 24px; }
.tz.tz-auto .tz-batt { display: none; }
.tz.tz-auto .tz-big { display: flex; }
.tz.tz-auto .tz-front { display: none; }
.tz.tz-auto .tz-side { display: block; }
.tz.tz-auto .tz-charge { align-self: end; }
.tz.tz-auto .tz-ctrls { justify-content: center; align-self: end; }
.tz.tz-auto .tz-rows { display: none; }
.tz.tz-auto .tz-art { align-self: stretch; }
.tz.tz-auto .tz-big { align-self: start; }
}
/* very short, whatever the width: name, battery and the bar */
@container (max-height: 170px) {
.tz.tz { grid-template-areas: "head" "charge" "ctrls"; grid-template-columns: minmax(0, 1fr); grid-template-rows: auto 1fr auto; }
.tz.tz .tz-art, .tz.tz .tz-big, .tz.tz .tz-cs, .tz.tz .tz-rows { display: none; }
.tz.tz .tz-batt { display: grid; }
.tz.tz .tz-charge, .tz.tz .tz-charge.tz-idle { display: flex; align-self: center; }
}
@container (max-height: 120px) { .tz.tz .tz-ctrls { display: none; } }
/* ============================================================ phone dashboards */ /* ============================================================ phone dashboards */
body.phone-ui:not(.peek) #topbar { display: none; } body.phone-ui:not(.peek) #topbar { display: none; }
+81 -66
View File
@@ -1134,30 +1134,42 @@
// ---------------------------------------------------------------- car // ---------------------------------------------------------------- car
const truthy = (s) => !!s && (["on", "true", "charging", "plugged_in", "connected"].includes(String(s.state).toLowerCase())); const truthy = (s) => !!s && (["on", "true", "charging", "plugged_in", "connected"].includes(String(s.state).toLowerCase()));
// The car is the battery: a side view that fills up from the back with the charge level, the charge // Inspired by the Tesla app: the car in its own color as the centerpiece, the battery top right,
// limit marked in it, and the numbers above it. Sizes adapt through container queries. // a green bar while charging and plain icon buttons. A square card shows the car from the front,
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"; // a wide card from the side with the details on the left. Container queries pick one (or the Layout field does).
const CAR_GLASS = "M164 40C154 32 144 26 132 24C120 22 104 22 92 23L94 40ZM88 23C74 25 60 31 48 40H90Z"; const SIDE_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_X0 = 7, CAR_W = 226; // where the body starts and how long it is, for the fill const FRONT_BODY = "M16 90C14 76 18 66 30 61L52 52C68 47 132 47 148 52L170 61C182 66 186 76 184 90V96C184 101 180 104 175 104H25C20 104 16 101 16 96Z";
function carArt(id, pct, limPct, o) { function carFront(id) {
const fx = CAR_X0 + CAR_W * Math.max(0, Math.min(100, pct ?? 0)) / 100; return `<svg class="tz-front" viewBox="0 0 200 116" aria-hidden="true">
const lx = limPct != null ? CAR_X0 + CAR_W * Math.max(0, Math.min(100, limPct)) / 100 : null; <defs><linearGradient id="tzs-${id}" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#fff" stop-opacity=".45"/><stop offset=".45" stop-color="#fff" stop-opacity="0"/><stop offset="1" stop-color="#000" stop-opacity=".35"/></linearGradient>
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"/>`; <radialGradient id="tzg-${id}" cx=".5" cy=".2" r=".9"><stop offset="0" stop-color="#8fa3bf" stop-opacity=".55"/><stop offset="1" stop-color="#0d1117" stop-opacity=".95"/></radialGradient></defs>
return `<svg class="cv-svg" viewBox="0 0 240 92" aria-hidden="true"> <ellipse cx="100" cy="108" rx="90" ry="5" class="tz-shadow"/>
<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> <rect x="19" y="78" width="22" height="30" rx="6" class="tz-tyre"/><rect x="159" y="78" width="22" height="30" rx="6" class="tz-tyre"/>
<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> <path d="M50 54C54 36 64 24 80 21C92 19 108 19 120 21C136 24 146 36 150 54Z" class="tz-paint"/>
<ellipse cx="121" cy="86" rx="110" ry="3.5" class="cv-shadow"/> <path d="${FRONT_BODY}" class="tz-paint"/>
<path d="${CAR_BODY}" class="cv-shell"/> <path d="M50 54C54 36 64 24 80 21C92 19 108 19 120 21C136 24 146 36 150 54Z" fill="url(#tzs-${id})"/>
<g clip-path="url(#cvb-${id})"> <path d="${FRONT_BODY}" fill="url(#tzs-${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> <path d="M57 52C61 38 68 29 81 26C93 24 107 24 119 26C132 29 139 38 143 52C128 49.5 72 49.5 57 52Z" fill="url(#tzg-${id})"/>
${lx != null ? `<line x1="${lx.toFixed(1)}" y1="0" x2="${lx.toFixed(1)}" y2="92" class="cv-limit"/>` : ""} <path d="M50 55L38 52C35 51.5 34 54 36 55.5L48 59ZM150 55L162 52C165 51.5 166 54 164 55.5L152 59Z" class="tz-paint"/>
</g> <path d="M70 61C82 58 118 58 130 61" class="tz-crease"/>
<path d="${CAR_GLASS}" class="cv-glass"/> <path d="M26 69C34 66 46 65 58 67L56 71C46 71 36 72 28 73ZM174 69C166 66 154 65 142 67L144 71C154 71 164 72 172 73Z" class="tz-lamp"/>
<path d="${CAR_BODY}" class="cv-edge"/> <rect x="64" y="84" width="72" height="8" rx="4" class="tz-intake"/>
<path d="M34 52L212 49" class="cv-crease"/> <path d="M24 100H176" class="tz-crease"/></svg>`;
<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"/>` : ""} function carSide(id, o) {
const wheel = (cx) => `<circle cx="${cx}" cy="70" r="15" class="tz-tyre"/><circle cx="${cx}" cy="70" r="10" class="tz-rim"/>
<path d="M${cx} 61V79M${cx - 9} 70H${cx + 9}M${cx - 6.4} 63.6L${cx + 6.4} 76.4M${cx - 6.4} 76.4L${cx + 6.4} 63.6" class="tz-spokes"/><circle cx="${cx}" cy="70" r="2.6" class="tz-hub"/>`;
return `<svg class="tz-side" viewBox="0 0 240 92" aria-hidden="true">
<defs><linearGradient id="tzd-${id}" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#fff" stop-opacity=".4"/><stop offset=".5" stop-color="#fff" stop-opacity="0"/><stop offset="1" stop-color="#000" stop-opacity=".35"/></linearGradient>
<linearGradient id="tzw-${id}" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#8fa3bf" stop-opacity=".6"/><stop offset="1" stop-color="#0d1117" stop-opacity=".95"/></linearGradient></defs>
<ellipse cx="121" cy="86" rx="112" ry="3.5" class="tz-shadow"/>
<path d="${SIDE_BODY}" class="tz-paint"/><path d="${SIDE_BODY}" fill="url(#tzd-${id})"/>
<path d="M164 40C154 32 144 26 132 24C120 22 104 22 92 23L94 40ZM88 23C74 25 60 31 48 40H90Z" fill="url(#tzw-${id})"/>
<path d="M34 52L212 49M93 41L95 66M150 42L152 64" class="tz-crease"/>
<path d="M118 46H128M66 47H76" class="tz-handle"/>
<path d="M220 49.5L230 53" class="tz-lamp-s"/><path d="M8.5 49L17 47" class="tz-tail"/>
${o.plugged ? `<circle cx="24" cy="51" r="3.4" class="tz-port"/>` : ""}
${wheel(58)}${wheel(184)}</svg>`; ${wheel(58)}${wheel(184)}</svg>`;
} }
@@ -1166,6 +1178,8 @@
fields: [ fields: [
{ k: "name", t: "text", l: "Name", d: "My car" }, { k: "name", t: "text", l: "Name", d: "My car" },
{ k: "image", t: "text", l: "Car image URL (optional, a side view with a transparent background looks best)", ph: "https://… or /local/car.png" }, { k: "image", t: "text", l: "Car image URL (optional, a side view with a transparent background looks best)", ph: "https://… or /local/car.png" },
{ k: "layout", t: "seg", l: "Layout", o: ["auto", "square", "wide"], d: "auto" },
{ k: "paint", t: "color", l: "Car color", ph: "Silver" },
{ k: "kind", t: "seg", l: "Drivetrain", o: ["electric", "fuel"], d: "electric" }, { k: "kind", t: "seg", l: "Drivetrain", o: ["electric", "fuel"], d: "electric" },
{ k: "level", t: "entity", l: "Battery or fuel level (%)", domains: ["sensor", "number", "input_number"] }, { k: "level", t: "entity", l: "Battery or fuel level (%)", domains: ["sensor", "number", "input_number"] },
{ k: "range", t: "entity", l: "Range", domains: ["sensor", "number", "input_number"] }, { k: "range", t: "entity", l: "Range", domains: ["sensor", "number", "input_number"] },
@@ -1193,60 +1207,61 @@
const low = pct != null && pct <= 20; const low = pct != null && pct <= 20;
const limPct = !fuel && limit && isFinite(Number(limit.state)) ? Number(limit.state) : null; const limPct = !fuel && limit && isFinite(Number(limit.state)) ? Number(limit.state) : null;
const unit = range ? range.attributes.unit_of_measurement || "km" : ""; const unit = range ? range.attributes.unit_of_measurement || "km" : "";
const rangeTxt = range ? `${fmtNum(range.state)} ${unit}` : "";
const climOn = clim && (isOn(clim) || clim.state === "on");
const inside = clim?.attributes.current_temperature;
const id = esc(w?.id || "x");
// status, top right // status under the name, the way the Tesla app says it
let status = "", tone = ""; const place = loc ? (loc.state === "home" ? t("Home") : loc.state === "not_home" ? t("Away") : st(loc.state)) : "";
if (charging) { status = t("Charging"); tone = "ok"; } const status = [charging ? t("Charging") : plugged ? t("Plugged in") : loc ? t("Parked") : "", place, climOn && t("Climate on")].filter(Boolean).join(" · ");
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);
// the line under the range: when it will be full, or how far it charges // time left on the charge
let sub = ""; let left = "", fullAt = "";
if (charging && toFull) { 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); 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) sub = t("Full at {0}", new Date(Date.now() + mins * 60000).toLocaleTimeString(LOCALE, { hour: "2-digit", minute: "2-digit" })); if (isFinite(mins) && mins > 0) {
left = mins >= 60 ? t("{0} h {1} min left", Math.floor(mins / 60), Math.round(mins % 60)) : t("{0} min left", Math.round(mins));
fullAt = 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") : "";
const hero = pct != null const p = Math.max(0, Math.min(100, pct ?? 0));
? `<div class="cv-big num">${pct}<span>%</span></div> const battIcon = pct != null ? `<svg class="tz-bicon" viewBox="0 0 28 14" aria-hidden="true"><rect x=".75" y=".75" width="23.5" height="12.5" rx="3.2" class="tz-bshell"/><rect x="2.5" y="2.5" width="${(20 * p / 100).toFixed(1)}" height="9" rx="1.6" class="tz-blevel"/><path d="M26 4.8v4.4" class="tz-bnub"/>${charging ? `<path d="M13.6 2.6 9.8 7.6h3.4l-.8 3.8 3.8-5h-3.4z" class="tz-bbolt"/>` : ""}</svg>` : "";
<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>` const batt = `<div class="tz-batt">${battIcon}${pct != null ? `<span class="num">${pct}%</span>` : ""}${rangeTxt ? `<span class="tz-brange num">${esc(rangeTxt)}</span>` : ""}</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 big = `<div class="tz-big">${pct != null ? `<span class="tz-pct num">${pct}<small>%</small></span>` : ""}${rangeTxt ? `<span class="tz-rng num">${esc(rangeTxt)}</span>` : ""}</div>`;
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>` : ""; // the charging bar: green while charging, with the charge limit marked
const art = c.image ? `<div class="cv-img"><img src="${esc(c.image)}" alt=""></div>` : carArt(esc(w?.id || "x"), pct, limPct, { charging, plugged }); const bar = pct != null ? `<div class="tz-bar"><i style="width:${p}%"></i>${limPct != null ? `<b style="left:${Math.max(0, Math.min(100, limPct))}%"></b>` : ""}</div>` : "";
const chargeInfo = plugged
? `<div class="tz-charge"><div class="tz-cl"><span class="tz-ct">${charging ? t("Charging") : t("Plugged in")}${charging && power && Number(power.state) > 0 ? ` <span class="num">${esc(fmtState(power))}</span>` : ""}</span><span class="tz-cr num">${esc(left || (limPct != null ? t("Charges to {0}%", Math.round(limPct)) : ""))}</span></div>${bar}${fullAt || limPct != null ? `<div class="tz-cs"><span>${esc(fullAt)}</span><span>${limPct != null ? esc(t("Limit {0}%", Math.round(limPct))) : ""}</span></div>` : ""}</div>`
: `<div class="tz-charge tz-idle">${bar}</div>`;
// controls: each shows what it controls and its state // icon buttons
const ctrl = (act, eid, ico, label, { on = false, warn = false, svc = "" } = {}) => const ctrl = (act, eid, ico, label, cls = "", extra = "") => `<button class="tz-btn wbtn ${cls}" data-act="${act}" data-e="${esc(eid)}" ${extra} aria-label="${esc(label)}" title="${esc(label)}">${Icons.svg(ico)}</button>`;
`<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 = []; 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 (lock) { const locked = lock.state === "locked"; ctrls.push(ctrl("toggle", c.lock, locked ? "lock" : "unlock", locked ? t("Locked, tap to unlock") : t("Unlocked, tap to lock"), locked ? "" : "warn", `aria-pressed="${!locked}"`)); }
if (clim) { if (clim) ctrls.push(ctrl("toggle", c.climate, "fan", climOn ? t("Climate on") : t("Climate off"), climOn ? "on" : "", `aria-pressed="${climOn}"`));
const on = isOn(clim) || clim.state === "on", inside = clim.attributes.current_temperature; if (chg && !fuel) ctrls.push(domainOf(c.chargeSwitch) === "button" ? ctrl("svc", c.chargeSwitch, "bolt", t("Start charging"), "", `data-v="button.press"`)
ctrls.push(ctrl("toggle", c.climate, on ? "fan" : "snowflake", inside != null ? `${fmtNum(inside)}°` : on ? t("Climate on") : t("Climate"), { on })); : ctrl("toggle", c.chargeSwitch, "bolt", charging ? t("Stop charging") : t("Start charging"), charging ? "ok" : "", `aria-pressed="${charging}"`));
} if (loc) ctrls.push(ctrl("more", c.location, "pin", t("Location: {0}", place)));
if (chg && !fuel) {
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) ctrls.push(ctrl("more", c.location, "pin", fmtState(loc)));
const stat = (k, v) => `<div class="cv-stat"><span>${esc(k)}</span><b class="num">${esc(v)}</b></div>`; // detail rows, for cards with room to spare
const stats = [odo && stat(t("Odometer"), fmtState(odo)), limPct != null && stat(t("Charge limit"), `${Math.round(limPct)}%`), const row = (ico, title, sub, act, eid) => `<${act ? `button class="tz-row wbtn" data-act="${act}" data-e="${esc(eid)}"` : `div class="tz-row"`}>${Icons.svg(ico)}<span class="tz-rt">${esc(title)}</span><span class="tz-rs num">${esc(sub)}</span>${act ? Icons.svg("chevron", "fwd") : ""}</${act ? "button" : "div"}>`;
charging && power && stat(t("Charging power"), fmtState(power)), clim?.attributes.current_temperature != null && stat(t("Inside"), `${fmtNum(clim.attributes.current_temperature)}°`)].filter(Boolean).join(""); const rows = [clim && row("thermometer", t("Climate"), [inside != null && t("Inside {0}°", fmtNum(inside)), climOn ? t("On") : t("Off")].filter(Boolean).join(" · "), "more", c.climate),
loc && row("pin", t("Location"), place, "more", c.location), odo && row("road", t("Odometer"), fmtState(odo))].filter(Boolean).join("");
const cls = [charging && "charging", plugged && "plugged", low && "low", fuel && "fuel", c.image && "has-img"].filter(Boolean).join(" "); const art = c.image ? `<img class="tz-img" src="${esc(c.image)}" alt="">` : `${carFront(id)}${carSide(id, { plugged })}`;
return `<div class="cv ${cls}"> const lay = c.layout === "square" ? "tz-sq" : c.layout === "wide" ? "tz-wide" : "tz-auto";
<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> const cls = [lay, charging && "charging", plugged && "plugged", low && "low", fuel && "fuel"].filter(Boolean).join(" ");
<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> const paint = /^#[0-9a-f]{3,8}$|^rgb|^hsl|^[a-z]+$/i.test(c.paint || "") ? ` style="--tz-paint:${esc(c.paint)}"` : "";
<div class="cv-art">${art}${bar}</div> return `<div class="tz ${cls}"${paint}>
${stats ? `<div class="cv-stats">${stats}</div>` : ""} <div class="tz-head"><div class="tz-title"><div class="tz-name">${esc(c.name || t("Car"))}</div>${status ? `<div class="tz-status">${esc(status)}</div>` : ""}</div>${batt}</div>
${ctrls.length ? `<div class="cv-ctrls">${ctrls.join("")}</div>` : ""} ${big}
<div class="tz-art">${art}</div>
${chargeInfo}
${ctrls.length ? `<div class="tz-ctrls">${ctrls.join("")}</div>` : ""}
${rows ? `<div class="tz-rows">${rows}</div>` : ""}
</div>`; </div>`;
}, },
}; };