Merge remote-tracking branch 'origin/issue/220-space-tab-reorder' into dev

This commit is contained in:
Sergey Matyunin
2026-08-21 11:03:51 +03:00
22 changed files with 2008 additions and 128 deletions
File diff suppressed because one or more lines are too long
+224
View File
@@ -0,0 +1,224 @@
// Issue #220: the order of the space tabs is changed by dragging one of them.
//
// The demo fixture has two spaces, which is all the panel needs to prove the
// contract. What this smoke protects is not the animation but the three
// promises around it: the
// new order survives a save, an ordinary click still switches the space, and
// nothing of the sort exists in View, where the same tabs are a touch-first
// navigation control.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 1100, height: 900 }, 1);
const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const tabs = () => [...sr().querySelectorAll('[data-hp="space-tab"]')];
const ids = () => tabs().map((tab) => tab.dataset.id);
const settle = async () => {
const started = performance.now();
do { await new Promise((r) => requestAnimationFrame(r)); }
while (c._modeTransitionBusy && performance.now() - started < 1500);
await c.updateComplete;
};
// A save must reach the server exactly once per drop, and carry the order.
const writes = [];
const realWrite = c._writeConfig.bind(c);
c._writeConfig = () => {
writes.push((c._serverCfg.spaces || []).map((space) => space.id));
return Promise.resolve();
};
const drag = async (fromId, toId, { travel = 40 } = {}) => {
const from = tabs().find((tab) => tab.dataset.id === fromId);
const to = tabs().find((tab) => tab.dataset.id === toId);
const a = from.getBoundingClientRect();
const b = to.getBoundingClientRect();
const event = (type, x, y, target) => target.dispatchEvent(new PointerEvent(type, {
pointerId: 7, pointerType: 'mouse', clientX: x, clientY: y, bubbles: true, composed: true,
}));
event('pointerdown', a.x + a.width / 2, a.y + a.height / 2, from);
// one intermediate move on the source keeps the gesture honest: the drag
// must begin from travel, not from merely touching a second tab
event('pointermove', a.x + a.width / 2 + travel, a.y + a.height / 2, from);
event('pointermove', b.x + b.width / 2, b.y + b.height / 2, to);
event('pointerup', b.x + b.width / 2, b.y + b.height / 2, to);
await settle();
// The write is debounced (~500 ms). Waiting for it is the point: a smoke
// that checks the panel and leaves proves the DOM, not the save.
const deadline = performance.now() + 1500;
const seen = writes.length;
while (writes.length === seen && performance.now() < deadline) {
await new Promise((r) => setTimeout(r, 25));
}
};
await settle();
c._mode = 'plan';
c.requestUpdate();
await settle();
// A marker with neither an explicit space nor an area that names one is the
// whole reason this feature has to be careful: today it renders in whichever
// space sits first, so a reorder would hand it to another one. The fixture
// has no such marker, so the smoke plants it — otherwise the guarantee would
// be tested only as a pure function, never as applied behaviour.
const firstBefore = c._model[0].id;
c._serverCfg.markers = [
...(c._serverCfg.markers || []),
{ id: 'smoke-dangling', binding: 'virtual', name: 'dangling' },
];
const dangling = () => (c._serverCfg.markers || [])
.find((marker) => marker.id === 'smoke-dangling');
out.plantedMarkerStartsWithoutSpace = !dangling().space;
const before = ids();
out.enoughTabsToReorder = before.length >= 2;
out.reorderableInEditor = tabs()[0].hasAttribute('data-reorderable');
// --- AC1: the drop changes the order and asks for a save -------------------
const moved = before[before.length - 1];
const target = before[0];
const active = c._space;
await drag(moved, target);
const after = ids();
out.tabMovedToTheFront = after[0] === moved;
out.otherTabsKeptOrder = JSON.stringify(after.filter((id) => id !== moved))
=== JSON.stringify(before.filter((id) => id !== moved));
out.orderReachedTheServer = writes.length >= 1;
out.savedOrderMatchesPanel = JSON.stringify(writes[writes.length - 1])
=== JSON.stringify(after);
out.activeSpaceUnchanged = c._space === active;
// AC3: the order-dependent marker keeps the space it had, written down.
out.danglingMarkerPinnedToItsOldSpace = dangling().space === firstBefore;
out.danglingMarkerDidNotFollowTheOrder = dangling().space !== ids()[0]
|| firstBefore === ids()[0];
// --- AC7: the positional-floor warning is said once ------------------------
out.warnedAboutPositionalFloor = typeof c._toast === 'string' && c._toast.length > 0;
c._toast = '';
const second = ids();
await drag(second[second.length - 1], second[0]);
out.secondDropAlsoReordered = ids()[0] === second[second.length - 1];
out.warningNotRepeated = !c._toast;
// --- AC2: a click without travel still switches the space ------------------
const other = ids().find((id) => id !== c._space);
const writesBeforeClick = writes.length;
await new Promise((r) => setTimeout(r, 700)); // let any pending debounce land
const tab = tabs().find((t) => t.dataset.id === other);
const rect = tab.getBoundingClientRect();
const at = (type) => tab.dispatchEvent(new PointerEvent(type, {
pointerId: 8, pointerType: 'mouse', composed: true,
clientX: rect.x + rect.width / 2, clientY: rect.y + rect.height / 2, bubbles: true,
}));
at('pointerdown'); at('pointermove'); at('pointerup');
tab.click();
await settle();
out.clickStillSwitchesSpace = c._space === other;
await new Promise((r) => setTimeout(r, 700));
out.clickDidNotReorder = writes.length === writesBeforeClick;
// --- AC5: touch and View never start a drag -------------------------------
const touchOrder = ids();
const src = tabs()[tabs().length - 1];
const dst = tabs()[0];
const ra = src.getBoundingClientRect();
const rb = dst.getBoundingClientRect();
const touch = (type, x, y, target) => target.dispatchEvent(new PointerEvent(type, {
pointerId: 9, pointerType: 'touch', clientX: x, clientY: y, bubbles: true, composed: true,
}));
touch('pointerdown', ra.x + ra.width / 2, ra.y + ra.height / 2, src);
touch('pointermove', rb.x + rb.width / 2, rb.y + rb.height / 2, dst);
touch('pointerup', rb.x + rb.width / 2, rb.y + rb.height / 2, dst);
await settle();
out.touchDidNotReorder = JSON.stringify(ids()) === JSON.stringify(touchOrder);
c._mode = 'view';
c.requestUpdate();
await settle();
out.notReorderableInView = !tabs()[0].hasAttribute('data-reorderable');
const viewOrder = ids();
await drag(viewOrder[viewOrder.length - 1], viewOrder[0]);
out.viewDidNotReorder = JSON.stringify(ids()) === JSON.stringify(viewOrder);
// --- review r1 M1: the mouse is released away from the panel ---------------
//
// A horizontal drag that ends a few pixels below the tabs is ordinary hand
// imprecision. Without pointer capture no tab ever sees the release, the
// gesture stays stuck with moved:true, and the next click is swallowed by
// _tabClick — the panel simply stops switching spaces.
c._mode = 'plan';
c.requestUpdate();
await settle();
const strayTabs = tabs();
const strayFrom = strayTabs[strayTabs.length - 1];
const strayRect = strayFrom.getBoundingClientRect();
const stray = (type, x, y) => strayFrom.dispatchEvent(new PointerEvent(type, {
pointerId: 11, pointerType: 'mouse', clientX: x, clientY: y, bubbles: true, composed: true,
}));
stray('pointerdown', strayRect.x + strayRect.width / 2, strayRect.y + strayRect.height / 2);
stray('pointermove', strayRect.x + strayRect.width / 2 + 40, strayRect.y + strayRect.height / 2);
// released far below the panel, where no tab lives
// released on the stage, not on a tab: without pointer capture no tab
// handler ever runs and the gesture stays stuck
(sr().querySelector('.stage') || document.body).dispatchEvent(new PointerEvent('pointerup', {
pointerId: 11, pointerType: 'mouse', composed: true,
clientX: strayRect.x + 400, clientY: strayRect.y + 400, bubbles: true,
}));
await settle();
out.strayReleaseEndedTheDrag = c._tabDrag === null;
const strayTarget = ids().find((id) => id !== c._space);
const strayNext = tabs().find((tab) => tab.dataset.id === strayTarget);
strayNext.dispatchEvent(new PointerEvent('pointerdown', {
pointerId: 12, pointerType: 'mouse', bubbles: true, composed: true,
}));
strayNext.dispatchEvent(new PointerEvent('pointerup', {
pointerId: 12, pointerType: 'mouse', bubbles: true, composed: true,
}));
strayNext.click();
await settle();
out.panelStillSwitchesAfterStrayRelease = c._space === strayTarget;
// --- review r2/r3 F1: the card is destroyed mid-drag -----------------------
//
// Lovelace rebuilds its tree, or the user leaves the view with the button
// still down. The window listeners the gesture installed must not outlive
// the card: they hold the instance alive and would let an invisible card
// write its order on the next pointerup anywhere on the page.
c._mode = 'plan';
c.requestUpdate();
await settle();
const orderBeforeDetach = ids();
const detachTabs = tabs();
const held = detachTabs[detachTabs.length - 1];
const heldRect = held.getBoundingClientRect();
const heldEvent = (type, x, y) => held.dispatchEvent(new PointerEvent(type, {
pointerId: 13, pointerType: 'mouse', clientX: x, clientY: y,
bubbles: true, composed: true,
}));
heldEvent('pointerdown', heldRect.x + heldRect.width / 2, heldRect.y + heldRect.height / 2);
heldEvent('pointermove', heldRect.x + heldRect.width / 2 + 40, heldRect.y + heldRect.height / 2);
out.dragWasActiveBeforeDetach = c._tabDrag !== null && c._tabDrag.moved === true;
const parent = c.parentNode;
const next = c.nextSibling;
const writesBeforeDetach = writes.length;
c.remove();
await new Promise((r) => setTimeout(r, 50));
out.detachEndedTheDrag = c._tabDrag === null;
// the release the detached card must no longer hear
window.dispatchEvent(new PointerEvent('pointerup', {
pointerId: 13, pointerType: 'mouse', bubbles: true, composed: true,
}));
await new Promise((r) => setTimeout(r, 700));
out.detachedCardDidNotWrite = writes.length === writesBeforeDetach;
parent.insertBefore(c, next);
await settle();
out.orderSurvivedDetach = JSON.stringify(ids()) === JSON.stringify(orderBeforeDetach);
c._writeConfig = realWrite;
return out;
});
checkAll(res);
await finish(browser, res);
File diff suppressed because one or more lines are too long
+51 -42
View File
File diff suppressed because one or more lines are too long
+5
View File
@@ -2,6 +2,11 @@
## Unreleased
- Space tabs can be reordered: grab a tab with the mouse in any editor mode and
drop it where it belongs. The order is saved, and swipe and carousel
navigation follow it. Markers stay exactly where they were
([#220](https://github.com/Matysh/houseplan-card/issues/220)).
- “Optimize plans” now removes microscopic floating-point noise from stored
grid coordinates even when nothing visibly moves. Its preview separately
reports updated spaces and cleaned coordinate values, and a second run is an
+5
View File
@@ -8,6 +8,11 @@
## Не выпущено
- Порядок вкладок пространств теперь можно менять: в режиме редактора возьмите
вкладку мышью и перетащите на нужное место. Порядок сохраняется, свайп и
карусель следуют ему. Устройства при этом остаются на своих местах
([#220](https://github.com/Matysh/houseplan-card/issues/220)).
- «Оптимизировать планы» теперь устраняет микроскопический floating-point шум
сохранённых координат сетки, даже если визуально ничего не сдвигается. В
предпросмотре отдельно показаны обновлённые пространства и очищенные
+17
View File
@@ -235,6 +235,23 @@ The plan image keeps its proportions initially. Background can later move,
scale or rotate it. Detaching a plan never deletes its server file; deletion
requires an explicit user action.
### Tab order
Space tabs follow the order in which the spaces were created, and that order can
be changed: in any editor mode, grab a tab with the mouse and drag it to a new
position. The new order is saved immediately and applies everywhere — the tabs,
the kiosk swipe between floors and the carousel arrows.
Dragging works **with a mouse and in the editors only**. In ordinary View and on
touch screens a tab still does one thing: it switches the space. There it is the
primary way to navigate, and a gesture must not compete with a plain tap. Order
is changed on a computer, like the rest of the plan work.
If a card anywhere pins its floor **by number** (`floor: 0`), remember that the
number means a position: after a reorder such a card shows a different floor.
The card warns about this once. Pin the floor by space id instead of a number to
avoid it entirely.
### Display settings
A space can show/hide room borders, names, LQI, Background and openings. It can
+18
View File
@@ -269,6 +269,24 @@ desktop: для точного рисования, Resize, модификато
них, последующая смена источника больше не сбрасывает ни один выбор. В мастере
этажей каждый следующий этаж получает собственные чистые defaults.
### Порядок вкладок
Вкладки пространств стоят в том порядке, в котором пространства заведены, и
этот порядок можно изменить: в любом режиме редактора возьмите вкладку мышью и
перетащите на новое место. Порядок сохраняется сразу и действует везде —
вкладки, свайп между этажами в киоске, стрелки карусели.
Перетаскивание работает **только мышью и только в редакторах**. В обычном
просмотре и на сенсорных экранах вкладка по-прежнему только переключает
пространство: там это основной способ навигации, и жест не должен мешать
обычному нажатию. Порядок меняется на компьютере — как и остальная работа с
планом.
Если где-то карточка закреплена за этажом **по номеру** (`floor: 0`), помните,
что номер означает позицию: после перестановки такая карточка покажет другой
этаж. Карточка предупредит об этом один раз. Чтобы этого не случалось, задавайте
этаж идентификатором пространства, а не номером.
### Настройки пространства
| Раздел | Настройка | Результат |
+277
View File
@@ -0,0 +1,277 @@
# Код-ревью issue #220 — порядок вкладок пространств перетаскиванием
- **Issue:** https://github.com/Matysh/houseplan-card/issues/220
- **ТЗ:** `docs/specs/220-space-tab-reorder.md`, зелёное ревью r3
(`docs/reviews/SPEC-REVIEW-220-r3.md`), заход исчерпан на 2/4.
- **Заход код-ревью:** r1/4 (первый цикл код-ревью; счётчик отдельный от
ревью ТЗ, §10.4 PROCESS.md).
- **Диапазон:** ветка `issue/220-space-tab-reorder`, коммит `8369c0e`
(единственный коммит реализации поверх `e57e1d9`).
- **Ревьюер:** свежая сессия, без контекста реализации.
## 1. Скоуп проверки
Диапазон — весь коммит `8369c0e` (первый код-ревью для этой задачи, полный
разбор, §2.10 не применяется): `src/space-order.ts` (новый), правки
`src/houseplan-card.ts` (обработчики drag, `_commitTabOrder`), `src/styles.ts`,
i18n en/ru, `scripts/mutation-gate.mjs`, `test/space-order.test.mjs`,
`demo/smoke_space_tab_reorder.mjs`, оба changelog, оба USER-GUIDE, три копии
бандла.
## 2. Как проверялось
| Гейт | Команда | Результат |
|---|---|---|
| Typecheck | `npx tsc --noEmit` | чисто |
| Unit | `npm test` | 982/982, 0 fail |
| Build + сверка копий | `npm run build && cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js && cmp dist/houseplan-card.js demo/srv/assets/houseplan-card.js` | пересобран, все три копии побайтово совпадают, `git status` после сборки чист |
| Смок AC1/2/4/5/7 | `node demo/smoke_space_tab_reorder.mjs` | OK, все 18 проверок зелёные |
| Смок соседнего контракта (#210) | `node demo/smoke_fixed_floor.mjs` | OK |
| Смок соседнего контракта (размещение маркеров) | `node demo/smoke_subarea.mjs` | OK |
| Mutation-gate, дешёвая проверка якорей | `node scripts/mutation-gate.mjs --check` | все патчи, включая 4 новых, находят свой якорь в исходниках |
| **Прямой вызов production-функций** (не гейт, целевая проверка находки H1) | см. §4.1 | воспроизводит расхождение |
**Не прогонялось и почему:** `golden:verify` — задача не трогает визуал в
состоянии покоя (панель вкладок вне матрицы, §15 ТЗ), diff подтверждает: правки
только в `.tab`/`.dragging`/`.droptarget` стилях активного взаимодействия;
`pytest tests_backend` — ни один файл `custom_components/**/*.py` не тронут;
полный (дорогой) прогон `mutation-gate` с пересборкой на каждого мутанта — это
предрелizный гейт, не гейт ревью (§8 PROCESS.md); остальные 124 браузерных
смока — diff не задевает их поверхности (canvas-инструменты, устройства,
партиции и т.д.), только панель вкладок и её ближайших соседей, которые
прогнаны выше.
## 3. Находки
### H1 (High) — материализация неверно определяет «зависимые от порядка» маркеры: обычный маркер устройства с площадью из реестра HA получает чужой `space`
**Что не так.** `markersNeedingPlacement` (`src/space-order.ts:101-119`) решает,
какому маркеру нужно материализовать `firstSpaceId`, глядя только на
персистентные поля самого маркера — `marker.area` и `marker.space`:
```ts
const area = typeof marker.area === 'string' ? marker.area : '';
if (area && areaToSpace[area]) continue;
out.push({ id, space: firstSpaceId });
```
Но реальное размещение маркера, привязанного к HA-устройству или сущности
(`binding: 'device:...'` / `'entity:...'`), решает не только `marker.area` —
`resolveExplicitMarkerPlacement` (`src/devices.ts:1045-1065`) в первую очередь
берёт **область из реестра HA** (`dev?.area_id` / `reg?.area_id`), и только
если её нет — откатывается к `marker.area`, `marker.space`, затем
`firstSpaceId`:
```ts
const area = marker.area || registryArea || '';
return { area, space: (area && areaToSpace[area]) || marker.space || firstSpaceId };
```
**Ровно так и создаются обычные маркеры.** `_markerDraft`
(`houseplan-card.ts:18868-18913`) и её аналог для существующей формы
(`houseplan-card.ts:13305-13344`) пишут `marker.area`/`marker.space` **только
если** `d.binding === 'virtual' || d.room` — то есть только для виртуальных
маркеров или маркеров, которым вручную выбрана комната. Любой маркер,
созданный обычным способом «привязать существующее HA-устройство» (переименовать,
сменить иконку, добавить контролы, спрятать с плана) без ручного выбора
комнаты — **самый частый путь в реальной инсталляции** (SCOPE.md: 20–200
устройств, в основном автообнаруженных) — сохраняется **без** `area` и без
`space` вовсе. Такой маркер сегодня прекрасно резолвится через область своего
HA-устройства и от порядка `spaces` не зависит.
`markersNeedingPlacement` не видит `registryArea` — у неё в сигнатуре нет ни
самого устройства, ни его области, только поля маркера. Поэтому она
классифицирует **любой такой маркер** как «зависящий от `firstSpaceId`» и
`_commitTabOrder` (`houseplan-card.ts:1300-1330`) прямо в транзакции
перестановки дописывает ему `marker.space = <старое первое пространство>` —
хотя AC3/§8.3 ТЗ прямо требуют, чтобы такие маркеры «остались побитово
прежними».
**Воспроизведение** (прямой вызов той же production-сборки, без браузера):
```js
import { buildDevices } from './test-build/devices.js';
import { markersNeedingPlacement } from './test-build/space-order.js';
const hass = { devices: { boiler: {
id: 'boiler', name: 'Boiler', model: 'X', area_id: 'attic',
identifiers: [['demo', 'boiler']], entry_type: null, via_device_id: null,
} }, entities: {}, states: {}, areas: {} };
const marker = { id: 'boiler-marker', binding: 'device:boiler' }; // ни area, ни space
const ctx = { hass, areaToSpace: { attic: 'f2' }, markers: [marker], settings: {},
excluded: new Set(), showAll: false, firstSpaceId: 'f1', loc: () => '' };
buildDevices(ctx).find((d) => d.id === 'boiler-marker').space;
// → 'f2' — маркер сегодня корректно и стабильно резолвится через область HA,
// пространство 'f2' не имеет отношения к firstSpaceId
markersNeedingPlacement([marker], { attic: 'f2' }, 'f1');
// → [{ id: 'boiler-marker', space: 'f1' }]
// _commitTabOrder на основании этого результата запишет marker.space = 'f1' —
// пространство, в котором маркер никогда не находился.
```
Запущено против собранного `test-build` этой самой ветки (`npx tsc -p
tsconfig.test.json`) — расхождение воспроизводится, не гипотетическое.
**Почему это не ловится существующими тестами.** Юнит `test/space-order.test.mjs`
(«only order-dependent markers are pinned») и смок (`smoke-dangling`, marker с
`binding: 'virtual'`) проверяют только маркеры, у которых «отсутствие
зависимости от порядка» и так видно по их собственным полям (`area: 'kitchen'`,
`space: 'f2'`). Ни один тест не заводит обычный `device:`/`entity:`-маркер без
area/space, чьё реальное размещение решает область его HA-устройства —
единственный случай, где расхождение проявляется. Мутационный гейт тоже не
покрывает эту ветку: из пяти записей §14 ТЗ реализованы четыре (см. M2 ниже),
причём именно недостающая `materialization-touches-bound-markers`
концептуально ближе всего к этой находке.
**Практическое следствие.** Прямо сейчас маркер не переезжает визуально: в
`resolveExplicitMarkerPlacement` `area && areaToSpace[area]` стоит раньше
`marker.space` в приоритете, поэтому испорченный `marker.space` временно
маскируется правильной резолюцией через область. Но конфиг тихо получает
недостоверное персистентное поле на **каждом** обычном маркере устройства без
ручной комнаты при **каждой** перестановке вкладок: как только HA-область этого
устройства когда-либо станет недоступна (переназначение area в HA, замена
устройства, повторная привязка сущности) — `area && areaToSpace[area]`
перестанет резолвиться, и маркер молча прыгнет в то самое `firstSpaceId`,
которое было актуально на момент случайной перестановки вкладок месяцы назад,
без какой-либо связи с текущим действием пользователя. Это ровно тот
сценарий («маркеры уезжают»), который §8.3 ТЗ называет главным риском задачи —
только отложенный во времени и оттого более коварный.
**Это находка в скоупе задачи** (§8.3/AC3 — центральный контракт этой самой
задачи), блокирует зелёный вердикт.
### M1 (Medium, в скоупе) — перетаскивание вкладки не захватывает указатель: отпускание за пределами панели оставляет «зависшее» состояние drag
`_tabPointerDown` (`houseplan-card.ts:1259-1272`) не вызывает
`setPointerCapture`/`capturePointer`, в отличие от **всех** остальных
pointer-based drag-жестов в этом файле — resize краёв/углов
(`_rszEdgeDown`/`_rszCornerDown`, `capturePointer(ev)`), компас
(`setPointerCapture` напрямую, `:14271`), инструменты декора (`:9598`, `:9608`,
`:14944` и другие) — все идут через явный захват указателя или через
специально написанный для этого хелпер:
```ts
// houseplan-card.ts:667-673
const capturePointer = (ev: PointerEvent): void => {
try { (ev.target as Element | null)?.setPointerCapture?.(ev.pointerId); }
catch { /* an inactive pointerId must never kill the drag */ }
};
```
Без захвата `pointermove`/`pointerup` у `_tabPointerMove`/`_tabPointerUp`
привязаны к каждому `.tab`-элементу и получают события только пока указатель
физически находится над этим элементом. Если пользователь, естественно
двигая руку по горизонтали, слегка уводит курсор за пределы строки вкладок
(вниз, на «+», в промежуток между вкладками) и там отпускает кнопку — ни
`pointerup`, ни `pointercancel` не срабатывают ни на одном `.tab`. Результат:
1. `_tabDrag` остаётся с `moved: true` навсегда — вкладка-источник продолжает
показывать класс `dragging` (`opacity: 0.55`), последняя наведённая —
`droptarget` (синяя рамка), пока не начнётся и не завершится следующий
полноценный drag на каком-то `.tab`.
2. `_tabClick` (`houseplan-card.ts:1290-1293`) читает `this._tabDrag?.moved`,
чтобы отличить клик от драга. Пока `_tabDrag` в таком «зависшем» виде,
следующий тап **другим** типом указателя (например, тач на гибридном
тач-экране ноутбука — актуально: `docs/TOUCH-SUPPORT.md` прямо
рассматривает такие устройства) не пройдёт через `_tabPointerDown`
(`canStartTabDrag` вернёт `false` для `pointerType !== 'mouse'`, ранний
`return`), состояние не сбросится, и `_tabClick` молча проглотит переключение
пространства.
Спек §8.1 требует «при отпускании вне панели порядок не меняется» — это
формально верно (commit не вызывается), но не покрывает визуальный/интерактивный
артефакт, который отпускание вне панели создаёт. Смок не ловит это: `drag()` в
`demo/smoke_space_tab_reorder.mjs` всегда завершает `pointerup` на целевой
вкладке (`to`), сценарий «отпустить вне панели» не воспроизведён ни разу.
**Почин.** Один вызов `capturePointer(event)` (используя уже существующий
хелпер `houseplan-card.ts:667`) в начале `_tabPointerDown`, как это уже сделано
для каждого другого перетаскивания в файле.
### M2 (Medium, в скоупе) — мутационный гейт реализован не полностью относительно принятого §14 ТЗ
Спека (`docs/specs/220-space-tab-reorder.md` §14) фиксирует пять записей
мутационного гейта, в том числе:
```
| materialization-touches-bound-markers | материализовать space и у маркеров с area | юнит AC3 |
```
В `scripts/mutation-gate.mjs` добавлены только четыре: `tab-reorder-not-persisted`,
`reorder-skips-materialization`, `tab-reorder-eats-click`,
`tab-reorder-ignores-pointer-type`. Хендофф-комментарий в issue тоже называет
«четыре записи, все прогнаны» — без пометки, что пятая из принятого ТЗ
пропущена, и без объяснения почему. Раз AC5.md §14 — часть зелёного ТЗ (не
«принятое предположение»), тихое сокращение состава гейта — отклонение от DoR,
а не право исполнителя.
Показательно, что именно эта недостающая проверка концептуально ближе всего к
H1: она должна была утверждать «материализация не трогает маркеры с area» —
и её реализация (более широкая, чем нынешний юнит-тест, который проверяет
только буквальное поле `marker.area`) с большей вероятностью поймала бы
находку выше на этапе реализации.
**Не отдельный issue** — это Medium в скоупе текущей задачи (§14 — её
собственный принятый план тестов), правится в этой же ветке добавлением
пятой записи мутационного гейта с юнит-тестом, покрывающим маркер, чьё
размещение решает область HA-устройства, а не собственное поле `area`.
## 4. AC — чем доказано (сверх заявленного автором)
| AC | Проверка ревьюера | Вывод |
|---|---|---|
| AC1 | смок прогнан повторно, зелёный; код `_commitTabOrder` + `reorderSpaceIds`/`applySpaceOrder` прочитан | доказано |
| AC2 | смок повторно; unit `passedDragThreshold`/`canStartTabDrag` прочитаны | доказано |
| AC3 | смок повторно зелёный **для сценария из смока**, но разбор по коду нашёл незакрытый случай — **H1** | **не доказано полностью**, находка блокирует |
| AC4 | `swipeTarget` берёт `this._model.map(m => m.id)` (`:5825`), `_model` пересчитывается по `_cfgEpoch`, который `_saveConfig()` инкрементирует синхронно до дебаунса записи — прочитано, логика верна | проверено чтением, корректно |
| AC5 | смок повторно; `canStartTabDrag` разобран построчно, семь юнит-веток на отрицание | доказано |
| AC6 | `_commitTabOrder` → `_saveConfig()` → `_saveConfigDebounced` → `_writeConfig()` — тот же путь конфликта, что у остальных правок (`toast.conflict`, `_reloadConfigOnly`), своего кода обработки не добавлено — прочитано | проверено чтением, не исполнением (согласен с автором) |
| AC7 | смок повторно зелёный | доказано |
| AC8 | md5 всех трёх копий бандла совпадает после независимой пересборки; changelog/USER-GUIDE поменяны в обоих языках | доказано — **с оговоркой**: формулировка «Markers stay exactly where they were» станет точной только после фикса H1 |
## 5. Что проверено и корректно
- Порог 4 px, разделение клика и драга, вставка перед целевой вкладкой (а не
обмен местами) — соответствует §17 ТЗ.
- `.tabadd` («+») не участвует: у неё нет `pointerdown`/`pointermove`/`pointerup`
обработчиков, поэтому она не может стать ни источником, ни целью — код
подтверждает намерение спеки без отдельного смок-кейса.
- Одно пространство — `canStartTabDrag` возвращает `false` при `spaceCount <= 1`
(юнит-тест есть).
- `_hasFixedFloor` корректно блокирует drag (панель с фиксированным этажом
показывает одну вкладку — двойная защита, не только через `spaceCount`).
- View/киоск/тач исключены на уровне чистой функции и подтверждены смоком.
- Атомарность записи (порядок + материализация одним `cfg`-мутированием перед
единственным `_saveConfig()`) соблюдена — ровно то, что требовал норматив
§17.3 ТЗ и на чём настоял ревью ТЗ r2/r3 (M3).
- Тост «once per session» — `_tabOrderWarned` не персистится, инстанс-поле,
сбрасывается только при пересоздании карточки — соответствует формулировке
АС7 «за сессию».
- Три копии бандла побайтово идентичны после независимой пересборки ревьюером.
## 6. Чего не проверял
- Полный (дорогой, предрелизный) прогон `mutation-gate.mjs` с реальной
пересборкой на каждый мутант — вне объёма код-ревью (§8 PROCESS.md), прогнан
только `--check`.
- `golden:verify` — визуал панели вкладок не в матрице, изменений в покое нет;
не прогонялся сознательно (см. §2).
- `pytest tests_backend` — Python не тронут.
- Остальные ~124 браузерных смока за пределами панели вкладок и её
непосредственных соседей (#210, привязка маркеров) — diff их поверхностей не
касается.
- Ручного тестирования в браузере (drag мышью вживую) не было — по регламенту
цикла (нет фазы ручного тестирования); H1 и M1 доказаны разбором по коду и
прямым вызовом продакшен-функций соответственно, а не интерактивной сессией.
## 7. Итог
Один High (H1) — блокирует. Два Medium (M1, M2) — в скоупе задачи, правятся в
этой же ветке. AC3 не доказан полностью: смок проверяет только тот подкласс
«зависимых от порядка» маркеров, который сам код классифицирует явными
полями, и не покрывает самый частый в реальных конфигурациях случай —
маркер устройства, чьё размещение решает область HA, а не поле маркера.
Возврат автору: `S6-in-progress`.
+213
View File
@@ -0,0 +1,213 @@
# SPEC-REVIEW-220-r1
- **Issue:** https://github.com/Matysh/houseplan-card/issues/220
- **ТЗ:** `docs/specs/220-space-tab-reorder.md` (коммит `0fd2331`)
- **Ревьюер:** Claude (роль «ревьюер ТЗ», PROCESS.md §2.4)
- **Цикл:** r1/2 (обычный трек — лимит 4, но фиксирую фактический номер цикла)
- **Вердикт:** жёлтый · High: 0 · Medium: 2 (обе в скоупе задачи)
## Скоуп ревью
Полный разбор — первый цикл. Проверено:
1. `docs/SCOPE.md` — соответствие job J6, персона «администратор плана».
2. `AGENTS.md`, `PROCESS.md` §2.4, §2.5, §7.1 — обязательные разделы ТЗ,
критерии DoR, формат вердикта.
3. Тело issue #220 и оба комментария (аналитика → `S3-spec`; хендофф ТЗ →
`S4-spec-review`).
4. `docs/USER-GUIDE.ru.md` — терминология «пространство», «вкладка».
5. `docs/TOUCH-SUPPORT.md` — канонический документ для editor-only фичи с
явным touch-решением владельца.
6. `docs/CONFIG-COMPATIBILITY.md` — паттерн описания новых/условных полей.
7. Код на `dev` (`src/houseplan-card.ts`, `src/logic.ts`, `src/devices.ts`) —
сверка всех фактических утверждений ТЗ (номера строк, сигнатуры,
поведение).
## Как проверялось
- Построчное чтение ТЗ (`docs/specs/220-space-tab-reorder.md`, 236 строк)
против требуемых разделов §7.1 — все присутствуют: сценарий, что человек
увидит до/после, подтверждённая причина, скоуп/не-скоуп, контракт поведения,
данные/i18n/a11y/security, performance, риски, AC1…AC8 с доказательством,
план автотестов, мутационный гейт, release-артефакты, откат, блок
принятых предположений.
- Каждая техническая ссылка на код (`houseplan-card.ts:15717/6692/3350/4273/
1165`, `devices.ts:1058/1063/1249`, `logic.ts:1870-1879`) сверена с текущим
`dev` через `grep`/чтение файлов — расхождение только в точных номерах строк
(код на `dev` сдвинулся на десятки строк с момента анализа), сама логика,
сигнатуры и поведение подтверждены дословно:
- `navigationSpaces = fixed.kind === 'valid' ? [space] : model` (`:15673`,
рендер вкладок `:15746`) — подтверждает побочный эффект: при активном
`floor` (#210) в панели всегда ровно один таб, значит правило AC5 «одно
пространство — обработчики не навешиваются» само по себе накрывает и этот
случай, отдельной ветки в контракте не требуется;
- `firstSpaceId: this._model[0]?.id || ''` встречается дважды
(`houseplan-card.ts:3350`, `:4273`), потребляется в `devices.ts` в
`resolveExplicitMarkerPlacement` и в ветке virtual-маркера — подтверждён
риск, ради которого написан §8.3/AC3;
- `swipeTarget(dx, dy, zoom, spaceIds, current, minPx)` (`logic.ts:1870`)
принимает `spaceIds` параметром, вызывающий код передаёт
`this._model.map((m) => m.id)` (`houseplan-card.ts:5719`) — заявление §8.4
«изменений не требует» подтверждено буквально;
- `_fixedFloorState` (`:1165`) действительно имеет ветвь
`out-of-range-index` — таблица в §«подтверждённая причина» точна;
- `_canEdit` (`:845`, серверный `can_write`/`is_admin`) и `_mode` (тип
`'view'|'plan'|'devices'|'decor'`, `mode-transition.ts:1`) — разные
независимые гварды, оба перечислены в §8.1 корректно и раздельно;
- `_writeConfig`/`expected_rev` (`:6702`) — существующий механизм, на
который опирается AC6, подтверждён.
- Итог: ни одного факта, выданного за решение без опоры на код или на
явный owner-decision, не найдено. Все «подтверждённые причины» в ТЗ
подтверждаются.
- `docs/TOUCH-SUPPORT.md` прочитан целиком — раздел «Documentation rule»
(строки 153–165) обязывает каждую спецификацию новой editor-фичи явно
указать один из трёх ярлыков: `Touch editor: supported` /
`best effort / intentionally degraded` / `not exposed`. В ТЗ #220 такой
строки нет (см. находку M1).
- `docs/CONFIG-COMPATIBILITY.md` прочитан как образец того, как описываются
новые/условные поля конфигурации, для сверки с находкой M2.
- AC1…AC8 проверены на однозначность и наличие способа доказательства —
все восемь пронумерованы и каждый называет `unit`/`smoke`/`diff` (см.
ниже раздел «AC»).
- Блок §17 «Принятые предположения» проверен на то, что в нём действительно
лежат только технические решения (не продуктовые) — да, все четыре пункта
такие; продуктовые вопросы (тач, права, клавиатура) закрыты явными
owner-decision в §4, что подтверждается вторым комментарием issue.
## Находки
### M1 — Medium, в скоупе. Отсутствует обязательная touch-классификация
`docs/TOUCH-SUPPORT.md` («Documentation rule», строки 153–165) требует, чтобы
спецификация новой editor-фичи явно называла один из трёх статусов:
`Touch editor: supported` / `best effort / intentionally degraded` /
`not exposed`. Задача #220 добавляет новую editor-фичу (перетаскивание
вкладок), которая по прямому решению владельца (§4.1 ТЗ) на сенсорных
экранах не работает вовсе — это классический случай `not exposed`. ТЗ нигде
не содержит этой строки (проверено `grep -n -i "touch editor"
docs/specs/220-space-tab-reorder.md` → пусто).
**Почему это не формальность.** Правило существует специально для того, чтобы
touch-ограничение было явным, а не выведенным читателем ТЗ из контекста: §103
того же документа («Deliberate degradation rule») требует пяти условий,
включая «touch limitation is deliberate, described in the user-facing
limitations» — описание в USER-GUIDE запланировано (§15 ТЗ), но сама
классификация как акт признания решения — нет.
**Чем закрывается:** одна строка вида `Touch editor: not exposed —
перетаскивание вкладок работает только мышью в редакторах (§4.1, §7)` в ТЗ,
рядом с §4 или §9. Не требует новых AC и не меняет скоуп.
### M2 — Medium, в скоупе. §9 утверждает «нет нового поля», хотя §8.3/§17.3 предполагают обратное
§9 («Данные») формулирует как решённый факт: *«только порядок элементов
массива `spaces`. Ни одно поле не добавляется и не удаляется; миграции и
compatibility-полей нет»*.
Это противоречит собственному §8.3/§17.3 того же документа. Причина
противоречия не косметическая, а структурная: сегодня «первое пространство»
физически **не имеет** другого представления, кроме позиции 0 в массиве —
отдельного поля-якоря или временной метки создания не существует
(подтверждено чтением `devices.ts` и `houseplan-card.ts`: `firstSpaceId`
везде вычисляется как `_model[0]?.id`). Значит, чтобы якорь **пережил
перезагрузку страницы** — а он обязан её пережить, иначе после reload
`_model[0]` снова укажет на новый первый элемент (тот, что оказался на месте
0 после перестановки), и AC3 перестанет выполняться при следующей сессии —
единственный способ сохранить инвариант «маркер не меняет `space`» состоит в
том, чтобы **где-то записать исходный id постоянно**. Собственный блок
предположений автора (§17.3) прямо это и предлагает: «хранить якорь в
`settings` при первой записи конфигурации».
То есть §9 отвечает «нет» на вопрос, который DoR (`PROCESS.md` §2.5: «миграция
и compatibility-поля решены по `docs/CONFIG-COMPATIBILITY.md`») требует
решить, а не задекларировать закрытым текстом, который тут же опровергается
соседним разделом. Технический выбор (само устройство поля) законно оставлен
автору — но факт «поле, скорее всего, появится» должен быть проговорён в §9,
а не спрятан только в необязательном для чтения блоке предположений.
**Последствие, если не поправить.** Если реализация действительно добавит
поле в `settings` (наиболее вероятный путь по собственному §17.3),
`docs/CONFIG-COMPATIBILITY.md` не получит записи о новом поле, хотя документ
существует ровно для таких случаев (сравните с трактовкой `known_devices`,
`custom_fill`, `bg_mode` — все новые поля `settings` в этом файле
задокументированы отдельным разделом с историей совместимости).
**Чем закрывается:** переформулировать §9 в духе «если якорь потребует нового
поля в `settings` — оно аддитивно и forward-compatible: старые клиенты его
игнорируют, при отсутствии поля fallback остаётся прежним (`_model[0]`)», и
добавить короткую запись в `docs/CONFIG-COMPATIBILITY.md` в том же коммите,
где появится реализация (не обязательно сейчас, но контракт должен быть
проговорён в ТЗ, а не отрицаться).
### Low — не является блокирующим, не требует правки
- Гвард начала drag (`pointerdown` по `.tab`, порог 4px) не оговаривает
явно элемент `.tabedit` (шестерёнка настройки пространства) внутри вкладки:
текущий обработчик шестерёнки — `@click` с `e.stopPropagation()`
(`houseplan-card.ts:15754`), но `pointerdown`, скорее всего, будет повешен
на `.tab`, а не на кнопку, и `stopPropagation` в `@click` не остановит
всплытие `pointerdown`. Реализация почти наверняка и так исключит `.tabedit`
из зоны старта drag (иначе шестерёнка перестанет открывать диалог при
случайном микросдвиге курсора) — это тривиальная деталь без продуктовой
неоднозначности и без риска потери данных. Снимаю без правки: чисто
реализационная деталь, накрываемая кодревью.
## AC — проверка на однозначность и доказуемость
| AC | Однозначен | Способ доказательства указан | Комментарий |
|---|---|---|---|
| AC1 | да | `smoke` | конкретное поведение: позиция + переживает reload |
| AC2 | да | `smoke` | порог 4px, оба исхода (клик/drag) проверяемы |
| AC3 | да | `unit` (`buildDevices`) | явно требует «тест красный до развязки» — хорошая практика |
| AC4 | да | `unit`+`smoke` | подтверждено чтением кода: `swipeTarget` не требует правок |
| AC5 | да | `unit`+`smoke` | четыре условия исключения перечислены явно и проверяемо |
| AC6 | да | `unit`/`smoke` | опирается на существующий механизм `expected_rev` |
| AC7 | да | `smoke` | «один раз за сессию» — паттерн уже есть в коде (`_onboardingShown`), не изобретён |
| AC8 | да | `diff` + сверка бандлов | стандартный release-гейт |
Ни один AC не сформулирован расплывчато, у каждого назван корректный способ
доказательства применительно к тому, что он утверждает.
## Что проверено и корректно
- Персона и job (`docs/SCOPE.md` J6) подобраны верно, сценарий и «что человек
увидит» — в пользовательских терминах, без реализации.
- Продуктовые решения (§4) действительно закрывают все три продуктовых
вопроса из аналитики (тач/права/клавиатура) явной ссылкой на owner-decision
2026-08-20, а не на догадку исполнителя.
- Все три места, где порядок массива несёт скрытый смысл (`firstSpaceId`,
`swipeTarget`, числовой `floor`), названы, подтверждены по коду и закрыты
разделами контракта (§8.3, §8.4, §8.5) — это тот самый риск, ради которого
задача вообще была оценена в сложность 4/10, а не 1/10.
- Технические предположения (§17) действительно технические — ни один пункт
не требует продуктового решения владельца, что соответствует правилу
«размытое место не додумывается, а выносится владельцу» из PROCESS.md §7.1
(здесь размытых продуктовых мест не осталось).
- Мутационный гейт целится в самые опасные регрессии (потеря записи, съеденный
клик, возврат порядко-зависимого `firstSpaceId`, снятие проверки
`pointerType`) — это именно те четыре мутанта, которые действительно ловят
типовые ошибки такой фичи.
- Release-артефакты (§15) и откат (§16) присутствуют и покрывают оба
changelog, оба USER-GUIDE, отсутствие влияния на golden/perf.
## Чего не проверял
- Не проверял реализацию — её ещё нет (это ревью ТЗ, код не существует).
- Не запускал никаких гейтов/тестов: на этапе ревью ТЗ гейты (`typecheck`,
`npm test`, `npm run build`) неприменимы — класса A/B изменений нет, только
документация (класс C).
- Не проверял `docs/specs/README.md` на полноту сверх наличия строки про
#220 — строка присутствует, этого достаточно для этого этапа.
- Не оценивал абсолютную величину порога 4px и механику «вставка перед» —
оба явно помечены автором как «принято предположительно, поменять
свободно» (§17.1–17.2), оспаривать нечего: реализация вправе выбрать другое
значение без нового цикла ревью.
## Итог
Два Medium-замечания, обе в скоупе задачи, обе устраняются точечной правкой
текста ТЗ (без изменения AC, скоупа или контракта поведения). High-находок
нет. Вердикт — жёлтый: автор правит §4/§9 ТЗ (добавляет touch-классификацию и
снимает противоречие в разделе «Данные»), правка проходит второй цикл ревью
ТЗ. Задача не `small`, лимит циклов ревью ТЗ — 4 (`PROCESS.md` §4); этот
раунд — первый из них.
+185
View File
@@ -0,0 +1,185 @@
# SPEC-REVIEW-220-r2
- **Issue:** https://github.com/Matysh/houseplan-card/issues/220
- **ТЗ:** `docs/specs/220-space-tab-reorder.md` (текущий коммит `e57e1d9`,
«docs: close M1 and M2 from the spec review of #220»)
- **Ревьюер:** Claude (роль «ревьюер ТЗ», PROCESS.md §2.4)
- **Заход:** r2 · блокирующих циклов 1/4
- **Вердикт:** жёлтый · High: 0 · Medium: 1 (в скоупе задачи)
## Скоуп ревью
Разбор по дельте (PROCESS.md §2.9, #214), а не заново — с одним расширением.
Раунд r1 закрыл два Medium (M1, M2) точечной правкой текста ТЗ, но правка по
M2 не косметическая: §8.3 полностью переписан — вместо «хранить якорь в
`settings`» теперь «материализовать привязку в той же записи». Это смена
контракта поведения (новая нормативная процедура записи конфигурации), а не
переформулировка факта, поэтому именно этот кусок разобран полно — включая
сверку с кодом `devices.ts`/`houseplan-card.ts` заново, а не по памяти r1.
Остальное (§1–§7, §10–§16, AC1/2/4/5/6/7/8, мутационный гейт вне AC3, план
автотестов) дельта не касается — унаследовано из r1 без повторной проверки
(раздел ниже).
## Как проверялось
1. Найден вердикт r1 в комментариях issue #220
(`https://github.com/Matysh/houseplan-card/issues/220#issuecomment-5361187761`,
`2026-08-20T20:17:49Z`) и документ `docs/reviews/SPEC-REVIEW-220-r1.md`,
зафиксировавший ТЗ на коммите `0fd2331`.
2. Объявлена дельта: `git diff 0fd2331..HEAD -- docs/specs/220-space-tab-reorder.md`
(HEAD = `e57e1d9`). Дельта — 58 вставок / 19 удалений, только §8.3, §9, AC3,
мутационная таблица, §17.3; остальные разделы файла побитово не менялись.
3. Комментарий владельца о закрытии r1 называет коммит `d0a5bfa` — такого
объекта в репозитории нет (`git cat-file -t d0a5bfa` → `fatal: Not a valid
object name`), вероятно переписан ребейзом веток issue. Не доверяю
названному SHA, проверка сделана по факту — прямым диффом файла между
`0fd2331` (зафиксирован в документе r1) и текущим `HEAD` (`e57e1d9`,
единственный коммит после `c3278dd`/review-doc r1, который трогает файл
ТЗ) — содержимое дословно совпадает с тем, что владелец описал в
комментарии, расхождение только в имени SHA в тексте комментария.
4. Каждая находка r1 (M1, M2) сверена не по заявлению автора, а по строке
текста ТЗ — см. таблицу «Закрытие раунда r1».
5. `docs/TOUCH-SUPPORT.md` (§153–165, «Documentation rule») прочитан повторно,
построчно сверена ровно та формулировка ярлыка, которую требует правило.
6. Новая нормативная процедура §8.3 (материализация в той же записи) сверена
с фактическим кодом разрешения `firstSpaceId`:
`src/devices.ts:1045-1065` (`resolveExplicitMarkerPlacement`, включая ветку
`manualRoomWithoutArea`, ранее не разбиравшуюся отдельно ни в issue, ни в
r1) и `src/devices.ts:1249` (виртуальный маркер). Оба случая подтверждают
общий критерий ТЗ «нет ни `area`, ведущей в пространство, ни собственного
`space`» — он корректно обобщает все три ветки кода, включая ветку
marker-без-HA-area из #3, которую ТЗ не называет по номеру, но покрывает
по содержанию.
Также проверено третье использование `firstSpaceId`
(`src/houseplan-card.ts:18818`, черновик маркера в диалоге) — это
непостоянный preview, не пишется в конфиг, к обязательству §8.3
(«ни один маркер не меняет `space`») не относится: диалог всегда
пересчитывает его заново по актуальной модели.
7. AC3 (единственный AC, задетый дельтой) перепроверен на однозначность и
доказуемость с учётом новой формулировки. AC1, AC2, AC4–AC8 дельтой не
задеты — унаследованы из r1 без повторной проверки.
## Закрытие раунда r1
| Находка r1 | Чем закрыта | Где это видно |
|---|---|---|
| **M1** — нет обязательной touch-классификации по `docs/TOUCH-SUPPORT.md` §153 | В §9 добавлена строка **`Touch editor: not exposed.`** с обоснованием (вкладки живут во View, где переключение — fully supported/release-blocking; жест на вкладке рискует съесть тап) и отдельно — что safety floor §69 соблюдён по построению | `docs/specs/220-space-tab-reorder.md:156-165`; формулировка ярлыка совпадает с требуемой буква в букву (сверено с `docs/TOUCH-SUPPORT.md:160-162`) |
| **M2** — §9 отрицал новое поле конфигурации, §8.3/§17.3 предполагали якорь в `settings` | §8.3 переписан: вместо якоря — материализация существующего поля `marker.space` в той же записи, что и порядок; §9 теперь говорит «новых полей конфигурации не появляется... см. §8.3», и это уже не противоречит остальному документу, а согласуется с ним; §17.3 переписан в терминах материализации, старое предположение про якорь явно помечено отвергнутым | `docs/specs/220-space-tab-reorder.md:107-139` (§8.3), `:167-170` (§9), `:268-271` (§17.3); AC3 (`:202-206`) и мутационная таблица (`:240-241`) синхронно обновлены под новую механику |
Обе находки закрыты не декларативно: правка убирает саму причину
противоречия (M2) и добавляет предметное содержание, а не просто ярлык (M1).
Новых полей конфигурации в изменённом варианте действительно не появляется —
проверено по коду: материализация пишет уже существующее поле маркера
`space`, `CONFIG_SCHEMA` и `scripts/config-field-registry.mjs` в этой задаче
не упоминаются как изменяемые, и это утверждение больше не противоречит §9.
## Унаследовано из r1
Принято без повторной проверки в этом раунде — проверено и подтверждено в
`docs/reviews/SPEC-REVIEW-220-r1.md` на коммите `0fd2331`, дельта эти разделы
не касается:
- Персона, сценарий и job `docs/SCOPE.md` J6 (§1–§2 ТЗ).
- Продуктовые решения владельца §4 (тач/права/клавиатура) и их соответствие
owner-decision в комментариях issue.
- §8.1, §8.2, §8.4, §8.5 контракта (порог 4px, `.tabadd`, запись через
`_writeConfig`/`expected_rev`, `swipeTarget`, тост о числовом `floor`).
- AC1, AC2, AC4, AC5, AC6, AC7, AC8 — однозначность и способ доказательства.
- Мутанты `tab-reorder-not-persisted`, `tab-reorder-eats-click`,
`tab-reorder-ignores-pointer-type` (не переписаны в дельте).
- §11 (риски), §15 (release-артефакты), §16 (откат) кроме уже отражённых в
дельте формулировок.
- Low-находка r1 про `.tabedit`/`pointerdown` — оставлена без правки
экспертным решением r1 («реализационная деталь, накрываемая кодревью»),
дельта её не касается, пересматривать нет причины.
## Находки
### M3 — Medium, в скоупе. §17.3 маркирует нормативное требование как «свободно меняемое»
Заголовок §17 — «Принятые предположения (**техническое, менять свободно**)».
Пункт 3 внутри него (`docs/specs/220-space-tab-reorder.md:268-271`) гласит:
«Материализация привязки (§8.3) выполняется в том же `config/set`, что и
порядок, а не отдельной записью: две записи дали бы окно, в котором порядок
уже новый, а привязка ещё старая».
Но именно это же самое требование в §8.3 названо не предположением, а
**«Норматив»** (`:113`, буквально это слово стоит заголовком абзаца) — и оно
уже вошло в тестируемый контракт: AC3 требует «маркер получает явное `space`
**в той же записи**» (`:203-204`), а мутант `reorder-skips-materialization`
(`:240`) специально ловит его нарушение.
**Почему это не формальность.** §17.3 своим же текстом объясняет, почему
атомарность нельзя менять свободно: если порядок и материализация уйдут
двумя разными записями `config/set`, то в промежутке между ними
`firstSpaceId = model[0]?.id` уже пересчитан по новому порядку — маркер,
ещё не материализованный, в это окно резолвится в **не то** пространство.
Если исполнитель прочитает заголовок §17 буквально («менять свободно») и
раздельными записями, а не заголовок §8.3 («Норматив»), он получит ровно тот
риск, ради которого писан весь раздел 8.3 и ради которого задача оценена в
сложность 4/10 (см. §11, риск №2 «Маркеры уезжают» — главный риск задачи).
Опасность усугубляется тем, что при таком (неверном) выборе реализации
собственный юнит-тест AC3, написанный тем же исполнителем под ту же
(неверную) модель, скорее всего будет проверять «согласованность после двух
записей», а не «атомарность одной записи» — то есть мутационный гейт
`reorder-skips-materialization` перестанет быть надёжным барьером именно в
том сценарии, для которого его писали.
**Чем закрывается:** убрать пункт 3 из §17 (он не является свободным
предположением — норма уже сформулирована в §8.3 как обязательная и
проверяется AC3), либо явно пометить его как исключение из «менять свободно»
с отсылкой на §8.3/AC3. Правка текстовая, AC и скоуп не меняются.
## AC — что перепроверено дельтой
| AC | Задет дельтой | Однозначен | Доказательство | Комментарий |
|---|---|---|---|---|
| AC1, AC2, AC4, AC5, AC6, AC7, AC8 | нет | — | — | унаследованы из r1 без повторной проверки |
| AC3 | да | да | `unit` (`buildDevices` + запись) | формулировка усилена («маркеры с `area`/`space` — побитово прежними»), однозначна и доказуема; ровно вокруг него — находка M3 (не сам AC, а соседний раздел §17.3, который создаёт риск неверной трактовки реализации, проверяемой этим же AC) |
## Что проверено и корректно
- M1 и M2 закрыты предметно, а не декларативно — см. таблицу выше; §9 больше
не противоречит §8.3.
- Новая механика §8.3 (материализация) корректно обобщает все три реальных
пути резолюции `firstSpaceId` в `devices.ts`, включая ветку
`manualRoomWithoutArea` (маркер ручной комнаты без HA area, контракт #3),
которую ни issue, ни r1 не разбирали пофамильно — критерий «нет `area`,
ведущей в пространство, и нет собственного `space`» покрывает её без
исключений.
- Материализация не задевает preview-путь черновика маркера
(`houseplan-card.ts:18818`) — он не персистентный, обязательство §8.3 на
него не распространяется, и в ТЗ об этом ничего лишнего не заявлено.
- Новая пара мутантов (`reorder-skips-materialization`,
`materialization-touches-bound-markers`) корректно закрывает ровно два
направления ошибки материализации (пропуск и избыточность), заменив собой
устаревший `first-space-follows-order`.
- Владелец сам в комментарии честно отделил решение от подгонки: назвал две
альтернативы снятия противоречия M2 («признать поле» / «обойтись без
него») и выбрал вторую с объяснением — это ровно то, что PROCESS требует
от продуктовых/технических решений, выносимых в ревью.
## Чего не проверял
- Не проверял реализацию — кода ещё нет, это ревью ТЗ.
- Не запускал `tsc`/`npm test`/`npm run build` — класс изменения C
(документация), гейты неприменимы, как и в r1.
- Не пересматривал разделы, не тронутые дельтой (§1–§7, §10–§16, AC1/2/4-8,
три из пяти мутантов) — они унаследованы из r1 на коммите `0fd2331`, дельта
их не меняла ни байтом.
- Не проверял точность SHA `d0a5bfa`, названного владельцем в комментарии о
закрытии r1 — объект не существует в репозитории; вместо этого верификация
сделана по прямому диффу файла до `HEAD` (`e57e1d9`), см. «Как
проверялось», п.3. Расхождение SHA не влияет на вывод ревью, но зафиксировано
как наблюдение.
## Итог
Обе находки r1 закрыты предметно. Правка §8.3 (материализация вместо якоря)
— смена контракта поведения, а не косметика, и именно в ней найдена новая
находка M3: §17 маркирует как «свободно меняемое» требование, которое сам же
документ в §8.3 называет обязательным норматив и проверяет тестируемым AC3.
Находка в скоупе, чинится точечной правкой текста (перенос/каветирование
пункта 17.3), без изменения AC, скоупа или контракта поведения. High-находок
нет. Вердикт — жёлтый: правка проходит третий цикл ревью ТЗ (заход r3, лимит
циклов ревью ТЗ — 4).
+150
View File
@@ -0,0 +1,150 @@
# SPEC-REVIEW-220-r3
- **Issue:** https://github.com/Matysh/houseplan-card/issues/220
- **ТЗ:** `docs/specs/220-space-tab-reorder.md` (текущий коммит `5ba46df`,
«docs: take the atomic write out of the "free to change" block (#220 M3)»)
- **Ревьюер:** Claude (роль «ревьюер ТЗ», PROCESS.md §2.4)
- **Заход:** r3 · блокирующих циклов израсходовано 2 из 4
- **Вердикт:** зелёный · High: 0 · Medium: 0
## Скоуп ревью
Разбор по дельте (PROCESS.md §2.9, #214). Найден вердикт r2
(`https://github.com/Matysh/houseplan-card/issues/220#issuecomment-5361277814`,
`2026-08-20T20:26:51Z`) и документ `docs/reviews/SPEC-REVIEW-220-r2.md`,
зафиксировавший ТЗ на коммите `e57e1d9`. Владелец в комментарии о закрытии r2
(`https://github.com/Matysh/houseplan-card/issues/220#issuecomment-5361297569`)
сам называет SHA после пуша — `5ba46df` — и это совпадает с текущим `HEAD`;
в r2 автор один раз назвал несуществующий SHA (`d0a5bfa`, переписан
`git pull --rebase`), в этом раунде расхождения нет, проверено
`git rev-parse HEAD` = `5ba46df`.
`git diff e57e1d9..5ba46df -- docs/specs/220-space-tab-reorder.md` — единственный
хунк, 12 вставок / 4 удаления, весь внутри пункта 3 §17 («Принятые
предположения»). Ни §8.3 (норматив материализации), ни §9, ни AC12, ни таблица
мутантов §14, ни любой другой раздел не тронуты ни байтом — это чисто
текстовая правка одного абзаца, не смена контракта поведения (сам норматив
и его формулировка в §8.3 не изменились, изменилось только то, как §17
классифицирует уже существующее требование). Дельта локальна, полный
повторный разбор не требуется.
Разобрана дельта полностью: сам абзац §17.3 и его согласованность с §8.3, AC3
и мутантом `reorder-skips-materialization`, на которые он теперь явно
ссылается. Остальное (§1–§16 кроме одного абзаца §17, AC1/2/4–8, весь
мутационный гейт, план автотестов, release-артефакты, откат) дельта не
касается — унаследовано из r2 (раздел ниже).
## Как проверялось
1. Прочитан текущий пункт 3 §17 (`docs/specs/220-space-tab-reorder.md:268-279`)
целиком и сверен построчно с находкой M3 ревью r2: требовалось либо убрать
пункт из «менять свободно», либо явно пометить его исключением со ссылкой
на §8.3/AC3. Автор выбрал второй вариант: первая фраза пункта —
«Атомарность записи предположением не является», далее прямая ссылка на
норматив §8.3, AC3 и мутант `reorder-skips-materialization`, и явное
«свободно меняется всё остальное в этом разделе, но не это». Формулировка
закрывает причину противоречия, а не переименовывает её: заголовок §17
(«менять свободно») больше не может быть прочитан как разрешение развязать
запись на две — конкретный пункт прямо и по имени объявляет себя
исключением.
2. Проверено, что новая формулировка не создаёт обратного противоречия: §8.3
(`:107-139`, не тронут дельтой) называет атомарность «Норматив», AC3
(`:194-224`, не тронут дельтой) и мутант `reorder-skips-materialization`
(§14, не тронут дельтой) её проверяют — пункт §17.3 теперь на них ссылается,
а не спорит с ними. Три источника (§8.3, AC3, мутант, §17.3) говорят одно и
то же.
3. Сохранённый второй абзац («историческая справка про якорь в `settings`,
отвергнутый по M2 r1») — не норматив, а комментарий к истории решения;
он не противоречит новой формулировке первого абзаца и не меняет статус
пункта.
4. `grep -n "17\.3\|§17"` по всему файлу — единственная ссылка на этот раздел
находится в самом пункте 3 (самоссылка на находку M3), других мест
документа, которые ожидали бы прежнюю формулировку, нет.
5. AC, задетые дельтой — ни одного: AC3, единственный кандидат, ссылается на
§8.3 как источник нормы, а не на §17, и текст AC3 не менялся. Проверено
построчным сравнением AC3 в текущем файле с версией, зафиксированной в
`SPEC-REVIEW-220-r2.md` — идентична.
## Закрытие раунда r2
| Находка r2 | Чем закрыта | Где это видно |
|---|---|---|
| **M3** — §17 маркирует нормативное требование атомарности записи как «свободно меняемое», хотя §8.3 называет его нормативом, а AC3 и мутант `reorder-skips-materialization` его проверяют | Пункт 3 §17 переписан: первая фраза — «Атомарность записи предположением не является», с прямой ссылкой на норматив §8.3, AC3 и мутант, и явной оговоркой «свободно меняется всё остальное в этом разделе, но не это» | `docs/specs/220-space-tab-reorder.md:268-275`; сверено с §8.3 (`:113`, слово «Норматив» как было), AC3 и таблицей мутантов §14 — все три не менялись и согласуются с новой формулировкой |
Находка закрыта не переименованием, а содержательно: раньше заголовок §17
разрешал буквальное прочтение «эту запись можно разнести на две», теперь
пункт сам называет себя исключением и объясняет, какому нормативу подчинён.
## Унаследовано из r2
Принято без повторной проверки в этом раунде — проверено и подтверждено в
`docs/reviews/SPEC-REVIEW-220-r2.md` на коммите `e57e1d9`, дельта эти разделы
не касается:
- Персона, сценарий и job `docs/SCOPE.md` J6 (§1–§2 ТЗ) — унаследовано из r2,
которое само унаследовало это из r1 (`0fd2331`).
- Продуктовые решения владельца §4 (тач/права/клавиатура).
- §8.1, §8.2, §8.4, §8.5 контракта.
- §8.3 целиком (материализация вместо якоря) и его согласованность с §9 —
закрытие M1/M2 из r1, подтверждённое в r2 сверкой с кодом `devices.ts`
(все три пути резолюции `firstSpaceId`, включая ветку ручной комнаты без
HA area).
- AC1–AC8 — однозначность и способ доказательства (AC3 переподтверждён в r2
под новую механику материализации; в этом раунде не менялся).
- Все пять записей мутационного гейта §14, включая
`reorder-skips-materialization` и `materialization-touches-bound-markers`.
- §9 (данные/i18n/touch: `Touch editor: not exposed`), §10 (performance),
§11 (риски), §15 (release-артефакты), §16 (откат).
- Low-находка r1 про `.tabedit`/`pointerdown` — оставлена без правки
экспертным решением r1, дельта её не касается.
- Наблюдение r2 про несуществующий SHA `d0a5bfa` в комментарии о закрытии r1 —
закрыто содержательно в этом раунде: владелец подтвердил промах и в
комментарии о закрытии r2 назвал SHA `5ba46df` уже после пуша, он совпадает
с текущим `HEAD`.
## Находки
Нет. High: 0, Medium: 0, Low: 0.
## AC — что перепроверено дельтой
| AC | Задет дельтой | Комментарий |
|---|---|---|
| AC1, AC2, AC4, AC5, AC6, AC7, AC8 | нет | унаследованы из r1/r2 без повторной проверки |
| AC3 | нет (текст AC3 не менялся; менялась только классификация соседнего раздела §17) | сверен построчно с версией из r2 — идентичен |
## Что проверено и корректно
- M3 закрыта предметно: пункт §17.3 больше не может быть прочитан как
разрешение разнести атомарную запись на два `config/set` — именно тот
риск («маркеры уезжают»), ради предотвращения которого писан весь §8.3.
- Три источника нормы (§8.3 «Норматив», AC3, мутант
`reorder-skips-materialization`) и указатель на них в §17.3 теперь говорят
одно и то же, без противоречий.
- Историческая справка про отвергнутый якорь `settings` сохранена как
контекст решения, не как действующая норма — не создаёт путаницы с текущей
механикой материализации.
- Дельта не расширилась дальше объявленной находки: диффом подтверждено, что
ни один другой раздел документа (контракт, AC, мутанты, release-артефакты,
откат) не менялся между `e57e1d9` и `5ba46df`.
- Владелец сам указал на собственную ошибку с несуществующим SHA прошлого
раунда и впредь называет SHA только после пуша — это снимает наблюдение r2,
а не оставляет его висеть.
## Чего не проверял
- Не проверял реализацию — кода ещё нет, это ревью ТЗ.
- Не запускал `tsc`/`npm test`/`npm run build` — класс изменения C
(документация), гейты неприменимы, как и в r1/r2.
- Не пересматривал разделы, не тронутые дельтой (§1–§16 кроме одного абзаца
§17, AC1/2/4–8, весь мутационный гейт) — они унаследованы из r2 на коммите
`e57e1d9`, дельта их не меняла ни байтом (подтверждено `git diff`).
- Не проверял `docs/specs/README.md` сверх того, что было проверено в r1 —
дельта этого раунда файла не касается.
## Итог
Находка M3 закрыта предметно и без побочных противоречий. Правка — 16 строк
внутри одного пункта §17, не меняет AC, скоуп, контракт поведения или
мутационный гейт. Новых находок не появилось. Вердикт — зелёный: ТЗ готово к
`S5-ready`.
+282
View File
@@ -0,0 +1,282 @@
# 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` действительно где-то используется: карточка не видит чужие панели,
поэтому условие проверить нечем.
+1
View File
@@ -60,6 +60,7 @@ GitHub Issues и GitHub Projects (v2) остаются единственным
| [#218](https://github.com/Matysh/houseplan-card/issues/218) Floating-point шум комнаты не гасит Glow пространства | [218-glow-floor-geometry.md](218-glow-floor-geometry.md) |
| [#219](https://github.com/Matysh/houseplan-card/issues/219) Единая палитра замков и glyph на оранжевых подложках | [219-lock-orange-palette.md](219-lock-orange-palette.md) |
| [#205](https://github.com/Matysh/houseplan-card/issues/205) Продолжение следа после короткой остановки пылесоса | [205-vacuum-trail-resume-grace.md](205-vacuum-trail-resume-grace.md) |
| [#220](https://github.com/Matysh/houseplan-card/issues/220) Порядок пространств перетаскиванием вкладок | [220-space-tab-reorder.md](220-space-tab-reorder.md) |
| [#226](https://github.com/Matysh/houseplan-card/issues/226) Entity-marker не дублируется родительским HA-устройством | [226-entity-parent-dedup.md](226-entity-parent-dedup.md) |
| [#223](https://github.com/Matysh/houseplan-card/issues/223) Optimize канонизирует координаты без floating-point шума | [223-optimize-coordinate-canonicalization.md](223-optimize-coordinate-canonicalization.md) |
+79
View File
@@ -717,6 +717,85 @@ export const MUTANTS = [
replace: ' .dev:not(.unavail):hover {',
}],
},
{
id: 'tab-reorder-not-persisted',
guard: 'node demo/smoke_space_tab_reorder.mjs',
because: 'перестановка вкладок, оставшаяся только в памяти, выглядит рабочей ровно до '
+ 'перезагрузки страницы — смок обязан требовать запись на сервер',
patches: [{
file: 'src/houseplan-card.ts',
find: ' cfg.spaces = applySpaceOrder(cfg.spaces || [], order);\n this._saveConfig();',
replace: ' cfg.spaces = applySpaceOrder(cfg.spaces || [], order);',
}],
},
{
id: 'reorder-skips-materialization',
guard: 'node demo/smoke_space_tab_reorder.mjs',
because: 'без материализации привязки маркер без space и area уезжает вслед за порядком: '
+ 'его пространство решает firstSpaceId, а тот после перестановки другой (#220 §8.3)',
patches: [{
file: 'src/houseplan-card.ts',
find: ' const byId = new Map(pinned.map((entry) => [entry.id, entry.space]));',
replace: ' const byId = new Map();',
}],
},
{
id: 'materialization-touches-bound-markers',
guard: 'node --test --test-name-pattern="issue 220" test/space-order.test.mjs',
because: 'материализация обязана трогать только маркеры, чьё пространство решает порядок; '
+ 'запись space маркеру, закреплённому HA-областью, кладёт в конфиг поле, которое '
+ 'сдвинет его в старое первое пространство в день смены области (ревью r1, H1)',
patches: [{
file: 'src/space-order.ts',
find: ' if (area && areaToSpace[area]) continue;',
replace: ' if (false) continue;',
}],
},
{
id: 'tab-drag-survives-release-outside',
guard: 'node demo/smoke_space_tab_reorder.mjs',
because: 'мышь, отпущенная мимо панели, обязана завершить жест: иначе перетаскивание '
+ 'зависает с moved:true и съедает следующий клик по вкладке (ревью r1, M1)',
patches: [{
file: 'src/houseplan-card.ts',
find: " window.addEventListener('pointerup', this._tabDragRelease);",
replace: ' void 0;',
}],
},
{
id: 'tab-drag-outlives-the-card',
guard: 'node demo/smoke_space_tab_reorder.mjs',
because: 'слушатели жеста, пережившие disconnectedCallback, держат инстанс карточки '
+ 'и дают невидимой карточке записать порядок по следующему pointerup на странице '
+ '(ревью r2/r3, F1)',
patches: [{
file: 'src/houseplan-card.ts',
find: ' this._endTabDrag();\n clearInterval(this._cycleTimer);',
replace: ' clearInterval(this._cycleTimer);',
}],
},
{
id: 'tab-reorder-eats-click',
guard: 'node --test --test-name-pattern="issue 220" test/space-order.test.mjs',
because: 'нулевой порог превращает обычный клик по вкладке в перетаскивание, и '
+ 'переключение пространств — основное действие панели — перестаёт работать',
patches: [{
file: 'src/space-order.ts',
find: 'export const TAB_DRAG_THRESHOLD_PX = 4;',
replace: 'export const TAB_DRAG_THRESHOLD_PX = 0;',
}],
},
{
id: 'tab-reorder-ignores-pointer-type',
guard: 'node --test --test-name-pattern="issue 220" test/space-order.test.mjs',
because: 'на тач-устройстве вкладки — это навигация View, где тап обязан оставаться тапом; '
+ 'решение владельца «Touch editor: not exposed» держится именно этой проверкой',
patches: [{
file: 'src/space-order.ts',
find: " if (ctx.pointerType !== 'mouse') return false;",
replace: ' if (false) return false;',
}],
},
{
id: 'internal-path-ignores-query',
guard: 'node scripts/backend-test-guard.mjs issue_225',
+157 -2
View File
@@ -261,6 +261,10 @@ import {
type OpeningPlacementCore, type OpeningPlacementPreset, type OpeningPlacementType,
} from './opening-placement';
import { safeStoredColor } from './color';
import {
applySpaceOrder, canStartTabDrag, markersNeedingPlacement, passedDragThreshold,
reorderSpaceIds,
} from './space-order';
const CARD_VERSION = '1.66.0';
const DISPLAY_LABEL_KEYS: Record<DeviceDisplayMode, I18nKey> = {
@@ -1238,6 +1242,130 @@ class HouseplanCard extends LitElement {
}
/** Direct space tabs use the same motion as swipe/carousel navigation. */
// ---- reordering the space tabs (issue #220) ------------------------------
//
// Mouse only, editors only: the same tabs switch spaces in View, where touch
// is a first-class citizen, so a gesture here would compete with that tap.
// docs/specs/220-space-tab-reorder.md §4.1, "Touch editor: not exposed".
private get _canReorderTabs(): boolean {
return canStartTabDrag({
canEdit: this._canEdit,
kiosk: this._kiosk,
mode: this._mode,
pointerType: 'mouse',
spaceCount: this._model.length,
fixedFloor: this._hasFixedFloor,
});
}
private _tabPointerDown(event: PointerEvent, id: string): void {
if (!canStartTabDrag({
canEdit: this._canEdit,
kiosk: this._kiosk,
mode: this._mode,
pointerType: event.pointerType,
spaceCount: this._model.length,
fixedFloor: this._hasFixedFloor,
})) return;
// A mouse released past the edge of the panel fires neither pointerup nor
// pointercancel on any tab, and the gesture would stay stuck mid-drag —
// taking the next click with it, since _tabClick swallows clicks that
// follow a drag (review CODE-REVIEW-220-r1, M1).
//
// Capture is the usual answer and this file uses it everywhere, but it is
// not a guarantee: the browser grants it only for a live pointer, so a
// gesture that starts any other way keeps no capture at all. The window
// listener below is what actually closes the gesture; capture merely keeps
// the moves flowing to the tab while the button is held.
capturePointer(event);
this._tabDragRelease = (release: PointerEvent) => this._tabPointerUp(release);
window.addEventListener('pointerup', this._tabDragRelease);
window.addEventListener('pointercancel', this._tabDragRelease);
this._tabDrag = {
id, pointerId: event.pointerId, x: event.clientX, y: event.clientY,
moved: false, overId: id,
};
}
private _tabPointerMove(event: PointerEvent, overId: string): void {
const drag = this._tabDrag;
if (!drag || drag.pointerId !== event.pointerId) return;
if (!drag.moved
&& !passedDragThreshold(event.clientX - drag.x, event.clientY - drag.y)) return;
// Past the threshold the gesture is a drag: the click that would otherwise
// follow is suppressed in _tabClick, and the panel shows where it lands.
if (drag.moved && drag.overId === overId) return;
this._tabDrag = { ...drag, moved: true, overId };
}
private _tabPointerUp(event: PointerEvent): void {
const drag = this._tabDrag;
if (drag && drag.pointerId !== event.pointerId) return;
this._endTabDrag();
if (!drag || !drag.moved) return;
this._commitTabOrder(drag.id, drag.overId);
}
/** Drop the gesture and its window listeners, wherever the release happened. */
private _endTabDrag(): void {
this._tabDrag = null;
if (!this._tabDragRelease) return;
window.removeEventListener('pointerup', this._tabDragRelease);
window.removeEventListener('pointercancel', this._tabDragRelease);
this._tabDragRelease = null;
}
/** A click that followed a real drag must not also switch the space. */
private _tabClick(id: string): void {
if (this._tabDrag?.moved) return;
this._pickSpace(id);
}
/**
* Write the new order — and, in the same write, the placement that used to
* depend on it.
*
* A marker with neither an explicit space nor an area that names one renders
* in whatever space sits first. Reordering would silently hand it to another
* space, so the answer it has right now is written down first. This is the
* whole reason the two changes may not be split into two saves.
*/
private _commitTabOrder(movedId: string, targetId: string): void {
const cfg = this._serverCfg;
if (!cfg || !this._canReorderTabs) return;
const ids = this._model.map((space) => space.id);
const order = reorderSpaceIds(ids, movedId, targetId);
if (order === ids) return;
// The area in force, not merely the one stored on the marker: a marker that
// binds an HA device inherits its area from the registry, and such a marker
// never depended on the order (review CODE-REVIEW-220-r1, H1).
const areaById = new Map(
this._devices.map((device) => [String(device.id), String(device.area || '')]),
);
const pinned = markersNeedingPlacement(
cfg.markers || [],
Object.fromEntries(
Object.entries(this._areaToSpace).map(([area, value]) => [area, value.space]),
),
ids[0] || '',
(markerId) => areaById.get(markerId) || '',
);
if (pinned.length) {
const byId = new Map(pinned.map((entry) => [entry.id, entry.space]));
for (const marker of cfg.markers || []) {
const space = byId.get(String((marker as any).id));
if (space) (marker as any).space = space;
}
}
cfg.spaces = applySpaceOrder(cfg.spaces || [], order);
this._saveConfig();
if (!this._tabOrderWarned) {
this._tabOrderWarned = true;
this._showToast(this._t('toast.space_order_changed'));
}
}
private _pickSpace(id: string): void {
if (id === this._space) return;
const ids = this._model.map((sp) => sp.id);
@@ -1827,6 +1955,17 @@ class HouseplanCard extends LitElement {
private _cycleTimer?: number;
private _cyclePausedUntil = 0;
private _swipeStart: { x: number; y: number; id: number } | null = null;
/** Live tab reorder: which tab is held, where it started, where it would land. */
private _tabDrag: {
id: string; pointerId: number; x: number; y: number; moved: boolean; overId: string;
} | null = null;
/** Window-level release handler while a tab is held; see _tabPointerDown. */
private _tabDragRelease: ((event: PointerEvent) => void) | null = null;
/** The positional-`floor` warning is worth saying once, not on every drop. */
private _tabOrderWarned = false;
private _lastTap = 0;
private get _labsIso(): boolean {
return this._labs.active.includes('iso');
@@ -1916,6 +2055,7 @@ class HouseplanCard extends LitElement {
private _holdFired = false;
static properties = {
_tabDrag: { state: true },
_hdrH: { state: true },
_booting: { state: true },
_bootFading: { state: true },
@@ -2096,6 +2236,13 @@ class HouseplanCard extends LitElement {
this._bootSettling = false;
for (const rt of this._activityRt.values()) clearTimeout(rt.timer); // pending activity-window repaints
window.removeEventListener('keydown', this._keyHandler);
// A tab drag holds window listeners for the length of the gesture. Losing
// the card mid-drag — Lovelace rebuilding its tree, the user leaving the
// view with the button still down — would leave them alive: the closure
// keeps this instance (and its config) from being collected, and the next
// pointerup anywhere on the page would make an invisible card write its
// order (review CODE-REVIEW-220-r2/r3, F1).
this._endTabDrag();
clearInterval(this._cycleTimer);
clearTimeout(this._kioskDotsTimer);
clearTimeout(this._kioskHoldTimer);
@@ -15746,8 +15893,16 @@ class HouseplanCard extends LitElement {
${navigationSpaces.map(
(s) => html`<button
data-hp="space-tab" data-id="${s.id}"
class="tab ${this._space === s.id ? 'active' : ''}"
@click=${() => this._pickSpace(s.id)}
class="tab ${this._space === s.id ? 'active' : ''}${
this._tabDrag?.moved && this._tabDrag.id === s.id ? ' dragging' : ''}${
this._tabDrag?.moved && this._tabDrag.overId === s.id
&& this._tabDrag.id !== s.id ? ' droptarget' : ''}"
?data-reorderable=${this._canReorderTabs}
@pointerdown=${(e: PointerEvent) => this._tabPointerDown(e, s.id)}
@pointermove=${(e: PointerEvent) => this._tabPointerMove(e, s.id)}
@pointerup=${(e: PointerEvent) => this._tabPointerUp(e)}
@pointercancel=${() => this._endTabDrag()}
@click=${() => this._tabClick(s.id)}
>
${s.title}${this._norm && this._canEdit
? html`<ha-icon class="tabedit" icon="mdi:cog-outline"
+1
View File
@@ -287,6 +287,7 @@
"toast.ha_disabled_add": "A disabled Home Assistant object cannot be added to the plan. Enable it in Home Assistant first.",
"toast.ha_binding_unverified": "The object status could not be verified through the Home Assistant registry. Display and actions are temporarily unavailable.",
"toast.markup_needs_server": "Markup is available after the config is moved to the server",
"toast.space_order_changed": "Order changed. If any card pins its floor by number, check those panels.",
"toast.conflict": "Config was changed in another window — data refreshed, repeat your last action",
"toast.cfg_save_failed": "Failed to save config: {err}",
"toast.room_overlap": "The outline overlaps room “{name}” — rooms must not overlap",
+1
View File
@@ -287,6 +287,7 @@
"toast.ha_disabled_add": "Деактивированный объект Home Assistant нельзя добавить на план. Сначала активируйте его в Home Assistant.",
"toast.ha_binding_unverified": "Статус объекта не удалось подтвердить по реестру Home Assistant. Отображение и действия временно недоступны.",
"toast.markup_needs_server": "Разметка доступна после переноса конфига на сервер",
"toast.space_order_changed": "Порядок изменён. Если где-то этаж карточки задан номером, проверьте такие панели.",
"toast.conflict": "Конфиг изменён в другом окне — данные обновлены, повторите последнее действие",
"toast.cfg_save_failed": "Не удалось сохранить конфиг: {err}",
"toast.room_overlap": "Контур накладывается на комнату «{name}» — комнаты не должны накладываться",
+131
View File
@@ -0,0 +1,131 @@
/**
* Reordering the space tabs.
*
* The order of `config.spaces` is not decoration: it feeds the marker
* placement fallback (`firstSpaceId`), the swipe neighbour and the positional
* `floor` of a fixed-floor card. Moving a tab must therefore move nothing else
* — see docs/specs/220-space-tab-reorder.md §8.
*
* Everything here is pure so the rules can be tested without a browser.
*/
/** How far the pointer must travel before a click becomes a drag. */
export const TAB_DRAG_THRESHOLD_PX = 4;
export interface TabDragContext {
/** The card allows editing at all. */
canEdit: boolean;
/** A wall panel never reorders anything. */
kiosk: boolean;
/** Current mode; reordering lives in the editors only (owner, 2026-08-20). */
mode: 'view' | 'plan' | 'devices' | 'decor';
/** Pointer that started the gesture. */
pointerType: string;
/** How many spaces the panel shows right now. */
spaceCount: number;
/** A card pinned to one floor shows a single tab and must not reorder. */
fixedFloor: boolean;
}
/**
* Whether a pointerdown on a tab may begin a reorder.
*
* Touch is deliberately excluded rather than degraded: the tabs live in View
* as well, where switching spaces is a fully supported touch interaction, and
* any gesture on a tab would compete with the tap that switches. The product
* decision is recorded as `Touch editor: not exposed`.
*/
export function canStartTabDrag(ctx: TabDragContext): boolean {
if (!ctx.canEdit || ctx.kiosk || ctx.fixedFloor) return false;
if (ctx.mode === 'view') return false;
if (ctx.pointerType !== 'mouse') return false;
return ctx.spaceCount > 1;
}
/** Has the pointer moved far enough to mean "drag" rather than "click"? */
export function passedDragThreshold(dx: number, dy: number): boolean {
return Math.hypot(dx, dy) >= TAB_DRAG_THRESHOLD_PX;
}
/**
* Move `movedId` so that it sits where `targetId` is, keeping every other id
* in its relative order. Returns the same array instance when nothing moves,
* so a caller can skip the write without comparing element by element.
*/
export function reorderSpaceIds(
ids: readonly string[], movedId: string, targetId: string,
): string[] {
const from = ids.indexOf(movedId);
const to = ids.indexOf(targetId);
if (from < 0 || to < 0 || from === to) return ids as string[];
const next = ids.slice();
next.splice(from, 1);
next.splice(to, 0, movedId);
return next;
}
/** Reorder the stored spaces to match `order`; ids missing from it keep their tail. */
export function applySpaceOrder<T extends { id?: unknown }>(
spaces: readonly T[], order: readonly string[],
): T[] {
const rank = new Map(order.map((id, index) => [id, index]));
// A stable sort keeps unknown ids (there should be none) in their old order
// instead of shuffling them by an accidental comparison result.
return spaces
.map((space, index) => ({ space, index }))
.sort((a, b) => {
const ra = rank.get(String(a.space?.id)) ?? Number.MAX_SAFE_INTEGER;
const rb = rank.get(String(b.space?.id)) ?? Number.MAX_SAFE_INTEGER;
return ra - rb || a.index - b.index;
})
.map((entry) => entry.space);
}
export interface PlacementMarker {
id?: unknown;
space?: unknown;
area?: unknown;
removed?: unknown;
}
/**
* Markers whose space is decided by the "first space" fallback, and where that
* fallback currently lands.
*
* Such a marker has neither an explicit `space` nor an area that names a
* space. Today it renders in whichever space happens to sit first; after a
* reorder that would be a different one — the marker would move on its own,
* which is the one thing a reorder may never do. Writing the answer it has
* right now makes the placement explicit and independent of order for good.
*
* **The area is not only the marker's own field.** `resolveExplicitMarkerPlacement`
* (`devices.ts`) reads `marker.area || <area of the HA device or entity>`, so a
* marker that simply binds an existing HA device — the ordinary case, saved
* without `area` or `space` — is anchored by the registry and never depended on
* the order at all. Judging by `marker.area` alone would classify it as
* order-dependent and write it a `space` it never asked for: a field that is
* dormant today and moves the marker the day its HA area changes. Hence
* `effectiveArea`, which answers with the area actually in force (review
* CODE-REVIEW-220-r1, H1).
*/
export function markersNeedingPlacement(
markers: readonly PlacementMarker[],
areaToSpace: Readonly<Record<string, string>>,
firstSpaceId: string,
effectiveArea: (markerId: string) => string = () => '',
): { id: string; space: string }[] {
if (!firstSpaceId) return [];
const out: { id: string; space: string }[] = [];
for (const marker of markers) {
if (!marker || marker.removed === true) continue;
const id = typeof marker.id === 'string' ? marker.id : '';
if (!id) continue;
const explicit = typeof marker.space === 'string' ? marker.space : '';
if (explicit) continue;
const own = typeof marker.area === 'string' ? marker.area : '';
const area = own || effectiveArea(id) || '';
if (area && areaToSpace[area]) continue;
out.push({ id, space: firstSpaceId });
}
return out;
}
+4
View File
@@ -1692,6 +1692,10 @@ export const cardStyles = css`
}
.modetab:active { transform: scale(0.97); }
.modetab ha-icon { --mdc-icon-size: 15px; }
/* issue #220: a tab can be dragged to a new position in the editors */
.tab[data-reorderable] { cursor: grab; }
.tab.dragging { cursor: grabbing; opacity: 0.55; }
.tab.droptarget { box-shadow: inset 2px 0 0 0 var(--primary-color, #03a9f4); }
.modetab .closex {
--mdc-icon-size: 13px;
box-sizing: border-box;
+104
View File
@@ -0,0 +1,104 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
TAB_DRAG_THRESHOLD_PX, applySpaceOrder, canStartTabDrag, markersNeedingPlacement,
passedDragThreshold, reorderSpaceIds,
} from '../test-build/space-order.js';
const ctx = (over = {}) => ({
canEdit: true, kiosk: false, mode: 'plan', pointerType: 'mouse',
spaceCount: 3, fixedFloor: false, ...over,
});
// --- AC5: где перетаскивание вообще включается -------------------------------
test('issue 220 tab drag is available to an editor with a mouse', () => {
assert.equal(canStartTabDrag(ctx()), true);
assert.equal(canStartTabDrag(ctx({ mode: 'devices' })), true);
assert.equal(canStartTabDrag(ctx({ mode: 'decor' })), true);
});
test('issue 220 tab drag is not exposed to touch, View, kiosk or a fixed floor', () => {
// Touch is excluded by product decision, not by omission: the same tabs
// switch spaces in View, where a tap must stay a tap.
assert.equal(canStartTabDrag(ctx({ pointerType: 'touch' })), false);
assert.equal(canStartTabDrag(ctx({ pointerType: 'pen' })), false);
assert.equal(canStartTabDrag(ctx({ mode: 'view' })), false);
assert.equal(canStartTabDrag(ctx({ kiosk: true })), false);
assert.equal(canStartTabDrag(ctx({ canEdit: false })), false);
assert.equal(canStartTabDrag(ctx({ fixedFloor: true })), false);
assert.equal(canStartTabDrag(ctx({ spaceCount: 1 })), false);
});
test('issue 220 a click stays a click until the pointer really travels', () => {
assert.equal(passedDragThreshold(0, 0), false);
assert.equal(passedDragThreshold(TAB_DRAG_THRESHOLD_PX - 1, 0), false);
assert.equal(passedDragThreshold(TAB_DRAG_THRESHOLD_PX, 0), true);
assert.equal(passedDragThreshold(0, TAB_DRAG_THRESHOLD_PX), true);
});
// --- AC1: сама перестановка --------------------------------------------------
test('issue 220 a tab lands where it was dropped and the rest keep their order', () => {
assert.deepEqual(reorderSpaceIds(['a', 'b', 'c'], 'c', 'a'), ['c', 'a', 'b']);
assert.deepEqual(reorderSpaceIds(['a', 'b', 'c'], 'a', 'c'), ['b', 'c', 'a']);
assert.deepEqual(reorderSpaceIds(['a', 'b', 'c'], 'b', 'b'), ['a', 'b', 'c']);
assert.deepEqual(reorderSpaceIds(['a', 'b', 'c'], 'b', 'zz'), ['a', 'b', 'c']);
});
test('issue 220 stored spaces follow the new order', () => {
const spaces = [{ id: 'a' }, { id: 'b' }, { id: 'c' }];
assert.deepEqual(applySpaceOrder(spaces, ['c', 'a', 'b']).map((s) => s.id), ['c', 'a', 'b']);
// an id the order does not mention keeps its tail position instead of moving
assert.deepEqual(
applySpaceOrder([...spaces, { id: 'd' }], ['c', 'a', 'b']).map((s) => s.id),
['c', 'a', 'b', 'd'],
);
});
// --- AC3: маркеры не двигаются ----------------------------------------------
test('issue 220 only order-dependent markers are pinned, and to where they are now', () => {
const markers = [
{ id: 'dangling' }, // no space, no area
{ id: 'by-area', area: 'kitchen' }, // area names a space
{ id: 'explicit', space: 'f2' }, // already explicit
{ id: 'area-unknown', area: 'nowhere' }, // area names nothing
{ id: 'gone', removed: true }, // tombstone
];
const pinned = markersNeedingPlacement(markers, { kitchen: 'f1' }, 'f1');
assert.deepEqual(pinned, [
{ id: 'dangling', space: 'f1' },
{ id: 'area-unknown', space: 'f1' },
]);
});
test('issue 220 a marker anchored by its HA area is left alone (review r1 H1)', () => {
// The ordinary marker: it binds an HA device and stores neither area nor
// space, because resolveExplicitMarkerPlacement reads the area from the
// registry. Such a marker never depended on the order, so writing it a space
// would plant a field that moves it the day its HA area changes.
const markers = [{ id: 'ha-device' }];
const areaOf = (id) => (id === 'ha-device' ? 'kitchen' : '');
assert.deepEqual(markersNeedingPlacement(markers, { kitchen: 'f1' }, 'f1', areaOf), []);
// …while a registry area that names no space leaves the marker order-bound.
assert.deepEqual(markersNeedingPlacement(markers, { hall: 'f1' }, 'f1', areaOf), [
{ id: 'ha-device', space: 'f1' },
]);
// The marker's own area still wins over the registry, as in devices.ts.
assert.deepEqual(
markersNeedingPlacement([{ id: 'ha-device', area: 'hall' }], { hall: 'f2' }, 'f1', areaOf),
[],
);
});
test('issue 220 pinning writes the space the marker has before the reorder', () => {
// The fallback is the FIRST space of the current order. If the write used
// the order after the move, the marker would follow the reorder — the very
// thing this pinning exists to prevent.
const markers = [{ id: 'dangling' }];
assert.deepEqual(markersNeedingPlacement(markers, {}, 'garden'), [
{ id: 'dangling', space: 'garden' },
]);
assert.deepEqual(markersNeedingPlacement(markers, {}, ''), []);
});
+1
View File
@@ -20,6 +20,7 @@
"src/virtual-light-state.ts",
"src/types.ts",
"src/space-geometry.ts",
"src/space-order.ts",
"src/signing.ts", "src/initial-load.ts", "src/space-model-selection.ts", "src/space-dialog.ts",
"src/visual-continuity.ts", "src/mode-transition.ts", "src/pointer-modality.ts",
"src/render-device-snapshot.ts",