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

25 KiB
Raw Blame History

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:

  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, но сам жест выполняется снаружи страницы:

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. Гейты реализации

Обязательные:

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.