31 KiB
SPEC-REVIEW-645-r1
Issue: #645 — «Plan editor: временно перемещать кнопку "Настройки комнаты", если она перекрывает элементы» (bug/polish, P2, полный трек — лёгкий трек аналитика отклонила: нарушен критерий §5 «нет нового UX-контракта», плюс задача трогает touch-контракт редактора). Заход: r1 · блокирующих циклов израсходовано 0 из 4.
Скоуп ревью
ТЗ живёт в теле issue #645, раздел ## ТЗ (решение владельца 2026-09-10,
#517). Ревьюер читает только тело issue и комментарии, без устных пояснений
автора. Материал: тело issue на момент чтения (2026-09-24), метки bug,
P2, polish, S4-spec-review. Реализация ещё не начиналась (хендофф-
комментарий автора: «продуктовый код не менялся… реализация не начиналась»),
поэтому диапазон origin/dev...HEAD пуст для класса A/B — на этом этапе
проверяется только текст.
Как проверялось
- Прочитаны
docs/SCOPE.md,docs/process/REVIEWER.md,AGENTS.md(порядок из инструкции),PROCESS.md§2.3–§2.5, §2.10, §4, §7.1, §7.2 — секции, определяющие объём и формат ревью ТЗ. - Прочитано полностью тело issue #645: секции до
## ТЗ(«Проблема», «Предлагаемое UX-решение», «Время жизни позиции», «Почему не только автоматическое уклонение», «Ожидаемое поведение», «Проверки для будущего ТЗ») и сам раздел## ТЗцеликом (Сценарий → «Принято предположительно»), плюс оба комментария («Оценка…», «Взял…», «Сделано…»). - Сверены все 14 пунктов «Контракта поведения» и 10 AC на однозначность, проверяемость и полноту покрытия — каждый пункт контракта закрыт минимум одним AC (проверено построчным сопоставлением, таблица приведена ниже в «Что проверено»).
- Каждое фактическое утверждение ТЗ о текущем коде сверено с исходниками, а
не принято на слово:
_renderRoomGear(),poleOfInaccessibility(room.poly),.rlgearbtn,pointer-events: auto—src/houseplan-editor-runtime.ts:10276–10300. Подтверждено: центр — плановая точкаc, транслируется вleft/top %через текущийview((c[0]-view.x)/view.w*100), то есть модель «временная позиция в координатах плана» технически реализуема поверх существующего рендера без структурных изменений.- «Существующий редакторский порог 3 CSS px» (контракт п.3) — найден и
подтверждён:
src/houseplan-card.ts:7031,Math.abs(ev.clientX-drag.sx)+Math.abs(ev.clientY-drag.sy) > 3в_pointerMoveNow(device drag),clientX/clientYуже в CSS-пикселях, от DPR/zoom не зависят — заявление ТЗ точное, не догадка. - Существующий паттерн подавления synthetic click —
_suppressClick/setTimeout(…, 0)вsrc/houseplan-card.ts(строки 1905, 5598, 6708, 6804, 6903–6904, 6941, 7062–7063) — подтверждает, что механизм из «Принято предположительно» («конкретный механизм… одноразового подавления synthetic click») уже есть готовый прецедент в этом же файле. pointInPolygonсуществует (src/logic.ts, используется также вsrc/houseplan-editor-runtime.ts,src/houseplan-card.ts) — ссылка ТЗ в «Принято предположительно» не выдумана.- Терминология кнопки (
room.settings_title/room.settings_short= «Настройки комнаты») сверена сsrc/i18n/ru.json:765,800иdocs/USER-GUIDE.ru.md:1621— совпадает, ТЗ не изобретает термин.
- Проверено взаимодействие с соседним поведением, читающим позицию кнопки:
поиск
gearCenter/poleOfInaccessibilityпоsrc/**вскрылsrc/resize-labels.ts:25–27,97,113–116и вызывающий кодsrc/houseplan-editor-runtime.ts:3404–3435(_rszEdgeLabels→placeResizeAreaLabel({ …, gearCenter: poleOfInaccessibility(poly), … })) — живая раскладка длин/площади при Resize стены уклоняется именно от позиции кнопки «Настройки комнаты». См. находку M2. - Проверено
docs/TOUCH-SUPPORT.mdцеликом, в частности «Documentation rule» (строки 214–223: три обязательных значенияTouch editor: …) — применительно к тому, что #645 является «new editor feature specification», трогающей touch-путь Plan editor. См. находку M1. - Проверены
docs/UX-MODES.md(модель трёх режимов/вкладок редактора,_mode: 'view'|'plan'|'devices'|'decor'вsrc/houseplan-card.ts:851) — пункт контракта 12 («сессия… заканчивается при выходе из редактора "План"») однозначно читается как «переключение на вкладку Устройств/ Подложки завершает сессию», конфликта или недосказанности не найдено. - Проверены обязательные разделы ТЗ по чек-листу
PROCESS.md§7.1 — присутствуют все: сценарий, что человек увидит, проблема, скоуп/не-скоуп, контракт поведения, UX и доступность, модель данных/миграция/i18n, AC1–10 с доказательством, план автотестов, риски, откат, release-артефакты. - Проверен DoR-чек-лист
PROCESS.md§2.5 пункт за пунктом (список — в «Что проверено»).
Этап spec: тяжёлые/browser-гейты не запускались и не нужны — код ещё не написан, ни один AC не реализован; проверке подлежит только текст ТЗ и его непротиворечивость уже существующему коду.
Находки
Medium (в скоупе задачи — без High это жёлтый вердикт, правится в #645)
M1. Отсутствует обязательная строка Touch editor: … из
docs/TOUCH-SUPPORT.md → «Documentation rule».
docs/TOUCH-SUPPORT.md:214–223 требует буквально для «new editor feature
specifications»: одно из трёх значений — Touch editor: supported /
Touch editor: best effort / intentionally degraded / Touch editor: not exposed, — записанное явной строкой. ТЗ #645 добавляет именно новую
touch-возможность редактора (одиночный touch/pen drag капсулы кнопки,
контракт п.2/8, AC6) и в прозе объясняет, что редактор «остаётся best effort
по docs/TOUCH-SUPPORT.md», но нигде не содержит требуемую формальную
строку. Тот же формальный пробел уже разбирался ревью на #449 (закрыт
добавлением ровно одной строки в шапку ТЗ, docs/reviews/SPEC-REVIEW-449-r1.md
M1) — прецедент показывает, что это не придирка к форме, а работающий
механизм, который здесь просто не применён.
Воспроизведение:
grep -n "Touch editor:" <(gh issue view 645 --repo Matysh/houseplan-card --json body -q .body)
даёт пусто; в то же время docs/specs/359-furniture-placement-preview.md:12
и docs/specs/449-double-fit-all.md:9 показывают действующий образец записи
для сопоставимых по риску editor-фич.
Почему это находка: это не стилистика — правило docs/TOUCH-SUPPORT.md
существует именно чтобы код-ревью и будущие читатели не собирали
touch-классификацию по кусочкам прозы, а видели один явный маркер. Задача
прямо подпадает под критерий «new editor feature specification».
Как чинится: добавить одну строку вида Touch editor: best effort / intentionally degraded (или supported — выбор буквы предположительно за
автором, поскольку это техническая классификация, а не продуктовый вопрос)
рядом с разделом «Сценарий» или «UX и доступность», с краткой ссылкой на
docs/TOUCH-SUPPORT.md.
M2. ТЗ не учитывает существующую зависимость live-подписей Resize от позиции кнопки «Настройки комнаты» — после реализации риск воспроизвести ту же проблему («подпись перекрывает кнопку»), которую issue должен решить.
src/houseplan-editor-runtime.ts:3404–3435 (_rszEdgeLabels, вызывается во
время активного перетаскивания стены в режиме Resize того же редактора
«План», см. src/houseplan-card.ts:1663–1667 — Resize это часть _mode === 'plan') строит живые подписи длины/площади и передаёт в
placeResizeAreaLabel() (src/resize-labels.ts:25–27,97,113–116)
gearCenter: poleOfInaccessibility(poly) — то есть всегда автоматический
центр, безусловно, без какой-либо временной поправки. Кнопка .rlgearbtn
при этом продолжает рендериться параллельно (this._markup ? space.rooms.map(...this._renderRoomGear...) : nothing,
src/houseplan-card.ts:11181) и, согласно контракту #645 п.9/12, может в
этот момент фактически находиться в другой, перетащенной пользователем
точке той же комнаты.
Итог: если пользователь в рамках одной сессии редактора сначала перетащил кнопку в свободное место, а затем начал Resize стены той же комнаты, алгоритм уклонения площади/длины по-прежнему обходит старую (уже не отображаемую) автоматическую точку. Он может как оставить подпись рядом с пустым местом без причины, так и, что хуже, отрисовать подпись поверх фактической, перетащенной позиции кнопки — то есть воспроизвести ровно тот дефект «элемент перекрыт и недоступен», ради устранения которого заведён #645, только теперь в паре «подпись Resize ↔ кнопка», а не «кнопка ↔ устройство/мебель/проём».
Раздел «Затронутые модули» не называет ни src/resize-labels.ts, ни
_rszEdgeLabels/placeResizeAreaLabel; «Скоуп» и «Не-скоуп» не упоминают
Resize вообще; ни один из AC1–AC10 не покрывает этот сценарий. Это не
догадка — оба вызова и точная передача poleOfInaccessibility(poly) без
альтернативы прочитаны в текущем коде (пути указаны выше).
Воспроизведение (чтением, не исполнением — кода ещё нет):
sed -n '3404,3435p' src/houseplan-editor-runtime.ts # gearCenter: poleOfInaccessibility(poly)
sed -n '25,27p;90,120p' src/resize-labels.ts # gearCenter используется для уклонения подписи
sed -n '11175,11182p' src/houseplan-card.ts # кнопка рендерится в том же _markup-режиме
Почему это находка, а не придирка: ревью ТЗ обязано искать не только
внутреннюю непротиворечивость AC, но и то, решает ли изменение заявленный
сценарий, не ухудшая соседний (docs/process/REVIEWER.md, «Находки и
вердикт»; PROCESS.md §2.7 по духу применимо уже на этапе спецификации,
поскольку риск детерминированно вытекает из уже читаемого кода, а не из
будущей реализации). Здесь риск не гипотетический: оба фрагмента кода уже
существуют и жёстко связаны единственным явным чтением
poleOfInaccessibility(poly).
Как чинится (любой вариант закрывает находку):
- явно включить в скоуп:
_rszEdgeLabels/placeResizeAreaLabelдолжны получать эффективную (временную, если она есть) позицию кнопки, а не всегда автоматическую — с отдельным AC и добавлением файла в «Затронутые модули»; либо - явно вынести в «Не-скоуп» с обоснованием и явным принятым риском (например: «одновременная комбинация активного Resize и перетащенной кнопки в той же комнате достаточно редка, деградация принимается»), чтобы код-ревью не спрашивал о ней как о непокрытой находке.
Любой из вариантов — правка внутри текущего ТЗ, отдельный issue не нужен: дефект — прямое следствие этой же задачи.
High / Low
Находок этих уровней нет.
Что проверено и корректно
- Выбор полного трека обоснован явно — аналитика называет нарушенный критерий §5 («нет нового UX-контракта») и указывает на touch-контракт редактора, а не отделывается фразой «обычный трек» (issue #338).
- Оба первых обязательных раздела
PROCESS.md§7.1 присутствуют и корректны: «Сценарий» называет персону (администратор дома,docs/SCOPE.md), поверхность (Plan editor, desktop) и момент (кнопка закрывает элемент); «Что человек увидит до и после» — одной фразой без терминов реализации. - Обязательные разделы ТЗ присутствуют все (сценарий · что человек
увидит · проблема · скоуп/не-скоуп · контракт поведения · UX/доступность ·
модель данных/миграция · i18n · AC1–AC10 · план автотестов · риски · откат
· release-артефакты) —
PROCESS.md§7.1 выполнен формально и по содержанию. - Контракт поведения (14 пунктов) покрыт критериями приёмки без пробелов: 1→AC1, 2→AC2/AC3 (косвенно, начало жеста), 3→AC3, 4→AC1, 5→AC2, 6→AC4, 7→AC3, 8→AC6, 9→AC5, 10→AC7, 11→AC8, 12→AC7, 13→AC7, 14→AC9; AC10 закрывает регрессии вне редактора. Ни один пункт контракта не остался без проверяемого AC.
- Каждый AC однозначен, привязан к конкретному наблюдаемому исходу и несёт
способ доказательства (
unit/browser smoke/ревью кода, без смешения «ручное тестирование» как доказательства — раздел Release-артефакты прямо это исключает). - Технические заявления о текущем коде верны, что подтверждено чтением
(см. «Как проверялось» п.4):
_renderRoomGear, порог 3 px, механизм подавления click,pointInPolygon— ничего не придумано. - Ограничение полигоном для вогнутой комнаты специфицировано корректно: «доходит до ближайшей допустимой точки на пути жеста» (а не «до ближайшей евклидовой точки границы») — формулировка исключает типичную ошибку прыжка в несвязанную допустимую область, реализуемо через пересечение сегмента жеста с границей полигона.
- Модель координат согласована с реальным рендером:
_renderRoomGearуже хранит и транслирует плановую точку черезviewв проценты экрана (src/houseplan-editor-runtime.ts:10289–10290) — контракт п.9 (позиция переживает pan/zoom) не требует новой модели преобразований, только замену источника точкиc. - Не-скоуп сформулирован явно и без размывания: collision-aware раскладка относительно устройств/мебели, сохранение позиции вне сессии, Undo/Redo, изменение исходного алгоритма/вида кнопки, выход за пределы комнаты, полная touch-паритетность — всё явно исключено, без формулировок «и подобное».
- Данные/миграция/совместимость закрыты явным «нет» — соответствует
DoR-пункту (§2.5): постоянная схема не меняется,
docs/CONFIG- COMPATIBILITY.mdне применяется к чисто временному UI-состоянию. - i18n закрыт корректно: новых строк нет, существующие ключи
room.settings_title/room.settings_shortне меняются (сверено сsrc/i18n/ru.json,src/i18n/en.json). - Производительность названа предметно, не общей фразой: O(1) по
roomId, ограничение геометрии только для активного жеста, переиспользование существующего coalescing pointermove (тот же паттерн, что уже используется для device drag,_queuePointerMove('device', …),src/houseplan-card.ts:7016) — реалистично, не выдумана мощность. - Откат назван и реалистичен: убрать обработчики/временное отображение,
_renderRoomGear()возвращается к чистому автоматическому центру без миграции — пользовательских данных для отката нет, потому что позиция никогда не сохранялась. - Release-артефакты названы полно: оба changelog со ссылкой на #645,
оба
USER-GUIDE(en+ru),STATUS.mdпри наличии соответствующего раздела, явное «нет» для golden (обоснованно — статичный idle-кадр не меняется, см. также M2 о смежном Resize-сценарии, не о golden) и для performance/security/backend/migration notes. - Риски перечислены содержательно и каждый закрыт конкретным AC
(проверено построчно): synthetic click → AC3; потерянный pointerup/
grabbing→ AC6 (см. также ниже проlostpointercapture, отмечено как «не проверял» — технический нюанс уже прикрыт разделом «Принято предположительно»); второй touch/pinch-owner → AC6; смешение экранных/ плановых координат при DPR → AC5; clamp для вогнутой комнаты → AC4; устаревшийroomId/полигон → AC8; лишнийrequestUpdateна pointermove → раздел «Производительность» + план автотестов. - Блок «Принято предположительно» корректно отделяет техническое от
продуктового: имена полей, структура состояния (
Map), выборpointInPolygon/бинарного поиска, механизм pointer capture и подавления click, величина визуального подъёма — всё нечувствительно для пользователя и справедливо оставлено на усмотрение исполнителя/ревьюера код-ревью, без подмены продуктовых решений (позиция кнопки, её видимый вид, порог 3 px, время жизни) техническими догадками. - Открытых продуктовых вопросов действительно нет: единственная
потенциально спорная точка — граница «сколько touch-паритета входит в
скоуп» — разрешена ТЗ явно и без выдумки (одиночный pointer — да, полная
паритетность — нет), совпадает с прежним owner-решением
docs/TOUCH-SUPPORT.md(«editors are desktop-first… best effort»), не требует нового решения владельца.
Чего не проверял и почему
- Реализацию — её нет: диапазон
git diff origin/dev...HEADне содержит ни одного файла класса A/B на момент ревью; хендофф-комментарий автора прямо это подтверждает. npx tsc --noEmit/npm test/npm run build/node scripts/check-docs.mjs— не гонял: ни одна строка кода ещё не изменена, прогон гейтов на этапе ревью ТЗ бессмыслен (§2.4 их не требует).- Browser smoke / golden / performance / mutation — не запускались по
той же причине; план автотестов ТЗ оценён на реалистичность (существующие
примитивы
_queuePointerMove,_suppressClick,pointInPolygon,mutation-gate.mjs-конвенции существуют), но не исполнялся. - Тонкость
lostpointercaptureпри штатном завершении drag — контракт п.7/8 и AC3/AC6 в сумме требуют, чтобы обычныйpointerupпосле drag сохранял позицию, аlostpointercapture— откатывал к началу жеста; в браузерахlostpointercaptureштатно следует сразу заpointerup, когда элемент удерживал capture, так что наивная реализация «любойlostpointercapture= отмена» откатывала бы даже успешный drag. Это не противоречие ТЗ по существу — стандартная идиома (различать «capture потеряна без завершающегоpointerupтой же последовательности» и «capture отпущена вследствиеpointerup») закрывает вопрос, и ТЗ уже относит «конкретный механизм pointer capture» к «Принято предположительно» — технический выбор, а не пробел контракта. Не поднимаю отдельной находкой (Low, снята решением ревьюера с записью): проверяемо будущим unit-тестом реордера жеста (план автотестов, п.1), и не требует вмешательства владельца. - Существование/содержимое смоков, которые план автотестов обещает
повторно прогнать (
smoke_room_settings,smoke_room_cards,smoke_feedback_v2,smoke_hide_room_names) — не открывал построчно; для целей spec-ревью достаточно, что файлы называются по существующей конвенции (demo/smoke_*.mjs), их фактическое существование и охват — забота код-ревью при реализации. docs/CANVAS.md,docs/WALL-THICKNESS.md,docs/LIGHT.md,docs/SUN.mdцеликом — не читал: ни один из них не является каноническим документом предметной области этой задачи (Plan editor UI-жест, не геометрия стен, свет или солнце);docs/UX-MODES.mdиdocs/TOUCH-SUPPORT.mdпрочитаны как релевантные каноны.- История issue #645 до текущей редакции тела — issue не редактировался
после публикации (
edited: falseво всех трёх комментариях и в самом issue по даннымgh issue view), поэтому вопрос «на каком тексте вынесен вердикт» не возникает; хеш тела для якоря ниже.
Материал раунда
- Issue: #645, метки на момент ревью:
bug,P2,polish,S4-spec-review. - ТЗ: раздел
## ТЗтела issue #645 (единственная редакция, r1,edited: false). - Комментарии учтены: «Оценка…» (аналитика, полный трек обоснован), «Взял:
автор ТЗ · сессия Codex · ветка
issue/645-room-settings-button-drag», «Сделано: … реализация не начиналась». - Рабочее дерево репозитория на момент чтения кода:
2b3959e55bcb139b6ad872a312ad212de36f44db(справочно, для проверки фактических заявлений ТЗ о текущем коде — не является материалом самого ТЗ, т.к. ветка задачи не создавала коммитов поверх него).
Вердикт
Вердикт: жёлтый · заход r1 · блокирующих циклов 0/4 · High: 0 · Medium: 2 → в задаче | — · Документ: docs/reviews/SPEC-REVIEW-645-r1.md
Обе находки (M1, M2) — в скоупе задачи #645 и без High-находок дают жёлтый
вердикт (PROCESS.md §2.4, §12): ТЗ возвращается автору на правку. M1
чинится одной строкой; M2 требует явного решения — включить Resize-подписи в
скоуп с AC или явно и обоснованно исключить их с принятием риска. Обе правки
локальны для текста ТЗ, следующий цикл разбирается по дельте (§2.10).
Материал раунда
- Ветка:
issue/645-room-settings-button-drag, коммит2b3959e55bcb— ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет. - Дерево материала:
7f08cac08613119d6e48b96c345db826e22d4b9agit log --all --format='%H %T' | grep 7f08cac08613 - Тело issue:
9102a02fd0ea8a92464cbbe6662e2932cb711640384b5f99ce28f04f3acded91 - Вердикт конвейера:
yellow· High 0