Волна 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
31 KiB
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. Зафиксированные решения и исходный контракт
- #277 уже реализован и выпущен. Его ТЗ и
docs/RESIZE.mdявляются функциональным каноном; #264 не пересматривает eligibility, geometry, disabled reasons, preview или commit semantics. - Старый продуктовый вопрос из тела #264 закрыт решением #277: при любом нарушении инварианта жест fail closed — без частичного применения, диагностики с пользовательскими данными, write или Undo.
- Владелец явно выбрал #264 следующим слайсом, поэтому прежнее сомнение «сначала небольшой диалог или Resize» закрыто.
- Schema/model version, compatibility fields и миграции не меняются.
allowClearдля Resize всегдаfalse. Очистка толщины относится к другому явному инструменту и не может быть побочным эффектом перемещения стены.- Opened/disabled handles и мышь/клавиатура/touch сохраняют поведение #277.
Осознанная desktop-first деградация редакторов из
docs/TOUCH-SUPPORT.mdне расширяется. _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:
- состояниями
idleиdragging; наличие принятого preview — частьdragging, не параллельный флаг; idle дополнительно хранит необязательныйselectedRoomId, который сохраняет существующую двухшаговую Escape-ветку; - pointer id, start point,
SafeResizePlan, immutable rooms/openings/options, snapshot identity, current accepted delta, moved flag, changed room ids и однократным preview-rejection notification flag; - eligibility cache, ограниченным одним space/snapshot/grid context;
- ссылкой на exact accepted preview, live measurement model и retained pre-drag wall-union token как opaque generic value;
- переходами
select/clearSelection,resolve → begin → move* → finishиbegin/move → cancel; wrong pointer id даёт no-op; - вызовом существующих pure
safeResizePointerDisplacement, snap adapter,clampSafeResize,applySafeResizeиvalidateSafeResizeв прежнем порядке; - правилом «failed projection сохраняет последний полный accepted preview, delta и labels»;
- формированием typed
commit | no-op | rejected | cancelledoutcome.
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
begincaptures exact snapshot once. Every move projects from a fresh deep copy of that snapshot, never from previous preview or mutable_serverCfg.- Projected rooms, openings,
wallsandwall_segmentsare the same objects subsequently shown by render and offered for commit. - Preview success is atomic: preview, accepted delta and live labels replace the previous accepted triple together. Failure replaces none of them.
- Candidate projection keeps the existing fixed-topology
rekeyWallsAfterMoveChecked(..., 'fixed-topology'), changed-record carrier check and common physical preflight. - Immediately after rekey, exact wall-record preservation is checked against
the immutable before snapshot. Failure returns bounded
wall-metadataand cannot publish the preview. finishproves current snapshot identity,validateSafeResize, identity of the accepted preview, common physical preflight and exact wall-record preservation again immediately before returningcommit.- Root copies exactly
rooms,openings,wallsandwall_segmentsfrom 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
Обязательная матрица:
- disabled handle mouse/keyboard/touch — controller не входит в dragging;
- synthetic pointer, на котором capture throws, не ломает session;
- wrong pointer id на move/up/cancel — no-op;
- repeated identical move — не перестраивает preview/labels;
- first failed projection — preview отсутствует, pointer визуально остаётся на committed model, toast один;
- failed projection after success — остаётся последний полный safe preview;
- failure adapter exception — то же bounded поведение;
- zero-delta pointerup — no write/Undo;
- stale config/space snapshot на pointerup — reject, zero write/Undo;
- topology validation failure — reject, zero write/Undo;
- wall record count/value loss, включая
cm:0— preview/commit reject; - reordered records с тем же exact profile — разрешено;
- carrier/physical preflight failure — reject;
- Escape, Ctrl/Cmd+Z, pointercancel, lost capture, tool/mode/space exit — один cancel, exact committed geometry, zero write/Undo;
- late cancel after up — no second outcome;
- success — one outcome, one history command, one scheduled write;
- room-area click в idle Resize + Escape + Escape сохраняет текущую последовательность «очистить latent selection → выйти в Draw»;
- Undo/Redo/reload — exact parity с поведением #277;
- 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.