fix: show honest new-space display defaults

Issue: #204
User-Visible: yes
This commit is contained in:
Sergey Matyunin
2026-08-19 17:44:48 +00:00
committed by claude[bot]
parent 4c8ba981e7
commit 6f89002e3a
15 changed files with 504 additions and 243 deletions
File diff suppressed because one or more lines are too long
@@ -0,0 +1,103 @@
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const state = () => ({
source: c._spaceDialog?.source,
showBorders: c._spaceDialog?.showBorders,
showNames: c._spaceDialog?.showNames,
displayTouched: c._spaceDialog?.displayTouched,
});
const choose = async (source) => {
const radios = [...sr().querySelectorAll('hp-dialog input[name="plansrc"]')];
radios[source === 'file' ? 0 : 1].click();
await c.updateComplete;
};
const toggle = async (key) => {
const label = [...sr().querySelectorAll('hp-dialog label.srcrow')]
.find((row) => row.textContent.includes(c._t(key)));
label.querySelector('input[type="checkbox"], ha-switch').click();
await c.updateComplete;
};
c._openSpaceDialog('create'); await c.updateComplete;
out.freshFile = state();
await choose('draw');
out.untouchedDraw = state();
await choose('file');
out.untouchedFileAgain = state();
await choose('draw');
await toggle('space.show_borders');
out.mixedTouched = state();
await choose('file');
out.mixedFile = state();
await choose('draw');
out.mixedDrawAgain = state();
c._spaceDialog = { ...c._spaceDialog, title: 'Mixed display defaults' };
await c._saveSpaceDialog(); await c.updateComplete;
const mixed = c._serverCfg.spaces.find((s) => s.title === 'Mixed display defaults');
out.savedMixed = [mixed?.settings?.show_borders, mixed?.settings?.show_names];
c._openSpaceDialog('edit', mixed.id); await c.updateComplete;
out.reopenedMixed = state();
await choose('file');
out.editSourcePreservesPair = state();
const beforeCancel = JSON.stringify(c._serverCfg);
c._spaceDialog = null; await c.updateComplete;
c._openSpaceDialog('create'); await c.updateComplete;
await choose('draw');
await toggle('space.show_names');
c._spaceDialog = null; await c.updateComplete;
out.cancelDidNotWrite = JSON.stringify(c._serverCfg) === beforeCancel;
c._openSpaceDialog('create'); await c.updateComplete;
out.freshAfterCancel = state();
c._spaceDialog = null;
c._importQueue = ['Floor one', 'Floor two'];
c._importTotal = 2;
c._openNextImport(); await c.updateComplete;
out.onboardingFirst = { title: c._spaceDialog.title, ...state() };
await choose('draw');
await toggle('space.show_borders');
c._skipImport(); await c.updateComplete;
out.onboardingSecond = { title: c._spaceDialog.title, ...state() };
c._importQueue = [];
c._importTotal = 0;
c._spaceDialog = null;
c._openSpaceDialog('create'); await c.updateComplete;
const existingPlan = c._serverCfg.spaces.find((s) => s.plan_url)?.plan_url;
c._spaceDialog = {
...c._spaceDialog, title: 'File display defaults', planUrl: existingPlan || '/local/houseplan/plan.svg',
};
await c._saveSpaceDialog(); await c.updateComplete;
const file = c._serverCfg.spaces.find((s) => s.title === 'File display defaults');
out.savedFileDefaults = [file?.settings?.show_borders, file?.settings?.show_names];
return out;
});
checkAll(res, {
freshFile: { source: 'file', showBorders: false, showNames: false, displayTouched: false },
untouchedDraw: { source: 'draw', showBorders: true, showNames: true, displayTouched: false },
untouchedFileAgain: { source: 'file', showBorders: false, showNames: false, displayTouched: false },
mixedTouched: { source: 'draw', showBorders: false, showNames: true, displayTouched: true },
mixedFile: { source: 'file', showBorders: false, showNames: true, displayTouched: true },
mixedDrawAgain: { source: 'draw', showBorders: false, showNames: true, displayTouched: true },
savedMixed: [false, true],
reopenedMixed: { source: 'draw', showBorders: false, showNames: true, displayTouched: true },
editSourcePreservesPair: { source: 'file', showBorders: false, showNames: true, displayTouched: true },
freshAfterCancel: { source: 'file', showBorders: false, showNames: false, displayTouched: false },
onboardingFirst: {
title: 'Floor one', source: 'file', showBorders: false, showNames: false, displayTouched: false,
},
onboardingSecond: {
title: 'Floor two', source: 'file', showBorders: false, showNames: false, displayTouched: false,
},
savedFileDefaults: [false, false],
});
await finish(browser, res);
+3 -1
View File
@@ -62,7 +62,9 @@ const res = await page.evaluate(async () => {
out.labelSaved = !!c._layout['rl_r1'];
// 6) диалог: create + draw
c._openSpaceDialog('create'); await c.updateComplete;
c._spaceDialog = { ...c._spaceDialog, title: 'Attic', source: 'draw', orientation: 'square' };
[...sr().querySelectorAll('input[name="plansrc"]')][1].click();
await c.updateComplete;
c._spaceDialog = { ...c._spaceDialog, title: 'Attic', orientation: 'square' };
await c.updateComplete;
out.saveEnabled = !sr().querySelector('hp-dialog .btn.on[disabled]');
await c._saveSpaceDialog(); await c.updateComplete;
File diff suppressed because one or more lines are too long
+76 -76
View File
File diff suppressed because one or more lines are too long
+5
View File
@@ -2,6 +2,11 @@
## v1.65.0-beta.5 — 2026-08-19
- The new-space dialog now shows the defaults it will actually save: image
spaces start with borders and names off, while switching to a hand-drawn
plan visibly turns both on. After either switch is edited, changing the plan
source preserves the complete user-selected pair; the Floors/Areas wizard
follows the same rule ([#204](https://github.com/Matysh/houseplan-card/issues/204)).
- Turning off **Show names** now removes room names completely in View, kiosk,
the static card and hidden isometric mode, while Plan still exposes the
existing draggable card for positioning. Re-enabling names restores its
+6
View File
@@ -8,6 +8,12 @@
## v1.65.0-beta.5 — 2026-08-19
- Диалог нового пространства теперь показывает именно те значения, которые
сохранит: для плана с изображением границы и названия изначально выключены,
а при переходе к ручному плану оба переключателя видимо включаются. После
изменения любого из них смена источника сохраняет всю выбранную пару; мастер
этажей/зон следует тому же правилу
([#204](https://github.com/Matysh/houseplan-card/issues/204)).
- Выключение **«Показывать названия»** теперь полностью убирает названия комнат
из Просмотра, киоска, статической карточки и скрытой изометрии, а редактор
Плана по-прежнему временно показывает существующую карточку для настройки
+1 -1
View File
@@ -22,7 +22,7 @@ metadata). Only an explicit owner-approved emergency hotfix may skip this gate.
| Item | State |
|---|---|
| Version | **v1.65.0-beta.5** everywhere (manifest, const.py, package.json, CARD_VERSION) — prerelease candidate for the current `S8-merged` queue |
| Current local cycle | v1.65.0-beta.5 candidate carries #176, #186, #195, #200, #201 and #203: independent-wall openings preserve their physical jamb margin, room labels keep View/Plan geometry and obey the name-visibility switch, atomic wall stretches inherit the intended thickness, the editor close target is easier to hit, and unreachable legacy Partition tool-state is removed. Publication waits for a green exact-SHA Validate and fail-closed prerelease preflight; v1.65.0-beta.4 is published and stable v1.64.0 remains unchanged. |
| Current local cycle | v1.65.0-beta.5 candidate carries #176, #186, #195, #200, #201 and #203; issue branch #204 additionally makes new-space File/Draw border/name defaults visible and preserves a user-touched pair across source switches. Independent-wall openings preserve their physical jamb margin, room labels keep View/Plan geometry and obey the name-visibility switch, atomic wall stretches inherit the intended thickness, the editor close target is easier to hit, and unreachable legacy Partition tool-state is removed. Publication waits for a green exact-SHA Validate and fail-closed prerelease preflight; v1.65.0-beta.4 is published and stable v1.64.0 remains unchanged. |
| Hidden Labs Stage | #89 Stage 1 ships in v1.63.0-beta.1. #122 Stage 2 ships in v1.64.0 and evolves the same hidden, expiring `iso` experiment with matte walls, a low exterior floor edge, restrained shared shadows and live vertical door/window/gate panels. Flat remains default; editors and `houseplan-space-card` remain flat; live floor effects and HA actions remain unchanged. Public activation remains a separate task. |
| Workflow | Superseded 2026-08-12: the pre-1.62 rule of "local edits without tests or commits" is **dead** — since release 1.62 every product change follows `PROCESS.md` (issue in `S5-ready`+, branch `issue/<NN>-slug`, trailers on every commit, review pipeline; `AGENTS.md` is the summary). Release mechanics below remain current. A requested pre-release gets a production build plus the smallest targeted unit/smoke set covering the changed surfaces, one tested `dev` commit/tag and a GitHub Release with `prerelease=true`; `main` stays untouched. The complete local frontend/backend/smoke gate runs only before a stable release, after which `main` is fast-forwarded to the exact tested `dev` SHA and the GitHub Release uses `prerelease=false`. Release bodies are short and bilingual (Russian first): only significant user changes get individual bullets, while minor/code-only work is grouped as `Мелкие исправления и улучшения` / `Small fixes and improvements`; every body ends with separate links to the Russian and English changelogs. Detailed RU/EN changelog bullets may link the corresponding closed GitHub Issues; open or partially delivered issues are never presented as shipped. Telegram announcements are sent only for stable releases; beta and RC publication is silent. `docs/RELEASE-NOTES.md` is the current canonical body instance; `npm run release:prerelease -- <tag> --issues=… --yes` is the primary local publication path and the manual `Publish prerelease` workflow is its GitHub-only equivalent once present on `main`. Nothing is copied to the home instance by hand |
| GitHub | https://github.com/Matysh/houseplan-card — [Issues](https://github.com/Matysh/houseplan-card/issues) are the canonical task records; their labels carry priority and workflow status (`PROCESS.md` §9). GitHub Projects is no longer used. `main` carries stable releases; pre-release tags may point directly at `dev`. Work lands on `dev` and is merged into `main` for a stable release, so `dev` is normally equal to or ahead of `main`, never behind. Push via SSH key `ha_jb` (remote git@github.com:…); API releases via the fine-grained PAT in `~/.git-credentials` (Contents R/W, issued 2026-07-23) |
+19
View File
@@ -2011,6 +2011,25 @@ require hands on real hardware — they remain for the human pass.
- [ ] **A non-admin never sees it**: the button follows the same rule as the
per-space gear (`_canEdit`) [manual, needs a non-admin HA user]
## Honest new-space display defaults (#204, dev, unreleased)
- [ ] **The dialog and Save agree**: open a new space. File begins with room
borders/names `false/false`; before either control is touched, switching
to Draw shows `true/true` and switching back restores `false/false`.
Saving either source and reopening it yields the exact visible pair
[auto: `space-dialog.test`, `smoke_space_create_display_defaults`,
`smoke_space_settings`].
- [ ] **One touch protects both choices**: on Draw change either display
switch, including the mixed `true/false` and `false/true` cases. Further
File ↔ Draw switches change only the source; Save never silently restores
`true/true` [auto: `space-dialog.test`,
`smoke_space_create_display_defaults`; mutation:
`space-create-hidden-display-override`].
- [ ] **Draft state does not leak**: Cancel and a fresh Create return to File
`false/false`. In Floors/Areas onboarding every next floor also starts
clean and cannot inherit the preceding floor's touched state
[auto: `smoke_space_create_display_defaults`].
## Coming back to the tab (docs/WARM-REMOUNT.md, dev, unreleased)
- [ ] **A quick return does not flash**: leave the browser tab or minimise the
+6
View File
@@ -262,6 +262,12 @@ desktop: для точного рисования, Resize, модификато
| Без изображения | План рисуется непосредственно в House Plan | Бумага формируется по контурам комнат; границы и названия удобно включить сразу |
| Импорт этажей HA | В HA уже создан реестр этажей | Мастер создаёт пространства по очереди; любой этаж можно пропустить |
Новое пространство с изображением открывается с выключенными границами и
названиями. При выборе варианта **«Без изображения»** оба переключателя сразу
включаются — это видимый default ручного плана. Если изменить хотя бы один из
них, последующая смена источника больше не сбрасывает ни один выбор. В мастере
этажей каждый следующий этаж получает собственные чистые defaults.
### Настройки пространства
| Раздел | Настройка | Результат |
+12
View File
@@ -476,6 +476,18 @@ export const MUTANTS = [
replace: ' const areaLinkInteractive = true;',
}],
},
{
id: 'space-create-hidden-display-override',
guard: 'node demo/smoke_space_create_display_defaults.mjs',
because: 'Save must persist the two booleans visibly chosen for a new hand-drawn space; '
+ 'the production smoke saves and reopens a mixed pair so the old hidden true/true override cannot return',
patches: [{
file: 'src/houseplan-card.ts',
find: ' show_borders: d.showBorders,\n show_names: d.showNames,',
replace: " show_borders: d.source === 'draw' && d.mode === 'create' ? true : d.showBorders,\n"
+ " show_names: d.source === 'draw' && d.mode === 'create' ? true : d.showNames,",
}],
},
];
// --- механика ---------------------------------------------------------------
+16 -12
View File
@@ -84,6 +84,9 @@ import {
resolveInitialSpace, settleBestEffort, type InitialSpaceSelection,
} from './initial-load';
import { selectActiveSpaceModel, selectSpaceModelById } from './space-model-selection';
import {
initialSpaceDisplayDraft, switchSpacePlanSource, touchSpaceDisplay,
} from './space-dialog';
import { mdiHomeCityOutline } from '@mdi/js';
import {
Affine, applyAffine, readVacTelemetry,
@@ -1593,6 +1596,8 @@ class HouseplanCard extends LitElement {
source: 'file' | 'draw'; // draw = no background image, hand-drawn rooms
showBorders: boolean;
showNames: boolean;
/** Create-only source-default guard; never persisted. Edit starts touched. */
displayTouched: boolean;
hideDecor: boolean; // the decorative layer is not drawn outside its editor
hideOpenings: boolean; // opening symbols are not drawn outside the plan editor
roomColor: string;
@@ -13210,6 +13215,7 @@ class HouseplanCard extends LitElement {
mode, spaceId, title: sp.title, planUrl: sp.plan_url || null, planFile: null,
source: sp.plan_url ? 'file' : 'draw',
showBorders: disp.showBorders, showNames: disp.showNames,
displayTouched: true,
hideDecor: disp.hideDecor, hideOpenings: disp.hideOpenings,
roomColor: disp.color, roomOpacity: disp.opacity,
// `none` is a legacy space token. The current editor represents its
@@ -13232,8 +13238,7 @@ class HouseplanCard extends LitElement {
} else {
this._spaceDialog = {
mode, title: '', planUrl: null, planFile: null,
source: 'file',
showBorders: false, showNames: false,
...initialSpaceDisplayDraft(),
hideDecor: false, hideOpenings: false,
roomColor: DEFAULT_ROOM_COLOR, roomOpacity: DEFAULT_ROOM_OPACITY, fillMode: 'custom',
// `custom` replaces the old `none` choice in the editor, but creating
@@ -13469,12 +13474,12 @@ class HouseplanCard extends LitElement {
delete sp.plan_x; delete sp.plan_y; delete sp.plan_scale;
delete sp.plan_scale_x; delete sp.plan_scale_y; delete sp.plan_angle;
}
// per-space display settings; hand-drawn spaces get borders+names on by default
const draw = d.source === 'draw';
// Persist exactly what the dialog showed. Source defaults are projected
// visibly while editing the create state, never hidden here at Save.
sp.settings = {
...(sp.settings || {}),
show_borders: draw && d.mode === 'create' ? true : d.showBorders,
show_names: draw && d.mode === 'create' ? true : d.showNames,
show_borders: d.showBorders,
show_names: d.showNames,
// written only when ON: a plan that never hid anything stores nothing
hide_decor: d.hideDecor || undefined,
hide_openings: d.hideOpenings || undefined,
@@ -13607,8 +13612,7 @@ class HouseplanCard extends LitElement {
if (title === undefined) return;
this._spaceDialog = {
mode: 'create', title, planUrl: null, planFile: null,
source: 'file',
showBorders: false, showNames: false,
...initialSpaceDisplayDraft(),
hideDecor: false, hideOpenings: false,
roomColor: DEFAULT_ROOM_COLOR, roomOpacity: DEFAULT_ROOM_OPACITY, fillMode: 'custom',
customFill: null,
@@ -19518,7 +19522,7 @@ class HouseplanCard extends LitElement {
<label>${this._t('space.plan_label')}</label>
<label class="srcrow">
<input type="radio" name="plansrc" .checked=${d.source === 'file'}
@change=${() => (this._spaceDialog = { ...d, source: 'file' })} />
@change=${() => (this._spaceDialog = switchSpacePlanSource(d, 'file'))} />
<span>${this._t('space.source_file')}</span>
</label>
${d.source === 'file'
@@ -19545,7 +19549,7 @@ class HouseplanCard extends LitElement {
: nothing}
<label class="srcrow">
<input type="radio" name="plansrc" .checked=${d.source === 'draw'}
@change=${() => (this._spaceDialog = { ...d, source: 'draw' })} />
@change=${() => (this._spaceDialog = switchSpacePlanSource(d, 'draw'))} />
<span>${this._t('space.source_draw')}</span>
</label>
@@ -19565,11 +19569,11 @@ class HouseplanCard extends LitElement {
<label class="dispsection">${this._t('space.display_section')}</label>
<label class="srcrow">
${this._boolInput(d.showBorders, (v) => (this._spaceDialog = { ...d, showBorders: v }))}
${this._boolInput(d.showBorders, (v) => (this._spaceDialog = touchSpaceDisplay(d, 'showBorders', v)))}
<span>${this._t('space.show_borders')}</span>
</label>
<label class="srcrow">
${this._boolInput(d.showNames, (v) => (this._spaceDialog = { ...d, showNames: v }))}
${this._boolInput(d.showNames, (v) => (this._spaceDialog = touchSpaceDisplay(d, 'showNames', v)))}
<span>${this._t('space.show_names')}</span>
</label>
<label class="srcrow">
+42
View File
@@ -0,0 +1,42 @@
export type SpacePlanSource = 'file' | 'draw';
export interface SpaceDisplayDraft {
source: SpacePlanSource;
showBorders: boolean;
showNames: boolean;
/** Ephemeral create-dialog guard; never persisted in the House Plan config. */
displayTouched: boolean;
}
/** Source-specific visible defaults for a fresh create/onboarding step. */
export function initialSpaceDisplayDraft(source: SpacePlanSource = 'file'): SpaceDisplayDraft {
const visible = source === 'draw';
return {
source,
showBorders: visible,
showNames: visible,
displayTouched: false,
};
}
/**
* Project source defaults only while the owner has not changed either display
* switch. Once touched, the pair is one user choice and must survive switches.
*/
export function switchSpacePlanSource<T extends SpaceDisplayDraft>(
draft: T,
source: SpacePlanSource,
): T {
if (draft.displayTouched) return { ...draft, source };
const visible = source === 'draw';
return { ...draft, source, showBorders: visible, showNames: visible };
}
/** Changing either switch protects the complete pair from source projection. */
export function touchSpaceDisplay<T extends SpaceDisplayDraft>(
draft: T,
field: 'showBorders' | 'showNames',
value: boolean,
): T {
return { ...draft, [field]: value, displayTouched: true };
}
+62
View File
@@ -0,0 +1,62 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
initialSpaceDisplayDraft,
switchSpacePlanSource,
touchSpaceDisplay,
} from '../test-build/space-dialog.js';
test('fresh create projects honest File and Draw display defaults', () => {
const file = initialSpaceDisplayDraft();
assert.deepEqual(file, {
source: 'file', showBorders: false, showNames: false, displayTouched: false,
});
const draw = switchSpacePlanSource(file, 'draw');
assert.deepEqual(draw, {
source: 'draw', showBorders: true, showNames: true, displayTouched: false,
});
assert.deepEqual(switchSpacePlanSource(draw, 'file'), file);
});
test('touching either display switch preserves the complete mixed pair', () => {
const draw = switchSpacePlanSource(initialSpaceDisplayDraft(), 'draw');
const mixed = touchSpaceDisplay(draw, 'showBorders', false);
assert.deepEqual(mixed, {
source: 'draw', showBorders: false, showNames: true, displayTouched: true,
});
assert.deepEqual(switchSpacePlanSource(mixed, 'file'), {
...mixed, source: 'file',
});
assert.deepEqual(switchSpacePlanSource(mixed, 'draw'), mixed);
});
test('every supported Draw pair remains exact after a touched source round-trip', () => {
for (const [showBorders, showNames] of [
[false, false], [true, false], [false, true],
]) {
let draft = switchSpacePlanSource(initialSpaceDisplayDraft(), 'draw');
draft = touchSpaceDisplay(draft, 'showBorders', showBorders);
draft = touchSpaceDisplay(draft, 'showNames', showNames);
const roundTrip = switchSpacePlanSource(switchSpacePlanSource(draft, 'file'), 'draw');
assert.deepEqual(roundTrip, {
source: 'draw', showBorders, showNames, displayTouched: true,
});
}
});
test('source and display transitions are immutable and preserve unrelated fields', () => {
const draft = { ...initialSpaceDisplayDraft(), title: 'Ground', cellCm: 7.5 };
const switched = switchSpacePlanSource(draft, 'draw');
const touched = touchSpaceDisplay(switched, 'showNames', false);
assert.notEqual(switched, draft);
assert.notEqual(touched, switched);
assert.deepEqual(draft, {
source: 'file', showBorders: false, showNames: false, displayTouched: false,
title: 'Ground', cellCm: 7.5,
});
assert.equal(touched.title, 'Ground');
assert.equal(touched.cellCm, 7.5);
});
+1 -1
View File
@@ -20,7 +20,7 @@
"src/virtual-light-state.ts",
"src/types.ts",
"src/space-geometry.ts",
"src/signing.ts", "src/initial-load.ts", "src/space-model-selection.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/render-device-snapshot.ts",
"src/command-stack.ts",