Files
ha-dashboard-studio/dashboard_studio/web/widgets.js
T
veenm aa75a572c5 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>
2026-10-25 23:55:56 +01:00

1074 lines
69 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* Widget library. Each widget type declares:
* label, icon, size [w, h], fields (inspector schema), render(cfg, ctx) -> HTML
* optional: entities(cfg) -> ids it depends on, after(el, cfg, ctx), flush, transparent,
* lit(cfg, ctx) -> "lamp" | "on" | false, lamp(cfg, ctx) -> CSS color for a lit light
* Interactive elements are real <button>s carrying data-act / data-e, handled by app.js.
*/
(function () {
const Icons = window.Icons;
const { t, state: st, locale: LOCALE } = window.I18n;
const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
const domainOf = (eid) => (eid || "").split(".")[0];
const ON_STATES = new Set(["on", "open", "opening", "unlocked", "playing", "home", "heat", "cool", "auto", "heat_cool", "dry", "fan_only", "cleaning"]);
const TOGGLE_DOMAINS = new Set(["light", "switch", "fan", "input_boolean", "automation", "cover", "lock", "media_player", "scene", "script", "button", "input_button", "siren", "humidifier", "vacuum", "climate"]);
const STATELESS = new Set(["scene", "script", "button", "input_button"]);
const isOn = (s) => !!s && ON_STATES.has(s.state);
const friendly = (s, override) => override || s?.attributes?.friendly_name || s?.entity_id || "";
// ------------------------------------------------------------ icons
const DOMAIN_ICON = {
light: "bulb", switch: "plug", fan: "fan", input_boolean: "toggle", automation: "robot", cover: "window",
media_player: "speaker", climate: "thermometer", scene: "sparkles", script: "script", camera: "camera",
person: "person", device_tracker: "pin", vacuum: "vacuum", button: "button", input_button: "button",
sun: "sun", alarm_control_panel: "shield", weather: "cloud-sun", input_number: "number", number: "number", counter: "number",
};
const CLASS_ICON = {
temperature: "thermometer", humidity: "droplet", moisture: "droplet", power: "bolt", energy: "bolt", voltage: "bolt",
current: "bolt", battery: "battery", illuminance: "sun", pressure: "gauge", gas: "flame", water: "droplet",
distance: "road", speed: "wind", wind_speed: "wind", co2: "pulse", pm25: "fog", aqi: "fog",
};
const BINARY_ICON = { door: ["door", "door-open"], garage_door: ["garage", "garage"], window: ["window", "window"], opening: ["door", "door-open"], motion: ["person", "person"], occupancy: ["person", "person"], presence: ["home", "home"], smoke: ["flame", "flame"], moisture: ["droplet", "droplet"], plug: ["cable", "cable"], battery_charging: ["battery", "bolt"], lock: ["lock", "unlock"] };
const WX = { "clear-night": "moon", cloudy: "cloud", exceptional: "shield", fog: "fog", hail: "snow", lightning: "storm", "lightning-rainy": "storm", partlycloudy: "cloud-sun", pouring: "rain", rainy: "rain", snowy: "snow", "snowy-rainy": "snow", sunny: "sun", windy: "wind", "windy-variant": "wind" };
function iconName(s) {
if (!s) return "pulse";
const d = domainOf(s.entity_id), dc = s.attributes.device_class;
if (d === "binary_sensor") return (BINARY_ICON[dc] || ["pulse", "pulse"])[isOn(s) ? 1 : 0];
if (d === "sensor") return CLASS_ICON[dc] || "pulse";
if (d === "lock") return s.state === "locked" ? "lock" : "unlock";
if (d === "cover" && dc === "garage") return "garage";
if (d === "weather") return WX[s.state] || "cloud";
if (d === "media_player" && s.state === "playing") return "music";
return DOMAIN_ICON[d] || "pulse";
}
/** An override can be an icon name from the set or any text, such as an emoji. */
function icon(s, override) {
if (override) return Icons.has(override) ? Icons.svg(override) : `<span class="glyph">${esc(override)}</span>`;
return Icons.svg(iconName(s));
}
// ------------------------------------------------------------ formatting
const cap = (s) => (s ? String(s).charAt(0).toUpperCase() + String(s).slice(1) : "").replace(/_/g, " ");
const BINARY_LABELS = { door: ["Closed", "Open"], window: ["Closed", "Open"], garage_door: ["Closed", "Open"], opening: ["Closed", "Open"], motion: ["Clear", "Motion"], occupancy: ["Clear", "Occupied"], moisture: ["Dry", "Wet"], smoke: ["Clear", "Smoke"], presence: ["Away", "Home"], plug: ["Unplugged", "Plugged in"], battery_charging: ["Not charging", "Charging"] };
function fmtNum(v, decimals) {
const n = Number(v);
if (!isFinite(n)) return v;
if (decimals !== "" && decimals != null && !isNaN(decimals)) {
const d = Math.max(0, Math.min(6, Number(decimals)));
return n.toLocaleString(LOCALE, { minimumFractionDigits: d, maximumFractionDigits: d });
}
return n.toLocaleString(LOCALE, { maximumFractionDigits: Math.abs(n) >= 100 ? 0 : 1 });
}
function fmtState(s) {
if (!s) return t("Unavailable");
const d = domainOf(s.entity_id), a = s.attributes;
if (s.state === "unavailable" || s.state === "unknown") return st(s.state);
if (d === "binary_sensor") return t((BINARY_LABELS[a.device_class] || ["Off", "On"])[isOn(s) ? 1 : 0]);
if (d === "light") return s.state === "on" ? (a.brightness ? `${Math.round(a.brightness / 2.55)}%` : t("On")) : t("Off");
if (d === "climate") return `${st(s.state)}${a.temperature != null ? " · " + a.temperature + "°" : ""}`;
if (d === "scene") return t("Scene");
if (d === "script") return s.state === "on" ? t("Running") : t("Script");
if (d === "media_player" && a.media_title) return `${st(s.state)} · ${a.media_title}`;
if (d === "weather") return `${st(s.state)} · ${a.temperature}°`;
if (d === "device_tracker" || d === "person") return s.state === "home" ? t("Home") : s.state === "not_home" ? t("Away") : st(s.state);
if (a.unit_of_measurement && !isNaN(Number(s.state))) return `${fmtNum(s.state)} ${a.unit_of_measurement}`;
return st(s.state);
}
const relTime = (iso) => {
const sec = (Date.now() - new Date(iso).getTime()) / 1000;
if (sec < 60) return t("just now");
if (sec < 3600) return t("{0} min ago", Math.floor(sec / 60));
if (sec < 86400) return t("{0} h ago", Math.floor(sec / 3600));
return t("{0} d ago", Math.floor(sec / 86400));
};
const missing = (c, what = "an entity") => `<div class="empty">${c ? t("Can’t find {0}.", esc(c)) + "<br>" + t("Check the entity ID in the inspector.") : t("Choose {0} in the inspector.", t(what))}</div>`;
const title = (txt) => (txt ? `<div class="wt">${esc(txt)}</div>` : "");
const has = (x) => x !== "" && x != null && isFinite(Number(x));
function lampColor(s) {
const rgb = s?.attributes?.rgb_color;
return s?.state === "on" && Array.isArray(rgb) ? `rgb(${rgb.join(",")})` : null;
}
const litKind = (s) => (!isOn(s) ? false : domainOf(s.entity_id) === "light" ? "lamp" : "on");
// ------------------------------------------------------------ widgets
const W = {};
W.heading = {
label: "Heading", icon: "heading", size: [6, 1], transparent: true,
fields: [
{ k: "text", t: "text", l: "Text", d: "Welcome home" },
{ k: "subtitle", t: "text", l: "Subtitle", d: "" },
{ k: "size", t: "number", l: "Font size (px)", d: 30 },
{ k: "align", t: "seg", l: "Alignment", o: ["left", "center", "right"], d: "left" },
],
render: (c) => `<div class="heading" style="text-align:${esc(c.align)}">
<h2 class="h-main" style="font-size:${Number(c.size) || 30}px">${esc(c.text)}</h2>
${c.subtitle ? `<div class="wt wrap">${esc(c.subtitle)}</div>` : ""}</div>`,
};
W.entity = {
label: "Entity tile", icon: "tile", size: [3, 2],
fields: [
{ k: "entity", t: "entity", l: "Entity" },
{ k: "name", t: "text", l: "Name" , ph: "Entity name" },
{ k: "icon", t: "icon", l: "Icon" },
{ k: "tap", t: "seg", l: "Tap action", o: ["auto", "toggle", "more-info", "none"], d: "auto" },
{ k: "showChanged", t: "check", l: "Show when it last changed", d: false },
],
render(c, { ha }) {
const s = ha.states[c.entity];
if (!s) return missing(c.entity);
const d = domainOf(c.entity);
const tap = c.tap === "auto" ? (TOGGLE_DOMAINS.has(d) ? "toggle" : "more-info") : c.tap;
const name = friendly(s, c.name), state = fmtState(s) + (c.showChanged ? " · " + relTime(s.last_changed) : "");
const inner = `<span class="badge">${icon(s, c.icon)}</span>
<span class="tile-text"><span class="name">${esc(name)}</span><span class="state">${esc(state)}</span></span>`;
if (tap === "none") return `<div class="tile">${inner}</div>`;
const pressed = tap === "toggle" && !STATELESS.has(d) ? ` aria-pressed="${isOn(s)}"` : "";
return `<button class="tile wbtn" data-act="${tap === "toggle" ? "toggle" : "more"}" data-e="${esc(c.entity)}"${pressed}>${inner}</button>`;
},
lit: (c, { ha }) => litKind(ha.states[c.entity]),
lamp: (c, { ha }) => lampColor(ha.states[c.entity]),
};
W.value = {
label: "Big value", icon: "number", size: [3, 2],
fields: [
{ k: "entity", t: "entity", l: "Entity", domains: ["sensor", "input_number", "number", "counter"] },
{ k: "name", t: "text", l: "Name", ph: "Entity name" },
{ k: "unit", t: "text", l: "Unit", ph: "From entity" },
{ k: "decimals", t: "number", l: "Decimals", d: "" },
{ k: "warn", t: "number", l: "Warn above", d: "" },
{ k: "crit", t: "number", l: "Critical above", d: "" },
{ k: "icon", t: "icon", l: "Icon" },
],
render(c, { ha }) {
const s = ha.states[c.entity];
if (!s) return missing(c.entity);
const v = Number(s.state);
const lvl = isFinite(v) && has(c.crit) && v > Number(c.crit) ? "crit" : isFinite(v) && has(c.warn) && v > Number(c.warn) ? "warn" : "";
const unit = c.unit || s.attributes.unit_of_measurement || "";
return `<button class="value wbtn ${lvl}" data-act="more" data-e="${esc(c.entity)}">
<span class="value-top"><span class="wt">${esc(friendly(s, c.name))}</span><span class="value-ico">${icon(s, c.icon)}</span></span>
<span class="big num">${esc(isFinite(v) ? fmtNum(s.state, c.decimals) : cap(s.state))}<span class="unit">${esc(unit)}</span></span>
${lvl ? `<span class="level">${lvl === "crit" ? t("Critical") : t("High")}</span>` : ""}</button>`;
},
};
function arc(cx, cy, r, a0, a1) {
const p = (a) => [cx + r * Math.cos(a), cy + r * Math.sin(a)];
const [x0, y0] = p(a0), [x1, y1] = p(a1);
return `M${x0.toFixed(2)} ${y0.toFixed(2)} A${r} ${r} 0 ${a1 - a0 > Math.PI ? 1 : 0} 1 ${x1.toFixed(2)} ${y1.toFixed(2)}`;
}
W.gauge = {
label: "Gauge", icon: "gauge", size: [3, 3],
fields: [
{ k: "entity", t: "entity", l: "Entity", domains: ["sensor", "input_number", "number"] },
{ k: "name", t: "text", l: "Name", ph: "Entity name" },
{ k: "min", t: "number", l: "Minimum", d: 0 },
{ k: "max", t: "number", l: "Maximum", d: 100 },
{ k: "green", t: "number", l: "Green from", d: "" },
{ k: "yellow", t: "number", l: "Yellow from", d: "" },
{ k: "red", t: "number", l: "Red from", d: "" },
],
render(c, { ha }) {
const s = ha.states[c.entity];
if (!s) return missing(c.entity);
const v = Number(s.state), min = Number(c.min) || 0, max = Number(c.max) || 100;
const frac = isFinite(v) ? Math.max(0, Math.min(1, (v - min) / (max - min || 1))) : 0;
let color = "var(--d-text)";
if (isFinite(v)) {
if (has(c.red) && v >= Number(c.red)) color = "var(--crit)";
else if (has(c.yellow) && v >= Number(c.yellow)) color = "var(--warn)";
else if (has(c.green) && v >= Number(c.green)) color = "var(--ok)";
}
const a0 = Math.PI * 0.75, span = Math.PI * 1.5;
const unit = s.attributes.unit_of_measurement || "";
return `<button class="gauge wbtn" data-act="more" data-e="${esc(c.entity)}" aria-label="${esc(friendly(s, c.name))}, ${esc(fmtState(s))}">
<svg viewBox="0 0 100 82" aria-hidden="true">
<path d="${arc(50, 50, 40, a0, a0 + span)}" stroke="var(--d-fill)" stroke-width="8" fill="none" stroke-linecap="round"/>
${frac > 0.001 ? `<path d="${arc(50, 50, 40, a0, a0 + span * frac)}" stroke="${color}" stroke-width="8" fill="none" stroke-linecap="round"/>` : ""}
<text x="50" y="53" text-anchor="middle" class="num" font-size="20" font-weight="700" fill="currentColor">${esc(isFinite(v) ? fmtNum(v) : "–")}</text>
<text x="50" y="67" text-anchor="middle" font-size="9" fill="var(--d-text-2)">${esc(unit)}</text>
</svg><span class="wt">${esc(friendly(s, c.name))}</span></button>`;
},
};
W.light = {
label: "Light", icon: "bulb", size: [3, 2],
fields: [
{ k: "entity", t: "entity", l: "Light", domains: ["light"] },
{ k: "name", t: "text", l: "Name", ph: "Entity name" },
{ k: "members", t: "check", l: "For a light group, show each light", d: true },
],
// A light group lists its lights in the entity_id attribute; follow them too.
entities: (c, ha) => [c.entity, ...(ha?.states[c.entity]?.attributes.entity_id || [])].filter(Boolean),
render(c, { ha }) {
const s = ha.states[c.entity];
if (!s) return missing(c.entity, "a light");
const members = c.members !== false && Array.isArray(s.attributes.entity_id) ? s.attributes.entity_id : [];
const pct = s.state === "on" ? Math.round((s.attributes.brightness || 255) / 2.55) : 0;
const dimmable = (s.attributes.supported_color_modes || []).some((m) => m !== "onoff");
const name = friendly(s, c.name);
return `<div class="light">
<button class="tile-row wbtn" data-act="toggle" data-e="${esc(c.entity)}" aria-pressed="${isOn(s)}">
<span class="badge">${icon(s)}</span>
<span class="tile-text"><span class="name">${esc(name)}</span><span class="state">${esc(fmtState(s))}</span></span></button>
${dimmable ? `<input class="slider" type="range" min="1" max="100" value="${pct || 1}" style="--pct:${pct}%" data-act="brightness" data-e="${esc(c.entity)}" aria-label="${esc(name)} brightness">` : ""}
${members.length ? `<div class="list light-members" role="group" aria-label="${t("Lights in {0}", esc(name))}">${members.map((eid) => {
const m = ha.states[eid];
if (!m) return "";
return `<button class="list-row wbtn" role="switch" aria-checked="${isOn(m)}" data-act="toggle" data-e="${esc(eid)}"><span class="row-ico">${icon(m)}</span><span class="grow">${esc(friendly(m))}</span><span class="st">${esc(fmtState(m))}</span><span class="switch"></span></button>`;
}).join("")}</div>` : ""}</div>`;
},
lit: (c, { ha }) => litKind(ha.states[c.entity]),
lamp: (c, { ha }) => lampColor(ha.states[c.entity]),
};
W.climate = {
label: "Thermostat", icon: "thermometer", size: [3, 3],
fields: [
{ k: "entity", t: "entity", l: "Thermostat", domains: ["climate"] },
{ k: "name", t: "text", l: "Name", ph: "Entity name" },
{ k: "step", t: "number", l: "Step", d: 0.5 },
],
render(c, { ha }) {
const s = ha.states[c.entity];
if (!s) return missing(c.entity, "a thermostat");
const a = s.attributes, step = Number(c.step) || 0.5, e = esc(c.entity);
return `<div class="climate">
<div class="wt">${esc(friendly(s, c.name))} · ${t("Now")} ${esc(a.current_temperature ?? "–")}°</div>
<div class="temps">
<button class="round wbtn" data-act="temp" data-e="${e}" data-v="${-step}" aria-label="${t("Lower to {0}°", esc((a.temperature ?? 0) - step))}">${Icons.svg("minus")}</button>
<div class="target num" aria-live="polite">${esc(a.temperature ?? "–")}°</div>
<button class="round wbtn" data-act="temp" data-e="${e}" data-v="${step}" aria-label="${t("Raise to {0}°", esc((a.temperature ?? 0) + step))}">${Icons.svg("plus")}</button>
</div>
<div class="chips" role="group" aria-label="${t("Mode")}">${(a.hvac_modes || []).map((m) => `<button class="chip wbtn" aria-pressed="${m === s.state}" data-act="hvac" data-e="${e}" data-v="${esc(m)}">${esc(st(m))}</button>`).join("")}</div></div>`;
},
};
W.graph = {
label: "History graph", icon: "chart", size: [6, 3],
fields: [
{ k: "title", t: "text", l: "Title", d: "" },
{ k: "entities", t: "entities", l: "Sensors", domains: ["sensor", "input_number", "number"] },
{ k: "hours", t: "number", l: "Hours to show", d: 24 },
{ k: "fill", t: "check", l: "Fill under the line", d: true },
],
entities: (c) => c.entities || [],
render(c) {
if (!c.entities?.length) return `<div class="empty">${t("Add one or more sensors in the inspector.")}</div>`;
return `<div class="graph">${title(c.title)}<div class="legend"></div><div class="plot"><div class="empty">${t("Loading history…")}</div></div></div>`;
},
async after(el, c, ctx) {
if (!c.entities?.length) return;
const hours = Number(c.hours) || 24;
const key = c.entities.join(",") + "|" + hours;
const cache = ctx.cache;
if (cache.key !== key || Date.now() - cache.at > 10 * 60 * 1000) {
cache.key = key; cache.at = Date.now();
cache.loading = ctx.ha.history(c.entities, hours).catch(() => ({})).then((s) => (cache.series = s));
}
// a re-render while the fetch is in flight must wait for the same request
await cache.loading;
if (!el.isConnected) return;
for (const eid of c.entities) {
const s = ctx.ha.states[eid];
if (!s || !cache.series[eid]) continue;
const pts = cache.series[eid], last = pts[pts.length - 1];
const t = new Date(s.last_updated).getTime();
if (!last || t > last[0]) pts.push([t, Number(s.state)]);
const cutoff = Date.now() - hours * 3600e3;
while (pts.length > 2 && pts[0][0] < cutoff) pts.shift();
}
drawGraph(el, c, ctx);
},
};
// Series differ by dash pattern as well as color, so color is never the only cue.
const SERIES = [["var(--d-accent)", ""], ["var(--warn)", "6 4"], ["var(--ok)", "2 3"], ["var(--crit)", "10 3 2 3"]];
function drawGraph(el, c, ctx) {
const plot = el.querySelector(".plot"), legend = el.querySelector(".legend");
if (!plot) return;
const W_ = plot.clientWidth, H = plot.clientHeight;
const series = c.entities.map((eid) => ({ eid, pts: (ctx.cache.series || {})[eid] || [] })).filter((s) => s.pts.length);
legend.innerHTML = c.entities.map((eid, i) => {
const s = ctx.ha.states[eid], [col, dash] = SERIES[i % SERIES.length];
return `<span><svg width="18" height="8" aria-hidden="true"><line x1="1" y1="4" x2="17" y2="4" stroke="${col}" stroke-width="2.5" stroke-dasharray="${dash}" stroke-linecap="round"/></svg>${esc(friendly(s))} <b class="num">${esc(s ? fmtState(s) : "–")}</b></span>`;
}).join("");
if (!series.length || W_ < 10 || H < 10) { plot.innerHTML = `<div class="empty">${t("No numeric history for this period.")}</div>`; return; }
const hours = Number(c.hours) || 24;
const t1 = Date.now(), t0 = t1 - hours * 3600e3;
let lo = Infinity, hi = -Infinity;
series.forEach((s) => s.pts.forEach(([, v]) => { if (isFinite(v)) { lo = Math.min(lo, v); hi = Math.max(hi, v); } }));
if (!isFinite(lo)) { plot.innerHTML = `<div class="empty">${t("No numeric history for this period.")}</div>`; return; }
if (hi - lo < 1e-6) { hi += 1; lo -= 1; }
const pad = (hi - lo) * 0.1; lo -= pad; hi += pad;
const L = 36, B = 16;
const x = (t) => L + ((t - t0) / (t1 - t0)) * (W_ - L);
const y = (v) => (1 - (v - lo) / (hi - lo)) * (H - B);
const paths = series.map((s) => {
const [col, dash] = SERIES[c.entities.indexOf(s.eid) % SERIES.length];
const pts = s.pts.filter(([, v]) => isFinite(v));
if (!pts.length) return "";
let d = "";
pts.forEach(([t, v], j) => { d += `${j ? "L" : "M"}${x(Math.max(t, t0)).toFixed(1)} ${y(v).toFixed(1)}`; });
d += `L${x(t1).toFixed(1)} ${y(pts[pts.length - 1][1]).toFixed(1)}`;
const area = c.fill && series.length === 1 ? `<path d="${d}L${x(t1).toFixed(1)} ${H - B}L${x(Math.max(pts[0][0], t0)).toFixed(1)} ${H - B}Z" fill="${col}" opacity=".12"/>` : "";
return `${area}<path d="${d}" fill="none" stroke="${col}" stroke-width="2" stroke-dasharray="${dash}" stroke-linejoin="round" stroke-linecap="round"/>`;
}).join("");
const ticks = [0, 0.5, 1].map((f) => lo + (hi - lo) * f);
plot.innerHTML = `<svg viewBox="0 0 ${W_} ${H}" role="img" aria-label="${t("History for the last {0} hours", hours)}">
${ticks.map((v) => `<line x1="${L}" x2="${W_}" y1="${y(v)}" y2="${y(v)}" stroke="var(--d-separator)"/><text class="axis" x="${L - 6}" y="${y(v) + 4}" text-anchor="end">${fmtNum(v)}</text>`).join("")}
${paths}
<text class="axis" x="${L}" y="${H - 2}">${t("−{0} h", hours)}</text><text class="axis" x="${W_}" y="${H - 2}" text-anchor="end">${t("Now")}</text></svg>`;
}
const POSITIONS = ["top-left", "top", "top-right", "left", "center", "right", "bottom-left", "bottom", "bottom-right"];
W.clock = {
label: "Clock", icon: "clock", size: [3, 2],
fields: [
{ k: "h24", t: "check", l: "24-hour time", d: true },
{ k: "seconds", t: "check", l: "Show seconds", d: false },
{ k: "date", t: "check", l: "Show date", d: true },
{ k: "pos", t: "pos9", l: "Text position", o: POSITIONS, d: "center" },
{ k: "weather", t: "entity", l: "Weather (optional)", domains: ["weather"] },
{ k: "forecast", t: "check", l: "Show today’s high and low", d: true },
{ k: "bgSource", t: "seg", l: "Background image", o: ["none", "links", "immich"], d: "none" },
{ k: "bgUrls", t: "textarea", l: "Image URLs, one per line", d: "", when: (c) => c.bgSource === "links" },
{ k: "bgAlbums", t: "albums", l: "Immich albums", when: (c) => c.bgSource === "immich" },
{ k: "bgInterval", t: "number", l: "Change photo every (seconds)", d: 60, when: (c) => c.bgSource && c.bgSource !== "none" },
{ k: "dim", t: "number", l: "Darken the image (%)", d: 30, when: (c) => c.bgSource && c.bgSource !== "none" },
],
entities: (c) => (c.weather ? [c.weather] : []),
render(c, { ha }) {
const s = c.weather && ha.states[c.weather];
const bg = c.bgSource === "links" ? urlList({ urls: c.bgUrls }).length > 0 : c.bgSource === "immich" && c.bgAlbums?.length > 0;
const pos = POSITIONS.includes(c.pos) ? c.pos : "center";
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" : ""}" ${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>`;
},
async after(el, c, ctx) {
const box = el.querySelector(".clock-bg");
if (box) slideshow(el, box, ctx, (ctx.cache.bg ||= {}), { source: c.bgSource, urls: c.bgUrls, albums: c.bgAlbums, shuffle: true, interval: c.bgInterval || 60 });
if (!c.weather || c.forecast === false || !ctx.ha.states[c.weather]) return;
const cache = ctx.cache;
if (cache.key !== c.weather || Date.now() - cache.at > 30 * 60 * 1000) {
cache.key = c.weather; cache.at = Date.now();
cache.loading = ctx.ha.forecast(c.weather, "daily").then((f) => (cache.today = f[0])).catch(() => (cache.today = null));
}
await cache.loading;
const hl = el.querySelector("[data-hl]");
const f = cache.today;
if (hl && f && f.temperature != null) hl.textContent = f.templow != null ? t("High {0}° · Low {1}°", Math.round(f.temperature), Math.round(f.templow)) : t("High {0}°", Math.round(f.temperature));
},
};
function clockHTML(h24, sec, date) {
const d = new Date();
const time = d.toLocaleTimeString(LOCALE, { hour: "2-digit", minute: "2-digit", second: sec ? "2-digit" : undefined, hour12: !h24 });
return `<div class="time num">${time}</div>${date ? `<div class="date">${d.toLocaleDateString(LOCALE, { weekday: "long", day: "numeric", month: "long" })}</div>` : ""}`;
}
W.weather = {
label: "Weather", icon: "cloud-sun", size: [4, 2],
fields: [{ k: "entity", t: "entity", l: "Weather", domains: ["weather"] }, { k: "name", t: "text", l: "Name", ph: "Entity name" }],
render(c, { ha }) {
const s = ha.states[c.entity];
if (!s) return missing(c.entity, "a weather entity");
const a = s.attributes;
return `<button class="weather wbtn" data-act="more" data-e="${esc(c.entity)}"><span class="wx-ico">${Icons.svg(WX[s.state] || "cloud")}</span>
<span class="wx-text"><span class="wx-temp num">${esc(a.temperature ?? "–")}°</span><span class="wt">${esc(friendly(s, c.name))} · ${esc(st(s.state))}</span>
<span class="wt">${t("Humidity")} ${esc(a.humidity ?? "–")}% · ${t("Wind")} ${esc(a.wind_speed ?? "–")} ${esc(a.wind_speed_unit || "km/h")}</span></span></button>`;
},
};
W.list = {
label: "Entity list", icon: "list", size: [4, 4],
fields: [{ k: "title", t: "text", l: "Title", d: "Devices" }, { k: "entities", t: "entities", l: "Entities" }],
entities: (c) => c.entities || [],
render(c, { ha }) {
if (!c.entities?.length) return `${title(c.title)}<div class="empty">${t("Add entities in the inspector.")}</div>`;
const rows = c.entities.map((eid) => {
const s = ha.states[eid];
if (!s) return `<div class="list-row"><span class="row-ico">${Icons.svg("pulse")}</span><span class="grow muted">${esc(eid)}</span></div>`;
const sw = ["light", "switch", "fan", "input_boolean", "automation"].includes(domainOf(eid));
// Switches belong in list rows, so this is the one place we draw them.
return sw
? `<button class="list-row wbtn" role="switch" aria-checked="${isOn(s)}" data-act="toggle" data-e="${esc(eid)}"><span class="row-ico">${icon(s)}</span><span class="grow">${esc(friendly(s))}</span><span class="switch"></span></button>`
: `<button class="list-row wbtn" data-act="more" data-e="${esc(eid)}"><span class="row-ico">${icon(s)}</span><span class="grow">${esc(friendly(s))}</span><span class="st">${esc(fmtState(s))}</span></button>`;
}).join("");
return `${title(c.title)}<div class="list">${rows}</div>`;
},
};
W.button = {
label: "Button", icon: "button", size: [2, 2],
fields: [
{ k: "label", t: "text", l: "Label", d: "Run" },
{ k: "icon", t: "icon", l: "Icon", d: "sparkles" },
{ k: "entity", t: "entity", l: "Target entity (optional)" },
{ k: "service", t: "text", l: "Action (e.g. scene.turn_on)", d: "" },
{ k: "data", t: "json", l: "Action data (JSON)", d: "" },
],
render: (c) => `<button class="btn-widget wbtn" data-act="button"><span class="badge">${icon(null, c.icon || "sparkles")}</span><span class="name">${esc(c.label)}</span></button>`,
};
// ---------------------------------------------------------------- media (one or more players)
const mediaIds = (c) => (c.entities?.length ? c.entities : c.entity ? [c.entity] : []);
const ACTIVE = ["playing", "paused", "buffering", "on", "idle"];
/** Which player the card shows: the one picked, else whatever is playing, else the first that's on. */
function mediaSelected(ids, ha, cache) {
if (cache.sel && ids.includes(cache.sel)) return cache.sel;
return ids.find((e) => ha.states[e]?.state === "playing") || ids.find((e) => ha.states[e]?.state === "paused") || ids.find((e) => ACTIVE.includes(ha.states[e]?.state)) || ids[0];
}
W.media = {
label: "Media players", icon: "music", size: [4, 3],
fields: [
{ k: "entities", t: "entities", l: "Media players", domains: ["media_player"] },
{ k: "art", t: "check", l: "Show album art", d: true },
{ k: "hideOff", t: "check", l: "Hide players that are off from the switcher", d: false },
],
entities: mediaIds,
render(c, { ha, cache }) {
let ids = mediaIds(c).filter((e) => ha.states[e]);
if (!ids.length) return missing(mediaIds(c)[0], "a media player");
const sel = mediaSelected(ids, ha, cache);
if (c.hideOff) ids = ids.filter((e) => e === sel || ha.states[e].state !== "off");
const s = ha.states[sel], a = s.attributes, e = esc(sel);
const playing = s.state === "playing", off = ["off", "standby", "unavailable"].includes(s.state);
const bars = `<span class="eq" aria-hidden="true"><i></i><i></i><i></i></span>`;
const switcher = ids.length > 1 ? `<div class="mp-switch" role="tablist" aria-label="${t("Media players")}">${ids.map((id) => {
const p = ha.states[id], on = p.state === "playing";
return `<button class="chip wbtn ${on ? "is-playing" : ""}" role="tab" aria-selected="${id === sel}" data-act="mp-select" data-v="${esc(id)}" aria-label="${esc(friendly(p))}, ${esc(st(p.state))}">${on ? bars : ""}${esc(friendly(p))}</button>`;
}).join("")}</div>` : "";
const pic = c.art !== false && a.entity_picture && !off ? `<img class="mp-art" src="${esc(a.entity_picture)}" alt="">` : `<span class="mp-art ph" aria-hidden="true">${icon(s)}</span>`;
const title = off ? st(s.state) : a.media_title || a.app_name || a.source || st(s.state);
const sub = off ? "" : [a.media_artist, a.media_album_name].filter(Boolean).join(" · ") || (a.media_title ? "" : st(s.state));
const controls = off
? `<button class="car-act wbtn" data-act="svc" data-e="${e}" data-v="media_player.turn_on">${Icons.svg("power")}<span>${t("Turn on")}</span></button>`
: `<button class="round wbtn" data-act="svc" data-e="${e}" data-v="media_player.media_previous_track" aria-label="${t("Previous track")}">${Icons.svg("prev")}</button>
<button class="round wbtn primary" data-act="svc" data-e="${e}" data-v="media_player.media_play_pause" aria-label="${playing ? t("Pause") : t("Play")}">${Icons.svg(playing ? "pause" : "play")}</button>
<button class="round wbtn" data-act="svc" data-e="${e}" data-v="media_player.media_next_track" aria-label="${t("Next track")}">${Icons.svg("next")}</button>
${a.volume_level != null ? `<input class="slider thin" type="range" min="0" max="100" value="${Math.round(a.volume_level * 100)}" style="--pct:${Math.round(a.volume_level * 100)}%" data-act="volume" data-e="${e}" aria-label="${esc(t("Volume"))} ${esc(friendly(s))}">` : ""}`;
return `<div class="media mp">${switcher}
<div class="mp-now">${pic}<div class="mp-meta">
${ids.length === 1 ? `<div class="wt">${esc(friendly(s))}</div>` : ""}
<div class="title">${esc(title)}</div>${sub ? `<div class="wt">${esc(sub)}</div>` : ""}</div></div>
<div class="ctrls">${controls}</div></div>`;
},
lit: (c, { ha, cache }) => (ha.states[mediaSelected(mediaIds(c), ha, cache || {})]?.state === "playing" ? "on" : false),
};
// ---------------------------------------------------------------- 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, 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, 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), 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 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 : [];
// 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),
};
// ---------------------------------------------------------------- appliance (washer, dryer, dishwasher)
// Works with any integration: a status entity, and/or a power sensor above a threshold,
// plus optional remaining time (minutes, hours or an end timestamp), progress and program.
const RUNNING_RE = /run|wash|dry|rins|spin|heat|clean|activ|program|bezig|draait|centrifug|spoel|droog|^on$|^true$/;
const DONE_RE = /finish|end|done|complete|ready|klaar|gereed|beëindigd/;
const PAUSE_RE = /pause|gepauzeerd|delayed|uitgesteld/;
function appliancePhase(c, ha) {
const status = String(ha.states[c.status]?.state || "").toLowerCase();
if (status && !["unavailable", "unknown"].includes(status)) {
if (DONE_RE.test(status)) return "done";
if (PAUSE_RE.test(status)) return "paused";
if (RUNNING_RE.test(status)) return "running";
}
const power = Number(ha.states[c.power]?.state);
if (c.power && isFinite(power) && power > (Number(c.threshold) || 5)) return "running";
const left = remainingMinutes(c, ha);
if (!c.status && !c.power && left > 0) return "running";
return "off";
}
function remainingMinutes(c, ha) {
const s = ha.states[c.remaining];
if (!s) return null;
if (/^\d{4}-\d{2}-\d{2}T/.test(s.state)) return Math.max(0, Math.round((new Date(s.state) - Date.now()) / 60000));
const n = Number(s.state);
if (!isFinite(n)) return null;
const unit = String(s.attributes.unit_of_measurement || "min").toLowerCase();
return Math.round(unit.startsWith("h") ? n * 60 : unit === "s" ? n / 60 : n);
}
const fmtDuration = (m) => (m >= 60 ? t("{0} h {1} min", Math.floor(m / 60), m % 60) : t("{0} min", m));
W.appliance = {
label: "Appliance", icon: "washer", size: [4, 2],
fields: [
{ k: "name", t: "text", l: "Name", d: "Washing machine" },
{ k: "kind", t: "seg", l: "Appliance", o: ["washer", "dryer", "dishwasher", "other"], d: "washer" },
{ k: "status", t: "entity", l: "Status", domains: ["sensor", "binary_sensor", "switch", "select", "input_select", "input_boolean"] },
{ k: "power", t: "entity", l: "Power (W), if there is no status", domains: ["sensor"] },
{ k: "threshold", t: "number", l: "Running above (W)", d: 5 },
{ k: "remaining", t: "entity", l: "Time remaining or end time", domains: ["sensor"] },
{ k: "progress", t: "entity", l: "Progress (%)", domains: ["sensor", "number"] },
{ k: "program", t: "entity", l: "Program", domains: ["sensor", "select", "input_select"] },
{ k: "hideWhenOff", t: "check", l: "Only show while running or finished", d: true },
],
entities: (c) => [c.status, c.power, c.remaining, c.progress, c.program].filter(Boolean),
autoHide: (c, { ha }) => !!c.hideWhenOff && !!(c.status || c.power || c.remaining) && appliancePhase(c, ha) === "off",
render(c, { ha }) {
if (!c.status && !c.power && !c.remaining) return `<div class="empty">${t("Choose the appliance’s status, power or remaining-time entity in the inspector.")}</div>`;
const phase = appliancePhase(c, ha);
const label = { running: t("In progress"), done: t("Finished"), paused: t("Paused"), off: t("Off") }[phase];
const left = phase === "running" || phase === "paused" ? remainingMinutes(c, ha) : null;
const prog = Number(ha.states[c.progress]?.state);
const program = ha.states[c.program]?.state;
const ends = left != null ? new Date(Date.now() + left * 60000).toLocaleTimeString(LOCALE, { hour: "2-digit", minute: "2-digit" }) : "";
const ico = { washer: "washer", dryer: "dryer", dishwasher: "dishwasher", other: "plug" }[c.kind] || "washer";
const showProgram = program && !["unknown", "unavailable", "none", "off", ""].includes(String(program).toLowerCase());
return `<div class="appl phase-${phase}">
<div class="tile-row"><span class="badge">${Icons.svg(ico)}</span>
<span class="tile-text"><span class="name">${esc(c.name || t("Appliance"))}</span>${showProgram ? `<span class="state">${esc(st(program))}</span>` : ""}</span>
<span class="appl-status">${esc(label)}</span></div>
${phase === "done" ? `<div class="appl-big">${t("Ready to unload")}</div>` : left != null ? `<div class="appl-big"><span class="num">${esc(fmtDuration(left))}</span> <span class="wt">${t("left · ready at {0}", ends)}</span></div>` : ""}
${isFinite(prog) && c.progress && phase !== "off" ? `<div class="car-bar" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${Math.round(prog)}" aria-label="${t("Progress")}"><span class="car-fill" style="width:${Math.max(0, Math.min(100, prog))}%"></span></div>` : ""}
</div>`;
},
lit: (c, { ha }) => (["running", "done"].includes(appliancePhase(c, ha)) ? "on" : false),
};
W.camera = {
label: "Camera", icon: "camera", size: [6, 4], flush: true,
fields: [{ k: "entity", t: "entity", l: "Camera", domains: ["camera"] }, { k: "refresh", t: "number", l: "Refresh every (seconds)", d: 10 }, { k: "showName", t: "check", l: "Show name", d: true }],
render(c, { ha }) {
const s = ha.states[c.entity];
if (!s) return `<div class="pad">${missing(c.entity, "a camera")}</div>`;
return `<button class="cam-btn wbtn" data-act="more" data-e="${esc(c.entity)}" aria-label="${esc(t("{0} camera", friendly(s)))}"><img class="cam" data-cam="${esc(c.entity)}" data-refresh="${Number(c.refresh) || 10}" src="api/camera/${encodeURIComponent(c.entity)}?t=${Date.now()}" alt=""></button>
${c.showName ? `<div class="cam-label">${esc(friendly(s))}</div>` : ""}`;
},
};
// ---------------------------------------------------------------- car
const truthy = (s) => !!s && (["on", "true", "charging", "plugged_in", "connected"].includes(String(s.state).toLowerCase()));
W.car = {
label: "Car", icon: "car", size: [6, 3],
fields: [
{ k: "name", t: "text", l: "Name", d: "My car" },
{ k: "image", t: "text", l: "Car image URL (optional)", ph: "https://… or /local/car.png" },
{ k: "kind", t: "seg", l: "Drivetrain", o: ["electric", "fuel"], d: "electric" },
{ k: "level", t: "entity", l: "Battery or fuel level (%)", domains: ["sensor", "number", "input_number"] },
{ k: "range", t: "entity", l: "Range", domains: ["sensor", "number", "input_number"] },
{ k: "charging", t: "entity", l: "Charging", domains: ["binary_sensor", "sensor", "switch"] },
{ k: "power", t: "entity", l: "Charging power", domains: ["sensor"] },
{ k: "plug", t: "entity", l: "Plugged in", domains: ["binary_sensor", "sensor"] },
{ k: "limit", t: "entity", l: "Charge limit (%)", domains: ["number", "sensor", "input_number"] },
{ k: "lock", t: "entity", l: "Doors", domains: ["lock"] },
{ k: "climate", t: "entity", l: "Climate", domains: ["climate", "switch", "input_boolean"] },
{ k: "location", t: "entity", l: "Location", domains: ["device_tracker", "person", "sensor"] },
{ k: "odometer", t: "entity", l: "Odometer", domains: ["sensor"] },
],
entities: (c) => ["level", "range", "charging", "power", "plug", "limit", "lock", "climate", "location", "odometer"].map((k) => c[k]).filter(Boolean),
render(c, { ha }) {
const st = (k) => (c[k] ? ha.states[c[k]] : null);
const level = st("level"), range = st("range"), lock = st("lock"), clim = st("climate"), loc = st("location");
if (!c.level && !c.range && !c.lock) return `<div class="empty">${t("Set the car’s level, range or lock entity in the inspector.")}</div>`;
const pct = level && isFinite(Number(level.state)) ? Math.round(Number(level.state)) : null;
const power = st("power"), plug = st("plug"), limit = st("limit");
const charging = truthy(st("charging")) || (!c.charging && power && Number(power.state) > 0);
const plugged = charging || truthy(plug);
const low = pct != null && pct <= 20;
let status;
if (charging) status = `${t("Charging")}${power && Number(power.state) > 0 ? " · " + fmtState(power) : ""}`;
else if (plugged) status = t("Plugged in");
else if (loc) status = loc.state === "home" ? t("Parked at home") : loc.state === "not_home" ? t("Away") : st(loc.state);
else if (lock) status = lock.state === "locked" ? t("Locked") : t("Unlocked");
else status = "";
const limPct = limit && isFinite(Number(limit.state)) ? Number(limit.state) : null;
const bar = pct == null ? "" : `
<div class="car-bar ${charging ? "charging" : ""} ${low ? "low" : ""}" role="meter" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${pct}" aria-label="${c.kind === "fuel" ? t("Fuel level") : t("Battery level")}">
<span class="car-fill" style="width:${pct}%"></span>
${limPct != null && c.kind !== "fuel" ? `<span class="car-limit" style="left:${limPct}%" title="${t("Charge limit {0}%", limPct)}"></span>` : ""}
</div>`;
const actions = [];
if (lock) {
const locked = lock.state === "locked";
actions.push(`<button class="car-act wbtn" data-act="toggle" data-e="${esc(c.lock)}" aria-pressed="${!locked}">${Icons.svg(locked ? "lock" : "unlock")}<span>${locked ? t("Locked") : t("Unlocked")}</span></button>`);
}
if (clim) {
const on = isOn(clim) || clim.state === "on";
actions.push(`<button class="car-act wbtn" data-act="toggle" data-e="${esc(c.climate)}" aria-pressed="${on}">${Icons.svg(on ? "fan" : "snowflake")}<span>${on ? t("Climate on") : t("Climate")}</span></button>`);
}
if (loc) actions.push(`<button class="car-act wbtn" data-act="more" data-e="${esc(c.location)}">${Icons.svg("pin")}<span>${esc(fmtState(loc))}</span></button>`);
const odo = st("odometer");
return `<div class="car ${c.image ? "has-img" : ""}">
${c.image ? `<img class="car-img" src="${esc(c.image)}" alt="">` : ""}
<div class="car-head"><div class="name">${esc(c.name || t("Car"))}</div><div class="wt">${charging ? Icons.svg("bolt", "inline") : ""}${esc(status)}</div></div>
<div class="car-main">
${pct != null ? `<span class="big num">${pct}<span class="unit">%</span></span>` : ""}
${range ? `<span class="car-range">${esc(fmtState(range))}<span class="wt"> ${t("range")}</span></span>` : ""}
${low ? `<span class="level">${t("Low")}</span>` : ""}
</div>
${bar}
${actions.length || odo ? `<div class="car-actions">${actions.join("")}${odo ? `<span class="wt car-odo">${esc(fmtState(odo))}</span>` : ""}</div>` : ""}
</div>`;
},
};
W.text = {
label: "Text", icon: "text", size: [4, 2],
fields: [
{ k: "text", t: "textarea", l: "Text — use {{ sensor.x }} for live values", d: "Inside it’s {{ sensor.living_room_temperature }}." },
{ k: "size", t: "number", l: "Font size (px)", d: 17 },
],
entities: (c) => [...String(c.text || "").matchAll(/\{\{\s*([a-z_]+\.[a-z0-9_]+)\s*\}\}/g)].map((m) => m[1]),
render(c, { ha }) {
const html = esc(c.text).replace(/\{\{\s*([a-z_]+\.[a-z0-9_]+)\s*\}\}/g, (_, eid) => `<b>${esc(fmtState(ha.states[eid]))}</b>`);
return `<div class="text-w" style="font-size:${Math.max(13, Number(c.size) || 17)}px">${html}</div>`;
},
};
// ---------------------------------------------------------------- photos
// One image, a slideshow of URLs, or a slideshow of Immich albums (proxied by the server).
const urlList = (c) => String(c.urls ?? c.url ?? "").split(/\s*\n\s*/).map((u) => u.trim()).filter(Boolean);
W.image = {
label: "Photos", icon: "image", size: [4, 3], flush: true,
fields: [
{ k: "source", t: "seg", l: "Source", o: ["links", "immich"], d: "links" },
{ k: "urls", t: "textarea", l: "Image URLs, one per line", d: "", when: (c) => c.source !== "immich" },
{ k: "albums", t: "albums", l: "Immich albums", when: (c) => c.source === "immich" },
{ k: "interval", t: "number", l: "Change photo every (seconds)", d: 20 },
{ k: "shuffle", t: "check", l: "Shuffle", d: true },
{ k: "fit", t: "seg", l: "Fit", o: ["cover", "contain"], d: "cover" },
{ k: "showDate", t: "check", l: "Show when the photo was taken", d: false, when: (c) => c.source === "immich" },
{ k: "alt", t: "text", l: "Description (for screen readers)" },
],
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" 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) {
return slideshow(el, el.querySelector(".photos"), ctx, ctx.cache, { source: c.source, urls: c.urls ?? c.url, albums: c.albums, shuffle: c.shuffle, interval: c.interval, showDate: c.showDate });
},
};
/** Load photos from links or Immich albums and cross-fade through them in `box` (.photos with two .ph-layer images).
* Used by the Photos card and as a clock background; `cache` keeps the list and position across redraws. */
async function slideshow(el, box, ctx, cache, o) {
clearTimeout(cache.timer);
if (!box) return;
const msg = box.querySelector(".ph-msg");
const key = JSON.stringify([o.source, o.urls, o.albums, o.shuffle]);
if (cache.key !== key || Date.now() - cache.at > 15 * 60 * 1000) {
cache.key = key; cache.at = Date.now(); cache.pos = 0;
cache.loading = (async () => {
if (o.source !== "immich") return urlList({ urls: o.urls }).map((src) => ({ src }));
const lists = await Promise.all((o.albums || []).map((id) => ctx.api(`api/immich/albums/${id}/assets`)));
return lists.flat().map((a) => ({ src: `api/immich/assets/${a.id}/preview`, date: a.date }));
})().then((items) => {
if (o.shuffle) for (let i = items.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [items[i], items[j]] = [items[j], items[i]]; }
cache.items = items; cache.error = null;
}).catch((e) => { cache.items = []; cache.error = e.message; });
}
await cache.loading;
if (!el.isConnected) return;
const say = (text) => { if (msg) msg.innerHTML = `<div class="empty">${text}</div>`; };
if (cache.error) { say(t("Couldn’t load photos. {0}", esc(cache.error))); return; }
if (!cache.items.length) { say(t("These albums have no photos yet.")); return; }
const layers = box.querySelectorAll(".ph-layer"), dateEl = box.querySelector(".ph-date");
let front = 0;
const show = () => {
if (!el.isConnected) return;
const item = cache.items[cache.pos % cache.items.length];
cache.pos++;
const next = layers[1 - front];
next.onload = () => {
next.classList.add("on"); layers[front].classList.remove("on"); front = 1 - front;
if (msg) msg.innerHTML = "";
if (dateEl) dateEl.textContent = o.showDate && item.date ? new Date(item.date).toLocaleDateString(LOCALE, { day: "numeric", month: "long", year: "numeric" }) : "";
};
next.onerror = () => { if (cache.items.length > 1) cache.timer = setTimeout(show, 500); };
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();
}
// ---------------------------------------------------------------- calendar
const CAL_COLORS = ["var(--d-accent)", "var(--ok)", "var(--warn)", "#af52de", "#ff2d55", "#5ac8fa"];
const dayKey = (d) => `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
const parseWhen = (v) => (typeof v === "string" && !v.includes("T") ? new Date(`${v}T00:00:00`) : new Date(v?.dateTime || v?.date || v));
const hm = (d) => d.toLocaleTimeString(LOCALE, { hour: "2-digit", minute: "2-digit" });
W.calendar = {
label: "Calendar", icon: "calendar", size: [4, 4],
fields: [
{ k: "title", t: "text", l: "Title", d: "Upcoming" },
{ k: "entities", t: "entities", l: "Calendars", domains: ["calendar"] },
{ k: "days", t: "number", l: "Days ahead", d: 7 },
{ k: "max", t: "number", l: "Most events to show", d: 12 },
{ k: "showCalendar", t: "check", l: "Show which calendar", d: true },
],
entities: (c) => c.entities || [],
render(c) {
if (!c.entities?.length) return `${title(c.title)}<div class="empty">${t("Add one or more calendars in the inspector.")}</div>`;
return `${title(c.title)}<div class="cal"><div class="empty">${t("Loading events…")}</div></div>`;
},
async after(el, c, ctx) {
if (!c.entities?.length) return;
const days = Math.max(1, Number(c.days) || 7);
const key = c.entities.join(",") + "|" + days;
const cache = ctx.cache;
if (cache.key !== key || Date.now() - cache.at > 5 * 60 * 1000) {
cache.key = key; cache.at = Date.now();
const start = new Date(); start.setHours(0, 0, 0, 0);
const end = new Date(start.getTime() + days * 86400e3);
cache.loading = ctx.ha.calendarEvents(c.entities, start, end)
.then((res) => { cache.res = res || {}; cache.error = null; })
.catch((e) => { cache.res = {}; cache.error = e.message; });
}
await cache.loading;
const list = el.querySelector(".cal");
if (!el.isConnected || !list) return;
if (cache.error) { list.innerHTML = `<div class="empty">${t("Couldn’t load events. {0}", esc(cache.error))}</div>`; return; }
const now = new Date();
const events = [];
c.entities.forEach((eid, i) => {
for (const ev of cache.res[eid]?.events || []) {
const allDay = typeof ev.start === "string" ? !ev.start.includes("T") : !!ev.start?.date;
const s = parseWhen(ev.start), e = parseWhen(ev.end);
if (e < now) continue;
events.push({ ...ev, s, e, allDay, cal: friendly(ctx.ha.states[eid]), color: CAL_COLORS[i % CAL_COLORS.length] });
}
});
events.sort((a, b) => a.s - b.s || b.allDay - a.allDay);
const shown = events.slice(0, Math.max(1, Number(c.max) || 12));
if (!shown.length) { list.innerHTML = `<div class="empty">${t("Nothing planned for the next {0} days.", days)}</div>`; return; }
const today = new Date(); today.setHours(0, 0, 0, 0);
const label = (d) => {
const diff = Math.round((new Date(d).setHours(0, 0, 0, 0) - today) / 86400e3);
return diff <= 0 ? t("Today") : diff === 1 ? t("Tomorrow") : d.toLocaleDateString(LOCALE, { weekday: "long", day: "numeric", month: "long" });
};
let html = "", lastDay = "";
const multi = c.entities.length > 1 && c.showCalendar;
for (const ev of shown) {
const day = ev.s < today ? today : ev.s;
if (dayKey(day) !== lastDay) { lastDay = dayKey(day); html += `<div class="cal-day">${esc(label(day))}</div>`; }
const when = ev.allDay ? t("All day") : `${hm(ev.s)}–${hm(ev.e)}`;
const live = !ev.allDay && ev.s <= now && ev.e > now;
html += `<div class="cal-ev ${live ? "live" : ""}"><span class="cal-bar" style="background:${ev.color}"></span>
<span class="cal-when num">${esc(when)}</span>
<span class="cal-what"><span class="cal-sum">${esc(ev.summary || t("Busy"))}</span>${ev.location || multi ? `<span class="cal-sub">${esc([multi ? ev.cal : "", ev.location].filter(Boolean).join(" · "))}</span>` : ""}</span>
${live ? `<span class="cal-now">${t("Now")}</span>` : ""}</div>`;
}
list.innerHTML = html;
},
};
W.iframe = {
label: "Web page", icon: "globe", size: [6, 4], flush: true,
fields: [{ k: "url", t: "text", l: "URL" }],
render: (c) => (c.url ? `<iframe class="frame" src="${esc(c.url)}" loading="lazy" title="${t("Embedded page")}"></iframe>` : `<div class="pad"><div class="empty">${t("Add a URL in the inspector.")}</div></div>`),
};
// ---------------------------------------------------------------- waste (Afvalwijzer)
// Reads the per-type sensors of the Afvalwijzer integration: state is the next pickup
// date (ISO, or a timestamp), with days_until_collection_date and is_collection_date_*.
const WASTE_TYPES = {
gft: ["Organic", "#4CAF50"], papier: ["Paper", "#2196F3"], pmd: ["PMD", "#800000"], plastic: ["Plastic", "#FF9800"],
restafval: ["Residual waste", "#9E9E9E"], restafvalzakken: ["Residual waste bags", "#9E9E9E"], restwagen: ["Residual waste", "#9E9E9E"],
textiel: ["Textile", "#00695C"], grofvuil: ["Bulky waste", "#795548"], kca: ["Hazardous waste", "#F44336"], luiers: ["Nappies", "#FFA000"],
glas: ["Glass", "#009688"], snoeiafval: ["Pruning waste", "#2E7D32"], tuinafval: ["Garden waste", "#2E7D32"], kerstbomen: ["Christmas trees", "#1B5E20"],
best_tas: ["BEST bag", "#6D4C41"], milieubus: ["Hazardous waste van", "#F44336"], grip: ["GRIP", "#607D8B"], maas: ["Maas", "#607D8B"],
};
const WASTE_KEYS = Object.keys(WASTE_TYPES).sort((a, b) => b.length - a.length);
const isWasteSensor = (s) => !!s && s.entity_id.startsWith("sensor.afvalwijzer_") && "is_collection_date_today" in s.attributes;
const wasteKey = (eid) => WASTE_KEYS.find((k) => eid.endsWith(`_${k}`)) || eid.split("_").pop();
function wasteItems(c, ha) {
const ids = c.entities?.length ? c.entities : Object.keys(ha.states).filter((e) => isWasteSensor(ha.states[e]));
const today = new Date(); today.setHours(0, 0, 0, 0);
const out = [];
for (const eid of ids) {
const s = ha.states[eid];
if (!s) continue;
const raw = s.state;
const date = /^\d{4}-\d{2}-\d{2}$/.test(raw) ? new Date(`${raw}T00:00:00`) : new Date(raw);
if (isNaN(date)) continue; // "geen" or unavailable: no pickup planned
date.setHours(0, 0, 0, 0);
const days = s.attributes.days_until_collection_date ?? Math.round((date - today) / 86400e3);
if (days < 0) continue;
const key = wasteKey(eid), known = WASTE_TYPES[key];
out.push({ eid, key, days, date, name: known ? t(known[0]) : friendly(s).replace(/^afvalwijzer\s*/i, "") || cap(key), color: known?.[1] || "var(--d-text-2)" });
}
return out.sort((a, b) => a.days - b.days || a.name.localeCompare(b.name));
}
const whenLabel = (d) => (d === 0 ? t("Today") : d === 1 ? t("Tomorrow") : d === 2 ? t("Day after tomorrow") : t("In {0} days", d));
const shortDate = (d) => d.toLocaleDateString(LOCALE, { weekday: "short", day: "numeric", month: "short" });
W.waste = {
label: "Waste collection", icon: "bin", size: [4, 4],
fields: [
{ k: "title", t: "text", l: "Title", d: "Waste collection" },
{ k: "layout", t: "seg", l: "Layout", o: ["next", "list"], d: "next" },
{ k: "entities", t: "entities", l: "Waste sensors (leave empty to find them automatically)", domains: ["sensor"] },
{ k: "max", t: "number", l: "Most pickups to show", d: 6 },
{ k: "within", t: "number", l: "Only pickups within (days)", d: 31 },
],
// With automatic discovery every Afvalwijzer sensor matters, so listen to all of them.
entities: (c, ha) => (c.entities?.length ? c.entities : Object.keys(ha?.states || {}).filter((e) => e.startsWith("sensor.afvalwijzer_"))),
render(c, { ha }) {
const within = Number(c.within) || 31;
const items = wasteItems(c, ha).filter((i) => i.days <= within).slice(0, Math.max(1, Number(c.max) || 6));
if (!items.length) {
const any = c.entities?.length || Object.values(ha.states).some(isWasteSensor);
return `${title(c.title)}<div class="empty">${any ? t("No pickups in the next {0} days.", within) : t("No Afvalwijzer sensors found. Install the Afvalwijzer integration, or pick the sensors in the inspector.")}</div>`;
}
const dot = (i) => `<span class="bin" style="--bin:${i.color}" aria-hidden="true">${Icons.svg("bin")}</span>`;
let head = "", rest = items;
if (c.layout !== "list") {
const first = items.filter((i) => i.days === items[0].days);
rest = items.slice(first.length);
const soon = first[0].days <= 1;
head = `<div class="waste-next ${soon ? "soon" : ""}">
<div class="waste-bins">${first.map(dot).join("")}</div>
<div class="waste-next-text"><span class="wt">${t("Next pickup")}</span>
<span class="waste-when">${esc(whenLabel(first[0].days))}</span>
<span class="waste-types">${esc(first.map((i) => i.name).join(" · "))}</span>
${first[0].days === 1 ? `<span class="level">${t("Put the bin out tonight")}</span>` : ""}</div></div>`;
}
const rows = rest.map((i) => `<button class="list-row waste-row wbtn" data-act="more" data-e="${esc(i.eid)}">${dot(i)}
<span class="grow">${esc(i.name)}</span><span class="waste-day ${i.days <= 1 ? "soon" : ""}">${esc(whenLabel(i.days))}</span><span class="st">${esc(shortDate(i.date))}</span></button>`).join("");
return `${title(c.title)}${head}${rows ? `<div class="list waste-list">${rows}</div>` : ""}`;
},
};
// ---------------------------------------------------------------- containers
// Scroll and swipe cards hold other cards in cfg.children. They draw placeholders;
// ctx.renderChildren fills them using the normal widget renderer.
const slots = (c, ctx) => (c.children || []).filter((ch) => ctx.isVisible(ch));
const slot = (ch) => `<div class="w child" data-id="${esc(ch.id)}"></div>`;
const noKids = (c) => `<div class="pad"><div class="empty">${(c.children || []).length ? t("None of these cards are showing right now.") : t("Add cards in the inspector.")}</div></div>`;
W.hscroll = {
label: "Horizontal scroll", icon: "hscroll", size: [12, 3], container: true, transparent: true, flush: true,
fields: [
{ k: "title", t: "text", l: "Title", d: "" },
{ k: "itemWidth", t: "number", l: "Card width (px)", d: 260 },
],
render(c, ctx) {
const kids = slots(c, ctx);
if (!kids.length) return noKids(c);
return `<div class="hs">${c.title ? `<div class="wt hs-title">${esc(c.title)}</div>` : ""}
<div class="hs-track" style="--item-w:${Math.max(120, Number(c.itemWidth) || 260)}px" tabindex="0" role="group" aria-label="${esc(c.title || t("Horizontal scroll"))}">${kids.map(slot).join("")}</div>
<button class="hs-nav prev wbtn" data-act="hs-prev" aria-label="${t("Scroll left")}">${Icons.svg("chevron", "back")}</button>
<button class="hs-nav next wbtn" data-act="hs-next" aria-label="${t("Scroll right")}">${Icons.svg("chevron", "fwd")}</button></div>`;
},
after(el, c, ctx) {
ctx.renderChildren(el);
const track = el.querySelector(".hs-track"), box = el.querySelector(".hs");
if (!track) return;
// Keep the scroll position when a card inside updates and everything is redrawn.
track.scrollLeft = ctx.cache.scroll || 0;
const edges = () => {
box.classList.toggle("at-start", track.scrollLeft < 4);
box.classList.toggle("at-end", track.scrollLeft + track.clientWidth > track.scrollWidth - 4);
};
track.addEventListener("scroll", () => { ctx.cache.scroll = track.scrollLeft; edges(); }, { passive: true });
edges();
},
};
W.swipe = {
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 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" 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 sw = el.querySelector(".sw"), track = el.querySelector(".sw-track");
if (!track) return;
const cache = ctx.cache, n = track.children.length;
const reduce = () => matchMedia("(prefers-reduced-motion: reduce)").matches;
let page = Math.min(cache.page || 0, n - 1);
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(() => {
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);
},
};
window.Widgets = W;
window.HAFmt = { esc, domainOf, isOn, friendly, fmtState, icon, relTime, clockHTML, TOGGLE_DOMAINS, cap };
})();