mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
docs(spec): define reliable space tab drop target
Issue: #243 User-Visible: no
This commit is contained in:
@@ -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.
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user