27 KiB
CODE-REVIEW — issue #565, заход r1
Вердикт: жёлтый · заход r1 · блокирующих циклов 1/4 · High: 0 · Medium: 1 → в задаче
Скоуп
Материал ревью: git log --oneline origin/dev..HEAD / git diff origin/dev...HEAD
на SHA 127cd07ba78fc75cd776ebd6518248a6bef91e82 (рабочая копия на нём же).
Диапазон — один продуктовый коммит 127cd07b (fix: улучшить доступность View (#565), трейлеры Issue: #565 / User-Visible: yes на месте) плюс
предшествующий 65a426d6 (docs: review document for #565, публикация
SPEC-REVIEW-565-r1 конвейером).
ТЗ (тело issue #565, зафиксировано владельцем 2026-09-14, ревью ТЗ зелёное,
docs/reviews/SPEC-REVIEW-565-r1.md) реализует три независимых дефекта
доступности:
- AC1/AC2 — полоса вкладок пространств получает именованную навигацию и
ровно один
aria-current="page", Tab/Enter/Space не меняются. - AC3/AC4 — доступное имя устройства схлопывает точные повторы
локализованных сегментов (в основном View и
houseplan-space-card), не трогая различающиеся факты. - AC5–AC7 — клавиатурный
:focus-visibleна маркере устройства в View открывает ту же tooltip, что и mouse-hover, следует за фокусом, закрывается по перечисленным границам и не активируется touch/pen.
Не-скоуп: контраст/цвет/текст подписей комнат (AC8, явно защищён — не тронут ни один файл стилей/логики подписей), конфиг/миграции (AC9), общий ARIA-tablist, roving tabindex, интерактивность статической карточки — всё подтверждено диффом (см. «Что проверено»).
Файлы: src/houseplan-card.ts, src/live-hover.ts,
src/live-interaction-runtime.ts, src/device-presentation.ts,
src/space-render.ts, src/i18n/{en,ru,de,fr}.json,
test/view-accessibility.test.mjs, test/device-presentation.test.mjs,
demo/smoke_household_journeys.mjs, demo/smoke_space_card.mjs,
scripts/mutation-registry.mjs, документация
(docs/CHANGELOG{.ru,}.md, docs/TOUCH-SUPPORT.md, docs/UX-MODES.md,
docs/USER-GUIDE{.ru,}.md, docs/TESTING.md) — соответствует хендоффу.
Как проверялось
| Гейт | Результат | Как учтён |
|---|---|---|
npx tsc --noEmit |
не перезапускал | Validate зелёный на точном SHA 127cd07b (https://github.com/Matysh/houseplan-card/actions/runs/34814012888) покрывает |
npm test |
не перезапускал | покрывается тем же Validate-прогоном |
npm run build + сверка 3 копий бандла |
не перезапускал | покрывается тем же Validate-прогоном; diff-stat подтверждает, что все три копии (dist/, custom_components/.../frontend/, корневые houseplan-card.js/houseplan-panel.js) изменены синхронно |
node scripts/check-docs.mjs |
не перезапускал | часть Validate; diff трогает src/**, автор зафиксировал ожидаемое Linux-предупреждение отдельно |
| mutation-gate (4 новых witness) | не перезапускал | часть Validate (mutation-gate шардируется в validate.yml); прочитаны сами мутанты в scripts/mutation-registry.mjs — патчи корректно снимают именно ту защиту, которую называют (см. таблицу AC ниже) |
node scripts/smoke-select.mjs --base origin/dev --head HEAD |
не запускал сам инструмент | по диффу очевидны затронутые смоки: demo/smoke_household_journeys.mjs, demo/smoke_space_card.mjs — оба уже расширены в этом коммите; demo/smoke_touch_tips.mjs не изменён, но прочитан целиком (ниже) |
node demo/smoke_household_journeys.mjs, node demo/smoke_space_card.mjs, node demo/smoke_touch_tips.mjs |
не прогонял | браузерные смоки не запускались в этом ревью; вместо исполнения — построчное чтение новых проверок (см. «Находки» и «Что проверено») и прослеживание кода, который они должны ловить. Это ослабление отмечаю явно: находка ниже обнаружена именно потому, что код не исполнялся под сценарием, которого смоки не покрывают |
npm run golden:verify |
не требуется | AC8 защищён явно: ни один файл стилей/логики подписей комнат не тронут (git diff --stat -- src/ не включает styles/, space-render правки — только импорт deviceAccessibleLabel и замена .filter(Boolean).join(', ')); <div>→<nav> не меняет визуал (.tabs — классовый селектор, см. src/styles/chrome.styles.ts:12) |
python -m pytest tests_backend |
не требуется | бэкенд не тронут |
node scripts/model-invariants.mjs |
не требуется | геометрия/layout/толщины не тронуты |
| perf-профили | не требуется | не названы в AC, изменённые файлы (live-hover.ts, live-interaction-runtime.ts, houseplan-card.ts) входят в perf-триггер large-house-interaction-v1 из §8 PROCESS.md — но сам Validate это уже прогнал автоматически по классификатору диффа, входит в тот же зелёный прогон |
Важно: ревью опирается на зелёный Validate SHA 127cd07b для дешёвых
гейтов (typecheck/test/build/check-docs/mutation-gate/perf-smoke), но не
опирается на него для доказательства поведенческого AC5/AC6 в комбинированном
сценарии мышь+клавиатура одновременно — Validate доказывает, что существующие
проверки прошли, а не то, что новый сценарий (см. находку M1) вообще
предусмотрен тестом.
Находки
M1 (Medium, в скоупе). Клавиатурная focus-tooltip тихо стирается наведением мыши на комнату — нарушает контракт AC6
Где: src/houseplan-card.ts:11677-11688 (замыкание tip, вызывающее
this._showTip(...) для комнатной подсказки) и src/houseplan-card.ts:7423-7436
(_showTip, безусловно перезаписывает this._tip).
Воспроизведение по чтению кода (не исполнялось, применимый oracle описан ниже):
- Настройка по умолчанию:
show_room_tooltipне задана →showRoomTooltipOf()вsrc/logic.ts:1382-1384возвращаетtrue(!== false). - Пользователь клавиатуры делает Tab до маркера устройства в View.
@focus→_showDeviceFocusTip→live-hover.ts:showDeviceFocusTipставитhost._tip = { ..., source: 'focus', deviceId }. Фокус остаётся на маркере (blur не происходит). - Тот же пользователь (или синтетическое движение указателя, эмулирующее
человека, который водит мышью, читая подсказку) наводит указатель на ЛЮБУЮ
комнату плана — событие
pointermoveна<path>/<polygon>комнаты (houseplan-card.ts:11677, обработчикtip). tip(e)→_roomTipEnabledForPointer(e)возвращаетtrue(шаг 1) →this._showTip(e, roomName, area, ...)выполняется._showTip(houseplan-card.ts:7423) не проверяетthis._tip?.sourceперед присваиванием:this._tip = { ..., source: 'pointer', room: true }безусловно заменяет предыдущий объект. Focus-tooltip устройства исчезает, на её месте появляется подсказка комнаты (название/площадь) — без единого события из перечня контракта AC6 («blur, смена активного пространства, выход в редактор, исчезновение маркера»).
Почему это находка, а не допустимое поведение. ТЗ (раздел «Tooltip
клавиатурного фокуса», AC6) перечисляет закрывающие focus-tooltip события
исчерпывающе и не включает в этот список наведение указателя на несвязанный
элемент (комнату). Тот же коммит добавляет в docs/UX-MODES.md:86-89 фразу
«closes at the usual focus, mode, space and lifecycle boundaries» — наведение
на комнату не является ни одной из них. Сценарий не экзотический: комнаты
покрывают почти всю площадь плана, поэтому указатель мыши почти всегда лежит
над какой-то комнатой, пока пользователь читает клавиатурную подсказку у
устройства — это не редкий пограничный случай, а обычное совмещённое
использование мышь+клавиатура.
Асимметрия, которая это обнажает. Путь наведения на ДРУГОЕ устройство
защищён (src/live-hover.ts:showDevicePointerTip: if (host._tip?.source === 'focus') return;) — но эта же защита блокирует легитимный hover другого
устройства мышью, пока где-то ещё держится клавиатурный фокус (это
противоречит явному пункту контракта «Pointer-hover продолжает работать по
прежним правилам»). Путь наведения на комнату вообще не имеет такой защиты.
Это показывает, что оговорка source === 'focus' добавлена точечно к одному
вызову (showDevicePointerTip), а не как общий инвариант «пока в
_tip.source === 'focus', посторонний pointermove не должен ни блокировать
чужой hover, ни затирать текущий tooltip без предусмотренного события» — и
второй вызывающий _showTip (комната) остался без этой оговорки.
Чем не является. Не влияет на screen-reader контракт AC3/AC4 —
aria-label устройства не зависит от visual tooltip и остаётся корректным.
Затрагивает только визуальную focus-tooltip (AC5/AC6) для зрячего
клавиатурного пользователя.
Не поймано существующими тестами. demo/smoke_household_journeys.mjs
(j4/j4Next/j4Blur/j4HoverParity) проверяет: hover→focus content parity,
перенос по Tab, закрытие по blur, и hover того же устройства после blur —
но нигде не наводит указатель на комнату, пока focus-tooltip другого элемента
активна, и не наводит указатель на другое устройство в этом состоянии.
Пробел в покрытии — то же самое несовпадение, что породило дефект.
Требуемое исправление (не мной, дело автора): объединить защиту в одном
месте — например, _showTip не должен перезаписывать _tip.source === 'focus' c source: 'pointer' без явного триггера из контракта; либо явно
решить и задокументировать, что комнатный hover тоже входит в список
закрывающих событий, и добавить тест на этот случай (плюс развязать
чрезмерно широкую блокировку hover другого устройства в
showDevicePointerTip, которая сейчас блокирует ВСЕ устройства, а не только
сфокусированное).
Что проверено и корректно
- AC1/AC2 —
<nav class="tabs" aria-label=${this._t('nav.spaces')}>,aria-current=${this._space === s.id ? 'page' : nothing}только у активной кнопки, кнопка добавления пространства (data-hp="space-add") шаблон не трогает →aria-currentне получает.nothingв lit-биндинге атрибута удаляет атрибут целиком (уже устоявшийся паттерн в файле, см. комментарий «docs/STYLING-HOOKS.md §3» рядом). Никакихrole="tablist"/"tab", roving-tabindex или обработчиков стрелок не добавлено — грепом по всему диффу подтверждено отсутствие..tabsстилизуется по классу (src/styles/chrome.styles.ts:12), смена тегаdiv→navне меняет CSS. Тест-контрактtest/view-accessibility.test.mjsи browser smoke (j5.*,j5_keyboard.*вdemo/smoke_household_journeys.mjs) закрывают оба факта источником и негативной защитой (мутантview-current-space-aria-removed, гард — тот же smoke). - AC3/AC4 —
deviceAccessibleLabel()(src/device-presentation.ts:63-82): трим + схлопывание пробелов + сравнение без регистра, первое вхождение сохраняет исходный текст, пустые/false/nullотбрасываются. Юнит-тестtest/device-presentation.test.mjsпокрывает: полный повтор с разным регистром/пробелами, частичное совпадение НЕ удаляется («LQI 117» vs «LQI 117, medium signal» — оба сохраняются как разные факты), паритетstate_a11y_alarm === pulse_a11y_alarmдля всех 4 локалей и что после дедупликации слово встречается один раз. Применена в обоих местах (houseplan-card.ts:12519,space-render.ts:594-604), других независимых сборокstate_a11y_*/pulse_a11y_*в View/статической карточке не осталось (грепом подтверждено; третье место,hp-device-preview.ts:252, — панель предпросмотра редактора устройств, вне скоупа задачи поdocs/SCOPE.md/ТЗ). Мутантdevice-accessible-label-dedup-removedснимает именно дедупликацию (seen.hasигнорируется), гард — целевой unit-тест. Замечание из SPEC-REVIEW-565-r1 (старая проверкаlabel.split(',').length >= 2не доказывает AC3) — закрыто: добавленыj2.alarm_fact_is_not_repeated(Set vs length) иj2.alarm_is_spoken_once(счётчик вхождений словаalarm), именно то, что требовал ревьюер ТЗ. - AC5 —
deviceTipContent()вlive-hover.ts— единственный источник заголовка/метрик для обоих путей (pointer и focus), поэтому содержимое focus-tooltip и hover-tooltip совпадает по построению (не по совпадению двух независимых веток) — это и есть «одно число, один источник» для этого диффа. Позиция берётся изtarget.getBoundingClientRect()(live-hover.ts:showDeviceFocusTip), а не из последних координат указателя.syncTip(live-hover.ts:133-152) добавляет clamp поinnerWidth/innerHeightс отступом 8px — общий для обоих источников подсказки (пункт «принято предположительно» ТЗ разрешает это явно). Смокj4.focus_shows_same_device_tooltip,j4.focus_tooltip_is_anchored_in_viewport,j4.tooltip_clamps_at_viewport_edge,j4.focus_and_mouse_tooltip_content_matchпроверяют именно это (последний — послеblur, поэтому не пересекается с находкой M1, см. выше). - AC6 (частично, см. M1) —
blur→@focusout=${() => this._hideDeviceFocusTip(d.id)}→hideDeviceFocusTipвlive-hover.tsпроверяетdeviceId, чтобы не стереть чужую tooltip; смена пространства/режима —_setMode/переключатель пространства уже вызывает_clearTransientHover(true)(новая строка вhouseplan-card.ts:7601, внутри существующего перехода режима); исчезновение маркера —reconcileDeviceFocusTipна каждыйcommit()сверяет:focus-visibleнайденного узла и обнуляет_tip, если узел исчез или потерял фокус.j4Next/j4Blurв смоке подтверждают перенос по Tab и очистку по blur. Не подтверждено: очистка по наведению на комнату — вот и находка M1. - AC7 — mouse-only gate (
_pointerModality.hoverEnabled) не тронут; touch/pen по-прежнему полагаются на уже существующий (не изменённый в этом диффе)_notePointer, который безусловно вызывает_clearTransientHover()при смене модальности наtouch/pen— значит, и focus-, и pointer-tooltip одинаково стираются реальным touch/pen-событием (прочитано вhouseplan-card.ts:7313-7319, не исполнялось; логика не менялась этим диффом, только новое полеsourceне влияет на этот безусловный сброс).demo/smoke_touch_tips.mjs(не изменён в этом коммите, прочитан целиком) проверяетtouchClearsAllTransientHoverиnoTipOnTouch— оба независимы отsourceи продолжают быть корректными после рефакторинга. - AC8 — ни один файл, отвечающий за цвет/прозрачность/текст подписей
комнат, не тронут (
git diff --stat -- src/не содержитstyles/*.tsкроме импорта;space-render.ts-диффа — только замена сборкиaria-label). Golden/screenshot baseline не в diff-stat — согласуется с отсутствием визуальных изменений. - AC9 — модели данных/конфига в диффе нет;
test/device-presentation.test.mjsи остальные unit не требуют миграции; подтверждено чтением — новых полей вtypes.ts/схеме конфигурации нет. - Трейлеры и changelog — единственный продуктовый коммит
127cd07bнесётIssue: #565иUser-Visible: yes;docs/CHANGELOG.md/docs/CHANGELOG.ru.mdправки — в том же коммите (провереноgit show --stat 127cd07b). - i18n — новый ключ
nav.spacesприсутствует вen/ru/de/fr, старыеstate_a11y_*/pulse_a11y_*не тронуты (диффом подтверждено — единственные строки с+в i18n-файлах это самnav.spaces). - Ребейз между заходами — учтён (см. комментарии issue): ветка
ребейзнута на
origin/dev7d08a28d, конфликт был только вscripts/mutation-registry.mjs(независимые добавления #565/#568, оба сохранены) — не меняет поведенческий контракт, полный разбор проведён на итоговом дереве127cd07b.
Чего не проверял
- Не запускал ни один браузерный смок (
demo/smoke_household_journeys.mjs,demo/smoke_space_card.mjs,demo/smoke_touch_tips.mjs),npx tsc --noEmit,npm test,npm run build/сверку бандлов,check-docs.mjs, mutation-gate — все они покрыты зелёным Validate на точном SHA127cd07b(https://github.com/Matysh/houseplan-card/actions/runs/34814012888) согласно правилу «дешёвые гейты на этом SHA уже подтверждены» (#343). Находка M1 обнаружена чтением кода, а не выполнением смоков — сами смоки этот сценарий не проверяют (см. выше), так что зелёный Validate её не мог поймать. - Не проводил contrast-аудит подписей комнат (F2) — вне скоупа #565, решение владельца зафиксировано в ТЗ.
- Не гонял
golden:verify/model-invariants/pytest tests_backend/ perf-профили отдельно — не применимо к этому диффу (геометрия, бэкенд и визуал не тронуты; perf-триггер входит в тот же Validate-прогон). - Не проверял
hp-device-preview.ts(третье место со сборкойlqi_a11y_*) на предмет собственного дублирования тревоги — эта панель вне скоупа задачи (редактор устройств, не View/houseplan-space-card) и не упомянута ни в проблеме, ни в AC. - Не проверял поведение при реальном screen-reader (NVDA/VoiceOver) — вне скоупа ТЗ, как и в ревью спецификации.
Таблица «AC · чем доказан · чем краснеет» (защитные AC)
| AC | Чем доказан | Чем краснеет |
|---|---|---|
| AC1 | demo/smoke_household_journeys.mjs (j5.*) |
мутант view-current-space-aria-removed, гард — тот же smoke (по регистру) |
| AC2 | test/view-accessibility.test.mjs (source-contract на role="tablist"/"tab") + j5.native_button_navigation_is_kept, j5_keyboard.* |
не мутирован отдельно — регресс проявился бы падением smoke-проверки Enter/Space; допустимо для AC, не заявляющего отдельную защиту сверх существующего поведения |
| AC3/AC4 | test/device-presentation.test.mjs (#565 accessible device label...) |
мутант device-accessible-label-dedup-removed, гард — тот же unit-тест по имени |
| AC5 | j4.focus_shows_same_device_tooltip, j4.focus_and_mouse_tooltip_content_match |
мутант device-focus-tooltip-handler-removed, гард — demo/smoke_household_journeys.mjs |
| AC6 | j4Next/j4Blur в smoke |
мутант device-focus-tooltip-blur-cleanup-removed, гард — тот же smoke; но комнатный триггер (M1) свидетеля не имеет — пустая ячейка, находка |
| AC7 | demo/smoke_touch_tips.mjs (не изменён, всё ещё применим) |
не мутирован в этом диффе — логика _notePointer не менялась продуктовым кодом задачи |
| AC8 | review диффа (source-contract: файлы стилей/логики подписей не тронуты) | н/п — AC защищает от отсутствия правки, не от наличия дефекта в новом коде |
| AC9 | review диффа (нет новых полей/схемы) | н/п |
Материал раунда
- SHA материала:
127cd07ba78fc75cd776ebd6518248a6bef91e82(рабочая копия на нём же в момент ревью). - Предыдущий заход: отсутствует — это первый код-ревью #565 (
заход r1). Единственная более ранняя попытка («Ревью не запускалось») остановилась на проверке ребейза до чтения кода и цикл не образовала.
Материал раунда
- Ветка:
issue/565-view-accessibility, коммит127cd07ba78f— ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет. - Дерево материала:
1e5b1ef2ad489ee22733776958baafde2df84965git log --all --format='%H %T' | grep 1e5b1ef2ad48 - Тело issue:
86508a821b1cdb4b2b7ad0b6e79395cb2e8ff094ea42f52487221af5c5e28093 - Вердикт конвейера:
yellow· High 0