Dashboard Studio 2.4.1: compact litter box card

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-31 16:07:31 +01:00
parent 0b2027083a
commit db6684b787
6 changed files with 76 additions and 44 deletions
+1 -1
View File
@@ -302,7 +302,7 @@
"Clean button": "Schoonmaakknop", "Auto clean switch": "Schakelaar automatisch schoonmaken", "Choose the litter box’s status entity in the inspector.": "Kies de status-entiteit van de kattenbak in de inspector.",
"Emptying": "Aan het legen", "Levelling": "Aan het egaliseren", "Sanitizing": "Aan het ontsmetten", "Sleeping": "Slaapt", "Ready": "Klaar voor gebruik",
"Bin full": "Afvalbak vol", "Check the litter box": "Kijk even bij de kattenbak", "Cat inside": "Kat in de bak", "Needs attention": "Heeft aandacht nodig",
"visits today": "bezoekjes vandaag", "visits": "bezoekjes", "in the box today": "in de bak vandaag", "cat’s weight": "gewicht kat", "Clean now": "Nu schoonmaken", "Auto clean": "Automatisch schoonmaken",
"visits today": "bezoekjes vandaag", "visits": "bezoekjes", "visit": "bezoekje", "Clean": "Schoonmaken", "Auto": "Auto", "in the box today": "in de bak vandaag", "cat’s weight": "gewicht kat", "Clean now": "Nu schoonmaken", "Auto clean": "Automatisch schoonmaken",
"Starts later": "Start later", "Action needed": "Actie nodig", "Error": "Fout", "Stopping": "Stoppen", "Door locked": "Deur vergrendeld",
"Ready at {0}": "Klaar om {0}", "Check the appliance": "Kijk even bij het apparaat", "Something went wrong": "Er ging iets mis", "Stop": "Stop",
"Hide this card when none of its cards are showing": "Verberg deze kaart als geen van de kaarten erin zichtbaar is",
+43 -20
View File
@@ -1575,31 +1575,46 @@ body.in-haven #btn-new { display: none; }
.appl-fact { font-size: var(--c-fs-meta); font-weight: 500; }
.appl-big { font-size: clamp(22px, 17cqh, 34px); }
/* litter box: what it's doing in words, today's figures, clean now and auto clean */
.lb .appl-text { gap: 4px; }
.lb .appl-main-text { flex-wrap: nowrap; overflow: hidden; }
.lb .appl-big { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.lb.phase-cleaning .appl-big { color: var(--d-accent); }
.lb.phase-attention .appl-big { color: var(--warn); }
.lb.phase-cleaning .appl-ico { color: var(--d-accent); }
.lb.phase-attention .appl-ico { color: var(--warn); }
/* litter box: compact; icon, name and controls on top, what it's doing, today's figures in one line */
.lb { display: flex; align-items: stretch; gap: 16px; height: 100%; min-width: 0; }
.lb-ring { display: none; }
.lb-ring > svg { transform: none; }
.lb-spin { transform-origin: 50px 50px; animation: lb-spin 1.6s linear infinite; }
@keyframes lb-spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) { .lb-spin { animation: none; } }
.lb-figs { display: flex; gap: 18px; min-width: 0; }
.lb-fig { display: flex; flex-direction: column; min-width: 0; }
.lb-fig b { font-size: var(--c-fs-mid); font-weight: 400; line-height: 1.15; white-space: nowrap; }
.lb-s { display: none; }
.lb-fig span { font-size: var(--c-fs-meta); color: var(--d-text-2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.lb-clean { flex: none; display: inline-flex; align-items: center; gap: 6px; height: var(--c-ctl); padding: 0 14px; border-radius: 99px; background: var(--d-fill); font-size: var(--c-fs-body); font-weight: 600; }
.lb-body { flex: 1; min-width: 0; display: grid; grid-template-columns: auto minmax(0, 1fr) auto; grid-template-areas: "ico name name" "state state acts" "figs figs figs" "warn warn warn";
align-content: space-between; align-items: center; column-gap: 8px; row-gap: 2px; }
.lb-ico { grid-area: ico; display: flex; font-size: var(--c-ico); color: var(--d-text-2); }
.lb-name { grid-area: name; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.lb-acts { grid-area: acts; display: flex; align-items: center; gap: 8px; }
.lb-state { grid-area: state; min-width: 0; font-size: clamp(20px, 16cqh, 32px); font-weight: 400; letter-spacing: -0.03em; line-height: 1.15; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.lb-figs { grid-area: figs; min-width: 0; display: flex; flex-wrap: wrap; column-gap: 12px; font-size: var(--c-fs-meta); color: var(--d-text-2); }
.lb-fig { white-space: nowrap; }
.lb-fig b { font-size: var(--c-fs-body); font-weight: 500; color: var(--d-text); }
.lb-warn { grid-area: warn; display: flex; flex-wrap: wrap; gap: 6px; margin-top: 4px; min-width: 0; }
.lb.phase-cleaning .lb-state, .lb.phase-cleaning .lb-ico, .lb.phase-cleaning .appl-ico { color: var(--d-accent); }
.lb.phase-attention .lb-state, .lb.phase-attention .lb-ico, .lb.phase-attention .appl-ico { color: var(--warn); }
.lb-clean { flex: none; display: inline-flex; align-items: center; justify-content: center; gap: 6px; height: var(--c-ctl); min-width: var(--c-ctl); padding: 0 12px; border-radius: 99px; background: var(--d-fill); font-size: var(--c-fs-meta); font-weight: 600; }
.lb-clean:disabled { opacity: .5; cursor: default; }
@media (hover: hover) { .lb-clean:not(:disabled):hover { background: color-mix(in srgb, var(--d-text) 14%, transparent); } }
.lb-auto { flex: none; display: inline-flex; align-items: center; gap: 8px; font-size: var(--c-fs-meta); color: var(--d-text-2); }
.lb-fig .unit { font-size: var(--c-fs-meta); }
@container (max-width: 240px) { .lb-fig:nth-child(3) { display: none; } }
@container (max-width: 400px) { .lb-figs { gap: 12px; } .lb-auto-l { display: none; } .lb-clean span { display: none; } .lb-clean { width: var(--c-ctl); padding: 0; justify-content: center; } }
@container (max-height: 180px) { .lb .appl-text { gap: 2px; } .lb-fig { flex-direction: row; align-items: baseline; gap: 5px; } .lb-fig b { font-size: var(--c-fs-body); } .lb-l { display: none; } .lb-s { display: inline; } }
.lb-auto { flex: none; display: inline-flex; align-items: center; gap: 6px; font-size: var(--c-fs-meta); color: var(--d-text-2); }
/* narrow: the controls as icons */
@container (max-width: 320px) { .lb-clean { padding: 0; } .lb-clean span { display: none; } }
/* narrow: clean now stays, auto clean (a setting you rarely change) goes; the tiniest cards keep just the state */
@container (max-width: 260px) { .lb-auto-l { display: none; } .lb-acts:has(.lb-clean) .lb-auto { display: none; } .lb-figs { column-gap: 8px; } }
@container (max-width: 150px) { .lb-acts { display: none; } .lb-ico { display: none; } .lb-body { column-gap: 0; } }
/* one row high: everything on one line */
@container (max-height: 110px) {
.lb-body { grid-template-columns: auto minmax(0, max-content) minmax(0, 1fr) auto; grid-template-areas: "ico state figs acts"; align-content: center; column-gap: 12px; }
.lb-name, .lb-warn { display: none; }
.lb-state { font-size: var(--c-fs-mid); }
.lb-figs { line-height: 22px; height: 22px; overflow: hidden; } /* figures that don't fit wrap out of sight, whole */
}
@container (max-height: 110px) and (max-width: 360px) {
.lb-figs { display: none; }
.lb-body { grid-template-columns: auto minmax(0, 1fr) auto; grid-template-areas: "ico state acts"; }
}
@container (max-height: 110px) and (max-width: 280px) { .lb-acts:has(.lb-clean) .lb-auto { display: none; } }
/* camera and photos: square label plates (above) */
@@ -2032,4 +2047,12 @@ button.fader-input { cursor: pointer; }
.lt-colors { flex: 1; min-width: 0; display: flex; align-items: center; gap: 8px; }
.lt-colors .swatches { flex: 1; min-width: 0; margin: 0; justify-content: flex-start; }
.lt-colors .swatch { width: 28px; height: 28px; }
@container (min-height: 260px) { .lb-figs { justify-content: center; } .lb-fig { align-items: center; } .lb .appl-main { flex-direction: column; } }
/* tall: a ring on top, the rest centred below */
@container (min-height: 260px) {
.lb { flex-direction: column; align-items: center; justify-content: center; text-align: center; }
.lb-body { flex: none; width: 100%; grid-template-columns: minmax(0, 1fr); grid-template-areas: "name" "state" "figs" "acts" "warn"; justify-items: center; row-gap: 4px; }
.lb-ico { display: none; }
.lb-figs { justify-content: center; }
.lb-acts { margin-top: 6px; }
}
@container (min-height: 260px) and (min-width: 180px) { .lb-ring { display: grid; height: min(50%, 170px); } }
+26 -21
View File
@@ -1072,39 +1072,44 @@
return { phase: "ready", word: /^(standby|idle|ready|on|off|none|)$/.test(s) ? t("Ready") : st(s), alerts };
}
// Compact: icon, name and controls on top, what it's doing below, today's figures as one short line.
// One row high it all goes on one line; tall cards put a ring on top (see the container queries in style.css).
function litterBox(c, ha) {
if (!c.status) return `<div class="empty">${t("Choose the litter box’s status entity in the inspector.")}</div>`;
const { phase, word, alerts } = litterPhase(c, ha);
// figures: visits and time today, and the cat's weight; the unit in grey
const fig = (eid, lbl, fmt, short = "") => {
const val = (eid) => {
const s = ha.states[eid];
if (!s || ["unknown", "unavailable"].includes(s.state)) return "";
const n = Number(s.state), unit = s.attributes.unit_of_measurement || "";
const v = fmt ? fmt(n, unit) : `${isFinite(n) ? n.toLocaleString(LOCALE, { maximumFractionDigits: 1 }) : esc(s.state)}${unit ? `<small class="unit"> ${esc(unit)}</small>` : ""}`;
return `<div class="lb-fig"><b class="num">${v}</b><span class="lb-l">${esc(lbl)}</span>${short ? `<span class="lb-s">${esc(short)}</span>` : ""}</div>`;
return s && !["unknown", "unavailable"].includes(s.state) ? s : null;
};
// time today: Tuya reports it in seconds or minutes, shown as minutes
const mins = (n, unit) => {
if (!isFinite(n)) return "–";
const m = Math.round(/^s/.test(unit) ? n / 60 : /^h/.test(unit) ? n * 60 : n);
return `${m}<small class="unit"> min</small>`;
};
const figs = [fig(c.visits, t("visits today"), (n) => (isFinite(n) ? String(Math.round(n)) : "–"), t("visits")), fig(c.timeToday, t("in the box today"), mins), fig(c.catWeight, t("cat’s weight"))].join("");
const fig = (v, unit, title) => `<span class="lb-fig" title="${esc(title)}"><b class="num">${v}</b> ${esc(unit)}</span>`;
const visits = val(c.visits), time = val(c.timeToday), weight = val(c.catWeight);
const figs = [
visits && isFinite(Number(visits.state)) && fig(Math.round(Number(visits.state)), Number(visits.state) === 1 ? t("visit") : t("visits"), t("Visits today")),
// time today: Tuya reports it in seconds or minutes, shown as minutes
time && isFinite(Number(time.state)) && (() => {
const n = Number(time.state), unit = String(time.attributes.unit_of_measurement || "min").toLowerCase();
return fig(Math.round(unit.startsWith("s") ? n / 60 : unit.startsWith("h") ? n * 60 : n), t("min"), t("Time in the box today"));
})(),
weight && fig(isFinite(Number(weight.state)) ? Number(weight.state).toLocaleString(LOCALE, { maximumFractionDigits: 1 }) : esc(weight.state), weight.attributes.unit_of_measurement || "", t("Cat’s weight")),
].filter(Boolean).join("");
const device = friendly(ha.states[c.status]).replace(/\s*(status|state)$/i, "");
const strip = (txt) => (device && txt.toLowerCase().startsWith(device.toLowerCase() + " ") ? txt.slice(device.length + 1) : txt);
const warn = alerts.map((e) => `<span class="appl-fact warn">${Icons.svg("shield")}<span>${esc(cap(strip(friendly(ha.states[e]))))}</span></span>`).join("");
const clean = c.cleanBtn && ha.states[c.cleanBtn]
? `<button class="lb-clean wbtn" data-act="svc" data-e="${esc(c.cleanBtn)}" data-v="${domainOf(c.cleanBtn) === "script" ? "script.turn_on" : "button.press"}" ${phase === "cleaning" ? "disabled" : ""}>${Icons.svg("sparkles")}<span>${t("Clean now")}</span></button>` : "";
? `<button class="lb-clean wbtn" data-act="svc" data-e="${esc(c.cleanBtn)}" data-v="${domainOf(c.cleanBtn) === "script" ? "script.turn_on" : "button.press"}" ${phase === "cleaning" ? "disabled" : ""} title="${t("Clean now")}" aria-label="${t("Clean now")}">${Icons.svg("sparkles")}<span>${t("Clean")}</span></button>` : "";
const auto = ha.states[c.autoClean];
const autoSw = auto ? `<span class="lb-auto"><span class="lb-auto-l">${t("Auto clean")}</span><button class="light-switch wbtn" role="switch" aria-checked="${isOn(auto)}" data-act="toggle" data-e="${esc(c.autoClean)}" aria-label="${t("Auto clean")}"><span class="switch"></span></button></span>` : "";
return `<div class="appl lb phase-${phase}">
const autoSw = auto ? `<span class="lb-auto" title="${t("Auto clean")}"><span class="lb-auto-l">${t("Auto")}</span><button class="light-switch wbtn" role="switch" aria-checked="${isOn(auto)}" data-act="toggle" data-e="${esc(c.autoClean)}" aria-label="${t("Auto clean")}"><span class="switch"></span></button></span>` : "";
const ico = phase === "attention" ? "shield" : "paw";
return `<div class="lb phase-${phase}">
<div class="appl-ring lb-ring" aria-hidden="true"><svg viewBox="0 0 100 100"><circle cx="50" cy="50" r="44" class="track"/>${phase === "cleaning" ? `<circle cx="50" cy="50" r="44" class="fill lb-spin" stroke-dasharray="70 207"/>` : ""}</svg>
<span class="appl-ico">${Icons.svg(phase === "attention" ? "shield" : "paw")}</span></div>
<div class="appl-text">
<div class="appl-top"><span class="name">${esc(c.name || t("Litter box"))}</span>${autoSw}</div>
<div class="appl-main"><div class="appl-main-text"><span class="appl-big ${phase === "off" ? "appl-off" : ""}">${esc(word)}</span></div>${clean}</div>
<span class="appl-ico">${Icons.svg(ico)}</span></div>
<div class="lb-body">
<span class="lb-ico" aria-hidden="true">${Icons.svg(ico)}</span>
<span class="name lb-name">${esc(c.name || t("Litter box"))}</span>
${clean || autoSw ? `<div class="lb-acts">${autoSw}${clean}</div>` : ""}
<span class="lb-state ${phase === "off" ? "appl-off" : ""}">${esc(word)}</span>
${figs ? `<div class="lb-figs">${figs}</div>` : ""}
${warn ? `<div class="appl-facts">${warn}</div>` : ""}
${warn ? `<div class="lb-warn">${warn}</div>` : ""}
</div></div>`;
}