docs: specify resize controller extraction

Issue: #264
User-Visible: no
This commit is contained in:
Matysh
2026-08-27 19:43:17 +03:00
parent 78850a8733
commit a3e329950a
+442
View File
@@ -0,0 +1,442 @@
# 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. Зачем это делается
После #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. Мёртвое session-only поле `_rszSel` удаляется: после #277 оно не имеет
renderer-потребителя и не влияет на пиксели, config или Undo. Escape в
idle Resize, как и фактически сейчас, сразу возвращает neutral Draw.
Открытых продуктовых вопросов нет.
## 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`, не параллельный флаг;
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. переходами `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
Конкретные имена типов могут уточняться при реализации, но граница обязательна:
```ts
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` | `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` является единственным алгоритмом:
```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. Удаление неиспользуемого
`rszSel` из `WarmViewport` не меняет сериализуемый config или видимый state.
- `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 и мёртвого `_rszSel`;
- 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. Undo/Redo/reload — exact parity с поведением #277;
18. 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_safe_resize.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. |
| Removal of `_rszSel` changes warm/Escape behavior | Source/runtime parity test; it has no render consumer and idle Escape ends at Draw. |
| 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.