Desk Restore + House-Temperatures Dashboard Implementation Plan¶
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Restore the Desk dashboard to its pre-redesign layout and add a new kiosk-capable "Temps" YAML dashboard showing all 8 house temperatures in the established glass + conic-ring design language.
Architecture: Two independent changes. (1) A clean git-level revert of desk_companion.yaml to commit 862acee. (2) A net-new YAML-mode Lovelace dashboard temps_dashboard.yaml — a type: panel view with a header bar, 3 air-quality "hero" ring tiles (Office/Bedroom/Dining) that tap-open native thermostat pop-ups, and a strip of 5 compact temperature ring tiles (Henry/Isaac/Vivian/Annie/Kitchen). Registered in configuration.yaml, the CI deploy file set, and the entity-ref test.
Tech Stack: Home Assistant YAML-mode Lovelace; custom:button-card ([[[ JS ]]] templates for conic rings + threshold colors), custom:bubble-card (hash pop-ups), custom:mini-graph-card, native thermostat card; NemesisRE/kiosk-mode plugin. No build system — the repo test suite (make test) is the gate; deploy is via the ci.yml deploy-ha job on push to main.
Global Constraints¶
- Design language (copied verbatim from the
862aceeDesk "Office Air" tile): glass card =background: rgba(255,255,255,0.10),border: 1px solid rgba(255,255,255,0.20),border-radius: 14px,backdrop-filter: blur(9px)(+-webkit-prefix),box-shadow: none,box-sizing: border-box. View background =radial-gradient(130% 120% at 8% -10%, #1b2542 0%, #0a0e1a 58%) fixed. - Comfort ring: conic-gradient; fill % =
Math.max(0, Math.min((temp - 55) / (90 - 55), 1)) * 100; ring colortcol = (temp >= 68 && temp <= 76) ? "#34d399" : (temp >= 64 && temp <= 80) ? "#fbbf24" : "#ef4444"; inner hole#101830. - AQ stat thresholds (heroes only): VOC
< 500 #e7ecf6,< 1000 #fbbf24, else#ef4444. CO₂< 800 #e7ecf6,< 1200 #fbbf24, else#ef4444. RH always#e7ecf6, shownNN%. - Occupancy dot: occupied (
state === "on") →#34d399withbox-shadow: 0 0 8px #34d399; vacant →rgba(255,255,255,0.14), no glow. - Kiosk: every YAML dashboard uses
kiosk_mode: { kiosk: '{{ is_state("input_boolean.kiosk_mode", "on") }}' }. - No new sensors/automations/HVAC control — display only. Ecobee owns HVAC.
- All referenced entities already exist in
tests/fixtures/entities.txt(verified): the 8 temps, 3 VOC, 3 CO₂, 3 RH, 8 occupancy,climate.office/bedroom/dining_room,sensor.time_date. Noentities_allow.txtchanges needed. - Commit message format:
auto: <what changed> [— <why>]. - Never edit
automation_*.yamlfragments. Adding any config file other thanautomations.yamltriggers a fullha core restarton deploy.
Task 1: Restore the Desk dashboard to commit 862acee¶
Clean revert — the redesign at 1129eff (2026-06-27) crammed an 8-temperature grid into the Desk; the user wants the original three-column office-ring / energy / now-playing layout back.
Files: - Modify (overwrite): desk_companion.yaml
Interfaces: - Consumes: nothing. - Produces: nothing other tasks depend on (independent change).
- [ ] Step 1: Confirm the restore target exists and preview it
Run:
Expected: prints the header comment# Desk Companion — kiosk dashboard for the iPad on the desk arm (YAML mode). and title: Desk. If 862acee is not found, run git log --oneline -- desk_companion.yaml and pick the last commit dated 2026-06-16 (the pre-1129eff version). - [ ] Step 2: Overwrite the working file with the 862acee version
Run:
- [ ] Step 3: Verify the diff is the expected revert
Run:
Expected: the file changes back to the three-column layout (look formedia_player.office_apple_tv "Now Playing" column and the sensor.office_carbon_dioxide Office Air ring). If git diff shows no changes, the working copy already matched 862acee — skip to Step 6. - [ ] Step 4: Lint
Run:
Expected: yamllint passes (exit 0), no errors ondesk_companion.yaml. - [ ] Step 5: Entity-ref + structural tests
Run:
Expected: PASS.desk_companion.yaml is already registered in tests/conftest.py, and the 862acee version only references entities already in fixtures, so the entity-ref test stays green. - [ ] Step 6: Commit
git add desk_companion.yaml
git commit -m "auto: restore Desk dashboard to 862acee — revert 1129eff 8-temp grid back to office-ring / energy / now-playing"
Task 2: Create the new "Temps" dashboard and register it¶
Build temps_dashboard.yaml (Layout A) and wire it into the config, CI deploy file set, and entity-ref test so it deploys and is gated by the suite.
Files: - Create: temps_dashboard.yaml - Modify: configuration.yaml (add to lovelace.dashboards, after the command-center block at lines 32–37) - Modify: .github/workflows/ci.yml (line 55 change-detect regex; line 135 HA_FILES) - Modify: tests/conftest.py (add to the refs map in all_referenced_entities(), after line 156)
Interfaces: - Consumes: nothing from earlier tasks. - Produces: a type: panel view whose three hero tiles navigate to bubble-card pop-ups keyed #thermo-office, #thermo-bedroom, #thermo-dining. Task 3 adds nothing here; Task 4 documents it.
- [ ] Step 1: Create
temps_dashboard.yamlwith exactly this content
# House Temperatures — kiosk dashboard (YAML mode). Built in code, not the UI.
# See docs/superpowers/specs/2026-06-29-desk-temperature-dashboard-design.md
# Layout A: header + 3 air-quality hero ring-tiles (Office/Bedroom/Dining, tap =
# native thermostat pop-up) + a strip of 5 compact ring-tiles (kids + Kitchen).
# Design language carried verbatim from the 862acee Desk "Office Air" tile:
# glass cards, conic comfort ring (green 68-76°, amber 64-80°, red beyond),
# threshold-colored CO₂/VOC/RH cells, green occupancy dot per tile.
title: Temps
kiosk_mode:
# NemesisRE/kiosk-mode: hide HA header+sidebar only while input_boolean.kiosk_mode
# is on; the Jinja template is live-reactive so toggling the helper re-shows chrome.
kiosk: '{{ is_state("input_boolean.kiosk_mode", "on") }}'
views:
- title: Temps
path: temps
type: panel
background: "radial-gradient(130% 120% at 8% -10%, #1b2542 0%, #0a0e1a 58%) fixed"
cards:
- type: vertical-stack
cards:
# ===================================================== HEADER
- type: custom:button-card
entity: sensor.time_date
show_icon: false
show_name: false
show_state: false
tap_action:
action: none
custom_fields:
title: '[[[ return `<span style="font-size:24px;font-weight:300;letter-spacing:.5px;background:linear-gradient(120deg,#fff,#9db4ff);-webkit-background-clip:text;background-clip:text;color:transparent">🌡 House Temperatures</span>` ]]]'
clock: |
[[[
const d = new Date();
const h = d.getHours() % 12 || 12;
const m = String(d.getMinutes()).padStart(2, "0");
const ap = d.getHours() < 12 ? "AM" : "PM";
return `<span style="font-size:24px;font-weight:300;color:#e7ecf6">${h}:${m}</span><span style="font-size:13px;color:#9fb0d0;margin-left:6px">${ap}</span>`;
]]]
styles:
card: &glass
- background: "rgba(255,255,255,0.10)"
- border: "1px solid rgba(255,255,255,0.20)"
- border-radius: 14px
- backdrop-filter: blur(9px)
- "-webkit-backdrop-filter": blur(9px)
- box-shadow: none
- box-sizing: border-box
- height: 11vh
- padding: 0 22px
grid:
- grid-template-areas: '"title clock"'
- grid-template-columns: 1fr auto
- align-items: center
custom_fields:
title:
- justify-self: start
clock:
- justify-self: end
# ===================================================== HERO ROW (3 thermostat zones)
- type: horizontal-stack
cards:
# ---------- OFFICE ----------
- type: custom:button-card
entity: sensor.office_carbon_dioxide
show_icon: false
show_name: false
triggers_update: all
tap_action:
action: navigate
navigation_path: '#thermo-office'
custom_fields:
label: |
[[[
const occ = states["binary_sensor.office_occupancy"].state === "on";
const dot = `<span style="width:10px;height:10px;border-radius:50%;display:inline-block;background:${occ ? "#34d399" : "rgba(255,255,255,0.14)"};box-shadow:${occ ? "0 0 8px #34d399" : "none"}"></span>`;
return `<div style="display:flex;align-items:center;justify-content:space-between;width:100%"><span style="font-size:11px;letter-spacing:2px;text-transform:uppercase;color:#8aa0c8">Office</span>${dot}</div>`;
]]]
body: |
[[[
const temp = Number(states["sensor.office_temperature"].state);
const tpct = Math.max(0, Math.min((temp - 55) / (90 - 55), 1)) * 100;
const tcol = (temp >= 68 && temp <= 76) ? "#34d399" : (temp >= 64 && temp <= 80) ? "#fbbf24" : "#ef4444";
const ring = `<div style="width:194px;height:194px;border-radius:50%;background:conic-gradient(${tcol} 0 ${tpct}%,rgba(255,255,255,0.08) ${tpct}%);display:flex;align-items:center;justify-content:center">
<div style="width:154px;height:154px;border-radius:50%;background:#101830;display:flex;flex-direction:column;align-items:center;justify-content:center">
<b style="font-size:48px;color:#e7ecf6">${Math.round(temp * 10) / 10}°</b><s style="font-size:11px;color:#9fb0d0;text-decoration:none">Office</s></div></div>`;
const voc = Number(states["sensor.office_volatile_organic_compounds"].state);
const vcol = voc < 500 ? "#e7ecf6" : voc < 1000 ? "#fbbf24" : "#ef4444";
const co2 = Number(states["sensor.office_carbon_dioxide"].state);
const ccol = co2 < 800 ? "#e7ecf6" : co2 < 1200 ? "#fbbf24" : "#ef4444";
const rh = Math.round(Number(states["sensor.office_humidity"].state));
const cell = (l, v, c) => `<div style="text-align:center"><div style="font-size:9px;color:#8aa0c8;text-transform:uppercase">${l}</div><b style="font-size:17px;color:${c}">${v}</b></div>`;
const stats = `<div style="display:flex;gap:24px;justify-content:center">${cell("VOC",voc,vcol)}${cell("CO₂",co2,ccol)}${cell("RH",rh+"%","#e7ecf6")}</div>`;
return `<div style="display:flex;flex-direction:column;align-items:center;gap:24px">${ring}${stats}</div>`;
]]]
styles:
card: &herocard
- background: "rgba(255,255,255,0.10)"
- border: "1px solid rgba(255,255,255,0.20)"
- border-radius: 14px
- backdrop-filter: blur(9px)
- "-webkit-backdrop-filter": blur(9px)
- box-shadow: none
- height: 46vh
- box-sizing: border-box
- padding: 16px
grid: &herogrid
- grid-template-areas: '"label" "body"'
- grid-template-rows: min-content 1fr
custom_fields: &herofields
label:
- justify-self: stretch
- align-self: start
body:
- align-self: center
- justify-self: center
# ---------- BEDROOM ----------
- type: custom:button-card
entity: sensor.bedroom_carbon_dioxide
show_icon: false
show_name: false
triggers_update: all
tap_action:
action: navigate
navigation_path: '#thermo-bedroom'
custom_fields:
label: |
[[[
const occ = states["binary_sensor.bedroom_occupancy"].state === "on";
const dot = `<span style="width:10px;height:10px;border-radius:50%;display:inline-block;background:${occ ? "#34d399" : "rgba(255,255,255,0.14)"};box-shadow:${occ ? "0 0 8px #34d399" : "none"}"></span>`;
return `<div style="display:flex;align-items:center;justify-content:space-between;width:100%"><span style="font-size:11px;letter-spacing:2px;text-transform:uppercase;color:#8aa0c8">Bedroom</span>${dot}</div>`;
]]]
body: |
[[[
const temp = Number(states["sensor.bedroom_temperature"].state);
const tpct = Math.max(0, Math.min((temp - 55) / (90 - 55), 1)) * 100;
const tcol = (temp >= 68 && temp <= 76) ? "#34d399" : (temp >= 64 && temp <= 80) ? "#fbbf24" : "#ef4444";
const ring = `<div style="width:194px;height:194px;border-radius:50%;background:conic-gradient(${tcol} 0 ${tpct}%,rgba(255,255,255,0.08) ${tpct}%);display:flex;align-items:center;justify-content:center">
<div style="width:154px;height:154px;border-radius:50%;background:#101830;display:flex;flex-direction:column;align-items:center;justify-content:center">
<b style="font-size:48px;color:#e7ecf6">${Math.round(temp * 10) / 10}°</b><s style="font-size:11px;color:#9fb0d0;text-decoration:none">Bedroom</s></div></div>`;
const voc = Number(states["sensor.bedroom_volatile_organic_compounds"].state);
const vcol = voc < 500 ? "#e7ecf6" : voc < 1000 ? "#fbbf24" : "#ef4444";
const co2 = Number(states["sensor.bedroom_carbon_dioxide"].state);
const ccol = co2 < 800 ? "#e7ecf6" : co2 < 1200 ? "#fbbf24" : "#ef4444";
const rh = Math.round(Number(states["sensor.bedroom_humidity"].state));
const cell = (l, v, c) => `<div style="text-align:center"><div style="font-size:9px;color:#8aa0c8;text-transform:uppercase">${l}</div><b style="font-size:17px;color:${c}">${v}</b></div>`;
const stats = `<div style="display:flex;gap:24px;justify-content:center">${cell("VOC",voc,vcol)}${cell("CO₂",co2,ccol)}${cell("RH",rh+"%","#e7ecf6")}</div>`;
return `<div style="display:flex;flex-direction:column;align-items:center;gap:24px">${ring}${stats}</div>`;
]]]
styles:
card: *herocard
grid: *herogrid
custom_fields: *herofields
# ---------- DINING ROOM ----------
- type: custom:button-card
entity: sensor.dining_room_carbon_dioxide
show_icon: false
show_name: false
triggers_update: all
tap_action:
action: navigate
navigation_path: '#thermo-dining'
custom_fields:
label: |
[[[
const occ = states["binary_sensor.dining_room_occupancy"].state === "on";
const dot = `<span style="width:10px;height:10px;border-radius:50%;display:inline-block;background:${occ ? "#34d399" : "rgba(255,255,255,0.14)"};box-shadow:${occ ? "0 0 8px #34d399" : "none"}"></span>`;
return `<div style="display:flex;align-items:center;justify-content:space-between;width:100%"><span style="font-size:11px;letter-spacing:2px;text-transform:uppercase;color:#8aa0c8">Dining</span>${dot}</div>`;
]]]
body: |
[[[
const temp = Number(states["sensor.dining_room_temperature"].state);
const tpct = Math.max(0, Math.min((temp - 55) / (90 - 55), 1)) * 100;
const tcol = (temp >= 68 && temp <= 76) ? "#34d399" : (temp >= 64 && temp <= 80) ? "#fbbf24" : "#ef4444";
const ring = `<div style="width:194px;height:194px;border-radius:50%;background:conic-gradient(${tcol} 0 ${tpct}%,rgba(255,255,255,0.08) ${tpct}%);display:flex;align-items:center;justify-content:center">
<div style="width:154px;height:154px;border-radius:50%;background:#101830;display:flex;flex-direction:column;align-items:center;justify-content:center">
<b style="font-size:48px;color:#e7ecf6">${Math.round(temp * 10) / 10}°</b><s style="font-size:11px;color:#9fb0d0;text-decoration:none">Dining</s></div></div>`;
const voc = Number(states["sensor.dining_room_volatile_organic_compounds"].state);
const vcol = voc < 500 ? "#e7ecf6" : voc < 1000 ? "#fbbf24" : "#ef4444";
const co2 = Number(states["sensor.dining_room_carbon_dioxide"].state);
const ccol = co2 < 800 ? "#e7ecf6" : co2 < 1200 ? "#fbbf24" : "#ef4444";
const rh = Math.round(Number(states["sensor.dining_room_humidity"].state));
const cell = (l, v, c) => `<div style="text-align:center"><div style="font-size:9px;color:#8aa0c8;text-transform:uppercase">${l}</div><b style="font-size:17px;color:${c}">${v}</b></div>`;
const stats = `<div style="display:flex;gap:24px;justify-content:center">${cell("VOC",voc,vcol)}${cell("CO₂",co2,ccol)}${cell("RH",rh+"%","#e7ecf6")}</div>`;
return `<div style="display:flex;flex-direction:column;align-items:center;gap:24px">${ring}${stats}</div>`;
]]]
styles:
card: *herocard
grid: *herogrid
custom_fields: *herofields
# ===================================================== KIDS + KITCHEN STRIP (temp-only)
- type: horizontal-stack
cards:
# ---------- HENRY (bedroom_1) ----------
- type: custom:button-card
entity: sensor.bedroom_1_temperature
show_icon: false
show_name: false
triggers_update: all
tap_action:
action: more-info
custom_fields:
label: |
[[[
const occ = states["binary_sensor.bedroom_1_occupancy"].state === "on";
const dot = `<span style="width:9px;height:9px;border-radius:50%;display:inline-block;background:${occ ? "#34d399" : "rgba(255,255,255,0.14)"};box-shadow:${occ ? "0 0 8px #34d399" : "none"}"></span>`;
return `<div style="display:flex;align-items:center;justify-content:space-between;width:100%"><span style="font-size:10px;letter-spacing:1px;text-transform:uppercase;color:#8aa0c8">Henry</span>${dot}</div>`;
]]]
body: |
[[[
const temp = Number(states["sensor.bedroom_1_temperature"].state);
const tpct = Math.max(0, Math.min((temp - 55) / (90 - 55), 1)) * 100;
const tcol = (temp >= 68 && temp <= 76) ? "#34d399" : (temp >= 64 && temp <= 80) ? "#fbbf24" : "#ef4444";
return `<div style="width:104px;height:104px;border-radius:50%;background:conic-gradient(${tcol} 0 ${tpct}%,rgba(255,255,255,0.08) ${tpct}%);display:flex;align-items:center;justify-content:center">
<div style="width:82px;height:82px;border-radius:50%;background:#101830;display:flex;align-items:center;justify-content:center">
<b style="font-size:26px;color:#e7ecf6">${Math.round(temp * 10) / 10}°</b></div></div>`;
]]]
styles:
card: &kidcard
- background: "rgba(255,255,255,0.10)"
- border: "1px solid rgba(255,255,255,0.20)"
- border-radius: 14px
- backdrop-filter: blur(9px)
- "-webkit-backdrop-filter": blur(9px)
- box-shadow: none
- height: 26vh
- box-sizing: border-box
- padding: 12px
grid: &kidgrid
- grid-template-areas: '"label" "body"'
- grid-template-rows: min-content 1fr
custom_fields: &kidfields
label:
- justify-self: stretch
- align-self: start
body:
- align-self: center
- justify-self: center
# ---------- ISAAC (bedroom_2) ----------
- type: custom:button-card
entity: sensor.bedroom_2_temperature
show_icon: false
show_name: false
triggers_update: all
tap_action:
action: more-info
custom_fields:
label: |
[[[
const occ = states["binary_sensor.bedroom_2_occupancy"].state === "on";
const dot = `<span style="width:9px;height:9px;border-radius:50%;display:inline-block;background:${occ ? "#34d399" : "rgba(255,255,255,0.14)"};box-shadow:${occ ? "0 0 8px #34d399" : "none"}"></span>`;
return `<div style="display:flex;align-items:center;justify-content:space-between;width:100%"><span style="font-size:10px;letter-spacing:1px;text-transform:uppercase;color:#8aa0c8">Isaac</span>${dot}</div>`;
]]]
body: |
[[[
const temp = Number(states["sensor.bedroom_2_temperature"].state);
const tpct = Math.max(0, Math.min((temp - 55) / (90 - 55), 1)) * 100;
const tcol = (temp >= 68 && temp <= 76) ? "#34d399" : (temp >= 64 && temp <= 80) ? "#fbbf24" : "#ef4444";
return `<div style="width:104px;height:104px;border-radius:50%;background:conic-gradient(${tcol} 0 ${tpct}%,rgba(255,255,255,0.08) ${tpct}%);display:flex;align-items:center;justify-content:center">
<div style="width:82px;height:82px;border-radius:50%;background:#101830;display:flex;align-items:center;justify-content:center">
<b style="font-size:26px;color:#e7ecf6">${Math.round(temp * 10) / 10}°</b></div></div>`;
]]]
styles:
card: *kidcard
grid: *kidgrid
custom_fields: *kidfields
# ---------- VIVIAN (bedroom_3) ----------
- type: custom:button-card
entity: sensor.bedroom_3_temperature
show_icon: false
show_name: false
triggers_update: all
tap_action:
action: more-info
custom_fields:
label: |
[[[
const occ = states["binary_sensor.bedroom_3_occupancy"].state === "on";
const dot = `<span style="width:9px;height:9px;border-radius:50%;display:inline-block;background:${occ ? "#34d399" : "rgba(255,255,255,0.14)"};box-shadow:${occ ? "0 0 8px #34d399" : "none"}"></span>`;
return `<div style="display:flex;align-items:center;justify-content:space-between;width:100%"><span style="font-size:10px;letter-spacing:1px;text-transform:uppercase;color:#8aa0c8">Vivian</span>${dot}</div>`;
]]]
body: |
[[[
const temp = Number(states["sensor.bedroom_3_temperature"].state);
const tpct = Math.max(0, Math.min((temp - 55) / (90 - 55), 1)) * 100;
const tcol = (temp >= 68 && temp <= 76) ? "#34d399" : (temp >= 64 && temp <= 80) ? "#fbbf24" : "#ef4444";
return `<div style="width:104px;height:104px;border-radius:50%;background:conic-gradient(${tcol} 0 ${tpct}%,rgba(255,255,255,0.08) ${tpct}%);display:flex;align-items:center;justify-content:center">
<div style="width:82px;height:82px;border-radius:50%;background:#101830;display:flex;align-items:center;justify-content:center">
<b style="font-size:26px;color:#e7ecf6">${Math.round(temp * 10) / 10}°</b></div></div>`;
]]]
styles:
card: *kidcard
grid: *kidgrid
custom_fields: *kidfields
# ---------- ANNIE (bedroom_4) ----------
- type: custom:button-card
entity: sensor.bedroom_4_temperature
show_icon: false
show_name: false
triggers_update: all
tap_action:
action: more-info
custom_fields:
label: |
[[[
const occ = states["binary_sensor.bedroom_4_occupancy"].state === "on";
const dot = `<span style="width:9px;height:9px;border-radius:50%;display:inline-block;background:${occ ? "#34d399" : "rgba(255,255,255,0.14)"};box-shadow:${occ ? "0 0 8px #34d399" : "none"}"></span>`;
return `<div style="display:flex;align-items:center;justify-content:space-between;width:100%"><span style="font-size:10px;letter-spacing:1px;text-transform:uppercase;color:#8aa0c8">Annie</span>${dot}</div>`;
]]]
body: |
[[[
const temp = Number(states["sensor.bedroom_4_temperature"].state);
const tpct = Math.max(0, Math.min((temp - 55) / (90 - 55), 1)) * 100;
const tcol = (temp >= 68 && temp <= 76) ? "#34d399" : (temp >= 64 && temp <= 80) ? "#fbbf24" : "#ef4444";
return `<div style="width:104px;height:104px;border-radius:50%;background:conic-gradient(${tcol} 0 ${tpct}%,rgba(255,255,255,0.08) ${tpct}%);display:flex;align-items:center;justify-content:center">
<div style="width:82px;height:82px;border-radius:50%;background:#101830;display:flex;align-items:center;justify-content:center">
<b style="font-size:26px;color:#e7ecf6">${Math.round(temp * 10) / 10}°</b></div></div>`;
]]]
styles:
card: *kidcard
grid: *kidgrid
custom_fields: *kidfields
# ---------- KITCHEN (downstairs) ----------
- type: custom:button-card
entity: sensor.downstairs_temperature
show_icon: false
show_name: false
triggers_update: all
tap_action:
action: more-info
custom_fields:
label: |
[[[
const occ = states["binary_sensor.downstairs_occupancy"].state === "on";
const dot = `<span style="width:9px;height:9px;border-radius:50%;display:inline-block;background:${occ ? "#34d399" : "rgba(255,255,255,0.14)"};box-shadow:${occ ? "0 0 8px #34d399" : "none"}"></span>`;
return `<div style="display:flex;align-items:center;justify-content:space-between;width:100%"><span style="font-size:10px;letter-spacing:1px;text-transform:uppercase;color:#8aa0c8">Kitchen</span>${dot}</div>`;
]]]
body: |
[[[
const temp = Number(states["sensor.downstairs_temperature"].state);
const tpct = Math.max(0, Math.min((temp - 55) / (90 - 55), 1)) * 100;
const tcol = (temp >= 68 && temp <= 76) ? "#34d399" : (temp >= 64 && temp <= 80) ? "#fbbf24" : "#ef4444";
return `<div style="width:104px;height:104px;border-radius:50%;background:conic-gradient(${tcol} 0 ${tpct}%,rgba(255,255,255,0.08) ${tpct}%);display:flex;align-items:center;justify-content:center">
<div style="width:82px;height:82px;border-radius:50%;background:#101830;display:flex;align-items:center;justify-content:center">
<b style="font-size:26px;color:#e7ecf6">${Math.round(temp * 10) / 10}°</b></div></div>`;
]]]
styles:
card: *kidcard
grid: *kidgrid
custom_fields: *kidfields
# ===================================================== THERMOSTAT POP-UPS
# Same cards as the Command Overview climate tiles: native thermostat dial
# + 24h current_temperature trend. Hash-triggered; render hidden until tapped.
- type: custom:bubble-card
card_type: pop-up
hash: '#thermo-office'
name: Office Thermostat
icon: mdi:thermometer
cards:
- type: thermostat
entity: climate.office
features:
- type: climate-hvac-modes
card_mod:
style: |
ha-card { background: transparent; border: none; box-shadow: none; }
- type: custom:mini-graph-card
name: Office temp (24h)
hours_to_show: 24
points_per_hour: 2
entities:
- entity: climate.office
attribute: current_temperature
- type: custom:bubble-card
card_type: pop-up
hash: '#thermo-bedroom'
name: Bedroom Thermostat
icon: mdi:thermometer
cards:
- type: thermostat
entity: climate.bedroom
features:
- type: climate-hvac-modes
card_mod:
style: |
ha-card { background: transparent; border: none; box-shadow: none; }
- type: custom:mini-graph-card
name: Bedroom temp (24h)
hours_to_show: 24
points_per_hour: 2
entities:
- entity: climate.bedroom
attribute: current_temperature
- type: custom:bubble-card
card_type: pop-up
hash: '#thermo-dining'
name: Dining Room Thermostat
icon: mdi:thermometer
cards:
- type: thermostat
entity: climate.dining_room
features:
- type: climate-hvac-modes
card_mod:
style: |
ha-card { background: transparent; border: none; box-shadow: none; }
- type: custom:mini-graph-card
name: Dining temp (24h)
hours_to_show: 24
points_per_hour: 2
entities:
- entity: climate.dining_room
attribute: current_temperature
- [ ] Step 2: Register the dashboard in
configuration.yaml
Add this block to lovelace.dashboards, immediately after the command-center: block (after line 37, before the blank line at 38). Match the existing 4-space/6-space indentation exactly:
temps:
mode: yaml
title: Temps
icon: mdi:thermometer
show_in_sidebar: true
filename: temps_dashboard.yaml
- [ ] Step 3: Add the file to the CI change-detection regex
In .github/workflows/ci.yml line 55, add temps_dashboard to the alternation (the trailing command_dashboard group). Change:
echo "ha_config=$(m '^(automations|configuration|configuration_tou_addition|homekit|influxdb_ha|scenes|scripts|desk_companion|plant_dashboard|command_dashboard)\.yaml$')"
echo "ha_config=$(m '^(automations|configuration|configuration_tou_addition|homekit|influxdb_ha|scenes|scripts|desk_companion|plant_dashboard|command_dashboard|temps_dashboard)\.yaml$')"
- [ ] Step 4: Add the file to the CI
HA_FILESdeploy list
In .github/workflows/ci.yml line 135, append temps_dashboard.yaml to the HA_FILES string:
HA_FILES="automations.yaml configuration.yaml configuration_tou_addition.yaml homekit.yaml influxdb_ha.yaml scenes.yaml scripts.yaml desk_companion.yaml plant_dashboard.yaml command_dashboard.yaml temps_dashboard.yaml"
- [ ] Step 5: Register the dashboard in the entity-ref test
In tests/conftest.py, inside the refs dict in all_referenced_entities(), add a line after the command_dashboard.yaml entry (line 156). Use the default scan (NOT bare_scan) — like command_dashboard.yaml, this file references entities via entity: and states["..."] bracket syntax, which the default regex catches:
"command_dashboard.yaml": extract_yaml_refs("command_dashboard.yaml"),
# default scan: refs are `entity:` + JS states["..."] literals (no string-built ids).
"temps_dashboard.yaml": extract_yaml_refs("temps_dashboard.yaml"),
- [ ] Step 6: Lint
Run:
Expected: yamllint passes ontemps_dashboard.yaml and configuration.yaml (exit 0). If yamllint complains about line length, that is acceptable only if the existing dashboards trip the same rule — check .yamllint (the repo disables line-length for these files); if it errors, the rule is active and the offending JS lines must be left as-is only if command_dashboard.yaml already has equally long lines (it does). Resolve any genuine syntax error (e.g. a mis-indented anchor) before proceeding. - [ ] Step 7: Entity-ref + structural tests
Run:
Expected: PASS. The newtemps_dashboard.yaml key is now scanned; every referenced entity (sensor.office_temperature, sensor.office_volatile_organic_compounds, sensor.office_carbon_dioxide, sensor.office_humidity, binary_sensor.office_occupancy, the bedroom/dining equivalents, sensor.bedroom_1..4_temperature, binary_sensor.bedroom_1..4_occupancy, sensor.downstairs_temperature, binary_sensor.downstairs_occupancy, climate.office/bedroom/dining_room, sensor.time_date) is in tests/fixtures/entities.txt, so the entity-ref test stays green. If it fails listing an unknown entity, re-check the exact entity_id spelling against grep <id> tests/fixtures/entities.txt. - [ ] Step 8: Container config check (validates the YAML loads in HA)
Run (needs Docker; this is the same gate CI runs before deploy):
Expected:check_config passes — confirms HA can parse configuration.yaml with the new temps dashboard pointing at temps_dashboard.yaml. If Docker is unavailable locally, skip and rely on CI's test job, but note the skip. - [ ] Step 9: Commit
git add temps_dashboard.yaml configuration.yaml .github/workflows/ci.yml tests/conftest.py
git commit -m "auto: new 'Temps' dashboard — 3 AQ hero rings (tap→thermostat popup) + 5 compact room rings; register + CI + entity-ref test"
Task 3: Document the new dashboard (CLAUDE.md + user manual)¶
Per the repo rule, a new user-visible dashboard must update CLAUDE.md's dashboard table and the user-facing manual in the same change.
Files: - Modify: CLAUDE.md (the "Dashboards — there are TWO kinds" table + the Pushing Changes to HAOS "Config YAML files" deployable list) - Modify: docs/entities.md (entity reference) and/or docs/climate.md (the temperature/AQ feature page)
Interfaces: - Consumes: the dashboard from Task 2 (url_path: temps, sidebar title "Temps"). - Produces: nothing.
- [ ] Step 1: Add the "Temps" row to the CLAUDE.md dashboard table
In CLAUDE.md, find the dashboards table (the one with header | Sidebar title | Mode | Repo file | url_path |). Add a row after the Plants row:
- [ ] Step 2: Add
temps_dashboard.yamlto the deployable-files sentence in CLAUDE.md
In the "Config YAML files — deploy via CI" section, the sentence listing deployable files ends … and the three YAML dashboardscommand_dashboard.yaml,desk_companion.yaml,plant_dashboard.yaml. Update it to read **four** YAML dashboards and append `, `temps_dashboard.yaml. Also update the parenthetical in the "Dashboards — there are TWO kinds" intro that says "and three YAML-mode dashboards" → "and four YAML-mode dashboards", and any "All five dashboards are entity-ref tested" → "All six dashboards…".
- [ ] Step 3: Add the dashboard to the user manual
In docs/climate.md, add a short subsection (plain language, household audience) describing the new Temps dashboard: a tablet display showing every room's temperature as a colored ring (green = comfortable 68–76°, amber = a bit off, red = extreme), the three main rooms (Office, Bedroom, Dining) also showing air quality (CO₂, VOC, humidity) and opening a full thermostat control when tapped, and a green dot meaning someone's currently in that room. Reference it by its sidebar name "Temps". Keep it to one short admonition/paragraph + a bullet list; do not introduce broken cross-page links (the docs.yml build runs mkdocs build --strict).
- [ ] Step 4: Build the manual strictly (catches broken links)
Run:
Expected: builds clean (exit 0). Ifmkdocs isn't installed locally, skip and rely on the docs.yml CI job, but note the skip. Fix any WARNING/broken-link that --strict turns into an error. - [ ] Step 5: Commit
git add CLAUDE.md docs/climate.md
git commit -m "auto: docs — document new Temps dashboard in CLAUDE.md table + climate.md user manual"
Deploy (after all tasks committed)¶
- Theconfiguration.yaml change makes deploy-ha do a full ha core restart. - After it goes green, verify on the tablet: the Temps dashboard appears in the sidebar, rings render with correct comfort colors, occupancy dots glow for occupied rooms, and tapping Office/Bedroom/Dining opens the native thermostat pop-up. - The docs/** change triggers the separate docs.yml workflow; verify with gh run list --workflow=docs.yml --limit 1. Post-deploy visual tuning (note, not a blocker)¶
vh heights (header 11 / heroes 46 / kids 26) and ring sizes (hero 194/154, kid 104/82) are starting values chosen to fit a typical landscape tablet. If the kids' strip clips or the heroes overflow on the actual desk tablet, adjust the height: values in temps_dashboard.yaml and re-push (a desk_companion.yaml-class change → restart). This is expected dashboard polish, not a correctness issue.
Self-Review¶
1. Spec coverage: - Restore Desk to 862acee → Task 1. ✅ - New "Temps" dashboard, Layout A (header + 3 heroes + 5 kid tiles) → Task 2, Step 1. ✅ - Design language verbatim (glass, conic ring, thresholds, occupancy dot) → Global Constraints + Task 2 Step 1. ✅ - Hero tiles tap → bubble-card thermostat pop-up (Office/Bedroom/Dining), same cards as Command Overview → Task 2 Step 1 (bubble-cards) + tap_action navigate. ✅ - Kid/Kitchen tiles → more-info → Task 2 Step 1. ✅ - Kids ordered Henry→Isaac→Vivian→Annie, Kitchen last → Task 2 Step 1 (bedroom_1..4 then downstairs). ✅ - Register in configuration.yaml / ci.yml / conftest.py → Task 2 Steps 2–5. ✅ - Kiosk, name Temps, icon mdi:thermometer, url temps → Task 2 Steps 1–2. ✅ - CLAUDE.md table + manual → Task 3. ✅ - Tier-3 entities all present (29 incl. climate) → Global Constraints + Task 2 Step 7. ✅
2. Placeholder scan: No TBD/TODO/"add error handling"/"similar to Task N". The full dashboard YAML is shown verbatim (heroes and kids repeated in full, not referenced). ✅
3. Type consistency: Pop-up hashes match between tile navigation_path and bubble-card hash (#thermo-office/#thermo-bedroom/#thermo-dining). YAML anchors (&herocard/*herocard, &kidcard/*kidcard, etc.) are defined on the first use and referenced consistently. Entity IDs match fixtures exactly. ✅