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:
+88
-17
@@ -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 @@
|
||||
{"version": "2.2.0", "code": 2}
|
||||
{"version": "2.3.0", "code": 3}
|
||||
|
||||
@@ -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;
|
||||
})();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user