# 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.