Dashboard Studio 1.9.0: real swiping, tap for next photo, bigger vacuum card

- Swipe card: pointer-driven drag for mouse and touch with resistance
  at the ends and flick detection, keyboard arrows, cards fill the whole
  card, page dots float and hide after a few seconds; updates wait until
  a swipe ends and the release never presses a button underneath
- Tapping the clock background or a Photos card skips to the next photo
- Media card: removing players works inside scroll/swipe cards, and
  pre-1.5 single-player cards are migrated into the list
- Vacuum card grows with its size: robot with battery ring, cleaning
  pulse and charging badge, stat tiles, suction power and labelled
  buttons; compact layout on small cards

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-25 23:55:56 +01:00
parent e73c688017
commit aa75a572c5
7 changed files with 214 additions and 68 deletions
+15 -6
View File
@@ -173,6 +173,12 @@
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;
// Media cards from before 1.5 kept one player in cfg.entity; move it into the list so it can be edited.
const fix = (w) => {
if (w.type === "media" && w.cfg.entity) { w.cfg.entities = [...new Set([w.cfg.entity, ...(w.cfg.entities || [])])]; delete w.cfg.entity; }
(w.cfg.children || []).forEach(fix);
};
d.tabs.forEach((t) => t.widgets.forEach(fix));
return d;
}
@@ -505,6 +511,7 @@
}
const ctxFor = (w) => ({
ha, w, api, cache: (caches[w.id] ||= {}),
rerender: () => renderWidget(w),
isVisible: (child) => editMode || isVisible(child),
// Containers draw placeholders (.w.child); this fills each with its card.
renderChildren: (el) => el.querySelectorAll(".w.child[data-id]").forEach((ph) => {
@@ -556,6 +563,7 @@
for (const w of affected) {
const el = gridEl.querySelector(`.w[data-id="${w.id}"]`);
if (interacting && el?.contains(document.activeElement)) continue; // don't yank a slider mid-drag
if (caches[w.id]?.busy) { caches[w.id].dirty = true; continue; } // mid-swipe: redraw when the swipe ends
const focused = el?.contains(document.activeElement) ? document.activeElement.dataset.act : null;
renderWidget(w, el);
if (focused) el.querySelector(`[data-act="${focused}"]`)?.focus();
@@ -1078,10 +1086,11 @@
const track = a.closest(".hs").querySelector(".hs-track");
track.scrollBy({ left: (act === "hs-next" ? 1 : -1) * track.clientWidth * 0.8, behavior: reducedMotion() ? "auto" : "smooth" });
}
else if (act === "sw-go") {
const track = a.closest(".sw").querySelector(".sw-track");
track.scrollTo({ left: Number(a.dataset.v) * track.clientWidth, behavior: reducedMotion() ? "auto" : "smooth" });
else if (act === "photo-next") {
const c = caches[a.closest(".w").dataset.id];
(c?.bg?.next || c?.next)?.(); // the clock keeps its slideshow under .bg
}
else if (act === "sw-go") caches[a.closest(".w:not(.child)").dataset.id]?.go?.(Number(a.dataset.v));
else if (act === "svc") { const [d, s] = a.dataset.v.split("."); ha.callService(d, s, {}, eid); }
else if (act === "button") {
const c = findWidget(a.closest(".w").dataset.id).cfg;
@@ -1436,9 +1445,9 @@
}
const del = e.target.closest("[data-ent-del]");
if (del) {
const w = tab().widgets.find((x) => x.id === selectedId);
mutate(() => w.cfg[del.dataset.k].splice(Number(del.dataset.entDel), 1), null, { rerender: false });
renderWidget(w); renderInspector(); return;
const w = editTarget(); // may be a card inside a scroll or swipe card
mutate(() => (w.cfg[del.dataset.k] || []).splice(Number(del.dataset.entDel), 1), null, { rerender: false });
renderWidget(selectedWidget()); renderInspector(); return;
}
const childEdit = e.target.closest("[data-child-edit]"), childBtn = e.target.closest("[data-child-op]");
if (childEdit) { childIndex = Number(childEdit.dataset.childEdit); renderInspector(); return; }
+4
View File
@@ -268,6 +268,10 @@
"Show suction power": "Toon zuigkracht", "Battery {0}%": "Accu {0}%", "Cleaning map": "Schoonmaakkaart", "Suction power": "Zuigkracht",
"Start cleaning": "Begin met schoonmaken", "Send back to the dock": "Terug naar het dock", "Locate (makes a sound)": "Zoeken (maakt geluid)",
"Cleaning…": "Aan het schoonmaken…", "On its way to the dock": "Onderweg naar het dock",
"Next photo": "Volgende foto", "Show page dots while swiping": "Toon paginapuntjes tijdens het vegen",
"Battery": "Accu", "Cleaned area": "Schoongemaakt oppervlak", "Cleaning time": "Schoonmaaktijd",
"More sensors, e.g. filter or brush life": "Meer sensoren, bijv. levensduur van filter of borstel",
"Resume": "Hervat", "Start": "Start", "Dock": "Dock", "Locate": "Zoek",
"Today": "Vandaag", "Tomorrow": "Morgen", "All day": "Hele dag", "Busy": "Bezet",
};
+59 -17
View File
@@ -260,6 +260,7 @@ textarea.input { resize: vertical; min-height: 76px; font-family: ui-monospace,
.w-body { position: absolute; inset: 0; padding: 16px; display: flex; flex-direction: column; min-height: 0; }
.w-body.flush { padding: 0; }
.w.transparent .w-body, .surface-plain .w .w-body { padding: 6px 4px; }
.w.transparent .w-body.flush, .surface-plain .w .w-body.flush { padding: 0; }
.pad { padding: 16px; height: 100%; display: flex; }
/* Lit: a light that is on turns its tile into lamplight — the signature moment. */
@@ -532,14 +533,23 @@ body.kiosk #tabnav { top: 6px; max-height: calc(100vh - 20px); }
.hs.at-start .hs-nav.prev, .hs.at-end .hs-nav.next { opacity: 0; pointer-events: none; }
@media (hover: none) { .hs-nav { display: none; } }
.i.back { transform: rotate(90deg); } .i.fwd { transform: rotate(-90deg); }
.sw { height: 100%; display: flex; flex-direction: column; gap: 8px; }
.sw-track { flex: 1; min-height: 0; display: flex; overflow-x: auto; overflow-y: hidden; scroll-snap-type: x mandatory; scrollbar-width: none; overscroll-behavior-x: contain; border-radius: var(--d-radius, 20px); }
.sw-track::-webkit-scrollbar { display: none; }
.sw-track > .w.child { width: 100%; scroll-snap-align: center; scroll-snap-stop: always; }
.sw-dots { flex: none; display: flex; justify-content: center; gap: 2px; }
.sw { position: relative; height: 100%; overflow: hidden; border-radius: var(--d-radius, 20px); touch-action: pan-y; }
.sw-track { display: flex; height: 100%; will-change: transform; user-select: none; -webkit-user-select: none; outline: none; }
.sw-track > .w.child { flex: 0 0 100%; width: 100%; }
.sw.dragging .sw-track { cursor: grabbing; }
.sw.dragging .w.child { pointer-events: none; }
.sw-track:focus-visible { box-shadow: inset var(--focus); }
/* Dots float over the cards and only show while swiping, hovering or focused. */
.sw-dots {
position: absolute; left: 50%; bottom: 10px; z-index: 4; transform: translate(-50%, 6px);
display: flex; gap: 0; padding: 2px 6px; border-radius: 99px;
background: var(--glass); backdrop-filter: blur(16px) saturate(1.3); -webkit-backdrop-filter: blur(16px) saturate(1.3);
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.18); opacity: 0; pointer-events: none; transition: opacity .25s, transform .25s;
}
.sw.dots-on .sw-dots, .sw:has(:focus-visible) .sw-dots { opacity: 1; transform: translate(-50%, 0); pointer-events: auto; }
.sw-dot { width: 22px; height: 22px; display: grid; place-items: center; }
.sw-dot::after { content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--d-text-2); opacity: .45; transition: width .2s, opacity .2s; }
.sw-dot[aria-selected="true"]::after { width: 18px; border-radius: 4px; opacity: 1; background: var(--d-text); }
.sw-dot::after { content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--text); opacity: .35; transition: width .2s, opacity .2s; }
.sw-dot[aria-selected="true"]::after { width: 18px; border-radius: 4px; opacity: 1; }
.btn.back { margin: 0 0 10px; padding-left: 8px; }
.child-list { display: flex; flex-direction: column; gap: 6px; }
.child-item { display: flex; gap: 4px; align-items: center; }
@@ -560,6 +570,7 @@ body.kiosk #tabnav { top: 6px; max-height: calc(100vh - 20px); }
.clock.pos-top-right, .clock.pos-right, .clock.pos-bottom-right { align-items: flex-end; text-align: right; }
.clock-bg { position: absolute; inset: 0; margin: -16px; z-index: 0; border-radius: inherit; }
.w.transparent .clock-bg, .surface-plain .clock-bg { margin: -6px -4px; }
.clock.on-image, .photos[data-act] { cursor: pointer; }
.clock-dim { position: absolute; inset: 0; margin: -16px; z-index: 0; background: linear-gradient(to bottom, rgba(0, 0, 0, calc(var(--dim) * 0.8)), rgba(0, 0, 0, var(--dim))); }
.clock.on-image { color: #fff; --d-text-2: rgba(255, 255, 255, 0.86); text-shadow: 0 1px 12px rgba(0, 0, 0, 0.45); }
.clock.on-image .clock-wx { background: rgba(0, 0, 0, 0.32); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); }
@@ -573,17 +584,48 @@ body.kiosk #tabnav { top: 6px; max-height: calc(100vh - 20px); }
.light-members { flex: 1; min-height: 0; margin: 0 -6px; }
.light:has(.light-members) { justify-content: flex-start; }
/* vacuum */
.vac { display: flex; flex-direction: column; height: 100%; gap: 10px; }
.vac-batt { margin-left: auto; display: inline-flex; align-items: center; gap: 4px; font-size: 14px; font-weight: 600; color: var(--d-text-2); }
/* vacuum: compact on small cards, a robot with stats on bigger ones */
.vac { display: flex; flex-direction: column; height: 100%; gap: 12px; }
.vac-head { display: flex; align-items: center; gap: 12px; min-width: 0; }
.vac-batt { margin-left: auto; display: inline-flex; align-items: center; gap: 4px; font-size: 14px; font-weight: 600; color: var(--d-text-2); white-space: nowrap; }
.vac-batt .i { font-size: 18px; }
.vac-map { flex: 1; min-height: 0; width: 100%; object-fit: contain; border-radius: 12px; background: var(--d-fill); }
.vac-spacer { flex: 1; }
.vac .ctrls { display: flex; gap: 8px; align-items: center; }
.vac .round:disabled { opacity: .35; cursor: default; }
.vac-hint { margin-left: 4px; }
.vac.is-error .state { color: var(--crit); font-weight: 600; }
@container (max-height: 220px) { .vac-map { display: none; } .vac-spacer { display: none; } }
.vac-hero { flex: 1; min-height: 0; display: flex; align-items: center; justify-content: center; gap: 18px; }
.vac-map { width: 100%; height: 100%; object-fit: contain; border-radius: 14px; background: var(--d-fill); }
.vac-robot { position: relative; width: clamp(84px, 32cqh, 150px); aspect-ratio: 1; flex: none; display: grid; place-items: center; }
.vac-ring { position: absolute; inset: 0; transform: rotate(-90deg); }
.vac-ring circle { fill: none; stroke-width: 5; }
.vac-ring .track { stroke: var(--d-fill); }
.vac-ring .fill { stroke: var(--ok); stroke-linecap: round; transition: stroke-dasharray .6s; }
.vac-ring .fill.low { stroke: var(--crit); }
.vac-robot-ico { position: relative; z-index: 1; width: 62%; aspect-ratio: 1; border-radius: 50%; display: grid; place-items: center; font-size: clamp(30px, 12cqh, 54px); background: var(--d-fill); }
.state-cleaning .vac-robot-ico, .state-returning .vac-robot-ico { background: var(--accent-w); color: var(--d-on-accent, #fff); }
.vac-pulse { position: absolute; inset: 19%; border-radius: 50%; }
.vac-robot.is-cleaning .vac-pulse { animation: vac-pulse 1.8s ease-out infinite; border: 2px solid var(--accent-w); }
@keyframes vac-pulse { from { transform: scale(1); opacity: .7; } to { transform: scale(1.45); opacity: 0; } }
.vac-charge { position: absolute; z-index: 2; right: 8%; bottom: 8%; width: 28%; aspect-ratio: 1; border-radius: 50%; display: grid; place-items: center; background: var(--ok); color: #04210f; font-size: clamp(14px, 4cqh, 20px); box-shadow: 0 0 0 3px var(--w-bg, var(--d-tile)); }
.vac-hero-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.vac-headline { font-size: clamp(18px, 6cqh, 26px); font-weight: 700; line-height: 1.15; }
.vac-stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(96px, 1fr)); gap: 8px; flex: none; }
.vac-stat { display: grid; grid-template-columns: auto 1fr; grid-template-rows: auto auto; column-gap: 8px; align-items: center; padding: 10px 12px; border-radius: 14px; background: var(--d-fill); min-width: 0; }
.vac-stat .i { grid-row: span 2; font-size: 18px; color: var(--d-text-2); }
.vac-stat-v { font-size: 17px; font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.vac-stat-l { font-size: 12px; color: var(--d-text-2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.vac-fan { flex: none; background: var(--d-fill); }
.vac-fan button { color: inherit; display: grid; place-items: center; }
.vac-fan button[aria-pressed="true"] { background: var(--d-tile); color: var(--d-text); }
.vac-actions { display: flex; gap: 8px; flex: none; }
.vac-btn { flex: 1; display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-height: 44px; padding: 0 12px; border-radius: 14px; background: var(--d-fill); font-size: 14px; font-weight: 600; }
.vac-btn .i { font-size: 18px; flex: none; }
.vac-btn.primary { background: var(--d-text); color: var(--d-tile); }
.vac-btn:disabled { opacity: .4; cursor: default; }
.vac.state-error .state { color: var(--crit); font-weight: 600; }
/* the more room, the more it shows */
@container (max-width: 330px) { .vac-btn span { display: none; } .vac-hero-text { display: none; } }
@container (max-height: 370px) { .vac-stats { display: none; } }
@container (max-height: 330px) { .vac-fan { display: none; } }
@container (max-height: 250px) { .vac-hero { display: none; } }
@container (min-height: 251px) { .vac-head .vac-batt, .vac-head .state { display: none; } }
@container (min-height: 251px) and (max-width: 330px) { .vac-head .vac-batt { display: inline-flex; } }
/* photos */
.photos { position: absolute; inset: 0; background: #000; overflow: hidden; }
+128 -40
View File
@@ -358,7 +358,8 @@
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 pos-${pos} ${bg ? "on-image" : ""}" style="--dim:${Math.min(90, Math.max(0, Number(c.dim ?? 30))) / 100}">
return `<div class="clock pos-${pos} ${bg ? "on-image" : ""}" ${bg ? 'data-act="photo-next"' : ""} style="--dim:${Math.min(90, Math.max(0, Number(c.dim ?? 30))) / 100}">
${bg ? `<button class="sr-only" data-act="photo-next">${t("Next photo")}</button>` : ""}
${bg ? `<div class="photos clock-bg" aria-hidden="true"><img class="ph-layer" alt=""><img class="ph-layer" alt=""></div><div class="clock-dim"></div>` : ""}
<div class="clock-content"><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></div>`;
},
@@ -477,37 +478,66 @@
};
// ---------------------------------------------------------------- vacuum
// One layout that grows with the card: compact on small cards; a robot with a battery ring,
// stats and labelled buttons on bigger ones (see the container queries in style.css).
W.vacuum = {
label: "Vacuum", icon: "vacuum", size: [4, 3],
label: "Vacuum", icon: "vacuum", size: [4, 5],
fields: [
{ k: "entity", t: "entity", l: "Vacuum", domains: ["vacuum"] },
{ k: "name", t: "text", l: "Name", ph: "Entity name" },
{ k: "map", t: "entity", l: "Map camera (optional)", domains: ["camera", "image"] },
{ k: "refresh", t: "number", l: "Refresh the map every (seconds)", d: 5, when: (c) => !!c.map },
{ k: "battery", t: "entity", l: "Battery sensor (if not on the vacuum)", domains: ["sensor"] },
{ k: "area", t: "entity", l: "Cleaned area", domains: ["sensor"] },
{ k: "duration", t: "entity", l: "Cleaning time", domains: ["sensor"] },
{ k: "entities", t: "entities", l: "More sensors, e.g. filter or brush life", domains: ["sensor", "binary_sensor"] },
{ k: "fan", t: "check", l: "Show suction power", d: true },
],
entities: (c) => [c.entity, c.battery].filter(Boolean),
entities: (c) => [c.entity, c.battery, c.area, c.duration, ...(c.entities || [])].filter(Boolean),
render(c, { ha }) {
const s = ha.states[c.entity];
if (!s) return missing(c.entity, "a vacuum");
const a = s.attributes, e = esc(c.entity);
const cleaning = s.state === "cleaning", busy = cleaning || s.state === "returning";
const a = s.attributes, e = esc(c.entity), name = friendly(s, c.name);
const cleaning = s.state === "cleaning", returning = s.state === "returning";
const batt = Number(c.battery ? ha.states[c.battery]?.state : a.battery_level);
const status = a.status && String(a.status).toLowerCase() !== s.state ? `${st(s.state)} · ${a.status}` : st(s.state);
const hasBatt = isFinite(batt);
const charging = s.state === "docked" && hasBatt && batt < 100;
const headline = cleaning ? t("Cleaning…") : returning ? t("On its way to the dock") : charging ? t("Charging") : st(s.state);
const detail = a.status && !headline.toLowerCase().includes(String(a.status).toLowerCase()) ? a.status : "";
const mapUrl = c.map ? (domainOf(c.map) === "image" ? ha.states[c.map]?.attributes.entity_picture : `api/camera/${encodeURIComponent(c.map)}?t=${Date.now()}`) : "";
const speeds = c.fan !== false && Array.isArray(a.fan_speed_list) ? a.fan_speed_list : [];
return `<div class="vac ${s.state === "error" ? "is-error" : ""}">
<div class="tile-row"><span class="badge">${Icons.svg("vacuum")}</span>
<span class="tile-text"><span class="name">${esc(friendly(s, c.name))}</span><span class="state">${esc(status)}</span></span>
${isFinite(batt) ? `<span class="vac-batt" aria-label="${t("Battery {0}%", Math.round(batt))}">${Icons.svg("battery")}<span class="num">${Math.round(batt)}%</span></span>` : ""}</div>
${mapUrl ? `<img class="vac-map" src="${esc(mapUrl)}" ${domainOf(c.map) === "camera" ? `data-cam="${esc(c.map)}" data-refresh="${Number(c.refresh) || 5}"` : ""} alt="${t("Cleaning map")}">` : `<div class="vac-spacer"></div>`}
${speeds.length ? `<div class="chips" role="group" aria-label="${t("Suction power")}">${speeds.map((f) => `<button class="chip wbtn" aria-pressed="${f === a.fan_speed}" data-act="vac-fan" data-e="${e}" data-v="${esc(f)}">${esc(cap(f))}</button>`).join("")}</div>` : ""}
<div class="ctrls">
<button class="round wbtn primary" data-act="svc" data-e="${e}" data-v="vacuum.${cleaning ? "pause" : "start"}" aria-label="${cleaning ? t("Pause") : t("Start cleaning")}">${Icons.svg(cleaning ? "pause" : "play")}</button>
<button class="round wbtn" data-act="svc" data-e="${e}" data-v="vacuum.return_to_base" aria-label="${t("Send back to the dock")}" ${s.state === "docked" ? "disabled" : ""}>${Icons.svg("home")}</button>
<button class="round wbtn" data-act="svc" data-e="${e}" data-v="vacuum.locate" aria-label="${t("Locate (makes a sound)")}">${Icons.svg("pin")}</button>
${busy ? `<span class="wt vac-hint">${cleaning ? t("Cleaning…") : t("On its way to the dock")}</span>` : ""}
// battery ring around the robot
const R = 46, C = 2 * Math.PI * R, frac = hasBatt ? Math.max(0, Math.min(1, batt / 100)) : 0;
const ring = `<svg class="vac-ring" viewBox="0 0 100 100" aria-hidden="true"><circle cx="50" cy="50" r="${R}" class="track"/>
${hasBatt ? `<circle cx="50" cy="50" r="${R}" class="fill ${batt <= 20 ? "low" : ""}" stroke-dasharray="${(C * frac).toFixed(1)} ${C.toFixed(1)}"/>` : ""}</svg>`;
const stat = (label, value, ico) => `<div class="vac-stat">${Icons.svg(ico)}<span class="vac-stat-v num">${esc(value)}</span><span class="vac-stat-l">${esc(label)}</span></div>`;
const stats = [
hasBatt ? stat(t("Battery"), `${Math.round(batt)}%`, "battery") : "",
c.area && ha.states[c.area] ? stat(t("Cleaned area"), fmtState(ha.states[c.area]), "tile") : "",
c.duration && ha.states[c.duration] ? stat(t("Cleaning time"), fmtState(ha.states[c.duration]), "clock") : "",
...(c.entities || []).map((x) => ha.states[x] && stat(friendly(ha.states[x]).replace(new RegExp(`^${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*`, "i"), ""), fmtState(ha.states[x]), "pulse")),
].filter(Boolean).join("");
const btn = (svc, label, ico, primary, disabled) => `<button class="vac-btn wbtn ${primary ? "primary" : ""}" data-act="svc" data-e="${e}" data-v="vacuum.${svc}" ${disabled ? "disabled" : ""}>${Icons.svg(ico)}<span>${esc(label)}</span></button>`;
return `<div class="vac state-${esc(s.state)}">
<div class="vac-head"><span class="badge">${Icons.svg("vacuum")}</span>
<span class="tile-text"><span class="name">${esc(name)}</span><span class="state">${esc(headline)}${detail ? ` · ${esc(detail)}` : ""}</span></span>
${hasBatt ? `<span class="vac-batt">${Icons.svg(charging ? "bolt" : "battery")}<span class="num">${Math.round(batt)}%</span></span>` : ""}</div>
<div class="vac-hero">
${mapUrl ? `<img class="vac-map" src="${esc(mapUrl)}" ${domainOf(c.map) === "camera" ? `data-cam="${esc(c.map)}" data-refresh="${Number(c.refresh) || 5}"` : ""} alt="${t("Cleaning map")}">`
: `<div class="vac-robot ${cleaning ? "is-cleaning" : ""}">${ring}<span class="vac-pulse" aria-hidden="true"></span><span class="vac-robot-ico">${Icons.svg("vacuum")}</span>
${charging ? `<span class="vac-charge" aria-hidden="true">${Icons.svg("bolt")}</span>` : ""}</div>
<div class="vac-hero-text"><span class="vac-headline">${esc(headline)}</span>${hasBatt ? `<span class="wt">${t("Battery {0}%", Math.round(batt))}</span>` : ""}</div>`}
</div>
${stats ? `<div class="vac-stats">${stats}</div>` : ""}
${speeds.length ? `<div class="seg vac-fan" role="group" aria-label="${t("Suction power")}">${speeds.map((f) => `<button class="wbtn" aria-pressed="${f === a.fan_speed}" data-act="vac-fan" data-e="${e}" data-v="${esc(f)}">${esc(cap(f))}</button>`).join("")}</div>` : ""}
<div class="vac-actions">
${btn(cleaning ? "pause" : "start", cleaning ? t("Pause") : returning ? t("Resume") : t("Start"), cleaning ? "pause" : "play", true)}
${btn("return_to_base", t("Dock"), "home", false, s.state === "docked")}
${btn("locate", t("Locate"), "pin")}
</div></div>`;
},
lit: (c, { ha }) => (["cleaning", "returning"].includes(ha.states[c.entity]?.state) ? "on" : false),
@@ -694,7 +724,8 @@
render(c) {
const empty = c.source === "immich" ? (c.albums?.length ? "" : t("Choose one or more Immich albums in the inspector.")) : urlList(c).length ? "" : t("Add image URLs in the inspector.");
if (empty) return `<div class="pad"><div class="empty">${empty}</div></div>`;
return `<div class="photos" style="--fit:${esc(c.fit || "cover")}" role="img" aria-label="${esc(c.alt || t("Photo slideshow"))}">
return `<div class="photos" data-act="photo-next" style="--fit:${esc(c.fit || "cover")}" role="img" aria-label="${esc(c.alt || t("Photo slideshow"))}">
<button class="sr-only" data-act="photo-next">${t("Next photo")}</button>
<img class="ph-layer" alt=""><img class="ph-layer" alt=""><div class="ph-date"></div><div class="ph-msg"><div class="empty">${t("Loading photos…")}</div></div></div>`;
},
after(el, c, ctx) {
@@ -742,6 +773,8 @@
next.src = item.src;
if (cache.items.length > 1) cache.timer = setTimeout(show, Math.max(3, Number(o.interval) || 20) * 1000);
};
// Tapping the card skips ahead; the automatic timer restarts from there.
cache.next = cache.items.length > 1 ? () => { clearTimeout(cache.timer); show(); } : null;
show();
}
@@ -936,46 +969,101 @@
label: "Swipe cards", icon: "swipe", size: [4, 3], container: true, transparent: true, flush: true,
fields: [
{ k: "autoplay", t: "number", l: "Next card every (seconds)", d: 0, ph: "0 = off" },
{ k: "dots", t: "check", l: "Show page dots", d: true },
{ k: "dots", t: "check", l: "Show page dots while swiping", d: true },
],
render(c, ctx) {
const kids = slots(c, ctx);
if (!kids.length) return noKids(c);
const dots = c.dots !== false && kids.length > 1
? `<div class="sw-dots" role="tablist" aria-label="${t("Cards")}">${kids.map((_, i) => `<button class="sw-dot wbtn" role="tab" data-act="sw-go" data-v="${i}" aria-label="${t("Card {0} of {1}", i + 1, kids.length)}" aria-selected="false"></button>`).join("")}</div>` : "";
return `<div class="sw ${dots ? "with-dots" : ""}"><div class="sw-track">${kids.map(slot).join("")}</div>${dots}</div>`;
return `<div class="sw" role="group" aria-roledescription="carousel" aria-label="${t("Swipe cards")}">
<div class="sw-track" tabindex="0">${kids.map(slot).join("")}</div>${dots}</div>`;
},
// Swiping is done by hand (pointer events + transform), so it works with a mouse as well as touch,
// follows the finger, resists at the ends and settles on the nearest card or in the flick direction.
after(el, c, ctx) {
ctx.renderChildren(el);
const track = el.querySelector(".sw-track");
const sw = el.querySelector(".sw"), track = el.querySelector(".sw-track");
if (!track) return;
const cache = ctx.cache, n = track.children.length;
const page = () => Math.round(track.scrollLeft / Math.max(1, track.clientWidth));
const mark = () => el.querySelectorAll(".sw-dot").forEach((d, i) => d.setAttribute("aria-selected", String(i === page())));
track.scrollLeft = (Math.min(cache.page || 0, n - 1)) * track.clientWidth;
mark();
// Only settled positions count (scrollend), so an autoplay step in flight isn't overwritten.
track.addEventListener("scroll", mark, { passive: true });
const settle = () => { cache.page = page(); mark(); };
if ("onscrollend" in window) track.addEventListener("scrollend", settle);
else { let t0; track.addEventListener("scroll", () => { clearTimeout(t0); t0 = setTimeout(settle, 150); }, { passive: true }); }
const reduce = () => matchMedia("(prefers-reduced-motion: reduce)").matches;
let page = Math.min(cache.page || 0, n - 1);
// One timer per card that survives redraws (cards inside update often); it always
// works on the latest track, which each redraw records in the cache.
cache.track = track;
track.addEventListener("pointerdown", () => { cache.pausedUntil = Date.now() + 20000; }, { passive: true });
let hideTimer;
const showDots = () => {
sw.classList.add("dots-on");
clearTimeout(hideTimer);
hideTimer = setTimeout(() => { if (!sw.querySelector(":focus-visible")) sw.classList.remove("dots-on"); }, 2500);
};
const mark = () => el.querySelectorAll(".sw-dot").forEach((d, i) => d.setAttribute("aria-selected", String(i === page)));
const go = (p, animate = true) => {
page = Math.max(0, Math.min(n - 1, p));
cache.page = page;
track.style.transition = animate && !reduce() ? "transform .38s cubic-bezier(.2,.8,.2,1)" : "none";
track.style.transform = `translateX(${-page * 100}%)`;
mark();
};
cache.go = (p) => { go(p); showDots(); };
cache.n = n;
go(page, false);
let drag = null;
track.addEventListener("pointerdown", (e) => {
if ((e.pointerType === "mouse" && e.button !== 0) || e.target.closest("input, select, textarea") || n < 2) return;
drag = { x: e.clientX, y: e.clientY, t: performance.now(), dx: 0, id: e.pointerId, on: false };
cache.pausedUntil = Date.now() + 20000;
});
track.addEventListener("pointermove", (e) => {
if (!drag || e.pointerId !== drag.id) return;
const dx = e.clientX - drag.x, dy = e.clientY - drag.y;
if (!drag.on) {
if (Math.abs(dx) > 8 && Math.abs(dx) > Math.abs(dy)) { // horizontal: it's a swipe
drag.on = true; cache.busy = true;
track.setPointerCapture(e.pointerId);
track.style.transition = "none";
sw.classList.add("dragging");
showDots();
} else if (Math.abs(dy) > 8) { drag = null; return; } // vertical: let the page scroll
else return;
}
drag.dx = dx;
const atEdge = (page === 0 && dx > 0) || (page === n - 1 && dx < 0);
track.style.transform = `translateX(calc(${-page * 100}% + ${atEdge ? dx / 3 : dx}px))`;
});
const end = () => {
const d = drag;
drag = null;
if (!d?.on) return;
sw.classList.remove("dragging");
cache.busy = false;
const speed = d.dx / Math.max(1, performance.now() - d.t); // px per ms
let p = page;
if (d.dx < -sw.clientWidth / 4 || speed < -0.35) p++;
else if (d.dx > sw.clientWidth / 4 || speed > 0.35) p--;
go(p);
showDots();
cache.swallowClick = true; // the release after a swipe must not press a button underneath
if (cache.dirty) { cache.dirty = false; setTimeout(() => ctx.rerender(), 400); }
};
track.addEventListener("pointerup", end);
track.addEventListener("pointercancel", end);
track.addEventListener("click", (e) => { if (cache.swallowClick) { cache.swallowClick = false; e.stopPropagation(); e.preventDefault(); } }, true);
track.addEventListener("keydown", (e) => {
if (e.key === "ArrowRight") { e.preventDefault(); cache.go(page + 1); }
if (e.key === "ArrowLeft") { e.preventDefault(); cache.go(page - 1); }
});
sw.addEventListener("pointermove", (e) => { if (e.pointerType === "mouse") showDots(); });
sw.addEventListener("focusin", () => { if (sw.querySelector(":focus-visible")) showDots(); });
// One autoplay timer per card that survives redraws; it always drives the latest card.
const every = Math.max(3, Number(c.autoplay) || 0) * 1000;
if (!(Number(c.autoplay) > 0) || n < 2) { clearInterval(cache.timer); cache.timer = null; return; }
if (cache.timer && cache.every === every) return;
clearInterval(cache.timer);
cache.every = every;
cache.timer = setInterval(() => {
const tr = cache.track;
if (!tr?.isConnected || Date.now() < (cache.pausedUntil || 0) || document.body.classList.contains("editing")) return;
const count = tr.children.length, next = ((cache.page || 0) + 1) % count;
cache.page = next; // a redraw mid-animation then lands on the new card, not the old one
const reduce = matchMedia("(prefers-reduced-motion: reduce)").matches;
tr.scrollTo({ left: next * tr.clientWidth, behavior: reduce ? "auto" : "smooth" });
if (Date.now() < (cache.pausedUntil || 0) || cache.busy || document.body.classList.contains("editing") || reduce()) return;
cache.go(((cache.page || 0) + 1) % cache.n); // cache.go always drives the latest drawing of this card
}, every);
},
};