diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md
index 28c7febf..3db6b1ba 100644
--- a/docs/CHANGELOG.md
+++ b/docs/CHANGELOG.md
@@ -6,9 +6,10 @@
independently imported spaces. Exact import copies keep their positions;
other active devices safely lose only the missing placement and return via
their HA Area or the first space. The preview reports repaired and unresolved
- references. Spaces that still contain active devices cannot be deleted, a
- space import repairs matching target references, and the card editor warns
- when `default_floor` names a missing space
+ references. A space that still contains active devices cannot be deleted while
+ another space remains; the sole remaining space can be removed without losing
+ marker bindings or settings. A space import repairs matching target
+ references, and the card editor warns when `default_floor` names a missing space
([#244](https://github.com/Matysh/houseplan-card/issues/244)).
## v1.67.0-beta.2 — 2026-08-22
diff --git a/docs/CHANGELOG.ru.md b/docs/CHANGELOG.ru.md
index b6737ac6..0f89dfb2 100755
--- a/docs/CHANGELOG.ru.md
+++ b/docs/CHANGELOG.ru.md
@@ -14,8 +14,10 @@
только отсутствующее размещение и возвращается через свою Area HA либо в
первое пространство. Предпросмотр отдельно показывает исправленные и
неразрешённые ссылки. Пространство с активными устройствами удалить нельзя,
- импорт пространства исправляет совпавшие ссылки в текущем плане, а редактор
- карточки предупреждает, если `default_floor` указывает на отсутствующее
+ пока остаётся другое пространство; единственное оставшееся можно удалить без
+ потери привязок и настроек маркеров. Импорт пространства исправляет совпавшие
+ ссылки в текущем плане, а редактор карточки предупреждает, если `default_floor`
+ указывает на отсутствующее
пространство
([#244](https://github.com/Matysh/houseplan-card/issues/244)).
diff --git a/docs/CONFIG-COMPATIBILITY.md b/docs/CONFIG-COMPATIBILITY.md
index c41dffd9..5f4a912c 100644
--- a/docs/CONFIG-COMPATIBILITY.md
+++ b/docs/CONFIG-COMPATIBILITY.md
@@ -195,10 +195,12 @@ is data-driven, idempotent and runs even when `model_version` is already 7.
A one-space import uses its known id map (not a heuristic) to repair matching
orphan target references when the original space id is absent. Full restore is
unchanged. Space deletion now uses a revision-guarded config/layout transaction
-and refuses active marker dependencies; removed tombstones keep their metadata
-and lose only placement fields owned by the deleted space. Older clients can
-read every repaired candidate because the schemas and field shapes did not
-change.
+and refuses active marker dependencies while another space remains; removed
+tombstones keep their metadata and lose only placement fields owned by the
+deleted space. Deleting the sole remaining space is the intentional exception:
+all affected active and removed marker records survive with only `space` and
+`room_id` cleared, preserving the empty-state contract. Older clients can read
+every repaired candidate because the schemas and field shapes did not change.
Current `fill_mode` additionally accepts `custom`. Its optional color is stored
as `{c:'#RRGGBB',a:0..1}` in `space.settings.custom_fill` and, for an explicit
diff --git a/docs/TESTING.md b/docs/TESTING.md
index 7f77ae10..ca00deb3 100644
--- a/docs/TESTING.md
+++ b/docs/TESTING.md
@@ -2222,14 +2222,18 @@ require hands on real hardware — they remain for the human pass.
preserve the marker record; ambiguous/truncated signatures and opaque
layout are never guessed. Preview/Apply/Undo use one exact candidate and
show remaining debt even for a no-op. Space import repairs target refs by
- its known map. Space delete deduplicates active marker blockers, rechecks
- both revisions under the backend lock and removes owned layout without
- deleting tombstone metadata. A missing `default_floor` remains raw and
+ its known map. With another space present, space delete deduplicates active
+ marker blockers; deleting the sole remaining space instead preserves every
+ affected active/removed marker record while clearing only `space` and
+ `room_id`. Both paths recheck both revisions under the backend lock and
+ remove owned layout without deleting marker metadata. The sole-space path
+ also keeps the #113 empty-state smoke green. A missing `default_floor`
+ remains raw and
gains a RU/EN inline warning after spaces load
[unit: space-reference-repair, plan-optimizer, space-deletion,
card-editor-validation; backend: test_ha_import_export,
- test_ha_websocket; pre-release: targeted browser smoke and light/dark
- golden].
+ test_ha_websocket; smoke: orphan-space-references + optional-space-model;
+ pre-release: targeted browser smoke and light/dark golden].
- [ ] **Every write prevents new ULP coordinate noise (#224)**: config/layout
schema, import, direct storage writers, startup recovery and maintenance
Undo produce the same nine-decimal allow-listed geometry as the frontend.
diff --git a/docs/USER-GUIDE.md b/docs/USER-GUIDE.md
index 9905a42f..21f9be89 100644
--- a/docs/USER-GUIDE.md
+++ b/docs/USER-GUIDE.md
@@ -277,9 +277,11 @@ Room cards are positioned and scaled on the plan. View renders only the metrics
enabled for that space.
Deleting a space is blocked while any active device still points to the space,
-one of its rooms or a saved position on it. Move or delete those devices first;
-then the confirmed delete removes the space-owned layout. Plan images and
-attachments are not deleted automatically.
+one of its rooms or a saved position on it, provided another space remains.
+Move or delete those devices first; then the confirmed delete removes the
+space-owned layout. The sole remaining space can still be deleted after
+confirmation: affected devices keep their bindings, icons, actions and settings
+but become unplaced. Plan images and attachments are not deleted automatically.

diff --git a/docs/USER-GUIDE.ru.md b/docs/USER-GUIDE.ru.md
index 1be64dd4..c5c44b6d 100644
--- a/docs/USER-GUIDE.ru.md
+++ b/docs/USER-GUIDE.ru.md
@@ -326,10 +326,13 @@ desktop: для точного рисования, Resize, модификато
миграции.
Удаление пространства блокируется, пока хотя бы одно активное устройство
-ссылается на это пространство, его комнату или сохранённую на нём позицию.
-Сначала перенесите или удалите такие устройства; затем подтверждённое удаление
-уберёт пространство и принадлежащую ему раскладку. Файл подложки и вложения при
-этом автоматически не удаляются.
+ссылается на это пространство, его комнату или сохранённую на нём позицию и при
+этом остаётся другое пространство. Сначала перенесите или удалите такие
+устройства; затем подтверждённое удаление уберёт пространство и принадлежащую
+ему раскладку. Единственное оставшееся пространство можно удалить после
+подтверждения: затронутые устройства сохранят привязки к HA, иконки, действия и
+настройки, но останутся без размещения. Файл подложки и вложения при этом
+автоматически не удаляются.
diff --git a/docs/specs/244-orphan-space-references.md b/docs/specs/244-orphan-space-references.md
index c1ca3a76..5f9fdd6f 100644
--- a/docs/specs/244-orphan-space-references.md
+++ b/docs/specs/244-orphan-space-references.md
@@ -35,7 +35,9 @@ Optimize сообщает только о геометрии. Удаление
пространство, а без надёжного назначения снимает только мёртвую привязку и
возвращает сам маркер в доступный план. Preview сообщает, сколько устройств
восстановлено и какие ссылки требуют внимания. Удалить занятое пространство
-нельзя до переноса его маркеров. Редактор карточки постоянно показывает, какой
+нельзя до переноса его маркеров, пока существует другое пространство. Последнее
+пространство можно удалить после подтверждения: маркеры сохраняют настройки и
+теряют только размещение. Редактор карточки постоянно показывает, какой
`default_floor` отсутствует.
## 3. Подтверждённый диагноз
@@ -71,8 +73,12 @@ Optimize сообщает только о геометрии. Удаление
3. Весь marker record не удаляется: это потеряло бы пользовательские настройки,
уничтожило virtual-маркер и не гарантировало бы автопоявление реального
устройства без HA Area.
-4. Удаление пространства блокируется, пока на нём остаются активные маркеры;
- UI называет количество и просит сначала перенести или удалить их.
+4. Если пространств два или больше, удаление пространства блокируется, пока на
+ нём остаются активные маркеры; UI называет количество и просит сначала
+ перенести или удалить их. Исключение: последнее пространство можно удалить
+ после подтверждения. У всех затронутых активных и removed marker records
+ снимаются только `space` и `room_id`; binding, иконка, действия и остальные
+ настройки сохраняются. Это сохраняет empty-state контракт #113.
5. Невалидный `default_floor` получает постоянную inline-ошибку в редакторе
карточки. Дополнительный toast не показывается; безопасный fallback на первое
пространство сохраняется.
@@ -255,8 +261,8 @@ room-label position имеет приоритет над stale target label пр
## 10. Безопасное удаление пространства
-Перед delete frontend считает активные marker dependencies. Маркер блокирует
-удаление, если выполняется хотя бы одно:
+Перед delete frontend считает активные marker dependencies. Если пространств
+два или больше, маркер блокирует удаление, если выполняется хотя бы одно:
- `marker.space` равен удаляемому id;
- его `room_id` принадлежит комнате удаляемого пространства;
@@ -267,7 +273,8 @@ room-label position имеет приоритет над stale target label пр
пользовательскими устройствами: после снятия marker blockers они удаляются
вместе с пространством.
-При blockers редактор пространства сохраняет диалог открытым, показывает
+При blockers и наличии другого пространства редактор пространства сохраняет
+диалог открытым, показывает
inline `role=alert` с количеством и инструкцией перенести/удалить устройства,
не показывает native confirm и не пишет config/layout/revisions. Delete control
остаётся доступным для активации, чтобы причина была объяснена, а не выглядит
@@ -278,11 +285,20 @@ entries с `s == spaceId`; у removed tombstones снимаются только
которые ссылались на удалённое пространство. Marker metadata и файлы не
удаляются по inference.
+Если удаляется единственное оставшееся пространство, dependencies не
+блокируют подтверждение. Та же авторитетная транзакция удаляет space и весь его
+layout, а у каждого затронутого активного или removed marker record снимает
+ровно `space` и `room_id`. Затронутым считается маркер, чьё прямое `space`,
+комната либо marker-layout position принадлежит удаляемому пространству.
+Binding, иконка, действия и любые остальные настройки сохраняются; незатронутые
+legacy-ссылки не переписываются.
+
Авторитетная backend-операция повторно проверяет dependencies под write lock,
сверяет обе expected revisions и сохраняет config/layout без наблюдаемого
полусостояния. Гонка возвращает стабильный `conflict` либо `space_in_use` с
counts; frontend обновляет данные и показывает локализованную причину. Удаление
-последнего пустого пространства сохраняет действующий контракт #111.
+последнего пустого или занятого пространства сохраняет действующий empty-state
+контракт #113.
## 11. `default_floor` в редакторе карточки
@@ -345,8 +361,8 @@ best effort на touch, поэтому новые editor controls не расш
| AC6 | Нерешённый `vacuum.segment_map` не удаляется, но считается в `nestedRefsUnresolved`; exact room map переписывает его полностью. | Vacuum reference unit. |
| AC7 | Optimize preview и toast отдельно показывают remapped/detached counters; warning виден и при `changed=false`, Apply тогда отсутствует; Cancel не пишет, Apply сохраняет exact candidate, Undo возвращает исходные ссылки. | UI unit + targeted browser/backend smoke. |
| AC8 | Space import при отсутствующем source id переписывает target marker/layout/room/vacuum refs известным `id_map`, отражает count в preview и apply; при существующем source id target не меняется. Full restore остаётся прежним. | Backend import/export tests preview/revalidate/apply. |
-| AC9 | Удаление пространства с N зависимыми активными markers показывает inline blocker с N, не вызывает confirm/backend write и не меняет revisions. Один marker по трём ссылкам считается один раз. | Pure dependency unit + browser smoke. |
-| AC10 | После переноса/удаления blockers delete под revision guard удаляет space и весь принадлежащий ему layout без полусостояния; concurrent change даёт `conflict`/`space_in_use`; marker metadata/files не удаляются. | Backend transaction tests + frontend smoke. |
+| AC9 | При двух или более пространствах удаление пространства с N зависимыми активными markers показывает inline blocker с N, не вызывает confirm/backend write и не меняет revisions. Один marker по трём ссылкам считается один раз. | Pure dependency unit + browser smoke. |
+| AC10 | После переноса/удаления blockers delete под revision guard удаляет space и весь принадлежащий ему layout без полусостояния; concurrent change даёт `conflict`/`space_in_use`; marker metadata/files не удаляются. Единственное оставшееся пространство удаляется и при dependencies: у всех затронутых active/removed markers снимаются только `space`/`room_id`, остальные настройки сохраняются, после чего действует empty-state #113. | Backend transaction tests + `smoke_orphan_space_references` + `smoke_optional_space_model`. |
| AC11 | Редактор с отсутствующим `default_floor=f1` после загрузки показывает raw id и RU/EN inline error; выбор валидного id убирает её. Runtime открывает первое пространство, toast отсутствует, несвязанное поле не стирает raw value. | Editor unit + light/dark golden/browser smoke. |
| AC12 | Уже валидные marker/layout/room refs не меняются; входы не мутируются; model version не создаёт запись сам по себе; старый schema fixture читается. | Immutability/compatibility/idempotence units. |
| AC13 | Проход линейный по spaces + rooms + markers + layout и не входит в render/state tick; permission и optimistic-lock guards не ослаблены. | Code inspection + large synthetic unit/backend test. |
diff --git a/src/houseplan-card.ts b/src/houseplan-card.ts
index 25df6179..fb1234e1 100755
--- a/src/houseplan-card.ts
+++ b/src/houseplan-card.ts
@@ -14460,11 +14460,15 @@ class HouseplanCard extends LitElement {
private async _deleteSpace(): Promise {
const d = this._spaceDialog;
if (!d || d.mode !== 'edit') return;
- const sp = this._serverCfg!.spaces.find((x: any) => x.id === d.spaceId);
+ const serverCfg = this._serverCfg;
+ if (!serverCfg) return;
+ const sp = serverCfg.spaces.find((x: any) => x.id === d.spaceId);
const dependencies = collectSpaceMarkerDependencies(
- this._serverCfg, this._layout || {}, d.spaceId || '',
+ serverCfg, this._layout || {}, d.spaceId || '',
);
- if (dependencies.count) {
+ const deletingLastSpace = serverCfg.spaces.length === 1
+ && serverCfg.spaces[0]?.id === d.spaceId;
+ if (dependencies.count && !deletingLastSpace) {
this._spaceDialog = { ...d, deleteBlockers: dependencies.count };
return;
}
@@ -14496,11 +14500,18 @@ class HouseplanCard extends LitElement {
if (e?.code === 'conflict' || e?.code === 'space_in_use') {
await Promise.all([this._reloadConfigOnly(true), this._reloadLayoutOnly()]);
}
- if (this._spaceDialog) {
+ const refreshedConfig = this._serverCfg;
+ if (this._spaceDialog && refreshedConfig) {
const refreshed = collectSpaceMarkerDependencies(
- this._serverCfg, this._layout || {}, d.spaceId || '',
+ refreshedConfig, this._layout || {}, d.spaceId || '',
);
- this._spaceDialog = { ...this._spaceDialog, busy: false, deleteBlockers: refreshed.count };
+ const stillLastSpace = refreshedConfig.spaces.length === 1
+ && refreshedConfig.spaces[0]?.id === d.spaceId;
+ this._spaceDialog = {
+ ...this._spaceDialog,
+ busy: false,
+ deleteBlockers: stillLastSpace ? 0 : refreshed.count,
+ };
}
this._showToast(this._t('toast.delete_failed', { err: this._errText(e) }));
}
diff --git a/src/space-deletion.ts b/src/space-deletion.ts
index b13e6d08..31fc3e54 100644
--- a/src/space-deletion.ts
+++ b/src/space-deletion.ts
@@ -34,7 +34,9 @@ export function createSpaceDeletionCandidate(
const dependencies = collectSpaceMarkerDependencies(configIn, layoutIn, spaceId);
const config = clone(configIn);
const layout = clone(layoutIn || {});
- if (dependencies.count) return { config, layout, dependencies };
+ const spaces = config.spaces || [];
+ const deletingLastSpace = spaces.length === 1 && spaces[0]?.id === spaceId;
+ if (dependencies.count && !deletingLastSpace) return { config, layout, dependencies };
const space = (config.spaces || []).find((item: any) => item?.id === spaceId);
const roomIds = new Set(
@@ -42,6 +44,16 @@ export function createSpaceDeletionCandidate(
);
config.spaces = (config.spaces || []).filter((item: any) => item?.id !== spaceId);
for (const marker of config.markers || []) {
+ const markerOwnsPosition = typeof marker?.id === 'string'
+ && layout?.[marker.id]?.s === spaceId;
+ const referencesDeletedSpace = marker?.space === spaceId
+ || (typeof marker?.room_id === 'string' && roomIds.has(marker.room_id))
+ || markerOwnsPosition;
+ if (deletingLastSpace && referencesDeletedSpace) {
+ delete marker.space;
+ delete marker.room_id;
+ continue;
+ }
if (marker?.removed !== true) continue;
if (marker.space === spaceId) delete marker.space;
if (typeof marker.room_id === 'string' && roomIds.has(marker.room_id)) delete marker.room_id;
diff --git a/test/space-deletion.test.mjs b/test/space-deletion.test.mjs
index fc73ae95..55c0f0bf 100644
--- a/test/space-deletion.test.mjs
+++ b/test/space-deletion.test.mjs
@@ -68,3 +68,44 @@ test('issue 244 deleting the last unoccupied space produces an explicit empty mo
assert.deepEqual(result.config.settings, { keep: true });
assert.deepEqual(result.layout, {});
});
+
+test('issue 244 deleting the last occupied space detaches placement but preserves markers', () => {
+ const config = {
+ spaces: [{ id: 'only', rooms: [{ id: 'room-only' }] }],
+ markers: [
+ {
+ id: 'direct', binding: 'virtual', space: 'only', room_id: 'room-only',
+ name: 'Direct', icon: 'mdi:lightbulb', actions: [{ tap: 'more-info' }],
+ },
+ {
+ id: 'room', binding: 'entity:sensor.room', space: 'legacy', room_id: 'room-only',
+ name: 'Room', removed: true,
+ },
+ { id: 'position', binding: 'entity:sensor.position', name: 'Position' },
+ { id: 'unrelated', binding: 'virtual', space: 'legacy', name: 'Unrelated' },
+ ],
+ settings: { keep: true },
+ };
+ const layout = {
+ direct: { s: 'only', x: 0.1, y: 0.2 },
+ position: { s: 'only', x: 0.3, y: 0.4 },
+ rl_room_only: { s: 'only', x: 0.5, y: 0.6 },
+ };
+ const result = createSpaceDeletionCandidate(config, layout, 'only');
+
+ assert.equal(result.dependencies.count, 2);
+ assert.deepEqual(result.config.spaces, []);
+ assert.deepEqual(result.layout, {});
+ assert.deepEqual(result.config.markers.map((marker) => ({
+ id: marker.id, space: marker.space, room: marker.room_id, name: marker.name,
+ })), [
+ { id: 'direct', space: undefined, room: undefined, name: 'Direct' },
+ { id: 'room', space: undefined, room: undefined, name: 'Room' },
+ { id: 'position', space: undefined, room: undefined, name: 'Position' },
+ { id: 'unrelated', space: 'legacy', room: undefined, name: 'Unrelated' },
+ ]);
+ assert.equal(result.config.markers[0].icon, 'mdi:lightbulb');
+ assert.deepEqual(result.config.markers[0].actions, [{ tap: 'more-info' }]);
+ assert.equal(config.markers[0].space, 'only', 'input config is immutable');
+ assert.equal(layout.direct.s, 'only', 'input layout is immutable');
+});
diff --git a/tests_backend/test_ha_websocket.py b/tests_backend/test_ha_websocket.py
index 8fdcf14e..13c10aa3 100644
--- a/tests_backend/test_ha_websocket.py
+++ b/tests_backend/test_ha_websocket.py
@@ -66,6 +66,52 @@ def test_issue_244_space_delete_dependency_and_tombstone_candidate() -> None:
assert config["markers"][0]["space"] == "f1"
+def test_issue_244_last_occupied_space_candidate_detaches_all_affected_markers() -> None:
+ config = {
+ "spaces": [_space("only", "room-only")],
+ "markers": [
+ {
+ "id": "direct", "binding": "virtual", "space": "only",
+ "room_id": "room-only", "name": "Kept", "icon": "mdi:lightbulb",
+ },
+ {
+ "id": "position", "binding": "virtual", "name": "Position",
+ },
+ {
+ "id": "removed", "binding": "entity:light.old", "removed": True,
+ "space": "only", "room_id": "room-only", "name": "Tombstone",
+ },
+ {
+ "id": "unrelated", "binding": "virtual", "space": "legacy",
+ "name": "Unrelated",
+ },
+ ],
+ "settings": {},
+ }
+ layout = {
+ "direct": {"s": "only", "x": 0.1, "y": 0.2},
+ "position": {"s": "only", "x": 0.3, "y": 0.4},
+ "rl_room-only": {"s": "only", "x": 0.5, "y": 0.6},
+ }
+
+ candidate, candidate_layout, dependencies, removed_layout = _space_delete_candidate(
+ config, layout, "only",
+ )
+
+ assert dependencies == ["direct", "position"]
+ assert candidate["spaces"] == []
+ assert candidate_layout == {}
+ assert removed_layout == 3
+ by_id = {marker["id"]: marker for marker in candidate["markers"]}
+ for marker_id in ("direct", "position", "removed"):
+ assert "space" not in by_id[marker_id]
+ assert "room_id" not in by_id[marker_id]
+ assert by_id["direct"]["icon"] == "mdi:lightbulb"
+ assert by_id["unrelated"]["space"] == "legacy"
+ assert config["markers"][0]["space"] == "only"
+ assert layout["direct"]["s"] == "only"
+
+
async def test_issue_244_space_delete_is_authoritative_and_revision_guarded(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator,
) -> None:
@@ -136,6 +182,51 @@ async def test_issue_244_space_delete_is_authoritative_and_revision_guarded(
assert final_layout["layout"] == {}
+async def test_issue_244_last_occupied_space_delete_preserves_marker_records(
+ hass: HomeAssistant, hass_ws_client: WebSocketGenerator,
+) -> None:
+ await _setup(hass)
+ client = await hass_ws_client(hass)
+ config = {
+ "spaces": [_space("only", "room-only")],
+ "markers": [{
+ "id": "device", "binding": "virtual", "space": "only",
+ "room_id": "room-only", "name": "Kept", "icon": "mdi:lightbulb",
+ }],
+ "settings": {},
+ }
+ await client.send_json_auto_id({
+ "type": "houseplan/config/set", "config": config, "expected_rev": 0,
+ })
+ config_set = await client.receive_json()
+ assert config_set["success"]
+ await client.send_json_auto_id({
+ "type": "houseplan/layout/set",
+ "layout": {"device": {"s": "only", "x": 0.2, "y": 0.3}},
+ })
+ layout_set = await client.receive_json()
+ assert layout_set["success"]
+
+ await client.send_json_auto_id({
+ "type": "houseplan/space/delete", "space_id": "only",
+ "expected_config_rev": config_set["result"]["rev"],
+ "expected_layout_rev": layout_set["result"]["rev"],
+ })
+ deleted = await client.receive_json()
+ assert deleted["success"]
+
+ await client.send_json_auto_id({"type": "houseplan/config/get"})
+ final_config = (await client.receive_json())["result"]["config"]
+ await client.send_json_auto_id({"type": "houseplan/layout/get"})
+ final_layout = (await client.receive_json())["result"]["layout"]
+ assert final_config["spaces"] == []
+ assert final_config["markers"] == [{
+ "id": "device", "binding": "virtual", "name": "Kept",
+ "icon": "mdi:lightbulb",
+ }]
+ assert final_layout == {}
+
+
async def test_layout_roundtrip(hass: HomeAssistant, hass_ws_client: WebSocketGenerator) -> None:
await _setup(hass)
client = await hass_ws_client(hass)