Корпус геометрий, household-сценарии и аудит доступности View

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

Issue: #560
User-Visible: no
This commit is contained in:
Codex
2026-09-13 21:05:28 +03:00
parent a03ccd65a5
commit db7eef5306
11 changed files with 2750 additions and 0 deletions
+201
View File
@@ -0,0 +1,201 @@
# Полевая проверка 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; свидетель к
каждой приедет вместе с правкой, а не поселится здесь зелёной проверкой,
узаконивающей дефект.