From 133fdc9c10b80b037ecb99fe5bbb7b0c944e4387 Mon Sep 17 00:00:00 2001 From: Sergey Matyunin Date: Sat, 22 Aug 2026 19:03:36 +0300 Subject: [PATCH] docs(spec): define reliable space tab drop target Issue: #243 User-Visible: no --- docs/specs/243-space-tab-drop-target.md | 358 ++++++++++++++++++++++++ docs/specs/README.md | 1 + 2 files changed, 359 insertions(+) create mode 100644 docs/specs/243-space-tab-drop-target.md diff --git a/docs/specs/243-space-tab-drop-target.md b/docs/specs/243-space-tab-drop-target.md new file mode 100644 index 00000000..1f4f7c0e --- /dev/null +++ b/docs/specs/243-space-tab-drop-target.md @@ -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. diff --git a/docs/specs/README.md b/docs/specs/README.md index 1fa92626..170506e6 100644 --- a/docs/specs/README.md +++ b/docs/specs/README.md @@ -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