Files
houseplan-card/docs/reviews/CODE-REVIEW-212-r1.md
T
2026-08-20 06:09:06 +00:00

23 KiB
Raw Blame History

CODE-REVIEW-212-r1

  • Issue: https://github.com/Matysh/houseplan-card/issues/212
  • Спецификация: docs/specs/212-device-icons-polish.md (зелёный SPEC-REVIEW-212-r1)
  • Диапазон: origin/dev...HEAD = ec6f77b (specify) · 5cae1fd (spec review doc) · 120d413 (fix: polish device icons and pointer feedback, Issue: #212, User-Visible: yes)
  • Ветка: issue/212-device-icons-polish
  • Роль: ревьюер кода, свежая сессия, без контекста написания ТЗ/кода.

Скоуп проверки

Единая задача #212, консолидирующая #22/#154/#181: 10% визуальное уменьшение marker geometry, форма display: value, accepted-action feedback (200 ms), instance-local pointer modality и снятие sticky touch-hover, гейтинг всех :hover в общем View/shared CSS, обновление docs/UX-MODES.md, docs/TOUCH-SUPPORT.md, docs/USER-GUIDE.ru.md, docs/TESTING.md, оба changelog. AC1–AC16 из спецификации.

Как проверялось

Гейт Команда Результат
Типы npx tsc --noEmit зелёный
Юнит npm test 952/952 зелёных
Сборка npm run build зелёная; md5sum dist/… custom_components/…/frontend/… demo/srv/assets/… — три копии идентичны
Документация node scripts/check-docs.mjs --external «Documentation checks passed (7 files, 10 external links)»
Mutation-proof (§19.1: 4 критичных guard'а) node scripts/mutation-gate.mjs --id=device-visual-factor-removed и ещё 3 (device-text-capsule-radius-restored, device-press-duration-drifted, device-touch-hover-gate-removed) все 4: «тест покраснел, как обязан» — тесты доказанно умеют падать
Таргетированные browser smoke (названные в §19.1) node demo/smoke_device_icon_design.mjs зелёный, включая новые ключи acceptedActionFeedback, reducedMotionUsesNoScale, keyboardActionFeedback, textCoreIsStadium
node demo/smoke_device_preview_parity.mjs зелёный
node demo/smoke_static_icon.mjs зелёный
node demo/smoke_state_value.mjs зелёный
node demo/smoke_icon_scale.mjs зелёный
node demo/smoke_touch_tips.mjs (переписан под real-touch context) зелёный
node demo/smoke_feedback_v2.mjs зелёный
node demo/smoke_help_affordance.mjs зелёный (включая mouseHover внутри вложенного hp-dialog)
node demo/smoke_long_press_gesture.mjs зелёный
Regression-смоки затронутой поверхности (не названы в §19.1, но задеты диффом hover/pointer) smoke_lock_action, smoke_lock_invariant, smoke_toggle_confirmation, smoke_vacuum, smoke_opening_binding, smoke_editor_gestures, smoke_isometric_live_touch, smoke_kiosk_pan_lock, smoke_pan_any_zoom все зелёные — lock-инвариант и gesture-safety не задеты
Regression-смоки затронутой поверхности (найдены сломанными) smoke_glow.mjs, smoke_room_settings.mjs, smoke_ux_fixes.mjs красные — см. находки M1/M2 ниже
Golden npm run golden:verify (диагностически, Windows/Linux CI локально недоступен) ожидаемо много different (10% resize, value-radius, hover-гейт) — согласно §19.2 принятие эталонов не входит в код-ревью и остаётся пре-релизным шагом

Геометрический вывод AC1/AC3 проверен чтением, не исполнением, с ручным выводом формул (см. ниже) — сверх того, что покрывают unit/smoke.

AC-трассировка

  • AC1 (10% geometry). Формула: --dev-size: calc(icon-size × dev-scale × var(--device-visual-factor, 0.9)); --puck-size использует тот же множитель. Unit (device-marker-polish-contract.test.mjs) + browser smoke (geometryMatchesAt32_56_96, referenceStateMatrixMatches) зелёные; mutation-proof device-visual-factor-removed красит guard. Доказано.
  • AC2 (anchor/hit area). .dev::before держит width/height: max(44px, --device-shell-size); painted face уменьшается, hit target и left/top anchor — нет. hitAreaAtLeast44 в smoke зелёный. Доказано чтением + smoke.
  • AC3 (value capsule). Проверено чтением и ручным геометрическим выводом: core height фиксирована (--dev-size), core border-radius = --dev-size/2; shell height фиксирована (--device-shell-size), border-radius = --device-shell-size/2. Поскольку --device-shell-size = --dev-size × 1.26875 и --device-shell-pad/border устроены так, что --dev-size + 2× (pad+border) = --device-shell-size (уже верно в базовом пакете #179), прямой подстановкой получается: straight_length_inner = straight_length_outer = core-content-width − 0.68×dev-size, т.е. эрозия равномерна — прямые стороны и полукруглые торцы получают одинаковый inset, не приближённо, а точно по формуле. textCoreIsStadium (browser smoke) и mutation-proof device-text-capsule-radius-restored подтверждают на практике. Доказано.
  • AC4/AC5 (feedback, gate действия). _startDevicePressFeedback вызывается ровно в трёх точках, все — непосредственно перед реальным callWS/ callService: virtual-light toggle, обычный toggle (включая cover/valve — projectedTapAction проецирует legacy 'cover' в 'toggle', тот же путь), run (script/scene/automation) внутри guarded() после согласия confirmation. info/more-info/gate _deviceBindingActive перед run не получают feedback. _keyDevice идёт через тот же _clickDevice. Смоки acceptedActionFeedback, informationalActionHasNoFeedback, keyboardActionFeedback, touchDispatchGetsFeedback зелёные; mutation-proof device-press-duration-drifted красит guard. Доказано.
  • AC6 (composition/lifecycle). renderDeviceFace кладёт activity-ring/ activity-dot/newdot/habadge/.lqi соседями .device-shell, а не внутри него — анимация scale на .device-shell не касается их. Web Animations scale-keyframe анимирует только раскрашенное тело относительно собственного центра (стандартный transform-origin элемента). updated() зовёт _pruneDevicePressFeedback() — снимает анимацию исчезнувшего маркера; _cancelDevicePressFeedback() вызывается на смену режима/пространства/ visibilitychange/reduced-motion change. feedbackLifecycleBounded в smoke зелёный. Доказано чтением + smoke.
  • AC7 (reduced motion/focus). _reducedMotion переключает набор keyframes на outline-акцент вместо scale; reducedMotionUsesNoScale зелёный. Focus/:focus-visible не гейтится pointer-атрибутом (см. AC10). Доказано.
  • AC8 (modality authority). PointerModalityController/ nextPointerModality — чистые функции, покрыты unit-матрицей (pointer-modality.test.mjs), включая изоляцию двух экземпляров и отказ compatibility-mouse (sourceCapabilities.firesTouchEvents) возвращать hover. Browser smoke touchOnlyHardwareRejectsMouseHover, compatibilityMouseIgnored, realMouseRestoresHover зелёные. Доказано.
  • AC9 (JS hover cleanup). _clearTransientHover вызывается на terminal pointerup/pointercancel/lostpointercapture, начало multi-touch, _commitSpace, _setMode, visibilitychange hidden, disconnectedCallback. Smoke touchClearsTransientHover/ touchClearsAllTransientHover зелёные. Доказано.
  • AC10 (CSS/nested hover inventory). Все :hover в styles.ts, hp-dialog.ts, hp-help.ts, hp-color-opacity.ts, space-card.ts гейтированы :host([data-pointer-hover]); unit-тест сканирует эти 5 файлов и требует пустой список голых селекторов. Но hp-device-preview.ts получил такой же гейтированный селектор (:host([data-pointer-hover]) .previewstage .dev:hover), не входит в список файлов юнит-теста и не входит в список элементов, которым родительская карточка проставляет атрибут — см. M2. Кроме этого файла, AC10 доказан.
  • AC11 (desktop/hybrid parity). smoke_help_affordance.mjs (mouseHover/mouseLeave) и smoke_feedback_v2.mjs (realMouseRestoresHover) зелёные на реальных PointerEvent. Но три ранее существовавших регрессионных смока той же поверхности (room/device tooltip и hover) остались на старом MouseEvent-API и красные — см. M1.
  • AC12 (gesture/action safety). smoke_editor_gestures, smoke_isometric_live_touch, smoke_kiosk_pan_lock, smoke_pan_any_zoom, smoke_lock_action, smoke_lock_invariant, smoke_toggle_confirmation, smoke_vacuum, smoke_opening_binding — все зелёные, регрессии не обнаружено. Доказано.
  • AC13 (surface parity). Один renderDeviceFace на full/preview/static; smoke_device_preview_parity, smoke_static_icon зелёные. Доказано.
  • AC14 (no data/i18n change). git diff не содержит правок в src/i18n/** ни в custom_components/**/*.py. Доказано чтением.
  • AC15 (documentation address). docs/UX-MODES.md получил раздел hover/pressed ownership; docs/CANVAS.md не тронут (git diff пуст); check-docs.mjs --external зелёный. Доказано.
  • AC16 (performance). Один MediaQueryList-listener и один MutationObserver на карточку (не на маркер); _pruneDevicePressFeedback сканирует .dev[data-id] только пока жив хотя бы один press-анимейшн (узкое окно ~200 мс на одно нажатие), не на каждый кадр. Полный performance_smoke — пре-релизный гейт, не прогонялся по правилу соразмерности. Проверено чтением, не исполнением, кроме этого — за пределами код-ревью.

Находки

M1 — три существующих regression-смока сломаны миграцией mouse→pointer событий (Medium, в скоупе)

src/houseplan-card.ts заменил обработчики комнаты/маркера с @mouseenter/@mousemove/@mouseleave на @pointerenter/@pointermove/ @pointerleave, а статический HouseplanCard._touchSeen удалён целиком — это входит в скоуп (§12, AC8/AC9/AC11). Автор обновил под новое API smoke_touch_tips.mjs, smoke_feedback_v2.mjs, smoke_help_affordance.mjs, smoke_device_icon_design.mjs, но пропустил три других существующих смока той же поверхности:

  • demo/smoke_glow.mjs — трогает _hoverRoom через new MouseEvent('mouseenter'). Воспроизведение: node demo/smoke_glow.mjs → FAILED (3): hoverUsesPlainSvg, hoverUsesNeutralDarkening, hoverLayerOrder. На origin/dev (проверено в чистом worktree) тот же смок зелёный целиком.
  • demo/smoke_room_settings.mjs — node demo/smoke_room_settings.mjs → FAILED (1): tooltipShowsHumiditySource.
  • demo/smoke_ux_fixes.mjs — использует и MouseEvent('mousemove', …) для tooltip, и несуществующий более c.constructor._touchSeen = true для проверки touch-подавления. node demo/smoke_ux_fixes.mjs → FAILED (8): tipTemp, tipHum, tipHasTempLine, tipHasHumLine, tipMetricOrder, tipPositionUnchanged, tipHasArea, tipHasAreaLine.

Это не регрессия поведения для реального пользователя — я подтвердил диагностически: подменив в smoke_ux_fixes.mjs вызовы на настоящие PointerEvent('pointermove', {pointerType:'mouse', …}) (без изменения продуктового кода), все 7 из 8 упавших проверок содержимого/позиции тултипа (tipTemp, tipHum, tipHasTempLine, tipHasHumLine, tipMetricOrder, tipPositionUnchanged, tipHasArea, tipHasAreaLine) зазеленели — реальная мышь по-прежнему корректно показывает тултип с температурой/влажностью/ площадью в прежнем порядке и позиции. Единственная оставшаяся red (noHoverSuppressesTooltip) — прямое следствие удалённого _touchSeen, который тест больше не может имитировать без настоящего touch-события (смотри как это сделано в переписанном smoke_touch_tips.mjs).

Почему это всё равно находка, а не «просто устаревший тест». CI-джоб smoke в .github/workflows/validate.yml прогоняет весь набор demo/smoke_*.mjs на каждый push (только байт-в-байт идентичный вход снимает прогон через reuse-кэш — здесь src/** меняется, значит прогон неизбежен). Эти три файла красные прямо сейчас на этом SHA — обязательный пре-релизный гейт CI будет красным. Кроме того, smoke_ux_fixes.mjs noHoverSuppressesTooltip после фикса перестаёт что-либо проверять (тест не падает по правильной причине, а молча не взаимодействует с картой) — это ровно тот «тест, который не умеет падать по факту», который правило ревью требует ловить.

Требуется: в smoke_glow.mjs, smoke_room_settings.mjs, smoke_ux_fixes.mjs заменить MouseEvent('mouse{enter,move,leave}') на PointerEvent('pointer{enter,move,leave}', {pointerType:'mouse', …}) (по образцу уже переписанных смоков этого же коммита) и переписать touch-latch проверку в smoke_ux_fixes.mjs через настоящий PointerEvent('pointerdown', {pointerType:'touch'}) вместо c.constructor._touchSeen.

M2 — hp-device-preview.ts гейтирует hover, но никто не выставляет ему атрибут (Medium, в скоупе)

src/hp-device-preview.ts:357 меняет .previewstage .dev:hover { z-index: 2; } на :host([data-pointer-hover]) .previewstage .dev:hover { z-index: 2; } — тот же паттерн, что и в hp-dialog.ts/hp-help.ts/hp-color-opacity.ts. Но:

  • houseplan-card.ts._syncPointerHoverTargets() и ._syncPointerHoverSubtree() выставляют data-pointer-hover только на 'hp-dialog, hp-help, hp-color-opacity' — hp-device-preview в списке нет;
  • у HpDevicePreview нет собственного PointerModalityController (в отличие от HouseplanSpaceCard, который держит свой);
  • unit-тест device-marker-polish-contract.test.mjs («removes the global touch latch and gates every shared hover selector») сканирует только ['styles.ts', 'hp-dialog.ts', 'hp-help.ts', 'hp-color-opacity.ts', 'space-card.ts'] — hp-device-preview.ts не входит, поэтому «голый» (в реальности осиротевший) селектор не поймался.

Воспроизведение: <hp-device-preview> рендерится напрямую в шаблоне houseplan-card.ts:19684 (Device editor → preview). В демо-харнессе: открыл _setMode('devices') → _openMarkerDialog(...), нашёл вложенный hp-device-preview, продиспатчил на нём настоящие PointerEvent('pointerover' /'pointermove', {pointerType:'mouse'}). Результат: у карты data-pointer-hover появляется (true), у hp-device-preview — нет (false) ни при каких условиях, поскольку ничего не вызывает toggleAttribute на этом элементе. Эффект: .previewstage .dev:hover { z-index: 2 } в Device preview теперь не включается никогда (до #212 работал через голый :hover) — маленькая, но настоящая регрессия видимого поведения на явно указанной в скоупе поверхности (§5: «Device preview»; §16 явно называет этот файл среди тех, что нужно трогать «если parity требует явной передачи»).

Требуется: либо добавить hp-device-preview в список целей _syncPointerHoverTargets/_syncPointerHoverSubtree (симметрично hp-dialog/hp-help/hp-color-opacity), либо завести собственный PointerModalityController внутри HpDevicePreview по образцу HouseplanSpaceCard; вместе с этим расширить список файлов в naked-hover-selector unit-тесте, чтобы такой пробел не проходил снова.

Проверено и корректно

  • Формула 10%-коэффициента, её применение к shell/core/glyph/puck/rings и сохранение anchor/hit-area — код и мат. вывод сходятся, mutation-proof ловит регрессию.
  • Радиус display: value — точный геометрический вывод показывает равномерный inset по всему периметру, не приближение.
  • Feedback-контракт (кому положен, длительность, reduced-motion, keyboard, lifecycle, отсутствие влияния на semantic state/LQI/badges) — сходится по коду и по смокам на всех проверенных путях диспатча (toggle, virtual-light, cover через projected toggle, run/script, keyboard Enter/Space, touch tap).
  • Lock-инвариант, gesture-safety (pan/pinch/long-press/second pointer), toggle-confirmation — не задеты; все относящиеся смоки зелёные.
  • Три копии бандла идентичны, документация (UX-MODES/TOUCH-SUPPORT/ USER-GUIDE.ru/TESTING/оба CHANGELOG) обновлена в том же коммите с верным адресом (CANVAS.md не тронут), трейлеры корректны.
  • i18n/backend не задеты.

Чего не проверял

  • npm run golden:accept не запускался и не должен — принятие эталонов вне код-ревью (PROCESS.md §8, §13). golden:verify прогнан диагностически локально (Windows), ожидаемо показывает массовые different от 10%-resize и гейта hover; финальная сверка на полном Linux CI-артефакте — задача пре-релиза.
  • performance_smoke и npm run inventory не прогонялись — не относятся к гейту код-ревью (PROCESS.md §8), AC16 закрыт чтением кода.
  • Полный набор из 127 demo/smoke_*.mjs не прогонялся целиком — прогнаны все смоки из §19.1 плюс 12 смоков смежных поверхностей (lock/confirmation/ gesture/vacuum/opening), которые и вскрыли M1. Оставшиеся ~100 смоков не относятся к затронутым файлам (plan/backup/floors/registry и т.п.) и не прогонялись.
  • python -m pytest tests_backend не прогонялся — диапазон не трогает custom_components/**/*.py.
  • Ручная визуальная приёмка golden-скриншотов (Light/Dark 32/56/96, pressed midpoint, reduced-motion accent) не проводилась — не относится к код-ревью, явно назначена на пре-релиз (§19.2).

Вердикт

Продуктовое поведение для реального пользователя не сломано ни разу за то, что я смог воспроизвести (M1 — только стенд, не поведение; M2 — реальная, но небольшая визуальная регрессия в admin-only Device preview). High: 0. Обе находки — Medium, в скоупе задачи (пункты §12/§13 самой спецификации), чинятся в этой же ветке без нового issue.

Вердикт: жёлтый · цикл r1/4 · High: 0 · Medium: 2 → в задаче