25 KiB
Issue #243 — рабочее перетаскивание вкладок и точный указатель вставки
- Дата: 2026-08-22
- Тип: bug · приоритет P1 · ценность 8/10 · сложность/риск 4/10 и 5/10
- Issue: #243
- Исходная функция: #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:
_tabPointerDown()вызываетcapturePointer(event)и сохраняет id исходной вкладки.pointermoveнавешен на каждую.tabи вызывает_tabPointerMove(event, s.id).- После
setPointerCaptureбраузер адресует все движения исходной вкладке, даже когда координаты уже находятся над соседней. Поэтому передаваемыйoverIdостаётся равенdrag.id. _tabPointerUp()вызывает_commitTabOrder(id, overId)с одинаковыми id;reorderSpaceIds()корректно возвращает исходный массив, запись не идёт.
Существующий demo/smoke_space_tab_reorder.mjs не воспроизводит этот путь: он
сам вызывает dispatchEvent(pointermove) на вкладке-приёмнике. Такой
синтетический вызов обходит захват указателя и доказывает только чистую
перестановку после вручную подставленного overId, а не пользовательский жест.
4. Зафиксированные продуктовые решения
- Захват указателя сохраняется. Он не является источником цели, но остаётся частью надёжного закрытия жеста, введённого после CODE-REVIEW #220 r1.
- Цель определяется по экранным координатам курсора, а не по
event.target,currentTargetили id обработчика. - Позиция вставки сохраняет семантику
reorderSpaceIds()из #220: при переносе к более ранней вкладке — перед целью, к более поздней — после цели. - Разделитель рисуется на соответствующей стороне вкладки-приёмника и виден только при настоящей допустимой цели.
- Действующие границы #220 не меняются: drag только мышью и только в
редакторах; во View, kiosk, на touch/pen, при одном пространстве и на
карточке с
fixedFloorего нет. - Отпускание вне вкладки отменяет перестановку.
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() навигационных вкладок:
- вкладка считается целью только если
(clientX, clientY)находится внутри её rect; - удерживаемая вкладка,
.tabaddи любые не-навигационные кнопки целью не являются; - если target стоит раньше source в текущем порядке, placement =
before; если позже —after; - если курсор не внутри допустимой вкладки, цель и placement очищаются;
- 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, но сам жест выполняется снаружи страницы:
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();
Обязательные сценарии:
- перенос средней вкладки к левой и к правой цели с проверкой обеих сторон;
- запись, сохранение active space и fallback-маркера из #220;
- движение меньше порога и обычный click;
- уход в gap/stage, release вне панели, затем рабочий click;
pointercancelбез записи;- detach при удерживаемой мыши без поздней записи;
- отрицательные границы 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. Гейты реализации
Обязательные:
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. Риски
- Release вне панели снова зависает. Митигация: capture + оконный release сохраняются, AC4 и мутант #220.
- Старый target остаётся после ухода курсора. Митигация: target очищается на каждом движении без попадания, AC4 и отдельный мутант.
- Wrap вкладок меняет направление. Митигация: side определяется порядком моделей, а попадание — rect конкретной вкладки; координата строки не используется как направление сортировки.
- Двойная доставка pointerup. Событие может пройти через tab и window;
_endTabDrag()обязан сделать второй путь идемпотентным, AC1 проверяет одну запись. - Golden удерживает мышь между сценами. Митигация: harness освобождает
кнопку в
finally/cleanup и возвращает pointer в(0,0).
19. Принятые предположения (техническое, менять свободно)
- Рабочая форма состояния —
targetId: string | nullиplacement: 'before' | 'after' | null; имена полей можно менять. - Предпочтительный обработчик
pointermoveживёт на.tabs, потому что captured-событие исходной вкладки всплывает через контейнер. Допустим временный window-listener, если он ставится/снимается симметрично и не создаёт вторую доставку. - Для линии можно использовать
box-shadow, псевдоэлемент или outline. Выбор свободен, если line-side, отсутствие layout shift и semantic guard доказаны. - Координатный helper можно оставить приватным в card либо вынести в
space-order.ts; решает простота тестирования, а не API.
Не являются предположениями: настоящий page.mouse, сохранение capture,
точное before/after, отмена drop вне вкладки и отсутствие drag в
View/touch/fixedFloor — нормативные требования #243 и #220.