# Handover for HA dashboard https://www.reddit.com/r/homeassistant/s/t5HpCF2vcx

> Source: <https://gist.github.com/JohnJocoo/4160edc194181d359a524d7cca17a1e0>
> Published: 2026-09-08 19:51:23+00:00

**Who this is for:** an AI agent (or engineer) helping someone build an illustrated,
animated floorplan dashboard for Home Assistant **from scratch**, with their own
house, their own illustrations, their own sensors.

**How to read it:** it is written as questions and answers. Find the question the
user is really asking and answer from that section. The numbers, room names and
entity IDs here are placeholders — every one of them will be different in a new
build. What transfers is the *method*: the canvas rule, the file naming scheme,
the card patterns, and a long list of traps that cost real time to discover.

Nothing here requires the original artwork. A new build can have one floor or five, three devices or thirty.

A hand-illustrated, isometric cutaway of a house, one image per floor, used as the home screen for Home Assistant. Devices are painted into the picture. When the air conditioner runs, the AC in the picture blows; when the TV is on, the screen glows and flickers; at night the whole house switches to a night render with warm windows. Small translucent "pills" sit over each room showing temperature, humidity and the AC setpoint.

It is built on Home Assistant's stock **`picture-elements`** card. The illustration
is the card's background; every device is an absolutely-positioned `image` element
on top of it that swaps its own file when the entity changes state.

One Lovelace **view per floor or area**, each a full-screen panel holding a single
`picture-elements` card. A reference build had four:

| view | what it holds | 
|---|---|
| Ground floor | floorplan + AC units, TV, wood stove + two room pills | 
| First floor | floorplan + AC units, monitor + four room pills | 
| Garden | three background states (day / night-dark / night-lit), tap zones on the lamps | 
| Combined | all three floors composited onto one canvas, sized for a TV | 

A new build can stop at one view. The combined view (Part 10) is genuinely optional and is the hardest part of the whole project.

- **It is beautiful, and it is legible.** People who do not use Home Assistant can
read it. "The living room AC is on" is a picture of an AC that is on.
- **Almost entirely stock.** The core (backgrounds, day/night, animated devices,
tap-to-open) needs no custom cards at all. Only optional extras pull in HACS.
- **Animation costs nothing at runtime.** The motion lives inside the image files
(animated WebP), not in CSS or JavaScript. The browser decodes an image. This is
what makes it viable on a cheap wall tablet or a TV browser.
- **It scales down gracefully.** Coordinates are percentages, so the card fills
whatever screen it lands on.
- **Excellent for a wall display or TV.** Big, glanceable, no chrome, no scrolling.

- **The artwork is the project.** Expect the illustration to be 80% of the cost and
the calendar time. If the user cannot commission or produce a consistent isometric
set, stop here and suggest a photograph-based or SVG floorplan instead.
- **Every device state is another drawing.** Adding a lamp later means going back to
the illustrator. This is the single biggest structural downside — see the file
count arithmetic in Part 4 before promising anything.
- **Placement is manual.** The background does not contain the devices, so nothing
can auto-locate them. Each device is dragged into position by hand, once.
- **`picture-elements` is a blunt tool.** No layout engine, no grouping, no boxes.
Everything is absolutely positioned, so hiding an element leaves a hole. There is
one important exception (Part 9).
- **It is not adaptive.** A phone gets the same picture, shrunk. If phone use matters,
the floorplan is a second dashboard, not the only one.
- **Maintenance is YAML.** No GUI editing — the visual editor actively corrupts this
card (Part 11).
- **Weight.** A full art set runs a few megabytes. Fine on a LAN, noticeable over a
slow remote connection.

- They have no illustration source and no budget for one.
- They want every entity in the house on it. This design suits ~4–8 visible devices per view; past that it turns into noise and the file count explodes.
- They mostly use their phone.
- They want to add devices frequently. Each addition is an art request.

A good compromise to offer: build **one** view for the main living space, with two or
three animated devices and the room pills. It is a fraction of the work and delivers
most of the impression.

| requirement | why | 
|---|---|
| Home Assistant with file access ( `/config` ) | the dashboard is YAML files on disk — Samba, SSH add-on, VS Code add-on, anything | 
| Willingness to run the dashboard in **YAML mode** | `!include` of shared element files is the whole architecture | 
| An illustration set, or a way to get one | see Part 3 | 
| A helper toggle for day/night | `input_boolean` | 
| Sensors that already work in HA | the dashboard displays entities, it does not create them | 
| An image tool that can encode animated WebP ( `img2webp` , from`libwebp` ) | plus `pngquant` for palette reduction | 

Start with **none**. Then, per feature:

| feature | needs | avoidable? | 
|---|---|---|
| backgrounds, day/night, animated devices, tap-to-open | nothing | — | 
| room status pills | nothing | — | 
| a popup controlling **two or more** entities from one tap | **Browser Mod** | yes — use a subview, or tap/hold for two dialogs | 
| the combined all-floors view | **card-mod** | no | 
| scaling a standalone view fluidly ( `--fp-u` ) | card-mod | yes, if only fixed-px sizing is wanted | 

Tell the user plainly which line they cross and when. A stock-only build is a real option and worth defending.

Anything reasonably current. Two version-sensitive points:

- The modern condition schema (`condition: state` ,`condition: numeric_state` ,`condition: or` ) works in`picture-elements` conditionals from frontend**2024.2** onward. Older versions only take the legacy shorthand (`entity:` +`state:` ).
- The `conditional` -as-flex-container behaviour in Part 9 was checked against release
tags spanning several months, not just`dev` — the exemption is in the shipped
builds, not a recent addition. Re-check if the target is much older.

Ask, because it constrains CSS. A reference build targeted a smart-TV browser, which ruled out:

- flexbox `gap` (Chromium 84+) — use margins on children instead
- `100dvh` (Chromium 108+) — use`100vh`

Modern tablets and desktops have no such limits. If the target is a TV or an old tablet, keep to the conservative forms throughout; it costs nothing.

**Every image must be exported on one shared canvas of the same size, with content
positioned where it belongs in the house — not cropped to itself.**

Call it the **master canvas**. Pick a size once (a reference build used 4096×2160 RGBA
PNG), and have the illustrator render every single asset onto it: each floor's day
render, each floor's night render, and *every device layer*, all at their true position
inside that canvas, transparent everywhere else.

Three payoffs, all large:

1. **The floors line up automatically.** Compositing all floors into one image later
becomes a plain alpha stack — no registration work, no guessing.
2. **Device layers land themselves.** If a device is exported full-canvas, its position
is already known; nobody has to drag it into place.
3. **Coordinates survive.** Every position can be expressed in master-canvas pixels,
which stay valid even if a crop box changes later. Converting percentage-to-percentage
between two different crops is where mistakes happen — always go via master-canvas
pixels.

In the reference build the *backgrounds* were full-canvas but the *device layers* were
pre-cropped, so the devices had to be placed by hand with a drag tool. That is the
avoidable half. Ask for full-canvas device layers up front.

Per view: find the content bounding box in the alpha channel, and **crop to it — never
resize**. Source pixels then map 1:1 to the served image, so every measurement stays
meaningful.

Record, for each view, its crop origin and size. Those four numbers convert any master-canvas pixel to a card percentage:

```
left% = (Xcentre − cropX) / cropW × 100
top%  = (Ycentre − cropY) / cropH × 100
```

Store them in a small `_meta.json` beside the built images. Future-you and future
tools will need them.

**The union of both.** Always.

Night renders bleed outward — light spills from windows, so the opaque area extends further left and down. In the reference build the difference was 151 px left and 88 px down on one floor. Crop each render to its own box and the entire floorplan visibly jumps the moment the card swaps day for night.

Crop day and night to the same union box. Both files then have identical dimensions and are pixel-aligned, and the swap is invisible. The same rule applies to any other whole-scene variant (a "lights on" night render, for example).

1. One isometric cutaway per floor/area, **day** and**night** versions, on the master
canvas.
2. For any whole-scene lighting state (garden lamps on, for instance): an additional full render.
3. For every animated device: a **still "off" frame** and an**8-frame loop** for "on",
in*both* day and night lighting, each on the master canvas.
4. Device layers with their own drop shadows, on transparency.
5. Everything as PNG with alpha. No flattening, no baked backgrounds behind device layers.

Yes, and enforce it. A reference build had one folder capitalised inconsistently
(`FIreplace_Night` with a capital I) which broke every glob that touched it. Agree a
scheme, lowercase it, and check before building.

This is the section to read before quoting the user a scope.

One per **whole-scene state**, which is the product of every global condition:

| situation | files | 
|---|---|
| day/night only | 2 — `<view>_day.webp` ,`<view>_night.webp` | 
| day/night + a scene light that changes the whole render | 3 — `<view>_day.webp` ,`<view>_night_off.webp` ,`<view>_night_on.webp` | 

Note the asymmetry in the second row: a garden's lamps are invisible in daylight, so day needs no on/off pair. Only draw the combinations that actually look different.

**Warning:** this is a product, not a sum. Two independent global toggles = 4 renders
per view; three = 8. Keep global states to one (day/night), plus at most one scene
light per view. Anything else should be a *device* overlay, not a background.

For a device drawn as its own layer with an on/off state, in a house that has day and
night backgrounds, it is **four files**:

| file | when it shows | 
|---|---|
| `<device>_off.webp` | day, entity off — a still | 
| `<device>_on.webp` | day, entity on — **animated** (or a still, if it does not move) | 
| `<device>_night_off.webp` | night, entity off — a still | 
| `<device>_night_on.webp` | night, entity on — **animated** | 

Rules that go with those four:

- The night art must be **geometrically identical** to the day art — same frame size,
same content position. That is what lets one set of`left` /`top` /`width` serve both.
Assert it at build time.
- **The off still and the on animation must share one canvas size.**`state_image` swaps the file but the element keeps one`width` and derives height from the image's
aspect ratio. Different sizes = the artwork jumps and resizes on every state change.
This bit the reference build exactly once, on a monitor whose glow spilled further in
the animation frames than in the still (189×284 vs 314×319).**Assert every frame
equals the still before building a component.**
- Naming: keep `_off` /`_on` for day and`_night_off` /`_night_on` for night. It is
asymmetric and slightly ugly; it is also easy to glob and easy to read.

`state_image` maps state → file, so any number of states is fine — you just need a
drawing for each visually distinct one. Two shapes come up:

- **Many states, one appearance.** A climate entity has`cool` ,`heat` ,`heat_cool` ,`auto` ,`dry` ,`fan_only` and`off` . If the AC looks the same whenever it runs, map
all six active modes to the*same*`_on` file. That is what the reference build does.
- **A rare alternate look.** An occasional takeover (a special image on the TV screen,
say) is best gated by a separate`input_boolean` and drawn as extra`conditional` blocks, so it does not multiply the normal art. Same geometry, different file, so no
coordinates change.

Two different mechanisms; pick per light:

1. **The light changes the whole scene** (a garden's lamps, a room whose glow floods the
render): make it a**background variant** , a full-canvas conditional overlay at`z-index: 0` . One extra render per state.
2. **The light is a local object** (a lamp on a table): make it a**device layer** with`_off` /`_on` , exactly like any other device. Four files.

Option 1 looks far better and costs one render; option 2 scales to many lights. Do not mix approaches for the same light.

One: a **1×1 fully transparent WebP** (about 34 bytes), per view folder. It is the
carrier for invisible tap targets — you place it, give it a width and height in CSS,
and it becomes a clickable rectangle over any part of the picture (the lamp posts in a
garden, a whole room, a door). Stock `picture-elements` has no "box" element, so this
is the standard workaround.

```
images = views × background_variants
       + animated_devices × 4
       + views × 1            (the transparent hit-area carrier)
```

A three-view build with eight animated devices and one garden light:
`(2 + 2 + 3) + 32 + 3` = **42 served files**, from roughly `8 devices × (2 stills + 16 frames)` = 144 source frames plus 7 background renders. Show the user that number
before they commit.

**WebP, throughout.** Every browser worth targeting renders it, including the browsers
built into smart TVs — those are Chromium-based and have been for many years. The
widely repeated claim that a given TV "does not support WebP" almost always refers to
its native *media/photo viewer*, not its browser engine; test it with one sample image
before believing it. A PNG-only build was tried in the reference project and reverted.

| asset | recipe | 
|---|---|
| Backgrounds | **lossy WebP, quality ~88.** Around 2.4/255 mean channel error on visible pixels — invisible in practice | 
| Device stills and animation frames | **`pngquant --quality 85-100` first, then encode LOSSLESS WebP** | 

That second row is counter-intuitive and worth internalising: for flat, cel-shaded art
with a lot of transparency, **lossy WebP is both larger and worse than lossless**. One
measured sprite sheet came out at 47 KB lossless-after-quantise versus 150 KB at q90.
The colour reduction is what does the work; the lossless encode then preserves it
exactly. Do not "optimise" this by switching to lossy.

Exception worth knowing: if a frame is *busy raster content* (a photographic image on a
screen, not flat cel art), palette reduction at 85-100 will fail to quantise and fall
through to a full-colour lossless encode. Drop that one asset to `--quality 70-95`
and check the error metric.

A full floor's bundle — day and night backgrounds, every still, every animation — lands around 1 MB with this recipe.

**Animated WebP.** The `_on.webp` file *is* the animation, looping forever. The card
simply swaps it in with `state_image`. No CSS animation, no JavaScript, no custom card.

Build with `img2webp`:

```
img2webp -loop 0 -lossless -m 6 -d <ms_per_frame> \
    frame0.png frame1.png ... frame7.png -o <device>_on.webp
```

`-d` is **milliseconds per frame**, not a frame rate. `-m 6` is the slowest/best
encoding method — if you are batch-encoding many files under a shell timeout, `-m 3`
is a reasonable fallback.

Tune per device, on the actual target screen, and then leave them alone. Reference values, after tuning:

| device kind | frames | per frame | loop | fps | 
|---|---|---|---|---|
| air conditioner (fan blades) | 8 | 167 ms | 1.34 s | 6 | 
| fire / flame | 8 | 125 ms | 1.00 s | 8 | 
| TV / screen flicker | 8 → 6 encoded | 125/250 ms | 1.00 s | 8 | 

Do **not** normalise them to one speed, and do not try to make the loops the same
length — nothing depends on them being in sync, and matching them makes the picture
look mechanical. The AC was deliberately slowed from 8 to 6 fps after testing on the
real display.

Because if consecutive source frames are byte-identical after palette reduction,
`img2webp` collapses them into one frame with a longer duration. Eight frames where
2==3 and 6==7 becomes six frames with durations `[125,125,250,125,125,250]` — still
1000 ms total, identical motion on screen. **This is not a bug; do not "fix" it.**

**Parse the WebP container's `ANMF` chunks.** Do not trust an imaging library —
Pillow reports `duration: 0` on read even for correct files.

And **use `img2webp`, not Pillow, to build them.** Pillow's `save_all` also merges
identical frames but *rewrites the durations* while doing it, which is easy to miss and
changes the loop length.

- **`pngquant` silently flattens a multi-frame APNG to frame 1.** Quantise individual
frames*before* assembling, never after.
- **`img2webp` needs an explicit `-lossy` flag.** A bare`-q` is silently ignored and
you get a lossless file back.

It works — a sprite sheet with `steps(N)` and an animated `background-position-x` was
built and verified pixel-exact — but it **requires card-mod**, because `@keyframes` are
tree-scoped: a rule defined in the document does not reach an element inside a shadow
root, and HA themes can only set CSS variables, not define rules. Since you are
generating the assets anyway, the speed is baked in at encode time either way, so CSS
buys almost nothing for a HACS dependency. **Use animated WebP.**

(If it ever is revived, the one rule that matters: the sheet must be **N+1 columns for
N frames**. Percentage `background-position` resolves against *(container width −
image width)*, so with N+1 columns `steps(8)` lands exactly on frames 0..7. A plain
8-column sheet lands on fractional frames and smears.)

As **YAML-mode Lovelace**: files on disk under `/config`, registered in
`configuration.yaml`. Not through the UI.

```
lovelace:
  mode: storage          # keeps existing UI-editable dashboards working
  dashboards:
    floor-plan-yaml:     # the slug MUST contain a hyphen
      mode: yaml
      filename: floorplan/dashboard.yaml
      title: Floor Plan
      icon: mdi:home-group
      show_in_sidebar: true
```

Adding it needs **one restart**. After that, editing any of the files needs only a
browser refresh — HA caches the dashboard on file mtime, so a newer file invalidates
the cache by itself. No restart, no reload service.

Using a new slug means it runs **side by side** with anything that already exists.
Always do that: confirm it works, then retire the old one.

```
/config/floorplan/
    dashboard.yaml            the views; hand-edited
    ground_elements.yaml      \
    upper_elements.yaml        }  the element lists, one per view; hand-edited
    garden_elements.yaml      /
    <anything>_elements.yaml     optional extra overlays

/config/www/floorplan/
    ground/  upper/  garden/     the served WebP, one folder per view
```

`/config/www/` is served at `/local/`, which is what every image path in the cards
refers to: `/local/floorplan/ground/ground_day.webp`.

Two reasons, and the first is the important one:

1. **Single source of truth.** If a combined view is built later (Part 10), the same
file is`!include` d by*both* the standalone floor view and the combined view. A room
is edited once and changes in both places. Verified: it is the same object in both.
2. `dashboard.yaml` stays short enough to read.

```
- type: picture-elements
  image: /local/floorplan/ground/ground_day.webp
  elements: !include ground_elements.yaml
```

Do this from day one even if there is no combined view planned. It costs nothing.

Each floor is a **panel view** holding one card:

```
- title: Ground Floor
  path: ground-floor
  icon: mdi:home-floor-g
  type: panel
  background: "#1c1c1c"
  show_icon_and_title: true
  cards:
    - type: picture-elements
      image: /local/floorplan/ground/ground_day.webp
      elements: !include ground_elements.yaml
```

To make the card fill the panel while keeping its aspect ratio, add card-mod CSS using that view's crop dimensions:

```
      card_mod:
        style: |
          ha-card {
            width: min(100%, calc((100vh - 56px) * <cropW> / <cropH>));
            margin: 0 auto;
          }
```

Use `100vh` and a literal `56px` if a TV or older browser is a target; `100dvh` with
`var(--header-height, 56px)` is nicer but needs Chromium 108+.

The core pattern — one stock `image` element per device:

```
- type: image
  entity: climate.living_room_ac
  image: /local/floorplan/ground/ac_living_off.webp        # the still
  state_image:                                             # state -> animated file
    cool:      /local/floorplan/ground/ac_living_on.webp
    heat:      /local/floorplan/ground/ac_living_on.webp
    heat_cool: /local/floorplan/ground/ac_living_on.webp
    auto:      /local/floorplan/ground/ac_living_on.webp
    dry:       /local/floorplan/ground/ac_living_on.webp
    fan_only:  /local/floorplan/ground/ac_living_on.webp
  tap_action:
    action: more-info
  style:
    left: 31.80%
    top: 78.88%
    width: 12.824%
    z-index: '2'
```

Unlisted states (`off`, `unavailable`) fall through to `image:`, so they handle
themselves.

Conventions that are not optional:

- **Never set `height`** on an image element. It follows from the aspect ratio; setting
it distorts the art.
- `z-index` values are**strings** in YAML (`'2'` ), because CSS wants a string.
- **Do not use YAML merge keys (`<<: *anchor`) inside element config.** These files are
parsed server-side by Python, but the config reaches a frontend that uses js-yaml.
Plain anchors/aliases (`&x` /`*x` ) are fine in`!include` d files — the frontend only
ever sees expanded JSON — but merge keys are not worth the risk.

Driven by one helper: `input_boolean.dashboard_night` (an automation on sun elevation
sets it; a manual toggle is useful too).

A `picture-elements` card's own `image:` **cannot be made conditional**. So:

- the **day** background stays as the card's`image:`
- the **night** background is painted as a`conditional` element at`z-index: '0'` ,`left: 50%` ,`top: 50%` ,`width: 100%` — covering the whole card

```
- type: conditional
  conditions:
    - entity: input_boolean.dashboard_night
      state: 'on'
  elements:
    - type: image
      image: /local/floorplan/ground/ground_night.webp
      tap_action: {action: none}
      style: {left: 50%, top: 50%, width: 100%, z-index: '0', pointer-events: none}
```

Because both backgrounds are the same union crop (Part 3), they are pixel-aligned and nothing shifts.

**Each device then needs two conditional blocks** — one for day, one for night — each
holding one `image` element that uses `state_image` for its own on/off. Two dimensions,
two mechanisms: `conditional` for day/night, `state_image` for entity state.
`state_image` can only key off one entity, so it cannot carry both.

`conditional` **ANDs** its conditions, so combinations are flat blocks, not nesting.
Day/night × a mode toggle = four flat `conditional` blocks, each with both conditions
listed. Verbose, but completely predictable — and it keeps every block's geometry
identical so nothing can drift.

Place the 1×1 transparent WebP, give it explicit `width`, `height` and `display: block`:

```
- type: image
  image: /local/floorplan/garden/ui_blank.webp
  entity: light.garden_lamps
  tap_action: {action: more-info}
  style:
    left: 32.37%
    top: 65.55%
    width: 70px
    height: 70px
    z-index: '8'
    display: block
    transform: translate(-50%, -20%)
    cursor: pointer
```

`display: block` is explicit because a custom element defaults to `inline`, where
`height` is ignored. The `transform` shifts the box off dead centre — useful for a lamp
where the hit area should sit on the head and hang down the post.

While positioning these, add a temporary `background: rgba(255,0,0,.3)` line to see the
box, then comment it out. Leave the commented line in the file for next time.

Write every pixel length as:

```
width: calc(70 * var(--fp-u, 1px))
```

`--fp-u` is a project-defined unit. A standalone view sets nothing, so the `1px`
fallback applies and it behaves like plain pixels. A combined view (Part 10) sets
`--fp-u: 0.0521vw` — exactly 1px at a 1920 viewport — and everything scales together.
Costs nothing to adopt from the start and is painful to retrofit.

Leave `border:` hairlines in real px so they cannot vanish sub-pixel.

Write a linter and run it after every edit. Three passes are enough to catch nearly everything:

1. **YAML syntax** , with line and column. (Ruby's Psych parses HA's custom tags —`!include` ,`!secret` — without complaint, because it stops at the AST. Handy if
pyyaml is not available.)
2. **Element structure** : every element has a`type` ; every`conditional` has both`conditions` and`elements` ; every`state_image` has an`entity` ; every`tap_action` /`hold_action` /`double_tap_action` names a real action.
3. **Image references** : every`/local/floorplan/...` string resolves to a file that
actually exists on disk. Strip cache-busting suffixes (`?v=2` ) first.

Only check nodes reached through an `elements:` list against the *element* vocabulary —
card configs (`views:`/` cards:`, and any popup content) use a different `type`
vocabulary and will otherwise produce pages of false warnings.

By hand, with a drag tool. **There is no automatic way**, and it is worth knowing why:
the backgrounds do not contain the devices. A device layer is a separate drawing with
its own drop shadow on transparency, so there is nothing in the background to match it
against. Template matching was tried and lands on blank wall. Do not spend time on it.

The workaround is a **self-contained HTML page**: the background as an `<img>`, each
device as a draggable `<img>` on top, and a readout of each one's `left` / `top` /
`width` as percentages of the view crop. Generate it (embed the images as data URIs so
it opens straight from disk with no server), drag, copy the numbers into the YAML.

Regenerate the placer whenever the crop box or the device set changes.

`left` and `top` are the **centre** of the element, as a percentage of the card.
`width` is a percentage of the card width. Height is never set.

To convert a device's width in master-canvas pixels to a card percentage:

```
width% = frame_width_px / cropW × 100
```

A useful trick when art has a lit and an unlit render of the same scene: **difference
the two images and take the centroid of each bright blob.** The emitters are the only
compact bright deltas, so this locates every lamp exactly. It is how a reference build
found four lamp posts in a garden. Reusable any time there is an on/off pair.

Keep a table of **master-canvas pixel centres** per device, alongside the percentages.
If the crop box ever changes, recompute percentages from the pixels. **Never convert
percentage to percentage** between two different crops — that is where the errors come
from.

Rough convention that works:

| layer | z-index | 
|---|---|
| night / scene background overlays | `'0'` | 
| device artwork | `'2'` –`'5'` , in painter's order (further from viewer = lower) | 
| invisible tap zones | `'8'` | 
| status pills and their contents | `'11'` | 

Give each device a distinct value in the order they should occlude each other.

| card | where | stock? | 
|---|---|---|
| **`picture-elements`** | every view — the whole dashboard | stock | 
| `vertical-stack` | only the combined view (Part 10), as an absolute-positioning host | stock | 
| `tile` (with`features:` ) | inside popups — big touch targets | stock | 
| `history-graph` | inside popups | stock | 
| `markdown` | inside popups, for templated detail | stock | 
| `heading` (with badges) | an alternative status strip above the floorplan | stock | 
| `card-mod` | the combined view; optional elsewhere | HACS | 
| `browser_mod` | popups that control more than one entity | HACS | 

| element | used for | 
|---|---|
| `image` | every piece of device artwork, every background overlay, every tap zone | 
| `conditional` | day/night, per-state art, and — importantly — as a **flex container** (Part 9) | 
| `icon` | glyphs in the status pills | 
| `state-label` | sensor readouts in the status pills | 
| `state-icon` | avoid where possible — see Part 9 | 

Generic shapes — substitute the user's real IDs, and **read them off their live
system, never invent them**:

| purpose | entity kind | example placeholder | 
|---|---|---|
| day/night switch | `input_boolean` | `input_boolean.dashboard_night` | 
| air conditioning | `climate` | `climate.<room>_ac` | 
| room temperature | `sensor` (device_class temperature) | `sensor.<room>_temperature` | 
| room humidity | `sensor` (device_class humidity) | `sensor.<room>_humidity` | 
| air quality | `sensor` | `sensor.<room>_carbon_dioxide` | 
| TV / media | `media_player` | `media_player.<name>` | 
| lights | `light` | `light.<area>` | 
| a decorative/manual state with no real device | `input_boolean` | `input_boolean.dashboard_<thing>_on` | 
| a rare alternate artwork mode | `input_boolean` | `input_boolean.dashboard_<mode>` | 

That last-but-one row matters: something drawn on the floorplan does not need a real
device behind it. A wood stove with no sensor is perfectly well represented by an
`input_boolean` the user flips when they light it.

| domain | states to list in `state_image` | 
|---|---|
| `climate` | every active mode: `cool` ,`heat` ,`heat_cool` ,`auto` ,`dry` ,`fan_only` | 
| `switch` ,`light` ,`input_boolean` | `"on"` (quote it — bare`on` is YAML`true` ) | 
| `media_player` | `playing` , and usually`on` too | 

Everything unlisted falls through to `image:`, which handles `off`, `unavailable` and
`unknown` for free.

`tap_action: {action: more-info}` — HA's own dialog for that entity. It is the right
answer nearly always: full controls, no design work, consistent everywhere.

Two exceptions:

- **Purely decorative art** (a background overlay):`tap_action: {action: none}` plus`pointer-events: none` , so it never eats a tap meant for something beneath it.
- **One tap needs to control two or more entities.**`more-info` takes exactly*one* entity. Options, in the order worth offering:
  1. A **subview** with a normal card — fully stock, but it is a page change (awkward
with a TV remote).
  2. A **Browser Mod popup** — best UX, first HACS dependency.
  3. Tap and hold for two different dialogs — stock, but undiscoverable.
  4. A group helper — its dialog leads with the group toggle, usually the wrong shape.
- A 

Browser Mod 2 style — `fire-dom-event`, **not** `call-service` / `perform-action`:

```
tap_action:
  action: fire-dom-event
  browser_mod:
    service: browser_mod.popup
    data:
      title: Garden watering
      dismissable: true
      timeout: 120000          # auto-close if left open
      initial_style: wide
      content:
        type: vertical-stack
        cards:
          - type: tile
            entity: valve.<something>
            features: [{type: toggle}]
          - type: history-graph
            hours_to_show: 24
            entities: [{entity: sensor.<something>}]
      right_button: Start now
      right_button_variant: success
      right_button_close: true
      right_button_action:
        action: perform-action
        perform_action: switch.turn_on
        target: {entity_id: switch.<something>}
      left_button: Close
      left_button_close: true
```

Put the same popup on every element that should open it (a `conditional` element
**cannot carry a `tap_action` itself**, so it goes on the icons inside). If the block
is repeated many times, a plain YAML anchor deduplicates it safely — these files are
resolved server-side.

Yes, and it is a legitimate alternative design to the in-picture pills:

- A **`heading` card** directly above the floorplan, whose badges lay out in a real
horizontal row. Each badge takes a`visibility:` block, and a badge whose condition
is false is removed and the row closes up. (Badge`visibility:` works but is
undocumented.) The view must be`type: sections` , not`panel` , for this.
- **View-level badges** — the only ones that expose a size variable (`--ha-badge-size` ,
default 36px).

The size difference is worth knowing: **heading-badge icons are hard-coded at 14px**
with no `var()`, so no theme can enlarge them; only card-mod can. If something large is
wanted, use view-level badges. If in-picture pills are wanted, read Part 9.

Yes — with one specific trick, and it is the most useful discovery in this whole project.

The general rule is that it *cannot*: every element the card renders gets a `.element`
class carrying `position: absolute; transform: translate(-50%,-50%)`, so elements never
affect one another. Hide one and you get a hole.

**But `conditional` elements are deliberately exempted from that class.** The frontend
excludes `HUI-CONDITIONAL-ELEMENT` on purpose, to keep it a transparent, static
container that appends children into itself. And `style:` is applied as **inline**
styles to every element, the conditional included — and inline beats a class.

Therefore: style a `conditional` as an absolutely-positioned `inline-flex` box, and set
`position: static; transform: none` on each of its children. They flow left to right,
the container auto-sizes, and a slot that vanishes takes its width with it.

```
- type: conditional
  conditions: []              # always true: checkConditionsMet runs conditions.every()
  style:
    position: absolute
    left: calc(29.5% - 12 * var(--fp-u, 1px))
    top: calc(66.28% + 0px)
    transform: translate(0, -50%)
    display: inline-flex
    align-items: center
    height: calc(34 * var(--fp-u, 1px))
    padding: 0 calc(11 * var(--fp-u, 1px))
    box-sizing: border-box
    background: rgba(60, 64, 72, 0.35)
    border-radius: calc(17 * var(--fp-u, 1px))
    backdrop-filter: blur(calc(3 * var(--fp-u, 1px)))
    border: 1px solid rgba(255, 255, 255, 0.14)
    z-index: '11'
    color: '#bbbbbb'                                 # inherited by every slot
    '--mdc-icon-size': calc(22 * var(--fp-u, 1px))   # ditto
    pointer-events: none
  elements:
    - type: icon
      icon: mdi:thermometer
      tap_action: {action: more-info, entity: sensor.<room>_temperature}
      style:
        position: static
        transform: none
        display: flex
        align-items: center
        pointer-events: auto
    - type: state-label
      entity: sensor.<room>_temperature
      style:
        position: static
        transform: none
        margin-left: calc(-2 * var(--fp-u, 1px))
        color: '#ffffff'
        font-size: calc(15 * var(--fp-u, 1px))
        font-weight: '600'
        white-space: nowrap
        line-height: '1'
```

The container **is** the pill — it sizes itself, so there is no separate background
element and no second width for the "something is hidden" case.

- **Spacing goes on each slot's LEADING element, never as flex `gap` on the container.** A`conditional` element whose conditions fail does*not* get`display: none` (that is
the conditional**card** , a different component). It merely empties itself and stays
in the DOM as a**zero-width flex item** . A`gap` would still be painted around it and
leave exactly the hole this design removes. (`gap` is also Chromium 84+, unsafe on a
TV.)
- **Use `type: icon`, not `type: state-icon`, for slot glyphs.**` state-icon` renders a`state-badge` whose shadow root hard-codes`width: 40px; height: 40px` on`:host` with no`var()` — unreachable, and in flow layout it sets the slot width.`type: icon` renders a bare`ha-icon` sized by`--mdc-icon-size` .`tap_action: {action: more-info, entity: X}` still works on it, because the action
handler checks`actionConfig.entity` before`config.entity` .
- **`pointer-events: none` on the container, `auto` on the interactive children.**
- Anything that can come and go goes in a **nested**`conditional` with`display: inline-flex; align-items: center` , carrying its margin on the element*inside* it.

Because `align-items: center` centres each child's **box**, and an icon's box is not
its glyph. The icon host is `display: block` with an inherited line-height, so its line
box carries a baseline strut: a 22px glyph sits at the top of a ~29px box with the
descender space hanging below. Centring the box puts the glyph ~3.5px high.

Fix, on every flow child:

```
    - type: icon
      style:
        display: flex           # collapse the host box onto the glyph
        align-items: center
    - type: state-label
      style:
        line-height: '1'        # 40px box -> 31px, stops overflowing the pill
```

**Measure this against the running dashboard, not a mock.** A hand-built HTML mock with
`<span>` placeholders has no line-height strut and will report everything as perfect.
A mock is reliable for widths, reflow and collapse-to-zero — all container behaviour —
and blind to anything that depends on what a real HA element puts in its own box.

**10u between slots, −2u between an icon and its own value** (where `u` is
`var(--fp-u, 1px)`).

The negative is not a mistake. An MDI glyph does not fill its `--mdc-icon-size` box —
it carries several px of side bearing inside the SVG, so the gap you *see* is the CSS
margin plus that bearing. Going from 4u to 2u looks like almost no change for exactly
that reason. −2u eats into the bearing and lands the value where it looks right.

Do not push much further: bearing varies per glyph, so a large negative that looks fine on one slot crowds another. −4u is about the limit. A first pass at 14u between slots read as loose; check on the real display before raising it.

Because `state-label` renders:

```
prefix + (config.attribute ? stateObj.attributes[config.attribute]
                           : hass.formatEntityState(stateObj)) + suffix
```

Only the **no-attribute** branch goes through `formatEntityState`, which is what appends
the unit. Read an attribute (`attribute: temperature` on a climate entity) and you get
the bare JS value — `22`, not `22 °C`.

Fix with the element's own `suffix: ' °C'`. Consequence: the suffix is hard-coded and
does **not** follow HA's unit system, so an instance that switches to imperial needs
those labels edited.

Badges behave differently and are not a counter-example — `state_content:` runs through
the attribute-formatting pipeline and appends the unit itself. Same data, different
renderer. Never assume behaviour transfers between badges and picture-elements.

If you used `type: state-icon`, it renders a `<state-badge>` whose shadow root sets, on
its own `:host`:

```
:host { color: var(--paper-item-icon-color, #44739e); }
```

That is an explicit declaration **on the element itself**, so it beats any `color`
inherited from the wrapper. The glyph stays HA's default blue-grey and your `color:`
looks completely ignored.

Custom properties *do* cross shadow boundaries, so set the property instead:

| wrapper sets | resulting glyph colour | 
|---|---|
| `color: #fff` | `#44739e` — ignored | 
| `--paper-item-icon-color: #fff` | white | 

Set `--paper-item-icon-color`, `--state-icon-color` and `color` together, plus
`state_color: false` so HA does not recolour by entity state. (With `type: icon` this
does not arise — plain `color:` works.)

**The general rule:** when something inside an HA component ignores your style, check
whether the component exposes a custom property for it. If it does, set the property.
If the value is a bare literal with no `var()`, only card-mod can win.

**One `%` anchor per room, offsets from it in scalable units.** The anchor tracks the
artwork as the card resizes; the contents keep their shape and stay legible instead of
shrinking. To move a whole panel, change its two anchor numbers and nothing else.

Keep panels **horizontal** — one row: `🌡 21.4 °C  💧 48 %  ❄ 22 °C`. A vertical
stack of icon/value pairs was tried and rejected.

Sensors and a mode glyph. Not toggles — tapping any glyph opens `more-info`, which has
the controls. A workable set per room: temperature, humidity, optionally air quality,
and the climate mode icon plus setpoint.

For climate, **use `hvac_mode` (the entity state), not `hvac_action`.** `hvac_action`
tracks what the compressor is doing moment to moment, so the icon flickers between
cooling and idle as the unit cycles. `hvac_mode` is what the user *set*, so it stays
put — and being the entity state rather than an attribute, the conditions are plain
`entity` + `state`.

Core ships no per-mode climate icons, so pick a vocabulary and reuse it:
`cool` ❄ `mdi:snowflake` · `heat` 🔥 `mdi:fire` · `heat_cool` `mdi:sun-snowflake-variant`
· `auto` `mdi:thermostat-auto` · `dry` `mdi:water-percent` · `fan_only` `mdi:fan` ·
`off` `mdi:fan-off`.

The setpoint slot should be conditional on `state_not: 'off'` — it is the slot that
comes and goes, and with the auto-fit container its absence simply shrinks the pill.

Skip this section unless the user specifically wants every floor on one screen. It is the most complex part of the project and it needs card-mod.

**No.** `picture-elements` cannot host cards. Verified in-browser: `hui-element` is not
a registered custom element ("Custom element doesn't exist"), and `hui-card` is
registered but has no `setConfig`, which is what the element factory calls
("r.setConfig is not a function"). Do not retry that route.

A **`vertical-stack`** whose children are absolutely positioned by card-mod over a
transparent spacer:

- **Child 1** is a fully transparent PNG the exact size of the union of all floors,
left in normal flow. It gives the stack's`#root` the right aspect ratio, so the
percentage`top:` values below have a height to resolve against.
- **Children 2..N** are the floor cards, absolutely positioned on top, each with a`z-index` .

```
- type: vertical-stack
  card_mod:
    style: |
      #root { position: relative; overflow: hidden; }
      #root > hui-card:nth-child(2) { position: absolute; left: 0%;      top: 31.26%; width: 57.84%; z-index: 2; }
      #root > hui-card:nth-child(3) { position: absolute; left: 3.97%;   top: -8.96%; width: 48.24%; z-index: 3; }
      #root > hui-card:nth-child(4) { position: absolute; left: 44.48%;  top: 18.90%; width: 55.52%; z-index: 1; }
  cards:
    - ...
```

Each nested card gets card-mod making it transparent and click-through:

```
      card_mod:
        style: |
          ha-card {
            --fp-u: 0.0521vw;
            --ha-card-background: transparent;
            --ha-card-border-width: 0;
            --ha-card-box-shadow: none;
            pointer-events: none;
          }
          ha-card * { pointer-events: auto; }
```

`pointer-events: none` on the card with `auto` on its children lets clicks fall through
the transparent regions to the floor beneath.

The **plain string form** (`card_mod: style: |`), which injects into the stack's own
shadow root where `#root` matches. The `hui-vertical-stack-card $:` key form does
**not** work — tested, no effect.

`gap: 0` is unnecessary: absolutely positioned children leave flex layout, so with one
in-flow spacer there are no gaps.

From three constants, in a script — never by hand, and never let the spacer image and the percentages be edited independently:

| constant | meaning | 
|---|---|
| `CANVAS_BOX` | each view's crop box on the master canvas | 
| `UPPER_LIFT` | how far to raise an upper floor for an exploded look | 
| `TOP_CROP` | dead space to trim off the top of the union | 

The script derives the union canvas, every card placement percentage, the stack CSS and
the transparent spacer image, so the image and the config can never disagree. Reference
this back to the spacer through a **self-versioning URL** (`?v=<width>x<height>`) so a
stale copy can never be served — and use the *same* URL on every card that references
it, or an overlay will position against the wrong height.

Have the script **patch only the geometry lines in place and print a diff**. It must
never rewrite the file wholesale (Part 11).

**Lower floors go on top of outdoor areas; upper floors go on top of lower floors.**
A garden belongs at the *bottom* — put it on top and its furniture draws straight
across the house interior.

Judge overlap by **counting opaque pixels in the intersection**, never by a whole-image
mean pixel difference. A mean over the full frame averages large localised differences
away — that is precisely how a reference build once concluded the z-order "barely
mattered" and stacked a garden over a house.

Enough for the exploded look, not enough to shrink everything. Zero overlap in the reference build needed 685px and cost another 8% of display width; 435px left 30% of the original overlap, confined to a rear wall and roof, which reads naturally. There is a real trade-off: the taller the union, the more a 16:9 screen fits it by height and the smaller everything renders. Build a drag tool that shows live overlap, the resulting union aspect, and the cost in display width, and let the user choose.

Add another `picture-elements` card to the stack, using the same transparent spacer as
its image, with its own `!include` d elements file, positioned at `left: 0; top: 0; width: 100%` and a high `z-index`.

**Its `pointer-events: none` must be cleared on the HOST, in the stack's own CSS**, not
just inside the card:

```
#root > hui-card:nth-child(5) { position: absolute; left: 0; top: 0; width: 100%; z-index: 4; pointer-events: none; }
```

card-mod can only reach inside a card's shadow root, so `ha-card` rules leave the
`hui-card` host itself hittable and the overlay still swallows every click on the whole
dashboard.

And inside the overlay card, do **not** write a blanket `ha-card * { pointer-events: auto }` — the container div spans the whole canvas, so that makes the entire dashboard
unclickable. Set `ha-card div, ha-card hui-image { pointer-events: none; }` and have
each genuinely interactive element opt back in with its own inline
`pointer-events: auto`.

Place such an overlay in an area no floor card occupies. Work out where that is from the geometry, do not guess.

The **modern** one — `condition: state`, `condition: numeric_state`, `condition: or` —
on frontend ≥ 2024.2. `hui-conditional-element` and `hui-conditional-card` share
`HuiConditionalBase`, so the picture-elements element gets the same `checkConditionsMet`
the card does. Both schemas can coexist in one file, because `checkConditionsMet` falls
through to a state check for any condition with no `condition:` key.

Two traps if you use `numeric_state`:

- **`below` is EXCLUSIVE.** With an integer 0–10 sensor, a red block at`above: 8` and
an amber guard at`below: 8` leaves the value 8 matching*neither* — no badge at all,
at the one value most likely to be sitting there. Use`below: 9` .
- **`numeric_state` is FALSE for a non-numeric state.** An`unavailable` sensor makes
every`numeric_state` on it false,*including a negated guard* . Add a`state: [unavailable, unknown]` escape hatch OR'd beside it.

If two badges are mutually exclusive, make them **actually mutually exclusive** rather
than stacking them by z-index. Two identical glyphs layered will leak the lower one
through the upper one's antialiased edges and double the drop shadow.

**Regenerating a file wholesale from a generator and overwriting it.** In the reference
project this destroyed real work twice: once clobbering hand-entered entity IDs, once
discarding hand edits to a deployed dashboard file with no backup.

The rule: **read what is on disk, make the targeted edit, diff, then write.** Assume
every file may have been edited by the user outside your session since it was last
generated. If a generator is involved, have it *patch specific lines in place* and print
a diff of exactly what it touched.

Run `git status` before any generator. If a file is already dirty, read the diff first —
that is how a hand edit gets silently reverted.

Also: never read a file back *after* an overwrite and report that nothing was lost. That
compounds the error into a false all-clear.

Decide this explicitly, write it down at the top of the repo, and keep it true:

| file | status | 
|---|---|
| `*_elements.yaml` | **SOURCE** — hand-edited, nothing generates them | 
| `dashboard.yaml` | **SOURCE** — hand-edited; a script may patch only its geometry lines | 
| the transparent spacer PNG | derived | 
| the drag placer HTML | derived | 

The one thing worse than not having this table is having one that is out of date.

**Three attempts, maximum.** Then stop, and tell the user what the check says, what you
changed, and what you think is actually wrong. A fourth guess is worth less than five
seconds of the user looking at it.

**Never commit or deploy while a check is failing** — not the broken file, not "the rest
of the work" alongside it, not with `--no-verify`.

**No — the visual editor corrupts them.** HA's image picker rewrites a plain `image:`
string into an object:

```
image:
  media_content_id: /local/floorplan/ground/ground_day.webp
  media_content_type: ''
  metadata: {...}
```

`hui-image` expects a **string** and sets `<img src>` from it directly, so an object
renders nothing — silently, with no console error and no log line. Seen in the wild on
a background and on two device layers after one GUI edit. The GUI editor also strips all
comments.

Keep `image:` and every value inside `state_image:` a plain `/local/...` string, and use
the **raw configuration editor**, never the per-card visual editor.

Check the browser console for `themesReady: Timeout waiting for themes to become ready`.
card-mod 4.x gates all styling behind themes being loaded, and on an instance with **zero
custom themes defined** it can time out and silently do nothing. Defining any theme at
all resolves it.

You cropped day and night to their own bounding boxes instead of the union. See Part 3.

The off still and the on animation have different canvas sizes. See Part 4.

If it has already happened and re-exporting is not an option: recover the offset by
masked template matching of the still into frame 0 (`cv2.matchTemplate` with `TM_SQDIFF`
and the alpha as mask; cross-check with an alpha-silhouette `TM_CCORR_NORMED` — both
should agree), then pad the still onto the animation's canvas at that offset. Then
recompute the element so nothing moves on screen: new canvas top-left = old still
top-left − (dx,dy); new centre = that + canvas/2. Verify the padded still lands
pixel-identically to the old one.

- **Deploy the files you mean, not the whole directory.** The copies on the HA box can
carry hand edits. Wrap`scp` in a script that refuses anything outside the intended
source folder and destination, and offer a`--dry-run` .
- If the deploy target runs OpenSSH ≥ 9, `scp` speaks SFTP and does**not** shell-expand
the remote path. Quoting it puts literal quotes in the filename and produces`remote mkdir "'/config/floorplan/'": No such file or directory` — which reads like a
connectivity error and is not one. Pass the remote path unquoted.
- Deploying does not lint. A failing check means do not push that file.

Two asymmetries worth knowing:

- **CSS custom properties DO cross shadow boundaries** , so a plain HA theme can restyle
text inside every card with no HACS.
- **`@keyframes` do NOT cross** — they are tree-scoped, so a rule defined in the
document never reaches an element inside a shadow root. That asymmetry is exactly why
a CSS-animation approach needs card-mod and an image-based one does not.

Theme variables are written **without** the leading `--` in a theme file.

Use these to size the job. The answers change the artwork brief, which is the expensive part.

1. **How many views?** One area, one floor, the whole house?
2. **Which devices should be visible and animated?** Get a list. Four to eight per view.
3. **Which of those are real entities, and which are just things they want to see?** (The second kind gets an`input_boolean` .)
4. **Day/night?** Doubles the art. Almost always worth it, but it is their call.
5. **Any whole-scene lighting state** beyond day/night?
6. **What screen is this for?** TV, wall tablet, desktop, phone. This decides the CSS
conservatism and the aspect ratio.
7. **Do they want all floors on one screen?** If yes, Part 10, and it needs card-mod.
8. **Are they willing to install HACS components?** Establish the line early.
9. **Where does the artwork come from?** If there is no answer here, stop and solve
that first.
10. **Which sensors already exist in HA?** Read the real entity IDs from their system.
Never invent them, never guess a naming pattern.

1. **Settle the artwork brief.** Master canvas size, view list, device list, states per
device, day/night. Give the illustrator the full-canvas rule in writing.
2. **Get one view's art only** — one floor, day and night, one device. Prove the whole
pipeline end to end before commissioning the rest.
3. **Crop to unions, record crop boxes** in a`_meta.json` per view.
4. **Encode** : backgrounds lossy q88; stills and frames pngquant-then-lossless; build
the animated WebP with`img2webp` and verify the`ANMF` durations.
5. **Copy to `/config/www/floorplan/<view>/`** , register the YAML dashboard, restart
once. Confirm the background alone renders.
6. **Build the drag placer** for that view; place the devices; paste the percentages.
7. **Add day/night** : the helper, the conditional background overlay, then two
conditional blocks per device.
8. **Add tap actions** (`more-info` almost everywhere).
9. **Add one status panel** , using the auto-fit container recipe. Measure the icon
alignment**on the running dashboard** , not in a mock.
10. **Show the user.** Iterate on spacing and animation speed on the*real* display.
11. **Repeat for the remaining views.**
12. **Only then** , if wanted, build the combined view.
13. **Write a linter** and run it on every edit. Write down what is source and what is
derived.

- The master canvas size, and each view's crop box and origin.
- Each device's centre in **master-canvas pixels** , not only percentages.
- The frame count and per-frame duration for every animation, and why it was chosen.
- The real entity IDs, marked as read from the live system.
- Every trap you hit, with the symptom that led to it. The symptom is the part that makes the note findable later — "the icons are glued to the top of the pill" is worth more than "line-height strut affects flex centring".

*End of handover. If something in here contradicts what the running dashboard actually
does, the running dashboard is right — go and measure it.*
