Files
houseplan-card/docs/reviews/CODE-REVIEW-565-r2.md
T
2026-09-14 07:21:41 +00:00

274 lines
25 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 — issue #565, заход r2
**Вердикт: зелёный · заход r2 · блокирующих циклов 1/4 · High: 0 · Medium: 0**
## Скоуп
Материал ревью: `git log --oneline origin/dev..HEAD` / `git diff origin/dev...HEAD`
на SHA `dded4fbd5c6d51e4127b4ddfd5d9a1c547f21de2` (рабочая копия на нём же).
Ветка приведена к `dev` конвейером до ревью: `origin/dev` продвинулся с `7d08a28d`
(база r1) до `84a7773e` (два новых коммита — `54265023` мутанты #569 и `84a7773e`
публикация их ревью, оба трогают только `scripts/mutation-registry.mjs` и не
задевают ни один файл #565). Задача автора `82ce5aae` перебазирована конвейером
в `dded4fbd`. Это другой код по определению §7.2, поэтому разбор — полный, не
по дельте: ниже проверены не только новые строки, но и то, что рёбейз не
исказил уже принятый материал r1 (сверка `git diff origin/dev..HEAD --
scripts/mutation-registry.mjs` — все шесть записей #565 присутствуют одним
чистым блоком, ни одного фрагмента #569 не задето и не продублировано).
Диапазон — четыре коммита: `6103e7de`/`c23c7927` (публикация SPEC/CODE-REVIEW-565-r1
конвейером, класс C), `6aca447f` (продуктовый коммит r1, уже разобран
`docs/reviews/CODE-REVIEW-565-r1.md`), `dded4fbd` — **предмет этого раунда**,
единственный продуктовый коммит после r1 (`fix: сохранить клавиатурную
подсказку (#565)`, трейлеры `Issue: #565` / `User-Visible: yes` на месте).
Единственная находка r1 — **M1 (Medium, в скоупе)**: клавиатурная focus-tooltip
устройства тихо стиралась наведением мыши на любую комнату, а симметричная
защита у другого устройства была избыточно широкой (блокировала ЛЮБОЙ hover, а
не только сфокусированного устройства). Коммит `dded4fbd` — целевой фикс этой
находки. Файлы: `src/houseplan-card.ts`, `src/live-hover.ts`,
`src/live-interaction-runtime.ts`, `demo/smoke_household_journeys.mjs`,
`scripts/mutation-registry.mjs`, `docs/CHANGELOG{.ru,}.md`, `docs/TESTING.md`,
`docs/UX-MODES.md` — соответствует хендоффу. Ни `device-presentation.ts`, ни
`space-render.ts`, ни i18n, ни тесты `test/*.test.mjs` не тронуты — AC1–AC4,
AC8, AC9 дельта не задевает.
## Разбор дельты (M1 → fix)
`src/live-hover.ts` вводит `focusTips: WeakMap<object, LiveTip>` — состояние
клавиатурной подсказки хранится отдельно от `host._tip` (то, что реально
отрисовано):
- `showDevicePointerTip` — убрана защита `if (host._tip?.source === 'focus')
return;`. Это и была избыточно широкая половина M1: она блокировала
legitimate hover ЛЮБОГО устройства мышью, пока где-то держался клавиатурный
фокус. Теперь наведение мышью на другое устройство работает как обычно.
- `showDeviceFocusTip` — пишет tip и в `focusTips`, и в `host._tip`.
- `hideDeviceFocusTip` (blur) — чистит запись в `focusTips` по `deviceId`,
сбрасывает `host._tip` только если текущий показ и есть focus-tooltip.
- `clearPointerHover` (`pointerleave` с устройства/комнаты) — `host._tip =
focusTips.get(value) || null`: возвращает сфокусированную подсказку, если
она ещё жива, иначе `null`.
- `reconcileDeviceFocusTip` (на каждый `commit()`) — переведена на проверку
`focusTips`, а не `host._tip`, поэтому больше не путает временный
pointer-tip с владением фокуса.
- Новые экспорты `deviceFocusTipActive`/`clearDeviceFocusTip`.
`src/houseplan-card.ts`:
- `_showTip(..., room)` — новая охрана `if (room && this._liveRt?.
deviceFocusActive()) return;` (houseplan-card.ts:7434) — это вторая,
недостающая половина M1: комнатный `pointermove` (`tip()`,
houseplan-card.ts:11677) больше не перезаписывает активную клавиатурную
подсказку. Прочитан весь путь: `enterRoom` (`pointerenter`) по-прежнему
ставит `_hoverRoom` для визуальной подсветки комнаты независимо от фокуса —
это не тот контракт, который защищает M1 (подсветка, не подпись), и в ТЗ/
находке не упоминается.
- `_clearTransientHover` — добавлен `this._liveRt?.clearDeviceFocus();` перед
остальной очисткой. Вызывается на границах жизненного цикла: смена
режима/пространства (`_setMode`, строка 7601, `this._clearTransientHover
(true)`), смена модальности на touch/pen (`_notePointer`), множественный
touch-контакт, `pointerup`/`pointercancel` на touch. Без этой правки
`focusTips` пережил бы границу и `clearPointerHover` мог бы «воскресить»
устаревшую подсказку после смены пространства — прочитано и подтверждено,
что путь перекрыт для всех вызывающих `_clearTransientHover`.
- `_clearPointerHover` — тернарник вместо `?? `; функционально то же самое, но
устраняет реальную ловушку: `pointerLeave(): void` возвращает `undefined`,
и старая форма `this._liveRt?.pointerLeave() ?? this._clearTransientHover()`
**всегда** дополнительно вызывала `_clearTransientHover()` (правый операнд
`??` срабатывает на `undefined`), т.е. любой `pointerleave` с живым
`_liveRt` стирал вообще весь transient-hover, включая только что
восстановленную focus-подсказку. Мутант `device-pointer-leave-clears-focus-
fallback` воспроизводит именно эту старую форму — назван ниже.
`src/live-interaction-runtime.ts` — тонкие делегаты `deviceFocusActive()` /
`clearDeviceFocus()` на `LiveRuntime`, используются в двух местах выше плюс
`dispose()`.
**Симметрия достигнута**: единственный инвариант «пока `focusTips` содержит
запись для хоста, посторонний `pointermove` не должен ни блокировать чужой
hover (было — избыточно), ни затирать текущую подсказку без предусмотренного
события (было — дыра M1)» реализован в одном источнике (`focusTips`), а не
двумя независимыми точечными проверками, как раньше.
## Что проверено исполнением (не только чтением)
Локально пересобран бандл (`npm run build && npm run bundle:sync`) — три копии
байт-в-байт совпали с материалом ревью (`git status --short` пуст после
сборки), typecheck прошёл без ошибок.
| Гейт | Команда | Результат |
|---|---|---|
| typecheck + build | `npm run build` | зелёный, `tsc --noEmit` без ошибок, `dist` пересобран идентично коммиту |
| bundle sync | `npm run bundle:sync` | зелёный, `git status` чист — все 3 копии синхронны |
| check-docs | `node scripts/check-docs.mjs --screenshots=warn` | зелёный: `Documentation checks passed (7 files, 12 external links)`; единственное `WARN` — уже известный устаревший screenshot-фингерпринт (#479, ожидаемо на плейн-пуше, не блокирует; тот же вывод был у автора и в r1) |
| **`node demo/smoke_household_journeys.mjs`** | прямое совпадение по diff (`_clearTransientHover, _showTip, _tip`), несёт сами новые проверки `j4.room_hover_does_not_replace_keyboard_tooltip`, `j4.other_device_keeps_ordinary_pointer_hover`, `j4.pointer_leave_restores_keyboard_tooltip` | **зелёный, `OK`** (0 записей в `_failures`) |
| **`node demo/smoke_touch_tips.mjs`** | прямое совпадение (`_hoverRoom, _notePointer, _showTip, _tip`); `_clearTransientHover` — тронутая функция, защищает AC7 | **зелёный, `OK`** (`touchClearsAllTransientHover`, `noTipOnTouch` и др. — все `true`) |
| **`node demo/smoke_room_tooltip_toggle.mjs`** | прямое совпадение (`_hoverRoom, _pointerModality, _tip`); ровно тот путь комнатного `tip()`, который получил новую охрану | **зелёный, `OK`** (`roomTipRestoredOnMove`, `disabledRoomTip`, `deviceTipSurvives` и др. — все `true`) |
`node scripts/smoke-select.mjs --base origin/dev --head HEAD` (полный вывод
приложен решением по каждой строке ниже): 33 прямых совпадения (в основном по
широким символам `_mode`/`_tip`/`_drag`, которые встречаются в большинстве
смоков файла) + 1 «зарегистрированная связь» (`smoke_value_face_source.mjs`
← `resolveDevicePresentation`, не относится к этой дельте — `device-
presentation.ts` не менялся в `dded4fbd`) + одна широкая помеченная явно
(`_space`, не учитывалась инструментом). Прогнаны три смока, которые прямо
покрывают изменённые функции и являются целью новых мутантов/носителем новых
проверок (`household_journeys`, `touch_tips`, `room_tooltip_toggle`); остальные
30 совпадений — по обиходным символам, использующимся в файле почти
повсеместно (`_mode`/`_drag`/`_config` встречаются в самом файле независимо от
tooltip-логики), не запускались: ни один из них не входит в файлы, изменённые
`dded4fbd`, а прочитанный код изменения (см. «Разбор дельты» выше) не касается
drag/resize/config/mode-переключателей как таковых — только маршрутизацию
tooltip между pointer и focus источниками. Полный набор — предрелизный гейт, а
не гейт ревью (PROCESS.md §8).
## Дешёвые гейты на этом SHA (подтверждены дважды)
Validate на точном материале `dded4fbd` зелёный:
https://github.com/Matysh/houseplan-card/actions/runs/34816298250 — покрывает
`npm test` (я не перезапускал: воспроизвёл typecheck/build локально, они
совпали, дополнительных причин сомневаться в юнитах, которые delta не трогает,
нет), `no-new-any`, `process-gate`, mutation-gate (шардируется в review-candidate
прогоне, покрывает все 6 witness'ов #565, включая 2 новых, — см. ниже),
perf-smoke триггер (`live-hover.ts`/`live-interaction-runtime.ts`/
`houseplan-card.ts` входят в `large-house-interaction-v1`, прогнан тем же
Validate). Не требуются и не прогонялись: `golden:verify` (стили/подписи не
тронуты, диффом подтверждено — как и в r1), `model-invariants` (геометрия не
тронута), `pytest tests_backend` (бэкенд не тронут).
## Таблица «AC · чем доказан · чем краснеет» — обновление к AC6
| AC | Чем доказан | Чем краснеет |
|---|---|---|
| AC6 (комнатный триггер, закрывает M1) | `j4.room_hover_does_not_replace_keyboard_tooltip` в `demo/smoke_household_journeys.mjs`, прогнан лично, `OK` | мутант `device-focus-tooltip-room-hover-overwrites` (снимает охрану `if (room && this._liveRt?.deviceFocusActive()) return;`), гард — тот же smoke; часть Validate review-candidate прогона на этом SHA |
| AC6 (восстановление после hover другого устройства, закрывает M1) | `j4.other_device_keeps_ordinary_pointer_hover` + `j4.pointer_leave_restores_keyboard_tooltip`, прогнаны лично, `OK` | мутант `device-pointer-leave-clears-focus-fallback` (возвращает старую форму `?? `, которая при живом `_liveRt` **всегда** доп. вызывает `_clearTransientHover()`, стирая восстановленную подсказку), гард — тот же smoke; часть Validate review-candidate прогона |
Обе новые записи `scripts/mutation-registry.mjs` матчатся `find` ровно на
текущий текст (`houseplan-card.ts:7434`, `houseplan-card.ts:5748`) — сверено
построчно, не только по имени функции.
## Закрытие раунда r1
| Находка r1 | Чем закрыта | Где это видно |
|---|---|---|
| M1 (Medium, в скоупе): клавиатурная focus-tooltip тихо стирается наведением мыши на комнату; симметричная защита у другого устройства избыточно широка | 1) Охрана `if (room && this._liveRt?.deviceFocusActive()) return;` внутри `_showTip` не даёт комнатному `pointermove` затереть активную focus-подсказку. 2) Снята избыточная защита `if (host._tip?.source === 'focus') return;` в `showDevicePointerTip` — hover другого устройства мышью снова разрешён (устраняет вторую половину асимметрии). 3) `focusTips` WeakMap хранит владение независимо от текущего показа и восстанавливает его в `clearPointerHover` при уходе указателя. 4) Границы жизненного цикла (`_clearTransientHover` → `clearDeviceFocus()`) чистят состояние, чтобы восстановление не «воскрешало» устаревшую подсказку | `src/houseplan-card.ts:7434` (новая охрана), `src/live-hover.ts:64-118` (весь механизм `focusTips`), `src/houseplan-card.ts:7304` (`clearDeviceFocus()` в `_clearTransientHover`); поведенчески — три новых проверки `j4.room_hover_does_not_replace_keyboard_tooltip` / `j4.other_device_keeps_ordinary_pointer_hover` / `j4.pointer_leave_restores_keyboard_tooltip` в `demo/smoke_household_journeys.mjs`, лично прогнаны и зелёные; защищены мутантами `device-focus-tooltip-room-hover-overwrites` и `device-pointer-leave-clears-focus-fallback` |
## Унаследовано из r1
Принято без повторной проверки кода (сам код этих участков не менялся в
`dded4fbd`, что подтверждено `git show dded4fbd --stat` — ни один из
перечисленных файлов/областей не входит в дифф этого коммита), опираясь на
`docs/reviews/CODE-REVIEW-565-r1.md` на материале `127cd07ba78fc75cd776ebd6518248a6bef91e82`:
- **AC1/AC2** (именованная навигация пространств, единственный `aria-current`,
Tab/Enter/Space не тронуты) — `src/houseplan-card.ts`, `<nav>`-разметка не
менялась в `dded4fbd`.
- **AC3/AC4** (дедупликация доступного имени целыми сегментами, паритет 4
локалей) — `src/device-presentation.ts` не входит в дифф `dded4fbd`.
- **AC5** (общий источник содержимого focus/hover tooltip, `deviceTipContent`,
clamp по viewport) — функция не менялась; фикс трогает только маршрутизацию
*какая* подсказка активна, не *что* в ней показано.
- **AC7** (touch/pen не открывают tooltip) — логика `_notePointer` не менялась;
дополнительно перепроверена исполнением (`demo/smoke_touch_tips.mjs`,
зелёный, см. таблицу выше) — не только унаследована, но и подтверждена
заново, поскольку `_clearTransientHover`, вызываемая на touch-границе,
получила новую строку.
- **AC8** (подписи комнат: цвет/прозрачность/текст не тронуты) —
`git diff origin/dev..HEAD -- src/` не содержит ни одного файла стилей;
`space-render.ts` не в диффе `dded4fbd`.
- **AC9** (нет миграции/новых полей конфига) — в диффе `dded4fbd` не появилось
ни одного поля схемы.
- Трейлеры и changelog r1-коммита (`6aca447f`) — уже проверены в r1-документе,
сам коммит не менялся ребейзом (SHA всех коммитов ветки, кроме последнего,
сохранились: `6103e7de`, `6aca447f`, `c23c7927` идентичны допереходному
дереву по содержимому, изменился только `82ce5aae → dded4fbd`).
- i18n (`nav.spaces` во всех 4 языках, старые ключи не тронуты) — `src/i18n/*`
не входит в дифф `dded4fbd`.
## Что проверено и корректно (дельта r2)
- Комнатная охрана размещена внутри `_showTip`, а не только у вызова из
`tip()` — значит защищает оба места вызова с `room=true` (сейчас оно одно,
но инвариант закреплён на уровне примитива, а не на уровне каждого
вызывающего — устраняет корневую причину M1: «точечная правка одного
вызывающего»).
- `enterRoom`/`_hoverRoom` (подсветка контура комнаты) намеренно не защищены
этим инвариантом и продолжают обновляться при наведении — это визуальная
подсветка плана, не подпись, вне контракта AC6 и вне текста находки M1.
- Оба новых changelog-абзаца (RU/EN, в одном коммите `dded4fbd`) описывают
именно наблюдаемое поведение («room hover no longer replaces a keyboard
tooltip; hovering another device temporarily shows that device and restores
the focused one when the pointer leaves») — совпадает с тем, что показало
исполнение smoke-проверок.
- `docs/UX-MODES.md` (канонический документ подсистемы) обновлён в том же
коммите тем же предложением — терминология «Room hover does not replace it;
ordinary mouse hover on another device may show that device temporarily,
then pointerleave restores the still-focused device» соответствует коду.
- `docs/TESTING.md` перечисляет оба новых мутанта под своими именами рядом со
старыми четырьмя — не потерялся ни один свидетель.
## Одно число — один источник
Дельта не добавляет и не меняет ни одной пользовательской величины (текст,
число, единицу измерения) — это чисто событийная маршрутизация состояния
tooltip между pointer- и focus-источником. `deviceTipContent()` остаётся
единственным источником заголовка/метрик для обоих путей и не изменена этим
коммитом (унаследовано из AC5, см. выше). Неприменимо к этому диффу.
## Чего не проверял
- Не гонял `npm test` напрямую (полагаюсь на зелёный Validate точного SHA
`dded4fbd`; локально воспроизвёл typecheck и build с идентичным результатом,
дополнительных причин сомневаться в юнитах, которые дельта не трогает
(`test/*.test.mjs` не в диффе `dded4fbd`), нет).
- Не гонял `golden:verify`, `model-invariants`, `pytest tests_backend`,
perf-профили по отдельности — не применимо к дельте (геометрия/бэкенд/визуал
не тронуты; perf-триггер входит в тот же Validate-прогон, который зелёный на
этом SHA).
- Не гонял 30 из 33 «прямых совпадений» `smoke-select.mjs` — все они матчатся
по широким обиходным символам (`_mode`, `_drag`, `_config`, `_deviceDrag`),
используемым в файле независимо от tooltip-логики; прочитанная дельта не
касается drag/resize/config/mode-переключателей, только маршрутизацию
подсказки между pointer/focus. Прогнаны три смока, которые несут либо новые
проверки (`household_journeys`), либо прямо упражняют тронутые функции
(`touch_tips` — `_clearTransientHover`/AC7, `room_tooltip_toggle` — путь
`tip()`/`_showTip` с `room=true`).
- Не гонял `smoke_space_card.mjs` — файл вне диффа `dded4fbd`, поведение
унаследовано из r1 без изменений.
- Не проводил ручное тестирование в браузере — вне процесса (PROCESS.md:
ручного тестирования в цикле нет).
- Не проверял поведение при реальном screen-reader — вне скоупа ТЗ, как и в r1.
---
<!-- material-anchors -->
## Материал раунда
- SHA материала: `dded4fbd5c6d51e4127b4ddfd5d9a1c547f21de2` (рабочая копия на
нём же в момент ревью, `git status --short` пуст).
- Предыдущий заход: `docs/reviews/CODE-REVIEW-565-r1.md`, материал
`127cd07ba78fc75cd776ebd6518248a6bef91e82` (заход r1, вердикт жёлтый,
High 0 · Medium 1). SHA r1 мёртв в текущей истории (ребейз), но найден и
прочитан из committed-документа в самой ветке — не потребовал
`git log --all --find-object`.
- Ребейз между заходами: `origin/dev` продвинулся `7d08a28d → 84a7773e` (два
коммита, оба вне скоупа #565); авторский коммит доработки `82ce5aae`
перебазирован конвейером в `dded4fbd`.
---
<!-- material-anchors: сгенерировано конвейером (#414) -->
## Материал раунда
- Ветка: `issue/565-view-accessibility`, коммит `dded4fbd5c6d` — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
- Дерево материала: `e63983fd12eebe0ec2029a09574d4e83cd933a14`
```
git log --all --format='%H %T' | grep e63983fd12ee
```
- Тело issue: `86508a821b1cdb4b2b7ad0b6e79395cb2e8ff094ea42f52487221af5c5e28093`
- Вердикт конвейера: `green` · High 0