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
+4 -4
View File
@@ -25,7 +25,7 @@ Heading, entity tile, big value (with warning and critical levels), gauge, light
### Photos and Immich
The Photos widget shows one picture or a slideshow that fades between photos. Pick a source:
The Photos widget shows one picture or a slideshow that fades between photos. Tap it to skip to the next photo. Pick a source:
- **Links:** paste one or more image URLs, one per line.
- **Immich:** tick one or more albums. Both your own albums and albums shared with you appear.
@@ -50,14 +50,14 @@ Bins use the integration's own colors (GFT green, Papier blue, PMD maroon, and s
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.
- **Text position:** place the time anywhere in the card with the 3×3 grid: top left, top, top right, left, center, right, bottom left, bottom or bottom right.
- **Background image:** choose image links (one per line) or Immich albums. With more than one photo the clock fades between them; set how often. **Darken the image** keeps the time readable on bright photos (30% by default), and the text turns white with a soft shadow.
- **Background image:** choose image links (one per line) or Immich albums. With more than one photo the clock fades between them; set how often. Tap the clock to skip to the next photo right away. **Darken the image** keeps the time readable on bright photos (30% by default), and the text turns white with a soft shadow.
### Horizontal scroll and swipe cards
These cards hold other cards. Select one in edit mode, pick a card type under **Cards** and press **Add**. The new card opens straight away so you can set it up; **←** takes you back. Use the arrows to reorder cards, and open a card to change or remove it.
- **Horizontal scroll** puts its cards in a row you can scroll sideways, by touch, trackpad or the arrow buttons that appear on hover. Set the width of the cards and an optional title. The row keeps its scroll position when something inside it changes.
- **Swipe cards** shows one card at a time: swipe, or tap the dots underneath. It can move to the next card automatically every few seconds. Touching it pauses that for 20 seconds, and it stops completely when the system's "reduce motion" setting is on.
- **Swipe cards** shows one card at a time, filling the whole card. Swipe with a finger or drag with the mouse: the card follows along, resists at the first and last card, and a quick flick is enough. The left and right arrow keys work too. Small dots appear at the bottom while you swipe or hover, and hide again after a few seconds; tap one to jump to that card. It can also move to the next card automatically every few seconds. Touching it pauses that for 20 seconds, and it stops completely when the system's "reduce motion" setting is on.
Cards inside a container work like anywhere else, and appliance cards inside them still hide themselves when the appliance is off.
@@ -67,7 +67,7 @@ When the light card points at a light group, it also lists every light in the gr
### Vacuum
Shows the robot's status and battery, buttons to start or pause, send it back to the dock and locate it, and the suction power levels it supports. Optionally add a map: pick the camera or image entity that your vacuum integration provides (for example Roborock or Valetudo), and it refreshes every few seconds. If the battery level is a separate sensor in your integration, pick it under **Battery sensor**.
The card grows with its size. On a small card it's a compact row with the status, battery and buttons. On a bigger card, the robot sits in the middle with a battery ring around it, a pulse while it cleans and a bolt while it charges. Below it are tiles for battery, cleaned area, cleaning time and any extra sensors you add (such as filter or brush life), the suction power levels, and labelled buttons to start or pause, send it back to the dock and locate it. Optionally add a map: pick the camera or image entity that your vacuum integration provides (for example Roborock or Valetudo), and it refreshes every few seconds. If the battery level is a separate sensor in your integration, pick it under **Battery sensor**.
### Media players
+1 -1
View File
@@ -1,5 +1,5 @@
name: Dashboard Studio
version: "1.8.0"
version: "1.9.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
+3
View File
@@ -51,6 +51,9 @@ class MockHA:
entity_id=["light.living_room", "light.kitchen", "light.reading_lamp"]),
entity("vacuum.robot", "docked", friendly_name="Robot Vacuum", battery_level=82, fan_speed="standard",
fan_speed_list=["quiet", "standard", "turbo", "max"], status="Charging"),
entity("sensor.robot_vacuum_cleaned_area", "23", friendly_name="Robot Vacuum Cleaned area", unit_of_measurement="m²"),
entity("sensor.robot_vacuum_cleaning_time", "34", friendly_name="Robot Vacuum Cleaning time", unit_of_measurement="min", device_class="duration"),
entity("sensor.robot_vacuum_filter_left", "72", friendly_name="Robot Vacuum Filter left", unit_of_measurement="%"),
entity("switch.coffee_machine", "off", friendly_name="Coffee Machine"),
entity("fan.office", "off", friendly_name="Office Fan"),
entity("sensor.living_room_temperature", "21.4", friendly_name="Living Room Temperature", unit_of_measurement="°C", device_class="temperature"),
+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);
},
};