Files
houseplan-card/docs/reviews/CODE-REVIEW-582-r1.md
T
2026-09-15 12:17:27 +03:00

283 lines
27 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.
# CODE-REVIEW-582-r1
Issue: [#582](https://github.com/Matysh/houseplan-card/issues/582) · этап: code · заход r1 ·
блокирующих циклов израсходовано 0 из 4 · материал: `0a6592f4112fd7ceef68cbd3be7c44eeb8a7ef3e`
(`origin/dev` = `bae6afaae7bac2dd45bca52b6caf64a16d96f182`, `origin/dev..HEAD` = `b9bf3daa`
(документ ревью ТЗ) + `0a6592f4` (сама правка)).
## Скоуп
ТЗ (в теле issue) чинит воспроизводимый в HA Companion дефект: во время pinch-zoom
пространства с фоном «Следует за Солнцем» появляются фиксированные белые/прозрачные
области, вызванные тем, что постоянный `will-change: filter` из #532 висит на
внутренней группе `.hp-paperg`, чей локальный SVG-bbox при `cell_cm: 1` может достигать
тысяч единиц — Chromium промотирует слой пропорционально этому координатному размеру, а
не экранному. Контракт (п.1–7) требует: сохранить визуальный контур и его перфоманс-выигрыш
из #532, но привязать промотированный слой к экранному viewport независимо от `cell_cm`, и
сделать то же самое для `houseplan-space-card`. AC1–AC9 в теле issue.
Это основная View-поверхность (J1) и touch-контракт View/kiosk (`docs/TOUCH-SUPPORT.md`,
release-blocking), так что вопрос "работает ли оно на самом деле" здесь не формальность.
## Материал и что сделано в коде
- Новый общий рендерер `src/render/paper-scene.ts` (`renderPaperShapes`) — заменяет три
дублированные ветки `path/poly/rect` в `houseplan-card.ts` и `space-render.ts`.
- В `houseplan-card.ts` и `space-render.ts` добавлен ОТДЕЛЬНЫЙ `<svg class="hp-paper-outline-svg">`
сосед перед основным `plan-svg`/`hp-static-plan-svg`, содержащий копию бумажных фигур;
он использует тот же `viewBox`/`data-hp-live-viewbox`, что и основной план, и тот же
`isoFloorMatrixCss()` в изометрии.
- В `plan.styles.ts` тройной `drop-shadow` и `will-change: filter` перенесены с
`.hp-paperg` на `.hp-paper-outline-svg` (стейдж-размерный корневой `<svg>`, растянутый
правилом `.zoomwrap > svg { width:100%; height:100% }`), тем самым слой теперь
действительно ограничен экранными пикселями, а не локальными SVG-единицами.
- `space-card.ts` получил такое же правило для `.hp-static-stage .hp-paper-outline-svg`.
- Новый браузерный смок `demo/smoke_daycycle_layer_budget.mjs` (CDP LayerTree + screencast)
— свидетель AC1/AC3. Новый мутант `daycycle-outline-promoted-on-inner-paper` (guard —
этот смок) — свидетель AC2. `smoke_daycycle_raster.mjs`/`smoke_bg_color.mjs` обновлены
под новые имена классов — свидетели AC4/AC5. Новый `test/paper-scene-contract.test.mjs`
— свидетель AC7. `scripts/smoke-links.mjs` регистрирует связь `renderPaperShapes` → 4
смока. `scripts/bundle-budget.mjs` подвинул потолок на 200 Б с объяснением. Оба changelog
и `docs/ARCHITECTURE.md`/`docs/SUN.md`/`docs/TOUCH-SUPPORT.md`/`docs/TESTING.md` обновлены
согласованно с кодом. Трейлеры коммита `Issue: #582` / `User-Visible: yes` на месте.
## Как проверялось
Дешёвые гейты подтверждены зелёным Validate на точном SHA
(https://github.com/Matysh/houseplan-card/actions/runs/34940487324, `workflow_dispatch`,
без `heavy`): предполёт, классификация, «Фронтенд: типы/юниты/бандл», 6 шардов
«Мутанты по диффу» — всё `success`. Тяжёлые джобы (`golden`, `smoke`, `performance_smoke`,
`hacs`, `hassfest`, `geometry_parity`, `backend`) в этом прогоне **skipped** — не PR,
не `Release:`, не `full=true`, поэтому они не гейтили этот пуш. Именно эти пропущенные
джобы оказались важны (см. находки).
Я лично прогнал (то, что Validate не покрыл, и что явно относится к диффу):
| Гейт | Результат | Почему прогнан/не прогнан |
|---|---|---|
| `npm run bundle:sync` (typecheck+build+rollup+bundle-tree) | зелёный, `git status` после — чисто | дёшево, диф трогает `src/**` |
| `node demo/smoke_daycycle_layer_budget.mjs` (AC1/AC3) | **зелёный**, 4 прогона подряд | прямое совпадение с диффом |
| `node demo/smoke_bg_color.mjs` | **зелёный** (все 47 проверок, включая изменённую `staticCardLayersStayOrdered`) | зарегистрированная связь, CI её не гонял (heavy=false) |
| `node demo/smoke_live_pan_coverage.mjs` | **зелёный** | зарегистрированная связь, CI её не гонял |
| `node demo/smoke_daycycle_raster.mjs` (AC4, свидетель #532) | **красный, воспроизводимо 8/8** (ratio 2.16–2.50 против потолка 2.0); на `origin/dev` тот же смок даёт 1.01–1.14 в 7/7 запусках в ЭТОМ ЖЕ окружении | прямое совпадение с диффом; CI отчитался зелёным на этом SHA, но локальный результат детерминирован и объяснён (см. Находка №1) |
| `npm run golden:verify` (AC6, полная матрица) | **4 из 4 day-cycle сцен = `different`**, остальные ~150 сцен `passed` | diff меняет рендер; CI `golden` был skipped на этом пуше, так что это первый реальный прогон на данном SHA |
| `npm run inventory` | не гонял | не требуется для этого раунда |
| `python -m pytest tests_backend -q`, HACS, Hassfest, geometry parity | не гонял | диф не трогает `custom_components/**/*.py`, манифесты или геометрию решётки/толщины стен |
| `performance_smoke` (полный) | не гонял | AC4 уже красный на более дешёвом целевом свидетеле; полный перф-профиль не добавит новой информации до фикса |
Плюс диагностика причины (CDP `LayerTree` + `compositingReasons`, одна и та же гарнитура
`demo/serve.mjs`, один и тот же демо-фикстур 1400×900, сравнение `origin/dev` vs `HEAD`
бок о бок в одном окружении) — вынесена в Находку №1.
## Находки
### Находка №1 (High) — AC4 не держится: день-цикл при pan рерастеризует весь план, не только контур
**Файлы:** `src/houseplan-card.ts:11597-11603`, `src/styles/plan.styles.ts:97-105`,
`src/space-render.ts` (тот же паттерн для static card).
**Механизм.** Раньше `will-change: filter` стоял на `.hp-paperg` — `<g>` ВНУТРИ
`plan-svg`, с малым собственным bbox. `plan-svg` целиком в свой отдельный
compositor-слой не попадал (в baseline `origin/dev` слой `plan-svg` в CDP `LayerTree`
вообще не значится: контент рисуется в общей поверхности). Патч кладёт контур в
ОТДЕЛЬНЫЙ `<svg class="hp-paper-outline-svg">`-СОСЕД перед `plan-svg` в том же
`.zoomwrap`. Chromium корректно ограничивает промотированный слой контура экранными
пикселями (это и было целью, AC1/AC2/AC3 — подтверждены), но побочный эффект: теперь
`plan-svg` (ВЕСЬ видимый план — стены, комнаты, устройства, декор) визуально
перекрывает промотированный слой-соседа и по правилам Chromium сам получает
отдельный compositor-слой с причиной `"Overlaps other composited content"`
(проверено CDP `LayerTree.compositingReasons` на обоих деревьях, один и тот же демо-стенд,
1400×900):
```
origin/dev (до патча): hp-paperg 920×720 (will-change: filter); НЕТ отдельного слоя plan-svg
0a6592f4 (после патча): hp-paper-outline-svg 780×724 (will-change: filter);
plan-svg 780×724 ("Overlaps other composited content") ← новый слой
```
Это ПОЛНЫЙ план как отдельная растеризуемая поверхность, которая теперь должна
перерисовываться при любом визуальном изменении плана во время жеста — то есть именно
тот класс стоимости, который #532 устранял для внешнего контура, теперь возвращается
для всего плана.
**Численное подтверждение (изолированный прогон, 20 шагов панорамы мышью, тот же
демо-стенд, RasterTask по CDP Tracing):**
```
origin/dev: 31.8 мс, 106 RasterTask
0a6592f4: 74.7 мс, 282 RasterTask (≈2.35× по времени, ≈2.7× по числу тасков)
```
**Свидетель AC4 (`demo/smoke_daycycle_raster.mjs`), 60-шаговая панорама, то же
окружение, 8 прогонов на `HEAD` против 7 на `origin/dev`:**
| Дерево | ratio (потолок 2.0) |
|---|---|
| `origin/dev` | 1.09, 1.14, 1.03, 1.03, 1.04, 1.01 (все ≤ 1.14) |
| `0a6592f4` | 2.19, 2.16, 2.23, 2.18, 2.50, 2.33 (все ≥ 2.16) |
Разброс между двумя деревьями непересекается ни разу за 13 запусков — это не шум
конкретной машины, а систематическая разница, воспроизводимая в контролируемом A/B
внутри ОДНОГО окружения (обе половины смока меряются в одном процессе на одной
странице, как и задумано автором смока).
**Почему CI на этом SHA отчитался зелёным, а не потому что находки нет.** Job
«Мутанты по диффу» реально исполняет `node demo/smoke_daycycle_raster.mjs` (лог
`ok чистый прогон: node demo/smoke_daycycle_raster.mjs`, 07:14:24) и код проверки
(`serve.mjs: finish()`) действительно ставит `process.exitCode=1` при `ratio > 2.0` —
то есть CI-раннер в моменте объективно получил ratio ≤ 2.0. Проблема в марже: порог
2.0 был откалиброван «примерно посередине по логарифму» между 7.9 (без подсказки
вообще) и 0.26 (подсказка на тесной `.hp-paperg`, комментарий в самом смоке, строка 15).
Новая реализация переехала с 0.26 на ~2.2–2.5 — то есть съела почти весь запас,
оставленный именно на случай машинной вариативности, и на GitHub-раннере это на
сегодня чуть-чуть укладывается, а на менее мощном/более шумном исполнителе — уже нет.
Целевая платформа этой самой задачи — Android HA Companion WebView — как правило
СЛАБЕЕ настольного CI-раннера, а не сильнее; то есть именно то устройство, ради
которого чинится #582, статистически более рискованно по AC4, чем моя тестовая машина.
**Последствие.** AC4 сформулирован как "свидетель #532 остаётся зелёным" — де-факто он
не остаётся зелёным как класс поведения (плана целиком, а не только контура), просто
конкретный числовой порог на конкретном раннере пока не перешагнут. Это разваливает и
собственное объяснение ТЗ в "Технические ограничения": "Фильтр/промоушен должен
применяться на уровне экранной scene surface, а не координатно большой внутренней
группы" — технически верно для самого контура, но не учитывает, что помещение этой
scene surface СОСЕДОМ (а не потомком) плана заставляет браузер промотировать и сам план.
### Находка №2 (High) — AC6 не проходит: все 4 day-cycle golden-сцены отличаются от эталона на каноничной платформе
**Файлы:** `demo/golden/baselines/day-cycle-{dawn,day,dusk,night}-dark.png` (не
обновлены в этом диффе — `git diff origin/dev...HEAD --stat -- demo/golden/` пуст).
`npm run golden:verify` (Linux, тот же движок, что CI/golden — каноничная платформа
по AGENTS.md) на точном SHA даёт:
| Сцена | статус | differing px | diffRatio | maxObservedDelta | порог diffRatio |
|---|---|---|---|---|---|
| day-cycle-dawn-dark | different | 26 903 | 0.0349 | 81 | 0.0005 |
| day-cycle-day-dark | different | 26 160 | 0.0339 | 81 | 0.0005 |
| day-cycle-dusk-dark | different | 25 667 | 0.0333 | 81 | 0.0005 |
| day-cycle-night-dark | different | 26 142 | 0.0339 | 94 | 0.0005 |
Это ~70× превышение допустимой доли различающихся пикселей и разница каналов до 94/255
при допуске 10/255 — не антиалиасинг-дребезг на границе допуска. Визуально (сравнение
`demo/golden/baselines/day-cycle-day-dark.png` и `artifacts/golden/actual/day-cycle-day-dark.png`
на глаз почти неотличимы; diff-маска в `artifacts/golden/diff/day-cycle-day-dark.png`
подсвечивает штриховку буквально ВСЕХ стен по всему плану, не только внешний контур)
похоже на тот же механизм, что и Находка №1: как только `plan-svg` стал отдельным
композитным слоем, финальная сборка кадра проходит через дополнительный проход
растеризации/блендинга этого слоя, слегка меняя суб-пиксельное позиционирование штриховки
стен по всему плану — отсюда широкий, но мелкий по амплитуде на глаз разброс.
Оставшиеся ~150 сцен матрицы прошли (`passed`), включая не-day-cycle сцены — регрессия
локализована именно в day-cycle рендере, как и предсказывает механизм Находки №1.
AC6 прямо требует: "четыре существующие day-cycle сцены... проходят... Любое
необходимое обновление baseline допускается только после объяснённого визуального
сравнения". Baseline не обновлён, объяснения в issue/коммите нет — сам факт диффа не
скрывается (никто не подделывал числа), но и не заявлен: ни в диффе, ни в комментариях
issue нет упоминания, что `golden:verify` был прогнан и дал результат. Раз diff явно
трогает рендер (`src/**`: `houseplan-card.ts`, `space-render.ts`, `paper-scene.ts`,
`plan.styles.ts`), `golden:verify` был обязателен перед `S7-code-review`
(AGENTS.md, "проверь по diff и AC" + сам этот процесс-документ).
**Обе находки объединяет один и тот же корень**: помещение дневного контура СОСЕДНИМ
`<svg>` перед `plan-svg` вместо ВНУТРИ него как раньше — технически верно решает
координатно-огромный слой (AC1–AC3 в порядке), но по пути превращает весь план в
отдельный композитный слой, что стоит и лишней растеризации (Находка №1), и лишнего
прохода блендинга, видимого в пикселях (Находка №2). Возможные направления для автора
(не предписание, только ориентир): не выносить контур отдельным сиблингом, а найти
способ ограничить экранными размерами именно промотированный слой ВНУТРИ существующего
дерева `plan-svg` (например, через `clipPath`/`contain`, привязанный к экранному
`viewBox`, а не к локальному bbox фигуры), либо явно и всегда держать `plan-svg` на
собственном стабильном слое ДО появления контура, чтобы контур не мог его "продавить"
через overlap-эвристику при первом появлении.
### Находка №3 (Low, не блокирует, оставлена без правки)
`src/houseplan-card.ts:11623-11624` — комментарий "One `<g>` around ALL paper shapes:
the external day-cycle outline (styles.ts) is composited once for the whole sheet"
относится к `.hp-paperg`, но сам фильтр туда больше не прикреплён (перенесён на
`.hp-paper-outline-svg`, отдельный узел). Смысл абзаца (одна группа = отсутствие швов
между комнатами) остаётся верным, но фраза про "the external day-cycle outline...
is composited" на этом самом `<g>` — уже не так. Мелкая правка комментария, не код.
## Что проверено и корректно
- **AC1/AC2/AC3** — подтверждены и CI-логом мутанта (`daycycle-outline-promoted-on-inner-paper`,
чистый прогон ok + красный на мутанте ok, 07:14:05–07:14:50 на точном SHA), и моим
локальным повторным запуском `smoke_daycycle_layer_budget.mjs` (4/4 зелёных): контур
теперь 826×736 (экранный порядок) вместо потенциальных 4700×4200; ни один content-слой
> 4096; 9/9 отснятых кадров pinch без белых пикселей.
- **Единый живой viewport (#579, контракт п.3–4).** `data-hp-live-viewbox` стоит на
`.hp-paper-outline-svg` тем же значением (`camera`/`floor`), что на `plan-svg`;
`live-viewport.ts:paintLiveViewport` обновляет оба через один и тот же
`querySelectorAll` и одну и ту же проекцию — контур и план не могут разойтись по
кадру или масштабу отдельно от терминального примирения. Проверено чтением, не
исполнением отдельно от смоков выше (которые это же утверждение покрывают AC3).
- **Основная бумага не фильтруется (контракт «Технические ограничения»).**
`renderPaperShapes(paperShapes)` без `groupClass` даёт `.hp-paperg` без фильтра;
`test/paper-scene-contract.test.mjs` проверяет отсутствие `will-change: filter` на
`.hp-paperg`; смок `smoke_daycycle_raster.mjs` подтверждает
`dayCyclePaperStaysUnfiltered: true` в моём прогоне.
- **Изометрия и static card не потеряли зеркальность.** `<g transform=${iso ?
isoFloorMatrixCss() : nothing}>` оборачивает `renderPaperShapes` в контурном SVG тем
же способом, что в основном; `hp-static-stage` получил аналогичное правило z-index/
overflow/pointer-events; `smoke_bg_color.mjs` (`staticCardLayersStayOrdered`) зелёный.
- **`plan-svg` z-index стал безусловным** (было `class=${iso ? 'plan-svg' : nothing}`,
стало `class="plan-svg"`) — раньше во flat-режиме класс не вешался вовсе и элемент
зависел от порядка DOM для стэкинга; теперь это явное `z-index: 1` против `0` у
контура. Не регрессия: до этого диффа конкурирующего элемента с явным z-index не было,
после — есть (сам контур), и явный класс корректно фиксирует стэкинг. `test/isometric-contract.test.mjs`
обновлён синхронно.
- **Changelog/трейлеры/документация.** `Issue: #582` + `User-Visible: yes` на
терминальном коммите; оба `docs/CHANGELOG*.md` правлены в нём же; `docs/ARCHITECTURE.md`,
`docs/SUN.md`, `docs/TOUCH-SUPPORT.md`, `docs/TESTING.md` описывают именно то, что
делает код (сверено построчно с диффом кода). `scripts/bundle-budget.mjs` — потолок
подвинут на 200 Б с содержательным объяснением, общий бюджет не тронут.
- **Мутационная регистрация.** `daycycle-outline-promoted-on-inner-paper` привязан к
правильному guard-файлу и реально ловит возврат старого `will-change` (подтверждено
и в CI, и локально).
## Чего не проверял и почему
- `python -m pytest tests_backend`, HACS, Hassfest, geometry-parity — diff не трогает
`custom_components/**/*.py`, манифесты, `layout`/`marker.space`/толщину стен; эти
гейты были в Validate `skipped` по классификации файлов, а не по heavy-флагу, что и
ожидаемо для чисто фронтенд-диффа.
- Полный `performance_smoke`/профили вне `smoke_daycycle_raster.mjs` — AC4 уже
провалена на целевом, более дёшевом свидетеле; гонять более тяжёлый профиль до
фикса не добавляет решающей информации.
- `npm run inventory` — не требуется для вынесения вердикта этого раунда.
- Полная browser-smoke матрица (250 файлов) — вне AC и вне выборки `smoke-select.mjs`
(4 зарегистрированные связи, все 4 прогнаны явно, см. таблицу выше).
- AC9 (полевая приёмка на реальном Android WebView владельцем в следующей бете) —
вне этого этапа по определению самого AC.
## Вывод
Реализация корректно решает координатно-зависимый размер промотированного слоя
(AC1–AC3), сохраняет единый live-viewport и визуальный контракт контура на уровне
кода, документация и трейлеры в порядке. Но выбранная архитектура (контур —
СОСЕДНИЙ `<svg>` перед планом, а не элемент внутри него) имеет побочный эффект:
`plan-svg` целиком продавливается в отдельный композитный слой, что воспроизводимо
проваливает собственный AC4 (перф-свидетель #532) вне узкой марки конкретного
CI-раннера и приводит к measurable расхождению во всех 4 golden-сценах AC6 на
каноничной Linux-платформе, не сопровождённому обновлением baseline или объяснением.
Обе находки — High, обе в скоупе задачи (эта же ветка чинит то, что сама же
сформулировала как риск: "Исправление WebView может регрессировать
Firefox-производительность #532"). Возврат автору.
---
<!-- material-anchors: сгенерировано конвейером (#414) -->
## Материал раунда
- Ветка: `issue/582-webview-large-filter-layers`, коммит `0a6592f4112f` — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
- Дерево материала: `9f4d95168fdda9650fa7d35f17d80c7177a7d12b`
```
git log --all --format='%H %T' | grep 9f4d95168fdd
```
- Тело issue: `4c1e1ecfe8ce205f3cfb3c44eb6028a35d0bb4358323e05ba60c6fd85b3b40ba`
- Вердикт конвейера: `red` · High 2