20 KiB
SPEC-REVIEW-613-r1
Материал: тело issue #613 (первая редакция, комментарий-аналитика от Matysh
2026-09-23T02:02:47Z уже приложен, ТЗ после него не менялся). Дерево кода на
момент проверки — 1241b9499c7b33d4a4b905d884f60ae6db74690c (dev), читано
только для проверки утверждений ТЗ о существующем коде, не как материал
код-ревью.
Заход r1, блокирующих циклов израсходовано 0 из 4 (ещё не потрачены — это первый заход, полный трек).
Скоуп ревью
Issue #613: дефект #1 (устаревание hit-индекса устройств после прокрутки
дашборда/страницы) и дефект #2 (_saveZoom() синхронно пишет localStorage
на каждый pointermove обоих pinch-путей). ТЗ полного трека, тело issue,
раздел ## ТЗ. Проверялись: наличие обязательных разделов §7.1, однозначность
и доказуемость AC1…AC6, соответствие описания текущему коду
(src/device-hit-owner.ts, src/houseplan-card.ts), соответствие
docs/SCOPE.md (J1/J3) и docs/TOUCH-SUPPORT.md (View/kiosk —
release-blocking), а также техническая реализуемость заявленного подхода к
scroll-инвалидации.
Как проверялось
- Прочитан
docs/SCOPE.md, релевантные разделыPROCESS.md(§1–§9, лимит циклов §4, лёгкий трек §5, шаблоны §7.2) иdocs/TOUCH-SUPPORT.mdцеликом. - Прочитано тело issue #613 и единственный комментарий (аналитика).
- Прочитан
src/device-hit-owner.tsцеликом (288 строк) — подтверждён механизмDeviceHitIndex/DeviceHitController/DevicePointerOwnerLatch, на который ссылается ТЗ (арбитраж #564). - В
src/houseplan-card.tsнайдены и прочитаны оба pinch-пути: обычный stage (_stagePointerMove/_stagePointerUp/_stagePointerCancel, строки ~6903–7113, 7964–7981) и capture-guard для жеста, начатого над устройством (_guardTouchGesture, строки ~7329–7413) — подтверждено, что_saveZoom()(строка 6968 и 7388) вызывается на каждыйpointermoveв обоих путях и что ни один терминальный обработчик (pointerup/pointercancel/lostpointercapture) сейчас не делает единственную финальную запись. - Поиском по
src/houseplan-card.tsподтверждено отсутствие любого текущегоscroll/visualViewportслушателя — инвалидация индекса сейчас идёт только черезResizeObserver(.stage)и явные вызовы_invalidateDeviceHitGeometry()при изменении геометрии устройств. Заявление ТЗ «сейчас он инвалидируется при ResizeObserver... но не при прокрутке» подтверждено кодом, не домыслено. - Подтверждено существование
_deviceHitOwnerAt(строка 5718) и обоих smoke-файлов, которые ТЗ предлагает расширить:demo/smoke_device_hit_capsules.mjs,demo/smoke_editor_gestures.mjs— план автотестов ссылается на реальные файлы, а не на выдуманные имена. - Найдено на будущее свойство:
connectedCallback/disconnectedCallbackвsrc/houseplan-card.ts(2672, 2780) существуют и уже несут прецедент «таймеры/подписки умирают на disconnect» (комментарий AUD-1552-01) — AC2 (lifecycle слушателей) реализуем на существующем паттерне. - Ключевая проверка — техническая реализуемость принятого предположения
«capture-listener
scrollнаownerDocument». Эмпирически проверено в реальном Chromium проекта (playwright, тот же движок, что и вdemo/serve.mjs): скролл элемента внутриshadowRootне долетает доdocument-уровневого слушателя даже с{capture: true}— см. находку H1 ниже, вывод теста приведён там же дословно. - Проверено по коду: в проекте уже есть прецедент подъёма по цепочке теневых
хостов через
getRootNode()(src/hp-dialog.ts:329,464-465,src/hp-zigbee-topology-overlay.ts:80,165,src/summary-panel-runtime-loaded.ts:396) — то есть команда уже решала именно эту категорию проблемы в других местах кода, просто не в этом ТЗ. - Автотесты/гейты в этом раунде не гонялись: на стадии
specне существует ни кода, ни коммитов для проверки — гонятьtsc/test/buildне на чем. Единственная фактическая проверка — приведённый выше playwright-эксперимент, подтверждающий конкретное утверждение о поведении браузера.
Находки
H1 — High. Принятое предположение о scroll-инвалидации не работает в основном заявленном сценарии (HA-дашборд), и это не покрыто ни одним AC
Файл: тело issue #613, разделы «Принятые технические предположения» и «Риски».
Сценарий отказа: Раздел «Принятые технические предположения» выбирает:
«Использовать capture-listener scroll на ownerDocument плюс
visualViewport.scroll/resize, а не подписываться на динамический список
предков». Раздел «Риски» тут же, тремя строками выше, называет ровно
противоположный факт: «Прокрутка может происходить внутри shadow DOM/
HA-контейнера, поэтому слушатель только на window не покрывает все случаи» —
но противоречие не разрешается: assumption выбирает именно ту схему, которую
risk описывает как недостаточную, и не говорит, почему это приемлемо или как
будет проверено.
Это не гипотетическое опасение. Проверено экспериментально в реальном
Chromium (том же движке, что использует demo/serve.mjs/playwright):
const host = document.getElementById('host');
const root = host.attachShadow({ mode: 'open' });
root.innerHTML = '<div id="scroller" style="overflow:auto;height:100px">'
+ '<div style="height:1000px"></div></div>';
document.addEventListener('scroll', () => window.__docScrollSeen++, true);
// прокрутка #scroller внутри теневого корня:
root.getElementById('scroller').scrollTop = 50;
// → window.__docScrollSeen остаётся 0
Результат: { doc: 0, win: undefined } — document/window capture-слушатель
не видит scroll, произошедший на элементе внутри отдельного
shadowRoot, даже с capture: true. Причина: событие scroll не
composed, поэтому его путь диспетчеризации не покидает теневое дерево, в
котором оно возникло, — не помогает ни capture, ни bubble.
Сценарий issue («карточка занимает часть прокручиваемого дашборда Home
Assistant») — это именно многоуровневая Shadow DOM структура
(home-assistant → home-assistant-main → ha-panel-lovelace → hui-view и
т.д., каждый уровень — свой shadowRoot), и сама кодовая база это признаёт
косвенно (в ней уже есть прецеденты подъёма по getRootNode() именно для
обхода этой границы: src/hp-dialog.ts:329,464-465,
src/hp-zigbee-topology-overlay.ts:80,165). Если реальный скроллящийся
предок дашборда находится в чужом (не document) теневом дереве — что для HA
Lovelace типично, — выбранный слушатель на ownerDocument его не увидит, и
дефект №1 («после прокрутки тап переключает соседнее устройство») в реальном
эксплуатационном сценарии останется неисправленным.
Почему это блокирует, а не «инженерная деталь на усмотрение реализации».
AC1 и AC2 доказываются smoke-тестом на «прокручиваемой странице/контейнере»
без указания, что этот контейнер обязан быть за пределами document-дерева
самой карточки (внутри отдельного shadowRoot, как в реальном HA). Смок,
построенный на простом <div style="overflow:auto"> в light DOM, зазеленеет
и «докажет» AC1/AC2 — но не докажет исправление сценария, ради которого issue
заведён: пользователь листает Lovelace-дашборд HA, где скролл-контейнер
почти наверняка находится в стороннем теневом дереве. Это ровно тот случай,
когда «AC формально выполнены» и «сценарий не решён» расходятся (жёлтый
вердикт допустим и при выполненных AC).
Что нужно от автора при возврате: либо (а) заменить/дополнить механизм
инвалидации на способ, не зависящий от composed-пути scroll — например,
подъём по реальной цепочке скроллящихся предков через getRootNode()
(прецедент уже есть в проекте) или наблюдение за геометрией независимо от
scroll-событий (IntersectionObserver, который штатно пересекает границы
теневых деревьев), — либо (б) явно ограничить AC1/AC2 договорённой
доказуемой моделью и вынести недостающее покрытие в названный, а не молчаливый
риск с решением, что с ним делать. Раздел «Риски» такой решённости сейчас не
даёт — он просто называет факт и переходит к следующему пункту.
M1 — Medium (в скоупе), сопутствует H1. AC1/AC2 не требуют, чтобы smoke-контейнер пересекал границу shadow DOM
Файл: тело issue #613, раздел «Критерии приёмки», AC1 и AC2; раздел «План автотестов».
Даже если H1 будет закрыт правильным техническим решением, формулировка
AC1 («два реальных маркера... в прокручиваемой странице/контейнере») и AC2
не требуют, чтобы хотя бы один тестовый сценарий воспроизводил скролл именно
внутри стороннего shadowRoot, имитирующего реальное вложение карточки в
Home Assistant. Без этого требования разработчик может закрыть AC на light-DOM
контейнере, интуитивно решить, что «прокрутка есть — тест прошёл», и не
заметить, что боевой сценарий не покрыт. Это отдельная от H1 находка, потому
что даже правильная реализация нуждается в тесте, который умеет упасть именно
на этом классе регрессии — иначе AC6 (мутационная защита) будет мутировать
код, который сам тест не отличает от рабочего в реалистичном вложении.
Правится вместе с H1 в этом же цикле: формулировка AC1/AC2 должна явно
называть, что хотя бы один прогон использует scroll-контейнер за пределами
ownerDocument дерева карточки (внутри отдельного attachShadow), а не
только «прокручиваемую страницу».
Что проверено и корректно
- Все обязательные разделы §7.1 присутствуют: сценарий, что человек увидит до и после, проблема, скоуп/не-скоуп, контракт поведения (7 пунктов), UX, модель данных и миграция, i18n, AC1…AC6 с указанием способа доказательства, план автотестов, риски, откат, release-артефакты.
- Оба технических утверждения о текущем поведении кода (два pinch-пути пишут
_saveZoom()на каждыйpointermove; индекс не инвалидируется на scroll) подтверждены чтениемsrc/device-hit-owner.tsиsrc/houseplan-card.ts, а не приняты на слово автора. - Ссылка на арбитраж #564 (латч владельца pointer-последовательности) —
подтверждена структурой
DevicePointerOwnerLatchвdevice-hit-owner.ts; контракт «прокрутка не меняет владельца уже начатой последовательности» реализуем на существующем механизме без структурных изменений. - AC1–AC6 однозначны и каждый называет способ доказательства (
smoke,unit/contract + smoke,mutation); AC6 корректно требует двух раздельных мутантов на две независимые защиты (scroll-инвалидация и terminal-only persistence), что соответствует §2.7 (таблица «чем краснеет»). - Не-скоуп сформулирован явно и исключает ровно те смежные контракты (#563, #578, размеры капсул, порядок перекрытий, рендер), которые действительно не должны меняться этой задачей.
- «Принятые технические предположения» оформлены отдельным блоком с пометкой «можно поменять свободно» — процессуально корректно; H1 — это ровно реализация права ревьюера оспорить такое предположение (§7.1), а не претензия к оформлению.
- Продуктовых вопросов к владельцу нет и не требуется: всё найденное — техническое, разрешается на этом ревью, не эскалируется.
- Track (полный) обоснован верно: две поверхности/подсистемы (hit-index +
zoom persistence) и прямое влияние на touch-контракт/perf горячего пути —
критерии
smallдействительно не выполняются, «лёгкий трек» здесь был бы неверным выбором. - Влияние на i18n, миграцию, откат — корректно и полно названы как «нет»/ «revert коммита», без домыслов.
Чего не проверял
- Не запускал
tsc/test/build/смоки — на стадии ТЗ нет кода для прогона; единственный практический тест в этом раунде — точечный playwright-скрипт, подтверждающий утверждение о поведенииscroll-событий относительноshadowRoot(см. H1), не гейт продукта. - Не проверял реальную DOM-структуру актуальной версии Home Assistant
Lovelace (
hui-view/ha-panel-lovelace) вживую — вывод о «типичной многоуровневой Shadow DOM» опирается на общеизвестную архитектуру HA frontend и на то, что сама кодовая база уже решает эту границу в других местах (getRootNode()прецеденты), а не на прямое наблюдение внутри этого ревью. - Не оценивал производительность предложенного IntersectionObserver-подобного fallback (это было бы решением за автора, не моя роль на этом этапе).
- Не проверял issues #563/#578/#582 построчно — они прочитаны только по ссылкам из аналитики #613 для проверки отсутствия дублирования; сам их код вне скоупа этой задачи (заявлено ТЗ явно, и это корректно).
Вывод
Один High (H1) и один сопутствующий Medium-в-скоупе (M1), оба технические, оба возвращаются автору ТЗ в этом же issue. Остальная часть ТЗ методологически и процессуально готова — переработка ожидается точечной (раздел «Принятые технические предположения» + формулировка AC1/AC2), не переписыванием задачи целиком.
Материал раунда
- Ветка:
issue/613-scroll-hit-pinch-persist, коммит1241b9499c7b— ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет. - Дерево материала:
9a4518ab2014ca6190468cd459762dedbcf1ff0fgit log --all --format='%H %T' | grep 9a4518ab2014 - Тело issue:
40b48efe32b7ca1cbc2bf41d1b3da9d1f55bf042cb41d7fa691c9453fa563e79 - Вердикт конвейера:
yellow· High 1