# The text block — a decor label that can show an entity's state Status: **released in v1.59.0-rc.1.** Code: `src/logic.ts` (`liveText`, `liveTextReference`, `liveTextToken`, `liveTextValue`, `decorTextScale`, `decorTextLines` — pure, unit-tested), `src/houseplan-card.ts` (`_renderDecorLayer`, `_renderTextFrame`, `_renderDecorTextDialog`, the `_dt*` gestures), `custom_components/houseplan/validation.py` (`DECOR_SCHEMA`, text branch). Smokes: `demo/smoke_live_text.mjs`, `demo/smoke_decor_text.mjs`; `demo/smoke_decor.mjs` keeps the surrounding decor contract. The shape: `{kind:'text', x, y, text, color, opacity?, size_cm?, angle?}` — plus legacy `size?`, `scale?`, `entity?`, `attr?`, and `unit?` fields that older plans may still carry. New live references are stored only inside `text`; existing linked labels render unchanged and migrate to inline references when that conversion is lossless. A legacy explicit `unit`, or an attribute name that cannot be represented by the inline grammar, stays in the legacy fields when edited. ## 1. Why a live label The plan already answers "which lamps are on" and "how warm is the bedroom" through icons and room fills. It cannot answer the things a house says in words: *water tank 68 %*, *garage 12 °C*, *watering tonight at 20:00*, *firewood left: 3 days*. Today a user who wants that puts a device marker in "value instead of icon" mode — which gives a badge with a bare number, always tied to a device with a position, an area and a tap action. What is missing is the caption on the wall: free-standing text, in the user's own words, that happens to have a live number in it. The competing card (ha-floorplan) covers this with `text_set` and it is one of its most used features; our decor text was one field away from it. ## 2. Inline HA variables `text` is both the visible copy and the complete template. It may contain any number of HA references mixed with ordinary text and line breaks: - `{sensor.water_tank}` — the entity state; - `{climate.hall:current_temperature}` — one attribute; - `Бак {sensor.water_tank}, зал {climate.hall:current_temperature}` — several independent values in one label. The editor writes the colon form because the boundary between entity and attribute is unambiguous. Hand-written `{climate.hall.current_temperature}` is accepted too: the first two dot-separated parts form the entity id and the rest is the attribute. Invalid brace contents stay literal, while a valid but missing entity or attribute renders as a dash. This remains substitution, not a template language: no expressions, conditions, arithmetic, Jinja, or nested braces. The 200-character limit is the limit of the saved template; each resolved value is still clipped to 60 characters. ### 2.1. Rendering rules - The value is read live from `hass` on every render — the same source as the rest of the card, no polling and no subscriptions of its own. A new `hass` repaints the label; nothing is re-created. - **Unavailable / unknown / missing or deleted-from-plan entity** → the value renders as `—` (an em dash) **and the dash carries no unit** («— °C» is not a reading); the rest of the template stays. A label that silently disappears when a sensor dies is worse than one that says "no data": the user must see that the caption is alive and the sensor is not. - Deletion does not rewrite the label template. Re-adding the same HA binding makes the saved variable live again. - An attribute that is not on the entity, or that is a dict, renders as the same dash. A list attribute is joined with `, `; `0` and `false` are values, not absences. - **Home Assistant formats the value; we still write no formatting of our own.** *(Refined 2026-08-05 — the rule below is narrowed, not revoked.)* We do not round, do not reformat and do not localise decimal separators ourselves: we hand the state object to **HA's own formatter** (`hass.formatEntityState`, and `hass.formatEntityAttributeValue` for an attribute) through the single wrapper `hassValue()` in `src/logic.ts` — the same call HA's more-info makes. So the label obeys the sensor's `display_precision`, the user's decimal separator and the state translations (`on` → *Включено*), because those are the user's HA settings and the settings are the one source of truth. What is forbidden is duplicating that logic here, not delegating it. An older HA without the formatter falls back to the raw state, byte-for-byte the pre-2026-08-05 behaviour. Imperial/metric is not our business either — the value and the unit come from HA (docs/STYLING-HOOKS.md §6). - **Units belong to Home Assistant.** State variables use HA's formatted state, including its unit. Attribute variables use HA's attribute formatter and do not inherit the entity state's unit. There is no separate unit override in the new editor; a literal suffix can be typed immediately after the token. - The value is clipped to `LIVE_TEXT_VALUE_MAX` (60) characters: a caption is a caption, and an attribute that turns out to be a 4 KB string must not become the plan's wallpaper. - Editors and kiosk render it identically; in the decor editor the *live* value is shown (not the raw template), so the user sees what visitors will see while positioning it. The read-only `houseplan-space-card` does not render the decor layer at all — that is unchanged, and out of scope here. ## 3. The block: size, rotation, lines The old `size: 's'|'m'|'l'` selector is **gone from the dialog**. A caption's size is not one of three opinions; it is whatever fits the place it is put in. - **`size_cm`** — the canonical physical font size, shown as centimetres or inches and written both by the numeric properties field and by dragging a corner of the selected block. Text always scales proportionally, including with `Shift`; changing the plan scale therefore keeps the label's physical size meaningful. The backend bounds it to `0.1…2000 cm`. - **`angle`** — degrees, written by the handle above the block. The step is **5°**, the same step a device icon rotates in; **Shift** enables a free angle but never disables positional grid snapping. Rotating back to zero removes the field, so a straight label stores nothing. - **Legacy size is read without a silent migration.** A stored `size` is read as the multiplier it used to render at — `s` = 0.7 (14 px), `m` = 1 (20 px), `l` = 1.5 (30 px) — so an old label comes back at exactly its old size. An explicit legacy `scale` wins. The first corner drag or properties save replaces both with the equivalent `size_cm`; **Оптимизировать планы** performs the same lossless conversion explicitly for the whole model. `size` stays in `DECOR_SCHEMA` (still bounded to the three known values) precisely because old plans keep sending it. - **Line breaks are the user's own.** The dialog's field is a textarea; a newline is stored and rendered as a newline (one `` per line, line height 1.2 em). The label **never wraps by itself** — a caption that reflows on every state change is a caption that jumps around the plan. A 200-character line stays one line. - **Multi-line blocks are centred**, horizontally (the decor layer's `text-anchor: middle`, which single-line labels already used) and vertically: the anchor `x/y` sits in the middle of the block, so adding a second line grows the label in both directions instead of pushing the first one up. - Both gestures pivot on the **anchor** (`x`/`y`), not on a box corner, so a label never walks away from the point it was placed at, and a rotated block still scales along the same axis (a distance from the anchor is invariant under its own rotation). The frame chrome — dashed outline, four corner handles, one rotate handle on a stem — reuses the backdrop frame's mechanics and sizes: chrome that never takes a pointer, handles that always do, with a **hit** radius of 1.8 % of the visible view so they stay finger-sized at any zoom (docs/BACKDROP.md §2). What you **see** is a quarter of that (owner, 2026-08-05: «уменьшить в 4 раза») — a bead, not a button, so the frame stops covering the words it frames. The two are different elements: an invisible `.dthandle` circle at the full radius owns the gesture, a `.dtknob` circle at `hr / 4` owns the paint and takes no pointer. The clickable area is therefore **unchanged**; only the ink shrank. Same split the wall-resize handles use (docs/RESIZE.md). - The frame is measured from the rendered glyphs (`getBBox`), so it appears one frame after the text and follows every edit of it. ## 4. Tools: what a click does Decor shapes are inert under a drawing tool — a new line must be able to start exactly on the end of an old one (owner, 2026-08-04). The **text tool has one exception**, asked for by the owner on the same day: | Text tool, press on… | What happens | |---|---| | an existing **label** | its editor opens (the same form, prefilled) | | empty canvas | a new label is created there | | a **non-text** shape (line, rect, ellipse) | a new label is created there; the shape stays inert | Under the **select** tool a label is selected and dragged as before, a double click opens its editor, and the corner/rotate handles appear. ## 5. The dialog - The textarea is the sole source of the label. It accepts ordinary copy, line breaks, and manually typed references in any order; Ctrl/⌘+Enter saves. - «Insert HA variable» contains an entity picker. After an entity is selected, the second control offers its state and actual attribute names. - Choosing the state or an attribute immediately inserts the complete token at the textarea's current selection/caret, then returns focus after the token. The user can continue typing or insert another variable, including one from another entity, until the 200-character field limit is reached. - There is no unit field, single-slot hint, or separate preview. The label on the plan is already the live preview and uses the same text template. ## 6. Backend `DECOR_SCHEMA`, text branch: `text` ≤ 200 characters, `opacity` 0…1, newlines and inline references included; canonical `size_cm` is finite `0.1…2000`; `angle` is optional and finite `-360…360`. Legacy `size`, `scale` (`0.15…20`) and `entity`/`attr`/`unit` remain accepted and bounded so old saved plans continue to validate and render. The frontend writes none of them after an ordinary representable label has been edited. It deliberately retains them when dropping an explicit unit or non-representable attribute would change what the label says. Tests: `tests_backend/test_validation.py` (`test_decor_text_live_fields`, `test_decor_text_block_scale_and_angle`). ## 7. What this is not - **Not a second device marker.** No tap action, no icon, no state class, no participation in room aggregation (LQI, climate averages) — it is a caption, not a device. A user who wants an interactive thing puts a marker. - **Not a template engine** (see §2). Multiple substitutions do not introduce expressions, conditions, or formatting rules. - **Not an auto-layout.** No wrapping, no shrink-to-fit: the size is set with the corners and the lines with the Enter key.