diff --git a/.github/workflows/_process.yml b/.github/workflows/_process.yml index 201c52d4..908ff0d6 100644 --- a/.github/workflows/_process.yml +++ b/.github/workflows/_process.yml @@ -1019,9 +1019,13 @@ jobs: 4. Тело issue #${{ github.event.issue.number }} и все комментарии. 5. Если меняется видимое поведение — docs/USER-GUIDE.ru.md: терминология интерфейса берётся оттуда, а не изобретается. - 6. Канонический документ затронутой подсистемы: docs/SUN.md, - LIGHT.md, CANVAS.md, WALL-THICKNESS.md, UX-MODES.md, - CONFIG-COMPATIBILITY.md, TOUCH-SUPPORT.md. + 6. Канонический документ затронутой подсистемы (тот же список, + что в AGENTS.md «Read this first»): docs/SUN.md, LIGHT.md, + CANVAS.md, WALL-THICKNESS.md, UX-MODES.md, + CONFIG-COMPATIBILITY.md, TOUCH-SUPPORT.md, ISOMETRIC.md, + VACUUM.md, DECOR-EDITOR.md, DEVICE-PRESENTATION.md, + FILTERING.md, STAIRS.md, RADAR.md, PDF-EXPORT.md, + STYLING-HOOKS.md. **Если цикл не первый — объём разбора по дельте, а не заново** (PROCESS.md §2.10, issue #214): найди вердикт и материал diff --git a/AGENTS.md b/AGENTS.md index 17fc8740..e228070f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,9 +35,12 @@ route and `test/entry-cost.test.mjs` keeps this list equal to its routes: The two digests quote and link `PROCESS.md` section by section; it stays the only complete canon and wins any disagreement, so open the linked section whenever a digest line governs your current step. For non-trivial changes add -`docs/ARCHITECTURE.md` plus the canonical document of the subsystem you touch: +`docs/ARCHITECTURE.md` plus the canonical document of the subsystem you touch +(one list, the same one the reviewer prompt in `_process.yml` reads): `SUN.md`, `LIGHT.md`, `CANVAS.md`, `WALL-THICKNESS.md`, `UX-MODES.md`, -`CONFIG-COMPATIBILITY.md`, `TOUCH-SUPPORT.md`. +`CONFIG-COMPATIBILITY.md`, `TOUCH-SUPPORT.md`, `ISOMETRIC.md`, `VACUUM.md`, +`DECOR-EDITOR.md`, `DEVICE-PRESENTATION.md`, `FILTERING.md`, `STAIRS.md`, +`RADAR.md`, `PDF-EXPORT.md`, `STYLING-HOOKS.md`. Standard commands live in `package.json` scripts, `CONTRIBUTING.md` and `docs/DEVELOPMENT.md`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a6474e96..d3bc622d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -58,6 +58,18 @@ set; maintain the existing English and Russian documentation according to the project's normal rules. Right-to-left layout is a separate product project, because the plan canvas and editors cannot be mirrored by translations alone. +## Documentation screenshots + +The images under `docs/images/` are produced only from synthetic data by the +`Docs screenshots` workflow (`demo/docs/capture.mjs` on the pinned Chromium) +and accepted locally with `npm run docs:accept -- --reviewed --from=`; when a change cannot move a pixel, `npm run docs:accept -- +--identical` re-captures locally, compares decoded pixels and refreshes only the +source fingerprint. Scenario version, source fingerprint and every image hash +are recorded in the [screenshot index](docs/images/screenshots.json), and +`node scripts/check-docs.mjs` reports a stale fingerprint before a beta +candidate. The full rule is in `PROCESS.md` (documentation screenshots). + ## Where to ask Not sure whether something is a bug, or just want to discuss an idea before diff --git a/README.md b/README.md index d246c89f..db2db668 100755 --- a/README.md +++ b/README.md @@ -189,6 +189,8 @@ concurrent clients, but avoid editing the same object in two browsers at once. - [Stairs and floor links](docs/STAIRS.md) - [Background editor](docs/DECOR-EDITOR.md) - [Robot vacuums](docs/VACUUM.md) +- [Presence radars](docs/RADAR.md) +- [PDF export](docs/PDF-EXPORT.md) - [Touch support](docs/TOUCH-SUPPORT.md) @@ -201,9 +203,4 @@ concurrent clients, but avoid editing the same object in two browsers at once. Include the version, browser, logs and reproduction steps; private entity IDs may be replaced with fictional ones. -Documentation screenshots are produced by the reproducible -`npm run build && node demo/docs/capture.mjs` command using synthetic data only. Scenario version, -source fingerprint and every image hash are recorded in the -[screenshot index](docs/images/screenshots.json). - License: [MIT](LICENSE). diff --git a/README.ru.md b/README.ru.md index cf93c30f..20252e64 100644 --- a/README.ru.md +++ b/README.ru.md @@ -43,6 +43,9 @@ Assistant — и устройства появятся на плане авто а карточки комнат показывают температуру, влажность, свет и средний LQI. - **Свет и окружение.** Заливки комнат, Glow от ламп, тени от стен, дневной фон и солнечные лучи из окон. +- **Плоский план или 2.5D.** Один переключатель в «Общие настройки › + Отображение › Объёмный вид плана (2.5D)» показывает план с глубиной везде: приподнятые плитки устройств с мягкими + тенями и мягкая засветка солнцем; декор и цвета стен остаются вашими. - **Двери, окна, ворота и пылесосы.** Проёмы отражают реальные датчики и замки; робот показывает позицию, базу и пройденный путь. - **Несколько этажей и экранов.** Вкладки пространств, жесты переключения, @@ -57,8 +60,9 @@ Assistant — и устройства появятся на плане авто ## Первая рабочая комната 1. Установите интеграцию и откройте **House Plan** в боковом меню Home Assistant. -2. Создайте первое **пространство**: загрузите SVG/PNG/JPG/WebP либо выберите - вариант без изображения, чтобы нарисовать план вручную. +2. Создайте первое **пространство**: загрузите SVG/PNG/JPG/WebP, возьмите уже + загруженное изображение либо выберите вариант без изображения, чтобы + нарисовать план вручную. 3. В редакторе «План» выберите **Стены** и нарисуйте непрерывную цепочку по периметру комнаты: когда она замкнёт область, откроется диалог комнаты. 4. Назовите комнату и свяжите её с зоной Home Assistant. Для помещения без @@ -188,8 +192,11 @@ default_floor: ground - [Полное руководство пользователя](docs/USER-GUIDE.ru.md) - [Матрица mouse/touch/keyboard](docs/USER-GUIDE.ru.md#6-навигация-масштаб-и-жесты) - [Инструменты плана](docs/USER-GUIDE.ru.md#инструменты-плана-в-короткой-таблице) +- [Лестницы и переходы между этажами](docs/STAIRS.md) - [Редактор подложки](docs/DECOR-EDITOR.md) - [Роботы-пылесосы](docs/VACUUM.md) +- [Радары присутствия](docs/RADAR.md) +- [Экспорт в PDF](docs/PDF-EXPORT.md) - [Поддержка touch](docs/TOUCH-SUPPORT.md) @@ -202,9 +209,4 @@ default_floor: ground обновление страницы (`Ctrl+F5`). Приложите версию, браузер, логи и шаги воспроизведения; приватные entity ID можно заменить вымышленными. -Скриншоты в документации получены воспроизводимой командой -`npm run build && node demo/docs/capture.mjs` только на синтетических данных. Версия сценариев, -fingerprint исходников и хеш каждого изображения находятся в -[индексе снимков](docs/images/screenshots.json). - Лицензия: [MIT](LICENSE). diff --git a/custom_components/houseplan/validation.py b/custom_components/houseplan/validation.py index dad2db2c..2d317bd3 100644 --- a/custom_components/houseplan/validation.py +++ b/custom_components/houseplan/validation.py @@ -1280,7 +1280,7 @@ _GEOM = vol.All(_finite, vol.Range(min=-CANVAS_LIMIT, max=CANVAS_LIMIT)) # than the old unit square — while staying strictly positive. _EXTENT = vol.All(_finite, vol.Range(min=0.001, max=CANVAS_LIMIT)) -# The backdrop's uniform scale (docs/BACKDROP.md). A MULTIPLIER, not a +# The backdrop's uniform scale (docs/DECOR-EDITOR.md §3). A MULTIPLIER, not a # coordinate: strictly positive, and bounded by what a person could mean — # a hundredth of the canvas is already a thumbnail, a hundred canvases is # already absurd. Mirrored by PLAN_SCALE_MIN/MAX in src/space-geometry.ts. @@ -1428,7 +1428,7 @@ SPACE_DISPLAY_SCHEMA = vol.Schema( extra=vol.ALLOW_EXTRA, ) -# Live text on a decor label (docs/LIVE-TEXT.md). An entity id is +# Live text on a decor label (docs/DECOR-EDITOR.md §5). An entity id is # `.`; HA itself allows only lowercase letters, digits and # underscores in both halves. The bound is a sanity limit, not a policy. MAX_ENTITY_ID = 255 @@ -1490,7 +1490,7 @@ DECOR_SCHEMA = vol.Any( vol.Schema({**_DECOR_COMMON, vol.Required("kind"): "text", vol.Required("x"): _NORM, vol.Required("y"): _NORM, # the template: newlines are the user's own line breaks and are - # kept verbatim (docs/LIVE-TEXT.md); the label never wraps itself + # kept verbatim (docs/DECOR-EDITOR.md §5); the label never wraps itself vol.Required("text"): vol.All(str, vol.Length(min=1, max=MAX_DECOR_TEXT)), # legacy font size ('s'|'m'|'l'). The dialog no longer offers it # — the block is scaled by its corner handles — but a plan @@ -1795,7 +1795,7 @@ SPACE_SCHEMA = vol.All(vol.Schema( vol.Optional("plan_aspect"): vol.Any( None, vol.All(vol.Coerce(float), vol.Range(min=0.05, max=20)) ), - # Backdrop placement (docs/BACKDROP.md): the picture may be moved, + # Backdrop placement (docs/DECOR-EDITOR.md §3): the picture may be moved, # resized per axis and rotated. Every transform field is optional; its # complete absence is the pre-v1.58.0 behaviour exactly, and an old # config validates unchanged. The offset is a normalised diff --git a/demo/shot_backdrop.mjs b/demo/shot_backdrop.mjs index eaca175c..80eaa0c5 100644 --- a/demo/shot_backdrop.mjs +++ b/demo/shot_backdrop.mjs @@ -1,4 +1,4 @@ -// Backdrop image (docs/BACKDROP.md) — two shots for the owner: +// Backdrop image (docs/DECOR-EDITOR.md §3) — two shots for the owner: // backdrop_frame — the transform frame mid-gesture: dashed outline, four // finger-sized corner handles, and the live "W × H" badge // stating the picture's real size through cell_cm; diff --git a/demo/smoke_backdrop.mjs b/demo/smoke_backdrop.mjs index 4dd677b1..99967685 100644 --- a/demo/smoke_backdrop.mjs +++ b/demo/smoke_backdrop.mjs @@ -1,5 +1,5 @@ /** - * Backdrop image: move, uniform scale, and the new paper rule (docs/BACKDROP.md). + * Backdrop image: move, uniform scale, and the new paper rule (docs/DECOR-EDITOR.md §3). * * The whole contract, on the demo's f1 — which IS an image plan, so every * assertion here lands on exactly the case that used to behave differently: @@ -234,7 +234,7 @@ check('reset_button_gone_again', await q('.editor-secondary .bdreset'), 0); // ---------- 5b) the SELECT tool still pans right over the picture --------- // The body of the picture is most of the screen; claiming it outside its own -// tool would take away the one-finger pan (docs/BACKDROP.md §2), which is why +// tool would take away the one-finger pan (docs/DECOR-EDITOR.md §3.2), which is why // moving is a tool. smoke_pan_any_zoom guards the same thing from the outside. await tool('select'); await settle(); diff --git a/demo/smoke_bg_color.mjs b/demo/smoke_bg_color.mjs index 4af0faa3..91759787 100644 --- a/demo/smoke_bg_color.mjs +++ b/demo/smoke_bg_color.mjs @@ -211,7 +211,7 @@ checkAll(res); // .hp-paper shapes sit under everything the plan draws. Since v1.58.0 the // paper is the ROOM CONTOURS and ONLY them — one shape per room, never their // bounding box (section 12), and never the backdrop image rect either -// (docs/BACKDROP.md §3). The demo's f1 IS an image plan, so this section now +// (docs/DECOR-EDITOR.md §3.3). The demo's f1 IS an image plan, so this section now // asserts the new rule on exactly the case that used to be the exception. // The scene colour is visible ONLY around the paper. The four-phase // environment changes only outside it and adds an alpha-aware outer outline. diff --git a/demo/smoke_cover_not_primary.mjs b/demo/smoke_cover_not_primary.mjs index 0c1f615f..5f1f10ec 100644 --- a/demo/smoke_cover_not_primary.mjs +++ b/demo/smoke_cover_not_primary.mjs @@ -151,8 +151,8 @@ const out = await page.evaluate(async () => { // the cover, which is what this section pins.) Same cause, same helper: // _stateClass and // the icon morph read d.primary, so the plan reported the state of - // `switch.*_reverse_direction`. The rule (docs/FILTERING.md «What a marker - // SHOWS»): the marker indicates the entity its tap ACTS ON — the cover + // `switch.*_reverse_direction`. The rule (docs/DEVICE-PRESENTATION.md «Source precedence: what a marker + // shows»): the marker indicates the entity its tap ACTS ON — the cover // exactly when the owner has explicitly chosen «Открыть/закрыть». const devEl = (dev) => { // The viewport may refit asynchronously after an editor transition. Use diff --git a/demo/smoke_decor.mjs b/demo/smoke_decor.mjs index 41d6f4ca..44d37736 100644 --- a/demo/smoke_decor.mjs +++ b/demo/smoke_decor.mjs @@ -225,7 +225,7 @@ const res = await page.evaluate(async () => { await popoverPicker?.updateComplete; // seven tools (the six drawing ones + «Мебель», docs/FURNITURE.md) plus // «Картинка-подложка», which f1 offers because it HAS a picture - // (docs/BACKDROP.md §2); a hand-drawn space still shows seven + // (docs/DECOR-EDITOR.md §3.2); a hand-drawn space still shows seven out.toolBtns = sr().querySelectorAll('.decorbar .btn.dtool').length === 8; // 2) нарисовать прямоугольник drag-ом (через прямые вызовы) c._decorTool = 'rect'; c._decorStyle = { color: '#ff0000', width: 3, fill: true }; await c.updateComplete; diff --git a/demo/smoke_decor_text.mjs b/demo/smoke_decor_text.mjs index 8078ca2c..1e2e8bfd 100644 --- a/demo/smoke_decor_text.mjs +++ b/demo/smoke_decor_text.mjs @@ -89,7 +89,7 @@ const res = await page.evaluate(async () => { const y0 = tsp[0] ? +tsp[0].getAttribute('y') : NaN; const y1 = tsp[1] ? +tsp[1].getAttribute('y') : NaN; out.blockCentredVertically = Math.abs((y0 + y1) / 2 - ay) < 0.01 && y1 > y0; - // длинная строка НЕ переносится сама (docs/LIVE-TEXT.md: никаких автопереносов) + // длинная строка НЕ переносится сама (docs/DECOR-EDITOR.md §5: никаких автопереносов) open(multi); await c.updateComplete; c._decorTextDialog = { ...c._decorTextDialog, text: 'x'.repeat(120) }; c._decorSaveText(); await c.updateComplete; @@ -109,7 +109,7 @@ const res = await page.evaluate(async () => { && frame().querySelectorAll('.dthandle').length === 5 && !!frame().querySelector('.dtrot'); - // --- визуал в 4 раза меньше, хит-зона прежняя (docs/LIVE-TEXT.md §3) --- + // --- визуал в 4 раза меньше, хит-зона прежняя (docs/DECOR-EDITOR.md §5.3) --- out.fiveVisibleKnobs = frame()?.querySelectorAll('.dtknob').length === 5; const rOf = (sel) => { const e = frame()?.querySelector(sel); return e ? +e.getAttribute('r') : NaN; }; const hitR = rOf('.dthandle.dtrot'); diff --git a/demo/smoke_live_text.mjs b/demo/smoke_live_text.mjs index 3e1dbe3a..649e026a 100644 --- a/demo/smoke_live_text.mjs +++ b/demo/smoke_live_text.mjs @@ -1,4 +1,4 @@ -// Живой текст в декоре (docs/LIVE-TEXT.md): все связи с HA живут прямо в +// Живой текст в декоре (docs/DECOR-EDITOR.md §5): все связи с HA живут прямо в // тексте как {entity} / {entity:attribute}. Один блок может смешивать обычный // текст и несколько переменных; выбор значения вставляет токен в позицию // курсора. Старые entity/attr/unit читаются, но первое сохранение мигрирует их. diff --git a/demo/smoke_value_format.mjs b/demo/smoke_value_format.mjs index ae494153..1bf9a153 100644 --- a/demo/smoke_value_format.mjs +++ b/demo/smoke_value_format.mjs @@ -1,4 +1,4 @@ -// Значения форматирует HOME ASSISTANT (docs/STYLING-HOOKS.md §6, docs/LIVE-TEXT.md §2.1). +// Значения форматирует HOME ASSISTANT (docs/STYLING-HOOKS.md §6, docs/DECOR-EDITOR.md §5.2). // Везде, где карточка печатает состояние ОДНОЙ сущности, она зовёт // hass.formatEntityState (и hass.formatEntityAttributeValue для атрибута) — // значит, работают display_precision, локальный разделитель дробной части и diff --git a/demo/stand/README.md b/demo/stand/README.md index 6375f271..c8c8bc1c 100644 --- a/demo/stand/README.md +++ b/demo/stand/README.md @@ -9,8 +9,7 @@ of the shipped integration. stand picks it up automatically from this path (`/opt/hp/bin/hp-update-dev.sh`, which calls the same script). The rest of the stand-only config (template LQI sensors, alarm helpers, the smoke automation) lives in the - seeds on the stand host — see `docs/TESTING-DEMO.md` and the memory note - `houseplan-demo-stand`. + seeds on the stand host; the demo home itself is described below. - `demo_guard/` — stand-only guard (2026-07-31). Visitors log in as an administrator (the card editor is gated on `is_admin`) and kept restarting @@ -56,3 +55,53 @@ refuses anything that does not have exactly one manifests turned the Hassfest job of PR #9004 red on 2026-08-11, five weeks into the review queue. `test/repo-hygiene.test.mjs` now fails if a second one appears, so this cannot be rediscovered by a reviewer again. + +## The demo home + +**https://demo.houseplan.tech** — public, login `demo` / `demo` (an +administrator). **https://dev.houseplan.tech** — the closed stand behind basic +auth (access with the owner), auto-deploys `dev`. The public stand **resets +every hour** to a pristine synthetic home — break it freely, delete rooms, +upload plans, an hour later everything is back (the countdown is printed to the +DevTools console). Long-lived checks («the file is gone a day later», «survived +a restart») therefore cannot live on the stand, and restarting HA from inside is +blocked by `demo_guard`. + +Demo home v2 is an anonymised layout of a real country house: dashboard +«House plan» (`/house-plan/0`; views Plan / Kiosk / Schema) and three spaces — +**Ground Floor** (Kitchen & Living, Hallway, Guest Bedroom, Guest WC, Boiler +Room, Sauna, Under-Stairs Closet, Outdoor Storage — hand-drawn, thick hatched +walls), **First Floor** (Kids Room A, Kids Room B, Upstairs Hall, Kids +Bathroom, Master Bathroom, Bedroom, Study — with a plan image backdrop) and +**Yard** (the Yard room plus a decor outline of the garage). 97 markers in +total: ~51 on Ground Floor, 23 on First Floor, 2 in the yard. Key demo devices: +the robot vacuum `vacuum.demo_robot` (dock in Under-Stairs Closet), leak +sensors (toggled by the helper `input_boolean.demo_leak`), smoke sensors (fire +by themselves every 10 minutes), the boiler and the water tank in Boiler Room +(value markers plus live text labels), the Front Door and Terrace locks, +curtains in Study, the garage gate in Yard, a TV with speakers, the kitchen +hood, the air conditioner in Bedroom, the composite Smart Plug, the +permanently unavailable Pantry Light, and the weather +`weather.demo_weather_south` (sun in the windows and the day/night cycle). +Everything is in English; the demo user's language is Auto, so the interface +follows the browser. **Smart Plug 2** is deactivated in the HA registry on +purpose — it is the ready-made example for the disabled-devices behaviour. + +The public demo user is an administrator, so the full registry scenario can be +checked on the stand; a limited/read-only user needs the local harness or a +separate unprivileged user. + +## What the stand cannot show + +- Real Zigbee/Z-Wave hardware, real robots (Dreame/Xiaomi/Valetudo), a real + wall tablet. +- The HACS install/update path, YAML-mode Lovelace, reinstalling the + integration while keeping its config. +- Restarting HA from inside — blocked by `demo_guard` (the hourly container + reset is the only «restart»). +- Anything that needs the stand's file system: broken stores, `kill -9` + between writes, unreadable folders, watching process memory. +- Long-lived scenarios (a day-long sweep, «an hour later») — the reset comes + first. +- A non-admin user (`admin_only`, hidden tabs) — the stand has the single + admin `demo`. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index abd1b421..bf8e17a0 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -654,7 +654,7 @@ not be added to an individual sink. ```json { "spaces": [{ "id","title","plan_url","plan_aspect", "plan_x","plan_y","plan_scale_x","plan_scale_y","plan_angle", - "plan_scale", // legacy optional fallback, docs/BACKDROP.md + "plan_scale", // legacy optional fallback, docs/DECOR-EDITOR.md §3 "view_box":[4], "rooms":[{"id","name","area","poly|x/y/w/h","wall_ids":[…],"settings"}], "wall_segments":[{"id","a","b","cm","owners":[…]}], @@ -671,7 +671,7 @@ All coordinates are **normalized (0..1 of the canvas)**; the canvas is always **square** (v1.48.0), render space `NORM_W × NORM_W` (1000×1000). A space has no proportions of its own — `plan_aspect` is the IMAGE's ratio, used to letterbox it centred on the square; optional `plan_x/y`, independent `plan_scale_x/y` -and `plan_angle` then transform that rectangle (`planRect`, docs/BACKDROP.md). +and `plan_angle` then transform that rectangle (`planRect`, docs/DECOR-EDITOR.md §3). Legacy `plan_scale` feeds both axes, and the absence of every transform field is the centred default exactly. The schema bounds geometry to ±5000 with strictly positive sizes (HP-1501/1502). `device_overrides`/`virtual_devices` are long @@ -1744,7 +1744,7 @@ its configured space. `plan_x/y`, independent `plan_scale_x/y` and `plan_angle`; legacy `plan_scale` feeds both axes. The image is interactive only in its own Background tool, rotated corners contribute to content bounds, and the - static card uses the same model. See `DECOR-EDITOR.md` and `BACKDROP.md`. + static card uses the same model. See `DECOR-EDITOR.md` §3. - **Independent Glow overlay** (#55): `settings.glow_enabled` is orthogonal to the data `fill_mode`; room `settings.glow` is the tri-state-compatible model foundation for #36. Legacy `fill_mode: 'glow'` remains a permanent read diff --git a/docs/BACKDROP.md b/docs/BACKDROP.md deleted file mode 100644 index ec2e8258..00000000 --- a/docs/BACKDROP.md +++ /dev/null @@ -1,93 +0,0 @@ -# Plan image backdrop - -Status: current in v1.60.0. The complete Background editor contract is in -`DECOR-EDITOR.md`. - -## Placement model - -The source image is first fitted proportionally into the square plan canvas by -`fitInSquare(plan_aspect, NORM_W)`. The optional transform is then applied: - -| Field | Meaning | Default | -|---|---|---:| -| `plan_x`, `plan_y` | top-left offset from the fitted rectangle, normalised by `NORM_W` | `0` | -| `plan_scale_x`, `plan_scale_y` | independent width/height multipliers | `1` | -| `plan_angle` | rotation around the transformed rectangle centre | `0°` | -| `plan_scale` | legacy uniform fallback for both axes | `1` | - -`planRect()` is the single reader used by the full card, static card and content -bounds. New writes remove `plan_scale`; **Оптимизировать планы** converts it -losslessly to both axis fields. - -## Editor behaviour - -The Background editor opens on **Select**, never on the image tool. The image -is interactive only under **Plan backdrop**: - -- body drag moves it; -- four corner handles preserve ratio by default; -- `Shift` allows independent axes; -- the upper handle rotates in 5° steps, or freely with `Shift`; -- double click opens numeric width, height and angle; -- `Esc` restores the transform at pointer-down; -- release creates one named command in the shared 50-step history; -- Reset removes all six transform fields and is itself undoable. - -In the Background editor the image is at opacity `0.5` under every other tool -and cannot receive pointer events from the controller. Under Plan backdrop it -is opaque. View and the other editors always show it at opacity `1`. - -Move and resulting top-left coordinates are grid-bound. Width and height are -quantised when resized or entered numerically. There is no positional modifier -that bypasses the grid. - -## Rendering and content - -The image is purely decorative and never changes rooms, walls, openings, -devices, Glow or sun geometry. It is still a content item for fit/pan bounds. -For a rotated image all four rotated corners contribute to those bounds. - -Layer order, top to bottom: - -```text -devices and room labels -opening symbols / positive and zero-thickness walls / late room-hover outline -sun rays -live Glow pools -decor -room hover fill / Glow-base rooms and tunnels / data room fills and tunnels -plan image -room-shaped paper -scene background -``` - -The paper remains room-shaped; an image without rooms does not create opaque -paper behind itself. - -## Large images (#39) - -Before any decode the picked raster is classified from its header bytes only -(`src/backdrop-probe.ts`): PNG IHDR (+colour type/tRNS for alpha), JPEG SOF, -WebP VP8/VP8L/VP8X. Thresholds live in that module as the single calibration -point: a decoded size above `WARN_DECODED_BYTES` (128 MiB ≈ 32 MP) opens a -warning dialog with the real numbers and offers an aspect-preserving reduced -copy (longest side `DOWNSCALE_TARGET_PX` = 4096; PNG with alpha stays PNG, -opaque images become JPEG q0.9, EXIF orientation honoured); a side beyond -`HARD_DIMENSION` (16384, the browser canvas cap) offers only Cancel. A failed -or timed-out decode of the reduced copy shows a toast and leaves staging clean -— the original the user declined is never uploaded silently. Unreadable -headers behave like a warning without numbers. SVG is never rasterised and -skips the probe entirely. Calibration matrix and rationale: -`docs/specs/039-large-backdrops.md`, rerun via -`demo/benchmark_backdrop_decode.mjs`. - -## Ownership - -| Concern | File | -|---|---| -| fitted/transformed rectangle and rotated bounds | `src/space-geometry.ts` | -| gestures, frame, numeric dialog, history/reset | `src/houseplan-card.ts` | -| full-card SVG image | `src/houseplan-card.ts` | -| static-card SVG image | `src/space-render.ts` | -| schema ranges | `custom_components/houseplan/validation.py` | -| legacy conversion | `src/plan-optimizer.ts` | diff --git a/docs/CANVAS.md b/docs/CANVAS.md index 8e85a1d7..afa5e43b 100644 --- a/docs/CANVAS.md +++ b/docs/CANVAS.md @@ -74,15 +74,16 @@ remains the explicit bulk-cleanup path. ## Model -| Concept | Before | Now | -| --- | --- | --- | -| Canvas | square `0..1`, rendered `0..1000` | unbounded plane, same units | -| `space.view_box` | the frame; everything was clamped into it | an OPTIONAL hint for the very first frame; used only when there is nothing to frame | -| "fit" rectangle | `view_box` (or the content bbox in view mode) | always the **content frame** (§4) | -| Zoom out floor | `ZOOM_MIN = 0.4` (fraction of `view_box`) | 3x the content frame (`MIN_ZOOM = 1/3`) | -| Pan bounds | content must cover the scene, and only above 100% zoom | content frame + one screen of slack in each direction, at any zoom | -| Icon size | % of `view_box`, i.e. grew with zoom | % of `iconUnit` — still grows with zoom (§6) | -| Validation range | `+/-4` | `+/-5000` (§3) | +- Canvas: an unbounded plane in the same units as the historical square + (`0..1`, rendered `0..1000`). +- `space.view_box`: an OPTIONAL hint for the very first frame, used only when + there is nothing to frame. +- "fit" rectangle: always the **content frame** (§4). +- Zoom-out floor: 3× the content frame (`MIN_ZOOM = 1/3`). +- Pan bounds: the content frame plus one screen of slack in each direction, at + any zoom (§5). +- Icon size: a percentage of `iconUnit`, so it still grows with zoom (§6). +- Validation range: `±5000` (§3). ### Render frame vs. view @@ -109,14 +110,14 @@ the exact target atomically without exposing a default-fit frame. `custom_components/houseplan/validation.py`: -| Symbol | Before | Now | What it is | -| --- | --- | --- | --- | -| `_COORD` (layout x/y) | `-4 .. 4` | `-5000 .. 5000` | coordinate | -| `_GEOM` (room x/y, poly points, opening x/y, `view_box` origin) | `-4 .. 4` | `-5000 .. 5000` | coordinate | -| `_EXTENT` (room w/h, `view_box` w/h) | `0.001 .. 4` | `0.001 .. 5000` | size — strictly positive | -| `_NORM` (decor x/y/w/h) | `-1 .. 2` | `-5000 .. 5000` | coordinate | -| stair `x/y`, `length/width/radius` | — | `-5000 .. 5000`; sizes positive | continuous transform | -| opening `length` | `0.001 .. 1` | `0.001 .. 5000` | size — strictly positive | +| Symbol | Range | What it is | +| --- | --- | --- | +| `_COORD` (layout x/y) | `-5000 .. 5000` | coordinate | +| `_GEOM` (room x/y, poly points, opening x/y, `view_box` origin) | `-5000 .. 5000` | coordinate | +| `_EXTENT` (room w/h, `view_box` w/h) | `0.001 .. 5000` | size — strictly positive | +| `_NORM` (decor x/y/w/h) | `-5000 .. 5000` | coordinate | +| stair `x/y`, `length/width/radius` | `-5000 .. 5000`; sizes positive | continuous transform | +| opening `length` | `0.001 .. 5000` | size — strictly positive | `+/-5000` is **garbage insurance, not a frame**. At the historical compatibility scale (`cell_cm` = 5, 240 grid cells across the unit width) @@ -417,16 +418,7 @@ fits `all`. ### 9.1 One bound, and it is the backend's -v1.57.0 freed the FRAME and the DRAWING, but not the drag handlers. Two -of them still clamped, and the owner and a user hit both: - -| Handler | Old clamp | Effect | -| --- | --- | --- | -| `_pointerMove` (device marker) | `_baseVb()` ± a 0.8 % inset — the CONTENT FRAME | a marker could never be dragged past the outline of what was already drawn, so a plan could not be extended by putting a device where the next room was going to be | -| `_labelMove` (room label) | `_spaceModel().vb` — the space's STORED `view_box` | worse: that is `[0,0,1,1]` for every plan the card has ever written, i.e. literally the old square. A room drawn at 2.5 had a name that could not reach its own room | -| `_decorCommitDraft` / decor text anchor | *none at all* | asymmetric with `_decorMoveUpdate`, which did clamp — a draft could be born outside the range the mover then refused to leave | - -The rule now: **an editor gesture has exactly one bound, `±CANVAS_LIMIT` +The rule: **an editor gesture has exactly one bound, `±CANVAS_LIMIT` (±5000 normalised, ±`SANE_LIMIT` in render units), and it is the same number `validation.py` enforces.** It is a garbage limit — insurance against a stored `1e100` — and never a frame. `clampCanvasR` / @@ -462,7 +454,7 @@ wall runs diagonally is broken geometry, not a tidy plan: | Element | Where | | --- | --- | -| the backdrop picture: move (its top-left corner) and proportional/independent corner scale | `_bdMove` → `_snap` / `snapToGrid` (docs/BACKDROP.md) | +| the backdrop picture: move (its top-left corner) and proportional/independent corner scale | `_bdMove` → `_snap` / `snapToGrid` (docs/DECOR-EDITOR.md §3) | | room vertices (draw tool) | `_markupClick` → `_snap` | | split tool's interior vertices | `_splitClick` → `_snap` | | fixed-topology room-wall resize | `_rszMove` → `_snap`; the last safe node before a corner/opening/third room wins | @@ -537,191 +529,13 @@ half-thickness) and stair magnet resolves outer footprint-to-footprint contact. Neither save/load nor Optimize may replace that contact with a nearby lattice node. -### 9.5 «Оптимизировать планы» — explicit whole-plan maintenance +### 9.5 «Оптимизировать планы» -Existing and imported plans may still hold grid-bound coordinates between the -nodes. Ordinary grid-bound editor operations do not create more; explicitly -continuous objects are exempt. General settings contain -a **Plan maintenance** group whose action previews and then repairs old -data through all current passes: model upgrades, mandatory grid -alignment, exact open-span canonicalisation and wall-interval compaction. -Unlike live snapping, the explicit maintenance pass also replaces a stored -coordinate which is only one or several ULPs away from its node with the exact -computed node. That has no visible displacement but removes topology noise at -its persisted source. - -Why an action rather than a silent migration: - -1. It moves the user's data without asking. A house plan is a drawing; - the card has no mandate to redraw it on a version bump. -2. A silent migration is unattributable. When a room looks 3 cm wrong - the owner cannot tell whether the card did it or they did. -3. An update that touches stored geometry cannot be rolled back by - downgrading the card. The explicit action has a one-deep snapshot and - can also simply not be pressed. - -`optimizePlans(config, layout)` (`src/plan-optimizer.ts`) is the pure -orchestrator. It converts legacy fields with an exact mapping, projects -`open_spans` (or the `open_to` fallback) into stable zero-thickness wall atoms, -calls the grid projection, rekeys exact wall endpoints onto moved rooms, -compacts consecutive atoms only when thickness and physical ownership both -match, and stamps `model_version`. Outer/shared transitions and changes of -shared-room pair remain exact breakpoints even at equal thickness. Unknown -fields are preserved and every pass is idempotent. - -The explicit pass also repairs pre-existing near-axis room walls, saved wall -chains and independent walls after ordinary grid alignment (#290). Coincident -room-owner copies count as one physical wall and move as one endpoint -equivalence class. The preview reports the unique count, maximum physical -movement and unsafe skipped candidates; only Confirm writes, and Undo restores -the prior geometry. Exact axes and true diagonals are not candidates. - -The optimizer deliberately does **not** alter backdrop calibration or saved -view boxes, deduplicate markers, or delete files. It may delete an unattached -layout entry only after classifying its owner against current rooms, marker -tombstones and an authoritative HA device/entity roster. Proven-absent room -labels, devices and group markers are cleaned; live owners are preserved unless -the administrator explicitly opts into removing their old positions, and an -incomplete registry or unknown namespace always fails closed. The cleanup is -part of the pure candidate, Undo and idempotence contract. File collection -remains the backend's reference-aware scheduled job. - -`alignAllToGrid(spaces, layout)` (`src/align-grid.ts`) is pure: it -copies its input, never mutates it, and returns the new spaces, the new -layout and the report. The dialog therefore measures and commits the -**same object** — the numbers it promises cannot differ from what it -does. The resulting config+layout pair is sent to -`houseplan/plan/optimize`; the backend persists a durable intent before -either store changes, commits both revisions, and retains one snapshot. -`houseplan/plan/optimize_undo` restores it only while neither revision -has changed since the optimization. A crash between store writes is -completed from the intent on the next integration setup. - -The grid pass deliberately excludes the complete transform of `furniture`, -uploaded `image` decor and `spaces[].stairs[]`. Their position, size and -rotation are continuously authored values (#383, #663), so changing even one of those fields would make -Optimize create debt from a normal editor operation. Other decor kinds and -storage-level numeric canonicalization keep their existing grid contract -(#477). - -The pair returned by `optimizePlans` passes the same lattice-aware boundary as -the storage writers **before** visible Align and before `changed` is computed. -This boundary is required because the normalized grid step `1 / 240` has no -finite decimal representation: an exact node and a nine-decimal JSON echo may -be visually identical but not `===`. Update-event reload and a cold read -therefore receive exactly the pair retained by the preview, and a second run -cannot manufacture fresh coordinate noise (#248, #291). - -Model v8 adds a second, identity-preserving stage at this write boundary -(#282). `materializeWallSegmentModel()` atomizes canonical room contours into -`wall_segments[]`, keeps the deterministic parent ID on one split child, emits -UUIDs only for genuinely new v8 atoms, and refreshes `rooms[].wall_ids[]`, -draft IDs and tagged opening hosts together. The historical `walls[]` entries -are regenerated from this catalog as a compatibility view. Reading or fitting -the canvas never runs this migration; only physical edits, Optimize and a -v7-to-v8 import may materialise it. Failure keeps the previous view, history -and persisted revision intact. - -Guarantees are covered by `test/align-grid.test.mjs` and the orchestration/ -idempotence case in `test/plan-optimizer.test.mjs`: - -* every grid-bound element ends on a node; a rect's FAR corner too (a - snapped *size* on an off-grid origin leaves the other side between - the nodes); -* an opening ends on its wall, at whole steps along it, inside it, and - **with the wall's own angle** — the angle is written, so it is part of - the diff (AUD-158B1-02: an opening already on its wall with a wrong - angle used to be returned changed inside `changed: false`, which made - it unfixable); -* a stray opening with no wall within 6 steps is left exactly where it - is rather than teleported; -* **idempotent across storage**: a second run in memory, after the lattice-aware - writer round-trip, after update-event reload or after a cold read reports - `moved: 0`, `changed: false`, and `latticeCoordinatesCanonicalized: 0`, and returns - objects deep-equal to the first persisted result; -* the report is an **upper bound**, not a sample (AUD-158B1-01). - -Before a changed preview can expose Apply, `checkOptimizeGeometry(config)` -(`src/plan-geometry-preflight.ts`) runs the exact candidate through the shared -production input projection and canonical wall/floor boolean builders for every -space. `failed-core`, `degraded-extra` or an exception is a structural failure; -an empty successful geometry and an empty/image-only space are not. One failure -blocks the whole operation and the endpoint is not called. The dialog retains -only bounded statuses plus `contentFingerprint(candidate.config)`: unchanged -Apply reuses that result, while a changed fingerprint is checked again and -fails closed. -This frontend barrier does not replace backend permission, schema, revision or -crash-recovery checks and is not a security attestation from an untrusted -client. - -The same projection has a one-space transaction entry point for ordinary -physical edits (#278). Room/wall/open-span/opening/partition/column -candidates are validated before entering Undo or the save queue. A physical -fingerprint is rechecked immediately before the deferred config write; failure -restores the saved geometry and produces no WebSocket call. Presentation-only -edits deliberately do not invoke this barrier, so a legacy degraded plan can -still be renamed, exported and inspected. - -### The report is a promise - -The confirmation is the decision gate in front of a geometry rewrite, so -`maxShift`/`maxShiftCm` must never be smaller than what the run does: - -* displacement is measured on the geometry **actually written back** — - all FOUR corners of a rect, minimum-size correction included. The two - corners nobody used to measure are exactly the two that can be worst: - they carry the X error of one side together with the Y error of the - other, which is √2 of either; -* an opening is measured on its **ends**, flip-invariantly, so turning - it in place costs what it really costs and a 180° rewrite costs - nothing; -* the maximum is accumulated in **centimetres**, each space through its - own `cell_cm`, and the report names the space it belongs to. One - normalised maximum converted through the *first* space's cell size - promised 2.5 cm for a vertex that moved 50 cm on a 100 cm floor; -* the dialog rounds the last tenth **up** and, on a multi-space plan, - says which space the maximum is in; openings corrected in angle alone - are counted on a line of their own. - -`latticeCoordinatesCanonicalized` counts individual near-node coordinate -components actually rewritten by the storage boundary. Its maximum is measured -in each value's own `cell_cm`, displayed with three significant digits and kept -separate from visible `moved/maxShift*`. Only touched spaces receive a detail -line; each line also states how many authored off-grid components were observed -and left unchanged. Layout values without a named space contribute only to the -summary. The older `coordsCanonicalized` remains an internal Align counter and -does not absorb this storage-only work. - -One undo is available until the next config or layout edit. It restores -the stored snapshot; re-running optimization itself is never treated as -undo because a grid projection is not invertible. - -## Every place that assumed the unit square - - -| Place | Assumption | Decision | -| --- | --- | --- | -| `contentBounds` envelope `-25 %..125 %` | content outside the square does not count | **removed** — replaced by §4.1 outlier rejection | -| `_baseVb()` `if (mode !== 'view') return m.vb` | editors need the whole square to have room to draw | **removed** — the content frame plus §5 pan slack and 3x zoom-out gives more room than the square ever did | -| `_baseVb()` `if (m.bg) return m.vb` | image plans frame on the square | image rect is now just one content item (§4) | -| `--icon-size` scaled by `vb.w / view.w` | the canvas is what an icon is a fraction OF | numerator becomes `iconUnit()`; the icon still scales with the plan (§6) | -| `defaultPositions` `minDist` from `NORM_W` | one canvas = one plan | `iconUnit()` (§6) | -| `markerPos` / `_pos` fallback = `view_box` centre | a device with no position belongs in the middle of the square | `spaceCenter()` — the middle of the content | -| grid `` over `vb` | the grid ends with the square | rect follows the view (§7) | -| grid pitch fixed | fine at 1 canvas wide | `gridLevels()` (§7) | -| `_clampView` pinned content over the scene | you cannot pan past the edge | §5 pan slack | -| `_stagePointerMove` panned only while `zoom > 1` | below 100% the content already covered the scene, so a drag had nowhere to go | **removed** — §5, panning at every zoom | -| `ZOOM_MIN = 0.4` | fraction of the square | `MIN_ZOOM = 1/3` of the content frame (§5) | -| `_decorMoveUpdate` clamp `-0.25 .. 1.25` | decor may hang a quarter past the edge | clamp widened to the sane range (`+/-CANVAS_LIMIT`) — corruption insurance, not a frame | -| `_pointerMove` clamp to `_baseVb()`, `_labelMove` clamp to `view_box` | a marker/label belongs inside the canvas | **removed** — §9.1; missed in v1.57.0 and reported by the owner | -| static card `aspect-ratio` + `viewBox` from `space.vb` | the static card frames the square | `spaceFrame()` — same content frame as the full card | -| `validation.py` `+/-4`, `_EXTENT <= 4`, decor `-1..2`, opening `length <= 1` | the square plus slack | §3 | -| `safeViewBox` fallback `[0,0,1,1]` | a broken `view_box` means the square | kept — it is only the last-resort hint (§4) | -| `fitInSquare` (image placement) | image is centred in the square | **kept** — it defines the image's own rectangle in canvas units, which is exactly what §4 wants as a content item. It is only the DEFAULT placement: `planRect()` adds `plan_x/y`, per-axis scale and angle on top (with legacy `plan_scale` as fallback), and the transformed corners are what §4 counts (docs/BACKDROP.md) | -| image plan papers the image rect | the picture IS the sheet | **removed** in v1.58.0 — the opaque paper is the room contours in every case, and the picture is drawn on top of it (docs/BACKDROP.md §3) | -| `_spaceH` / `_decorH` = `NORM_W` | the canvas is square | **kept** — this is the coordinate system's aspect, not a frame | -| `_gridPitch = NORM_W / GRID_N` | grid pitch is tied to the canvas unit | **kept** — the pitch is the real-world cell (`cell_cm`), it must not change with the plan's size | -| sun wedges / glow radii / resize maths | all in render units, relative to their own geometry | **unaffected** — verified: no `NORM_W`-relative constants | +Explicit whole-plan maintenance — the preview/apply pass that repairs stored +off-node coordinates, model upgrades, open spans and wall intervals — is a +storage contract, not a canvas one: `CONFIG-COMPATIBILITY.md`, section +«Optimize plans: explicit whole-plan maintenance». Ordinary grid-bound +editing never performs it implicitly. ## What is deliberately NOT done @@ -743,69 +557,7 @@ both endpoints, so it cannot deform the segment or let its far endpoint cross the backend boundary. Hit areas and drag thresholds are expressed in CSS pixels, therefore selection remains usable at every zoom. -## Architectural connection overlay - -When **Walls** is active in the Plan editor, a derived -pointer-transparent SVG layer exposes the centre axes of completed room walls -and independent partitions. It is painted after their -physical wall bodies, but before interactive editor chrome. Columns, decor, -devices, the active wall chain and its live preview are not candidates. Door, -window, gate and intentionally open-span intervals are cut from presentation -axes; a cut boundary does not become a new endpoint. - -The layer and hit resolver share one immutable geometry snapshot. Original -segment endpoints are deduplicated and drawn at a physical radius of 5 cm. -Inside a 12 CSS px hit zone, an endpoint wins over every line and grows to -10 cm. Otherwise the nearest solid line receives one 10 cm dynamic node: the -raw pointer is projected onto that line, then quantized by the grid step along -the line from its stable start. This keeps diagonal connections wall-bound even -when neither resulting coordinate is a global grid multiple. The same resolver -runs again on click, so hover is only a preview and never authoritative. - -Endpoint and line candidates override the normal grid and Shift/45° result. -Outside the hit zone, §9.3–9.4 remain unchanged. A line connection adds only the -new segment endpoint; it does not split or rewrite the existing wall. The -current anchor is excluded to prevent zero-length segments. The static geometry -is cached by structural editor state; pointer movement changes at most the -single active candidate and never writes config, layout or storage. - -A separate diagnostic projection is present throughout the Plan editor (#296), -including tools other than **Walls**. For every independent wall -segment with a positive exact collinear overlap against another wall, it keeps -that source segment's complete axis and original endpoints visible. It is -painted after every wall body and zero-thickness axis and before openings, selection -chrome and transient previews. The layer is `pointer-events:none`, -`aria-hidden`, absent from View and cached by structural revision; it neither -deduplicates source identities nor participates in the architectural snap -resolver above. The 1 CSS px non-scaling axis and physical 5 cm nodes therefore -diagnose an otherwise invisible Resize blocker without changing any hit target. - -## Planar wall faces - -Every completed Walls segment is persisted immediately as an ordinary -`partition`; only its ordered chain membership remains in memory. On the click -path only, an immutable planar graph is built from structural room edges and -partitions both before and after the latest segment. Unlike the -presentation/snap snapshot, this -face graph ignores door/window/gate/passage cuts; zero-thickness wall axes remain -structural graph edges even though they have no masonry body. -Endpoint, T, X and -collinear-overlap junctions atomize that computed graph without rewriting any -saved wall. A deterministic half-edge walk extracts bounded faces; canonical -identity ignores winding, cyclic start and derived collinear subdivision. - -Only faces added by the latest segment and containing one of its atoms are -offered. They are ordered by area and then canonical key. Existing exact or -partially overlapping rooms are excluded, nested rooms remain legal, and any -physical gap created by an `open_span` or absent wall remains a gap. A door, -window, gate or passage is a property of a wall and preserves connectivity. A clean divider across -one room reuses the Split contract: the larger side keeps the room identity, -metadata and device binding, and only the smaller side is offered. - -The terminal active path remains session-local while the resulting room dialogs -are open. Create/Keep-as-walls answers are buffered; Cancel/Esc discards all -answers and leaves the already persisted partitions unchanged. The final answer -revalidates the whole batch and applies accepted rooms while consuming only the -coincident partitions used by those rooms in one history/config transaction. -Graph construction never runs on pointermove, Home Assistant state updates or -ordinary rendering. +The architectural connection overlay of the **Walls** tool and the planar +face graph that offers rooms after a closed chain are wall contracts: +`WALL-THICKNESS.md`, sections «Architectural connection overlay» and «Planar +wall faces». diff --git a/docs/CONFIG-COMPATIBILITY.md b/docs/CONFIG-COMPATIBILITY.md index a02188c0..891e3f7e 100644 --- a/docs/CONFIG-COMPATIBILITY.md +++ b/docs/CONFIG-COMPATIBILITY.md @@ -15,10 +15,11 @@ The machine-readable source of truth is - migration behaviour; - the read-compatibility decision. -This registry initially covers the known compatibility and internal-field debt -identified by HP-DATA-01. It is not yet the complete canonical schema. The next -stage is to register all current public fields and add automated parity against -the TypeScript model and backend Voluptuous validation. +The registry started from the compatibility and internal-field debt identified +by HP-DATA-01 and has grown with every model change since; the TypeScript model +and the backend Voluptuous schema are checked against each other by tests +(#33), so a field that is missing here is a documentation gap, not an unknown +schema. ## Offline inventory @@ -870,3 +871,162 @@ retain the full-space migration, physical and junction barrier. Old frontends and old backends therefore see no new field or protocol, and a backend rejection still rolls the complete pending physical transaction back to its earliest snapshot. + +## Optimize plans: explicit whole-plan maintenance («Оптимизировать планы») + +Existing and imported plans may still hold grid-bound coordinates between the +nodes. Ordinary grid-bound editor operations do not create more; explicitly +continuous objects are exempt (the snap contract itself is `CANVAS.md` §9). General settings contain +a **Plan maintenance** group whose action previews and then repairs old +data through all current passes: model upgrades, mandatory grid +alignment, exact open-span canonicalisation and wall-interval compaction. +Unlike live snapping, the explicit maintenance pass also replaces a stored +coordinate which is only one or several ULPs away from its node with the exact +computed node. That has no visible displacement but removes topology noise at +its persisted source. + +Why an action rather than a silent migration: + +1. It moves the user's data without asking. A house plan is a drawing; + the card has no mandate to redraw it on a version bump. +2. A silent migration is unattributable. When a room looks 3 cm wrong + the owner cannot tell whether the card did it or they did. +3. An update that touches stored geometry cannot be rolled back by + downgrading the card. The explicit action has a one-deep snapshot and + can also simply not be pressed. + +`optimizePlans(config, layout)` (`src/plan-optimizer.ts`) is the pure +orchestrator. It converts legacy fields with an exact mapping, projects +`open_spans` (or the `open_to` fallback) into stable zero-thickness wall atoms, +calls the grid projection, rekeys exact wall endpoints onto moved rooms, +compacts consecutive atoms only when thickness and physical ownership both +match, and stamps `model_version`. Outer/shared transitions and changes of +shared-room pair remain exact breakpoints even at equal thickness. Unknown +fields are preserved and every pass is idempotent. + +The explicit pass also repairs pre-existing near-axis room walls, saved wall +chains and independent walls after ordinary grid alignment (#290). Coincident +room-owner copies count as one physical wall and move as one endpoint +equivalence class. The preview reports the unique count, maximum physical +movement and unsafe skipped candidates; only Confirm writes, and Undo restores +the prior geometry. Exact axes and true diagonals are not candidates. + +The optimizer deliberately does **not** alter backdrop calibration or saved +view boxes, deduplicate markers, or delete files. It may delete an unattached +layout entry only after classifying its owner against current rooms, marker +tombstones and an authoritative HA device/entity roster. Proven-absent room +labels, devices and group markers are cleaned; live owners are preserved unless +the administrator explicitly opts into removing their old positions, and an +incomplete registry or unknown namespace always fails closed. The cleanup is +part of the pure candidate, Undo and idempotence contract. File collection +remains the backend's reference-aware scheduled job. + +`alignAllToGrid(spaces, layout)` (`src/align-grid.ts`) is pure: it +copies its input, never mutates it, and returns the new spaces, the new +layout and the report. The dialog therefore measures and commits the +**same object** — the numbers it promises cannot differ from what it +does. The resulting config+layout pair is sent to +`houseplan/plan/optimize`; the backend persists a durable intent before +either store changes, commits both revisions, and retains one snapshot. +`houseplan/plan/optimize_undo` restores it only while neither revision +has changed since the optimization. A crash between store writes is +completed from the intent on the next integration setup. + +The grid pass deliberately excludes the complete transform of `furniture`, +uploaded `image` decor and `spaces[].stairs[]`. Their position, size and +rotation are continuously authored values (#383, #663), so changing even one of those fields would make +Optimize create debt from a normal editor operation. Other decor kinds and +storage-level numeric canonicalization keep their existing grid contract +(#477). + +The pair returned by `optimizePlans` passes the same lattice-aware boundary as +the storage writers **before** visible Align and before `changed` is computed. +This boundary is required because the normalized grid step `1 / 240` has no +finite decimal representation: an exact node and a nine-decimal JSON echo may +be visually identical but not `===`. Update-event reload and a cold read +therefore receive exactly the pair retained by the preview, and a second run +cannot manufacture fresh coordinate noise (#248, #291). + +Model v8 adds a second, identity-preserving stage at this write boundary +(#282). `materializeWallSegmentModel()` atomizes canonical room contours into +`wall_segments[]`, keeps the deterministic parent ID on one split child, emits +UUIDs only for genuinely new v8 atoms, and refreshes `rooms[].wall_ids[]`, +draft IDs and tagged opening hosts together. The historical `walls[]` entries +are regenerated from this catalog as a compatibility view. Reading or fitting +the canvas never runs this migration; only physical edits, Optimize and a +v7-to-v8 import may materialise it. Failure keeps the previous view, history +and persisted revision intact. + +Guarantees are covered by `test/align-grid.test.mjs` and the orchestration/ +idempotence case in `test/plan-optimizer.test.mjs`: + +* every grid-bound element ends on a node; a rect's FAR corner too (a + snapped *size* on an off-grid origin leaves the other side between + the nodes); +* an opening ends on its wall, at whole steps along it, inside it, and + **with the wall's own angle** — the angle is written, so it is part of + the diff (AUD-158B1-02: an opening already on its wall with a wrong + angle used to be returned changed inside `changed: false`, which made + it unfixable); +* a stray opening with no wall within 6 steps is left exactly where it + is rather than teleported; +* **idempotent across storage**: a second run in memory, after the lattice-aware + writer round-trip, after update-event reload or after a cold read reports + `moved: 0`, `changed: false`, and `latticeCoordinatesCanonicalized: 0`, and returns + objects deep-equal to the first persisted result; +* the report is an **upper bound**, not a sample (AUD-158B1-01). + +Before a changed preview can expose Apply, `checkOptimizeGeometry(config)` +(`src/plan-geometry-preflight.ts`) runs the exact candidate through the shared +production input projection and canonical wall/floor boolean builders for every +space. `failed-core`, `degraded-extra` or an exception is a structural failure; +an empty successful geometry and an empty/image-only space are not. One failure +blocks the whole operation and the endpoint is not called. The dialog retains +only bounded statuses plus `contentFingerprint(candidate.config)`: unchanged +Apply reuses that result, while a changed fingerprint is checked again and +fails closed. +This frontend barrier does not replace backend permission, schema, revision or +crash-recovery checks and is not a security attestation from an untrusted +client. + +The same projection has a one-space transaction entry point for ordinary +physical edits (#278). Room/wall/open-span/opening/partition/column +candidates are validated before entering Undo or the save queue. A physical +fingerprint is rechecked immediately before the deferred config write; failure +restores the saved geometry and produces no WebSocket call. Presentation-only +edits deliberately do not invoke this barrier, so a legacy degraded plan can +still be renamed, exported and inspected. + +### The report is a promise + +The confirmation is the decision gate in front of a geometry rewrite, so +`maxShift`/`maxShiftCm` must never be smaller than what the run does: + +* displacement is measured on the geometry **actually written back** — + all FOUR corners of a rect, minimum-size correction included. The two + corners nobody used to measure are exactly the two that can be worst: + they carry the X error of one side together with the Y error of the + other, which is √2 of either; +* an opening is measured on its **ends**, flip-invariantly, so turning + it in place costs what it really costs and a 180° rewrite costs + nothing; +* the maximum is accumulated in **centimetres**, each space through its + own `cell_cm`, and the report names the space it belongs to. One + normalised maximum converted through the *first* space's cell size + promised 2.5 cm for a vertex that moved 50 cm on a 100 cm floor; +* the dialog rounds the last tenth **up** and, on a multi-space plan, + says which space the maximum is in; openings corrected in angle alone + are counted on a line of their own. + +`latticeCoordinatesCanonicalized` counts individual near-node coordinate +components actually rewritten by the storage boundary. Its maximum is measured +in each value's own `cell_cm`, displayed with three significant digits and kept +separate from visible `moved/maxShift*`. Only touched spaces receive a detail +line; each line also states how many authored off-grid components were observed +and left unchanged. Layout values without a named space contribute only to the +summary. The older `coordsCanonicalized` remains an internal Align counter and +does not absorb this storage-only work. + +One undo is available until the next config or layout edit. It restores +the stored snapshot; re-running optimization itself is never treated as +undo because a grid projection is not invertible. diff --git a/docs/DECOR-EDITOR.md b/docs/DECOR-EDITOR.md index bdfd4bab..d5fbc865 100644 --- a/docs/DECOR-EDITOR.md +++ b/docs/DECOR-EDITOR.md @@ -1,10 +1,12 @@ -# Background editor: current contract +# Background editor: decor, plan image and text labels -Status: implemented in v1.60.0. This document is the source of -truth for the decorative layer. `BACKDROP.md`, `LIVE-TEXT.md` and -`FURNITURE.md` describe the specialised parts. +This document is the source of truth for the decorative layer: the Background +editor, the plan image backdrop (formerly `BACKDROP.md`) and the text label with +live Home Assistant values (formerly `LIVE-TEXT.md`). Furniture symbols keep +their own contract in `FURNITURE.md`. Sections are numbered so that code +comments can point at them (`docs/DECOR-EDITOR.md §3.2`). -## Purpose and invariants +## 1. Purpose and invariants The Background editor is a visual annotation layer. Its shapes, furniture, custom images, text and plan image never participate in rooms, wall geometry, @@ -21,29 +23,58 @@ light routing, device state or Home Assistant actions. | Rotation | Ordinary decor uses 5° steps by default and `Shift` for free rotation. Furniture and custom images are free by default and `Shift` snaps to 45°. Lines use endpoint handles instead of a rotation handle. | | Magnet targets | Only other decor objects and room contours: corners, edge centres, centres and edges. The image, devices and openings are excluded. | | Context emphasis | Decor and its editing chrome stay fully opaque. Rooms, labels, devices, openings, positive-thickness walls and solid/dashed zero-thickness walls are contextual only and render at 35% opacity. The whole device presentation (core, ring, capsule, values and badges) is pointer-inert: it never hovers, opens, acts or drags, and the active Background tool receives a press through it. | -| View composition | All decor kinds form one layer above room/data fills, room hover fill, opening-tunnel fills and Glow base. Live Glow, sun, physical walls, opening symbols, devices and room labels remain above decor. The plan image remains below it. | +| View composition | All decor kinds form one layer above room/data fills, room hover fill, opening-tunnel fills and Glow base. Live Glow, sun, physical walls, opening symbols, devices and room labels remain above decor. The plan image remains below it (§3.3). | | Compatibility | Legacy `width`, text `size/scale` and `plan_scale` remain readable. New writes use `width_cm`, `size_cm` and `plan_scale_x/y`. | -## Tools +## 2. Tools | Tool | Pointer action | Live feedback | Properties | |---|---|---|---| | Select | Select/move any decor object; corner handles resize; upper handle rotates | common selection frame and standard move/resize/rotate cursors | double click opens geometry, angle, contour/fill colour and opacity; text also exposes its content | -| Plan backdrop | Move the image by its body; resize/rotate by the frame | size badge; 5° rotation step | double click opens width, height and angle | +| Plan backdrop | Move the image by its body; resize/rotate by the frame (§3.2) | size badge; 5° rotation step | double click opens width, height and angle | | Line | Drag between two grid points | length, angle, magnetic alignment guides | endpoint handles; length, angle, stroke colour/opacity/thickness; Solid or Dashed style | | Rectangle | Drag a diagonal; `Shift` makes a square | width × height and area | size, angle, contour, optional independent fill colour/opacity | | Oval | Drag its bounding box; `Shift` makes a circle | `R` for a circle, `Rx × Ry` for an oval | bounding size, angle, contour and optional fill | -| Text | Click to open the text form | the saved label is selected immediately | content, HA variables, colour/opacity, physical size and angle | +| Text | Click to open the text form (§5) | the saved label is selected immediately | content with inline HA variables, colour/opacity, physical size and angle | | Furniture | Pick a symbol, then click its centre | wall magnet unless `Shift` is held | signed size, H/V mirror, angle and contour style; corners resize smoothly, four middle handles change one axis, rotation is free/`Shift` 45° | -| Image | Upload PNG/JPEG/WebP/SVG or pick a stored file, then click its centre | exact one-shot preview; no wall magnet | opacity, file replacement, signed size, H/V mirror and angle; the whole rotated rectangle is selectable | +| Image | Upload PNG/JPEG/WebP/SVG or pick a stored file, then click its centre (§7) | exact one-shot preview; no wall magnet | opacity, file replacement, signed size, H/V mirror and angle; the whole rotated rectangle is selectable | | Erase | Click a decor object; text uses its whole logical bounding box, including spaces between glyphs | confirmation dialog, then atomic removal of the whole object | A miss changes nothing; Undo restores a removed object | The editor always opens on **Select**. If the space has an image, the Plan backdrop tool appears next to Select but is never armed implicitly. -## Plan image behaviour +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: -The plan image is interactive only while **Plan backdrop** is selected. +| 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 | + +## 3. Plan image backdrop + +### 3.1. Placement model + +The source image is first fitted proportionally into the square plan canvas by +`fitInSquare(plan_aspect, NORM_W)`. The optional transform is then applied: + +| Field | Meaning | Default | +|---|---|---:| +| `plan_x`, `plan_y` | top-left offset from the fitted rectangle, normalised by `NORM_W` | `0` | +| `plan_scale_x`, `plan_scale_y` | independent width/height multipliers | `1` | +| `plan_angle` | rotation around the transformed rectangle centre, normalised to -180..180 | `0°` | +| `plan_scale` | legacy uniform fallback for both axes | `1` | + +`planRect()` is the single reader used by the full card, static card and content +bounds. New writes remove `plan_scale`; **Оптимизировать планы** converts it +losslessly to both axis fields. Reset removes every transform field and is +itself undoable. + +### 3.2. Editor behaviour + +The plan image is interactive only while **Plan backdrop** is selected: | Context | Image opacity | Frame/body interaction | |---|---:|---| @@ -51,18 +82,69 @@ The plan image is interactive only while **Plan backdrop** is selected. | Background editor, Select/drawing/furniture/erase | 0.5 | none; the stage remains available for pan/draw | | Background editor, Plan backdrop | 1.0 | move, proportional/independent resize, rotate | -Stored transform fields: +- body drag moves it; +- four corner handles preserve ratio by default; `Shift` allows independent axes; +- the upper handle rotates in 5° steps, or freely with `Shift`; +- double click opens numeric width, height and angle; +- `Esc` restores the transform at pointer-down; +- release creates one named command in the shared 50-step history. + +Move and resulting top-left coordinates are grid-bound. Width and height are +quantised when resized or entered numerically. There is no positional modifier +that bypasses the grid. + +The frame chrome — dashed outline, four corner handles, one rotate handle on a +stem — is the mechanics every decor transform frame reuses: 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. A live backdrop gesture +**freezes the view frame**: the picture is a content item, so letting a drag +grow the frame would rescale the view mid-gesture and the picture would run +away from the finger; the frame catches up on release. The backdrop transform +is part of the model fingerprint, so a drag never leaves a memoised model (and +the content frame built from it) showing the old rectangle. + +### 3.3. Rendering and layer order + +The image is purely decorative and never changes rooms, walls, openings, +devices, Glow or sun geometry. It is still a content item for fit/pan bounds; +for a rotated image all four rotated corners contribute to those bounds. A +rotated backdrop is hit-tested in its own local coordinate system. + +Layer order, top to bottom: ```text -plan_x, plan_y top-left offset, normalised to the square canvas -plan_scale_x, plan_scale_y independent positive multipliers -plan_angle degrees, normalised to -180..180 +devices and room labels +opening symbols / positive and zero-thickness walls / late room-hover outline +sun rays +live Glow pools +decor +room hover fill / Glow-base rooms and tunnels / data room fills and tunnels +plan image +room-shaped paper +scene background ``` -`plan_scale` is the legacy uniform fallback. The maintenance command converts -it losslessly to both axis fields. Reset removes every transform field. +The paper remains room-shaped; an image without rooms does not create opaque +paper behind itself (`SUN.md` describes the paper). -## Style and units +### 3.4. Large images (#39) + +Before any decode the picked raster is classified from its header bytes only +(`src/backdrop-probe.ts`): PNG IHDR (+colour type/tRNS for alpha), JPEG SOF, +WebP VP8/VP8L/VP8X. Thresholds live in that module as the single calibration +point: a decoded size above `WARN_DECODED_BYTES` (128 MiB ≈ 32 MP) opens a +warning dialog with the real numbers and offers an aspect-preserving reduced +copy (longest side `DOWNSCALE_TARGET_PX` = 4096; PNG with alpha stays PNG, +opaque images become JPEG q0.9, EXIF orientation honoured); a side beyond +`HARD_DIMENSION` (16384, the browser canvas cap) offers only Cancel. A failed +or timed-out decode of the reduced copy shows a toast and leaves staging clean +— the original the user declined is never uploaded silently. Unreadable +headers behave like a warning without numbers. SVG is never rasterised and +skips the probe entirely. Calibration matrix and rationale: +`docs/specs/039-large-backdrops.md`, rerun via +`demo/benchmark_backdrop_decode.mjs`. + +## 4. Style and units New decor writes use: @@ -95,7 +177,145 @@ legacy line is Solid by default. Double-click a line with **Select** to switch that individual object between **Solid** and **Dashed**; switching back removes the optional `line_style` key instead of persisting a redundant default. -## Interaction state machine +## 5. Text labels and live values + +Released in v1.59.0-rc.1. 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. + +A label is the caption on the wall the plan could not say otherwise: *water +tank 68 %*, *garage 12 °C*, *watering tonight at 20:00* — free-standing text in +the user's own words that happens to contain a live number. It is **not a +second device marker** (no tap action, icon, state class or room aggregation), +**not a template engine** (no expressions, conditions, arithmetic, Jinja or +nested braces) and **not an auto-layout** (no wrapping, no shrink-to-fit). + +### 5.1. 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. The 200-character limit is the +limit of the saved template; each resolved value is still clipped to 60 +characters. + +### 5.2. 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". +- 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 write no formatting of our own.** We + do not round, reformat or localise decimal separators ourselves: the state + object goes 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. Imperial/ + metric is not our business either — the value and the unit come from HA + (`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 editor; a literal suffix can be typed immediately after the token. +- The value is clipped to `LIVE_TEXT_VALUE_MAX` (60) characters: 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 Background 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` draws + only the plan image and custom decor images (`src/space-render.ts`); lines, + shapes, furniture and text labels — live or not — are full-card only. + +### 5.3. The block: size, rotation, lines + +- **`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` (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`) and vertically: the anchor `x/y` sits in the middle + of the block, so adding a second line grows the label in both directions. +- 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. The frame reuses the backdrop frame's + mechanics and sizes (§3.2). What you **see** is a quarter of the hit radius + (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 — the same split + the wall-resize handles use (`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. + +### 5.4. 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. + +### 5.5. 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`). + +## 6. Interaction state machine ```text idle → draft/move/scale/rotate → release → named history command → debounced save @@ -104,10 +324,8 @@ idle → draft/move/scale/rotate → release → named history command → debou Only the active tool owns pointer events. Drawing tools can therefore start a new line or figure exactly on top of an existing object. Select and Erase are -the general tools that target existing decor. The deliberate exception is -Text: clicking an existing text label with Text opens that label's editor; -clicking any non-text shape still starts a new label. The backdrop body belongs -only to the Plan backdrop tool. +the general tools that target existing decor; the Text tool's exception is in +§2. The backdrop body belongs only to the Plan backdrop tool. `Ctrl/Cmd+Z` first cancels an unfinished draft or live gesture, then walks the shared history. `Ctrl+Shift+Z` and `Ctrl+Y` obey the same transaction boundary: @@ -121,26 +339,7 @@ off-grid remainder and deliberately bypasses decor and wall magnets, so it can fine-tune furniture away from a wall. Each keydown is one named Undo step; controls, dialogs and live pointer gestures keep their own Arrow behaviour. -## Code ownership - -| Concern | File | -|---|---| -| persisted decor types and custom-image transform contract | `src/editors/decor/types.ts` | -| physical style conversion, oriented boxes, resize and snapping | `src/editors/decor/geometry.ts` | -| furniture-only continuous resize, flip projection and SVG transform | `src/furniture.ts` | -| shared colour/opacity field | `src/hp-color-opacity.ts` | -| orchestration, dialogs and SVG transform frames | `src/houseplan-card.ts` | -| static/full-card backdrop rendering and content bounds | `src/space-render.ts`, `src/space-geometry.ts` | -| accepted persisted ranges | `custom_components/houseplan/validation.py` | -| image validation, content identity and catalog | `custom_components/houseplan/decor_assets.py` | -| authenticated upload/list/resolve/delete | `custom_components/houseplan/http_api.py`, `custom_components/houseplan/websocket_api.py` | -| explicit legacy conversion | `src/plan-optimizer.ts` | - -The current root card still owns orchestration. Future extraction should move -it into `src/editors/decor/decor-editor.ts` without changing the typed model or -geometry helpers. - -## Custom image lifecycle +## 7. Custom image lifecycle The **Image** tool opens one shared catalog. Upload accepts decoded PNG, JPEG and WebP or a strict, canonical SVG; each canonical file is limited to 2 MiB. @@ -162,7 +361,29 @@ replace it without losing position, size, rotation, mirror or layer order. Export v2 records hashes and availability but never embeds image bytes; an import with missing bytes requires confirmation and preserves that placeholder. -## Edge cases +## 8. Code ownership + +| Concern | File | +|---|---| +| persisted decor types and custom-image transform contract | `src/editors/decor/types.ts` | +| physical style conversion, oriented boxes, resize and snapping | `src/editors/decor/geometry.ts` | +| furniture-only continuous resize, flip projection and SVG transform | `src/furniture.ts` | +| shared colour/opacity field | `src/hp-color-opacity.ts` | +| orchestration, dialogs, SVG transform frames, backdrop gestures and reset | `src/houseplan-card.ts` | +| live text: `liveText`, `liveTextReference`, `liveTextToken`, `liveTextValue`, `decorTextScale`, `decorTextLines`, `hassValue` (pure, unit-tested) | `src/logic.ts` | +| fitted/transformed backdrop rectangle and rotated bounds | `src/space-geometry.ts` | +| static-card backdrop and decor images | `src/space-render.ts` | +| large-image probe and thresholds | `src/backdrop-probe.ts` | +| accepted persisted ranges (`DECOR_SCHEMA`, backdrop transform) | `custom_components/houseplan/validation.py` | +| image validation, content identity and catalog | `custom_components/houseplan/decor_assets.py` | +| authenticated upload/list/resolve/delete | `custom_components/houseplan/http_api.py`, `custom_components/houseplan/websocket_api.py` | +| explicit legacy conversion | `src/plan-optimizer.ts` | + +Smokes: `demo/smoke_decor.mjs`, `demo/smoke_decor_text.mjs`, +`demo/smoke_live_text.mjs`, `demo/smoke_backdrop.mjs`, +`demo/smoke_backdrop_guard.mjs`, `demo/smoke_bg_color.mjs`. + +## 9. Edge cases - Degenerate drafts below half a cell are discarded and do not enter history. - Unknown furniture symbols remain stored but render nothing in an older card. @@ -173,7 +394,6 @@ import with missing bytes requires confirmation and preserves that placeholder. - A rotated box contributes all four rotated corners to content bounds. - A transparent custom image is selected by its complete rotated rectangle, not only opaque pixels. -- A rotated backdrop is hit-tested in its own local coordinate system. - Changing `cell_cm` changes the rendered width/font size of canonical physical styles, as expected: it changes the scale of the whole space. Legacy render-unit strokes/text retain their old pixels until migration. diff --git a/docs/DEVICE-LIGHT-SETTINGS-MATRIX.ru.md b/docs/DEVICE-LIGHT-SETTINGS-MATRIX.ru.md deleted file mode 100644 index 606cc37f..00000000 --- a/docs/DEVICE-LIGHT-SETTINGS-MATRIX.ru.md +++ /dev/null @@ -1,122 +0,0 @@ -# Матрица настроек света устройства - -Актуально для локальной реализации issues [#84](https://github.com/Matysh/houseplan-card/issues/84) -и [#88](https://github.com/Matysh/houseplan-card/issues/88). Каноническая модель -геометрии и распространения света остаётся в [`LIGHT.md`](LIGHT.md). - -## Обозначения - -- **A** — режим «Авто» нашёл у устройства собственный пространственный источник. -- **S** — у устройства есть собственная управляемая `light.*`/`switch.*`, способная - дать состояние и принять service call. -- **Источник** — устройство участвует в Glow, заливке «Свет», карточке и статистике комнаты. -- **Live** — доступен режим цвета «Из источника». -- **Ручн.** — доступны ручной цвет и ручная яркость. -- **R** — доступен локальный радиус Glow. -- `Авто → фикс.` — сохранённый режим не переписывается, но в UI и runtime пассивный - источник получает безопасный fallback: общий цвет, яркость 100%. - -Комбинация `A=да, S=нет` приведена для полноты контракта и мутационных тестов, но -недостижима штатным resolver: автоматически найденный источник всегда имеет реальную -`light.*`. Остальные 27 строк достижимы. - -## Полная матрица role × A × S × режим Glow - -| # | Роль | A | S | Сохранённый режим | Источник | Пассивный | Live | Ручн. | R | Эффективный режим | -|---:|---|:---:|:---:|---|:---:|:---:|:---:|:---:|:---:|---| -| 1 | Авто | нет | нет | Из источника | нет | нет | нет | нет | нет | Из источника, disabled | -| 2 | Авто | нет | нет | Задать цвет | нет | нет | нет | нет | нет | Задать цвет, disabled | -| 3 | Авто | нет | нет | Цвет + яркость | нет | нет | нет | нет | нет | Цвет + яркость, disabled | -| 4 | Авто | нет | да | Из источника | нет | нет | нет | нет | нет | Из источника, disabled | -| 5 | Авто | нет | да | Задать цвет | нет | нет | нет | нет | нет | Задать цвет, disabled | -| 6 | Авто | нет | да | Цвет + яркость | нет | нет | нет | нет | нет | Цвет + яркость, disabled | -| 7 | Авто | да | нет | Из источника | да | да | нет | да | да | Авто → фикс. (теоретическая) | -| 8 | Авто | да | нет | Задать цвет | да | да | нет | да | да | Задать цвет (теоретическая) | -| 9 | Авто | да | нет | Цвет + яркость | да | да | нет | да | да | Цвет + яркость (теоретическая) | -| 10 | Авто | да | да | Из источника | да | нет | да | да | да | Из источника | -| 11 | Авто | да | да | Задать цвет | да | нет | да | да | да | Задать цвет | -| 12 | Авто | да | да | Цвет + яркость | да | нет | да | да | да | Цвет + яркость | -| 13 | Всегда | нет | нет | Из источника | да | да | нет | да | да | Авто → фикс. | -| 14 | Всегда | нет | нет | Задать цвет | да | да | нет | да | да | Задать цвет | -| 15 | Всегда | нет | нет | Цвет + яркость | да | да | нет | да | да | Цвет + яркость | -| 16 | Всегда | нет | да | Из источника | да | нет | да | да | да | Из источника | -| 17 | Всегда | нет | да | Задать цвет | да | нет | да | да | да | Задать цвет | -| 18 | Всегда | нет | да | Цвет + яркость | да | нет | да | да | да | Цвет + яркость | -| 19 | Всегда | да | нет | Из источника | да | да | нет | да | да | Авто → фикс. (теоретическая) | -| 20 | Всегда | да | нет | Задать цвет | да | да | нет | да | да | Задать цвет (теоретическая) | -| 21 | Всегда | да | нет | Цвет + яркость | да | да | нет | да | да | Цвет + яркость (теоретическая) | -| 22 | Всегда | да | да | Из источника | да | нет | да | да | да | Из источника | -| 23 | Всегда | да | да | Задать цвет | да | нет | да | да | да | Задать цвет | -| 24 | Всегда | да | да | Цвет + яркость | да | нет | да | да | да | Цвет + яркость | -| 25 | Никогда | нет | нет | Из источника | нет | нет | нет | нет | нет | Из источника, disabled | -| 26 | Никогда | нет | нет | Задать цвет | нет | нет | нет | нет | нет | Задать цвет, disabled | -| 27 | Никогда | нет | нет | Цвет + яркость | нет | нет | нет | нет | нет | Цвет + яркость, disabled | -| 28 | Никогда | нет | да | Из источника | нет | нет | нет | нет | нет | Из источника, disabled | -| 29 | Никогда | нет | да | Задать цвет | нет | нет | нет | нет | нет | Задать цвет, disabled | -| 30 | Никогда | нет | да | Цвет + яркость | нет | нет | нет | нет | нет | Цвет + яркость, disabled | -| 31 | Никогда | да | нет | Из источника | нет | нет | нет | нет | нет | Из источника, disabled (теоретическая) | -| 32 | Никогда | да | нет | Задать цвет | нет | нет | нет | нет | нет | Задать цвет, disabled (теоретическая) | -| 33 | Никогда | да | нет | Цвет + яркость | нет | нет | нет | нет | нет | Цвет + яркость, disabled (теоретическая) | -| 34 | Никогда | да | да | Из источника | нет | нет | нет | нет | нет | Из источника, disabled | -| 35 | Никогда | да | да | Задать цвет | нет | нет | нет | нет | нет | Задать цвет, disabled | -| 36 | Никогда | да | да | Цвет + яркость | нет | нет | нет | нет | нет | Цвет + яркость, disabled | - -Ручной цвет при stateful-источнике оставляет живую яркость. Режим «Цвет + яркость» -фиксирует оба значения. Для пассивного источника живой яркости нет, поэтому «Задать -цвет» означает яркость 100%, а «Цвет + яркость» использует сохранённое значение. - -## Ведущая сущность (#88) - -| Роль | Управляемых собственных сущностей | Сохранённый `light_entity` | UI и runtime | -|---|---:|---|---| -| Авто / Никогда | любое число | любое значение | Селектор скрыт; поле сохраняется без изменения, но на текущую роль не влияет | -| Всегда | 0 | отсутствует | Пассивный источник, селектор скрыт | -| Всегда | 1 | отсутствует | Единственная сущность выбирается автоматически, селектор скрыт | -| Всегда | 2+ | отсутствует | Селектор показан; fallback `binding → primary → первая управляемая` | -| Всегда | 1+ | валидное значение | Выбранная сущность даёт state и service target | -| Всегда | любое число | сущность исчезла | Предупреждение; временный fallback, сохранённая ссылка не стирается | - -Выбор не зависит от текущего `on/off/unavailable`: capability берётся из binding и -реестра HA, а live state обрабатывается отдельно. - -## Связи «Управляет другими источниками света» (#84) - -| Цель в `controls` | Состояние цели | Service call контроллера | Glow и статистика | -|---|---|---|---| -| `light.*` / `switch.*` | Фактическое состояние entity; группа — `any(on)` | Только реальные entity IDs | Реальный отдельный marker владеет позицией; иначе цель участвует без отдельного пятна у контроллера | -| `marker:` stateful | Состояние ведущей сущности цели | Ведущая сущность цели, с дедупликацией | Позиция, комната, цвет и радиус принадлежат marker-цели | -| `marker:` passive, один controller | Его реальные targets, иначе собственная ведущая entity | Сам `marker:*` никогда не отправляется в HA | Пассивная лампа следует controller и светит в своей позиции | -| Passive, несколько controllers | OR всех активных driver entities | По каждому действию — только его реальные targets | Один источник и один голос комнаты, без дублей | -| Exact `virtual` + «Всегда» + Toggle, есть links | OR всех активных driver entities; ручной bit временно не участвует | Клик controller — его группа; клик лампы — deduplicated union всех её drivers | HA state едино управляет Glow, заливкой, статистикой и обоими marker | -| Exact `virtual` + «Всегда» + Toggle, links отсутствуют | Operational Store #107; отсутствие записи = `on` | Клик лампы меняет только operational state, без HA service | Ручное состояние общее для full/static и сохраняется после restart | -| Passive без сохранённых links, не exact-режим #107 | Всегда `on` | Нет собственного вызова | Постоянный Glow и `1 из 1` | -| Links есть, но все drivers скрыты/disabled/удалены | `off` / dormant | Нет вызова по битой цели | Ссылка сохраняется, пятна нет | -| Прямая entity + `marker:` на тот же stateful source | Одно effective состояние | Один service target | Один источник и один голос | -| Target переведён из «Всегда» в «Авто без источника»/«Никогда» | Dormant | Нет marker-service | Link сохраняется и оживает при возврате «Всегда» | -| Target скрыт или disabled в HA | Dormant | Нет marker-service | Не рисуется и не влияет на комнату | -| Target удалён с плана | Ссылка удаляется атомарно | Нет | Устройство снова можно добавить заново | -| Broken legacy ref | Игнорируется с диагностикой | Нет | Open → Save не уничтожает ссылку | -| Self-link / новый цикл | Запрещено | — | UI не предлагает, backend отклоняет запись/import | - -Связи разрешены между комнатами и пространствами одного плана. При экспорте одного -пространства внутренние `marker:`-ссылки ремапятся вместе с marker ID, а внешние -отбрасываются с предупреждением в preview. - -## Независимые переключатели отображения - -| Настройка | Влияние на световую модель | -|---|---| -| «Значок + состояние» | Подложка устройства показывает его resolved working state; Glow определяется матрицей выше | -| «Значок + состояние и активность» | Дополнительно показывает короткую или постоянную пульсацию; источник света не меняется | -| «Значение + состояние» | Меняет только содержимое маркера; источник света не меняется | -| «Всегда статичный значок» | Блокирует динамику самого значка/подложки, но не отменяет отдельно настроенные Glow, заливку «Свет» и статистику комнаты | -| Glow выключен у пространства | Пятна не рисуются, но источник, состояние комнаты и заливка «Свет» продолжают вычисляться | -| Маркер скрыт / HA-disabled | Маркер и его собственный источник не участвуют в плане независимо от остальных настроек | - -## Проверяемый контракт - -- Все 36 строк первой таблицы проверяются pure unit-тестом. -- Выбор ведущей сущности, stale fallback, passive OR, dormant links, alias-dedupe, - lifecycle и запрет передачи `marker:*` в HA покрыты отдельными unit-тестами. -- Контекстный селектор, passive-гейтинг и plan-source picker покрыты browser smoke. -- Backend отдельно проверяет новые ссылки, циклы и перенос между пространствами. diff --git a/docs/DEVICE-PRESENTATION.md b/docs/DEVICE-PRESENTATION.md index ce9ec6ad..49b15626 100644 --- a/docs/DEVICE-PRESENTATION.md +++ b/docs/DEVICE-PRESENTATION.md @@ -5,7 +5,10 @@ названия и обещания остаются в [USER-GUIDE.ru.md](USER-GUIDE.ru.md#12-визуальные-состояния-устройств); здесь зафиксировано, как уже разрешённые факты превращаются в одно «лицо» маркера. Любое изменение результата требует изменения строки, fixture и -mutation evidence в одном pull request. +mutation evidence в одном коммите. Правила выбора источника лица (порядок +cover → light sources → device role, шторы и медиаплееры) — в разделе +«Source precedence: what a marker shows» ниже; `FILTERING.md` решает только, +есть ли устройство на плане. ## Границы ответственности @@ -102,3 +105,142 @@ mutation evidence в одном pull request. Неуказанная ось не влияет на строку. Новая комбинация получает новый ряд только тогда, когда меняет победившее решение или наблюдаемый результат; полное декартово произведение binding × source × display × activity запрещено. + +## Source precedence: what a marker shows + +A marker's live indication — status plate, state-morphed icon and semantic +activity — is derived by one resolver from one effective source set. Action +selection is separate but shares `resolveToggleIntent` whenever the effective +action is Toggle state; `_actEntity` is legacy terminology and is not an +independent target resolver. Presentation source precedence is: + +`display: static_icon` is the deliberate presentation exception to the matrix +below. The resolver still retains source metadata for preview diagnostics, but +the rendered marker always uses its base icon on a neutral dark plate: no state +morph, work/open/alarm/unavailable paint, activity, RGB, value or satellite +temperature/humidity/LQI badge. The live vacuum puck, trail and route warning +are also suppressed. This changes presentation only: hover/focus, service-call +feedback, controls, Glow and room light aggregation keep using the real device. +Hidden, removed or HA-disabled lifecycle rules still outrank display mode. + +1. the resolved **cover** when `resolveToggleIntent` selected cover semantics. + This includes current explicit `toggle` and losslessly-read legacy + `tap_action: 'cover'`; the same exact entity drives open/close/stop and icon + morph/activity. It wins over EVERYTHING below; +2. the marker's **resolved light sources** (`resolvedLightSources`): external + `controls` plus its own primary controllable entity when `is_light: true` + (an `entity:*` marker's bound entity and a `device:*` marker's child entities + are excluded from the external list); `is_light: false` suppresses that own + candidate, while missing/null discovers automatic `light.*` only when `light` is the + device's resolved functional role. An auxiliary LED/display light on a + media player or appliance does not turn the whole marker into a lamp. This + exact set feeds Light fill, room light stats, marker feedback and group + toggle. Glow additionally requires a spatial source: an external control + never places a pool at the controller, while a real lamp marker or explicit + `is_light: true` marker does. When both name the same entity, the physical marker + owns its one Glow position regardless of registry order; + + A pre-v1.60 marker that lists its own `switch.*` in `controls` is ignored as + a self-reference; it is not interpreted as `is_light`. Marker Save removes + it, and Optimize Plans can remove the directly identifiable `entity:*` case. + The dialog preserves the ordered raw list of genuine external controls, + including duplicates and temporarily unknown targets; runtime consumers + separately de-duplicate and keep only currently controllable entities. +3. otherwise the device's **resolved state role** + (`resolvedDeviceStateEntities`): functional device domains first, then + semantic binary signals, then one representative switch, then passive + readings together. A switch-only device does not aggregate sibling feature + toggles into its working state; this covers integrations which expose power, + modes and options as uncategorised peer switches. If HA metadata identifies + a dedicated Power entity in that composite controller, Power=on is neutral + and Power=off uses the existing faded unavailable style. A lone relay is + unchanged and remains yellow while on. + `primaryEntity` is only the first entity of this same set for actions which + require one target; it no longer defines marker availability by itself. + +For `climate.*`, a recognized real `hvac_action`/equivalent action remains +authoritative: `idle` stays neutral even while the selected mode is `heat`, +while `heating`, `cooling`, `preheating` and `defrosting` are working. Unknown +vendor mode-like values in action attributes are ignored instead of suppressing +the normal enabled-mode fallback. If the integration exposes no recognized +action, the current non-off state is matched against HA's `hvac_modes` (plus the +standard modes) and used as the best available enabled/working approximation. + +The original cover-first rule was added 2026-08-04 on the owner's report: his Aqara «Roller shade +driver E1» curtains ship the `cover.*` hidden by the integration and a visible +`switch.*_reverse_direction`, so `primaryEntity` picked the service switch — +the plan showed no ring while a curtain travelled, no `curtains` / +`curtains-closed` morph, and a yellow «включено» plate whenever the +reverse-direction option happened to be on. Issue #94 moves target selection +from the former `coverEntityOf` branch into the shared action resolver; the +indication still follows exactly the entity the tap would drive. + +**Why the cover is FIRST and not third** (audit DEV-1DA1-01, fixed the same +day). It went in below `controls` and the lit light at first, and that left +the contract below («у штор не должно быть жёлтой подложки НИКОГДА») with two +holes big enough to walk through: a mixed device — a lamp that also ships a +blind — using the former explicit «Открыть/закрыть» action went yellow off its own lit `light.*`, and a +curtain marker with a bound wall switch went yellow off `controls`. In both +the early `return 'on'` never reached the cover branch, so the travelling +curtain also lost its breathing ring, and in glow fill (where the renderer +strips `on` from a shining source) it was left with no indicator at all — +while the tap still drove the cover. A rule that «шторы никогда не жёлтые» +cannot have exceptions decided by the neighbours in the entity list. + +**Why it hangs on the resolved action target and not merely on “the device has +a cover”.** A mixed device may be a lamp with an auxiliary blind. The universal +action resolver first honours explicit controls, then an exact entity binding, +then the device's resolved functional role. Presentation adopts cover semantics +only when that same result selected the cover, so the option, hint, service call +and state shown cannot disagree. A no-target or unsupported result falls through +to ordinary light/device-role presentation and never invents a service target. + +### A media player is powered, not "working" (owner 2026-08-07) + +The resolved role stays `media_player.*` for every TV, receiver, speaker and +soundbar; no model/name exception is involved. Its HA transport states +(`on`, `idle`, `playing`, `paused`, `standby`, etc.) all produce a neutral +marker with no running effect. Explicit `off` deliberately reuses the existing +`.dev.unavail` faded presentation used by `unknown` / `unavailable`; it does +not add another visual status. When a marker resolves several media entities, +it fades only if none is currently available and powered. + +The media role also outranks auxiliary `light.*`/`switch.*` entities belonging +to the same physical device. Status LEDs, display illumination and vendor +options therefore neither paint the media marker nor enter room light +aggregates automatically. Explicit marker `controls` or `is_light` remains the +user override for a real light source. + +### A cover is never painted (owner 2026-08-04) + +«У штор не должно быть жёлтой подложки НИКОГДА, индикация открыто/закрыто за +счёт морфинга иконки.» For the `cover` domain — and for the cover selected by +the universal toggle resolver, rule 1 above — the visual resolver returns +no working/open plate in any state: + +| cover state | plate | pulse | icon | +|---|---|---|---| +| `closed` | neutral | — | closed glyph | +| `open`, ajar (`open` + position) | neutral | — | open glyph | +| `opening`, `closing` | neutral | continuous pulse in Icon + state and activity | open glyph | +| `unknown` / no state | neutral | — | base icon, no morph | +| `unavailable` | neutral, faded (`.unavail`) | — | base icon | + +Until this the domain shared one branch with `valve` and wore `.dev.open` — +an orange FILLED badge (`--hp-open`), not a mere border — while open or +opening. The open/closed story is now told by the icon alone (`stateIcon` / +`COVER_ICONS`), so the morph has to be exhaustive: every device class maps +its two states to two DIFFERENT glyphs, and a cover with no `device_class` at +all (z2m ships plenty) morphs within the family of its own base icon — +`mdi:roller-shade` (what the name rule «штор|curtain|blind|shade» hands out), +`mdi:garage-variant`, `mdi:blinds-horizontal`, `mdi:door`. The one place a +hand-picked icon is not final: a cover whose custom icon IS one of those pair +members morphs inside that pair — never traded for another family — because +otherwise choosing an icon would silently switch the marker's only indicator +off. + +WHAT KEEPS THE FRAME. `.dev.open` is untouched everywhere else: door / window +/ garage_door / opening binary sensors, an unlocked `lock`, and `valve`. A +valve is deliberately left out of the owner's rule — no icon pair morphs for +it, so the frame is the only thing it has to say «открыт» with. If the owner +ever wants the two domains to read alike, a valve needs an icon pair first. diff --git a/docs/FILTERING.md b/docs/FILTERING.md index e2bb902d..3c0e5a50 100644 --- a/docs/FILTERING.md +++ b/docs/FILTERING.md @@ -166,141 +166,9 @@ the old behaviour until an editing client materialises it. - Duplicate names are still numbered, light groups still fold — those are aggregation, not hiding. -## What a marker SHOWS +## What a marker shows -A marker's live indication — status plate, state-morphed icon and semantic -activity — is derived by one resolver from one effective source set. Action -selection is separate but shares `resolveToggleIntent` whenever the effective -action is Toggle state; `_actEntity` is legacy terminology and is not an -independent target resolver. Presentation source precedence is: - -`display: static_icon` is the deliberate presentation exception to the matrix -below. The resolver still retains source metadata for preview diagnostics, but -the rendered marker always uses its base icon on a neutral dark plate: no state -morph, work/open/alarm/unavailable paint, activity, RGB, value or satellite -temperature/humidity/LQI badge. The live vacuum puck, trail and route warning -are also suppressed. This changes presentation only: hover/focus, service-call -feedback, controls, Glow and room light aggregation keep using the real device. -Hidden, removed or HA-disabled lifecycle rules still outrank display mode. - -1. the resolved **cover** when `resolveToggleIntent` selected cover semantics. - This includes current explicit `toggle` and losslessly-read legacy - `tap_action: 'cover'`; the same exact entity drives open/close/stop and icon - morph/activity. It wins over EVERYTHING below; -2. the marker's **resolved light sources** (`resolvedLightSources`): external - `controls` plus its own primary controllable entity when `is_light: true` - (an `entity:*` marker's bound entity and a `device:*` marker's child entities - are excluded from the external list); `is_light: false` suppresses that own - candidate, while missing/null discovers automatic `light.*` only when `light` is the - device's resolved functional role. An auxiliary LED/display light on a - media player or appliance does not turn the whole marker into a lamp. This - exact set feeds Light fill, room light stats, marker feedback and group - toggle. Glow additionally requires a spatial source: an external control - never places a pool at the controller, while a real lamp marker or explicit - `is_light: true` marker does. When both name the same entity, the physical marker - owns its one Glow position regardless of registry order; - - A pre-v1.60 marker that lists its own `switch.*` in `controls` is ignored as - a self-reference; it is not interpreted as `is_light`. Marker Save removes - it, and Optimize Plans can remove the directly identifiable `entity:*` case. - The dialog preserves the ordered raw list of genuine external controls, - including duplicates and temporarily unknown targets; runtime consumers - separately de-duplicate and keep only currently controllable entities. -3. otherwise the device's **resolved state role** - (`resolvedDeviceStateEntities`): functional device domains first, then - semantic binary signals, then one representative switch, then passive - readings together. A switch-only device does not aggregate sibling feature - toggles into its working state; this covers integrations which expose power, - modes and options as uncategorised peer switches. If HA metadata identifies - a dedicated Power entity in that composite controller, Power=on is neutral - and Power=off uses the existing faded unavailable style. A lone relay is - unchanged and remains yellow while on. - `primaryEntity` is only the first entity of this same set for actions which - require one target; it no longer defines marker availability by itself. - -For `climate.*`, a recognized real `hvac_action`/equivalent action remains -authoritative: `idle` stays neutral even while the selected mode is `heat`, -while `heating`, `cooling`, `preheating` and `defrosting` are working. Unknown -vendor mode-like values in action attributes are ignored instead of suppressing -the normal enabled-mode fallback. If the integration exposes no recognized -action, the current non-off state is matched against HA's `hvac_modes` (plus the -standard modes) and used as the best available enabled/working approximation. - -The original cover-first rule was added 2026-08-04 on the owner's report: his Aqara «Roller shade -driver E1» curtains ship the `cover.*` hidden by the integration and a visible -`switch.*_reverse_direction`, so `primaryEntity` picked the service switch — -the plan showed no ring while a curtain travelled, no `curtains` / -`curtains-closed` morph, and a yellow «включено» plate whenever the -reverse-direction option happened to be on. Issue #94 moves target selection -from the former `coverEntityOf` branch into the shared action resolver; the -indication still follows exactly the entity the tap would drive. - -**Why the cover is FIRST and not third** (audit DEV-1DA1-01, fixed the same -day). It went in below `controls` and the lit light at first, and that left -the contract below («у штор не должно быть жёлтой подложки НИКОГДА») with two -holes big enough to walk through: a mixed device — a lamp that also ships a -blind — using the former explicit «Открыть/закрыть» action went yellow off its own lit `light.*`, and a -curtain marker with a bound wall switch went yellow off `controls`. In both -the early `return 'on'` never reached the cover branch, so the travelling -curtain also lost its breathing ring, and in glow fill (where the renderer -strips `on` from a shining source) it was left with no indicator at all — -while the tap still drove the cover. A rule that «шторы никогда не жёлтые» -cannot have exceptions decided by the neighbours in the entity list. - -**Why it hangs on the resolved action target and not merely on “the device has -a cover”.** A mixed device may be a lamp with an auxiliary blind. The universal -action resolver first honours explicit controls, then an exact entity binding, -then the device's resolved functional role. Presentation adopts cover semantics -only when that same result selected the cover, so the option, hint, service call -and state shown cannot disagree. A no-target or unsupported result falls through -to ordinary light/device-role presentation and never invents a service target. - -### A media player is powered, not "working" (owner 2026-08-07) - -The resolved role stays `media_player.*` for every TV, receiver, speaker and -soundbar; no model/name exception is involved. Its HA transport states -(`on`, `idle`, `playing`, `paused`, `standby`, etc.) all produce a neutral -marker with no running effect. Explicit `off` deliberately reuses the existing -`.dev.unavail` faded presentation used by `unknown` / `unavailable`; it does -not add another visual status. When a marker resolves several media entities, -it fades only if none is currently available and powered. - -The media role also outranks auxiliary `light.*`/`switch.*` entities belonging -to the same physical device. Status LEDs, display illumination and vendor -options therefore neither paint the media marker nor enter room light -aggregates automatically. Explicit marker `controls` or `is_light` remains the -user override for a real light source. - -### A cover is never painted (owner 2026-08-04) - -«У штор не должно быть жёлтой подложки НИКОГДА, индикация открыто/закрыто за -счёт морфинга иконки.» For the `cover` domain — and for the cover selected by -the universal toggle resolver, rule 1 above — the visual resolver returns -no working/open plate in any state: - -| cover state | plate | pulse | icon | -|---|---|---|---| -| `closed` | neutral | — | closed glyph | -| `open`, ajar (`open` + position) | neutral | — | open glyph | -| `opening`, `closing` | neutral | continuous pulse in Icon + state and activity | open glyph | -| `unknown` / no state | neutral | — | base icon, no morph | -| `unavailable` | neutral, faded (`.unavail`) | — | base icon | - -Until this the domain shared one branch with `valve` and wore `.dev.open` — -an orange FILLED badge (`--hp-open`), not a mere border — while open or -opening. The open/closed story is now told by the icon alone (`stateIcon` / -`COVER_ICONS`), so the morph has to be exhaustive: every device class maps -its two states to two DIFFERENT glyphs, and a cover with no `device_class` at -all (z2m ships plenty) morphs within the family of its own base icon — -`mdi:roller-shade` (what the name rule «штор|curtain|blind|shade» hands out), -`mdi:garage-variant`, `mdi:blinds-horizontal`, `mdi:door`. The one place a -hand-picked icon is not final: a cover whose custom icon IS one of those pair -members morphs inside that pair — never traded for another family — because -otherwise choosing an icon would silently switch the marker's only indicator -off. - -WHAT KEEPS THE FRAME. `.dev.open` is untouched everywhere else: door / window -/ garage_door / opening binary sensors, an unlocked `lock`, and `valve`. A -valve is deliberately left out of the owner's rule — no icon pair morphs for -it, so the frame is the only thing it has to say «открыт» with. If the owner -ever wants the two domains to read alike, a valve needs an icon pair first. +Presentation — which entity the plate, icon morph and activity follow, and the +owner's rules for covers and media players — is not filtering. Its source of +truth is `DEVICE-PRESENTATION.md`, section «Source precedence: what a marker +shows»; this document only decides whether the device is on the plan at all. diff --git a/docs/ISOMETRIC.md b/docs/ISOMETRIC.md index adcadd6e..64fc43f0 100644 --- a/docs/ISOMETRIC.md +++ b/docs/ISOMETRIC.md @@ -6,6 +6,9 @@ presentation-only volumetric View experiment; the normative Stage 1 contract is `docs/adr/089-isometric-stage1-renderer.md`. Since Stage 6 ([#649](https://github.com/Matysh/houseplan-card/issues/649)) the 2.5D View is a public mode, see [Stage 6](#stage-6-public-mode-tiles-sun-and-materials-649). +This document describes the current View only; how it got here, stage by +stage, is in the ADRs (`docs/adr/089-…`, `122-…`, `160-…`, +`570-isometric-stage4-visual-handoff.md`) and their specs. ## Activation @@ -48,7 +51,7 @@ There is no toggle on the card and no alpha entry: `iso` is gone from - `clientToScenePoint()` maps a client point to the current scene; floor hit testing then uses `unprojectFloorPoint()`. -The current Stage 4 presentation uses a fixed orthographic affine camera: +The presentation uses a fixed orthographic affine camera: ```text rotDeg=0, tiltDeg=20, xyScale=1, zScale=1, origin=[500,500] @@ -92,8 +95,8 @@ Composition remains SVG-first: 3. screen-facing HTML overlays. The floor keeps the same nodes and order for paper/backdrop, room fills/hover, -Glow/spill, sun, decor/furniture, flat stair symbols, opening symbols and vacuum path/outline. Stage -1 does not add a second light source/layer. Markers and room cards intentionally +Glow/spill, sun, decor/furniture, flat stair symbols, opening symbols and vacuum path/outline. The +volumetric View adds no second light source/layer; markers and room cards intentionally remain above walls without geometric occlusion. ## Failure boundary @@ -104,34 +107,24 @@ data. The saved iso preference is retained; an explicit iso request retries the fingerprint, and changed geometry receives a new fingerprint. Flat rendering is the rollback path and does not depend on the iso cache. -## Deliberate Stage 1 limits +## Limits -- door, window and gate keep their current floor-plane symbol and live state; -- no vertical leaf/window panels, sill model or new window light; -- no floor-edge extrusion, shadows, photorealistic materials or marker - occlusion; -- no volumetric editor and no volumetric `houseplan-space-card`; -- no YAML/config option or public settings surface. +- No volumetric editor and no volumetric `houseplan-space-card`: editors and + the static card are always Flat (see Activation). +- No perspective, free rotation or user tilt; no marker occlusion by walls — + screen-facing overlays sit on a low plane above the floor and are shifted, + never hidden. +- No per-opening schema field for heights or leaves: every vertical element is + a fixed presentation ratio of `ISO_WALL_HEIGHT`. +- No YAML/config option beyond `settings.volumetric_view`. Golden references are accepted only from the complete reviewed Linux artifact. The full `large-house-isometric-v1` performance comparison is also canonical on the exact Linux CI SHA. -## Stage 2 composition (#122) +## Structural scene and cache -> Historical: written while 2.5D was a hidden `hp_alpha` experiment. Since #649 -> it is public; activation is described in [Activation](#activation). - -Stage 2 evolves the same hidden `iso` experiment; it does not add a flag, -setting or public activation path. The accepted implementation contract is -`docs/specs/122-isometric-stage2.md` and the fixed composition decisions are in -`docs/adr/122-isometric-stage2-composition.md`. Its historical per-feature -lifetime was superseded by the single indefinite `hp_alpha` gate in #448; the -Stage 2 rendering contract itself is unchanged. - -### One structural scene, live opening leaves - -The per-card LRU remains capped at eight entries. Its Stage 2 value contains: +The per-card LRU is capped at eight entries. A scene contains: - canonical wall top/sides and the physical-wall contact path; - a room/exterior floor footprint and its low visible outer faces; @@ -140,11 +133,14 @@ The per-card LRU remains capped at eight entries. Its Stage 2 value contains: - the shared projected frame, including wall/opening tops and the low floor edge. -The key includes rooms, masonry/opening geometry, flips, scale/camera, wall and -edge heights and algorithm revision. It excludes HA state, theme, hover, -day/night and filter capability. `openingAmount()` is applied only after an LRU -hit by `projectIsoOpening()`, so a contact update projects O(O) leaves without -repeating a wall or floor boolean operation. +The key fingerprints rooms, masonry/opening geometry, flips, scale/camera, wall +and edge heights, the `0°/20°/84` profile, opening policy revision 3 and +structural algorithm 5. It excludes HA state, live opening amount, theme, +hover/selection, day/night, SUN and filter capability. `openingAmount()` is +applied only after an LRU hit by `projectIsoOpening()`, so a contact update +projects O(O) leaves without repeating a wall or floor boolean operation. +Topology, projection or module mismatch enters the fingerprint-latched Flat +fallback (Failure boundary). `floorFootprintGeometry()` deliberately accepts no independent physical-body input. The slab is the union of room floors and derived exterior masonry: @@ -152,11 +148,10 @@ internal room boundaries and nested holes make no decorative step, detached room components keep separate outside edges, while partitions and columns do not enlarge it. -### Layer order and materials +## Layer order and materials All geometry roots use one scene `viewBox`. The existing floor/live nodes are -grouped under the Stage 1 affine matrix; HTML anchors still use -`projectPlanPoint()`. +grouped under the affine matrix; HTML anchors still use `projectPlanPoint()`. ```text stage background @@ -173,52 +168,51 @@ Definition count is constant per card, never per face or opening. Forced colours use solid `Canvas`/`CanvasText` faces and omit decoration. A runtime without the required filter paint keeps solid structure, floor edge and vertical panels but emits no ambient shadow; this does not enter the structural -fallback latch. +fallback latch. The 2.5D View adds no window beam, Glow source, sun renderer, +material config, network request or HA service path. -### Vertical openings and display settings +## Vertical openings and display settings `src/iso-openings.ts` mirrors the existing opening-symbol transform algebra: door has one jamb-hinged leaf, gate has two leaves with the established 0–10° exterior-face turn, and window has two light neutral casements. A saved `passage` keeps the same full-height masonry cut but has zero leaves/panels. -Heights are fixed presentation ratios of -`ISO_WALL_HEIGHT`; there is no schema field. -The saved opening axis and Flat symbol remain on their canonical centreline. -Derived 2.5D door/gate leaves pivot on the selected physical host face so their -prisms do not start inside masonry; windows remain centred across the reveal. -`flip_v` selects/determines the physical face and opening direction without -changing saved coordinates. Jamb/cut depth remains physical and independent of -the Flat symbol. Panels are pointer- and ARIA-inert. Existing lock badges/cards -and HA actions remain the only interactive opening surface. +Heights are fixed presentation ratios of `ISO_WALL_HEIGHT`; there is no schema +field. The saved opening axis and Flat symbol remain on their canonical +centreline. Derived 2.5D door/gate leaves pivot on the selected physical host +face so their prisms do not start inside masonry; windows remain centred across +the reveal. `flip_v` selects/determines the physical face and opening direction +without changing saved coordinates. Jamb/cut depth remains physical and +independent of the Flat symbol. Panels are pointer- and ARIA-inert. Existing +lock badges/cards and HA actions remain the only interactive opening surface. + +Door leaves turn by `50° × openingAmount`, paired window leaves by `65° × +openingAmount`, and gates retain their established 0–10° behaviour. The same +canonical flips/host face that drive the floor symbol determine hinges and +direction; missing, unknown or unavailable state keeps the existing static-plan +fallback. + +For wall height `H`, the fixed window frame spans `0.38H..1.00H`, its sash +`0.40H..0.98H`, and clear glass `0.45H..0.93H`; frame/sash rails are `0.05H`. +Frames and sill are neutral, glass side is `#c9e4f3` and its top face is +`#e3f2fa`. Fixed and live faces remain in `buildIsoWallDepthQueue()`. The slots +belonging to one opening are resolved by physical camera depth, so raised glass +covers the rear sill and rotating door/gate prism faces retain their physical +order without reordering unrelated walls or openings. Door/gate faces use fill +differences instead of strokes; window frame/glass borders remain. - borders visible: vertical panels replace the floor-plane symbols; -- `hide_openings: true`: panels disappear, while masonry - cuts, Glow/sun and contact/lock meaning remain; -- `show_borders: false`: Stage 2 roots are absent and the established floor - symbols and Stage 1 projected frame return (subject to `hide_openings`), - avoiding floating panels or an invisible Stage 2 bound that reframes them; +- `hide_openings: true`: panels disappear, while masonry cuts, Glow/sun and + contact/lock meaning remain; +- `show_borders: false` is the exact no-volume branch: the volumetric roots are + absent, the floor keeps the real 0°/20° affine matrix, the floor symbols + and the projected frame return (subject to `hide_openings`) and interactive + overlays return to their floor anchors; - Flat, editors and `houseplan-space-card` retain their old symbols and DOM. -Stage 2 adds no window beam, Glow source, sun renderer, material config, -network request or HA service path. Structural topology/projection exceptions -still use the Stage 1 latched Flat fallback. The known independent exact-SHA -view-toggle performance debt remains tracked in #124; #122 neither weakens its -budget nor treats fallback as benchmark success. +## Camera and overlay placement -## Stage 4 visual handoff (#570) - -> Historical: written while 2.5D was a hidden `hp_alpha` experiment. Since #649 -> it is public; activation is described in [Activation](#activation). - -Stage 4 refines the same hidden `iso` presentation behind `hp_alpha`; it adds no -public switch, configuration field or experiment id. Stage 3 history remains in -`docs/specs/160-isometric-stage3.md` and -`docs/adr/160-isometric-stage3-overlays.md`; the current acceptance contract is -the reviewed specification in issue #570 plus its revision-3 designer handoff. - -### Camera and low screen-facing overlays - -The camera is orthographic `rotDeg=0`, `tiltDeg=20`, with the same `[500,500]` +The camera is orthographic `rotDeg=0`, `tiltDeg=20`, with the `[500,500]` pivot and scale-aware 84-unit wall height. Floor SVG, wall/opening projection, inverse hit mapping, invisible collision footprints and fit bounds share that one affine authority. @@ -245,15 +239,13 @@ disk. The correction is runtime-only and is never written to configuration. If no completely legal common vector exists, the nearest deterministic result keeps the cluster rigid and prioritises room ownership, then wall clearance, then overlap with an earlier cluster. It never splits or shrinks a cluster; -residual overlap is an explicit degraded diagnostic. This supersedes the old -single-marker fallback while retaining the 48 px cap. The two full isometric +residual overlap is an explicit degraded diagnostic. The two full isometric profiles keep the ordinary 150/60/75 ms resize/pan/state noise allowances -(#585, #651). -Fit probes reserve the maximum correction but do not execute live collision -search. There is no painted plate, long tether, ground dot or per-marker -shadow. The original screen-facing HTML root remains the only hit, focus, -tooltip and action target, and selection/hover cannot invalidate the placement -cache. Vacuum, Glow/spill, SUN, room fills/hover, arbitrary decor, +(#585, #651). Fit probes reserve the maximum correction but do not execute +live collision search. There is no painted plate, long tether, ground dot or +per-marker shadow. The original screen-facing HTML root remains the only hit, +focus, tooltip and action target, and selection/hover cannot invalidate the +placement cache. Vacuum, Glow/spill, SUN, room fills/hover, arbitrary decor, furniture/backdrop, stairs and every persisted coordinate remain on `z=0`. Room names remain screen-facing and lose stroke, text shadow, drop shadow and @@ -261,40 +253,6 @@ halo. Iso uses `#303936` on a light presentation and `#f2f0e8` on a dark one; contrast comes from colour and the bounded position correction, never an outline. -### Openings, occlusion and materials - -Door leaves turn by `50° × openingAmount`, paired window leaves by `65° × -openingAmount`, and gates retain their established 0–10° behaviour. The same -canonical flips/host face that drive the floor symbol determine hinges and -direction; missing, unknown or unavailable state keeps the existing static-plan -fallback. Opening geometry stays inert and lock actions stay on the existing -guarded lock badge/card. - -For wall height `H`, the fixed window frame spans `0.38H..1.00H`, its sash -`0.40H..0.98H`, and clear glass `0.45H..0.93H`; frame/sash rails are `0.05H`. -Frames and sill are neutral, glass side is `#c9e4f3` and its top face is -`#e3f2fa`. Fixed and live faces remain in `buildIsoWallDepthQueue()`. The slots -belonging to one opening are resolved by physical camera depth, so raised glass -covers the rear sill and rotating door/gate prism faces retain their physical -order without reordering unrelated walls or openings. Door/gate faces use -fill differences instead of strokes; window frame/glass borders remain. - -The constant material/filter set remains theme-aware and bounded. Only the -building ambient shadow remains; wall-contact and leaf shadows are omitted. -Forced colours or missing filter support remove texture and the ambient shadow, not geometry, -ownership or actions. `show_borders:false` is the exact no-volume branch: the -floor keeps the real 0°/20° affine matrix and interactive overlays return to -their floor anchors. `hide_openings` removes vertical decoration but preserves -cuts, Glow/SUN and lock semantics. - -The eight-entry structural LRU fingerprints the 0°/20°/84 profile, opening -policy revision 3 and structural algorithm 5. It excludes HA state, live -opening amount, hover/selection, theme, SUN and filter capability. The lazy -`iso-scene-render` graph is still not requested with alpha off; topology, -projection or module mismatch still enters the established fingerprint-latched -Flat fallback. Linux exact-SHA goldens and performance profiles remain the -canonical release evidence. - ## Stage 6: public mode, tiles, sun and materials (#649) The visual language and the numbers come from the designer lab (sketch 07, diff --git a/docs/LIGHT.md b/docs/LIGHT.md index 67bcd448..2c59a0b8 100644 --- a/docs/LIGHT.md +++ b/docs/LIGHT.md @@ -214,8 +214,121 @@ is retained and visibly warned about; runtime uses the fallback until it returns. Capability comes from binding/registry metadata, never from a transient `unknown`, `unavailable` or missing state snapshot. -The complete UI and runtime truth table lives in -[`DEVICE-LIGHT-SETTINGS-MATRIX.ru.md`](DEVICE-LIGHT-SETTINGS-MATRIX.ru.md). +The complete UI and runtime truth table is the next section. + +## Device light settings: role × source × mode (#84, #88) + +Legend: **A** — «Auto» found the device's own spatial source; **S** — the +device has its own controllable `light.*`/`switch.*` able to report state and +take a service call; **Source** — the device takes part in Glow, the «Light» +fill, the room card and room statistics; **Live** — the «From source» colour +mode is available; **Manual** — manual colour and manual brightness are +available; **R** — the local Glow radius is available. `Auto → fixed` — the +stored mode is not rewritten, but UI and runtime give a passive source the safe +fallback: the shared colour at 100 % brightness. + +The combination `A = yes, S = no` is listed for completeness of the contract +and for mutation tests; the resolver cannot reach it (an automatically found +source always has a real `light.*`). The other 27 rows are reachable. + +| # | Role | A | S | Stored mode | Source | Passive | Live | Manual | R | Effective mode | +|---:|---|:---:|:---:|---|:---:|:---:|:---:|:---:|:---:|---| +| 1 | Auto | no | no | From source | no | no | no | no | no | From source, disabled | +| 2 | Auto | no | no | Set colour | no | no | no | no | no | Set colour, disabled | +| 3 | Auto | no | no | Colour and brightness | no | no | no | no | no | Colour and brightness, disabled | +| 4 | Auto | no | yes | From source | no | no | no | no | no | From source, disabled | +| 5 | Auto | no | yes | Set colour | no | no | no | no | no | Set colour, disabled | +| 6 | Auto | no | yes | Colour and brightness | no | no | no | no | no | Colour and brightness, disabled | +| 7 | Auto | yes | no | From source | yes | yes | no | yes | yes | Auto → fixed (theoretical) | +| 8 | Auto | yes | no | Set colour | yes | yes | no | yes | yes | Set colour (theoretical) | +| 9 | Auto | yes | no | Colour and brightness | yes | yes | no | yes | yes | Colour and brightness (theoretical) | +| 10 | Auto | yes | yes | From source | yes | no | yes | yes | yes | From source | +| 11 | Auto | yes | yes | Set colour | yes | no | yes | yes | yes | Set colour | +| 12 | Auto | yes | yes | Colour and brightness | yes | no | yes | yes | yes | Colour and brightness | +| 13 | Always | no | no | From source | yes | yes | no | yes | yes | Auto → fixed | +| 14 | Always | no | no | Set colour | yes | yes | no | yes | yes | Set colour | +| 15 | Always | no | no | Colour and brightness | yes | yes | no | yes | yes | Colour and brightness | +| 16 | Always | no | yes | From source | yes | no | yes | yes | yes | From source | +| 17 | Always | no | yes | Set colour | yes | no | yes | yes | yes | Set colour | +| 18 | Always | no | yes | Colour and brightness | yes | no | yes | yes | yes | Colour and brightness | +| 19 | Always | yes | no | From source | yes | yes | no | yes | yes | Auto → fixed (theoretical) | +| 20 | Always | yes | no | Set colour | yes | yes | no | yes | yes | Set colour (theoretical) | +| 21 | Always | yes | no | Colour and brightness | yes | yes | no | yes | yes | Colour and brightness (theoretical) | +| 22 | Always | yes | yes | From source | yes | no | yes | yes | yes | From source | +| 23 | Always | yes | yes | Set colour | yes | no | yes | yes | yes | Set colour | +| 24 | Always | yes | yes | Colour and brightness | yes | no | yes | yes | yes | Colour and brightness | +| 25 | Never | no | no | From source | no | no | no | no | no | From source, disabled | +| 26 | Never | no | no | Set colour | no | no | no | no | no | Set colour, disabled | +| 27 | Never | no | no | Colour and brightness | no | no | no | no | no | Colour and brightness, disabled | +| 28 | Never | no | yes | From source | no | no | no | no | no | From source, disabled | +| 29 | Never | no | yes | Set colour | no | no | no | no | no | Set colour, disabled | +| 30 | Never | no | yes | Colour and brightness | no | no | no | no | no | Colour and brightness, disabled | +| 31 | Never | yes | no | From source | no | no | no | no | no | From source, disabled (theoretical) | +| 32 | Never | yes | no | Set colour | no | no | no | no | no | Set colour, disabled (theoretical) | +| 33 | Never | yes | no | Colour and brightness | no | no | no | no | no | Colour and brightness, disabled (theoretical) | +| 34 | Never | yes | yes | From source | no | no | no | no | no | From source, disabled | +| 35 | Never | yes | yes | Set colour | no | no | no | no | no | Set colour, disabled | +| 36 | Never | yes | yes | Colour and brightness | no | no | no | no | no | Colour and brightness, disabled | + +A manual colour on a stateful source keeps the live brightness; «Colour and +brightness» fixes both. A passive source has no live brightness, so «Set +colour» means 100 % and «Colour and brightness» uses the stored value. + +### Leading entity (#88) + +| Role | Own controllable entities | Stored `light_entity` | UI and runtime | +|---|---:|---|---| +| Auto / Never | any number | any value | Selector hidden; the field is kept unchanged but does not affect the current role | +| Always | 0 | absent | Passive source, selector hidden | +| Always | 1 | absent | The only entity is chosen automatically, selector hidden | +| Always | 2+ | absent | Selector shown; fallback `binding → primary → first controllable` | +| Always | 1+ | valid value | The chosen entity provides state and the service target | +| Always | any number | entity disappeared | Warning; temporary fallback, the stored reference is not erased | + +The choice does not depend on the current `on/off/unavailable`: capability +comes from the binding and the HA registry, live state is handled separately. + +### «Controls other light sources» links (#84) + +| Target in `controls` | Target state | Controller's service call | Glow and statistics | +|---|---|---|---| +| `light.*` / `switch.*` | The entity's actual state; a group — `any(on)` | Real entity ids only | A real separate marker owns the position; otherwise the target takes part without a separate pool at the controller | +| `marker:` stateful | The target's leading entity state | The target's leading entity, deduplicated | Position, room, colour and radius belong to the marker target | +| `marker:` passive, one controller | Its real targets, otherwise its own leading entity | `marker:*` itself is never sent to HA | The passive lamp follows the controller and shines at its own position | +| Passive, several controllers | OR of all active driver entities | Per action — only its real targets | One source and one room vote, no duplicates | +| Exact `virtual` + «Always» + Toggle, links present | OR of all active driver entities; the manual bit temporarily abstains | Controller click — its group; lamp click — deduplicated union of all its drivers | HA state alone drives Glow, fill, statistics and both markers | +| Exact `virtual` + «Always» + Toggle, no links | Operational Store #107; no record = `on` | Lamp click changes only the operational state, no HA service | Manual state is shared by full/static cards and survives a restart | +| Passive without stored links, not the exact mode of #107 | Always `on` | No own call | Constant Glow and `1 of 1` | +| Links present, but every driver is hidden/disabled/removed | `off` / dormant | No call on a broken target | The link is kept, no pool | +| Direct entity + `marker:` on the same stateful source | One effective state | One service target | One source and one vote | +| Target moved from «Always» to «Auto without source»/«Never» | Dormant | No marker service | The link is kept and revives on return to «Always» | +| Target hidden or HA-disabled | Dormant | No marker service | Not drawn, no effect on the room | +| Target removed from the plan | The reference is removed atomically | No | The device can be added again | +| Broken legacy ref | Ignored with a diagnostic | No | Open → Save does not destroy the reference | +| Self-link / new cycle | Forbidden | — | The UI does not offer it, the backend rejects the write/import | + +Links are allowed across rooms and spaces of one plan. Exporting a single +space remaps internal `marker:` references together with marker ids and drops +external ones with a warning in the preview. + +### Independent display switches + +| Setting | Effect on the light model | +|---|---| +| «Icon + state» | The device shell shows its resolved working state; Glow follows the matrix above | +| «Icon + state and activity» | Additionally a short or constant ripple; the light source does not change | +| «Value + state» | Changes only the marker content; the light source does not change | +| «Always static icon» | Blocks the dynamics of the icon/shell itself but does not cancel separately configured Glow, «Light» fill and room statistics | +| Glow off for the space | Pools are not drawn, but the source, the room state and the «Light» fill are still computed | +| Marker hidden / HA-disabled | The marker and its own source take no part in the plan regardless of other settings | + +What holds this section: all 36 rows of the first table — +`test/devices.test.mjs`, «issues 84/88: exhaustive 36-case light settings +matrix is internally consistent»; the leading-entity choice, stale fallback, +passive OR, dormant links, alias dedupe, lifecycle and the ban on sending +`marker:*` to HA — the neighbouring unit tests of the same file; the context +selector, passive gating and plan-source picker — browser smokes; new links, +cycles and cross-space transfer — `tests_backend/`. ## What the tests hold diff --git a/docs/LIVE-TEXT.md b/docs/LIVE-TEXT.md deleted file mode 100644 index b5d76d06..00000000 --- a/docs/LIVE-TEXT.md +++ /dev/null @@ -1,192 +0,0 @@ -# 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. diff --git a/docs/PDF-EXPORT.md b/docs/PDF-EXPORT.md index 19bf5f20..760e929c 100644 --- a/docs/PDF-EXPORT.md +++ b/docs/PDF-EXPORT.md @@ -59,10 +59,8 @@ in consistent lanes clear of the wall body. Units follow Home Assistant. Very short internal edges use a tick instead of unreadable text. A value that has no room beside its own wall is not printed at all: it is never pushed through a wall, a room name or an area, and it is not moved to a separate list beside the -plan. Until v1.74.0-beta.2 such values went into a numbered "Internal -dimensions" column; that column cost the drawing a whole step of the scale -series and turned the sheet sideways, so it was removed and the drawing grew by -about a third instead. +plan: a separate list would cost the drawing a whole step of the scale series +and turn the sheet sideways. For a rectangular step in an exterior facade, the chain retains enough horizontal and vertical values to reconstruct the outline: both neighbouring diff --git a/docs/STATUS.md b/docs/STATUS.md index 070b6c30..fb2226f3 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -51,7 +51,7 @@ same commit as the change it describes. | Tests | Four layers: Node unit (`npm test`: frontend pure modules + tooling policy), pure backend (`pytest tests_backend`, runs anywhere), HA-harness backend (same folder, CI only — uses repository-pinned Python plus pytest-homeassistant-custom-component), and browser smokes (`demo/smoke_*.mjs`, headless chromium). **Counts and runtime pins are not duplicated here** — they drift faster than release prose; run `npm run inventory` for current counts and `npm run toolchain:check` for the executable pins, or read them from the exact CI run | | Input support | Owner's rule since 2026-08-08: View and kiosk are fully supported and release-blocking on touch. All three editors are desktop-first; touch editing is best effort and may be awkward, reduced or absent when parity is expensive. `docs/TOUCH-SUPPORT.md` defines the non-negotiable safety floor and documentation/test rules | | Vacuums | Live puck, server-side trails and fit calibration are shipped. The local v1.61 Stage 1 contract in docs/VACUUM.md adds explicit Dreame/XCME/Valetudo coverage, registry-less source selection, capability diagnostics, path-gap preservation and source-health warnings; #205 resumes one ended same-map run through an inclusive 30-minute station/pause grace. #209 renders current and previous trails through the same bounded 17.5 cm rounded-corner curve without changing stored points or gaps. Roomba remains Stage 2 | -| Demo stand | **https://demo.houseplan.tech** — public, login `demo`/`demo`, resets to a pristine synthetic home every hour. **https://dev.houseplan.tech** — closed (basic auth), auto-deploys the `dev` branch every 10 min. Since 2026-07-31 the stand covers most of the manual checklist: a scripted robot vacuum (`demo/stand/demo_robot` — Tasshack-shaped map sensor, serpentine run, pre-solved calibration, seeded server trail), Zigbee-style LQI template sensors, hand/auto-triggered leak+smoke alarms, an hvac_action climate marker and working script/scene/automation targets for tap-run. The stand-specific how-to-check guide is **docs/TESTING-DEMO.md** | +| Demo stand | **https://demo.houseplan.tech** — public, login `demo`/`demo`, resets to a pristine synthetic home every hour. **https://dev.houseplan.tech** — closed (basic auth), auto-deploys the `dev` branch every 10 min. Since 2026-07-31 the stand covers most of the manual checklist: a scripted robot vacuum (`demo/stand/demo_robot` — Tasshack-shaped map sensor, serpentine run, pre-solved calibration, seeded server trail), Zigbee-style LQI template sensors, hand/auto-triggered leak+smoke alarms, an hvac_action climate marker and working script/scene/automation targets for tap-run. The demo home and what the stand cannot show: `demo/stand/README.md` | | Community | **Telegram chat: https://t.me/ha_houseplan** (created 2026-07-27) — the primary user-facing support channel; GitHub issues stay for bugs/features. Link it from any new release notes and posts | | Product scope | `docs/SCOPE.md` is the feature guard rail; `docs/TOUCH-SUPPORT.md` is the input-support contract — check both before accepting interaction work | diff --git a/docs/STYLING-HOOKS.md b/docs/STYLING-HOOKS.md index b3dbdcc5..7092c2cd 100644 --- a/docs/STYLING-HOOKS.md +++ b/docs/STYLING-HOOKS.md @@ -1,6 +1,6 @@ # Styling hooks — the selectors card-mod may rely on -Status: **implemented (v1.59.0-beta.3).** Code: `src/houseplan-card.ts` +Code: `src/houseplan-card.ts` (`_renderDevice`, `_renderRoomLabel`, `_renderOpenings`, `_renderDecorLayer`, the room shapes in `render()`, the space tabs in the header), `src/space-render.ts` (the static `houseplan-space-card`). @@ -310,7 +310,7 @@ translations (`on` → *Включено*). One wrapper owns it — `hassValue() | Where | What it prints | | --- | --- | | Value badge (`display: value`) | the acting entity's state | -| Decor live text (docs/LIVE-TEXT.md) | the linked entity's state or attribute | +| Decor live text (docs/DECOR-EDITOR.md §5) | the linked entity's state or attribute | | Device info card | the primary state and every listed entity | **Fallbacks are silent.** An older Home Assistant without diff --git a/docs/SUN.md b/docs/SUN.md index 44a20b18..adc70f07 100644 --- a/docs/SUN.md +++ b/docs/SUN.md @@ -102,6 +102,20 @@ The public setting remains a two-value selector. `static` uses `bg_color`. static. - The background is independent of `north_deg`. North remains required only for direction-dependent window rays. +- **The scene background never bleeds through the plan** (owner, 2026-08-03). + In both modes the background — `bg_color` or the day-cycle environment — is + visible only AROUND the plan: opaque `.hp-paper` shapes sit under everything + the plan draws. The paper is the ROOM CONTOURS in every case — one shape per + room in exactly the room's own geometry (fill only, no stroke), never their + bounding box, so the background reaches the exterior walls of an L-shaped + house and fills the gaps between detached buildings; an empty drawn space + has no paper, and a plan image does not paper its own rectangle (it is drawn + on top of the room paper, `DECOR-EDITOR.md` §3.3). Open (virtual) + boundaries do not affect the paper; a live resize preview moves it together + with the rooms. Its colour is the pre-`bg_color` canvas — white for + hand-drawn plans, the theme card background under an image — and its alpha + never changes. Applies to view/kiosk/editors and the static space card alike + (`demo/smoke_bg_color.mjs`). New installations materialize global `daynight`; every manually or Floors/Areas-created space materializes its own `daynight`. The storage v1.2 @@ -115,81 +129,6 @@ carries its effective mode and a legacy space import without one becomes The exact palette, positioning formulas, migration matrix, lifecycle, and acceptance criteria are in `docs/specs/146-four-phase-sun-background.md`. -## Historical continuous background (removed by #146) - -The remainder of this section documents the pre-#146 elevation-interpolated -implementation for archaeology only. It is not a current runtime or UX -contract: the 45-second sky glide, `skysnap`, compass gate, and night -brightness filter described below were removed. - -- Global default in the general settings, per-space override (null = - inherit). Default `'static'`. -- `'static'` — the existing `bg_color` behaviour, color picker and - all. Nothing changes for existing installs. -- `'daynight'` — the stage background follows the sun's elevation: - WHITE at full day (the brightest moment of the day is white — owner, - 2026-08-03), a warm bright shift in the golden hour (elevation below - ~10°), cooling through dusk, deep darkening at night. The scale - (piecewise-linear between stops, `BG_STOPS` in `src/sun.ts`): - - | elevation | color | phase | - | --- | --- | --- | - | −90°…−12° | `#070c14` | deep night | - | −4° | `#131a28` | dusk cools down | - | 0° | `#4a3527` | warm band right at the horizon | - | +10° | `#e8ddcf` | morning light — warm and bright | - | +30°…+90° | `#ffffff` | plain day, white | - The PLAN - itself dims only ~10% at night (`filter: brightness(.9)`), so the - daytime room fills stay readable. Transitions are a CSS - background/filter transition tens of seconds long; - `prefers-reduced-motion` gets the current colors statically. -- **Glide, but never lag behind reality** (owner 2026-08-04: «цвет фона - не меняется сам с течением времени суток, только после - обновления страницы»). The sky colour and the plan dimming are - delivered by a 45 s CSS transition, and a CSS transition only advances - while the card is being PAINTED. A card that was not painting — a - background tab, another dashboard view, a sleeping wall tablet, an - editor session — comes back holding a stale sky and then crawls toward - the truth 45 s at a time; a page reload, by contrast, paints the right - colour outright, because a freshly mounted element has nothing to - transition FROM. So the card measures the gap: HA refreshes `sun.sun` - every ~4 minutes by day, i.e. ≤1° per update, and anything from - `SKY_SNAP_DEG` = 3° up therefore means "we were not watching". Such a - step is applied with `transition: none` for a single frame - (`.stage.daynight.skysnap`, released on the next - `requestAnimationFrame`); everything smaller keeps the 45 s breathing. - Returning after a genuinely long browser suspension arms the catch-up - outright. A quick tab switch or a short browser minimise preserves the - already painted sky and hover, so it cannot introduce a one-frame flash. -- The elevation the sky is computed from is rounded to 0.1° - (`skyElevation()`) — finer than the eye can tell across a 45 s glide, - and it keeps `dayPhase` (and the style attribute lit has to commit) - from churning on every `hass` tick. The wedge GEOMETRY keeps its own, - coarser memo: the two have deliberately different granularity — the - sky is cheap, the polygon clipping is not. -- The UI is a two-option selector; the color picker shows only for - `'static'`. -- Backend validation: `In(['static', 'daynight'])` at both levels. -- `'daynight'` follows the general gate: without `north_deg` (or - without `sun.sun`) it behaves as `'static'`. -- **The scene background never bleeds through the plan** (owner, - 2026-08-03). In BOTH modes the background — `bg_color` or the - daynight sky — is visible only AROUND the plan: opaque `.hp-paper` - shapes sit under everything the plan draws. An image plan papers the - backdrop image rect (the canvas IS the paper); a hand-drawn plan - papers the ROOM CONTOURS — one shape per room in exactly the room's - own geometry (fill only, no stroke), never their bounding box, so - the background reaches the exterior walls of an L-shaped house and - fills the gaps between detached buildings (an empty drawn space has - no paper). Open (virtual) boundaries do not affect the paper; a live - resize preview moves it together with the rooms. Its colour is the - pre-bg_color canvas — white for hand-drawn plans, the theme card - background under an image. The night dimming above is the - `brightness` filter on the zoomwrap ONLY; the paper's alpha never - changes. Applies to view/kiosk/editors and the static space-card - alike (smoke_bg_color). - ## Window light wedges — `settings.sun_rays` Boolean, global + per-space (null = inherit), default OFF. diff --git a/docs/TESTING-DEMO.md b/docs/TESTING-DEMO.md deleted file mode 100644 index 02c7e967..00000000 --- a/docs/TESTING-DEMO.md +++ /dev/null @@ -1,469 +0,0 @@ -# Чек-лист ручного тестирования на публичном демо-стенде - -> **Стенд:** https://demo.houseplan.tech — вход **demo / demo** (админ). -> Dev-версия: https://dev.houseplan.tech — закрытый стенд (basic auth, доступ у владельца). -> -> **Стенд сбрасывается каждый час** к эталонному состоянию — это фича: ломайте -> смело, удаляйте комнаты, заливайте планы, через час всё вернётся (обратный -> отсчёт до сброса печатается в консоли DevTools). Из этого же следует: -> долгоживущие проверки («файл исчез через сутки», «пережило рестарт») на стенде -> не живут. Рестарт HA изнутри заблокирован demo_guard. -> -> Источник истины — [TESTING.md](TESTING.md) и его приложения -> [`testing-notes/`](testing-notes/README.md) (ручной чек-лист по поверхностям -> перенесён туда, #634): формулировки пунктов и их `[auto:…]`-маркеры смотреть там. Здесь каждый пункт сокращён до сути и дополнен -> строкой «Стенд: …» — где и как ткнуть именно на демо-стенде. -> -> **Демо-дом v2** (анонимированная планировка реального загородного дома): -> дашборд «House plan» (`/house-plan/0`, виды: План / Киоск / Схема), три -> пространства: «Ground Floor» (Kitchen & Living, Hallway, Guest Bedroom, -> Guest WC, Boiler Room, Sauna, Under-Stairs Closet, Outdoor Storage — нарисован -> от руки, толстые стены со штриховкой), «First Floor» (Kids Room A, Kids Room B, -> Upstairs Hall, Kids Bathroom, Master Bathroom, Bedroom, Study — с -> картинкой-подложкой) и «Yard» (комната Yard + декор-контур гаража). Всего -> 97 маркеров: ~51 на Ground Floor, 23 на First Floor, 2 во дворе. Ключевые -> демо-устройства: робот-пылесос `vacuum.demo_robot` (база в Under-Stairs -> Closet), датчики протечки (включаются помощником `input_boolean.demo_leak`), -> датчики дыма (сами срабатывают каждые 10 минут), котёл и бак воды в Boiler -> Room (маркеры-значения + живые текст-метки), замки Front Door и Terrace, -> шторы в Study, ворота гаража во «Yard», TV + колонки, вытяжка на кухне, -> кондиционер в Bedroom, составная розетка Smart Plug, вечно-unavailable -> Pantry Light, погода `weather.demo_weather_south` (солнце в окнах + день/ночь). -> Всё на английском; язык пользователя demo = Auto — интерфейс следует языку -> браузера. - -## Деактивированные устройства HA - -Публичный demo-пользователь — админ, поэтому на стенде проверяется полный -registry-сценарий. Для limited/read-only нужен локальный harness или отдельный -непривилегированный пользователь. - -- Готовый пример в доме: **Smart Plug 2** деактивирована в реестре — она - зарезервирована под будущую фичу disabled-devices; сейчас рендерится по - текущему поведению. -- Сохранённый marker: деактивировать устройство или его единственную сущность - в настройках HA — оно исчезает из View и всех данных плана без изменения - конфига. -- Редактор устройств → «Скрытые и деактивированные»: виден серый служебный - ghost с причиной. «Показать» объясняет, что сначала нужна активация в HA; - «Открыть в HA», редактирование метаданных и удаление доступны. -- Активировать тот же объект: marker возвращается на старое место со старыми - настройками. Если до деактивации он был скрыт пользователем, сам не появляется. -- Для пылесоса вместе с marker исчезают puck и клиентские/полученные следы; - существующая серверная история не удаляется и возвращается после активации. -- Локальный standalone demo: `__setRegistryDisabled('device','d_mower','user')` - / `__setRegistryDisabled('device','d_mower',null)` переключают статус; - `__setRegistryAccess('limited')` включает отказ registry WS для проверки - консервативной деградации. - -## Вводные пункты (v1.43.x–v1.44.x) - -- Smoke-harness сам по себе — Стенд: не проверяется (это CI), только локально. -- Шестерёнка «⚙ Room» на каждой карточке комнаты в редакторе плана — Стенд: открыть «Редактор плана» на «Ground Floor», убедиться что у всех 8 комнат есть кнопка ⚙ фиксированного размера, открывает настройки комнаты. -- Метрики 0.75 от имени комнаты — Стенд: включить метрики в настройках пространства (⚙ у «Ground Floor») и посмотреть подпись под именем комнаты. -- Тултип не появляется после touch — Стенд: открыть с планшета/телефона, потыкать комнаты и иконки — hover-тултипов быть не должно. -- Роль источника света у выключателя — Стенд: у настенного выключателя или у розетки «Smart Plug» в диалоге устройства выбрать «Всегда» — при включении устройство светится в Glow; «Никогда» убирает собственное пятно, «Авто» возвращает классификацию устройства. -- Карточка устройства: управляемые сущности сверху, ≥30 px, замки не переключаются — Стенд: тапнуть TV или колонку; для замка — открыть карточку двери (бейдж замка на входной двери Front Door). -- Инвариант замков на всех путях — Стенд: Front Door: Unlock просит подтверждение, Lock — нет; иконка замка никогда не тогглится тапом. -- Транзакционная миграция вложений — Стенд: прикрепить PDF к устройству, перепривязать маркер на другую сущность — файл открывается после перепривязки. -- Планы и PDF в реальном браузере — Стенд: залить план в настройках пространства (любой PNG/SVG), DevTools → Network: `/api/houseplan/content/...` = 200 с `authSig`, тот же URL без authSig = 401. -- Единая политика прав (may_write) — Стенд: не проверяется напрямую (нужен не-админ; на стенде один пользователь demo-админ) — покрыто tests_backend. -- NaN/Infinity и лимиты openings — Стенд: не проверяется руками (нужен ручной WS-вызов) — tests_backend. -- Захват указателя при drag, декор не утаскивается за границы — Стенд: «Редактор фона», нарисовать фигуру и попытаться утащить её далеко за план. -- Скрытые термометры питают карточку комнаты — Стенд: скрыть любой датчик температуры (кнопка «Скрыть» в левом нижнем углу диалога, затем «Сохранить») — температура в карточке его комнаты остаётся. -- Тултип комнаты без «открыть area» — Стенд: навести на комнату с десктопа: имя + температура/сигнал, никакого «открыть». - -## Матрица окружений ★ - -- Chrome/Edge desktop — Стенд: да, просто открыть. -- Firefox desktop — Стенд: да, со своей машины. -- Safari (macOS) — Стенд: да, при наличии Mac. -- HA Companion Android (холодный старт!) — Стенд: да: добавить `https://demo.houseplan.tech` как сервер в приложении (demo/demo), убить приложение, открыть заново на дашборде House plan. -- HA Companion iOS — Стенд: аналогично Android. -- Планшет-киоск — Стенд: частично: открыть вид «Киоск» браузером планшета во весь экран; реальный настенный планшет — только на железе. -- Телефон портрет ≤400 px — Стенд: да, или DevTools device mode. -- Тёмная/светлая тема — Стенд: профиль demo → тема; проверить бейджи/диалоги/контраст. -- RU/EN локали — Стенд: язык пользователя demo = Auto (следует за браузером): сменить язык браузера или выставить язык явно в профиле; опцию `language:` карточки можно проверить, отредактировав вид дашборда. - -## Установка / обновление / удаление - -- Свежая установка через HACS — Стенд: не проверяется (интеграция ставится файлами, HACS нет) — локально. -- `single_config_entry` — Стенд: Настройки → Устройства и службы → Добавить интеграцию → «House Plan» недоступна второй раз. -- Обновление через HACS (`?v=` после рестарта) — Стенд: не проверяется (нет HACS; версию ресурса видно в DevTools: `houseplan-card.js?v=1.58.0`). -- YAML-mode Lovelace / extra_module_url — Стенд: частично: стенд как раз грузит карточку через `extra_module_url` (см. configuration.yaml) — если дашборд открылся, механизм жив. -- Удаление entry → ресурс исчезает, `.storage/houseplan.*` живёт — Стенд: можно (Настройки → Интеграции → House Plan → удалить), стенд сам восстановится при сбросе; переустановку файлами проверить нельзя. -- Diagnostics download + REDACTED — Стенд: Настройки → Интеграции → House Plan → «Загрузить диагностику», убедиться что name/link/description/pdfs = `**REDACTED**`. - -## Режимы ★ - -- Всегда открывается во View — Стенд: войти в редактор, перезагрузить страницу — карточка снова во View. -- View: только pan/zoom/tap/long-press, иконки не таскаются — Стенд: попробовать утащить любую иконку в режиме просмотра — должен идти pan. -- Шапка View: табы пространств, счётчик, zoom, табы редакторов, ⚙ — Стенд: глазами в шапке «Ground Floor | First Floor | Yard». -- Plan: тулбар разметки, иконки скрыты, оранжевая рамка — Стенд: вкладка «Редактор плана». -- Devices: drag иконок, клик открывает редактор маркера — Стенд: вкладка «Редактор устройств». -- Табы скрыты для не-админа — Стенд: не проверяется (второго, не-админского, пользователя на стенде нет). -- Открывания во View инертны (дверь/окно — чистый рисунок) — Стенд: во View потыкать входную дверь в Hallway мимо бейджа замка — ничего не происходит. -- Бейдж замка — единственное исключение — Стенд: клик по замочку на Front Door открывает карточку двери; в редакторе плана он инертен. -- Курсор pointer во View, grab в Devices — Стенд: сравнить курсор над иконкой в двух режимах. -- В Plan открывание интерактивно (drag по стенам, 3 px порог) — Стенд: редактор плана → потаскать дверь вдоль стены, одиночный тап открывает свойства. - -## Онбординг ★ - -Все пункты онбординга требуют ПУСТОЙ конфиг. На стенде: Настройки → Устройства и службы → House Plan можно удалить и добавить интеграцию заново, либо стереть все пространства руками — и пройти онбординг; сброс через час всё вернёт. -- Мастер импорта этажей — Стенд: если на стенде этажи HA не заведены, мастер предложит «Start from scratch»; полный сценарий с этажами — локально (или создать этажи в Настройки → Зоны и этажи, затем удалить пространства карточки). -- Остальные пункты мастера (снять галки → Create disabled, очередь диалогов, Skip/Cancel, авто-разметка с тостом, пустое состояние) — Стенд: в рамках того же прогона после удаления пространств. - -## Пространства ★ - -- Создание с картинкой (SVG/PNG/JPG/WebP), чёткость на зуме — Стенд: «+» в шапке → загрузить свой файл (Ground Floor и Yard нарисованы без подложки, у First Floor подложка есть — интересно сравнить оба пути). -- >8 MB → читаемый тост — Стенд: залить большой файл. -- «Без изображения — нарисую сам», ориентация — Стенд: «+» → режим рисования. -- Белый холст в draw-режиме — Стенд: Ground Floor и Yard такие: борта/имена читаемы на белом. -- Rename / замена картинки / image→draw отвязывает план — Стенд: ⚙ пространства (на First Floor можно отвязать подложку — сброс вернёт). -- Удаление пространства — Стенд: удалить «Yard», убедиться что «Ground Floor» и «First Floor» не тронуты; сброс вернёт. -- Настройки отображения: борта, имена, цвет+прозрачность, заливки — Стенд: ⚙ «Ground Floor» — там всё живое, предпросмотр после сохранения. -- Заливка «zigbee»: красный→зелёный по среднему LQI — Стенд: ⚙ «Ground Floor» → заливка «Сигнал Zigbee»: разброс LQI заведён специально — есть комнаты с сильным (зелёные) и слабым (красные) сигналом, цифры видны под датчиками; «Yard» не залит (нет zigbee). -- Заливка «lights» — Стенд: включить, зажечь свет в комнате с плана — комната жёлтая, потушить — серая. -- Заливка «temperature» + границы комфорта — Стенд: включить: комнаты красятся по датчикам/термоголовкам (значения живые, точные цифры плавают); границы 20–25 редактируются тут же. -- Радиогруппа заливок без легенды — Стенд: глазами в ⚙ пространства. -- Hover затемняет текущую заливку — Стенд: навести на комнату при любой заливке. -- Тултип комнаты со средней температурой — Стенд: навести на Kitchen & Living — «≈NN°». -- Средняя температура только от термометров — Стенд: в комнате с термоголовкой (climate) убедиться, что её current_temperature не ломает среднее (в комнате свой датчик температуры). -- Ширина диалога 500 px, компактные поля — Стенд: глазами в ⚙ пространства. -- Общие настройки (⚙ в шапке): цвета по режимам + Reset — Стенд: покрутить цвета glow/temp/lqi, Reset. -- Пользовательские цвета и на статичной карточке — Стенд: сменить цвет заливки, открыть вид «Схема». -- Градиент LQI между настроенными цветами — Стенд: сменить цвета weak/strong в ⚙ и посмотреть заливку zigbee. -- Per-space «Показывать LQI» — Стенд: выключить в ⚙ «Ground Floor» — бейджи цифр у датчиков и строка сигнала в тултипах пропадают только там. -- Центровка бейджа и глифа — Стенд: глазами на любой иконке при максимальном зуме. -- Hover-подсветка при кастомных бортах — Стенд: покрасить борта и навести. -- Настройки живут на сервере — Стенд: сменить цвет, открыть стенд в приватном окне/другом браузере — цвет тот же. - -## Редактор разметки комнат ★ - -Всё проверяется в «Редакторе плана» на «Ground Floor» (или создайте своё пространство — сброс простит всё). -- Сетка, снап, общие стены — Стенд: дорисовать комнату впритык к существующей. -- Линейка сегмента (метры; cm/cell из настроек) — Стенд: начать рисовать контур, смотреть на бегущую длину. -- Незакрытый контур не сохраняется — Стенд: начать контур, уйти из разметки, вернуться — линий нет. -- Удаление комнаты бережёт общие стены — Стенд: удалить Guest WC (⚙ комнаты → удалить), стены соседних комнат на месте; сброс вернёт. -- Нет инструмента «Erase» в тулбаре — Стенд: глазами. -- Пересечения запрещены (клик внутри чужой комнаты → тост) — Стенд: попытаться начать контур внутри Kitchen & Living. -- Контур вокруг существующей комнаты отклоняется — Стенд: обвести Sauna снаружи. -- Merge двух смежных / отказ для несмежных — Стенд: слить Hallway + Under-Stairs Closet; тост при попытке слить пару без общей стены (подобрать по плану, например Sauna и Guest WC через комнату); сброс вернёт. -- Split: две точки на стенах, бОльшая часть сохраняет имя — Стенд: разрезать Kitchen & Living; Cancel в диалоге — комната цела. -- Split с точкой вне стены / вдоль стены → тост — Стенд: там же. -- Split на не-грид комнатах (снап к стене) — Стенд: полноценно — локально, но снап виден: клик чуть мимо стены цепляется. -- Промах далеко от стены → тост — Стенд: кликнуть в центр комнаты. -- Esc/Ctrl+Z снимает точку, Reset чистит — Стенд: во время рисования. -- Замыкание (≥4 точек) → диалог комнаты — Стенд: нарисовать комнату во «Yard». -- В диалоге только свободные зоны; выбор зоны подставляет имя — Стенд: занятые зоны из списка исчезают; «— без зоны —» есть всегда. -- При «— без зоны —» обычная кнопка «Сохранить» активна после ввода имени и создаёт комнату с `area: null`; отдельной кнопки «Без зоны» нет — Стенд: там же. -- Cancel диалога возвращает контур — Стенд: там же. -- Сохранение с зоной раскладывает устройства зоны — Стенд: частично: у demo-устройств v2 есть registry (группы света и лампы-заглушки), но маркеры стенда расставлены явно; полноценная автораскладка — локально/на даче. -- Erase удаляет линию, удаление комнаты — полигон — Стенд: инструментом «ластик линий» в тулбаре (клик по своей линии). -- Иконки скрыты в разметке — Стенд: глазами. - -## Устройства на плане ★ - -- Авто-устройства только в комнатах своей зоны — Стенд: не проверяется напрямую (все 97 маркеров стенда расставлены явно) — локально/на даче. -- Фильтрация мостов/групп/сцен, 👁 «показать все» — Стенд: «Редактор устройств» → 👁: появляются скрытые сущности — в том числе лампы-заглушки световых групп (демо фильтрации). -- Нумерация дубликатов имён — Стенд: добавить второй маркер той же сущности не даст (занята) — проверяется локально. -- Группы света сворачивают лампы — Стенд: да: в комнатах — группы света, их лампы-заглушки скрыты; 👁 в редакторе устройств показывает заглушки. -- Drag иконок, снап к сетке, позиция per-space — Стенд: редактор устройств: перетащить иконку, F5 — позиция жива; у «Yard» своя раскладка. -- ↺ сброс автолейаута — Стенд: не проверяется: кнопка Reset убрана в v1.33.2 (пункт в TESTING.md исторический). -- Бейдж температуры, LQI с цветом — Стенд: под датчиками цифры LQI (разброс задан специально: от красных до зелёных по комнатам), температура на бейджах. -- Живые состояния: фактическая работа жёлтая, дверь/разблокированный замок/клапан оранжевые, штора нейтральная и меняет иконку, unavailable блёклый — Стенд: пощёлкать свет; тапнуть штору в Study; unavailable — Pantry Light, он такой всегда. -- Морфинг иконок по состоянию — Стенд: дверь/замок: открыть lock замка Front Door из карточки двери — иконка меняется; гаражные ворота во «Yard». -- «Значение вместо иконки» — Стенд: уже настроено: температуры котла в Boiler Room и % бака воды показаны цифрами; можно переключить display у любого датчика. -- RGB только в glow/activity — Стенд: включить лампу цветом (если выбранная группа поддерживает цвет — смотреть карточку): бейдж жёлтый стандартный, цвет только в пятне glow и activity-эффекте. -- Красная пульсация тревоги — Стенд: включить `input_boolean.demo_leak` (Настройки → Устройства и службы → Помощники) — датчики протечки пульсируют красным; датчики дыма вспыхивают сами каждые 10 минут (автоматизация). -- Стоимость рендера (geometry once) — Стенд: не проверяется руками — smoke_render_perf. -- Тап vs drag открывания — Стенд: редактор плана, тап по двери = свойства, drag на место = ничего не записано. -- Вогнутый остров — Стенд: нарисовать U-комнату во «Yard» и остров внутри. -- Backend hardening (B2–B5) — Стенд: не проверяется руками — unit/backend. -- Гонка сохранений (markup vs Save) — Стенд: два окна: в одном правка разметки, в другом Save диалога — правка выживает; остальное — тесты. -- Niche split — Стенд: разрез, начинающийся и кончающийся на одной стене — вырезается ниша. -- Контент только через подписанные URL — Стенд: залить план, скопировать URL картинки из DevTools, убрать authSig → 401; старые пути `/houseplan_files/plans` → 404. -- Каждая опция редактора сохраняема — Стенд: перебрать display/tap/fill в диалогах — ошибок валидации быть не должно. -- Кнопка настроек комнаты в геометрическом центре — Стенд: редактор плана, глазами. -- Один индикатор всегда (лампа жёлтая в plan-редакторе glow-пространства) — Стенд: включить свет, открыть редактор плана — бейдж жёлтый (glow-слоя там нет). -- Size/angle parity — Стенд: задать маркеру size 3 / angle 37 в диалоге → и на «Схеме» тоже ×3 и повёрнут. -- Tap запускает автоматизацию — Стенд: готового Run-маркера в v2 нет — назначить самому: в диалоге любого маркера tap-действие «Run» + поиск по automation/script/scene; тап → диалог подтверждения; Esc = не выполнять. -- Бейджи light-source в Glow — Стенд: у горящей группы света бейдж стандартный (индикатор — пятно), у включённого устройства с ролью «Всегда» (см. вводные) жёлтый. -- Множитель размера иконки растит глиф — Стенд: size 3 у любого маркера. -- Auto-grid parity, activity ghost, hidden LQI parity, ghost без цифр — Стенд: скрыть любой датчик с LQI: LQI комнаты не меняется, «Схема» совпадает с планом; «Hidden and disabled» в редакторе устройств показывает синий пунктирный призрак без значений и эффектов. -- Скрытие с плана — Стенд: кнопка «Скрыть» в левом нижнем углу диалога существующего устройства; после сохранения маркер исчезает из всех режимов и счётчика, но LQI комнаты держит. Через «Скрытые и деактивированные» тот же диалог предлагает кнопку «Показать». -- «Жёлтый = работает» — Стенд: вытяжка (kitchen hood) в Kitchen & Living: включить → жёлтая подложка; термоголовка с активным нагревом — тоже. AC в Bedroom — отдельный случай, см. «Климат без hvac_action». -- Safety floor редактора на touch (best effort, не проверка паритета) — Стенд: с телефона в редакторе плана pinch не создаёт геометрию, отпускание после pan не ставит точку, из редактора можно безопасно вернуться в View. -- Legacy-геометрия, положительные размеры, границы 1e100, no-op repair — Стенд: не проверяется (нужен подложенный битый store, у тестировщика нет доступа к .storage) — unit/backend. -- Карточка под другим контентом дашборда — Стенд: вид «Схема» — карточка стоит после других карточек и не схлопывается. -- Stranded migration repair / geometry repair — Стенд: не проверяется руками (WS-команда) — backend. -- Редакторы видят весь холст — Стенд: «Yard» (одна комната): в редакторе плана виден весь белый квадрат, во View — контент-фит. -- Save ждёт пропорции выбранного плана — Стенд: прикрепить «уже загруженный» план и мгновенно Save. -- Зум ниже фита (пол 1/3, `MIN_ZOOM`) — Стенд: минусовать зум ниже 100%. -- Migration crash / parallel upload quota — Стенд: не проверяется (kill HA между записями невозможен) — backend. -- Зум открывается на контенте — Стенд: «Yard» открывается комнатой на весь экран. -- Удаление используемого плана запрещено — Стенд: в списке «уже загруженные» у подложки First Floor (first-floor.svg) delete неактивен. -- Квота загрузок — Стенд: можно доливать планы до квоты — тост; сброс почистит. -- Миграция на квадратный холст — Стенд: не проверяется (миграция уже прошла; нужен старый store) — unit. -- Картинка плана по центру с полями — Стенд: залить широкую картинку — поля сверху/снизу, без растяжения. -- Re-attach отвязанного плана — Стенд: ⚙ «First Floor» → «рисование» → Save → reload → «Уже загруженные» → прикрепить first-floor.svg обратно. -- Detach хранит файл — Стенд: там же: после отвязки файл в списке; замена плана убирает старый сразу. -- Перепривязка не ест мануалы — Стенд: маркер с 2 PDF перепривязать на другую сущность — оба открываются. -- Ничего не копится на idle — Стенд: не проверяется полноценно (ежечасный сброс раньше суточного sweep) — backend. -- Drag выигрывает у удалённого move — Стенд: два окна, тащить разные иконки одновременно. -- Одноимённые загрузки из двух вкладок — Стенд: можно, два вложения обязаны выжить; тонкости — unit. -- Временные файлы не выживают — Стенд: не проверяется (нужен доступ к ФС) — backend. -- Две полные карточки согласны в позициях — Стенд: два окна: drag в одном виден во втором без reload. -- Загруженный SVG инертен (script не исполняется) — Стенд: залить SVG со ``, открыть его подписанный URL в новой вкладке — alert нет, план рендерится. -- Вложение не перезаписывает другое — Стенд: приложить manual.pdf к двум маркерам — два независимых файла. -- Два быстрых эдита выживают — Стенд: быстро сделать две правки подряд (throttle сети в DevTools) — обе на месте. -- Открытые границы следуют геометрии — Стенд: границы между Kitchen & Living ↔ Hallway ↔ Under-Stairs Closet уже открыты — утащить вершину общей стены в редакторе плана — разрыв едет вместе; можно открыть и свою. -- Внутренние лимиты (max+1) — Стенд: не проверяется руками — unit/backend. -- Большие файлы стримятся (50 MB) — Стенд: можно залить 50 MB мануал и скачать дважды; память HA снаружи не видна — полноценно только локально. -- Паритет статичной карточки (fill none) — Стенд: у комнаты поставить fill «нет» и сравнить со «Схемой». -- Layout доходит до статичной карточки — Стенд: drag иконки на «Плане» → на «Схеме» (другая вкладка того же дашборда) двигается. -- Repair-issue смертны — Стенд: удалить пространство с битым планом… проще: удалить пространство — связанные предупреждения из Настройки → Ремонт исчезают. -- Не-подписанный путь не зацикливает запросы — Стенд: не проверяется руками — unit. -- Подписи не усиливаются на плохой сети — Стенд: DevTools throttling + Network: один sign-запрос на URL. -- Битая папка планов не валит save — Стенд: не проверяется (нужна ФС) — backend. -- Два редактора, один план — Стенд: две вкладки, залить подложку в каждой по очереди — служится последняя, файлы целы. -- Фон статичной карточки — Стенд: открыть «Схему»: подложка First Floor на месте, в Network нет неподписанных запросов. -- Отклонённый save не трогает план — Стенд: две вкладки: обе меняют план, вторая получает конфликт — старый план жив. -- Кэш подписей на 200+ URL — Стенд: не воспроизводимо (нет 200 файлов) — smoke_sign_cap. -- Климат не дорожает с комнатами — Стенд: не проверяется руками — smoke_climate_once. -- Upload при конкурентной ревизии — Стенд: две вкладки: приложить фон при открытом втором редакторе. -- Подписанный фон плана — Стенд: см. выше: authSig в Network, нет failed login в журнале HA (Настройки → Система → Журналы). -- Зомби-диалоги — Стенд: Esc во время сохранения при throttling — диалог закрыт, тост об ошибке живёт. -- Нет hover-тултипов на touch — Стенд: с телефона. -- Слайдеры шрифтов (50–300%) — Стенд: ⚙ пространства + ⚙ комнаты: живой образец в диалоге следует слайдеру. -- Room settings tier 3 (имя, зона, fill override, источник T°/влажности) — Стенд: ⚙ любой комнаты; источник температуры/влажности можно переопределить (датчики влажности в доме есть) и посмотреть карточку. -- PDF переживает перепривязку — Стенд: см. «перепривязка не ест мануалы». -- Киоск-режим — Стенд: вид «Киоск» дашборда: шапки нет, свайп листает пространства с точками-индикатором, дабл-тап сброс зума, лонг-пресс 3 с на пустом месте — поповер размеров; авто-листание cycle — задано в конфиге вида; реальный планшет — руками на своём. -- Иконка-ссылка зоны у имени комнаты — Стенд: во View у имён комнат с привязанной зоной есть ↗ — клик ведёт в зону HA; в редакторах иконки нет. -- Смарт-гайды — Стенд: тащить иконку в редакторе устройств — пунктирные направляющие от соседей, бейдж длина·угол. -- Свет тогглится по умолчанию — Стенд: тап по любой группе света во View = вкл/выкл без настройки. -- Производные стены тоже режутся (.seg) — Стенд: открытые границы уже есть (Kitchen & Living ↔ Hallway ↔ Under-Stairs Closet): в редакторе плана сплошной линии под пунктиром нет. -- Пунктир открытых границ в разметке — Стенд: там же. -- Nav-персистентность (пространство+режим) — Стенд: уйти в «Yard», закрыть вкладку, открыть — снова «Yard»; `#space=f1` в URL сильнее. -- Чистка tap-действий + правый клик — Стенд: ПКМ по иконке во View = more-info HA. -- Редизайн секции биндинга — Стенд: «+» в редакторе устройств: радио Виртуальное/Из списка HA, «показать сущности», поиск в дропдауне. -- Настоящий пунктир открытой границы — Стенд: см. открытые границы. -- Hover открытия стены — Стенд: инструмент «Открыть границу»: у общей стены курсор pointer + янтарный пунктир-превью; на уже открытой — красное превью. -- Открытые границы: свет течёт по зоне — Стенд: при glow зажечь свет в Hallway — свечение заливает и Kitchen & Living, и Under-Stairs Closet через открытые границы. -- Свет через дверь непрерывен — Стенд: glow: смотреть проём из Hallway — сам проём освещён, за ним луч с чёткими краями, сужённый откосами; наружная дверь не светится вовсе. -- Per-source радиус glow — Стенд: поле «Радиус свечения» в диалоге лампы. -- Скрытая light-primary — Стенд: лампы-заглушки световых групп скрыты в реестре — glow комнаты питается группой; смотреть, что скрытые лампы не рисуют своих маркеров. -- Marker controls (тогл связки ламп) — Стенд: у настенного выключателя или виртуального маркера прописать «Управляет источниками света» + Toggle и тапнуть: лампы комнаты разом. -- Независимый Glow — Стенд: выбрать любую заливку пространства, отдельно - включить «Свечение источников света», зажечь свет — заливка данных остаётся, - поверх появляется пятно; радиус меняется в ⚙. Пересечения нескольких пятен - должны быть ярче и смешивать цвета. -- Островные комнаты — Стенд: нарисовать остров во «Yard». -- Иконка не прыгает при перепривязке — Стенд: перепривязать маркер: позиция та же; смена комнаты в том же пространстве тоже не двигает. -- Плейсхолдер автоиконки — Стенд: диалог без явной иконки: «Auto: mdi:…» с превью. -- Нет кнопки Reset в тулбаре Devices — Стенд: глазами: add / 👁 / ⬡. -- Сетка во всех редакторах + fade декора — Стенд: редактор фона: комнаты/иконки блёкнут до 35%, декор яркий. -- Редактор фона (фигуры/текст/ластик) — Стенд: порисовать во «Yard»: прямоугольник-«газон», текст; готовый образец — контур гаража. -- Свои изображения — Стенд: редактор подложки → «Изображение», загрузить PNG - с прозрачностью и безопасный SVG до 2 МиБ; выбрать из палитры, проверить - one-shot предпросмотр, плавный resize/отражение/поворот и отсутствие магнита - к стене. Создать вторую копию в другом пространстве: удалить общий файл из - палитры можно только после удаления обеих копий. Во View и отдельной карточке - изображение одинаково пассивно; светлая/тёмная тема не меняет его пиксели. -- Превью открывания (призрак 90 см) — Стенд: инструмент «Открывание», водить у стены. -- Split-polyline, курсоры, Esc-цепочка — Стенд: split Kitchen & Living ломаной через середину. -- Подсветка выбранного в merge/split — Стенд: янтарная обводка первой комнаты. -- Карточка vs инструмент — Стенд: в редакторе плана тащить карточку комнаты при активном Draw — точек не ставится. -- Карточки комнат (метрики, drag, resize) — Стенд: включить метрики в ⚙ пространства; в редакторе плана карточку можно тащить и растягивать за углы. -- Esc закрывает диалоги по одному — Стенд: наоткрывать вложенных диалогов. -- Общая ⚙ видна во всех режимах — Стенд: глазами. -- Табы редакторов (2 шт + X) — Стенд: глазами. -- ⚙ пространства в каждом режиме — Стенд: глазами. -- Lock action (Unlock красный + подтверждение) — Стенд: карточка Front Door; вторая дверь с замком — Terrace. -- Флаг нового устройства (красная точка) — Стенд: не проверяется напрямую (набор сущностей демо фиксирован; точка была бы у новых) — локально. -- Ноль устройств в HA — Стенд: не проверяется (в демо ~100 сущностей) — smoke_new_device. - -## Стены, перегородка, декор (новое в доме v2) - -- Толстые стены со штриховкой — Стенд: «Ground Floor» нарисован от руки, у стен 23 записи толщины: наружные/несущие толстые, со штриховкой; на зуме штриховка остаётся чёткой, бумага комнат ложится по контурам. -- Перегородка и колонна — Стенд: на Ground Floor есть одна перегородка и одна колонна — найти глазами; в редакторе плана редактируются своими инструментами. -- Пунктирная декор-линия — Стенд: на Ground Floor одна пунктирная линия декора; в редакторе фона выделяется и редактируется. -- Декор-тексты — Стенд: подписи Porch и Terrace на Ground Floor, контур гаража во «Yard» — чистый декор, во View инертны. - -## Подложка-картинка на «First Floor» (новое в доме v2) - -- First Floor демонстрирует фичу подложки (docs/BACKDROP.md): архитектурная SVG-картинка `first-floor.svg`, нарисованная точно по контурам комнат — Стенд: открыть «First Floor», глазами: картинка под бумагой комнат, бумага всегда по контурам. -- Двигать/масштабировать/вращать подложку — Стенд: «Редактор фона» на First Floor: сместить/отмасштабировать/повернуть картинку, Save, F5 — держится; сброс вернёт эталон. - -## Живые текст-метки (новое в доме v2) - -- «Boiler {}» и «Water tank {}» в Boiler Room — Стенд: глазами: плейсхолдер `{}` подставляет живые состояния `sensor.boiler_flow_temperature` и `sensor.water_tank_level`; значения меняются со временем. -- «Outdoor {}» во «Yard» — Стенд: там же, уличная температура. -- Редактирование — Стенд: редактор фона: текст с `{}` и привязанной сущностью. - -## Шторы и ворота — covers (новое в доме v2) - -- Тап открывает/закрывает штору — Стенд: две шторы (curtains) в Study: тап во View = открыть/закрыть, иконка морфится по состоянию. -- Гаражные ворота — Стенд: «Yard»: cover ворот исключён из общего тогла карточки правилами доменов — общий тогл ворота не трогает; открыть можно из карточки. - -## Медиаплееры нейтральны (новое в доме v2) - -- TV + колонки играют — маркер остаётся нейтральным (media-правило: воспроизведение ≠ «работа») — Стенд: запустить воспроизведение из карточки плеера и убедиться, что жёлтой подложки нет. - -## Климат без hvac_action и составная розетка (новое в доме v2) - -- AC в Bedroom = `climate.ecobee` БЕЗ атрибута hvac_action — жёлтая подложка приходит по фолбэку hvac_modes — Стенд: включить кондиционер из карточки: маркер желтеет, хотя hvac_action нет. -- Составной Smart Plug: сущность Power + опциональные свитчи — маркер красит только Power — Стенд: пощёлкать опции в карточке (маркер не меняется), затем Power (маркер желтеет). - -## Unavailable и деактивированные (новое в доме v2) - -- Pantry Light всегда unavailable — Стенд: глазами: блёклый маркер, без активности; тултип/карточка показывают недоступность. -- Smart Plug 2 деактивирована в реестре HA — зарезервирована под будущую фичу disabled-devices; сейчас рендерится по текущему поведению — Стенд: см. раздел «Деактивированные устройства HA». - -## «Значение вместо иконки» в бою (новое в доме v2) - -- Числа — Стенд: Boiler Room: температуры котла и % бака воды показаны цифрами вместо иконок. -- Текст и энергия — Стенд: Study: текстовый статус-сенсор (строка) и сенсор энергии в kWh (число) — оба в режиме «значение». - -## Солнце и день/ночь (новое в доме v2) - -- `weather.demo_weather_south` подключена к «Ground Floor» — Стенд: глазами: клинья солнечного света из окон внешних стен, фон день/ночь по реальному времени (docs/SUN.md); настройки — в ⚙ пространства. - -## Диалог устройства (маркеры) ★ - -- Все поля сохраняются — Стенд: любой маркер: имя/иконка/модель/ссылка/описание, F5. -- Перепривязка с поиском, занятые исключены — Стенд: дропдаун биндинга: уже размещённые сущности в списке отсутствуют. -- Виртуальное устройство: имя+комната, пунктир — Стенд: создать своё через «+» (готового виртуального в v2 нет — все маркеры привязаны к сущностям). -- Комнаты без зоны в списке комнат — Стенд: нарисовать комнату «без зоны» и поместить туда маркер. -- Room override центрирует — Стенд: сменить комнату маркера. -- Tap-action override — Стенд: перебрать все варианты у одного маркера. -- PDF: ok / >50 MB / .exe — Стенд: приложить файлы к маркеру; большие и .exe должны дать читаемую ошибку. -- `javascript:` в ссылке не кликабелен — Стенд: вписать `javascript:alert(1)` в поле ссылки — в карточке не ссылка. -- Remove: авто → скрытый маркер, виртуальное → насовсем — Стенд: удалить свой виртуальный маркер и любой entity-маркер (сброс вернёт). - -## Правила иконок ★ - -- ⬡ открывает редактор с текущими правилами — Стенд: редактор устройств → ⬡. -- Тест-поле, добавление/удаление/порядок — Стенд: там же, ввести имя «Boiler…». -- Битый regex краснеет и пропускается — Стенд: вписать `([`. -- Reset к дефолтам — Стенд: там же. -- Правила переиконивают существующие; per-device иконка сильнее; замки всегда mdi:lock — Стенд: правило `.*light.* → mdi:star` и смотреть план. -- Живут после reload и во втором браузере — Стенд: приватное окно. - -## Tap-действия и жесты ★ - -- Тап → карточка, `toggle` тогглит только свет/розетки/вентиляторы — Стенд: пощёлкать группы света (toggle по умолчанию), TV/колонку (карточка). -- Замки не тогглятся никогда; шторы Study открываются/закрываются тапом (covers), гаражные ворота — нет — Стенд: замок Front Door; штора в Study; ворота во «Yard». -- Long-press всегда карточка — Стенд: зажать лампу на 600 мс — карточка вместо toggle. -- Drag >3 px отменяет tap/long-press — Стенд: потащить палец с иконки. -- pointercancel без фантомов — Стенд: с телефона: начать тап и свернуть браузер. - -## Zoom / pan / подписи - -- Wheel у курсора, +/−, fit, % — Стенд: глазами. -- Pinch + двухпальцевый pan — Стенд: с телефона. -- Зум живёт per-space в localStorage — Стенд: зазумить «Ground Floor», перейти в «Yard» и назад, F5. -- Resize окна перефитит — Стенд: свернуть боковую панель HA. -- Подписи комнат таскаются (rl_*) — Стенд: в редакторе плана утащить имя комнаты, F5. -- Читаемость подписей на мин/макс зуме — Стенд: глазами на белом плане и на подложке First Floor. - -## Мультиклиент ★ - -- Две вкладки: drag виден без reload — Стенд: два окна рядом. -- Конфликт конфига → тост, авторесинк, retry — Стенд: два окна: одновременно править настройки пространства. -- Точечные layout-апдейты не трутся — Стенд: тащить разные иконки в двух окнах. -- `admin_only` для не-админа — Стенд: не проверяется (нет второго пользователя) — backend. - -## Крайние случаи - -- HA без устройств/зон — Стенд: не проверяется (демо полон сущностей) — локально. -- Пространство без комнат — Стенд: создать пустое, видна подсказка разметки. -- Комната без зоны + борта — Стенд: нарисовать, кликнуть — ничего. -- Нет zigbee вовсе — Стенд: «Yard»: заливка lqi оставляет двор незалитым, бейджей нет. -- 100+ устройств в одном пространстве — Стенд: почти: на «Ground Floor» ~51 маркер из 97; полная сотня в одном пространстве — локальная синтетика. -- Длинные имена — Стенд: переименовать маркер в очень длинное имя. -- HTML/эмодзи в именах — Стенд: назвать маркер `xss🚿` — текст, не разметка. -- План удалён с диска → Repairs — Стенд: не проверяется (нет доступа к ФС) — backend. -- Битый houseplan.config — Стенд: не проверяется (нет доступа к .storage) — backend. -- Рестарт HA при открытом диалоге — Стенд: не проверяется: demo_guard блокирует рестарт HA на стенде (кнопка перезапуска даёт ошибку) — локально/на даче. -- Legacy layout v1 — Стенд: не проверяется — unit. -- Киоск холодный старт в приложении — Стенд: Companion app + вид «Киоск», убить и открыть. - -## Релизные регрессии - -- Ноль ошибок консоли — Стенд: DevTools console на дашборде, в разметке, диалогах, зуме (обратный отсчёт сброса стенда в консоли — норма, не ошибка). -- Ноль houseplan-ошибок в логе HA — Стенд: Настройки → Система → Журналы, фильтр «houseplan». -- npm test / pytest / CI — Стенд: не про стенд — CI. -- Скриншоты README — Стенд: не про стенд. - -## houseplan-space-card (вид «Схема») - -- Рендер идентичен плану, без шапки — Стенд: вид «Схема» дашборда. -- Полностью неинтерактивна — Стенд: покликать по «Схеме» — ничего, даже тултипов. -- Кнопка-футер открывает полную карточку на этом пространстве — Стенд: клик по кнопке под «Схемой». -- Несколько с разными space — Стенд: «Схема» содержит несколько пространств — один общий WS-запрос (Network). -- Неизвестный space → аккуратная ошибка — Стенд: отредактировать вид, вписать space: nope. -- show_button: false — Стенд: правкой YAML вида. -- #space= диплинк — Стенд: открыть `…/plan#space=f2` (First Floor). - -## Состояние и активность устройства - -- В списке отображения ровно четыре режима: «Значок + состояние», «Значок + состояние и активность», «Значение + состояние», «Всегда статичный значок»; «Только пульсация» отсутствует. -- «Всегда статичный значок»: включённое, выключенное, unavailable и тревожное устройство выглядят одинаково нейтрально, без °/%/LQI и activity; hover и клик работают как прежде. У alarm-capable привязки есть предупреждение. -- У пылесоса этот режим скрывает puck и след, а возврат к динамическому режиму снова показывает сохранённый след. -- Motion в «Значок + активность» даёт три волны только при `off → on` и затихает примерно через 3,3 с, даже если датчик ещё `on` — Стенд: датчики движения расставлены по Ground Floor. -- Occupancy/presence показывает спокойную постоянную пульсацию, пока присутствие активно — Стенд: датчики присутствия там же (контраст с тремя конечными волнами motion — рядом). -- Штора в `opening/closing` показывает дыхание только в «Значок + активность»; при прямом `closed ↔ open` без промежуточного состояния эффект держится примерно 3,3 с — Стенд: шторы в Study. -- Работающий свет, реле, вентилятор, climate с активным `hvac_action` и пылесос получают жёлтую подложку; медиаплееры при воспроизведении остаются нейтральными (media-правило); `automation = on` (включена) тоже нейтральна. -- Дверь/окно, разблокированный замок и открытый клапан — оранжевые, а не жёлтые. -- Тревога всегда красная и пульсирует во всех динамических режимах; «Всегда статичный значок» — явное нейтральное исключение. Unavailable приглушён и не получает обычной активности — Стенд: дым сам каждые 10 минут, протечка через `input_boolean.demo_leak`, unavailable — Pantry Light. -- Цвет и размер ×2..×8 применяются к обычной активности; тревога остаётся красной. -- Размер ×0.5..×3 и поворот; бейджи масштабируются — Стенд: любой маркер. -- При выключенных live-состояниях обычные статус и активность выключены, критическая тревога остаётся. -- reduce motion — Стенд: включить в ОС тестера; обычная активность обозначается компактной точкой, статичное кольцо не появляется, а тревога остаётся видна по красной подложке. - -## Двери, окна и ворота - -Замки уже стоят: Front Door (вход через Hallway) и Terrace — бейджи замков на -открываниях. Оконные/дверные контакты навешаны на несколько окон и дверей; -всего на Ground Floor 15 открываний. -- Клик мимо стены → тост; у стены → диалог — Стенд: инструмент «Открывание» в разметке. -- Дверь: косяки+полотно+дуга, длина в см — Стенд: глазами у входной двери. -- Ворота: две равные створки без дуги, статически открыты наружу на 10°; - по умолчанию 300 см. На локальной сборке проверить датчик, замок и Glow; - на стенде пока не посеяны. -- Контакт: open → створка + дуга акцентом — Стенд: переключить сущность контакта из more-info/Developer Tools (demo — админ, может) и смотреть створку. -- Unavailable → статичный дефолт — Стенд: не проверяется на открываниях (unavailable в доме только Pantry Light) — локально. -- Замок: зелёный/оранжевый/серый бейдж — Стенд: замок Front Door, открыть/закрыть из карточки. -- Карточка с двумя состояниями, замок не тогглится с плана — Стенд: клик по двери/замку во View. -- Flip зеркалит петли/сторону — Стенд: диалог открывания (двойной клик с инструментом). -- Клик с инструментом → редактирование; Delete — Стенд: там же. -- Hover: акцент + grab во View — Стенд: навести на дверь. -- Drag вдоль стен с переснапом за углы — Стенд: тащить дверь по периметру Hallway. -- Один клик = карточка, дабл = свойства, drag = ничего — Стенд: там же. - -## Живые пылесосы (docs/VACUUM.md) - -Симулятор: робот `vacuum.demo_robot` с базой в Under-Stairs Closet (база = -маркер), карта в стиле Tasshack (позиция-словарь, комнаты с именами как на -плане, mm, ось Y перевёрнута), калибровка уже прописана. Маршрут уборки: -змейка Under-Stairs Closet → Hallway → Guest Bedroom → Kitchen & Living. -- Docked: только маркер базы — Стенд: глазами (робот в Under-Stairs Closet). -- Уборка: круглая пульсирующая шайба едет, база на месте — Стенд: тап по маркеру робота → карточка → Start (или Настройки → Устройства и службы → Сущности → vacuum.demo_robot); шайба змейкой проходит все 4 комнаты маршрута. -- Глайд ~1.2 с, телепорт при zoom/pan/switch — Стенд: во время уборки позумить. -- Режимы следа never/cleaning/always — Стенд: диалог маркера робота → «Показывать путь робота»; в always виден след прошлой уборки (если после сброса она уже была). -- Серверный след (переживает reload) — Стенд: запустить уборку, F5 посреди — линия продолжается с места; вторая вкладка видит ту же линию. -- Стиль: тёмный ореол + светлое ядро — Стенд: глазами поверх glow-заливки. -- Скрытый маркер: ни шайбы, ни следа — Стенд: скрыть маркер робота на время уборки. -- Калибровка: «Настроить автоматически» — Стенд: диалог робота → «Живая позиция»: имена комнат карты совпадают с комнатами плана — автокалибровка сходится; панель подгонки (призрак, углы, поворот, зеркало — зеркало осмысленно: ось Y в карте перевёрнута) тоже можно открыть и покрутить, Esc/Cancel не портит. -- Мультиэтаж (матрица на карту) — Стенд: частично: карта одна (map 1), матрица хранится по её id; мультикарта — только на реальном Dreame. -- Pause/Return — Стенд: кнопки в карточке робота: пауза замирает, «домой» возвращает по своим следам с двойной скоростью. - -## Чего на стенде нет принципиально - -- Реальное Zigbee/Z-Wave железо, реальные роботы (Dreame/Xiaomi/Valetudo), реальный настенный планшет. -- HACS-путь установки/обновления, YAML-mode Lovelace, переустановка интеграции с сохранением конфига. -- Рестарт HA изнутри — заблокирован demo_guard (сброс контейнера раз в час — единственный «рестарт»). -- Всё, что требует доступа к файловой системе стенда: битые store, kill -9 между записями, недоступные папки, наблюдение за памятью процесса. -- Долгоживущие сценарии (суточный sweep, «часом позже») — сброс стенда наступает раньше. -- Не-админский пользователь (admin_only, скрытие табов) — на стенде один админ demo. diff --git a/docs/UX-MODES.md b/docs/UX-MODES.md index cbfbd3d6..422824cf 100644 --- a/docs/UX-MODES.md +++ b/docs/UX-MODES.md @@ -34,8 +34,9 @@ obvious at a glance: **[ 📐 Plan editor ] [ 🔧 Device editor ] [ ✏️ Background editor ]** — View has NO tab (since v1.30.2). The Background editor (v1.33.0) manages a purely visual -decor layer (lines/rects/ovals/text in `space.decor`, drawn under the rooms, -inert everywhere outside its editor). +decor layer (lines/rects/ovals/text/furniture/images in `space.decor`, one layer +above room fills and Glow base and below live Glow, walls, devices and labels — +`DECOR-EDITOR.md` §1; inert everywhere outside its editor). - **View** is the implicit default state: no editor tab is active. Navigation persistence remembers only the last space. Reloading the page or leaving @@ -202,8 +203,8 @@ layer you cannot see is a layer you cannot edit. while editing a plan, device placement or its underlay makes those modes visually ambiguous. Their solid/dashed choice still affects light while the line itself is hidden. -- **`show_names: false` means no permanent fallback label.** View, kiosk, - hidden isometric and `houseplan-space-card` all omit the room name. Plan may +- **`show_names: false` means no permanent fallback label.** View (Flat and + 2.5D), kiosk and `houseplan-space-card` all omit the room name. Plan may show the same HTML card temporarily so its saved position remains editable; returning to View hides it again. Re-enabling names restores the saved layout rather than creating a new one. @@ -290,25 +291,16 @@ layer you cannot see is a layer you cannot edit. 4. Legacy localStorage mode (card without the integration) — candidate for removal in the next major; adds branching for a half-working scenario. -## Approved follow-up features (from issue #3, by priority) +## Follow-up features from issue #3 — all shipped -1. State-reflecting icons (open/closed door variants etc., like core HA). -2. `display: value` — show the measurement instead of an icon. -3. Light color in the activity effect (RGB lights). *(Shipped in v1.27.0; - superseded in v1.52.0: the colour lives in the glow spot and the activity - fallback only, the icon tint was removed by the owner's rule.)* -4. Alarm visual (leak/smoke/doorbell): red pulse overlay. -5. Rooms as sub-areas without an HA area + manual device placement by room id. -6. Backlog (not planned): music notes for players, directional TV effects. - -## Implementation iterations - -- **It.1 — mode shell:** the segmented control, mode state, View-mode gating of all - edit interactions/buttons (biggest UX win, smallest surface). -- **It.2 — Plan tab:** move markup tools + space dialogs + labels drag + openings - editing under Plan; colored frame indicator. -- **It.3 — Devices tab:** drag + direct-edit click + filtering tools under Devices. -- **It.4+:** follow-up features 1–5 above, each its own release. +The five follow-ups approved with this design are in the product: state- +reflecting icons and the alarm pulse (`DEVICE-PRESENTATION.md`), `display: +value` (the value face), the light colour in the activity effect (v1.27.0, +narrowed in v1.52.0 to the Glow spot and the activity fallback — the icon tint +was removed by the owner's rule) and rooms without an HA area with manual +placement by room id. The three implementation iterations (mode shell, Plan +tab, Devices tab) are history in the changelog. Not planned: music notes for +players, directional TV effects. ## Kiosk mode (v1.41.0) diff --git a/docs/VACUUM.md b/docs/VACUUM.md index 35a6c03b..289dd5cf 100644 --- a/docs/VACUUM.md +++ b/docs/VACUUM.md @@ -1,8 +1,7 @@ # Live robot vacuums on the plan -Status: implemented contract for the v1.61 development cycle. Stage 1 covers -Tier-A integrations. Roomba string-position support remains a separate Stage 2 -issue and is not claimed here. +This is the contract for the integration families in the coverage table +below. Roomba's core `position` string is not covered. ## What the user sees @@ -18,7 +17,7 @@ HA-disabled or `static_icon` marker has no puck, trail or room overlay. | Xiaomi Cloud Map Extractor | Yes, when the attributes below are enabled | Yes | Yes, including `path.path` subpaths | `map_name` when exposed | Usually explicit camera selection | | dreame-vacuum (Tasshack) | Yes | Yes; explicit room `x/y` is the anchor | No | Vacuum `selected_map` fallback | Automatic on the same HA device | | Valetudo camera conventions | Yes | Yes when room data is exposed | No | Often `default`; no stable multi-floor promise | Automatic on the same HA device | -| Roomba core `position` string | Not in Stage 1 | No | No | — | Stage 2 | +| Roomba core `position` string | Not covered | No | No | — | not covered | For Xiaomi Cloud Map Extractor the camera must expose: @@ -185,7 +184,7 @@ Server recording is independent of the display mode. The source health monitor checks saved marker/source pairs on config refresh and restart: one warning is emitted for a missing/disabled incident, reason changes are deduplicated, and another warning is possible only after proven recovery. -Detection is intentionally refresh/restart based in Stage 1; no extra entity +Detection is intentionally refresh/restart based; no extra entity registry subscription is installed. ## Storage and lifecycle @@ -242,7 +241,7 @@ debounced persistence and live-update path as a later state event. Plan will not guess a replacement. Commands, zones/no-go polygons, cleaning-history UI and Roomba string parsing -are outside Stage 1. +are outside this contract. ## Deleting and restoring a vacuum marker (#369) diff --git a/docs/WALL-THICKNESS.md b/docs/WALL-THICKNESS.md index 7c16da52..e190becc 100644 --- a/docs/WALL-THICKNESS.md +++ b/docs/WALL-THICKNESS.md @@ -1,6 +1,6 @@ # Wall thickness — the spec -Status: **implemented / evolving** (post beta.4 redesign). Visual reference: +Visual reference: [docs/assets/wall-thickness-reference.png](assets/wall-thickness-reference.png) (look at wall bodies only). @@ -17,6 +17,10 @@ Code: `src/wall-thickness.ts`, render in `src/houseplan-card.ts` / ### Stable stored identity and zero walls (model v10, #282/#306/#478) +The decision record behind the stored representation — what the code calls +«ADR 282» — is `docs/adr/282-wall-geometry-representation.md`; the migration +matrix is in `CONFIG-COMPATIBILITY.md` (model v8–v10). + From model v8 every atomic room-wall interval has a stable record in `space.wall_segments[]`. Its `id`, endpoints and `cm` are authoritative; `rooms[].wall_ids[]` references the ordered atoms that form each contour. @@ -535,3 +539,70 @@ pass, so an old butt face cannot become a visible seam or a false light barrier. Exact near-misses remain separate, an interior↔interior X crossing keeps normal boolean-union semantics, and malformed legacy segments fall back opaque without writing configuration. + +## 10. Architectural connection overlay (Walls tool) + +When **Walls** is active in the Plan editor, a derived +pointer-transparent SVG layer exposes the centre axes of completed room walls +and independent partitions. It is painted after their +physical wall bodies, but before interactive editor chrome. Columns, decor, +devices, the active wall chain and its live preview are not candidates. Door, +window, gate and intentionally open-span intervals are cut from presentation +axes; a cut boundary does not become a new endpoint. + +The layer and hit resolver share one immutable geometry snapshot. Original +segment endpoints are deduplicated and drawn at a physical radius of 5 cm. +Inside a 12 CSS px hit zone, an endpoint wins over every line and grows to +10 cm. Otherwise the nearest solid line receives one 10 cm dynamic node: the +raw pointer is projected onto that line, then quantized by the grid step along +the line from its stable start. This keeps diagonal connections wall-bound even +when neither resulting coordinate is a global grid multiple. The same resolver +runs again on click, so hover is only a preview and never authoritative. + +Endpoint and line candidates override the normal grid and Shift/45° result. +Outside the hit zone, the ordinary snap contract (`CANVAS.md` §9.3–9.4) is unchanged. A line connection adds only the +new segment endpoint; it does not split or rewrite the existing wall. The +current anchor is excluded to prevent zero-length segments. The static geometry +is cached by structural editor state; pointer movement changes at most the +single active candidate and never writes config, layout or storage. + +A separate diagnostic projection is present throughout the Plan editor (#296), +including tools other than **Walls**. For every independent wall +segment with a positive exact collinear overlap against another wall, it keeps +that source segment's complete axis and original endpoints visible. It is +painted after every wall body and zero-thickness axis and before openings, selection +chrome and transient previews. The layer is `pointer-events:none`, +`aria-hidden`, absent from View and cached by structural revision; it neither +deduplicates source identities nor participates in the architectural snap +resolver above. The 1 CSS px non-scaling axis and physical 5 cm nodes therefore +diagnose an otherwise invisible Resize blocker without changing any hit target. + +## 11. Planar wall faces + +Every completed Walls segment is persisted immediately as an ordinary +`partition`; only its ordered chain membership remains in memory. On the click +path only, an immutable planar graph is built from structural room edges and +partitions both before and after the latest segment. Unlike the +presentation/snap snapshot, this +face graph ignores door/window/gate/passage cuts; zero-thickness wall axes remain +structural graph edges even though they have no masonry body. +Endpoint, T, X and +collinear-overlap junctions atomize that computed graph without rewriting any +saved wall. A deterministic half-edge walk extracts bounded faces; canonical +identity ignores winding, cyclic start and derived collinear subdivision. + +Only faces added by the latest segment and containing one of its atoms are +offered. They are ordered by area and then canonical key. Existing exact or +partially overlapping rooms are excluded, nested rooms remain legal, and any +physical gap created by an `open_span` or absent wall remains a gap. A door, +window, gate or passage is a property of a wall and preserves connectivity. A clean divider across +one room reuses the Split contract: the larger side keeps the room identity, +metadata and device binding, and only the smaller side is offered. + +The terminal active path remains session-local while the resulting room dialogs +are open. Create/Keep-as-walls answers are buffered; Cancel/Esc discards all +answers and leaves the already persisted partitions unchanged. The final answer +revalidates the whole batch and applies accepted rooms while consuming only the +coincident partitions used by those rooms in one history/config transaction. +Graph construction never runs on pointermove, Home Assistant state updates or +ordinary rendering. diff --git a/docs/WARM-REMOUNT.md b/docs/WARM-REMOUNT.md index c0f13e47..3092c481 100644 --- a/docs/WARM-REMOUNT.md +++ b/docs/WARM-REMOUNT.md @@ -1,6 +1,6 @@ # Тёплый ре-маунт: возврат на вкладку без «перезагрузки» -> Статус: реализовано (dev, DEV-B703-01…03). Код: `src/houseplan-card.ts`, +> Код: `src/houseplan-card.ts`, > модульная памятка `warmBoot`. Смоки: `demo/smoke_warm_remount.mjs`, > `demo/smoke_warm_dialogs.mjs`, `demo/smoke_warm_owners.mjs` (владение слотами) > и `demo/smoke_nav_persist.mjs` (постоянно хранится только пространство). @@ -240,7 +240,7 @@ hover. После долгого сна общий `VisualContinuityController` | Не воскрешаем | Причина | |---|---| -| «**Выровнять всё по сетке**» (`_alignDialog`) | Модалка, всё содержимое которой — «нажмите OK, и я перепишу ваш план». Воскресить подтверждение рядом с человеком, который только что вернулся на вкладку, — прямой путь к слепому клику по разрушающей записи. Открывается одним нажатием из шестерёнки. | +| «**Оптимизировать планы**» (`_alignDialog`, `CONFIG-COMPATIBILITY.md` › Optimize plans) | Модалка, всё содержимое которой — «нажмите OK, и я перепишу ваш план». Воскресить подтверждение рядом с человеком, который только что вернулся на вкладку, — прямой путь к слепому клику по разрушающей записи. Открывается одним нажатием из общих настроек. | | Подтверждение **объединения комнат** (`_mergeDialog`) | Тот же класс: подтверждение необратимой правки геометрии. | | Подтверждение действия по тапу (`_tapConfirm`) | Держит замыкание `exec` на **мёртвый** экземпляр. | | Мастер импорта этажей (`_importDialog`) | `updated()` сам открывает его, пока конфиг пуст; воскрешение удвоило бы очередь. | diff --git a/docs/adr/570-isometric-stage4-visual-handoff.md b/docs/adr/570-isometric-stage4-visual-handoff.md new file mode 100644 index 00000000..58e2a775 --- /dev/null +++ b/docs/adr/570-isometric-stage4-visual-handoff.md @@ -0,0 +1,192 @@ +# ADR #570 — Stage 4 isometric visual handoff (historical record) + +- Issue: https://github.com/Matysh/houseplan-card/issues/570 +- Status: implemented; superseded in activation by #649 (2.5D is a public View + mode behind `settings.volumetric_view`, `hp_alpha` no longer gates it) +- Predecessors: `docs/adr/089-isometric-stage1-renderer.md`, + `docs/adr/122-isometric-stage2-composition.md`, + `docs/adr/160-isometric-stage3-overlays.md` +- Normative material at the time: the reviewed specification in issue #570 plus + its revision-3 designer handoff +- Provenance: the sections below were written in `docs/ISOMETRIC.md` while + 2.5D was a hidden `hp_alpha` experiment and moved here unchanged by #679 (the + current contract lives in `ISOMETRIC.md` without stage framing) + +## Stage 2 composition (#122) — as documented before #679 + +> Historical: written while 2.5D was a hidden `hp_alpha` experiment. Since #649 +> it is public; activation is described in [Activation](#activation). + +Stage 2 evolves the same hidden `iso` experiment; it does not add a flag, +setting or public activation path. The accepted implementation contract is +`docs/specs/122-isometric-stage2.md` and the fixed composition decisions are in +`docs/adr/122-isometric-stage2-composition.md`. Its historical per-feature +lifetime was superseded by the single indefinite `hp_alpha` gate in #448; the +Stage 2 rendering contract itself is unchanged. + +### One structural scene, live opening leaves + +The per-card LRU remains capped at eight entries. Its Stage 2 value contains: + +- canonical wall top/sides and the physical-wall contact path; +- a room/exterior floor footprint and its low visible outer faces; +- immutable opening jamb/axis bases, including type, flips and selected wall + face; +- the shared projected frame, including wall/opening tops and the low floor + edge. + +The key includes rooms, masonry/opening geometry, flips, scale/camera, wall and +edge heights and algorithm revision. It excludes HA state, theme, hover, +day/night and filter capability. `openingAmount()` is applied only after an LRU +hit by `projectIsoOpening()`, so a contact update projects O(O) leaves without +repeating a wall or floor boolean operation. + +`floorFootprintGeometry()` deliberately accepts no independent physical-body +input. The slab is the union of room floors and derived exterior masonry: +internal room boundaries and nested holes make no decorative step, detached +room components keep separate outside edges, while partitions and columns do +not enlarge it. + +### Layer order and materials + +All geometry roots use one scene `viewBox`. The existing floor/live nodes are +grouped under the Stage 1 affine matrix; HTML anchors still use +`projectPlanPoint()`. + +```text +stage background +→ shared ambient shadow + low exterior floor edge +→ existing floor SVG (paper/image, room fills/hover, decor, Glow, sun) +→ canonical wall sides/top + inert vertical opening panels +→ existing HTML devices, labels/cards, locks and vacuum overlays +``` + +Wall top and side use two shared matte gradients. One shared filter supplies +only the soft exterior ambient shadow of the complete building footprint; +internal wall-contact and opening-leaf shadows are deliberately absent. +Definition count is constant per card, never per face or opening. Forced +colours use solid `Canvas`/`CanvasText` faces and omit decoration. A runtime +without the required filter paint keeps solid structure, floor edge and +vertical panels but emits no ambient shadow; this does not enter the structural +fallback latch. + +### Vertical openings and display settings + +`src/iso-openings.ts` mirrors the existing opening-symbol transform algebra: +door has one jamb-hinged leaf, gate has two leaves with the established +0–10° exterior-face turn, and window has two light neutral casements. A saved +`passage` keeps the same full-height masonry cut but has zero leaves/panels. +Heights are fixed presentation ratios of +`ISO_WALL_HEIGHT`; there is no schema field. +The saved opening axis and Flat symbol remain on their canonical centreline. +Derived 2.5D door/gate leaves pivot on the selected physical host face so their +prisms do not start inside masonry; windows remain centred across the reveal. +`flip_v` selects/determines the physical face and opening direction without +changing saved coordinates. Jamb/cut depth remains physical and independent of +the Flat symbol. Panels are pointer- and ARIA-inert. Existing lock badges/cards +and HA actions remain the only interactive opening surface. + +- borders visible: vertical panels replace the floor-plane symbols; +- `hide_openings: true`: panels disappear, while masonry + cuts, Glow/sun and contact/lock meaning remain; +- `show_borders: false`: Stage 2 roots are absent and the established floor + symbols and Stage 1 projected frame return (subject to `hide_openings`), + avoiding floating panels or an invisible Stage 2 bound that reframes them; +- Flat, editors and `houseplan-space-card` retain their old symbols and DOM. + +Stage 2 adds no window beam, Glow source, sun renderer, material config, +network request or HA service path. Structural topology/projection exceptions +still use the Stage 1 latched Flat fallback. The known independent exact-SHA +view-toggle performance debt remains tracked in #124; #122 neither weakens its +budget nor treats fallback as benchmark success. + +## Stage 4 visual handoff (#570) — as documented before #679 + +> Historical: written while 2.5D was a hidden `hp_alpha` experiment. Since #649 +> it is public; activation is described in [Activation](#activation). + +Stage 4 refines the same hidden `iso` presentation behind `hp_alpha`; it adds no +public switch, configuration field or experiment id. Stage 3 history remains in +`docs/specs/160-isometric-stage3.md` and +`docs/adr/160-isometric-stage3-overlays.md`; the current acceptance contract is +the reviewed specification in issue #570 plus its revision-3 designer handoff. + +### Camera and low screen-facing overlays + +The camera is orthographic `rotDeg=0`, `tiltDeg=20`, with the same `[500,500]` +pivot and scale-aware 84-unit wall height. Floor SVG, wall/opening projection, +inverse hit mapping, invisible collision footprints and fit bounds share that +one affine authority. + +Device markers, room labels/cards and opening-lock badges keep their canonical +floor anchors but render on a low plane four visual units above the floor. +Devices and lock badges in the same room whose canonical reference-fit bounds +(expanded by 12 CSS px) connect form one rigid cluster. Every member receives +the same scene-space displacement, so pairwise vectors, rows and intervals are +the affine projection of the Flat layout rather than a per-marker fan toward a +room safe point. Room labels never enter a cluster and stay below interactive +roots. + +The reference-fit view, not the current live view, converts CSS safety values +into scene units. Wheel/button zoom, pinch and pan therefore transform an +already resolved scene and cannot invalidate placement. Structural changes — +stage/camera, walls, rooms, marker membership or canonical anchors — rebuild it +deterministically; viewport movement and HA-only state do not. One common +vector clears the exact wall silhouettes and already placed clusters within an +absolute 48 CSS-pixel reference-fit budget, using stable size/required-shift/ +kind-id order and boundary candidates instead of scanning the displacement +disk. The correction is runtime-only and is never written to configuration. + +If no completely legal common vector exists, the nearest deterministic result +keeps the cluster rigid and prioritises room ownership, then wall clearance, +then overlap with an earlier cluster. It never splits or shrinks a cluster; +residual overlap is an explicit degraded diagnostic. This supersedes the old +single-marker fallback while retaining the 48 px cap. The two full isometric +profiles keep the ordinary 150/60/75 ms resize/pan/state noise allowances +(#585, #651). +Fit probes reserve the maximum correction but do not execute live collision +search. There is no painted plate, long tether, ground dot or per-marker +shadow. The original screen-facing HTML root remains the only hit, focus, +tooltip and action target, and selection/hover cannot invalidate the placement +cache. Vacuum, Glow/spill, SUN, room fills/hover, arbitrary decor, +furniture/backdrop, stairs and every persisted coordinate remain on `z=0`. + +Room names remain screen-facing and lose stroke, text shadow, drop shadow and +halo. Iso uses `#303936` on a light presentation and `#f2f0e8` on a dark one; +contrast comes from colour and the bounded position correction, never an +outline. + +### Openings, occlusion and materials + +Door leaves turn by `50° × openingAmount`, paired window leaves by `65° × +openingAmount`, and gates retain their established 0–10° behaviour. The same +canonical flips/host face that drive the floor symbol determine hinges and +direction; missing, unknown or unavailable state keeps the existing static-plan +fallback. Opening geometry stays inert and lock actions stay on the existing +guarded lock badge/card. + +For wall height `H`, the fixed window frame spans `0.38H..1.00H`, its sash +`0.40H..0.98H`, and clear glass `0.45H..0.93H`; frame/sash rails are `0.05H`. +Frames and sill are neutral, glass side is `#c9e4f3` and its top face is +`#e3f2fa`. Fixed and live faces remain in `buildIsoWallDepthQueue()`. The slots +belonging to one opening are resolved by physical camera depth, so raised glass +covers the rear sill and rotating door/gate prism faces retain their physical +order without reordering unrelated walls or openings. Door/gate faces use +fill differences instead of strokes; window frame/glass borders remain. + +The constant material/filter set remains theme-aware and bounded. Only the +building ambient shadow remains; wall-contact and leaf shadows are omitted. +Forced colours or missing filter support remove texture and the ambient shadow, not geometry, +ownership or actions. `show_borders:false` is the exact no-volume branch: the +floor keeps the real 0°/20° affine matrix and interactive overlays return to +their floor anchors. `hide_openings` removes vertical decoration but preserves +cuts, Glow/SUN and lock semantics. + +The eight-entry structural LRU fingerprints the 0°/20°/84 profile, opening +policy revision 3 and structural algorithm 5. It excludes HA state, live +opening amount, hover/selection, theme, SUN and filter capability. The lazy +`iso-scene-render` graph is still not requested with alpha off; topology, +projection or module mismatch still enters the established fingerprint-latched +Flat fallback. Linux exact-SHA goldens and performance profiles remain the +canonical release evidence. + diff --git a/docs/testing-notes/README.md b/docs/testing-notes/README.md index 6c9becf2..5022001a 100644 --- a/docs/testing-notes/README.md +++ b/docs/testing-notes/README.md @@ -77,7 +77,7 @@ - [Decor composition order (#231)](decor-and-backdrop.md#decor-composition-order-231) - [Custom decor images (#51)](decor-and-backdrop.md#custom-decor-images-51) -- [Backdrop picture: move & scale (docs/BACKDROP.md, dev)](decor-and-backdrop.md#backdrop-picture-move--scale-docsbackdropmd-dev) +- [Backdrop picture: move & scale (docs/DECOR-EDITOR.md §3, dev)](decor-and-backdrop.md#backdrop-picture-move--scale-docsdecor-editormd-3-dev) - [«Already uploaded» plan picker (dev, unreleased)](decor-and-backdrop.md#already-uploaded-plan-picker-dev-unreleased) - [The furniture library (docs/FURNITURE.md, dev, unreleased)](decor-and-backdrop.md#the-furniture-library-docsfurnituremd-dev-unreleased) - [The furniture library (docs/FURNITURE.md, dev, unreleased)](decor-and-backdrop.md#the-furniture-library-docsfurnituremd-dev-unreleased-1) @@ -92,7 +92,7 @@ - [Vacuum trail smoothing (#209)](live-and-integrations.md#vacuum-trail-smoothing-209) - [Live vacuums (docs/VACUUM.md)](live-and-integrations.md#live-vacuums-docsvacuummd) - [Sun on the plan (docs/SUN.md)](live-and-integrations.md#sun-on-the-plan-docssunmd) -- [The text block on the plan (docs/LIVE-TEXT.md, dev, unreleased)](live-and-integrations.md#the-text-block-on-the-plan-docslive-textmd-dev-unreleased) +- [The text block on the plan (docs/DECOR-EDITOR.md §5, dev, unreleased)](live-and-integrations.md#the-text-block-on-the-plan-docsdecor-editormd-5-dev-unreleased) - [Sun ray rim (docs/SUN.md «The rim», dev, unreleased)](live-and-integrations.md#sun-ray-rim-docssunmd-the-rim-dev-unreleased) - [Coming back to the tab (docs/WARM-REMOUNT.md, dev, unreleased)](live-and-integrations.md#coming-back-to-the-tab-docswarm-remountmd-dev-unreleased) - [Реальная raw-карта Zigbee2MQTT (#450)](live-and-integrations.md#реальная-raw-карта-zigbee2mqtt-450) diff --git a/docs/testing-notes/decor-and-backdrop.md b/docs/testing-notes/decor-and-backdrop.md index 907f457b..f79643f6 100644 --- a/docs/testing-notes/decor-and-backdrop.md +++ b/docs/testing-notes/decor-and-backdrop.md @@ -54,7 +54,7 @@ `decor-assets.test.mjs`, `test_decor_assets.py`, `test_ha_import_export.py`]. -## Backdrop picture: move & scale (docs/BACKDROP.md, dev) +## Backdrop picture: move & scale (docs/DECOR-EDITOR.md §3, dev) - [ ] **The frame is there, and only there** (owner 2026-08-04): open a space that HAS an uploaded plan image → **Редактор подложки**. It opens on diff --git a/docs/testing-notes/live-and-integrations.md b/docs/testing-notes/live-and-integrations.md index c05775d8..4f590e0c 100644 --- a/docs/testing-notes/live-and-integrations.md +++ b/docs/testing-notes/live-and-integrations.md @@ -249,7 +249,7 @@ - [ ] Smoke: `node demo/smoke_sun.mjs`; units: `test/sun.test.mjs`; backend: `tests_backend/test_validation.py` (sun settings) -## The text block on the plan (docs/LIVE-TEXT.md, dev, unreleased) +## The text block on the plan (docs/DECOR-EDITOR.md §5, dev, unreleased) - [ ] **One text, many HA variables**: write `Бак {sensor.tank}, зал {climate.hall:current_temperature}` — both values render and update diff --git a/src/houseplan-card.ts b/src/houseplan-card.ts index 715cd236..9e582269 100755 --- a/src/houseplan-card.ts +++ b/src/houseplan-card.ts @@ -887,7 +887,7 @@ export class HouseplanCard extends LitElement { * command for an explicitly selected object. */ private _decorEraseConfirm: { id: string; kind: DecorShape['kind'] } | null = null; /** The text dialog. Live references are part of `text`; `pickerEntity` is - * only transient UI state and is never persisted (docs/LIVE-TEXT.md). */ + * only transient UI state and is never persisted (docs/DECOR-EDITOR.md §5). */ private _decorTextDialog: { id?: string; x: number; y: number; text: string; color: string; opacity: number; angle: string; sizeCm: number; @@ -953,7 +953,7 @@ export class HouseplanCard extends LitElement { moved: boolean; } | null = null; /** - * The live backdrop gesture (docs/BACKDROP.md §2): moving the picture by its + * The live backdrop gesture (docs/DECOR-EDITOR.md §3.2): moving the picture by its * body, scaling it by a corner handle or rotating it by the upper handle. * `base` is the untransformed, centred rectangle the transform is measured * from, so a gesture never accumulates rounding of its own. @@ -3782,7 +3782,7 @@ export class HouseplanCard extends LitElement { s += (x.id || '') + ',' + (x.plan_aspect || '') + ',' + (x.plan_url || '').length + ',' // the backdrop transform is geometry: without it in the key a drag of // the picture would leave the memoized model (and the content frame - // built from it) showing the old rectangle (docs/BACKDROP.md §5) + // built from it) showing the old rectangle (docs/DECOR-EDITOR.md §3.2) + (x.plan_x ?? '') + ',' + (x.plan_y ?? '') + ',' + (x.plan_scale ?? '') + ',' + (x.plan_scale_x ?? '') + ',' + (x.plan_scale_y ?? '') + ',' + (x.plan_angle ?? '') + ',' + (x.rooms?.length || 0) + ',' + (x.openings?.length || 0) + ',' + (x.decor?.length || 0) + ';'; @@ -5913,7 +5913,7 @@ export class HouseplanCard extends LitElement { // far away in the Plan editor kept View framing the empty ground it left // behind, until some unrelated model change happened to invalidate memo. const grow = this._mode !== 'view'; - // A LIVE BACKDROP GESTURE FREEZES THE FRAME (docs/BACKDROP.md §2). The + // A LIVE BACKDROP GESTURE FREEZES THE FRAME (docs/DECOR-EDITOR.md §3.2). The // picture is a content item, so dragging it grows the frame — which // rescales the view, which changes how many plan units a screen pixel is // worth, mid-gesture: the picture then runs away from the finger and no @@ -8172,7 +8172,7 @@ export class HouseplanCard extends LitElement { } // ---- common decor transform controller ---- - // The mechanics are the backdrop frame's (docs/BACKDROP.md), reused + // The mechanics are the backdrop frame's (docs/DECOR-EDITOR.md §3), reused // rather than reinvented: chrome that never takes a pointer, finger-sized // handles that always do, the gesture written live into the config and // PERSISTED only if something actually moved. What differs is the pivot — @@ -8229,7 +8229,7 @@ export class HouseplanCard extends LitElement { return this._editorRuntimeOrThrow()._confirmDecorErase(); } - // ============ backdrop transform frame (docs/BACKDROP.md) ============ + // ============ backdrop transform frame (docs/DECOR-EDITOR.md §3) ============ /** The centred, UNTRANSFORMED rectangle of the current backdrop image. */ private get _bdBase(): Rect | null { @@ -8346,7 +8346,7 @@ export class HouseplanCard extends LitElement { /** * The common selected-object frame: line endpoints, or a dashed outline, * four corner handles and one rotation handle. Same - * mechanics and same handle size as the backdrop frame (docs/BACKDROP.md + * mechanics and same handle size as the backdrop frame (docs/DECOR-EDITOR.md §3 * §2) — finger-sized in SCREEN terms, so it stays grabbable at any zoom — * and it rides the block's own rotation, so the corners stay at the corners. */ @@ -8530,7 +8530,7 @@ export class HouseplanCard extends LitElement { // The label is painted from the LIVE value on every render — the same // `hass` the rest of the card reads, no polling of its own. Without an // entity `liveText` gives the stored text back byte-for-byte, so a - // plain label is the plain label it always was (docs/LIVE-TEXT.md). + // plain label is the plain label it always was (docs/DECOR-EDITOR.md §5). const fs = this._decorTextUnits(sh); const frozenText = this._renderDeviceSnapshot?.facts.get(`decor:${this._space}:${sh.id}`); const lines = decorTextLines(typeof frozenText === 'string' ? frozenText : liveText( @@ -10897,7 +10897,7 @@ export class HouseplanCard extends LitElement { preserveAspectRatio="xMidYMid meet"> - ${''/* THE PAPER IS THE ROOMS (docs/BACKDROP.md §3, owner + ${''/* THE PAPER IS THE ROOMS (docs/DECOR-EDITOR.md §3.3, owner 2026-08-04). Opaque shapes stop the scene background — bg_color or the day-cycle environment — from bleeding through the plan. They follow the ROOM CONTOURS and nothing else: one @@ -11119,7 +11119,7 @@ export class HouseplanCard extends LitElement { ${this._markup && this._tool === 'resize' ? this._renderResizeLayer(view) : nothing} ${''/* editor chrome, not plan content: the backdrop frame sits on top of everything the plan draws so its handles stay - grabbable (docs/BACKDROP.md §2). It exists only in the + grabbable (docs/DECOR-EDITOR.md §3.2). It exists only in the backdrop editor, where rooms and devices are pointer-inert. */} ${this._renderBackdropFrame(view)} ${this._renderTextFrame(view)} diff --git a/src/houseplan-editor-runtime.ts b/src/houseplan-editor-runtime.ts index 15e9c29c..30346d82 100644 --- a/src/houseplan-editor-runtime.ts +++ b/src/houseplan-editor-runtime.ts @@ -3730,7 +3730,7 @@ public _decorPointerDown(ev: PointerEvent): boolean { // between letters appear to erase only the selection outline. if (t === 'select') this.host._decorSel = null; // …and under its own tool the picture is grabbable by its body - // (docs/BACKDROP.md §2). Only INSIDE the image rect: press beside the + // (docs/DECOR-EDITOR.md §3.2). Only INSIDE the image rect: press beside the // picture and the plane still pans with one finger. if (this.host._bdMovable) { const r = this.host._bdRect!; @@ -4450,7 +4450,7 @@ public _furnPlace(raw: number[], free = false, pointerType = 'mouse'): void { ...decorStylePatch(this.host._decorStyle, false), }; // a straight piece stores no angle at all, exactly as a straight label - // stores none (docs/LIVE-TEXT.md §3) + // stores none (docs/DECOR-EDITOR.md §5.3) if (placement.angle) shape.angle = placement.angle; sp.decor = [...this.host._decorList, shape]; this.host._decorSel = id; @@ -4797,7 +4797,7 @@ public _bdUp(): void { public _renderBackdropFrame(view: { x: number; y: number; w: number; h: number }): TemplateResult | typeof nothing { const r = this.host._bdRect; if (!this.host._bdActive || !r) return nothing; - // Two radii, one gesture — the split the text frame uses (docs/LIVE-TEXT.md + // Two radii, one gesture — the split the text frame uses (docs/DECOR-EDITOR.md §5 // §3) and, since 2026-08-05, every corner handle in the card. `hr` is the // HIT radius: a fraction of the visible view, so the target stays // finger-sized at any zoom. `kr` is what you SEE — a quarter of it, because @@ -5066,7 +5066,7 @@ public _renderEditorSecondary(): TemplateResult | typeof nothing { public _renderDecorBar(): TemplateResult { const tools = [ ['select', 'mdi:cursor-default-outline', 'decor.select'], - // moving the picture is a TOOL (docs/BACKDROP.md §2) — offered only when + // moving the picture is a TOOL (docs/DECOR-EDITOR.md §3.2) — offered only when // there IS a picture, so a hand-drawn space's bar is unchanged ...(this.host._bdRect ? [['backdrop', 'mdi:image-move', 'decor.backdrop'] as const] : []), ['line', 'mdi:vector-line', 'decor.line'], @@ -8147,7 +8147,7 @@ public async _saveSpaceDialog(): Promise { // (the uploaded file stays on disk; only the reference is cleared). // Its transform goes with it — there is nothing left for plan_x/plan_y/ // plan_scale to describe, and a stale one would silently apply to the - // NEXT picture uploaded here (docs/BACKDROP.md §1). + // NEXT picture uploaded here (docs/DECOR-EDITOR.md §3 §1). if (d.source === 'draw') { sp.plan_url = null; sp.plan_aspect = null; delete sp.plan_x; delete sp.plan_y; delete sp.plan_scale; diff --git a/src/logic.ts b/src/logic.ts index 65f1601f..8dfec543 100644 --- a/src/logic.ts +++ b/src/logic.ts @@ -979,7 +979,7 @@ export function floorsOf(hass: any): FloorInfo[] { return list; } -// ---------------- live text on a decor label (docs/LIVE-TEXT.md) ------------- +// ---------------- live text on a decor label (docs/DECOR-EDITOR.md §5) ------------- /** What a dead sensor says. A label that vanishes with its entity is worse * than one that admits it has no data. */ @@ -1149,7 +1149,7 @@ export function valueWithUnit(v: HassValue, own: string, explicit?: string | nul /** * The live value of a linked label, unit included — formatted the way HOME - * ASSISTANT formats it (docs/LIVE-TEXT.md §2.1, docs/STYLING-HOOKS.md §6). + * ASSISTANT formats it (docs/DECOR-EDITOR.md §5.2, docs/STYLING-HOOKS.md §6). * * We still write no rounding logic of our own: the value goes through * `hassValue`, which hands it to HA's formatter, so `display_precision`, the diff --git a/src/space-geometry.ts b/src/space-geometry.ts index 4f61ad4b..d591870b 100644 --- a/src/space-geometry.ts +++ b/src/space-geometry.ts @@ -56,12 +56,12 @@ export function fitInSquare(ratio: number | null | undefined, side: number) { return { x: (side - w) / 2, y: (side - h) / 2, w, h }; } -/** Per-axis backdrop scale bounds — mirrors validation.py (docs/BACKDROP.md). */ +/** Per-axis backdrop scale bounds — mirrors validation.py (docs/DECOR-EDITOR.md §3). */ export const PLAN_SCALE_MIN = 0.01; export const PLAN_SCALE_MAX = 100; /** - * WHERE THE BACKDROP IMAGE SITS (docs/BACKDROP.md). + * WHERE THE BACKDROP IMAGE SITS (docs/DECOR-EDITOR.md §3). * * `fitInSquare` is only the DEFAULT placement: the image centred in the square * canvas at its own proportions. On top of it a space may carry an optional @@ -330,7 +330,7 @@ export function contentItems( if (item) out.push(item); } // The backdrop image is ONE OF the objects of the space, exactly like a room - // (docs/BACKDROP.md §4): cropping to the outlined rooms would hide the parts + // (docs/DECOR-EDITOR.md §3 §4): cropping to the outlined rooms would hide the parts // of the picture nobody has drawn over yet, and — since v1.58.0 — the // rectangle here is the MOVED and SCALED one, so «Вписать всё» follows the // picture wherever the owner has dragged it. diff --git a/src/space-render.ts b/src/space-render.ts index 30ff2979..efb6180f 100644 --- a/src/space-render.ts +++ b/src/space-render.ts @@ -666,7 +666,7 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null { ? resolveDayCycle(planHass, o.dayCycleNow ?? new Date()) : null; const stageBg = stageBgOf(o.cfg?.settings, disp); - // Opaque plan paper, same contract as the full card (docs/BACKDROP.md §3): + // Opaque plan paper, same contract as the full card (docs/DECOR-EDITOR.md §3.3): // the paper is ALWAYS the ROOM CONTOURS and only them — never their bounding // box, and (since v1.58.0) never the backdrop image rect either. The scene // colour therefore reaches the exterior walls of an L-shaped house, fills the diff --git a/src/styles/plan.styles.ts b/src/styles/plan.styles.ts index d7fa95fb..c233e38d 100644 --- a/src/styles/plan.styles.ts +++ b/src/styles/plan.styles.ts @@ -888,7 +888,7 @@ export const planStyles = css` outline: 2px solid #26a69a; outline-offset: -2px; } - /* backdrop transform frame (docs/BACKDROP.md §2). Editor chrome: the + /* backdrop transform frame (docs/DECOR-EDITOR.md §3.2). Editor chrome: the outline never takes a pointer, the four corner handles do — and they are finger-sized (r = 2 % of the visible view), because this is dragged on a tablet as often as with a mouse. */ diff --git a/test/backdrop.test.mjs b/test/backdrop.test.mjs index a90c23b7..e9061322 100644 --- a/test/backdrop.test.mjs +++ b/test/backdrop.test.mjs @@ -1,5 +1,5 @@ /** - * The backdrop transform and the paper contract (docs/BACKDROP.md). + * The backdrop transform and the paper contract (docs/DECOR-EDITOR.md §3). * * Two questions only, and both are pure geometry: * 1. where the picture ends up once it has been moved and scaled, and @@ -86,7 +86,7 @@ test('the content frame follows the picture (Вписать всё never loses i assert.ok(sb.w < 400, `a quarter-scale picture frames tight, got ${sb.w}`); }); -test('the paper is the ROOMS, image or no image (docs/BACKDROP.md §3)', () => { +test('the paper is the ROOMS, image or no image (docs/DECOR-EDITOR.md §3.3)', () => { // paperRoomShapes knows nothing about plan_url — which is the whole point: // there is no longer a branch where the picture makes paper of its own. const rooms = [{ id: 'r', name: 'r', poly: [[100, 100], [500, 100], [500, 500], [100, 500]] }]; diff --git a/test/logic.test.mjs b/test/logic.test.mjs index 8e258975..1cd42931 100644 --- a/test/logic.test.mjs +++ b/test/logic.test.mjs @@ -1341,7 +1341,7 @@ test('openingShoulders: angled wall measures along the wall direction', () => { }); -// ---------------- live text on a decor label (docs/LIVE-TEXT.md) ------------ +// ---------------- live text on a decor label (docs/DECOR-EDITOR.md §5) ------------ const hassLive = { states: { diff --git a/tests_backend/test_validation.py b/tests_backend/test_validation.py index 29aad979..bf2a6c6c 100644 --- a/tests_backend/test_validation.py +++ b/tests_backend/test_validation.py @@ -255,7 +255,7 @@ def test_space_schema_drops_the_old_aspect_and_bounds_the_image_ratio(): def test_space_backdrop_transform_is_optional_and_bounded(): - """plan_x / plan_y / plan_scale — the backdrop placement (docs/BACKDROP.md). + """plan_x / plan_y / plan_scale — the backdrop placement (docs/DECOR-EDITOR.md §3). Optional to the letter: a space that has never been through the backdrop editor validates exactly as it did before, and the keys stay absent (there @@ -1782,7 +1782,7 @@ class TestVacuum: def test_decor_text_live_fields(): - """docs/LIVE-TEXT.md: new references live in `text`; the bounded legacy + """docs/DECOR-EDITOR.md §5: new references live in `text`; the bounded legacy entity/attribute/unit fields remain valid until an old label is edited.""" base = {"id": "s1", "title": "S", "view_box": [0, 0, 1, 1], "rooms": []} txt = {"id": "d", "kind": "text", "x": 0.5, "y": 0.5,