Files
houseplan-card/docs/specs/243-space-tab-drop-target.md
T
2026-08-22 19:40:31 +03:00

359 lines
25 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.