Files
houseplan-card/legacy/specs/264-resize-controller.md
Claudeandclaude[bot] df46fd1c3e docs(hygiene): ТЗ выпущенных задач без живых ссылок — в legacy/specs (#682)
Волна 5 эпика #674, перенос ТЗ (класс C). Из 240 файлов `docs/specs/` в
`legacy/specs/` уехали 219: на них не ссылается ни один живой файл (код,
тесты, скрипты, workflow, документы вне архива и ревью). Остались 21 ТЗ —
на которые ссылаются код, ADR, ISOMETRIC, SUN, RADAR, LIGHT (`docs/specs/067`),
DECOR-EDITOR, support-relay, и те, на которые ссылаются они сами; README
каталога объясняет, где искать остальное. Открытых issue с файлом ТЗ среди
перенесённых нет. Относительные ссылки перенесённых файлов переписаны
(`../X` → `../../docs/X`, соседние оставшиеся ТЗ → `../../docs/specs/…`) —
все 26 резолвятся. Попутно: битая ссылка в
`089-isometric-view-stage1.md:8` на удалённый `089-isometric-view.md` —
теперь команда `git show` по истории. Строка в `legacy/README.md`.

Issue: #682
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-09-27 22:10:46 +00:00

31 KiB
Raw Permalink Blame History

Issue #264 — отдельный контроллер Resize и commit-инвариант кладки

  • Issue: https://github.com/Matysh/houseplan-card/issues/264
  • Статус: первая редакция для независимого ревью; канонический статус задаётся метками issue
  • Тип / приоритет: tech-debt / P1
  • Оценка: пользовательская ценность 2/10; ценность для разработки 9/10; сложность 7/10; риск 8/10
  • Класс изменения: A + B + C, полный процесс
  • User-Visible: no — DOM, пиксели, тексты, жесты и persisted config не меняются
  • Связано: #34, #253, #254, #277; docs/RESIZE.md, docs/specs/034-frontend-decomposition.md

1. Сценарий, персона и проблема

Домашний администратор на desktop открывает редактор плана существующего этажа, выбирает Resize и перетаскивает обычную либо общую стену. Для него этот слайс обязан быть полностью незаметен: допустимость ручки, движение, измерения, сохранение, отмена и повтор остаются теми же, что в выпущенном контракте #277. Задача нужна не ради нового пользовательского шага, а чтобы следующие изменения этого критичного пути можно было делать и проверять изолированно.

После #277 Resize имеет безопасный fixed-topology контракт, но его конечный автомат по-прежнему встроен в HouseplanCard: там одновременно живут pointer session, immutable snapshot, eligibility cache, live preview, измерения, fail-closed commit, Undo и восстановление wall-union cache. Любая следующая правка этого инструмента снова меняет крупнейший компонент проекта и должна доказывать корректность переходов по разрозненным полям _rsz*.

#264 — refactor-only слайс канонической декомпозиции #34. Он выносит состояние и переходы одного инструмента в отдельный typed controller. Геометрические формулы остаются в src/resize.ts, а Lit/DOM, перевод, toast, config/history, серверная запись и рендер остаются у корневого компонента.

Одновременно transaction boundary получает production-инвариант сохранения wall records. Именно нарушение этого свойства вызвало #253. Сейчас похожая проверка существует как отдельная реализация в CLI-инвариантах и как inline сравнение внутри Resize; после #264 алгоритм находится в одном production- модуле и используется обоими путями.

Что человек увидит

Ничего нового. До и после изменения:

  • те же handles, disabled reasons, preview, подписи длины/площади и cursor;
  • те же границы safe drag, snap и clamp;
  • те же commit, Cancel/Escape, pointercancel/lostpointercapture и Undo/Redo;
  • те же пиксели View, Plan и остальных редакторов;
  • тот же JSON успешного Resize byte-for-byte.

Если внутренний инвариант нарушен, сохраняется уже принятое в #277 поведение: preview останавливается либо commit отменяется с существующим безопасным сообщением; config write и Undo-команда не создаются.

2. Зафиксированные решения и исходный контракт

  1. #277 уже реализован и выпущен. Его ТЗ и docs/RESIZE.md являются функциональным каноном; #264 не пересматривает eligibility, geometry, disabled reasons, preview или commit semantics.
  2. Старый продуктовый вопрос из тела #264 закрыт решением #277: при любом нарушении инварианта жест fail closed — без частичного применения, диагностики с пользовательскими данными, write или Undo.
  3. Владелец явно выбрал #264 следующим слайсом, поэтому прежнее сомнение «сначала небольшой диалог или Resize» закрыто.
  4. Schema/model version, compatibility fields и миграции не меняются.
  5. allowClear для Resize всегда false. Очистка толщины относится к другому явному инструменту и не может быть побочным эффектом перемещения стены.
  6. Opened/disabled handles и мышь/клавиатура/touch сохраняют поведение #277. Осознанная desktop-first деградация редакторов из docs/TOUCH-SUPPORT.md не расширяется.
  7. _rszSel не имеет renderer-потребителя после #277, но не является полностью мёртвым: клик по площади комнаты запоминает latent selection, после чего первый Escape очищает её и оставляет Resize активным, а второй возвращает neutral Draw. Это наблюдаемое keyboard-поведение сохраняется. Само поле уезжает из root в controller как selectedRoomId; warm viewport продолжает переносить это session-only значение через typed getter/restore, не меняя сериализуемый config.

Открытых продуктовых вопросов нет.

3. Термины

  • Root / host — HouseplanCard, composition shell, владелец браузерных и инфраструктурных side effects.
  • Controller — новый ResizeController, единственный владелец состояния жеста и допустимых переходов.
  • Adapter / port — typed callback, которым root предоставляет controller текущие данные или выполняет host-specific операцию без обратной записи в controller internals.
  • Geometry snapshot — immutable JSON-снимок SpaceGeometryState перед pointerdown плюс identity/fingerprint, с которым сверяется pointerup.
  • Preview candidate — полный exact space candidate, построенный только из pre-drag snapshot; он никогда не является частью _serverCfg.
  • Commit outcome — typed значение controller, разрешающее root применить ровно этот preview candidate одной geometry-командой.
  • Wall record profile — мультимножество конечных числовых cm у записей walls; в exact-режиме включает 0, потому что после #306 это значимое состояние стены.

4. Архитектурная граница

4.1 Controller владеет

Новый src/resize-controller.ts владеет только Resize orchestration:

  1. состояниями idle и dragging; наличие принятого preview — часть dragging, не параллельный флаг; idle дополнительно хранит необязательный selectedRoomId, который сохраняет существующую двухшаговую Escape-ветку;
  2. pointer id, start point, SafeResizePlan, immutable rooms/openings/options, snapshot identity, current accepted delta, moved flag, changed room ids и однократным preview-rejection notification flag;
  3. eligibility cache, ограниченным одним space/snapshot/grid context;
  4. ссылкой на exact accepted preview, live measurement model и retained pre-drag wall-union token как opaque generic value;
  5. переходами select/clearSelection, resolve → begin → move* → finish и begin/move → cancel; wrong pointer id даёт no-op;
  6. вызовом существующих pure safeResizePointerDisplacement, snap adapter, clampSafeResize, applySafeResize и validateSafeResize в прежнем порядке;
  7. правилом «failed projection сохраняет последний полный accepted preview, delta и labels»;
  8. формированием typed commit | no-op | rejected | cancelled outcome.

Controller не экспортирует изменяемые поля. Root читает immutable getters/ snapshots (dragging, preview, liveLabels) и меняет состояние только публичными командами controller.

4.2 Root сохраняет

src/houseplan-card.ts остаётся владельцем:

  • DOM/PointerEvent: preventDefault, stopPropagation, pointer capture, перевод координат через _svgPoint и маршрутизация событий;
  • Lit reactivity и requestUpdate;
  • _t, disabled-reason copy и toast;
  • получение current rooms/openings/obstacles/thickness/options из модели;
  • построение preview space, rekey wall metadata и wall-segment endpoints;
  • common physical geometry preflight и retained wall geometry/cache;
  • построение локализованных live labels и их DOM/SVG render;
  • _serverCfg, _cfgEpoch, geometry command stack, suppression synthetic click, _commitPhysicalGeometry и serialized write queue;
  • tool/mode/space lifecycle и сброс других инструментов.

Root handlers после выноса являются тонкими adapters: переводят Event в primitive input, вызывают controller, исполняют outcome и запрашивают render. Они не держат зеркальных _rszDrag/_rszPreview/_rszLive полей и не дублируют проверки state transition.

4.3 Что остаётся pure geometry

src/resize.ts остаётся единственным источником topology/eligibility/range/ candidate math. Controller импортирует его функции, но не копирует алгоритмы. src/resize-labels.ts остаётся единственным источником placement math.

Не создаются второй room model, второй wall model, новый render tree или общий framework контроллеров для остальных инструментов.

5. Typed ports и outcomes

Конкретные имена типов могут уточняться при реализации, но граница обязательна:

type ResizeControllerOutcome<TSpace, TSnapshot, TWallUnion> =
  | { kind: 'commit'; preview: Readonly<ResizePreview<TSpace>>;
      before: TSnapshot }
  | { kind: 'rejected'; reason: 'stale-snapshot' | 'invalid-topology'
      | 'invalid-candidate' | 'wall-records' }
  | { kind: 'no-op' }
  | { kind: 'cancelled'; restoreWallUnion: TWallUnion | null };

Controller получает через typed inputs/ports:

  • current context identity и immutable snapshot;
  • snap(point);
  • resolve(roomId, edge) либо данные для вызова pure resolver;
  • project(snapshot, candidate) → success с exact preview/retained geometry либо bounded failure reason;
  • validatePreview(exactPreview) — common physical preflight на pointerup;
  • measure(candidate, plan) — presentation model без DOM measurement.

Ports не получают controller instance и не могут менять его поля. Exceptions adapter'ов перехватываются на границе и превращаются в bounded fail-closed failure; exception text, ids и координаты не попадают в UI.

Root исполняет только kind: 'commit' и только один раз. Любой другой outcome сначала удаляет overlay/controller session, затем приводит render к committed модели и не вызывает _commitPhysicalGeometry.

6. Конечный автомат

Текущее состояние Команда Условие Результат
idle resolve тот же context identity cache hit либо один pure resolver call
idle selectRoom клик по площади комнаты сохранить latent selectedRoomId, без visual/config effect
idle escape selectedRoomId существует очистить selection, остаться в Resize
idle escape selection отсутствует typed outcome exit-tool; root включает Draw
idle begin tool Resize, enabled plan dragging, immutable session captured
idle begin disabled idle, no capture/preview/write; root показывает reason
dragging move wrong pointer id no-op
dragging move delta прежний no-op
dragging move projection valid replace exact preview/delta/labels atomically
dragging move projection invalid/throws retain last accepted triple; notification only once
dragging finish no movement/zero delta/no preview clear session, no-op
dragging finish stale snapshot, invalid topology/candidate/invariant clear session, rejected, zero write/Undo
dragging finish all guards pass clear session, one commit with exact preview
dragging cancel/pointer cancel/lost capture/Escape/Ctrl+Z/tool/space/mode exit matching active session clear session, cancelled, zero write/Undo; eligible cache invalidated as today
idle cancel/finish — no-op

Every transition is synchronous and deterministic for the same inputs. A late lostpointercapture after successful pointerup sees idle and cannot cancel or commit twice.

7. Preview и transaction boundary

  1. begin captures exact snapshot once. Every move projects from a fresh deep copy of that snapshot, never from previous preview or mutable _serverCfg.
  2. Projected rooms, openings, walls and wall_segments are the same objects subsequently shown by render and offered for commit.
  3. Preview success is atomic: preview, accepted delta and live labels replace the previous accepted triple together. Failure replaces none of them.
  4. Candidate projection keeps the existing fixed-topology rekeyWallsAfterMoveChecked(..., 'fixed-topology'), changed-record carrier check and common physical preflight.
  5. Immediately after rekey, exact wall-record preservation is checked against the immutable before snapshot. Failure returns bounded wall-metadata and cannot publish the preview.
  6. finish proves current snapshot identity, validateSafeResize, identity of the accepted preview, common physical preflight and exact wall-record preservation again immediately before returning commit.
  7. Root copies exactly rooms, openings, walls and wall_segments from commit outcome and calls one named history command. No simplify/rebuild or second candidate computation is allowed between outcome and write.

8. Один инвариант wall records

Новый src/wall-record-preservation.ts является единственным алгоритмом:

checkWallRecordsPreserved(before, after, {
  allowClear?: boolean,
  exactMultiplicity?: boolean,
}): WallRecordViolation[]

Контракт:

  • allowClear: true явно отключает проверку; Resize его не передаёт;
  • default/CLI mode сохраняет текущую семантику #254: каждое положительное конечное значение cm, существовавшее before, должно присутствовать after хотя бы один раз; законная merge одинаковых записей не считается потерей;
  • exactMultiplicity: true сравнивает полное мультимножество всех конечных числовых cm, включая 0; это режим fixed-topology Resize;
  • порядок records, key и endpoints не участвуют в профиле: их корректность отдельно доказывают rekey/carrier/physical checks;
  • NaN, infinity и non-number не превращаются неявно в 0; такие записи не маскируют потерю валидной записи и проверяются существующими model gates;
  • violation — bounded structured data без room ids/coordinates.

scripts/model-invariants.mjs удаляет собственную реализацию и re-export/import production helper из test-build, который команда npm run invariants уже собирает до запуска CLI. Это тот же действующий паттерн, что production preflight и near-axis в этом script. Unit tests проверяют обе семантики через один импорт и отдельно доказывают, что Resize вызывает exact mode на preview и finish.

9. Lifecycle, cache и восстановление

  • Eligibility cache key сохраняет семантику #277: space, cell/grid scale и exact geometry snapshot. Snapshot вычисляется root один раз на layer render, а controller cache хранит results по (roomId, edge).
  • Cache очищается при successful commit, tool/mode/space lifecycle reset и при смене context identity. Он bounded одной Map текущего context.
  • Cancel восстанавливает retained pre-drag wall-union alias только если root подтверждает прежнюю snapshot identity, как сейчас; иначе cache silently отбрасывается.
  • Preview по-прежнему увеличивает structural epoch и запрещает reuse clean-floor cache. После cancel/no-op/reject root возвращается к committed model.
  • Warm remount не переносит active gesture/preview. WarmViewport.rszSel продолжает сохранять latent selection, но читает/восстанавливает его только через controller API; это module/session state, не сериализуемый config.
  • dispose/disconnect, tool switch, space switch and mode exit must leave controller idle and release opaque retained references.

10. В скоупе

  • новый controller и перенос в него Resize state/transitions/cache;
  • typed root adapters и commit outcome;
  • extraction единого production wall-record invariant;
  • удаление controller-owned _rsz* mirrors из root и перенос latent _rszSel-семантики в controller;
  • unit tests state machine/invariant, source-boundary guard и действующие production pointer smokes;
  • актуализация architecture/Resize/testing/status docs;
  • синхронизация tracked bundle как обычный результат build.

11. Не входит

  • изменение geometry, UI, CSS, i18n, disabled reasons или hotkeys;
  • изменение schema/model version/migrations/compatibility JSON;
  • перенос _wallThick*, _opening*, drawing, labels renderer или общего geometry command stack;
  • новый общий controller framework/base class/dependency injection container;
  • переработка src/resize.ts и оптимизация boolean geometry;
  • новое логирование, telemetry или пользовательский recovery dialog;
  • cleanup иных мёртвых полей монолита.

Найденные вне этой границы дефекты получают отдельные issue и не исправляются попутно.

12. Ошибки и edge cases

Обязательная матрица:

  1. disabled handle mouse/keyboard/touch — controller не входит в dragging;
  2. synthetic pointer, на котором capture throws, не ломает session;
  3. wrong pointer id на move/up/cancel — no-op;
  4. repeated identical move — не перестраивает preview/labels;
  5. first failed projection — preview отсутствует, pointer визуально остаётся на committed model, toast один;
  6. failed projection after success — остаётся последний полный safe preview;
  7. failure adapter exception — то же bounded поведение;
  8. zero-delta pointerup — no write/Undo;
  9. stale config/space snapshot на pointerup — reject, zero write/Undo;
  10. topology validation failure — reject, zero write/Undo;
  11. wall record count/value loss, включая cm:0 — preview/commit reject;
  12. reordered records с тем же exact profile — разрешено;
  13. carrier/physical preflight failure — reject;
  14. Escape, Ctrl/Cmd+Z, pointercancel, lost capture, tool/mode/space exit — один cancel, exact committed geometry, zero write/Undo;
  15. late cancel after up — no second outcome;
  16. success — one outcome, one history command, one scheduled write;
  17. room-area click в idle Resize + Escape + Escape сохраняет текущую последовательность «очистить latent selection → выйти в Draw»;
  18. Undo/Redo/reload — exact parity с поведением #277;
  19. disconnected root не оставляет controller references/timers/listeners.

13. Performance

Изменение не должно ухудшить действующие бюджеты #277:

  • handle layer computes geometry snapshot once per render, not per edge;
  • warm eligibility cache не вызывает повторный resolver для того же key;
  • pointermove p95 на large-house fixture ≤16 ms и ≤20% хуже same-run baseline;
  • pointerup preflight p95 ≤75 ms;
  • commit invariant O(number of wall records), без polygon work и без JSON stringify каждого record сверх уже необходимого snapshot;
  • controller создаёт не более одного accepted preview на успешный distinct delta и не сохраняет историю preview после завершения session.

Отдельной новой performance feature нет; доказательство — существующий benchmark:safe-resize, render benchmark/call-count и review профиля diff.

14. Touch и accessibility

View/kiosk не затронуты. Plan editor остаётся desktop-first, но safety floor обязателен на touch: finger-sized handles, disabled reason on tap, pointer id guard, pointercancel/lost capture and pinch cancellation remain exactly as in #277. Controller receives primitives after DOM event handling and therefore не может отменить preventDefault, aria attributes, focusability или hit area.

15. Файлы и модули

Планируемый diff:

  • src/resize-controller.ts — новый controller и typed outcomes;
  • src/wall-record-preservation.ts — единый invariant;
  • src/houseplan-card.ts — composition/adapters/render wiring, удаление mirrors;
  • scripts/model-invariants.mjs — import/re-export production invariant;
  • tsconfig.test.json — test-build новых production modules;
  • test/resize-controller.test.mjs — state transition matrix;
  • test/wall-record-preservation.test.mjs и/или test/model-invariants.test.mjs — shared semantics;
  • test/resize-production-path.test.mjs — source boundary/exact guard;
  • существующие demo/smoke_room_resize.mjs, demo/smoke_resize_pointer_real_plan.mjs, demo/smoke_resize_wall_thickness.mjs, demo/smoke_resize_inner_dimensions.mjs — behavioral parity;
  • docs/ARCHITECTURE.md, docs/RESIZE.md, docs/TESTING.md, docs/STATUS.md — фактическая граница и проверка.

Иные файлы добавляются только если smoke-select/build sync механически этого требуют. RU/EN i18n и changelog не меняются, потому что User-Visible: no.

16. Критерии приёмки

AC Критерий Доказательство
AC1 В root нет mutable _rszDrag, _rszPreview, _rszLive, _rszEligibilityCache и _rszSel; состояние и cache принадлежат одному ResizeController. Source guard + code review.
AC2 Controller реализует таблицу §6: wrong pid/duplicate events/no-op/cancel/reject/success дают ровно один детерминированный outcome. Unit.
AC3 Preview строится из immutable pre-drag snapshot, никогда не пишет _serverCfg; failed projection сохраняет последний полный accepted preview/delta/labels и уведомляется один раз. Unit + production pointer smoke.
AC4 Pointerup возвращает commit только для exact displayed preview после snapshot/topology/common-preflight checks; root выполняет ровно одну geometry command/write, остальные outcomes — 0. Unit + production pointer smoke.
AC5 Cancel/Escape/Ctrl+Z/pointercancel/lost capture/tool/mode/space exit возвращают committed model, 0 Undo/write и не допускают late double outcome. Unit + browser smoke.
AC6 Один production helper сохраняет старую CLI presence-семантику и exact Resize-семантику, включая cm:0; script не содержит копии counting/comparison algorithm. Unit + source guard + npm run invariants.
AC7 Exact invariant вызывается при projection и повторно в final controller guard; искусственная потеря/изменение multiplicity даёт fail-closed до commit. Injectable unit + production-path test.
AC8 Safe Resize #277 остаётся функционально идентичным: eligibility, clamp, opening stops, preview, measurements, Undo/Redo/reload и disabled a11y проходят существующие тесты. Existing units + selected smokes.
AC9 Handle render вычисляет snapshot один раз, warm cache bounded текущим context и не повторяет resolver; бюджеты §13 зелёные. Call-count unit + benchmark.
AC10 DOM/CSS/i18n/config schema и tracked visual baselines не меняются; targeted golden verify не показывает diff. Source/diff review + golden verify.
AC11 Typecheck, unit, build, docs and model-invariant gates зелёные; tracked bundle синхронизирован. Local gates + CI.
AC12 Architecture/Resize/testing/status docs описывают controller boundary, single-source invariant и refactor-only status; changelog не изменён. Docs gate + review.

17. План тестирования

Unit

  • fake typed ports без DOM для каждой строки автомата §6 и edge cases §12;
  • prove a failed state transition by mutation/negative input, not only happy snapshots;
  • presence/exact/allowClear profiles: duplicates, count decrease, count increase, reordered values, zero, invalid numeric values;
  • controller exact-guard call count at preview and finish;
  • eligibility cache hit/miss/invalidation and one snapshot per rendered layer.

Production path / browser

  • update source contract so tests forbid state mirrors and inline wall-cm comparison in root;
  • execute real pointerdown/move/up and cancel paths through bundled card;
  • selected existing smokes from node scripts/smoke-select.mjs --base origin/dev --head HEAD, at minimum safe Resize, wall thickness and inner dimensions;
  • verify one write/Undo on success and zero on every fail/cancel path.

Visual / performance

  • targeted existing Resize golden scenes in light/dark must match committed baselines; new baselines are forbidden unless review proves unrelated drift;
  • existing safe-resize and safe-resize-render benchmarks stay inside §13.

Full prerelease smoke/golden/performance matrix remains a release-stage gate, not a substitute for targeted implementation checks.

18. Документация, compatibility и release artifacts

  • Schema/migration: none; successful persisted payload byte-equivalent.
  • i18n: no keys added/changed/removed.
  • Changelog RU/EN: no entries; refactor-only, User-Visible: no.
  • User guide: no change.
  • Architecture/engineering docs: update files from §15 in implementation commit.
  • Golden/screenshots: verify only; no accepted visual delta expected.
  • Security/backend: no impact.

19. Риски и меры

Риск Мера
Shallow extraction leaves two owners of state AC1 forbids mirrors; root gets immutable getters and outcomes only.
Controller absorbs renderer/HA and becomes a second monolith Explicit ownership matrix §4; no Lit/DOM/i18n/config write imports.
Preview and commit diverge during refactor Exact preview object identity + final guards + AC3/AC4.
Common invariant weakens #277 exact contract to coarse CLI presence Two modes in one helper; Resize always exact, AC6/AC7 include multiplicity and zero walls.
Zero-thickness walls from #306 disappear silently Exact profile includes finite numeric cm:0.
Adapter exception leaks partial state Boundary catches and maps to bounded reject, state cleared atomically.
Cancel loses expensive pre-drag wall union reuse Opaque retained token and snapshot-identity restore test.
Перенос _rszSel меняет warm/Escape behavior Controller сохраняет latent selection, warm getter/restore и двухшаговый Escape; dedicated unit/browser assertion.
Large mechanical diff hides behavior change Separate spec and implementation commits, targeted source guard, selected smokes, golden no-diff and independent review.

20. Rollback

Rollback is a normal revert of the implementation commit(s), including generated bundle and documentation. There is no data migration, feature flag or persisted new field to undo. Because successful payloads remain byte-identical, a plan edited by the refactored controller opens unchanged after rollback.