23 KiB
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-proofdevice-visual-factor-removedкрасит guard. Доказано. - AC2 (anchor/hit area).
.dev::beforeдержитwidth/height: max(44px, --device-shell-size); painted face уменьшается, hit target иleft/topanchor — нет.hitAreaAtLeast44в smoke зелёный. Доказано чтением + smoke. - AC3 (value capsule). Проверено чтением и ручным геометрическим выводом:
core height фиксирована (
--dev-size), coreborder-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-proofdevice-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-proofdevice-press-duration-driftedкрасит guard. Доказано. - AC6 (composition/lifecycle).
renderDeviceFaceкладётactivity-ring/activity-dot/newdot/habadge/.lqiсоседями.device-shell, а не внутри него — анимацияscaleна.device-shellне касается их. Web Animationsscale-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 smoketouchOnlyHardwareRejectsMouseHover,compatibilityMouseIgnored,realMouseRestoresHoverзелёные. Доказано. - AC9 (JS hover cleanup).
_clearTransientHoverвызывается на terminalpointerup/pointercancel/lostpointercapture, начало multi-touch,_commitSpace,_setMode,visibilitychangehidden,disconnectedCallback. SmoketouchClearsTransientHover/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 → в задаче