Dashboard Studio 2.6.0 and Haven 2.3.0: easier to use on a phone

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-31 18:48:32 +01:00
parent 3b2cbbc96c
commit 1bca71ff7e
12 changed files with 365 additions and 37 deletions
+88 -17
View File
@@ -301,8 +301,8 @@
const p = (t, cls = "") => `<p${cls ? ` class="${cls}"` : ""}>${esc(t)}</p>`;
let body, actions = `<button class="btn" data-x="cancel">${tr("Close")}</button>`;
if (IN_APP) {
body = p(tr("This is Haven, connected to {0}.", location.host));
actions += `<button class="btn" data-x="reset">${tr("Change address")}</button>`;
body = p(tr("This is Haven, connected to {0}. You can add another home, such as a holiday home, or switch between them.", location.host));
actions += `<button class="btn" data-x="reset">${tr("Homes and addresses")}</button>`;
} else if (android) {
// A home screen app from the browser needs https, and Home Assistant's own app can't make one at all: Haven always works.
body = p(tr("Get Haven, the Android app for your dashboards. It opens full screen with its own icon and keeps you signed in."))
@@ -325,7 +325,7 @@
if (browserInstall) actions += `<button class="btn primary" data-x="install">${tr("Install")}</button>`;
}
if (!IN_APP && !android && isAdmin()) body += p(tr("For Android phones there is Haven: see Admin, Haven app."), "muted small");
const choice = await openModal(`<h3>${tr("Install as an app")}</h3>${body}
const choice = await openModal(`<h3>${IN_APP ? "Haven" : tr("Install as an app")}</h3>${body}
${IN_APP || ios ? "" : p(tr("It opens on the dashboard chosen for that device. Mark buttons as a shortcut to use them from the app icon."), "muted small")}
<div class="modal-actions">${actions}</div>`,
(e, close) => { const x = e.target.closest("[data-x], a.btn"); if (x) close(x.dataset.x || "link"); });
@@ -2238,15 +2238,34 @@
// ------------------------------------------------------------ dialogs
let modalClose = null;
// An open sheet is a step in the history, so going back (Android's back gesture in Haven, the browser's
// back button) closes it instead of leaving the app.
let modalInHistory = false, closingByBack = false, ownBacks = 0;
window.addEventListener("popstate", () => {
if (ownBacks) { ownBacks--; return; } // the step back a closing sheet took itself (another may have opened since)
if (!modalInHistory) return;
modalInHistory = false;
closingByBack = true;
modalClose?.(null);
closingByBack = false;
});
/** Haven's back gesture asks first: close what is open here (a sheet, the top bar) and say so. */
window.havenBack = () => {
if (modalClose) { modalClose(null); return true; }
if (document.body.classList.contains("peek")) { document.body.classList.remove("peek"); return true; }
return false;
};
function openModal(html, onClick) {
const card = $("#modal .modal-card");
card.innerHTML = html;
$("#modal").hidden = false;
if (!modalInHistory) { history.pushState({ dsSheet: true }, "", location.href); modalInHistory = true; }
const prevFocus = document.activeElement;
return new Promise((resolve) => {
modalClose = (result) => {
$("#modal").hidden = true;
modalClose = null;
if (modalInHistory && !closingByBack) { modalInHistory = false; ownBacks++; history.back(); }
prevFocus?.focus?.();
resolve(result);
};
@@ -2467,23 +2486,56 @@
});
}
/** The details of one entity: what it is now in words, a day's history for a sensor, the controls,
* and the technical details (its ID and attributes) folded away for whoever needs them. */
function moreInfo(eid) {
const s = ha.states[eid];
if (!s) return;
if (domainOf(eid) === "light") { lightPopup(eid); return; }
if (domainOf(eid) === "climate") { climatePopup(eid); return; }
const toggle = window.HAFmt.TOGGLE_DOMAINS.has(domainOf(eid));
const actionLabel = domainOf(eid) === "lock" ? (s.state === "locked" ? tr("Unlock") : tr("Lock")) : ["scene", "script"].includes(domainOf(eid)) ? tr("Run") : isOn(s) ? tr("Turn off") : tr("Turn on");
openModal(`<h3>${esc(friendly(s))}</h3><div class="muted small">${esc(eid)}</div>
<div class="num" style="font-size:34px;font-weight:700;margin:14px 0 2px">${esc(fmtState(s))}</div>
const d = domainOf(eid);
if (d === "light") { lightPopup(eid); return; }
if (d === "climate") { climatePopup(eid); return; }
const toggle = window.HAFmt.TOGGLE_DOMAINS.has(d) && !["climate", "vacuum", "media_player", "cover"].includes(d);
const actionLabel = d === "lock" ? (s.state === "locked" ? tr("Unlock") : tr("Lock")) : ["scene", "script"].includes(d) ? tr("Run") : ["button", "input_button"].includes(d) ? tr("Press") : isOn(s) ? tr("Turn off") : tr("Turn on");
const numeric = ["sensor", "input_number", "number", "counter"].includes(d) && s.state !== "" && isFinite(Number(s.state));
// a few domains get their own buttons
const svc = (service, ico, label) => `<button class="btn" data-svc="${service}">${Icons.svg(ico)}${esc(label)}</button>`;
const extra = d === "cover" ? svc("cover.open_cover", "arrow-up", tr("Open")) + svc("cover.stop_cover", "stop", tr("Stop")) + svc("cover.close_cover", "arrow-down", tr("Close"))
: d === "media_player" ? svc("media_player.media_previous_track", "prev", tr("Previous track")) + svc("media_player.media_play_pause", s.state === "playing" ? "pause" : "play", s.state === "playing" ? tr("Pause") : tr("Play")) + svc("media_player.media_next_track", "next", tr("Next track"))
: d === "vacuum" ? svc(s.state === "cleaning" ? "vacuum.pause" : "vacuum.start", s.state === "cleaning" ? "pause" : "play", s.state === "cleaning" ? tr("Pause") : tr("Start")) + svc("vacuum.return_to_base", "home", tr("Dock")) : "";
const attrs = Object.entries(s.attributes).filter(([k]) => !["friendly_name", "icon", "entity_picture", "supported_features"].includes(k));
const done = openModal(`<div class="mi-head"><span class="mi-ico">${window.HAFmt.icon(s)}</span><h3>${esc(friendly(s))}</h3></div>
<div class="mi-state num">${esc(fmtState(s))}</div>
<div class="muted small">${esc(tr("Changed {0}", relTime(s.last_changed)))}</div>
<table class="attr-table">${Object.entries(s.attributes).map(([k, v]) => `<tr><td>${esc(cap(k))}</td><td>${esc(typeof v === "object" ? JSON.stringify(v) : v)}</td></tr>`).join("")}</table>
${numeric ? `<div class="mi-graph" aria-label="${esc(tr("The last 24 hours"))}"><div class="muted small">${tr("Loading history…")}</div></div>` : ""}
${extra ? `<div class="mi-actions">${extra}</div>` : ""}
<details class="mi-more"><summary>${tr("Technical details")}</summary>
<table class="attr-table"><tr><td>${tr("Entity ID")}</td><td>${esc(eid)}</td></tr>${attrs.map(([k, v]) => `<tr><td>${esc(cap(k))}</td><td>${esc(typeof v === "object" ? JSON.stringify(v) : v)}</td></tr>`).join("")}</table></details>
<div class="modal-actions"><button class="btn" data-x="close">${tr("Close")}</button>${toggle ? `<button class="btn primary" data-x="toggle">${actionLabel}</button>` : ""}</div>`,
(e, close) => {
const x = e.target.closest("[data-x]")?.dataset.x;
if (x === "toggle") defaultAction(eid);
if (x) close();
const b = e.target.closest("[data-x], [data-svc]");
if (!b) return;
if (b.dataset.svc) { const [dom, service] = b.dataset.svc.split("."); ha.callService(dom, service, {}, eid); haptic(); return; }
if (b.dataset.x === "toggle") defaultAction(eid);
close();
});
if (numeric) drawDetailGraph(eid, done);
}
/** A day of a sensor's history as a line, with its lowest and highest value. */
async function drawDetailGraph(eid, done) {
let pts = [];
try { pts = ((await ha.history([eid], 24))[eid] || []).filter(([, v]) => isFinite(v)); } catch { /* shown as unavailable */ }
const box = $("#modal:not([hidden]) .mi-graph");
if (!box) return;
if (pts.length < 2) { box.innerHTML = `<div class="muted small">${tr("No history for the last 24 hours.")}</div>`; return; }
const W_ = 320, H = 90, t0 = Date.now() - 24 * 3600e3, t1 = Date.now();
const vs = pts.map(([, v]) => v), lo = Math.min(...vs), hi = Math.max(...vs), span = hi - lo || 1;
const x = (t) => ((Math.max(t, t0) - t0) / (t1 - t0)) * W_, y = (v) => H - 6 - ((v - lo) / span) * (H - 12);
const line = pts.map(([t, v], i) => `${i ? "L" : "M"}${x(t).toFixed(1)} ${y(v).toFixed(1)}`).join("") + `L${W_} ${y(vs[vs.length - 1]).toFixed(1)}`;
const unit = ha.states[eid]?.attributes.unit_of_measurement || "";
const f = (v) => `${Number(v).toLocaleString(LOCALE, { maximumFractionDigits: 1 })}${unit ? ` ${unit}` : ""}`;
box.innerHTML = `<svg viewBox="0 0 ${W_} ${H}" preserveAspectRatio="none" aria-hidden="true"><path d="${line}" fill="none" stroke="var(--accent)" stroke-width="2" vector-effect="non-scaling-stroke" stroke-linejoin="round"/></svg>
<div class="mi-graph-legend muted small"><span>${tr("24 hours ago")}</span><span>${esc(tr("Low {0} · high {1}", f(lo), f(hi)))}</span><span>${tr("Now")}</span></div>`;
}
/** The changelog in a dialog. A small Markdown subset: ## headings, - bullets, **bold** and *italic*. */
@@ -2599,7 +2651,7 @@
const others = lockedKiosk ? store.dashboards.filter((d) => d.id !== current.id) : [];
const choice = await openModal(lockedKiosk
? `<h3>${tr("Dashboards")}</h3>${me?.guest && me.until ? `<p class="muted small">${esc(tr("This invite ends {0}.", new Date(me.until * 1000).toLocaleString(LOCALE, { weekday: "long", hour: "2-digit", minute: "2-digit" })))}</p>` : ""}${others.length ? `<div class="kp-dashes">${others.map((d) => `<button class="btn" data-x="dash:${d.id}">${Icons.svg("layout")}${esc(d.name)}</button>`).join("")}</div>` : `<p>${tr("This is the only dashboard you can see. An admin can add more for you.")}</p>`}
<div class="modal-actions">${IN_APP ? `<button class="btn" data-x="haven">${tr("Change address")}</button>` : ""}<button class="btn guest-hide" data-x="guest">${guestOn ? tr("End guest mode") : tr("Guest mode")}</button><button class="btn" data-x="full">${document.fullscreenElement ? tr("Exit full screen") : tr("Full screen")}</button><button class="btn primary" data-x="stay" autofocus>${tr("Close")}</button></div>`
<div class="modal-actions">${IN_APP ? `<button class="btn" data-x="haven">${tr("Homes and addresses")}</button>` : ""}<button class="btn guest-hide" data-x="guest">${guestOn ? tr("End guest mode") : tr("Guest mode")}</button><button class="btn" data-x="full">${document.fullscreenElement ? tr("Exit full screen") : tr("Full screen")}</button><button class="btn primary" data-x="stay" autofocus>${tr("Close")}</button></div>`
: `<h3>${tr("Leave kiosk mode?")}</h3><p>${tr("This closes by itself in a few seconds.")}</p>
<div class="modal-actions"><button class="btn" data-x="guest">${guestOn ? tr("End guest mode") : tr("Guest mode")}</button><button class="btn" data-x="full">${document.fullscreenElement ? tr("Exit full screen") : tr("Full screen")}</button><button class="btn" data-x="stay" autofocus>${tr("Stay")}</button><button class="btn primary" data-x="exit">${tr("Leave kiosk")}</button></div>`,
(e, close) => { const x = e.target.closest("[data-x]")?.dataset.x; if (x) close(x); });
@@ -3003,11 +3055,23 @@
if (on) peekTimer = setTimeout(() => document.body.classList.remove("peek"), 15000);
}
$("#phone-menu").onclick = () => peek(!document.body.classList.contains("peek"));
// Scrolling down, the ⋯ button steps aside so it doesn't sit on a card; scrolling up brings it back.
let lastScrollY = 0;
document.addEventListener("scroll", (e) => {
if (!document.body.classList.contains("phone-ui")) return;
const el = e.target === document ? document.scrollingElement : e.target;
if (!el || el.closest?.(".w, #modal")) return; // a list inside a card, or a sheet: not the page
const y = el.scrollTop || 0;
if (Math.abs(y - lastScrollY) < 6) return;
document.body.classList.toggle("menu-away", y > lastScrollY && y > 40);
lastScrollY = y;
}, { capture: true, passive: true });
// ------------------------------------------------------------ chrome wiring
function setCurrent(id) {
current = store.dashboards.find((d) => d.id === id) || store.dashboards[0];
lsSet("ds.current", current.id);
if (phoneScreen.matches) lsSet("ds.currentPhone", current.id);
activeTabId = lsGet(`ds.tab.${current.id}`);
guestOn = false; guestCache = null; awayState = null; awayFrom = null;
undoStack.length = redoStack.length = 0;
@@ -3721,10 +3785,14 @@
document.querySelectorAll("#menu [data-icon], #mode-seg [data-icon]").forEach((b) => b.insertAdjacentHTML("afterbegin", Icons.svg(b.dataset.icon)));
}
let everConnected = false;
ha.onStatus = (ok) => {
const c = $("#conn");
if (ok) everConnected = true;
c.classList.toggle("ok", ok);
c.textContent = ok ? tr("Connected") : offlineText();
// the first moments after opening are connecting, not offline
c.classList.toggle("connecting", !ok && !everConnected && !lastLive);
c.textContent = ok ? tr("Connected") : !everConnected && !lastLive ? tr("Connecting…") : offlineText();
showOffline(!ok);
// An update restarts the app, which drops the connection: check right after reconnecting.
if (ok) checkVersion();
@@ -3824,9 +3892,12 @@
const exists = (id) => id && store.dashboards.some((d) => d.id === id);
// The screen it was on last time; people who aren't admins, and Haven, only get the dashboard.
const lastMode = lsGet("ds.mode") || (lsGet("ds.edit") === "1" ? "builder" : "dashboard");
mode = isAdmin() && !IN_APP ? lastMode : "dashboard";
// the builder is for bigger screens: a phone always opens on the dashboard
mode = isAdmin() && !IN_APP && !phoneScreen.matches ? lastMode : "dashboard";
editMode = mode === "builder";
setCurrent([hashId, dev?.dashboard, home, lsGet("ds.current")].find(exists));
// On a phone: the one last used on this phone, else the first dashboard designed for phones.
const phone = phoneScreen.matches ? [lsGet("ds.currentPhone"), store.dashboards.find((d) => d.mobile)?.id] : [];
setCurrent([hashId, dev?.dashboard, home, ...phone, lsGet("ds.current")].find(exists));
if (!isAdmin()) { lockedKiosk = true; enterKiosk({ fullscreen: false }); }
else if (location.hash.includes("kiosk") || shared.get("ds_kiosk") === "1" || (dev?.kiosk && !hashId)) enterKiosk({ fullscreen: false });
else setMode(mode);
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
{"version": "2.2.0", "code": 2}
{"version": "2.3.0", "code": 3}
+11 -1
View File
@@ -303,6 +303,10 @@
"Emptying": "Aan het legen", "Levelling": "Aan het egaliseren", "Sanitizing": "Aan het ontsmetten", "Sleeping": "Slaapt", "Ready": "Klaar voor gebruik",
"Bin full": "Afvalbak vol", "Check the litter box": "Kijk even bij de kattenbak", "Cat inside": "Kat in de bak", "Needs attention": "Heeft aandacht nodig",
"visits today": "bezoekjes vandaag", "visits": "bezoekjes", "visit": "bezoekje",
"Connecting…": "Verbinden…", "Technical details": "Technische details", "Entity ID": "Entiteit-ID", "The last 24 hours": "De afgelopen 24 uur",
"No history for the last 24 hours.": "Geen geschiedenis van de afgelopen 24 uur.", "24 hours ago": "24 uur geleden", "Low {0} · high {1}": "Laagst {0} · hoogst {1}",
"Homes and addresses": "Huizen en adressen",
"This is Haven, connected to {0}. You can add another home, such as a holiday home, or switch between them.": "Dit is Haven, verbonden met {0}. Je kunt nog een huis toevoegen, zoals een vakantiehuis, of tussen huizen wisselen.",
"Size on the phone": "Formaat op de telefoon", "Small": "Klein", "Medium": "Middel", "Now {0}°": "Nu {0}°", "Nobody home": "Niemand thuis",
"Tap to watch": "Tik om te kijken", "Returning": "Terug naar het dock", "Sun {0}": "Zon {0}", "Grid {0}": "Net {0}", "Sending back {0}": "Teruglevering {0}",
"{0} open": "{0} open", "Nothing on the way": "Niets onderweg", "Partly open": "Half open", "{0} of {1} open": "{0} van {1} open", "Nothing planned": "Niets gepland", "Only show when the bin is full or something is wrong": "Alleen tonen als de afvalbak vol is of er iets mis is", "Clean": "Schoonmaken", "Auto": "Auto", "in the box today": "in de bak vandaag", "cat’s weight": "gewicht kat", "Clean now": "Nu schoonmaken", "Auto clean": "Automatisch schoonmaken",
@@ -811,6 +815,12 @@
armed_home: "Ingeschakeld (thuis)", armed_night: "Ingeschakeld (nacht)", disarmed: "Uitgeschakeld", triggered: "Geactiveerd",
};
// States whose names don't read well as they are ("partlycloudy", "not_home").
const STATES_EN = {
not_home: "Away", partlycloudy: "Partly cloudy", "clear-night": "Clear night", "lightning-rainy": "Thunder and rain", "snowy-rainy": "Sleet",
"windy-variant": "Windy", heat_cool: "Heat and cool", fan_only: "Fan only", above_horizon: "Above the horizon", below_horizon: "Below the horizon",
armed_away: "Armed (away)", armed_home: "Armed (home)", armed_night: "Armed (night)", armed_vacation: "Armed (vacation)", disarmed: "Disarmed",
};
const read = (k) => { try { return localStorage.getItem(k); } catch { return null; } };
function detect() {
const pref = read("ds.lang");
@@ -837,7 +847,7 @@
return args.length ? s.replace(/\{(\d)\}/g, (_, i) => args[i] ?? "") : s;
},
/** A Home Assistant state as readable text. */
state: (st) => (lang === "nl" && STATES_NL[st]) || cap(st),
state: (st) => (lang === "nl" ? STATES_NL[st] : STATES_EN[st]) || cap(st),
};
document.documentElement.lang = lang;
})();
+1 -1
View File
@@ -30,7 +30,7 @@
<button id="btn-new" class="btn icon" aria-label="New dashboard" title="New dashboard"></button>
</div>
<div class="tb-group">
<span id="conn" class="conn" role="status" data-i18n>Offline</span>
<span id="conn" class="conn connecting" role="status" data-i18n>Connecting…</span>
<span id="save-status" class="save-status edit-only" role="status"></span>
<span class="edit-only tb-group">
<button id="btn-undo" class="btn icon" aria-label="Undo" title="Undo (⌘Z)"></button>
+22
View File
@@ -125,6 +125,10 @@ h2, h3 { margin: 0; }
.conn { display: inline-flex; align-items: center; gap: 6px; font-size: 13px; color: var(--crit); padding: 0 6px; }
.conn::before { content: ""; width: 8px; height: 8px; border-radius: 50%; background: currentColor; }
.conn.ok { display: none; }
.conn.connecting { color: var(--text-2); }
.conn.connecting::before { animation: conn-pulse 1.2s ease-in-out infinite; }
@keyframes conn-pulse { 50% { opacity: .3; } }
@media (prefers-reduced-motion: reduce) { .conn.connecting::before { animation: none; } }
.save-status { font-size: 13px; color: var(--text-2); padding: 0 6px; white-space: nowrap; }
.menu {
@@ -1215,6 +1219,8 @@ body.phone-ui:not(.peek) #topbar { display: none; }
background: rgba(120, 120, 128, .28); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); color: var(--text); box-shadow: 0 2px 8px rgba(0, 0, 0, .15); }
#phone-menu svg { width: 20px; height: 20px; stroke-width: 2.6; }
body.phone-ui:not(.peek):not(.kiosk) #phone-menu { display: grid; }
#phone-menu { transition: opacity .2s, transform .2s; }
body.menu-away #phone-menu { opacity: 0; transform: translateY(-12px); pointer-events: none; }
/* the install button in the top bar is for phones and tablets; computers have it in the menu */
@media (pointer: fine) { #btn-app { display: none; } }
#btn-app[hidden] { display: none; }
@@ -2142,3 +2148,19 @@ button.fader-input { cursor: pointer; }
.phc-m:not(:has(.phc-bottom)) { justify-content: center; }
.phc-m:not(:has(.phc-bottom)) .phc-name { font-size: 20px; }
.phc-l:not(:has(.phc-val)) .phc-name { font-size: 24px; }
/* the details sheet: the state in words, a day's history, the controls; the technical details folded away */
.mi-head { display: flex; align-items: center; gap: 10px; }
.mi-head h3 { margin: 0; }
.mi-ico { display: flex; font-size: 22px; color: var(--text-2); }
.mi-state { font-size: 34px; font-weight: 400; letter-spacing: -0.02em; margin: 14px 0 2px; }
.mi-graph { margin-top: 14px; }
.mi-graph svg { display: block; width: 100%; height: 90px; }
.mi-graph-legend { display: flex; justify-content: space-between; gap: 8px; margin-top: 4px; }
.mi-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 16px; }
.mi-actions .btn { flex: 1; min-width: 0; justify-content: center; }
.mi-more { margin-top: 16px; }
.mi-more summary { cursor: pointer; font-size: 13px; color: var(--text-2); padding: 6px 0; }
.mi-more .attr-table { margin-top: 4px; }
/* lit tiles keep the lamp's dark text */
.phc { color: inherit; }