Files
houseplan-card/docs/reviews/SPEC-REVIEW-645-r1.md
T
2026-09-25 08:06:13 +03:00

31 KiB
Raw Blame History

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 — на этом этапе проверяется только текст.

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

  1. Прочитаны docs/SCOPE.md, docs/process/REVIEWER.md, AGENTS.md (порядок из инструкции), PROCESS.md §2.3–§2.5, §2.10, §4, §7.1, §7.2 — секции, определяющие объём и формат ревью ТЗ.
  2. Прочитано полностью тело issue #645: секции до ## ТЗ («Проблема», «Предлагаемое UX-решение», «Время жизни позиции», «Почему не только автоматическое уклонение», «Ожидаемое поведение», «Проверки для будущего ТЗ») и сам раздел ## ТЗ целиком (Сценарий → «Принято предположительно»), плюс оба комментария («Оценка…», «Взял…», «Сделано…»).
  3. Сверены все 14 пунктов «Контракта поведения» и 10 AC на однозначность, проверяемость и полноту покрытия — каждый пункт контракта закрыт минимум одним AC (проверено построчным сопоставлением, таблица приведена ниже в «Что проверено»).
  4. Каждое фактическое утверждение ТЗ о текущем коде сверено с исходниками, а не принято на слово:
    • _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 — совпадает, ТЗ не изобретает термин.
  5. Проверено взаимодействие с соседним поведением, читающим позицию кнопки: поиск 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.
  6. Проверено docs/TOUCH-SUPPORT.md целиком, в частности «Documentation rule» (строки 214–223: три обязательных значения Touch editor: …) — применительно к тому, что #645 является «new editor feature specification», трогающей touch-путь Plan editor. См. находку M1.
  7. Проверены docs/UX-MODES.md (модель трёх режимов/вкладок редактора, _mode: 'view'|'plan'|'devices'|'decor' в src/houseplan-card.ts:851) — пункт контракта 12 («сессия… заканчивается при выходе из редактора "План"») однозначно читается как «переключение на вкладку Устройств/ Подложки завершает сессию», конфликта или недосказанности не найдено.
  8. Проверены обязательные разделы ТЗ по чек-листу PROCESS.md §7.1 — присутствуют все: сценарий, что человек увидит, проблема, скоуп/не-скоуп, контракт поведения, UX и доступность, модель данных/миграция/i18n, AC1–10 с доказательством, план автотестов, риски, откат, release-артефакты.
  9. Проверен 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 — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
  • Дерево материала: 7f08cac08613119d6e48b96c345db826e22d4b9a
    git log --all --format='%H %T' | grep 7f08cac08613
    
  • Тело issue: 9102a02fd0ea8a92464cbbe6662e2932cb711640384b5f99ce28f04f3acded91
  • Вердикт конвейера: yellow · High 0