Files
houseplan-card/docs/specs/220-space-tab-reorder.md
T
Codex 5ba46dfd97 docs: take the atomic write out of the "free to change" block (#220 M3)
The assumptions section is explicitly labelled "technical, free to change",
and it held a requirement that AC3 and a mutant already test as a fact. Read
literally, it invited splitting the write in two — reopening the very window
in which markers move. The point now states the opposite: everything else in
that section is free, this one is normative and lives in section 8.3.

Issue: #220
User-Visible: no
2026-08-20 23:28:26 +03:00

283 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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` действительно где-то используется: карточка не видит чужие панели,
поэтому условие проверить нечем.