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
+17 -5
View File
@@ -97,6 +97,15 @@
});
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) {
await this.ready;
const res = await this.send({
@@ -134,7 +143,7 @@
const DEFAULT_KIOSK = { saver: 0, exitCorner: "top-right", hideCursor: true };
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.
function migrate(d) {
@@ -147,7 +156,7 @@
d.theme = { ...DEFAULT_THEME, ...t };
d.grid = { ...DEFAULT_GRID, ...(d.grid || {}) };
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 || [] }];
delete d.widgets;
return d;
@@ -169,8 +178,8 @@
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("clock", 8, 0, 4, 3, {});
const weather = first("weather")[0];
put("clock", 8, 0, 4, 3, { weather: weather || "" });
if (weather) put("weather", 0, 1, 4, 2, { entity: weather });
const lights = first("light", 3);
if (lights[0]) put("light", 4, 1, 4, 2, { entity: lights[0] });
@@ -394,9 +403,11 @@
navEl.hidden = !show;
if (!show) return;
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("") +
(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) {
@@ -626,6 +637,7 @@
<datalist id="dl-icons">${Icons.names.map((n) => `<option value="${n}">`).join("")}</datalist>
<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: "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">
<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")}">
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.",
"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",
"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",
};
+1
View File
@@ -6,6 +6,7 @@
<meta name="theme-color" content="#f2f2f7" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#0f0f11" media="(prefers-color-scheme: dark)">
<title>Dashboard Studio</title>
<link rel="icon" type="image/png" href="static/favicon.png">
<link rel="stylesheet" href="static/style.css">
</head>
<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 .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-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 { 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 .i { font-size: 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; }
.i.flip { transform: rotate(180deg); }
.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. */
@media (min-width: 641px) {
body.editing #tabnav { 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-label { font-size: 11px; font-weight: 600; max-width: 100%; }
body.editing #tabnav:not(.icons) { width: 84px; padding: 6px; margin-left: 10px; }
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:not(.icons) .tabnav-label { font-size: 11px; font-weight: 600; max-width: 100%; }
}
@media (max-width: 900px) {
#tabnav { 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-label { font-size: 11px; font-weight: 600; max-width: 100%; }
#tabnav:not(.icons) { width: 84px; padding: 6px; margin-left: 10px; }
#tabnav:not(.icons) .tabnav-item { flex-direction: column; justify-content: center; gap: 3px; padding: 8px 4px; min-height: 60px; text-align: center; }
#tabnav:not(.icons) .tabnav-label { font-size: 11px; font-weight: 600; max-width: 100%; }
#palette { width: 180px; }
#inspector { width: 260px; }
#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;
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.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; }
+22 -1
View File
@@ -330,8 +330,29 @@
{ k: "h24", t: "check", l: "24-hour time", d: true },
{ k: "seconds", t: "check", l: "Show seconds", d: false },
{ 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) {