# Issue #220 — порядок пространств перетаскиванием вкладок - **Issue:** https://github.com/Matysh/houseplan-card/issues/220 - **Связанные контракты:** #210 (фиксированный этаж), #170 (fallback-привязка маркера), #3 (комнаты без HA-зоны) - **Тип:** feature, обычный полный трек - **Приоритет:** P2 - **Пользовательское изменение:** да ## 1. Сценарий и персона **Персона:** администратор плана — тот, кто заводит пространства и поддерживает план в актуальном виде (`docs/SCOPE.md`, job J6). **Сценарий:** дом рос не по порядку. Сначала завели «Квартиру», через месяц — «Подвал», потом «Мансарду». Вкладки стоят в порядке создания, а читается дом снизу вверх. Сегодня переставить их нечем: порядок вкладок — это порядок массива `config.spaces`, и единственный способ его изменить — удалить пространство и завести заново, потеряв планировку, устройства и привязки. **Момент:** администратор в режиме редактора видит панель вкладок, берёт вкладку мышью и перетаскивает на новое место. ## 2. Что человек увидит до и после **До:** вкладки стоят в порядке заведения; изменить порядок невозможно. **После:** в режимах редактора вкладку можно взять мышью и перетащить; во время перетаскивания видно, куда она встанет; после отпускания порядок сохраняется и переживает перезагрузку. Свайп между этажами и стрелки киоска идут в новом порядке. Во View и на сенсорных экранах ничего не меняется: там вкладка по-прежнему только переключает пространство. ## 3. Подтверждённая причина Порядок вкладок задан порядком массива: панель рендерится прямым проходом по модели (`houseplan-card.ts:15717`), `navigationSpaces.map(...)`, отдельного поля сортировки нет. Запись идёт штатным `_writeConfig` (`:6692`) через `houseplan/config/set` с `expected_rev`; изменение порядка — обычная правка массива, схема и миграции не нужны. **Порядок массива несёт смысл в трёх местах.** Проверено по коду dev: | Место | Код | Что зависит | |---|---|---| | Fallback-пространство маркера | `houseplan-card.ts:3350`, `:4273` → `devices.ts:1058`, `:1063`, `:1249` | `firstSpaceId = _model[0]?.id`: маркеры без явного пространства садятся в первое | | Свайп и карусель | `logic.ts:1870-1879` | `spaceIds[(i + 1) % n]` — сосед по индексу | | `floor` числом (#210) | `houseplan-card.ts:1165` `_fixedFloorState` | числовое значение разрешается как позиция, есть ветвь `out-of-range-index` | Первое — риск потери данных на ровном месте, второе и третье обязаны следовать новому порядку либо предупреждать. ## 4. Продуктовые решения владельца (2026-08-20) 1. **Перетаскивание — только мышь и только в режимах редактора** (`plan`, `devices`, `decor`). Во View и киоске поведение вкладок не меняется вовсе. 2. **Числовой `floor` из #210** — после успешной перестановки показать предупреждение один раз. 3. **Клавиатурная альтернатива не нужна** — `docs/SCOPE.md` честно фиксирует отсутствие клавиатурной навигации в редакторах. ## 5. Цели - Порядок пространств меняется без потери данных и переживает перезагрузку. - Ни один маркер не меняет своё размещение из-за перестановки. - Навигация (свайп, карусель) следует новому порядку немедленно. - Скрытая зависимость «позиция в массиве = смысл» становится явной и покрытой тестами. ## 6. Scope - Панель вкладок: обработчики `pointerdown`/`pointermove`/`pointerup` на `.tab`, порог начала перетаскивания, индикатор места вставки, курсор. - Запись нового порядка `config.spaces` через существующий `_writeConfig`. - Развязка `firstSpaceId` с позицией в массиве (см. §8). - Предупреждение о числовом `floor` (§4.2), ключи i18n en+ru. - `docs/USER-GUIDE.md` и `docs/USER-GUIDE.ru.md`, оба changelog. ## 7. Не входит в задачу - Перетаскивание на сенсорных экранах и во View — решение владельца §4.1. - Клавиатурная альтернатива — §4.3. - Порядок комнат, устройств, вложений, вкладок режимов (`plan`/`devices`/`decor`). - Сортировка «по имени» и любые автоматические порядки. - Изменение самого механизма `floor` из #210: числовая адресация остаётся как есть, задача только предупреждает. ## 8. Контракт поведения ### 8.1. Перетаскивание - Drag начинается только при `_canEdit`, не в киоске, `this._mode !== 'view'`, `event.pointerType === 'mouse'` и после смещения ≥ 4 px по горизонтали. До порога это обычный клик — переключение пространства сохраняется. - Вкладка «+» (`.tabadd`) в перестановке не участвует и не может стать целью. - Во время перетаскивания видно место вставки; при отпускании вне панели порядок не меняется. - Одно пространство — перетаскивать нечего, обработчики не навешиваются. ### 8.2. Запись - Новый порядок пишется целиком массивом `spaces` штатным `_writeConfig` с `expected_rev`; конфликт ревизий обрабатывается как у любой другой правки. - Активное пространство после перестановки не меняется: перетащили не ту, что открыта — открытая остаётся открытой; перетащили открытую — она открыта на новом месте. ### 8.3. Маркеры не двигаются: материализация вместо нового поля Обязательное свойство одно: **ни один маркер не меняет `space` из-за изменения порядка**. Достигается оно не хранением якоря, а тем, что перестановка делает явной ту привязку, которая до неё держалась на позиции. **Норматив.** В той же транзакции записи, что и новый порядок, каждый маркер, чьё пространство сегодня разрешается через `firstSpaceId` — то есть у него нет ни `area`, ведущей в пространство, ни собственного `space` — получает явное `space`, равное тому пространству, в котором он находится **сейчас**, до перестановки. После этого его размещение от порядка не зависит вовсе, и `firstSpaceId` перестаёт быть для него значимым. **Почему так, а не якорь в `settings`.** Первая редакция ТЗ предполагала хранить id «первого» пространства отдельным полем. Ревью r1 (M2) справедливо указало, что это новое поле конфигурации, которое §9 в том же документе отрицал. Материализация решает ту же задачу без расширения схемы: - новых полей нет — `CONFIG_SCHEMA`, `scripts/config-field-registry.mjs` и `docs/CONFIG-COMPATIBILITY.md` не трогаются; - запись идёт одним `config/set` с уже существующими полями маркеров; - правка данных минимальна и сохраняет наблюдаемое состояние: маркер остаётся ровно там, где пользователь его видел; - откат не нужен: явное `space` — валидное и предпочтительное состояние, которое карточка и так пишет при любом сохранении маркера. **Граница.** Материализуются только маркеры, разрешавшиеся через `firstSpaceId`. Маркеры с `area` или с уже заданным `space` не трогаются — проверяется AC3. Если исполнитель обнаружит случай, который материализация не покрывает (например, маркер вообще не попал в текущую модель), это не повод возвращать якорь молча: такой случай выносится в ревью как находка. ### 8.4. Навигация `swipeTarget` продолжает работать по индексам — он получает уже переупорядоченный `spaceIds`, поэтому изменений не требует. AC4 фиксирует, что свайп идёт в новом порядке немедленно, без перезагрузки. ### 8.5. Предупреждение о числовом `floor` После первой успешной перестановки в текущей сессии карточка показывает тост: «Порядок пространств изменён. Если где-то этаж карточки задан номером, проверьте такие панели». Показывается один раз за сессию, независимо от числа перестановок, и не блокирует работу. ## 9. Данные, i18n, a11y, touch, privacy, security **Touch editor: not exposed.** Классификация по `docs/TOUCH-SUPPORT.md` §153: перетаскивание вкладок на сенсорных экранах не появляется вовсе — ни как degraded-вариант, ни как долгое нажатие. Это решение владельца §4.1, а не недоделка. Обоснование: вкладки живут во View, где переключение пространств — fully supported на тач и release-blocking; любой жест на вкладке рискует съесть тап. Порядок пространств меняется на десктопе, как и остальная работа с планом. Safety floor §69 того же документа соблюдён по построению: на тач-устройстве поведение вкладок не меняется ни на йоту, значит ни потери данных, ни случайной мутации при мультитаче новая функция внести не может. - **Данные:** порядок элементов массива `spaces` плюс материализация неявной привязки маркеров (§8.3). Новых полей конфигурации не появляется, схема и `CONFIG_SCHEMA` не меняются — см. §8.3 о том, почему выбран путь без нового поля и что это значит для `docs/CONFIG-COMPATIBILITY.md`. - **i18n:** один новый ключ тоста (en + ru) и, при необходимости, `title` вкладки в режиме редактора. - **a11y:** без изменений — клавиатурной навигации в редакторах нет и не обещано (`docs/SCOPE.md`). - **privacy / security:** новых данных и путей записи нет. ## 10. Performance Панель вкладок — единицы элементов. Обработчики навешиваются только в режимах редактора и только для мыши. Влияния на бюджеты нет; performance-профили не затрагиваются. ## 11. Риски 1. **Drag съедает клик.** Порог 4 px и проверка `pointerType` — единственное, что отделяет перестановку от переключения. AC2 проверяет обе стороны. 2. **Маркеры уезжают.** Главный риск задачи: `firstSpaceId` сегодня буквально «первый по порядку». Закрывается §8.3 + AC3 + мутант. 3. **Числовой `floor`.** Предупреждение — смягчение, а не защита: чужие панели отсюда не видны. Записано в USER-GUIDE. 4. **Конкурентная правка.** Перестановка уходит с `expected_rev`; на конфликт реагирует общий механизм — AC6. ## 12. Acceptance criteria 1. **AC1 — перестановка и запись.** Перетаскивание вкладки меняет её позицию; порядок сохраняется на сервере и переживает перезагрузку страницы. **Доказательство:** `smoke`. 2. **AC2 — клик не сломан.** Клик по вкладке без смещения переключает пространство; смещение ≥ 4 px начинает перетаскивание и клик не срабатывает. **Доказательство:** `smoke`. 3. **AC3 — маркеры не двигаются.** Конфигурация с маркером, чьё размещение опирается на fallback-пространство, после перестановки даёт то же `space` для каждого маркера; такой маркер получает явное `space` в той же записи, а маркеры с `area` или с уже заданным `space` остаются побитово прежними. Тест красный до §8.3. **Доказательство:** `unit` (`buildDevices` + запись). 4. **AC4 — навигация следует порядку.** После перестановки `swipeTarget` возвращает нового соседа немедленно, без перезагрузки. **Доказательство:** `unit` + `smoke`. 5. **AC5 — границы включения.** Обработчики не навешиваются во View, в киоске, при `pointerType !== 'mouse'` и при единственном пространстве; вкладка «+» не участвует. **Доказательство:** `unit` (чистая функция решения) + `smoke` для View. 6. **AC6 — конкурентная правка.** Перестановка уходит с `expected_rev`; конфликт ревизий не молчит и не теряет порядок. **Доказательство:** `unit` либо `smoke` по существующему механизму. 7. **AC7 — предупреждение о числовом `floor`.** После первой перестановки в сессии показан тост; повторные перестановки его не повторяют. **Доказательство:** `smoke`. 8. **AC8 — release-артефакты.** Оба changelog, оба USER-GUIDE описывают перестановку и её ограничения (мышь, редактор, числовой `floor`); `dist`, demo и integration bundle идентичны друг другу. **Доказательство:** diff + сверка копий бандла. ## 13. План автотестов - `test/` — юниты на чистую функцию «можно ли начать перетаскивание» (AC5) и на стабильность `firstSpaceId` в `buildDevices` (AC3); проверка `swipeTarget` на переупорядоченном списке (AC4). - `demo/smoke_space_tab_reorder.mjs` — новый смок: перестановка мышью, запись, сохранение активного пространства, клик без смещения, отсутствие drag во View, тост о числовом `floor` (AC1, AC2, AC4, AC5, AC7). - Существующие смоки навигации и `smoke_fixed_floor` (#210) — прогон без правок. ## 14. Мутационный гейт (`scripts/mutation-gate.mjs`) | id | Патч | Guard | |---|---|---| | `tab-reorder-not-persisted` | перестановка меняет только локальную модель, `_writeConfig` не зовётся | смок AC1 | | `tab-reorder-eats-click` | убрать порог смещения — любой pointerdown начинает drag | смок AC2 | | `reorder-skips-materialization` | записывать новый порядок, не материализуя привязку маркеров | юнит AC3 | | `materialization-touches-bound-markers` | материализовать `space` и у маркеров с `area` | юнит AC3 | | `tab-reorder-ignores-pointer-type` | снять проверку `pointerType === 'mouse'` | юнит AC5 | ## 15. Release-артефакты - `docs/CHANGELOG.md` + `docs/CHANGELOG.ru.md` — `User-Visible: yes`; - `docs/USER-GUIDE.md` + `docs/USER-GUIDE.ru.md` — раздел про панель вкладок: перестановка мышью в редакторе, ограничение по тачу, оговорка про числовой `floor`; - golden не затрагивается: панель вкладок в матрице не участвует, визуальных изменений в состоянии покоя нет; - performance-профили не затрагиваются. ## 16. Откат Порядок — обычные данные, обратной миграции не требуется: администратор перетаскивает вкладки назад. Код откатывается снятием обработчиков; развязка `firstSpaceId` (§8.3) остаётся полезной сама по себе и откату не подлежит. ## 17. Принятые предположения (техническое, менять свободно) 1. **Порог 4 px** взят как минимально заметное намеренное движение; точное число не продуктовое решение и может быть изменено ревьюером. 2. **Механика вставки** — вставка перетаскиваемой вкладки перед той, над серединой которой отпущена мышь. Альтернатива (обмен местами) отвергнута: при переносе через несколько позиций она даёт неожиданный результат. 3. **Атомарность записи предположением не является.** Требование «порядок и материализация уходят одним `config/set`» — норматив §8.3, его проверяет AC3 и стережёт мутант `reorder-skips-materialization`. Разносить запись на две нельзя: между ними возникает окно, где порядок уже новый, а привязка ещё старая — ровно тот риск, ради которого §8.3 написан. Здесь пункт оставлен только как указатель: свободно меняется всё остальное в этом разделе, но не это (находка M3 ревью r2; прежняя редакция §17 держала норматив под заголовком «менять свободно»). Историческая справка к §8.3: первая редакция предлагала хранить якорь `firstSpaceId` в `settings` — отвергнута по M2 ревью r1 как новое поле конфигурации. 4. **Тост о числовом `floor`** показывается всегда, а не только когда числовой `floor` действительно где-то используется: карточка не видит чужие панели, поэтому условие проверить нечем.