Files
houseplan-card/docs/QUALITY-560.md
T
Codex db7eef5306 Корпус геометрий, household-сценарии и аудит доступности View
Полевая проверка основного View по #560: шесть обезличенных планов корпуса и
цепочка import → Optimize → Optimize → Resize → сохранение с численными
оракулами, семь household-путей с независимыми оракулами в двух ширинах
карточки, аудит доступности с измеренными числами. Продуктовых правок нет:
находки заведены отдельно (#564, #565, #566).

Issue: #560
User-Visible: no
2026-09-13 21:05:28 +03:00

202 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Полевая проверка View, аудит доступности и геометрический корпус (#560)
Дерево проверки: `dev` на 13.09.2026 (после #556). Всё, помеченное
«проверено исполнением», снято прогоном на этом дереве; всё остальное
помечено как чтение или арифметика.
## 0. Что было и чего не было
Было: чтение реальной установки владельца (только чтение, по его прямому
разрешению), синтетический стенд `demo/` в двух ширинах, узловые тесты модели.
**Не было, и это не оговорка задним числом:** добровольцев кроме владельца,
физического Companion на телефоне/планшете, записи в реальную установку,
проверки со скринридером и с симуляцией цветовой слепоты. Пункты, которые без
этого не проверяются, названы ниже гипотезами, а не находками.
## 1. Профиль реального плана (чтение, 13.09.2026)
Модель v10, `rev` 2740, интеграция 1.76.0-beta.1.
| что | сколько |
|---|---|
| пространств / комнат | 5 / 37 |
| записей толщины / сегментов стен | 121 / — |
| проёмов / перегородок / колонн | 35 / 7 / 2 |
| маркеров | 156 |
| шаг сетки по пространствам | 5, 1, 30, 1, 1 см |
| толщины стен в одном пространстве | 20/22/28/29/33 · 20/30/40 · 4/15/17/33 · 10/15 |
| минимальное ребро комнаты | 5 см (шаг 1 см) и 10 см (шаг 5 см) |
| T-узлов по пространствам | 7 / 6 / 3 / 2 / 0 |
| X-узлов (степень ≥ 4) | **ни одного** |
| координат всего / с шумом ближе 1e-4 шага | 1010 / 23 |
| почти осевых стен (`classifyNearAxisSegment`) | 0 |
| проёмов, чей край лежит ровно в вершине комнаты | 1 |
| записей `layout` на УДАЛЁННЫЕ пространства | 4 (на 2 пространства) |
| ссылок на сущности HA в маркерах и проёмах | 42 |
| из проверенных 25 ссылок не существует в HA | 1 |
Инварианты модели на этом конфиге чистые: `node scripts/model-invariants.mjs`
сообщает «ссылки разрешимы, записи толщины находятся».
**Геометрия владельца в репозиторий не попала.** Фикстуры корпуса
синтетические и воспроизводят перечисленные условия числами. Это и есть
обезличивание: в репозитории нет ни плана дома, ни имён комнат, ни
идентификаторов сущностей.
## 2. Household-сценарии
Стенд: `demo/smoke_household_journeys.mjs`, штатная ширина карточки 780 px
(настенный планшет) и 390 px (узкая колонка телефона). У каждого пути назван
независимый оракул — не «класс появился», а вызов сервиса, доступное имя или
содержимое модели.
| путь | вход | оракул | результат |
|---|---|---|---|
| J1 «экран проснулся» | вкладка, не грузившая редакторский чанк | `_booting === false`, `.stage` видима, комнат 4, устройств 10 | пройден |
| J2 «где протечка» | датчик протечки уходит в `on` | доступное имя называет состояние (`Sink leak sensor, Alarm, …`), `data-state="alarm"` | пройден, см. F4 |
| J3 «сколько в спальне» | температурный маркер со значением | значение попадает в `aria-label` (`Temperature: 22.4°`), 7 из 10 маркеров несут число в имени | пройден |
| J4 «выключить свет с клавиатуры» | Tab до маркера, затем Enter | вызван `light.turn_off`; кольцо фокуса `#0C82F0` против `transparent` у остальных | пройден; до первого маркера 15 остановок Tab |
| J5 «переключить этаж» | клик по вкладке пространства | `_space` сменился, набор комнат перерисован, активная вкладка соответствует пространству | пройден, см. F3 |
| J6 «вернулся после обновления» | конфиг приходит заново | выбранное пространство сохранено, план и устройства нарисованы | пройден |
| J7 «нажать нужный светильник» | пять маркеров с шагом 0,0333 — самая плотная расстановка из поля | в центре ВИДИМОГО маркера нажатие достаётся ему же | 780 px — пройден; **390 px — не пройден, F1** |
## 3. Аудит доступности текущего View (проверено исполнением)
Что уже сделано правильно и должно остаться:
- маркер устройства с действием — `role="button"`, `tabindex="0"`, осмысленное
`aria-label` с названием, состоянием и значением;
- Enter и Space на маркере действительно вызывают действие (`_keyDevice` →
`_clickDevice` → `light.turn_off` в прогоне);
- фокус видим: `:focus-visible` красит кольцо `#0C82F0` (у остальных
`transparent`), скриншот вокруг маркера меняется;
- зона нажатия увеличена сознательно: `.dev::before` — круг
`max(44px, --device-shell-size)`, то есть 44 px при любом размере маркера.
Это выше минимума WCAG 2.5.8 (24 px) — и именно это порождает F1 при
плотной расстановке.
Измеренные проблемы — F1…F5 ниже.
## 4. Геометрический корпус
`test/fixtures/560-corpus/` — шесть планов; `test/geometry-corpus.test.mjs` —
26 проверок; `demo/smoke_geometry_corpus.mjs` — браузерная половина.
| фикстура | условие | откуда взято |
|---|---|---|
| `c1-short-edges` | уступ в два шага (10 см при шаге 5 см) | минимальное ребро поля |
| `c2-t-junction-thicknesses` | T-узел, где сходятся три разные толщины | 7 T-узлов и 5 толщин в поле |
| `c3-x-junction` | четыре комнаты в одной точке | **синтетика: в поле X-узлов нет** |
| `c4-opening-at-vertex` | край проёма ровно в вершине комнаты | один такой проём в поле |
| `c5-node-noise-near-axis` | шум у узла плюс честно наклонная стена | 23 шумных координаты в поле |
| `c6-stale-layout` | `layout` на удалённое пространство | 4 таких записи в поле |
Цепочка на каждом плане: импорт → инварианты → Optimize → инварианты →
Optimize → Resize → Optimize → сохранение и перечитывание. Оракулы численные:
- нарушений инвариантов ровно столько, сколько заявлено (для c1–c5 — ноль);
- Optimize не меняет вход (immutability, как в #281);
- приведённый план Optimize не трогает (`changed === false`), а второй проход
не двигает ничего никогда — фикс-пойнт #477;
- записи толщины целы (`checkWallRecordsPreserved`);
- ключи записей канонические (`wallKey` пересчитывается независимо);
- **наклонная стена не выпрямляется**: `classifyNearAxisSegment` не относит
наклон 1 шаг на 60 к почти осевым, и Optimize оставляет её как есть. Это
прямая проверка приёмки «корректная планировка не исправляется ради удобства
рендера»; шум у узла, наоборот, снимается — и это правильно.
Рендер эталоном не является: браузерная половина проверяет присутствие и число
узлов (комнаты, проём, заливка стен), попадание геометрии в `viewBox` и
сохранность набора толщин после ШТАТНОГО ресайза — то, что чистыми функциями
не проверяется, потому что перенос записей толщины делает хост карточки
(`project()` в `ResizeController.move`), а не `resize.ts`.
**Граница узловой половины названа прямо:** вход её шага Resize — «рёбра
сдвинуты, записи толщины на прежних координатах», состояние, которого штатный
writer не создаёт. Поэтому там проверяется сходимость Optimize за ≤ 2 прохода,
а не «за один».
## 5. Находки
### F1 (High) — в узкой колонке нажатие достаётся соседнему маркеру
Проверено исполнением на осевшей геометрии (замер повторяется до двух
одинаковых подряд — правило #533).
При ширине карточки 390 px маркеры имеют размер 10,84 px, а расстояние между
центрами при самой плотной расстановке поля (шаг 0,0333) — 11,8 px. Зона
нажатия каждого маркера — круг 44 px (`.dev::before`, `pointer-events: auto`,
`z-index: 3`), то есть ±22 px от центра. Круг соседа накрывает центр маркера
целиком, и выигрывает тот, кто выше по порядку.
Что измерено: центр `dense1` (203; 461,08) принадлежит `dense2`
(`elementFromPoint` и реальный `page.mouse.click` — обоими). Для пяти маркеров
владельцы центров: `dense2, dense3, dense4, dense5, dense5`. То есть **ни один
из первых четырёх маркеров нельзя нажать в его собственном центре**.
Порог: расхождение начинается, когда расстояние между центрами меньше 22 px
(половина фиксированного круга). На 780 px тот же план даёт 23,6 px между
центрами — проверка проходит, но запас 1,6 px.
Почему это важно: J3 «безопасно действовать» — на телефоне пользователь
включает не тот свет, и никакого признака ошибки нет.
### F2 (Medium) — подписи комнат: контраст 2,01:1 при норме 4,5:1
Тёмная тема по умолчанию: цвет подписи `rgb(85,96,108)` на фоне стейджа
`rgb(28,37,48)`, `opacity: 0.8`, размер 12,05 px. Контраст по чистому цвету
2,41:1, с учётом смешивания полупрозрачности — **2,01:1**. WCAG 1.4.3 (AA) для
такого размера требует 4,5:1.
Механизм: цвет подписи — это цвет комнаты (`disp.color`), а прозрачность —
`min(1, room_opacity + 0.25)`. Отдельного токена для текста плана нет, поэтому
подпись нельзя сделать читаемой, не перекрасив сами комнаты.
### F3 (Medium) — активная вкладка этажа не имеет программного состояния
`<button class="tab active">` не несёт ни `aria-current`, ни `aria-pressed`,
ни `aria-selected` (проверено чтением атрибутов в прогоне: все три `null`), а
полоса вкладок — обычный `div`, не `tablist`. Какой этаж открыт, сообщается
только классом, то есть видом. WCAG 4.1.2.
### F4 (Low) — состояние в доступном имени повторяется дважды
`Sink leak sensor, Alarm, Alarm, LQI 117, medium signal` — слово состояния
попадает в имя из двух источников. Скринридер произносит его дважды.
### F5 (Low) — подсказка с названием и метриками не показывается при фокусе
Один и тот же узел подсказки: при `pointerover` — `display: block` и текст
«Ceiling light / Smart Bulb E27», при клавиатурном фокусе — `display: none`.
Данные не теряются для скринридера (они есть в `aria-label`), но зрячий
пользователь клавиатуры их не получает.
### F6 (Low) — записи `layout` на удалённые пространства переживают Optimize
Проверено на `c6-stale-layout`: Optimize видит их (`positionsUnresolved: 2`),
но оставляет, и `npm run invariants` на экспорте пользователя сообщает о них
как о нарушениях. В поле таких записей четыре на два удалённых пространства.
Вреда пользователю нет; цена — ложный сигнал при будущем разборе.
## 6. Гипотезы UX (не находки, проверять с людьми)
- **U1.** Проём, чей `contact` удалён из HA, рисуется ОТКРЫТЫМ:
`openingAmount` при отсутствующем состоянии возвращает 1 для дверей и
калиток (для окон 0). Это отказ в безопасную сторону, но признака «состояние
неизвестно» нет. В поле ровно один такой случай: калитка ссылается на
`binary_sensor`, которого в HA больше не существует (404) — то есть на плане
владельца калитка нарисована открытой постоянно.
- **U2.** До первого маркера на плане 15 остановок Tab: сначала весь хром.
- **U3.** Состояние маркера зрительно передаётся заливкой (`#F0A00C` против
светлой/тёмной) при одинаковой иконке и размере. Светимости различаются
сильно, поэтому нарушения 1.4.1 здесь скорее нет, но без проверки с
симуляцией цветовой слепоты это гипотеза.
## 7. Что заведено отдельно
Продуктовые правки в этой задаче не делались — она research/tests-only.
F1, F2–F5 и F6 заведены отдельными issue со ссылкой на #560; свидетель к
каждой приедет вместе с правкой, а не поселится здесь зелёной проверкой,
узаконивающей дефект.