Files
houseplan-card/legacy/specs/289-no-mixed-role-resize.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

16 KiB
Raw Permalink Blame History

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 не создаются.

Доказательство: table-driven unit в test/resize.test.mjs плюс production-bundle pointer smoke на минимизированной fixture.

AC2. Причина доступна человеку

RU: «Нельзя сдвинуть только часть общей стены». EN передаёт тот же смысл. Disabled handle сохраняет hit area, aria-disabled, localized accessible name, tooltip на hover/focus и toast на click/tap. Он не запускает drag.

Доказательство: production-path DOM/source test для i18n/ARIA и browser smoke для click, focus, tap и нулевой history/config write.

AC3. Directed safe range не перепрыгивает смену роли

Для fixture, где один direction имеет несколько безопасных grid nodes, а затем доходит до конца соседней комнаты, preview останавливается на последнем endpoint-to-endpoint node. Он не перескакивает через запрещённый node к более дальнему валидному polygon. Обратное безопасное направление остаётся рабочим.

Доказательство: pure unit для последовательности grid candidates и browser smoke, сравнивающий фактическую preview/commit coordinate с первым unsafe node.

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 дают ноль записей.

Доказательство: table-driven positive unit matrix и существующие smoke_room_resize pointer scenarios без ослабления их exact assertions.

AC5. Persisted model чиста

После каждого разрешённого commit checkMixedRoleRecords возвращает ноль. checkWallRecordsPreserved, checkWallKeys, references и production geometry preflight также возвращают ноль нарушений. Exact forbidden repro оставляет JSON config/layout byte-equivalent.

Доказательство: scripts/model-invariants.mjs/targeted invariant unit на before/after candidate и byte-for-byte assertion forbidden config/layout.

AC6. Preview и commit используют один proof

Ownership profile входит в immutable SafeResizePlan/его signature и проверяется validateSafeResize() перед pointerup. Если committed snapshot или owners изменились, весь жест отменяется с resize.commit_failed, без partial write. Commit принимает только exact preview.

Доказательство: unit, подменяющий owner signature между preview/pointerup, и production smoke с нулём history/config writes после rejected commit.

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. Риски и меры

  • Слишком строгий ownership profile может отключить безопасные outer/exact shared стены. Мера: positive AC3/AC4 и exact reason matrix, запрещающая blanket-disable.
  • Слишком мягкий профиль снова позволит одному thickness record описывать shared и outer intervals. Мера: AC1/AC5, checkMixedRoleRecords и мутант AC8.
  • Preview и pointerup могут проверить разные owners после внешнего обновления. Мера: immutable signature и atomic reject AC6.

9. Откат

Чистый revert implementation-коммита возвращает прежнюю eligibility; миграция и feature flag не требуются, потому что persisted geometry/schema не меняются.

10. Ожидаемые файлы

Product code:

  • src/resize.ts;
  • src/houseplan-card.ts только для передачи profile/UX, если требуется;
  • src/i18n/en.json, src/i18n/ru.json, включая resize.disabled.partial-shared.

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.

11. Release и порядок интеграции

Implementation-коммит имеет Issue: #289, User-Visible: yes и оба changelog. Если меняется вид disabled handle, targeted golden/docs screenshots принимаются только из штатного Linux workflow после bundle sync.

Инфраструктурная #260 должна попасть в dev до финальной пересъёмки, но не входит в product branch #289.

12. Принятые технические предположения

  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 сохранён.