117 files

This commit is contained in:
Home Assistant Version Control
2026-08-08 10:38:07 +00:00
parent c31830deeb
commit ca54a47de7
119 changed files with 9737 additions and 623 deletions
+10 -8
View File
@@ -306,11 +306,13 @@ async def _async_require_admin(hass: HomeAssistant, call: ServiceCall) -> None:
async def _async_require_parent(hass: HomeAssistant, call: ServiceCall) -> None:
"""Reject user-initiated calls that aren't from an admin or a TaskMate parent.
Day-to-day parent actions (approve/reject, gift/adjust points, confirm
rewards/allowance, award badges, complete-as-parent) accept non-admin HA
users listed in ``parent_user_ids`` (issue #661). Context-less calls
(automations, scripts) pass. Structural config stays on
``_async_require_admin``.
Day-to-day parent actions (approve/reject, gift/adjust points, apply a
bonus or penalty, confirm rewards/allowance, award badges,
complete-as-parent) accept non-admin HA users listed in ``parent_user_ids``
(issue #661). Context-less calls (automations, scripts) pass. Structural
config — including *defining* bonuses and penalties — stays on
``_async_require_admin``; only applying an existing one is a parent action
(issue #749).
"""
if not call.context.user_id:
return
@@ -1324,7 +1326,7 @@ async def _async_register_services(hass: HomeAssistant) -> None:
hass.services.async_register(
DOMAIN,
SERVICE_REMOVE_POINTS,
_admin(handle_remove_points),
_parent(handle_remove_points),
schema=vol.Schema(
{
vol.Required(ATTR_CHILD_ID): cv.string,
@@ -1399,7 +1401,7 @@ async def _async_register_services(hass: HomeAssistant) -> None:
hass.services.async_register(
DOMAIN,
SERVICE_APPLY_PENALTY,
_admin(handle_apply_penalty),
_parent(handle_apply_penalty),
schema=vol.Schema({
vol.Required(ATTR_PENALTY_ID): cv.string,
vol.Required(ATTR_CHILD_ID): cv.string,
@@ -1443,7 +1445,7 @@ async def _async_register_services(hass: HomeAssistant) -> None:
hass.services.async_register(
DOMAIN,
SERVICE_APPLY_BONUS,
_admin(handle_apply_bonus),
_parent(handle_apply_bonus),
schema=vol.Schema({
vol.Required(ATTR_BONUS_ID): cv.string,
vol.Required(ATTR_CHILD_ID): cv.string,
+1 -1
View File
@@ -17,5 +17,5 @@
"iot_class": "calculated",
"issue_tracker": "https://github.com/tempus2016/taskmate/issues",
"requirements": [],
"version": "5.0.3"
"version": "5.0.4"
}
@@ -95,6 +95,23 @@
window.__taskmate_is_parent = isTaskmateParent;
/**
* Stable id of a badge entry from the badges sensor.
*
* ChildBadgesSensor publishes earned/available entries keyed `badge_id`, but
* several card templates were written against `b.id` — undefined on those
* entries, so every "is this the badge that was just earned?" test compared
* against the string "undefined" and never matched (#752). Read the id
* through this helper so both spellings work.
*/
function badgeId(badge) {
if (!badge) return "";
const id = badge.badge_id != null ? badge.badge_id : badge.id;
return id != null ? String(id) : "";
}
window.__taskmate_badge_id = badgeId;
/**
* Event-driven update guard for LitElement cards.
*
@@ -36,58 +36,54 @@ class TaskMateBadgesCard extends LitElement {
super();
this._filterTier = "all";
this._justEarned = null;
this._unsubscribeEvents = null;
this._subscribing = false;
this._seenBadgeIds = null;
this._justEarnedTimeout = null;
}
connectedCallback() {
super.connectedCallback();
this._subscribeEvents();
}
disconnectedCallback() {
super.disconnectedCallback();
this._unsubscribeEvents?.();
this._unsubscribeEvents = null;
if (this._justEarnedTimeout) {
clearTimeout(this._justEarnedTimeout);
this._justEarnedTimeout = null;
}
}
_subscribeEvents() {
if (!this.hass) return;
if (this._unsubscribeEvents || this._subscribing) return;
this._subscribing = true;
this.hass.connection.subscribeEvents((event) => {
const data = event.data || {};
if (data.child_id && this.config?.child_id && String(data.child_id) !== String(this.config.child_id)) return;
if (data.badge_id) {
this._justEarned = String(data.badge_id);
this.requestUpdate();
this._justEarnedTimeout = setTimeout(() => {
this._justEarned = null;
this.requestUpdate();
}, 1800);
}
}, "taskmate_badge_earned").then(unsub => {
this._subscribing = false;
if (!this.isConnected) {
unsub();
return;
}
this._unsubscribeEvents = unsub;
}).catch(() => {
this._subscribing = false;
});
/* Newly-earned badges are detected by diffing the badges sensor's `earned`
list across hass updates (#752). The card used to subscribe to the custom
`taskmate_badge_earned` event, but Home Assistant refuses a custom-event
subscription from a non-admin user and logs an error every time — so the
highlight never fired for children, and the log filled up. `state_changed`
is allowlisted for everyone, so the diff works for every user. */
_trackEarnedBadges() {
const earned = this.hass?.states?.[this.config?.entity]?.attributes?.earned;
if (!Array.isArray(earned)) return false;
const ids = new Set(earned.map(b => window.__taskmate_badge_id(b)).filter(Boolean));
// First observation is the baseline — never flash a badge earned earlier.
if (this._seenBadgeIds === null) {
this._seenBadgeIds = ids;
return false;
}
const fresh = [...ids].filter(id => !this._seenBadgeIds.has(id));
this._seenBadgeIds = ids;
if (!fresh.length) return false;
this._justEarned = fresh[0];
clearTimeout(this._justEarnedTimeout);
this._justEarnedTimeout = setTimeout(() => {
this._justEarned = null;
this.requestUpdate();
}, 1800);
return true;
}
shouldUpdate(changedProps) {
if (changedProps.has("hass")) {
return window.__taskmate_hasChanged
const flashed = this._trackEarnedBadges();
const relevant = window.__taskmate_hasChanged
? window.__taskmate_hasChanged(changedProps.get("hass"), this.hass, this.config?.entity)
: true;
return flashed || relevant;
}
return true;
}
@@ -557,7 +553,7 @@ class TaskMateBadgesCard extends LitElement {
}
_renderEarned(b) {
const justEarned = this._justEarned && String(this._justEarned) === String(b.id);
const justEarned = !!this._justEarned && this._justEarned === window.__taskmate_badge_id(b);
return html`
<div class="badge earned tier-${b.tier} ${justEarned ? 'just-earned' : ''}">
${b.point_bonus > 0 ? html`<div class="badge-bonus">+${b.point_bonus}</div>` : ''}
@@ -43,9 +43,11 @@ class TaskMateChildCard extends LitElement {
shouldUpdate(changedProps) {
if (changedProps.has("hass")) {
return window.__taskmate_hasChanged
const flashed = this._trackEarnedBadges();
const relevant = window.__taskmate_hasChanged
? window.__taskmate_hasChanged(changedProps.get("hass"), this.hass, this.config?.entity)
: true;
return flashed || relevant;
}
return true;
}
@@ -66,19 +68,17 @@ class TaskMateChildCard extends LitElement {
// Badge strip state
this._earnedBadges = [];
this._justEarnedBadge = null;
this._badgeEventUnsub = null;
}
connectedCallback() {
super.connectedCallback();
this._subscribeBadgeEvents();
this._seenBadgeIds = null;
this._justEarnedTimeout = null;
}
disconnectedCallback() {
super.disconnectedCallback();
this._stopTimerTick();
this._badgeEventUnsub?.();
this._badgeEventUnsub = null;
if (this._justEarnedTimeout) {
clearTimeout(this._justEarnedTimeout);
this._justEarnedTimeout = null;
}
if (this._audioContext) {
this._audioContext.close().catch(() => {});
this._audioContext = null;
@@ -118,27 +118,37 @@ class TaskMateChildCard extends LitElement {
return fn ? fn(this.hass, key, params) : key;
}
_subscribeBadgeEvents() {
if (this._badgeEventUnsub || this._badgeSubscribing) return;
if (!this.hass?.connection) return;
this._badgeSubscribing = true;
this.hass.connection.subscribeEvents((event) => {
const data = event.data || {};
const configChild = this.config?.child_id;
if (configChild && data.child_id && String(data.child_id) !== String(configChild)) return;
if (data.badge_id) {
this._justEarnedBadge = String(data.badge_id);
this.requestUpdate();
setTimeout(() => { this._justEarnedBadge = null; this.requestUpdate(); }, 1800);
}
}, "taskmate_badge_earned").then(unsub => {
this._badgeSubscribing = false;
if (!this.isConnected) {
unsub();
return;
}
this._badgeEventUnsub = unsub;
}).catch(() => { this._badgeSubscribing = false; });
/* Newly-earned badges are detected by diffing this child's badges sensor
`earned` list across hass updates (#752). The card used to subscribe to the
custom `taskmate_badge_earned` event, but Home Assistant refuses a
custom-event subscription from a non-admin user — logging an error every
time — so the strip never lit up for a child. `state_changed` is
allowlisted for everyone, so the diff works for every user. */
_trackEarnedBadges() {
const attrs = (window.__taskmate_attrs && window.__taskmate_attrs(this.hass, this.config?.entity))
|| this.hass?.states?.[this.config?.entity]?.attributes || {};
const child = (attrs.children || []).find(c => c.id === this.config?.child_id)
|| (!this.config?.child_id && (attrs.children || [])[0]);
const earned = this._resolveBadgesEntity(child)?.attributes?.earned;
if (!Array.isArray(earned)) return false;
const ids = new Set(earned.map(b => window.__taskmate_badge_id(b)).filter(Boolean));
// First observation is the baseline — never flash a badge earned earlier.
if (this._seenBadgeIds === null) {
this._seenBadgeIds = ids;
return false;
}
const fresh = [...ids].filter(id => !this._seenBadgeIds.has(id));
this._seenBadgeIds = ids;
if (!fresh.length) return false;
this._justEarnedBadge = fresh[0];
clearTimeout(this._justEarnedTimeout);
this._justEarnedTimeout = setTimeout(() => {
this._justEarnedBadge = null;
this.requestUpdate();
}, 1800);
return true;
}
_tierColor(tier) {
@@ -874,7 +884,7 @@ class TaskMateChildCard extends LitElement {
overflow: hidden;
}
/* Chore number wrapper (icon removed) */
/* Chore number wrapper — holds the number-or-picture badge */
.chore-number-wrapper {
display: flex;
align-items: center;
@@ -902,6 +912,14 @@ class TaskMateChildCard extends LitElement {
flex-shrink: 0;
}
/* A chore picture sits where the digit would, so it has to carry the
same white-on-colour weight the numeral gets from its text-shadow. */
.chore-number-badge ha-icon {
--mdc-icon-size: 22px;
color: #fff;
filter: drop-shadow(1px 1px 2px rgba(0, 0, 0, 0.25));
}
.chore-card:hover .chore-number-badge {
transform: rotate(5deg) scale(1.1);
}
@@ -1137,10 +1155,6 @@ class TaskMateChildCard extends LitElement {
filter: saturate(0.7);
}
.chore-card.completed .chore-icon-container {
background: rgba(255, 255, 255, 0.8);
}
.chore-card.completed .chore-name {
color: var(--primary-text-color);
opacity: 0.7;
@@ -1608,6 +1622,7 @@ class TaskMateChildCard extends LitElement {
.chore-card { padding: 10px 12px; gap: 8px; min-height: 54px; flex-wrap: nowrap; }
.chore-info { flex: 1; min-width: 0; overflow: hidden; }
.chore-number-badge { width: 30px; height: 30px; min-width: 30px; font-size: 1rem; }
.chore-number-badge ha-icon { --mdc-icon-size: 18px; }
.chore-name { font-size: 0.95rem; }
.chore-points { font-size: 0.82rem; }
.chore-checkbox { width: 34px; height: 34px; min-width: 34px; border-radius: 8px; }
@@ -1969,6 +1984,15 @@ class TaskMateChildCard extends LitElement {
.tmd-undochip[disabled] { opacity: 0.6; cursor: default; }
.q-undo { background: var(--tmd-surface-2); color: var(--tmd-dim); border: 1px solid var(--tmd-border); padding: 7px 10px; font-size: 14px; }
/* Designed: an explicit chore picture in the glyph slot. The emoji it
replaces is sized by font-size, which an ha-icon ignores, so each row
type restates its own size. Inherits the row accent so the icon reads
as part of the style rather than a pasted-in glyph. */
.tmd-glyph-icon { color: var(--ac, var(--tmd-accent)); display: block; }
.tmd-chore .ch-emoji .tmd-glyph-icon { --mdc-icon-size: 22px; }
.tmd-quest .q-emoji .tmd-glyph-icon { --mdc-icon-size: 19px; }
.tmd-check .c-emoji .tmd-glyph-icon { --mdc-icon-size: 18px; }
/* Designed: pending-points + countdown chips on the header/section */
.tmd-pending {
margin-left: auto; display: inline-flex; align-items: center; gap: 4px;
@@ -2205,7 +2229,7 @@ class TaskMateChildCard extends LitElement {
<div class="badge-strip" @click=${() => this._openBadgesView()} title="${this._t('badges.label')}">
<span class="badge-strip-label">${this._t('badges.label')}</span>
${earnedBadges.slice(0, 5).map(b => html`
<div class="badge-mini tier-${b.tier} ${this._justEarnedBadge && String(this._justEarnedBadge) === String(b.id) ? 'just-earned' : ''}"
<div class="badge-mini tier-${b.tier} ${this._justEarnedBadge === window.__taskmate_badge_id(b) ? 'just-earned' : ''}"
style="--t: ${this._tierColor(b.tier)}">
<ha-icon icon="${b.icon || 'mdi:medal'}"></ha-icon>
</div>
@@ -2332,6 +2356,20 @@ class TaskMateChildCard extends LitElement {
return "⭐";
}
/** What a designed chore row shows in its glyph slot.
*
* An explicit picture wins over the keyword guess (#745) — otherwise
* choosing mdi:watering-can changed nothing, because _choreEmoji only ever
* matched on words. The guess stays as the fallback so chores with no
* picture, which is all of them until someone sets one, look as they did.
* The icon inherits the row's accent so it reads as part of the style.
*/
_choreGlyph(chore) {
return chore.icon
? html`<ha-icon icon="${chore.icon}" class="tmd-glyph-icon"></ha-icon>`
: this._choreEmoji(chore);
}
/** Mirror of _renderChoreCard's "completed today" detection, designed branch only. */
_isChoreDone(chore, child, todaysCompletions) {
const childCompletionsToday = (todaysCompletions || []).filter(
@@ -2411,7 +2449,7 @@ class TaskMateChildCard extends LitElement {
return {
chore, child, done, loading, onAct, index: i, dimmed,
tone: this._designTone(i),
emoji: this._choreEmoji(chore),
glyph: this._choreGlyph(chore),
points: chore.effective_points ?? chore.points,
timed: chore.task_type === "timed",
mandatory: chore.mandatory === true,
@@ -2465,7 +2503,7 @@ class TaskMateChildCard extends LitElement {
<div class="tmd-badges" @click=${() => this._openBadgesView()} title="${this._t("badges.label")}">
<span class="lbl">${this._t("badges.label")}</span>
${earnedBadges.slice(0, 5).map(b => html`
<div class="tmd-badge-mini ${this._justEarnedBadge && String(this._justEarnedBadge) === String(b.id) ? "just-earned" : ""}"
<div class="tmd-badge-mini ${this._justEarnedBadge === window.__taskmate_badge_id(b) ? "just-earned" : ""}"
style="--t:${this._tierColor(b.tier)}">
<ha-icon icon="${b.icon || "mdi:medal"}"></ha-icon>
</div>`)}
@@ -2569,7 +2607,7 @@ class TaskMateChildCard extends LitElement {
${rows.map(r => r.timed ? this._designTimed(r) : html`
<div class="tmd-chore ${r.done ? "done" : ""} ${r.mandatory ? "mandatory" : ""} ${r.dimmed ? "dimmed" : ""}" style="--ac:${r.tone}">
<div class="num-badge" style="${r.done ? "--ac:var(--tmd-good)" : ""}">${r.done ? "✓" : r.index + 1}</div>
<span class="ch-emoji">${r.emoji}</span>
<span class="ch-emoji">${r.glyph}</span>
<div class="ch-mid">
<div class="ch-name">${r.chore.name}</div>
${r.done ? "" : html`<div class="chip soft" style="margin-top:3px">+${r.points} ⭐</div>`}
@@ -2589,7 +2627,7 @@ class TaskMateChildCard extends LitElement {
${rows.map(r => r.timed ? this._designTimed(r) : html`
<div class="tmd-quest ${r.done ? "done" : ""} ${r.mandatory ? "mandatory" : ""} ${r.dimmed ? "dimmed" : ""}" style="--ac:${r.tone}">
<div class="num q-num" style="${r.done ? "color:var(--tmd-good)" : ""}">${r.done ? "✓" : String(r.index + 1).padStart(2, "0")}</div>
<span class="q-emoji">${r.emoji}</span>
<span class="q-emoji">${r.glyph}</span>
<div class="q-mid">
<div class="q-name">${r.chore.name}</div>
${r.done
@@ -2611,7 +2649,7 @@ class TaskMateChildCard extends LitElement {
${rows.map(r => r.timed ? this._designTimed(r) : html`
<div class="tmd-check ${r.done ? "done" : ""} ${r.mandatory ? "mandatory" : ""} ${r.dimmed ? "dimmed" : ""}" style="--ac:${r.tone}">
<div class="c-num" style="${r.done ? "--ac:var(--tmd-good)" : ""}">${r.done ? "✓" : r.index + 1}</div>
<span class="c-emoji">${r.emoji}</span>
<span class="c-emoji">${r.glyph}</span>
<div class="c-mid">
<div class="c-name">${r.chore.name}</div>
${this._designChoreMeta(r)}
@@ -3289,6 +3327,23 @@ class TaskMateChildCard extends LitElement {
`;
}
/** The coloured badge at the head of a chore row.
*
* A chore's picture takes the digit's place when one is set (#745) — the
* number is only positional, whereas the icon is what a child actually
* recognises. Chores with no picture keep their number, which is every
* chore that predates the field. Shared by the standard and timed rows so
* a picture can't work on one and be invisible on the other.
*/
_choreNumberBadge(chore, colorClass, choreNumber) {
return html`
<div class="chore-number-badge ${colorClass}">
${chore.icon
? html`<ha-icon icon="${chore.icon}"></ha-icon>`
: choreNumber}
</div>`;
}
_renderChoreCard(chore, child, pointsIcon, todaysCompletions = [], choreIndex = 0) {
const isLoading = this._loading[chore.id];
const isCelebrating = this._celebrating === chore.id;
@@ -3398,7 +3453,7 @@ class TaskMateChildCard extends LitElement {
>
<div class="chore-info">
<div class="chore-number-wrapper">
<div class="chore-number-badge ${colorClass}">${choreNumber}</div>
${this._choreNumberBadge(chore, colorClass, choreNumber)}
</div>
<div class="chore-details">
<div class="chore-name">${chore.name}${chore.mandatory ? html`<span class="mandatory-badge">⚠ ${this._t('child.mandatory')}</span>` : ''}</div>
@@ -3525,7 +3580,7 @@ class TaskMateChildCard extends LitElement {
<div class="timed-chore-card ${state} ${isLoading ? 'loading' : ''}">
<div class="timed-top-row">
<div class="chore-number-wrapper">
<div class="chore-number-badge ${colorClass}">${choreNumber}</div>
${this._choreNumberBadge(chore, colorClass, choreNumber)}
</div>
<div class="chore-details">
<div class="chore-name">${chore.name}</div>
@@ -466,6 +466,28 @@ export function createIncentiveCard(P) {
return this._getAttrs().points_name || this._t("common.stars");
}
/* ── Roles (#749) ──────────────────────────────────────────────────────
Applying an existing bonus/penalty is a day-to-day parent action, so
admins *and* TaskMate parents get the Apply buttons. Defining one is
structural config, so the manage controls (pencil, add/edit/delete) are
admin-only — matching the `_parent` / `_admin` service gates. A child on
a shared tablet sees the list with no buttons. */
_canApply() {
return window.__taskmate_is_parent(this.hass);
}
_canManage() {
return !!this.hass?.user?.is_admin;
}
_toggleEditMode() {
if (!this._canManage()) return;
this._editMode = !this._editMode;
this._editingBonus = null;
this._showNewForm = false;
}
_getVisibleBonuses() {
const child = this._getSelectedChild();
if (!child) return this._getBonuses();
@@ -482,7 +504,7 @@ export function createIncentiveCard(P) {
async _applyBonus(bonus) {
const child = this._getSelectedChild();
if (!child) return;
if (!child || !this._canApply()) return;
const key = bonus.id;
if (this._loading[key]) return;
this._loading = { ...this._loading, [key]: true };
@@ -630,7 +652,7 @@ export function createIncentiveCard(P) {
<ha-icon icon="mdi:trash-can-outline"></ha-icon>
</button>
</div>
` : html`
` : this._canApply() ? html`
<button class="apply-btn"
?disabled=${isLoading || !child}
@click=${() => this._applyBonus(b)}>
@@ -639,7 +661,7 @@ export function createIncentiveCard(P) {
: html`<ha-icon icon="${P.applyIcon}"></ha-icon> ${this._t('common.apply')}`
}
</button>
`}
` : ""}
</div>
${isEditing ? this._renderEditForm() : ""}
`;
@@ -745,10 +767,12 @@ export function createIncentiveCard(P) {
${bonuses.length ? html`
<span class="bonus-count">${bonuses.length}</span>
` : ""}
<button class="icon-btn ${this._editMode ? "active" : ""}" title="${this._tp('manage_title')}"
@click=${() => { this._editMode = !this._editMode; this._editingBonus = null; this._showNewForm = false; }}>
<ha-icon icon="mdi:pencil"></ha-icon>
</button>
${this._canManage() ? html`
<button class="icon-btn ${this._editMode ? "active" : ""}" title="${this._tp('manage_title')}"
@click=${this._toggleEditMode}>
<ha-icon icon="mdi:pencil"></ha-icon>
</button>
` : ""}
</div>
</div>
@@ -776,7 +800,7 @@ export function createIncentiveCard(P) {
`}
` : ""}
${child && !this._editMode ? html`
${child && !this._editMode && this._canApply() ? html`
<div style="text-align:center; font-size:0.8rem; color:var(--text-secondary); padding-top:4px;">
${this._tp('applying_to', { childName: child.name, points: child.points, pointsName: this._getPointsName() })}
</div>
@@ -831,6 +855,7 @@ export function createIncentiveCard(P) {
}
_designApplyBtn(b, klass) {
if (!this._canApply()) return "";
const child = this._getSelectedChild();
const isLoading = this._loading[b.id];
return html`
@@ -955,11 +980,13 @@ export function createIncentiveCard(P) {
<span class="ic">${icon}</span>
<span class="tt">${this.config.title || this._tp('default_title')}${
bonuses.length ? html`<small>${bonuses.length}</small>` : ""}</span>
<button class="btn ghost sm" style="margin-left:auto"
title="${this._tp('manage_title')}"
@click=${() => { this._editMode = !this._editMode; this._editingBonus = null; this._showNewForm = false; }}>
<ha-icon icon="mdi:pencil"></ha-icon>
</button>
${this._canManage() ? html`
<button class="btn ghost sm" style="margin-left:auto"
title="${this._tp('manage_title')}"
@click=${this._toggleEditMode}>
<ha-icon icon="mdi:pencil"></ha-icon>
</button>
` : ""}
</div>`;
const rows = design === "playroom"
@@ -984,7 +1011,7 @@ export function createIncentiveCard(P) {
<ha-icon icon="mdi:plus"></ha-icon> ${this._tp('new_bonus')}
</button>`)
: ""}
${child && !this._editMode
${child && !this._editMode && this._canApply()
? html`<div class="d-foot">${this._tp('applying_to', { childName: child.name, points: child.points, pointsName: this._getPointsName() })}</div>`
: ""}`;
@@ -1403,12 +1403,14 @@ class TaskMatePanel extends HTMLElement {
}
async _doSaveChore() {
this._syncIconPickers();
const d = this._dialog.data;
if (!d.name || !d.name.trim()) { this._showToast("err", this._t("panel.toast_name_required")); return; }
const wasAdd = this._dialog.mode === "add";
const base = {
name: d.name.trim(),
description: d.description || "",
icon: d.icon || "",
points: Number(d.points) || 0,
assigned_to: d.assigned_to || [],
requires_approval: !!d.requires_approval,
@@ -5933,8 +5935,20 @@ class TaskMatePanel extends HTMLElement {
_styles() {
return `<style>
/* Fill the panel area without depending on a percentage-height chain.
HA nests us as taskmate-panel ha-panel-custom partial-panel-resolver
ha-drawer, and the middle two are display:inline / height:auto. A
height:100% here only resolves while some ancestor happens to carry a
definite height; where it doesn't, the shell collapses to its content
height and the nav column visibly stops partway down every short page
(#754). A flex column of exactly 100dvh is immune to the ancestor
chain: panel_custom gives the element the whole viewport HA draws no
toolbar above it so 100dvh is the full panel area, not an overflow.
It must be height, not min-height: the shell scrolls its own body,
and a floor-only rule would let a long section (Settings) grow the
panel past the viewport and scroll the nav column off the page. */
taskmate-panel {
display: block; height: 100%;
display: flex; flex-direction: column; height: 100dvh;
--tm-bg: var(--primary-background-color, #fafafa);
--tm-surface-0: var(--card-background-color, #fff);
@@ -6002,7 +6016,11 @@ class TaskMatePanel extends HTMLElement {
.tm-shell {
display: grid;
grid-template-columns: var(--tm-sidebar-w) 1fr;
height: 100%;
/* Flex to fill the panel rather than height:100% see the note on
taskmate-panel above (#754). min-height:0 lets the grid shrink
instead of being forced to its content height. */
flex: 1 1 auto;
min-height: 0;
position: relative;
overflow: hidden;
}