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:
@@ -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.
|
||||
|
||||
### 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
|
||||
|
||||
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.
|
||||
- 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.
|
||||
- **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
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
name: Dashboard Studio
|
||||
version: "1.5.0"
|
||||
version: "1.6.0"
|
||||
slug: dashboard_studio
|
||||
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
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
@@ -255,6 +255,13 @@ class MockHA:
|
||||
entry = (ws, frozenset(subs))
|
||||
self.clients.add(entry)
|
||||
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":
|
||||
ids = (req.get("target") or {}).get("entity_id") or []
|
||||
ids = [ids] if isinstance(ids, str) else ids
|
||||
|
||||
@@ -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 |
@@ -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",
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user