Dashboard Studio 1.6.0: app icon, clock weather, icon-only tab rail

- App store icon (icon.png) and favicon, drawn by tools/make_icon.py
- Clock can show a weather entity with today's high and low from
  weather.get_forecasts
- Side menu gets an icon-only rail style (now the default): full height,
  centered icons, active marker, names as hover/focus tooltips; phones
  keep a labelled bottom tab bar

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-25 22:31:00 +01:00
parent 9e7e7beaa0
commit 4187c8f38d
12 changed files with 177 additions and 15 deletions
+4
View File
@@ -26,3 +26,7 @@ pip install aiohttp && python3 dashboard_studio/server/main.py
``` ```
Then open http://localhost:8099. The Photos widget also has fake Immich albums in this mode. To try your real Immich server, set `IMMICH_URL` and `IMMICH_API_KEY` before starting the server. Then open http://localhost:8099. The Photos widget also has fake Immich albums in this mode. To try your real Immich server, set `IMMICH_URL` and `IMMICH_API_KEY` before starting the server.
## App icon
`dashboard_studio/icon.png` is drawn by `tools/make_icon.py` (needs Pillow): `python3 tools/make_icon.py dashboard_studio/icon.png`.
+6 -1
View File
@@ -45,6 +45,10 @@ This card works with the [Afvalwijzer](https://github.com/xirixiz/homeassistant-
Bins use the integration's own colors (GFT green, Papier blue, PMD maroon, and so on), and each bin is always named, so the color is never the only clue. Types with no pickup planned are hidden. You can limit the card to a number of pickups, or to pickups within a number of days. Bins use the integration's own colors (GFT green, Papier blue, PMD maroon, and so on), and each bin is always named, so the color is never the only clue. Types with no pickup planned are hidden. You can limit the card to a number of pickups, or to pickups within a number of days.
### Clock with weather
Pick a weather entity in the clock's settings to show the current conditions and temperature under the time. Today's high and low come from Home Assistant's forecast, refreshed every half hour. Tap the weather to see all its details.
### Media players ### Media players
One card can control several players. With more than one, a row of buttons at the top switches between them, and a small animated equalizer marks the ones that are playing. The card starts on whatever is playing. It shows the album art, title and artist, with previous, play/pause and next buttons and a volume slider. A player that is off gets a **Turn on** button instead. You can hide players that are off from the switcher. One card can control several players. With more than one, a row of buttons at the top switches between them, and a small animated equalizer marks the ones that are playing. The card starts on whatever is playing. It shows the album art, title and artist, with previous, play/pause and next buttons and a volume slider. A player that is off gets a **Turn on** button instead. You can hide players that are off from the switcher.
@@ -92,8 +96,9 @@ A dashboard can have several tabs, each with its own widgets, for example *Overv
- **Add tab** creates a tab and turns on the menu. - **Add tab** creates a tab and turns on the menu.
- Each tab has a name and an icon (an icon name or an emoji). Use the arrows to reorder tabs and the bin to delete one; ⌘Z undoes this. - Each tab has a name and an icon (an icon name or an emoji). Use the arrows to reorder tabs and the bin to delete one; ⌘Z undoes this.
- **Show tabs in a side menu** turns the menu on or off. - **Show tabs in a side menu** turns the menu on or off.
- **Menu style:** *Icons only* (the default) shows a slim rail along the left edge, with the icons centered and a marker on the current tab. Hovering over an icon, or reaching it with the keyboard, shows the tab's name. *With names* shows the names next to the icons.
The menu sits on the left on computers, becomes a narrow rail on tablets and while editing, and turns into a tab bar at the bottom on phones. To move a widget to another tab, select it and change **Tab** in the inspector. The menu also shows in kiosk mode. On phones the menu turns into a tab bar at the bottom, which always shows the names, since a phone has no hover. To move a widget to another tab, select it and change **Tab** in the inspector. The menu also shows in kiosk mode.
## Kiosk mode ## Kiosk mode
+1 -1
View File
@@ -1,5 +1,5 @@
name: Dashboard Studio name: Dashboard Studio
version: "1.5.0" version: "1.6.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
Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

+7
View File
@@ -255,6 +255,13 @@ class MockHA:
entry = (ws, frozenset(subs)) entry = (ws, frozenset(subs))
self.clients.add(entry) self.clients.add(entry)
await ws.send_json({"id": mid, "type": "result", "success": True, "result": None}) await ws.send_json({"id": mid, "type": "result", "success": True, "result": None})
elif typ == "call_service" and req.get("domain") == "weather" and req.get("service") == "get_forecasts":
ids = (req.get("target") or {}).get("entity_id") or []
ids = [ids] if isinstance(ids, str) else ids
day = datetime.now().astimezone().replace(hour=12, minute=0, second=0, microsecond=0)
forecast = [{"datetime": (day + timedelta(days=i)).isoformat(), "condition": c, "temperature": hi, "templow": lo}
for i, (c, hi, lo) in enumerate([("partlycloudy", 15, 8), ("rainy", 12, 7), ("sunny", 16, 6)])]
await ws.send_json({"id": mid, "type": "result", "success": True, "result": {"context": {"id": "mock"}, "response": {e: {"forecast": forecast} for e in ids}}})
elif typ == "call_service" and req.get("domain") == "calendar" and req.get("service") == "get_events": elif typ == "call_service" and req.get("domain") == "calendar" and req.get("service") == "get_events":
ids = (req.get("target") or {}).get("entity_id") or [] ids = (req.get("target") or {}).get("entity_id") or []
ids = [ids] if isinstance(ids, str) else ids ids = [ids] if isinstance(ids, str) else ids
+17 -5
View File
@@ -97,6 +97,15 @@
}); });
return res?.response || {}; return res?.response || {};
} }
/** Today's and the coming days' forecast, via weather.get_forecasts with return_response. */
async forecast(entityId, type = "daily") {
await this.ready;
const res = await this.send({
type: "call_service", domain: "weather", service: "get_forecasts",
target: { entity_id: entityId }, service_data: { type }, return_response: true,
});
return res?.response?.[entityId]?.forecast || [];
}
async history(entityIds, hours) { async history(entityIds, hours) {
await this.ready; await this.ready;
const res = await this.send({ const res = await this.send({
@@ -134,7 +143,7 @@
const DEFAULT_KIOSK = { saver: 0, exitCorner: "top-right", hideCursor: true }; const DEFAULT_KIOSK = { saver: 0, exitCorner: "top-right", hideCursor: true };
const newTab = (name, icon = "home") => ({ id: uid(), name, icon, widgets: [] }); const newTab = (name, icon = "home") => ({ id: uid(), name, icon, widgets: [] });
const newDashboard = (name) => ({ id: uid(), name, theme: { ...DEFAULT_THEME }, grid: { ...DEFAULT_GRID }, kiosk: { ...DEFAULT_KIOSK }, nav: { show: false }, tabs: [newTab(tr("Overview"))] }); const newDashboard = (name) => ({ id: uid(), name, theme: { ...DEFAULT_THEME }, grid: { ...DEFAULT_GRID }, kiosk: { ...DEFAULT_KIOSK }, nav: { show: false, style: "icons" }, tabs: [newTab(tr("Overview"))] });
// Bring dashboards saved by older versions up to the current shape. // Bring dashboards saved by older versions up to the current shape.
function migrate(d) { function migrate(d) {
@@ -147,7 +156,7 @@
d.theme = { ...DEFAULT_THEME, ...t }; d.theme = { ...DEFAULT_THEME, ...t };
d.grid = { ...DEFAULT_GRID, ...(d.grid || {}) }; d.grid = { ...DEFAULT_GRID, ...(d.grid || {}) };
d.kiosk = { ...DEFAULT_KIOSK, ...(d.kiosk || {}) }; d.kiosk = { ...DEFAULT_KIOSK, ...(d.kiosk || {}) };
d.nav = { show: false, ...(d.nav || {}) }; d.nav = { show: false, style: "icons", ...(d.nav || {}) };
if (!Array.isArray(d.tabs) || !d.tabs.length) d.tabs = [{ ...newTab(tr("Overview")), widgets: d.widgets || [] }]; if (!Array.isArray(d.tabs) || !d.tabs.length) d.tabs = [{ ...newTab(tr("Overview")), widgets: d.widgets || [] }];
delete d.widgets; delete d.widgets;
return d; return d;
@@ -169,8 +178,8 @@
const numeric = (e) => !isNaN(Number(ha.states[e].state)); const numeric = (e) => !isNaN(Number(ha.states[e].state));
put("heading", 0, 0, 8, 1, { text: tr("Home"), subtitle: tr("Everything at a glance") }); put("heading", 0, 0, 8, 1, { text: tr("Home"), subtitle: tr("Everything at a glance") });
put("clock", 8, 0, 4, 3, {});
const weather = first("weather")[0]; const weather = first("weather")[0];
put("clock", 8, 0, 4, 3, { weather: weather || "" });
if (weather) put("weather", 0, 1, 4, 2, { entity: weather }); if (weather) put("weather", 0, 1, 4, 2, { entity: weather });
const lights = first("light", 3); const lights = first("light", 3);
if (lights[0]) put("light", 4, 1, 4, 2, { entity: lights[0] }); if (lights[0]) put("light", 4, 1, 4, 2, { entity: lights[0] });
@@ -394,9 +403,11 @@
navEl.hidden = !show; navEl.hidden = !show;
if (!show) return; if (!show) return;
const active = tab().id; const active = tab().id;
navEl.innerHTML = current.tabs.map((t) => `<button class="tabnav-item" data-tab="${t.id}" ${t.id === active ? 'aria-current="page"' : ""}> // "icons": a slim rail; names appear as a tooltip on hover or keyboard focus.
navEl.classList.toggle("icons", current.nav.style !== "labels");
navEl.innerHTML = current.tabs.map((t) => `<button class="tabnav-item" data-tab="${t.id}" aria-label="${esc(t.name)}" ${t.id === active ? 'aria-current="page"' : ""}>
<span class="tabnav-ico">${Icons.has(t.icon) ? Icons.svg(t.icon) : t.icon ? `<span class="glyph">${esc(t.icon)}</span>` : Icons.svg("tile")}</span><span class="tabnav-label">${esc(t.name)}</span></button>`).join("") + <span class="tabnav-ico">${Icons.has(t.icon) ? Icons.svg(t.icon) : t.icon ? `<span class="glyph">${esc(t.icon)}</span>` : Icons.svg("tile")}</span><span class="tabnav-label">${esc(t.name)}</span></button>`).join("") +
(editMode ? `<button class="tabnav-item add" data-tabop="add">${Icons.svg("plus")}<span class="tabnav-label">${tr("Add tab")}</span></button>` : ""); (editMode ? `<button class="tabnav-item add" data-tabop="add" aria-label="${tr("Add tab")}">${Icons.svg("plus")}<span class="tabnav-label">${tr("Add tab")}</span></button>` : "");
} }
function setTab(id) { function setTab(id) {
@@ -626,6 +637,7 @@
<datalist id="dl-icons">${Icons.names.map((n) => `<option value="${n}">`).join("")}</datalist> <datalist id="dl-icons">${Icons.names.map((n) => `<option value="${n}">`).join("")}</datalist>
<div class="section"><div class="section-title">${tr("Tabs")}</div> <div class="section"><div class="section-title">${tr("Tabs")}</div>
${fieldHTML({ k: "show", t: "check", l: "Show tabs in a side menu" }, current.nav.show, "nav")} ${fieldHTML({ k: "show", t: "check", l: "Show tabs in a side menu" }, current.nav.show, "nav")}
${fieldHTML({ k: "style", t: "seg", l: "Menu style", o: ["icons", "labels"] }, current.nav.style || "icons", "nav")}
<div class="tab-list">${current.tabs.map((t, i) => `<div class="tab-item"> <div class="tab-list">${current.tabs.map((t, i) => `<div class="tab-item">
<span class="icon-prev" aria-hidden="true">${Icons.has(t.icon) ? Icons.svg(t.icon) : esc(t.icon || "")}</span> <span class="icon-prev" aria-hidden="true">${Icons.has(t.icon) ? Icons.svg(t.icon) : esc(t.icon || "")}</span>
<input class="input" type="text" data-path="tab" data-tab="${t.id}" data-k="name" value="${esc(t.name)}" aria-label="${tr("Tab name")}"> <input class="input" type="text" data-path="tab" data-tab="${t.id}" data-k="name" value="${esc(t.name)}" aria-label="${tr("Tab name")}">
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

+3
View File
@@ -239,6 +239,9 @@
"Choose the appliance’s status, power or remaining-time entity in the inspector.": "Kies in de inspector de status-, vermogens- of resterende-tijdentiteit van het apparaat.", "Choose the appliance’s status, power or remaining-time entity in the inspector.": "Kies in de inspector de status-, vermogens- of resterende-tijdentiteit van het apparaat.",
"In progress": "Bezig", "Finished": "Klaar", "Paused": "Gepauzeerd", "Ready to unload": "Klaar om uit te ruimen", "In progress": "Bezig", "Finished": "Klaar", "Paused": "Gepauzeerd", "Ready to unload": "Klaar om uit te ruimen",
"{0} h {1} min": "{0} u {1} min", "{0} min": "{0} min", "left · ready at {0}": "te gaan · klaar om {0}", "Progress": "Voortgang", "{0} h {1} min": "{0} u {1} min", "{0} min": "{0} min", "left · ready at {0}": "te gaan · klaar om {0}", "Progress": "Voortgang",
"Weather (optional)": "Weer (optioneel)", "Show today’s high and low": "Toon de hoogste en laagste temperatuur van vandaag",
"High {0}° · Low {1}°": "Max {0}° · min {1}°", "High {0}°": "Max {0}°",
"Menu style": "Menustijl", "icons": "Alleen pictogrammen", "labels": "Met namen",
"Today": "Vandaag", "Tomorrow": "Morgen", "All day": "Hele dag", "Busy": "Bezet", "Today": "Vandaag", "Tomorrow": "Morgen", "All day": "Hele dag", "Busy": "Bezet",
}; };
+1
View File
@@ -6,6 +6,7 @@
<meta name="theme-color" content="#f2f2f7" media="(prefers-color-scheme: light)"> <meta name="theme-color" content="#f2f2f7" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#0f0f11" media="(prefers-color-scheme: dark)"> <meta name="theme-color" content="#0f0f11" media="(prefers-color-scheme: dark)">
<title>Dashboard Studio</title> <title>Dashboard Studio</title>
<link rel="icon" type="image/png" href="static/favicon.png">
<link rel="stylesheet" href="static/style.css"> <link rel="stylesheet" href="static/style.css">
</head> </head>
<body> <body>
+42 -7
View File
@@ -352,6 +352,14 @@ body.editing .w:hover .w-resize, body.editing .w.selected .w-resize { opacity: 1
.clock { display: flex; flex-direction: column; justify-content: center; align-items: center; height: 100%; text-align: center; } .clock { display: flex; flex-direction: column; justify-content: center; align-items: center; height: 100%; text-align: center; }
.clock .time { font-size: clamp(30px, min(38cqh, 22cqw), 96px); font-weight: 700; letter-spacing: -0.03em; line-height: 1; } .clock .time { font-size: clamp(30px, min(38cqh, 22cqw), 96px); font-weight: 700; letter-spacing: -0.03em; line-height: 1; }
.clock .date { color: var(--d-text-2); margin-top: 6px; font-size: 15px; } .clock .date { color: var(--d-text-2); margin-top: 6px; font-size: 15px; }
.clock-time { display: flex; flex-direction: column; align-items: center; }
.clock-wx { display: inline-flex; align-items: center; flex-wrap: wrap; justify-content: center; gap: 4px 8px; margin-top: 12px; padding: 6px 12px; border-radius: 99px; background: var(--d-fill); max-width: 100%; }
.clock-wx-ico { font-size: 20px; display: flex; color: var(--d-text-2); }
.clock-wx-temp { font-size: 17px; font-weight: 700; }
.clock-wx-text { font-size: 14px; color: var(--d-text-2); }
.clock-hl { font-size: 14px; color: var(--d-text-2); }
.clock-hl:empty { display: none; }
@container (max-height: 150px) { .clock-wx-text, .clock-hl { display: none; } .clock-wx { margin-top: 6px; } }
.cam-btn { position: absolute; inset: 0; } .cam-btn { position: absolute; inset: 0; }
.cam { width: 100%; height: 100%; object-fit: cover; display: block; background: #000; } .cam { width: 100%; height: 100%; object-fit: cover; display: block; background: #000; }
@@ -405,6 +413,30 @@ body.editing .w:hover .w-resize, body.editing .w.selected .w-resize { opacity: 1
.tabnav-item.add { color: var(--d-text-2); margin-top: 4px; } .tabnav-item.add { color: var(--d-text-2); margin-top: 4px; }
.tabnav-item.add .i { font-size: 20px; } .tabnav-item.add .i { font-size: 20px; }
body.kiosk #tabnav { top: 6px; max-height: calc(100vh - 20px); } body.kiosk #tabnav { top: 6px; max-height: calc(100vh - 20px); }
/* Icon-only rail: full height on the left edge, names as tooltips. */
@media (min-width: 641px) {
#tabnav.icons {
top: 72px; width: 76px; height: calc(100vh - 72px); max-height: none; margin: 0; padding: 12px 10px;
justify-content: center; align-items: center; gap: 10px; overflow: visible;
border: 0; border-right: 1px solid var(--d-separator); border-radius: 0;
background: color-mix(in srgb, var(--d-tile) 72%, transparent);
}
body.kiosk #tabnav.icons { top: 6px; height: calc(100vh - 6px); }
#tabnav.icons .tabnav-item { position: relative; width: 56px; height: 56px; min-height: 0; padding: 0; justify-content: center; border-radius: 16px; transition: background-color .2s, transform .1s; }
#tabnav.icons .tabnav-item:active { transform: scale(0.94); }
#tabnav.icons .tabnav-ico { font-size: 25px; }
#tabnav.icons .tabnav-item[aria-current="page"]::before { content: ""; position: absolute; left: -10px; top: 16px; bottom: 16px; width: 4px; border-radius: 0 3px 3px 0; background: var(--d-accent); }
#tabnav.icons .tabnav-item.add { margin-top: 6px; border: 1.5px dashed var(--d-separator); }
#tabnav.icons .tabnav-label {
position: absolute; left: calc(100% + 14px); top: 50%; z-index: 40;
transform: translate(-4px, -50%); opacity: 0; pointer-events: none;
padding: 6px 10px; border-radius: 9px; background: var(--surface); color: var(--text);
font-size: 13px; font-weight: 600; box-shadow: 0 6px 20px rgba(0, 0, 0, 0.25); transition: opacity .15s, transform .15s;
}
#tabnav.icons .tabnav-item:hover .tabnav-label, #tabnav.icons .tabnav-item:focus-visible .tabnav-label { opacity: 1; transform: translate(0, -50%); }
.surface-plain #tabnav.icons { border-right-color: transparent; }
}
.surface-plain #tabnav { background: transparent; border-color: transparent; backdrop-filter: none; -webkit-backdrop-filter: none; } .surface-plain #tabnav { background: transparent; border-color: transparent; backdrop-filter: none; -webkit-backdrop-filter: none; }
.i.flip { transform: rotate(180deg); } .i.flip { transform: rotate(180deg); }
.tab-list { display: flex; flex-direction: column; gap: 6px; margin-bottom: 4px; } .tab-list { display: flex; flex-direction: column; gap: 6px; margin-bottom: 4px; }
@@ -531,14 +563,14 @@ body.kiosk.idle, body.kiosk.idle * { cursor: none !important; }
} }
/* While editing (and on tablets) the menu becomes a compact rail to leave room for the grid. */ /* While editing (and on tablets) the menu becomes a compact rail to leave room for the grid. */
@media (min-width: 641px) { @media (min-width: 641px) {
body.editing #tabnav { width: 84px; padding: 6px; margin-left: 10px; } body.editing #tabnav:not(.icons) { width: 84px; padding: 6px; margin-left: 10px; }
body.editing .tabnav-item { flex-direction: column; justify-content: center; gap: 3px; padding: 8px 4px; min-height: 60px; text-align: center; } body.editing #tabnav:not(.icons) .tabnav-item { flex-direction: column; justify-content: center; gap: 3px; padding: 8px 4px; min-height: 60px; text-align: center; }
body.editing .tabnav-label { font-size: 11px; font-weight: 600; max-width: 100%; } body.editing #tabnav:not(.icons) .tabnav-label { font-size: 11px; font-weight: 600; max-width: 100%; }
} }
@media (max-width: 900px) { @media (max-width: 900px) {
#tabnav { width: 84px; padding: 6px; margin-left: 10px; } #tabnav:not(.icons) { width: 84px; padding: 6px; margin-left: 10px; }
.tabnav-item { flex-direction: column; justify-content: center; gap: 3px; padding: 8px 4px; min-height: 60px; text-align: center; } #tabnav:not(.icons) .tabnav-item { flex-direction: column; justify-content: center; gap: 3px; padding: 8px 4px; min-height: 60px; text-align: center; }
.tabnav-label { font-size: 11px; font-weight: 600; max-width: 100%; } #tabnav:not(.icons) .tabnav-label { font-size: 11px; font-weight: 600; max-width: 100%; }
#palette { width: 180px; } #palette { width: 180px; }
#inspector { width: 260px; } #inspector { width: 260px; }
#palette-list { grid-template-columns: 1fr; } #palette-list { grid-template-columns: 1fr; }
@@ -550,7 +582,10 @@ body.kiosk.idle, body.kiosk.idle * { cursor: none !important; }
flex-direction: row; max-height: none; overflow-x: auto; padding: 6px; border-radius: 22px; flex-direction: row; max-height: none; overflow-x: auto; padding: 6px; border-radius: 22px;
background: var(--glass); border-color: var(--glass-edge); box-shadow: 0 8px 30px rgba(0, 0, 0, 0.2); background: var(--glass); border-color: var(--glass-edge); box-shadow: 0 8px 30px rgba(0, 0, 0, 0.2);
} }
body:not(.editing) #tabnav .tabnav-item { flex: 1; min-width: 64px; } body:not(.editing) #tabnav .tabnav-item { flex: 1; min-width: 64px; flex-direction: column; justify-content: center; gap: 3px; padding: 8px 4px; min-height: 56px; width: auto; height: auto; }
/* A tab bar keeps its names on phones: there's no hover to reveal them. */
body:not(.editing) #tabnav.icons .tabnav-label { position: static; opacity: 1; transform: none; background: none; box-shadow: none; padding: 0; font-size: 11px; color: inherit; pointer-events: auto; max-width: 100%; }
body:not(.editing) #tabnav.icons .tabnav-item[aria-current="page"]::before { display: none; }
body.has-tabs:not(.editing) #canvas { padding-bottom: 96px; } body.has-tabs:not(.editing) #canvas { padding-bottom: 96px; }
body.editing #tabnav { position: static; width: auto; flex-direction: row; overflow-x: auto; max-height: none; margin: 4px 10px; } body.editing #tabnav { position: static; width: auto; flex-direction: row; overflow-x: auto; max-height: none; margin: 4px 10px; }
body.editing #stage { flex-direction: column; align-items: stretch; } body.editing #stage { flex-direction: column; align-items: stretch; }
+22 -1
View File
@@ -330,8 +330,29 @@
{ k: "h24", t: "check", l: "24-hour time", d: true }, { k: "h24", t: "check", l: "24-hour time", d: true },
{ k: "seconds", t: "check", l: "Show seconds", d: false }, { k: "seconds", t: "check", l: "Show seconds", d: false },
{ k: "date", t: "check", l: "Show date", d: true }, { k: "date", t: "check", l: "Show date", d: true },
{ k: "weather", t: "entity", l: "Weather (optional)", domains: ["weather"] },
{ k: "forecast", t: "check", l: "Show today’s high and low", d: true },
], ],
render: (c) => `<div class="clock" data-clock data-h24="${c.h24 ? 1 : 0}" data-sec="${c.seconds ? 1 : 0}" data-date="${c.date ? 1 : 0}">${clockHTML(c.h24, c.seconds, c.date)}</div>`, entities: (c) => (c.weather ? [c.weather] : []),
render(c, { ha }) {
const s = c.weather && ha.states[c.weather];
const wx = s ? `<button class="clock-wx wbtn" data-act="more" data-e="${esc(c.weather)}" aria-label="${esc(friendly(s))}: ${esc(st(s.state))}, ${esc(s.attributes.temperature ?? "–")}°">
<span class="clock-wx-ico">${Icons.svg(WX[s.state] || "cloud")}</span><span class="clock-wx-temp num">${esc(s.attributes.temperature ?? "–")}°</span>
<span class="clock-wx-text">${esc(st(s.state))}</span><span class="clock-hl num" data-hl></span></button>` : "";
return `<div class="clock"><div class="clock-time" data-clock data-h24="${c.h24 ? 1 : 0}" data-sec="${c.seconds ? 1 : 0}" data-date="${c.date ? 1 : 0}">${clockHTML(c.h24, c.seconds, c.date)}</div>${wx}</div>`;
},
async after(el, c, ctx) {
if (!c.weather || c.forecast === false || !ctx.ha.states[c.weather]) return;
const cache = ctx.cache;
if (cache.key !== c.weather || Date.now() - cache.at > 30 * 60 * 1000) {
cache.key = c.weather; cache.at = Date.now();
cache.loading = ctx.ha.forecast(c.weather, "daily").then((f) => (cache.today = f[0])).catch(() => (cache.today = null));
}
await cache.loading;
const hl = el.querySelector("[data-hl]");
const f = cache.today;
if (hl && f && f.temperature != null) hl.textContent = f.templow != null ? t("High {0}° · Low {1}°", Math.round(f.temperature), Math.round(f.templow)) : t("High {0}°", Math.round(f.temperature));
},
}; };
function clockHTML(h24, sec, date) { function clockHTML(h24, sec, date) {
+74
View File
@@ -0,0 +1,74 @@
"""Render the Dashboard Studio app icon: a dashboard with one lamp-lit tile."""
import sys
from PIL import Image, ImageDraw, ImageFilter
S = 1024 # draw large, downscale for anti-aliasing
def vgradient(w, h, top, bottom):
g = Image.new("RGBA", (w, h))
px = g.load()
for y in range(h):
t = y / (h - 1)
c = tuple(int(top[i] + (bottom[i] - top[i]) * t) for i in range(3)) + (255,)
for x in range(w):
px[x, y] = c
return g
def rounded_mask(size, box, r):
m = Image.new("L", size, 0)
ImageDraw.Draw(m).rounded_rectangle(box, r, fill=255)
return m
img = Image.new("RGBA", (S, S), (0, 0, 0, 0))
# background squircle
bg = vgradient(S, S, (34, 44, 78), (14, 17, 30))
img.paste(bg, (0, 0), rounded_mask((S, S), (0, 0, S - 1, S - 1), 230))
pad, gap = 150, 56
left = (pad, pad, 492, S - pad) # tall lit tile
rt = (492 + gap, pad, S - pad, 492 - gap // 2) # graph tile
rb = (492 + gap, 492 + gap // 2, S - pad, S - pad) # switch tile
# warm glow behind the lit tile
glow = Image.new("RGBA", (S, S), (0, 0, 0, 0))
ImageDraw.Draw(glow).rounded_rectangle(left, 64, fill=(255, 196, 92, 170))
img = Image.alpha_composite(img, glow.filter(ImageFilter.GaussianBlur(60)))
# lit tile with a warm gradient
lit = vgradient(left[2] - left[0], left[3] - left[1], (255, 236, 190), (255, 196, 96))
img.paste(lit, left[:2], rounded_mask(lit.size, (0, 0, lit.size[0] - 1, lit.size[1] - 1), 64))
# translucent shapes go on a layer that is blended in, not pasted over
layer = Image.new("RGBA", (S, S), (0, 0, 0, 0))
ld = ImageDraw.Draw(layer)
for box in (rt, rb):
ld.rounded_rectangle(box, 64, fill=(255, 255, 255, 26), outline=(255, 255, 255, 40), width=4)
cx = left[0] + 100
ld.ellipse((cx - 52, left[1] + 48, cx + 52, left[1] + 152), fill=(255, 255, 255, 140))
bar = (left[0] + 48, left[3] - 128, left[2] - 48, left[3] - 64)
ld.rounded_rectangle(bar, 32, fill=(122, 82, 16, 50))
img = Image.alpha_composite(img, layer)
d = ImageDraw.Draw(img)
# lamp in the badge and the filled part of the brightness bar
d.ellipse((cx - 20, left[1] + 80, cx + 20, left[1] + 120), fill=(122, 82, 16, 255))
d.rounded_rectangle((bar[0], bar[1], bar[0] + int((bar[2] - bar[0]) * 0.68), bar[3]), 32, fill=(140, 94, 20, 255))
# graph tile: a line in the accent blue
pts = [(rt[0] + 44, rt[3] - 70), (rt[0] + 110, rt[3] - 120), (rt[0] + 170, rt[3] - 96), (rt[0] + 240, rt[3] - 170), (rt[2] - 44, rt[1] + 70)]
d.line(pts, fill=(90, 170, 255, 255), width=22, joint="curve")
for p in (pts[0], pts[-1]):
d.ellipse((p[0] - 11, p[1] - 11, p[0] + 11, p[1] + 11), fill=(90, 170, 255, 255))
# switch tile: an "on" switch
sw = (rb[0] + 50, (rb[1] + rb[3]) // 2 - 46, rb[2] - 50, (rb[1] + rb[3]) // 2 + 46)
d.rounded_rectangle(sw, 46, fill=(48, 209, 88, 255))
d.ellipse((sw[2] - 86, sw[1] + 6, sw[2] - 6, sw[3] - 6), fill=(255, 255, 255, 255))
out = sys.argv[1]
img.resize((256, 256), Image.LANCZOS).save(out)
img.resize((128, 128), Image.LANCZOS).save(out.replace(".png", "-128.png"))
print("saved", out)