diff --git a/docs/specs/289-no-mixed-role-resize.md b/docs/specs/289-no-mixed-role-resize.md new file mode 100644 index 00000000..2544e263 --- /dev/null +++ b/docs/specs/289-no-mixed-role-resize.md @@ -0,0 +1,234 @@ +# Issue #289 — Resize не создаёт стену со смешанной ролью + +- **Issue:** https://github.com/Matysh/houseplan-card/issues/289 +- **Статус:** первая редакция для внешнего ревью; канонический статус задаётся метками issue +- **Тип / приоритет:** bug / P1 +- **Оценка:** пользовательская ценность 9/10; ценность для разработки 9/10; + сложность 8/10; риск 9/10 +- **Область:** safe Resize eligibility/range, side-wall ownership, thickness + preservation, disabled UX, commit preflight и model invariants +- **Модель данных:** schema и число room/wall records не меняются автоматически +- **Связано:** #233, #253, #254, #264, #277, #281, #284, #287, + `docs/RESIZE.md`, `docs/WALL-THICKNESS.md`, `docs/TOUCH-SUPPORT.md` + +## 1. Сценарий и подтверждённая причина + +На реальном плане пользователь сдвинул нижнюю стену одной комнаты на 43 шага. +Соседняя комната не изменилась. Боковая запись толщины 20 см, которая до +жеста целиком описывала общую границу, растянулась с узла 928 до 971, хотя +соседняя комната по-прежнему заканчивается на 928. Новый участок стал наружной +стеной, но сохранил толщину общей границы вместо соседних наружных 30 см. + +Текущий `resolveSafeResize()` проверяет ownership самой moving edge. Он не +доказывает, что две удлиняемые/укорачиваемые side edges сохранят одну физическую +роль на всём новом пролёте. `rekeyWallsAfterMove()` затем честно переносит +старую запись по геометрии и получает mixed-role record. Инвариант +`checkMixedRoleRecords` из #287 уже обнаруживает результат. + +## 2. Решение владельца + +Зафиксировано 2026-08-24: + +- частичный сдвиг, который превращает часть прежней общей стены в наружную или + наоборот, запрещён; +- Resize не разрезает запись и не угадывает новую наружную толщину; +- рукоятка заранее disabled и объясняет: «Нельзя сдвинуть только часть общей + стены»; +- shared ownership допустим только пока обе комнаты владеют одним и тем же + endpoint-to-endpoint пролётом. + +Открытых продуктовых вопросов нет. + +## 3. Пользовательский результат + +Опасную стену нельзя начать перетаскивать. Рукоятка остаётся видимой, hover и +keyboard focus показывают понятную причину, click/tap повторяет её в toast. +План, history и сервер не меняются. Безопасный наружный Resize и точный +endpoint-to-endpoint Resize двух комнат работают как раньше. + +## 4. Ownership-контракт + +### 4.1 Роль атомарного пролёта + +Для moving edge и обеих side edges resolver строит ownership profile по +реальным room polygons до начала жеста. Каждый атомарный интервал имеет одну +роль: + +- `outer` — владелец ровно одна room; +- `shared(A,B)` — ровно две rooms с одной exact centreline; +- invalid/multiple — больше двух owners либо неоднозначное наложение. + +Thickness records не являются источником ownership: роль выводится из room +geometry, а затем используется как обязательное условие lossless rekey. + +### 4.2 Запрещённая смена роли + +Кандидат недопустим, если любой side interval после изменения длины: + +- объединяет `shared(A,B)` и `outer(A)` в одну запись/неразделённое ребро; +- оставляет продолжение у B, которым A больше не владеет; +- создаёт новую общую часть только на подмножестве существующего outer edge; +- требует вовлечь третью room либо разрезать/создать vertex. + +Resolver обязан обнаружить класс до pointer capture. Если хотя бы один +направленный шаг может быть разрешён без смены роли, handle может остаться +enabled, но `clampSafeResize()` обязан остановить конкретное направление на +последнем safe node. В exact пользовательском repro оба направления не дают +сохранить один endpoint-to-endpoint ownership profile, поэтому handle disabled +с `partial-shared`. + +### 4.3 Thickness preservation + +После каждого разрешённого preview и commit: + +- ни одна wall entry не описывает одновременно shared и outer intervals; +- effective `cm` каждого старого атомарного physical interval сохраняется; +- значения `cm` не назначаются по соседству и не создаются автоматически; +- untouched walls/open spans/rooms byte-equivalent; +- число topology vertices не меняется. + +## 5. Scope + +### Входит + +- side-edge ownership analysis в safe resolver/validator; +- directed clamp на первой смене роли; +- `partial-shared` disabled reason и RU/EN текст владельца; +- exact 43-step regression, production pointer smoke и model invariant; +- unit/mutation/performance evidence; +- resize/user/testing docs и оба changelog. + +### Не входит + +- автоматический split wall record и выбор новой толщины; +- изменение толщины стен как часть Resize; +- partial-shared topology cascade, vertex insertion или `simplifyPoly`; +- общий рефактор controller #264; +- исправление уже сохранённых mixed-role records через Optimize; +- renderer defects #288 и near-axis repair #290. + +## 6. Acceptance criteria + +### AC1. Exact пользовательский repro запрещён до жеста + +Fixture содержит общую боковую стену 20 см, наружные продолжения 30 см и +moving edge, чей сдвиг на 43 шага создал бы mixed-role interval. Для этой +ручки `resolveSafeResize()` возвращает +`{enabled:false, reason:'partial-shared'}`; pointer capture, preview, history и +write не создаются. + +### AC2. Причина доступна человеку + +RU: «Нельзя сдвинуть только часть общей стены». EN передаёт тот же смысл. +Disabled handle сохраняет hit area, `aria-disabled`, localized accessible name, +tooltip на hover/focus и toast на click/tap. Он не запускает drag. + +### AC3. Directed safe range не перепрыгивает смену роли + +Для fixture, где один direction имеет несколько безопасных grid nodes, а +затем доходит до конца соседней комнаты, preview останавливается на последнем +endpoint-to-endpoint node. Он не перескакивает через запрещённый node к более +дальнему валидному polygon. Обратное безопасное направление остаётся рабочим. + +### AC4. Разрешённые сценарии #277 сохраняются + +- non-shared outer wall изменяет одну room; +- exact shared endpoint-to-endpoint wall изменяет ровно две rooms; +- irregular-room clamp у первого corner; +- perpendicular opening jamb stop; +- zero-range handle #281 disabled по своей точной причине; +- pinch, pointercancel и lostpointercapture дают ноль записей. + +### AC5. Persisted model чиста + +После каждого разрешённого commit `checkMixedRoleRecords` возвращает ноль. +`checkWallRecordsPreserved`, `checkWallKeys`, references и production geometry +preflight также возвращают ноль нарушений. Exact forbidden repro оставляет +JSON config/layout byte-equivalent. + +### AC6. Preview и commit используют один proof + +Ownership profile входит в immutable `SafeResizePlan`/его signature и +проверяется `validateSafeResize()` перед pointerup. Если committed snapshot или +owners изменились, весь жест отменяется с `resize.commit_failed`, без partial +write. Commit принимает только exact preview. + +### AC7. Production-bundle smoke + +`demo/smoke_room_resize.mjs` выполняет реальную попытку forbidden 43-step drag, +проверяет disabled reason и отсутствие WS/config/history mutation. Затем тем же +bundle выполняет разрешённый outer и exact-shared drag, доказывая, что Resize +не отключён целиком. + +### AC8. Мутант + +Mutation убирает side ownership check либо разрешает rekey mixed-role записи. +AC1 или AC5 обязаны падать. Проверка не может быть удовлетворена безусловным +disable всех shared/adjacent handles — positive AC3/AC4 остаются зелёными только +при точной классификации. + +### AC9. Локальные гейты + +- `npm run typecheck`; +- `npm test`; +- `npm run build` и bundle parity; +- `node scripts/check-docs.mjs`; +- targeted Resize smoke, invariants и mutation. + +Полные golden, smoke, performance и Linux HA harness выполняются перед beta. + +## 7. Совместимость, touch, security и performance + +Persisted schema/model version не меняются; старые планы читаются без +перезаписи. Изменяется только eligibility нового жеста. Plan editor остаётся +desktop-first; touch editor — best effort, но safety floor обязателен: disabled +handle и cancellation не сохраняют геометрию. + +Новых HA actions и security boundaries нет. Ownership profile строится один +раз на committed snapshot и кэшируется вместе с eligibility. Pointermove не +получает новый глобальный `O(R×E)` анализ: он проверяет подготовленный plan и +contiguous deltas. Действующие p95 budgets из `docs/RESIZE.md` сохраняются. + +## 8. Ожидаемые файлы + +Product code: + +- `src/resize.ts`; +- `src/houseplan-card.ts` только для передачи profile/UX, если требуется; +- `src/i18n/en.json`, `src/i18n/ru.json`. + +Tests/evidence: + +- `test/resize.test.mjs`; +- privacy-minimized fixture из #284 без имён/полного экспорта; +- `demo/smoke_room_resize.mjs`; +- `scripts/model-invariants.mjs` используется как acceptance oracle и не + дублируется в product code; +- mutation registry и benchmark safe resize при изменении hot path. + +Документация: + +- `docs/RESIZE.md`, `docs/USER-GUIDE.md`, `docs/USER-GUIDE.ru.md`, + `docs/TESTING.md`; +- `docs/CHANGELOG.md`, `docs/CHANGELOG.ru.md`. + +## 9. Release и порядок интеграции + +Implementation-коммит имеет `Issue: #289`, `User-Visible: yes` и оба +changelog. Если меняется вид disabled handle, targeted golden/docs screenshots +принимаются только из штатного Linux workflow после bundle sync. + +Инфраструктурная #260 должна попасть в `dev` до финальной пересъёмки, но не +входит в product branch #289. + +## 10. Принятые технические предположения + +1. Существующий reason key `partial-shared` переиспользуется, но его текст + меняется на принятое владельцем объяснение. +2. Ownership сравнивается на atomic centreline intervals с действующим + geometry epsilon; epsilon поглощает storage noise, но не целый grid step. +3. Наличие safe direction не требует двух разных handles: текущий handle + остаётся bidirectional, а unsafe direction физически clamp'ится. +4. Уже сохранённый mixed-role plan не чинится молча; задача предотвращает новый + результат и тестирует конкретный pre-save candidate. +5. Touch editor: best effort / intentionally degraded; safety floor сохранён.