# Issue #300 — Понятные подписи и измеряемые стены во время Resize - Дата: 2026-08-24 - Тип: feature / polish · приоритет P2 - Ценность пользователю: 7/10 · ценность разработке: 4/10 - Сложность: 5/10 · риск: 5/10 - Issue: [#300](https://github.com/Matysh/houseplan-card/issues/300) - Ветка: `issue/300-resize-labels` Канонические документы: `docs/SCOPE.md`, `docs/RESIZE.md`, `docs/CANVAS.md`, `docs/USER-GUIDE.ru.md`, `docs/USER-GUIDE.md`, `docs/ARCHITECTURE.md`, `docs/TOUCH-SUPPORT.md`. Связанные контракты: [#233](233-resize-inner-dimensions.md) — внутренние размеры, [#277](277-safe-resize.md) — fixed-topology Resize. ## 1. Сценарий и персона Администратор дома на компьютере открывает **Редактор плана**, выбирает **Resize** и тянет горизонтальную или вертикальную стену. Во время жеста он смотрит, как меняются две соседние длины и чистая площадь одной либо двух комнат. Это улучшение J6 из `docs/SCOPE.md`: редактор помогает поддерживать план в актуальном состоянии. View, киоск, редактор устройств, редактор подложки и статическая карточка не затронуты. ## 2. Что человек увидит до и после **До:** рядом со стенами висят три длины без визуальной связи с измеряемыми отрезками; одна из них относится к перемещаемой стене и не меняется. Площадь находится в середине комнаты и может оказаться под кнопкой её настроек. **После:** видны только две меняющиеся длины, а соответствующие соседние стены подсвечены. Площадь каждой затронутой комнаты находится со своей стороны перемещаемой стены и связана с ней короткой выносной линией. ## 3. Подтверждённая проблема Текущий `_rszEdgeLabels()` в `src/houseplan-card.ts`: 1. добавляет длины предыдущего, перемещаемого и следующего ребра основной комнаты; 2. не передаёт в render model идентичность измеряемого ребра, поэтому отдельной подсветки нет; 3. ставит площадь каждой изменяемой комнаты в `poleOfInaccessibility(floor)` — туда же, где в Plan editor находится `.rlgearbtn`. Числа уже корректны по #233: `_rszInnerSpanCms()` измеряет между внутренними гранями, а площадь считается по чистому полу через `innerContourForRoom()` и `floorMinusBodies()`. Задача не меняет математическую конвенцию чисел — только отбор, связь и расположение. ## 4. Зафиксированные продуктовые решения 1. Длина перемещаемой стены не показывается: движение параллельно самой себе не меняет её длину. 2. Показываются две длины соседних рёбер **основной комнаты**, которые уже показывались по обе стороны от перемещаемого ребра. На общей стене не добавляются ещё две дублирующие длины соседней комнаты. 3. Оба ребра с показанными длинами подсвечиваются всё время активного жеста. 4. Площадь располагается по нормали от середины перемещаемой стены в сторону соответствующей комнаты. Для общей стены одновременно видны две площади по разные стороны. 5. Решение владельца по Q1 от 2026-08-24: принят **не default, а альтернатива**. Площадь остаётся видимой всегда. Если плашка не помещается внутри узкой комнаты, ей разрешено выйти за границу комнаты; выносная линия продолжает однозначно связывать её с нужной стороной стены. Площади не перекрывают друг друга. 6. Кнопка настроек комнаты остаётся видимой. Если nominal area-плашка попадает в zoom-dependent footprint кнопки для текущего `view.w`, вычисленный по `iconCqw()`, плашка сдвигается вдоль перемещаемой стены до первого свободного положения; leader сохраняет связь с исходным midpoint. Пункт 5 заменяет первоначальный AC6 в теле issue, где требовалось удерживать плашку внутри комнаты: владелец явно выбрал альтернативу в комментарии. ## 5. Скоуп ### Входит - render model двух изменяемых длин и двух соответствующих подсвеченных рёбер; - pointer-transparent SVG-подсветка поверх кладки; - размещение одной/двух площадей у перемещаемой стены; - короткие выносные линии для площадей; - collision-free раскладка area-плашки относительно room settings button; - horizontal/vertical, outer/shared и оба направления drag; - unit, browser smoke, golden, mutation guards и RU/EN документация. ### Не входит - математика длины и площади #233; - eligibility, clamp, preview, commit, Undo и opening movement #277; - новые виды Resize, диагональные стены и изменение топологии; - постоянные размеры в View (#52) и подписи размещения проёмов (#238); - изменение Room card или сохранённой позиции её подписи; - config/backend, storage, схема, миграция и compatibility-поля. ## 6. Контракт проекции подписей Из `_rszEdgeLabels()` выделяется renderer-independent проекция (рабочее место — новый `src/resize-labels.ts`). Вход: - immutable `SafeResizePlan`; - candidate `res.polys` после `clampSafeResize()`; - два уже вычисленных текста внутренних длин основной комнаты; - тексты площадей комнат из `plan.roomIds`; - текущий SVG viewBox. Выход не содержит Lit/DOM и различает три сущности: ```ts type ResizeLengthLabel = { kind: 'length'; roomId: string; edge: number; x: number; y: number; text: string; }; type ResizeMeasuredEdge = { roomId: string; edge: number; a: Pt; b: Pt; }; type ResizeAreaLabel = { kind: 'area'; roomId: string; x: number; y: number; text: string; side: 'left' | 'right' | 'above' | 'below'; tangentOffsetPx: number; leader: { a: Pt; b: Pt }; }; ``` Имена типов технические и могут измениться без продуктового решения. ### 6.1 Две длины Для `plan.roomId` и `plan.edge = i` возвращаются только рёбра: - `(i - 1 + n) % n`; - `(i + 1) % n`. Само ребро `i` отсутствует и в label model, и в DOM. Текст продолжает брать число из `_rszInnerSpanCms()` и форматировать через существующий `formatLength()`. Позиция остаётся в середине соответствующего ребра candidate, чтобы не менять привычную связь подписи со стеной. ### 6.2 Подсветка измеряемых стен Каждая из двух длин имеет ровно один `ResizeMeasuredEdge` с теми же room/edge и candidate endpoints. Подсветка: - повторяет сохранённую ось стены от endpoint до endpoint; это идентификатор стены, а не новая размерная линия; - рисуется сплошным accent-штрихом с theme-aware halo; - имеет `vector-effect="non-scaling-stroke"`, одинаково читается при zoom и `cell_cm` 1/5 см; - находится после физических wall bodies, но до символов проёмов и Resize handles; - `pointer-events:none`, `aria-hidden=true`, без анимации. Подсветка появляется только после фактического ненулевого preview move и исчезает синхронно с `_rszLive` на pointerup, Esc, pointercancel, lostpointercapture, pinch, смене инструмента/пространства/режима. ### 6.3 Площадь по сторонам стены Для каждого `roomId` из `plan.roomIds` берётся его moving edge из `plan.edgeByRoom[roomId]` и candidate poly. Внутренняя сторона определяется по самому candidate polygon, а не по направлению записи endpoint: - вертикальная стена даёт `left` или `right`; - горизонтальная — `above` или `below`. HTML-плашка якорится на midpoint moving edge. CSS-смещение использует **полный собственный размер плашки**, поэтому не требует синхронного DOM measurement: - `left`: плашка заканчивается за 12 CSS px до midpoint; - `right`: начинается через 12 CSS px после midpoint; - `above`: нижняя грань за 12 CSS px до midpoint; - `below`: верхняя грань через 12 CSS px после midpoint. Таким образом две плашки общей стены занимают противоположные полуплоскости и не могут перекрыться независимо от длины локализованного текста. На наружной стене строится одна плашка на стороне комнаты. Для collision check используется консервативный screen-space footprint плашки, вычисленный из форматированного текста и font/padding tokens, а также zoom-dependent footprint `.rlgearbtn`, вычисленный для текущего `view.w` по той же `iconCqw()`-семантике, что использует CSS кнопки. Nominal position сначала проверяется против gear той же комнаты. При пересечении helper выбирает минимальный сдвиг по касательной к moving wall; при равных вариантах стабильный порядок — к меньшей screen-coordinate. Выход за room polygon разрешён решением владельца, поэтому такой сдвиг не скрывает число и не требует менять сторону стены. Production gesture path не вызывает `getBoundingClientRect()` и не читает layout; фактические DOM rectangles проверяются браузерным smoke как post-render доказательство консервативности footprint. Короткая выносная линия идёт от midpoint стены на 12 CSS px в сторону плашки. Перевод screen px в render units использует текущий viewBox/stage size; stroke остаётся screen-fixed. Линия присутствует у каждой area-плашки: в обычной комнате это стабильная визуальная связь, а в узкой сохраняет принадлежность, когда дальний край плашки выходит за room polygon. Плашку не прячут, не обрезают, не переносят на противоположную сторону и не уменьшают. ## 7. Слои, жизненный цикл и безопасность жеста Новый SVG measurement layer рисуется в существующем Plan SVG. HTML labels остаются в `.measurelayer`. Оба слоя получают данные из одного projection object на том же accepted preview, поэтому линия, подсветка и число не могут относиться к разным candidate frames. Room settings buttons продолжают рендериться и не получают нового состояния. Area projection использует тот же вычисленный visual centre, что `_renderRoomGear()`, поэтому collision avoidance не создаёт вторую модель позиции кнопки. Новые элементы pointer-inert и не меняют capture/hit priority. Существующие гарантии `docs/RESIZE.md` обязательны без изменений: - preview строится из immutable snapshot; - config не меняется до pointerup; - cancel/pinch/pointercancel/lost capture дают ноль Undo и ноль writes; - commit сохраняет ровно существующий fixed-topology candidate. ## 8. UX и доступность - Новых кнопок, полей, сообщений и tooltip нет. - Цвет подсветки — существующий `--hp-accent`; halo использует фон темы. - Area-плашка сохраняет существующий `formatArea()` и оформление `.rszarea`. - Measurement overlay transient, non-focusable и `aria-hidden`; экранный диктор не получает поток значений на каждом pointermove. - `prefers-reduced-motion` ничего не меняет: новой анимации нет. - Light/dark темы обязаны давать одинаковую структуру и читаемый контраст. `Touch editor: best effort / intentionally unchanged.` Resize остаётся desktop-reference. Если существующий touch drag сработал, он получает те же подписи; новой hover-зависимости нет. Safety floor touch сохраняется и входит в smoke отмены. ## 9. Модель данных, миграция, i18n **Модель данных:** не меняется. Новых config/layout полей и WebSocket calls нет. **Миграция/compatibility:** отсутствуют. Старые планы не переписываются; обычный Open/Save ничего не материализует. **i18n:** новых ключей нет. Используются существующие `formatLength()` и `formatArea()`. Переводы `src/i18n/en.json` и `src/i18n/ru.json` не должны измениться. ## 10. Изменяемые файлы и модули Ожидаемый набор: - `src/resize-labels.ts` — чистая проекция двух длин, сторон площадей и leader; - `src/houseplan-card.ts` — сбор фактических значений и render/lifecycle; - `src/styles.ts` — measured-edge, leader и side transforms; - `test/resize-labels.test.mjs` — pure contract; - `test/resize-production-path.test.mjs` — production ownership/lifecycle; - `demo/smoke_resize_labels.mjs` — outer/shared/narrow/horizontal/vertical; - `demo/benchmark_safe_resize_render.mjs` — существующий real-render gate с абсолютным `RENDER_P95_MS = 25`; исходник benchmark менять не требуется; - `demo/golden/harness.mjs`, при необходимости `demo/golden/matrix.mjs` — semantic checks active Resize scene; - `scripts/mutation-gate.mjs` — guards §12; - `docs/RESIZE.md`, `docs/ARCHITECTURE.md`, `docs/USER-GUIDE.ru.md`, `docs/USER-GUIDE.md`; - `docs/CHANGELOG.md`, `docs/CHANGELOG.ru.md`. Точный diff определяется реализацией; class A/B файлы вне этих подсистем — находка вне скоупа. ## 11. Acceptance criteria | AC | Требование | Доказательство | |---|---|---| | AC1 | После ненулевого preview move `_rszLive`/projection содержит ровно две length-подписи основной комнаты: previous и next edge; moving edge отсутствует | unit + smoke | | AC2 | Тексты обеих длин равны текущему внутреннему контракту #233; площадь равна прежнему clean-floor значению | existing #233 smoke + smoke | | AC3 | Ровно две подсветки повторяют candidate endpoints тех же room/edge, лежат поверх wall body и не принимают pointer events | unit + smoke + code review | | AC4 | Наружная горизонтальная и вертикальная стена показывают одну area-плашку со стороны комнаты и одну выносную линию | unit + smoke | | AC5 | Общая горизонтальная и вертикальная стена показывают две area-плашки с разными `roomId` по противоположным сторонам и две выносные линии | unit + smoke | | AC6 | В narrow-room fixture обе площади остаются в DOM, могут выйти за room polygon по решению владельца, но их фактические DOM rectangles не пересекаются; leader ownership остаётся однозначным | smoke | | AC7 | При horizontal/vertical и обоих направлениях drag room settings button остаётся видимой, а её фактический DOM rectangle не пересекает area-плашку той же комнаты | unit + smoke | | AC8 | Pointerup очищает measurement overlay; Esc, pointercancel, lost capture и pinch также очищают его и создают 0 Undo/0 writes | smoke | | AC9 | Light/dark active-Resize golden показывает две подсвеченные стены, две площади shared wall и читаемые leaders; принятие baseline только из полного reviewed Linux CI artifact | golden review | | AC10 | User-visible docs и оба changelog обновлены в том же коммите; i18n/schema/backend отсутствуют в diff | code review | | AC11 | Existing `demo/benchmark_safe_resize_render.mjs` остаётся внутри реального абсолютного потолка `RENDER_P95_MS = 25`; `_rszMove` не делает forced layout read | benchmark + code review | ## 12. Mutation guards | id | Поломка | Краснеет | |---|---|---| | `resize-labels-restores-moving-length` | возвращает третью неизменяемую длину | AC1 | | `resize-labels-drops-measured-edge` | одна длина остаётся без соответствующей подсветки | AC3 | | `resize-labels-same-side-areas` | обе площади общей стены ставятся с одной стороны | AC5/AC6 | | `resize-labels-hide-narrow-area` | narrow fallback скрывает одну площадь вопреки решению владельца | AC6 | | `resize-labels-ignore-gear-collision` | nominal area-плашка не сдвигается от room settings button | AC7 | | `resize-labels-cancel-leak` | measurement overlay переживает abort | AC8 | ## 13. План автотестов 1. `test/resize-labels.test.mjs`: - previous/next edge modulo polygon length; - moving edge отсутствует; - horizontal/vertical room-side projection независимо от winding и endpoint direction; - shared owners получают противоположные стороны; - leader переводит 12 CSS px в правильное число render units. 2. `demo/smoke_resize_labels.mjs` запускает production `_rszEdgeDown → _rszMove → _rszUp` реальными browser pointer events: - outer horizontal и vertical; - shared wall с двумя rooms; - positive/negative drag; - narrow room с фактической проверкой `getBoundingClientRect()` двух badges; - default и non-default zoom, чтобы фактический zoom-dependent rectangle `.rlgearbtn` оставался вне area-плашки; - фактическое отсутствие overlap с видимой room settings button и все abort paths. 3. `demo/smoke_resize_inner_dimensions.mjs` остаётся зелёным и доказывает, что числа #233 не изменены. 4. Existing `safe-resize-handles-clamp-light/dark` golden используется как визуальная сцена; harness дополнительно fail-closed проверяет semantic DOM до screenshot. 5. Перед `S7-code-review`: `npm run typecheck`, `npm test`, `npm run build`, `npm run bundle:sync`, `node scripts/check-docs.mjs`, вывод `node scripts/smoke-select.mjs --base origin/dev --head HEAD` и все выбранные target smokes, включая `node demo/benchmark_safe_resize_render.mjs` как доказательство абсолютного p95-бюджета 25 ms. ## 14. Производительность, security, touch Проекция O(1) по числу затронутых комнат (максимум две) и создаёт фиксированные две measured edges, 1–2 area labels и 1–2 leaders. CSS placement использует собственный размер элемента и не читает layout синхронно в pointermove. Тест может читать DOM rectangles после settled frame; production — нет. Security и HA actions не затронуты: элементы pointer-inert, новых строк/URL/ HTML-ввода нет. Текст проходит существующие formatter/Lit boundaries. Touch — best effort, без изменения существующей поддержки; cancellation safety остаётся блокирующей. ## 15. Риски и митигации 1. **Плашка узкой комнаты визуально окажется в соседнем помещении.** Это сознательно принято владельцем. Короткая leader line и сторона moving wall являются обязательной связью; скрытие/обрезка запрещены. 2. **Winding и обратное направление shared edge перепутают стороны.** Сторона вычисляется по candidate polygon interior и покрывается зеркальными unit fixtures. 3. **Highlight перехватит жест или перекроет проём.** Слой pointer-transparent и расположен ниже opening symbols/handles. 4. **Консервативный footprint разойдётся с фактическим CSS.** Размеры выводятся из общих tokens, а smoke сравнивает реальные `getBoundingClientRect()` в horizontal/vertical и narrow fixtures. 5. **Forced reflow на каждом pointermove.** Production placement не измеряет DOM; mutation/code review стережёт отсутствие `getBoundingClientRect()` в gesture path. ## 16. Откат Одна frontend-ревизия: вернуть старую форму `_rszLive` и прежний render без measured-edge/leader layers. Данных и миграции нет, backend откатывать не нужно. Откат возвращает прежние три длины и площадь в центре комнаты; это допустимая техническая деградация, а не повреждение плана. ## 17. Release-артефакты - `docs/CHANGELOG.md` и `docs/CHANGELOG.ru.md` со ссылкой на #300; - `docs/USER-GUIDE.ru.md` и `docs/USER-GUIDE.md`: Resize показывает две меняющиеся длины у подсвеченных стен и площадь у moving wall; - `docs/RESIZE.md` и `docs/ARCHITECTURE.md`: projection/layer/lifecycle; - reviewed light/dark active-Resize golden. Baselines принимаются только `npm run golden:accept -- --reviewed` из полного Linux CI artifact с обязательными `Release:` и `Baseline-Reviewed:` trailers; - любая правка `src/**` обновляет screenshot fingerprint. Если `node scripts/check-docs.mjs` требует пересъёмку, запускается `Docs screenshots`, а полный artifact принимается через `npm run docs:accept -- --reviewed --from=`; - pre-beta: полный golden/smoke/performance по общему процессу. ## 18. Принятые предположения (техническое, менять свободно) 1. Новый pure helper живёт в `src/resize-labels.ts`; допустимо оставить его в существующем Resize module, если импортный граф и тестируемость лучше. 2. CSS gap равен 12 px — существующий размер opening-dimension labels и достаточно короткая визуальная связь. Reviewer может скорректировать число без продуктового вопроса, сохранив противоположные полуплоскости. 3. Leader рисуется всегда, а не только после определения выхода за polygon. Это избегает forced layout read и сохраняет стабильную ownership-связь без визуального переключения режима во время drag. 4. Collision helper использует консервативную расчётную ширину текста и zoom-dependent footprint room gear для текущего `view.w`, согласованный с `iconCqw()`. Допустима другая pure screen-space стратегия, если кнопка остаётся видимой, фактические rectangles не пересекаются на default и non-default zoom и pointermove не читает layout. 5. Highlight использует два screen-fixed strokes (halo + accent); точные ширины являются темизацией, не продуктовым решением. Не являются предположениями: отсутствие moving-wall length, две подсвеченные соседние стены, area по обе стороны shared wall, всегда видимая narrow-room площадь с leader и отсутствие overlap с видимой room settings button — это acceptance contract.