docs(spec): define reliable space tab drop target

Issue: #243
User-Visible: no
This commit is contained in:
Sergey Matyunin
2026-08-22 19:40:31 +03:00
parent 56a3977363
commit 133fdc9c10
2 changed files with 359 additions and 0 deletions
+358
View File
@@ -0,0 +1,358 @@
# Issue #243 — рабочее перетаскивание вкладок и точный указатель вставки
- Дата: 2026-08-22
- Тип: bug · приоритет P1 · ценность 8/10 · сложность/риск 4/10 и 5/10
- Issue: [#243](https://github.com/Matysh/houseplan-card/issues/243)
- Исходная функция: [#220](https://github.com/Matysh/houseplan-card/issues/220),
`docs/specs/220-space-tab-reorder.md`
- Ветка: `issue/243-space-tab-drop-target`
- Статус ТЗ: на ревью
Канонические документы: `docs/SCOPE.md`, `docs/ARCHITECTURE.md`,
`docs/UX-MODES.md`, `docs/TOUCH-SUPPORT.md`, `docs/USER-GUIDE.md`,
`docs/USER-GUIDE.ru.md`.
## 1. Сценарий и персона
Администратор дома на десктопе открывает любой редактор (`plan`, `devices` или
`decor`) и мышью меняет порядок вкладок пространств. Курсор `grab` и
полупрозрачность удерживаемой вкладки уже обещают drag, но на выпущенном
поведении отпускание не меняет порядок и не показывает точку вставки.
Это регрессия выпущенной функции #220, а не новый способ сортировки. Порядок
по-прежнему определяет вкладки, соседей карусели/свайпа и позиционную адресацию
`floor`; сохранение и материализация неявной привязки маркеров остаются
контрактом #220.
## 2. Что человек увидит до и после
**До:** вкладка бледнеет, но курсор над соседней вкладкой не меняет цель;
отпускание оставляет прежний порядок. Имеющаяся линия `.droptarget` в реальном
жесте не появляется. Даже при искусственном появлении она всегда стоит слева
от вкладки и потому лжёт при переносе вправо.
**После:** удерживаемая мышью вкладка следует тому же drag-контракту, но
координаты курсора определяют реальную вкладку-приёмник. Между вкладками виден
вертикальный разделитель ровно там, куда попадёт вкладка:
- перенос влево — перед вкладкой-приёмником, у её левого края;
- перенос вправо — после вкладки-приёмника, у её правого края.
Отпускание над действительной целью сохраняет порядок. Выход курсора за
вкладки скрывает указатель; отпускание там отменяет перестановку и только
завершает жест.
## 3. Подтверждённый диагноз
Диагноз подтверждён на актуальном `dev`:
1. `_tabPointerDown()` вызывает `capturePointer(event)` и сохраняет id
исходной вкладки.
2. `pointermove` навешен на каждую `.tab` и вызывает
`_tabPointerMove(event, s.id)`.
3. После `setPointerCapture` браузер адресует все движения исходной вкладке,
даже когда координаты уже находятся над соседней. Поэтому передаваемый
`overId` остаётся равен `drag.id`.
4. `_tabPointerUp()` вызывает `_commitTabOrder(id, overId)` с одинаковыми id;
`reorderSpaceIds()` корректно возвращает исходный массив, запись не идёт.
Существующий `demo/smoke_space_tab_reorder.mjs` не воспроизводит этот путь: он
сам вызывает `dispatchEvent(pointermove)` на вкладке-приёмнике. Такой
синтетический вызов обходит захват указателя и доказывает только чистую
перестановку после вручную подставленного `overId`, а не пользовательский жест.
## 4. Зафиксированные продуктовые решения
1. Захват указателя сохраняется. Он не является источником цели, но остаётся
частью надёжного закрытия жеста, введённого после CODE-REVIEW #220 r1.
2. Цель определяется по экранным координатам курсора, а не по `event.target`,
`currentTarget` или id обработчика.
3. Позиция вставки сохраняет семантику `reorderSpaceIds()` из #220:
при переносе к более ранней вкладке — **перед** целью, к более поздней —
**после** цели.
4. Разделитель рисуется на соответствующей стороне вкладки-приёмника и виден
только при настоящей допустимой цели.
5. Действующие границы #220 не меняются: drag только мышью и только в
редакторах; во View, kiosk, на touch/pen, при одном пространстве и на
карточке с `fixedFloor` его нет.
6. Отпускание вне вкладки отменяет перестановку. `pointercancel` и detach
карточки также только гасят жест и никогда не пишут порядок.
## 5. Цели
- реальный жест мышью меняет и сохраняет порядок;
- индикатор до отпускания совпадает с фактической позицией после отпускания;
- тест использует браузерную доставку событий и краснеет на текущем `dev`;
- все защитные границы и атомарная запись #220 остаются закрыты.
## 6. Границы задачи
### Входит
- единый обработчик движения на контейнере `.tabs` либо эквивалентный общий
путь, который видит всплывшее captured-событие;
- координатное определение вкладки-приёмника;
- явное состояние стороны вставки `before` / `after`;
- отдельные стили разделителя слева и справа;
- перевод сценариев reorder на настоящий `page.mouse`;
- регресс-контроль release вне панели, `pointercancel`, detach и следующего
клика;
- golden-доказательство transient-состояния Light/Dark;
- оба changelog и уточнение пользовательской документации.
### Не входит
- touch/pen drag, drag во View или kiosk;
- клавиатурная сортировка;
- новая модель порядка, обмен двух вкладок местами или сортировка по имени;
- изменение записи `config.spaces`, `expected_rev`, материализации маркеров,
тоста о числовом `floor`, свайпа или карусели;
- изменение поведения закреплённой `floor`-карточки;
- новые persisted-поля, миграция либо i18n-строки.
## 7. Контракт состояния и hit-testing
Состояние `_tabDrag` должно различать четыре вещи:
- `id` удерживаемой вкладки;
- исходные координаты и `pointerId`;
- пройден ли порог настоящего drag;
- валидная цель либо её отсутствие, плюс сторона `before` / `after`.
До прохождения порога это обычный клик. После порога каждое относящееся к
этому `pointerId` движение выполняет hit-test по текущим
`getBoundingClientRect()` навигационных вкладок:
1. вкладка считается целью только если `(clientX, clientY)` находится внутри
её rect;
2. удерживаемая вкладка, `.tabadd` и любые не-навигационные кнопки целью не
являются;
3. если target стоит раньше source в текущем порядке, placement = `before`;
если позже — `after`;
4. если курсор не внутри допустимой вкладки, цель и placement очищаются;
5. wrap контейнера не меняет правило: используется rect конкретной вкладки, а
не предположение об одной строке.
Допустима реализация через `ShadowRoot.elementFromPoint()`, если она приводит к
тому же результату и не зависит от retargeted captured `event.target`.
Hit-test не пишет конфигурацию и не переставляет DOM во время движения. Он
только обновляет transient-состояние и запрашивает Lit-render.
## 8. Контракт завершения
- `pointerup` над валидной целью завершает жест и один раз передаёт
`(movedId, targetId)` в существующий `_commitTabOrder()`; итоговый порядок
обязан совпасть с показанной стороной.
- `pointerup` без цели, `pointercancel`, смена режима/пространства, teardown и
`disconnectedCallback` очищают drag и оконные listeners без записи.
- Оконный `pointerup`/`pointercancel` остаётся fail-safe: отпускание вне
панели не оставляет `moved:true` и не съедает следующий обычный клик.
- После успешного drag браузерный click не выполняет второе действие и не
переключает пространство вопреки сохранённому active-space контракту #220.
- Один drop порождает не больше одной `_writeConfig`; порядок и материализация
fallback-маркеров по-прежнему уходят одной транзакцией.
## 9. Визуальный контракт
Разделитель — вертикальная линия цвета
`var(--primary-color, #03a9f4)` на общей границе вставки:
- класс/атрибут `before` рисует линию у левого края target;
- класс/атрибут `after` рисует линию у правого края target;
- линия не меняет ширину, положение и wrap вкладок;
- активный фон, hover и скругление вкладки не скрывают линию;
- в Light и Dark используется один theme token, без жёстко заданной отдельной
dark-палитры;
- удерживаемая вкладка сохраняет `cursor: grabbing` и текущую opacity.
Точное имя классов (`droptarget-before`/`droptarget-after` либо data-атрибут)
техническое. Один неоднозначный `.droptarget` без стороны контракту не
соответствует.
## 10. Данные, compatibility, i18n, a11y и touch
**Данные и compatibility:** без изменений. Формат `config.spaces`, layout и
schema не меняются; `docs/CONFIG-COMPATIBILITY.md` не обновляется.
**i18n:** без изменений. Индикатор графический и не добавляет текст.
**A11y:** без расширения. Клавиатурная альтернатива для редакторской сортировки
явно исключена решением #220; существующие button-семантики вкладок не
ухудшаются.
**Touch editor: not exposed.** На touch/pen не появляется ни `grab`, ни
drag-state, ни разделитель. В обычном View и kiosk вкладки продолжают только
переключать пространство. Safety floor `docs/TOUCH-SUPPORT.md` проверяется
регресс-сценариями, потому что общий header остаётся touch-first поверхностью.
**Fixed floor:** `_hasFixedFloor` продолжает давать `fixedFloor: true` в
`canStartTabDrag()`; `data-reorderable` отсутствует, обработчик ничего не
начинает. Это уже норматив #220 и unit-контракт, а не новый вопрос.
## 11. Производительность и безопасность
На движение выполняется линейный проход по видимым вкладкам; backend ограничен
50 пространствами, поэтому отдельный performance-профиль не нужен. Нельзя
добавлять document-wide постоянный listener: оконные listeners живут только
пока мышь удерживается и снимаются во всех путях §8.
Новых данных, сетевых вызовов и HTML-инъекций нет. Hit-test принимает только
DOM-узлы, уже построенные из нормализованной модели; id цели сверяется с
текущим списком пространств до записи.
## 12. Acceptance criteria
| AC | Требование | Доказательство |
|---|---|---|
| AC1 | Настоящий `page.mouse` переносит вкладку влево и вправо при действующем `setPointerCapture`; новый порядок виден в DOM и достигает `_writeConfig` ровно один раз на drop | browser smoke |
| AC2 | Во время переноса одной вкладки влево разделитель находится слева от target, вправо — справа; после drop вкладка оказывается ровно по показанную сторону | browser smoke + golden Light/Dark |
| AC3 | Порог < 4 px остаётся кликом без записи; ≥ 4 px становится drag и не выполняет лишнее переключение пространства | browser smoke + unit |
| AC4 | Выход курсора за вкладки убирает target; `pointerup` вне панели отменяет запись, завершает drag и следующий клик работает | browser smoke |
| AC5 | `pointercancel` и detach завершают drag без записи; оконные listeners не переживают карточку | browser smoke + mutant #220 |
| AC6 | View, kiosk, touch, pen, single-space и fixed-floor не получают drag/индикатор; вкладки сохраняют обычное переключение | unit + browser smoke |
| AC7 | Успешная перестановка сохраняет active space, атомарно материализует только fallback-маркеры, сохраняет `expected_rev` и один раз показывает существующий positional-floor toast | существующие unit + browser smoke #220 |
| AC8 | Golden transient-сцены показывают line placement Light/Dark и имеют semantic guard, который fail-closed проверяет target, side и ненулевой видимый разделитель | golden matrix/harness/runner tests |
| AC9 | Typecheck, unit, build и три bundle-копии зелёные; оба changelog и USER-GUIDE EN/RU обновлены в user-visible коммите | gates + diff review |
## 13. План автотестов
### 13.1. Browser smoke
`demo/smoke_space_tab_reorder.mjs` перестаёт отправлять reorder-события через
`dispatchEvent` на target. Координаты вкладок можно получить через
`page.evaluate`, но сам жест выполняется снаружи страницы:
```js
await page.mouse.move(source.x, source.y);
await page.mouse.down();
await page.mouse.move(target.x, target.y, { steps: 4 });
// прочитать transient target/side и геометрию линии
await page.mouse.up();
```
Обязательные сценарии:
1. перенос средней вкладки к левой и к правой цели с проверкой обеих сторон;
2. запись, сохранение active space и fallback-маркера из #220;
3. движение меньше порога и обычный click;
4. уход в gap/stage, release вне панели, затем рабочий click;
5. `pointercancel` без записи;
6. detach при удерживаемой мыши без поздней записи;
7. отрицательные границы View/touch/fixedFloor.
Технические setup-события для touch/pointercancel допустимо создавать
синтетически только в отрицательных сценариях, где проверяется явная ветка
`pointerType`/cancel. Положительные AC1–AC4 обязаны использовать `page.mouse`.
### 13.2. Unit
- существующие `canStartTabDrag`, `passedDragThreshold`, `reorderSpaceIds`,
marker materialization и active-order тесты остаются зелёными;
- если side/target вынесены в чистый helper, таблица проверяет left/right,
source, missing id, wrapped rect и координаты вне любой вкладки;
- unit не заменяет real-input smoke.
### 13.3. Golden
Добавить две page-capture сцены с минимум тремя вкладками:
- `space-tab-drop-before-light` — настоящий drag удерживается над более ранней
целью;
- `space-tab-drop-after-dark` — настоящий drag удерживается над более поздней
целью.
Harness выполняет `page.mouse.down/move` после полного render и оставляет
кнопку удерживаемой до screenshot; после capture мышь обязательно отпускается,
чтобы состояние не утекло в следующую сцену. Semantic guard проверяет
`dragging`, target, side, ненулевой rect/видимую линию и соответствие target
ожидаемому id. Пустая либо всегда-левая сцена обязана падать до сравнения PNG.
Baseline локально не принимаются. Их review/accept выполняется только из
reviewed Linux release artifact перед бетой.
## 14. Mutation guards
| id | Что ломает | Что обязано покраснеть |
|---|---|---|
| `tab-drag-target-follows-captured-source` | координатный target заменяется id исходной captured-вкладки — фактически возвращается баг #243 | AC1 real mouse smoke |
| `tab-drop-indicator-always-before` | placement принудительно `before` | AC2 smoke/golden после переноса вправо |
| `tab-drop-outside-commits-last-target` | при выходе за вкладки сохраняется предыдущая цель | AC4 smoke |
Существующие мутанты #220, особенно `tab-drag-outlives-the-card`, остаются в
реестре и прогоняются для затронутого пути. Новый основной мутант обязан быть
исполнен локально: чистый smoke зелёный, возвращённый capture-баг — красный.
## 15. Гейты реализации
Обязательные:
```text
npm run typecheck
npm test
npm run build
сверка dist / integration frontend / demo bundle
node scripts/smoke-select.mjs --base origin/dev --head HEAD
node demo/smoke_space_tab_reorder.mjs
node scripts/mutation-gate.mjs --check
node scripts/mutation-gate.mjs --id=tab-drag-target-follows-captured-source
npm run golden:verify
node scripts/check-docs.mjs
```
Вывод `smoke-select` разбирается построчно в handoff. Дополнительно прогоняются
все найденные им смоки и существующие `smoke_fixed_floor.mjs` / header-tabs
смоки, если selector не включил их автоматически.
Backend pytest не требуется: Python не затронут. Отдельный performance-профиль
не требуется по §11.
## 16. Release-артефакты
- `docs/CHANGELOG.md` и `docs/CHANGELOG.ru.md` — исправление реального drag и
точный разделитель вставки;
- `docs/USER-GUIDE.md` и `docs/USER-GUIDE.ru.md` — уточнить, что во время
переноса разделитель показывает фактическую позицию;
- новые golden-сцены Light/Dark и matrix/unit-контракт на них;
- `dist/houseplan-card.js`, integration frontend и demo bundle идентичны;
- schema, compatibility, i18n и backend release artifacts не меняются.
## 17. Откат
Одна ревизия: вернуть прежнюю адресацию `pointermove` и один класс
`.droptarget`. Данные отката не требуют: формат и уже сохранённый порядок не
меняются. Такой откат снова сломает функцию, поэтому допустим только как
аварийное снятие drag; View/touch navigation от него не зависит.
## 18. Риски
1. **Release вне панели снова зависает.** Митигация: capture + оконный release
сохраняются, AC4 и мутант #220.
2. **Старый target остаётся после ухода курсора.** Митигация: target очищается
на каждом движении без попадания, AC4 и отдельный мутант.
3. **Wrap вкладок меняет направление.** Митигация: side определяется порядком
моделей, а попадание — rect конкретной вкладки; координата строки не
используется как направление сортировки.
4. **Двойная доставка pointerup.** Событие может пройти через tab и window;
`_endTabDrag()` обязан сделать второй путь идемпотентным, AC1 проверяет одну
запись.
5. **Golden удерживает мышь между сценами.** Митигация: harness освобождает
кнопку в `finally`/cleanup и возвращает pointer в `(0,0)`.
## 19. Принятые предположения (техническое, менять свободно)
1. Рабочая форма состояния — `targetId: string | null` и
`placement: 'before' | 'after' | null`; имена полей можно менять.
2. Предпочтительный обработчик `pointermove` живёт на `.tabs`, потому что
captured-событие исходной вкладки всплывает через контейнер. Допустим
временный window-listener, если он ставится/снимается симметрично и не
создаёт вторую доставку.
3. Для линии можно использовать `box-shadow`, псевдоэлемент или outline. Выбор
свободен, если line-side, отсутствие layout shift и semantic guard
доказаны.
4. Координатный helper можно оставить приватным в card либо вынести в
`space-order.ts`; решает простота тестирования, а не API.
**Не являются предположениями:** настоящий `page.mouse`, сохранение capture,
точное before/after, отмена drop вне вкладки и отсутствие drag в
View/touch/fixedFloor — нормативные требования #243 и #220.
+1
View File
@@ -69,6 +69,7 @@ GitHub Issues и GitHub Projects (v2) остаются единственным
| [#228](https://github.com/Matysh/houseplan-card/issues/228) Надёжное рисование стен и операции с готовым контуром | [228-plan-drawing-problems.md](228-plan-drawing-problems.md) |
| [#239](https://github.com/Matysh/houseplan-card/issues/239) Масштаб сетки не меняет внешний вид плана; default 1 см/1 дюйм | [239-grid-scale-invariance.md](239-grid-scale-invariance.md) |
| [#231](https://github.com/Matysh/houseplan-card/issues/231) Декоративный слой виден поверх заливок комнат | [231-decor-layer-order.md](231-decor-layer-order.md) |
| [#243](https://github.com/Matysh/houseplan-card/issues/243) Рабочее перетаскивание вкладок и точный указатель вставки | [243-space-tab-drop-target.md](243-space-tab-drop-target.md) |
## P2