Files
houseplan-card/demo/smoke_hide_layers.mjs
T
Claude 91a008db9c feat(space): диалог «Пространство» по референсу — серия 1 из четырёх (#600)
§4 SPEC.md референса, четыре карточки: Basics (имя и масштаб в сетке 1fr/175,
подложка двумя choice-карточками, callout о пересчёте масштаба), Appearance
(строка-тумблер границ с иконкой и подписью, сегмент стиля стен с образцами
линии, плашка цвета с hex/прозрачностью/Reset, сегмент заливки с деталями
режима, компактный список «Видимые слои» с логикой «включено = видно» — К2),
Room cards (строка-тумблер названий, четыре плитки значений с отключением и
callout-ссылкой, слайдер размера с числом, концами и «Сбросить до 100%», образец
карточки внутри блока на тинте — Q2), Sun & light (сегмент фона, плашка цвета
фона, радио «свет через окна» в строку, север селектом с компасом 44 px и полем
градусов, строка-тумблер свечения).

Одна форма на два рантайма. `renderSpaceForm` в `editors/space-form.ts`
собирает тело и футер; редакторский вход (`space-settings-dialog.ts`) и
онбординг подают порт с тем, что у них различается, и сами рисуют
`<hp-dialog data-kind=…>` — контракт #489 требует литеральный kind. Копия
разметки онбординга на 300 строк удалена; локальные дубли CELL_CM_* и
strictNumber там же — тоже («одно число — один источник»).

К10 (решение владельца Q1): Save активен только при изменениях и без ошибок,
в футере «Есть несохранённые изменения» либо ссылка «Проверьте поля: N» к
первому ошибочному; «✕», Cancel и Escape с изменениями спрашивают через
`_confirmDanger`, без изменений закрывают сразу. Снимок делается при открытии
(`editors/space-form-state.ts`, WeakMap по хосту) — `SpaceDialogState` не
получил ни одного ключа. Валидация: имя, подложка без изображения, диапазон
температур, север 0–359 — с `aria-invalid` и текстом под полем.

Строки. 51 новый ключ живёт в ленивом словаре `i18n/settings` (приём
#423/#459), не в первом кадре: стартовый граф 291 260 при потолке 292 500.
Отдельный словарь, а не `support`, — иначе онбординг тянул бы весь словарь
редактора ради одной формы. 11 существующих ключей переименованы по Q6
(«Space name», «Appearance», «Room cards», «Draw it myself», «Zigbee signal
next to devices»…), 9 мёртвых удалены из четырёх локалей. `hp-color-opacity`
получил `hide-label`: подпись рисует плашка, `label` остаётся для title/aria и
`smoke_color_picker_consumers` проходит без правок (AC5).

Смоки. Переписаны осознанно (К6 снят), каждый — с пометкой #600 у правки:
smoke_hide_layers (компактные строки, инверсия К2 проверяется кликом),
smoke_ux_fixes (сегмент, строка-тумблер, поля °C, ширина 560 — Q7),
smoke_dialog_footer_width (560 для form-shell), smoke_temp_fill (два поля по
id), smoke_settings_dialog_cards (три сегмента), smoke_space_settings и
smoke_space_create_display_defaults и smoke_lazy_editor_chunk (choice-карточка
по смыслу, не по индексу — порядок §4.2 draw → file), smoke_space_scale_defaults
(поле с единицей). Новый фокусный свидетель demo/smoke_space_settings_form.mjs:
состав карточек и отсутствие старой разметки, оболочка 560 и один скроллер,
цели 44, запись каждого нового контрола в свой ключ, Save/dirty/вопрос при
закрытии, семь контрольных ширин (320…1920).

Мутанты: space-fill-segment-writes-a-neighbour-key,
space-save-enabled-without-changes, space-discard-without-asking,
space-layer-toggle-loses-inversion.

Контрактные тесты: `houseplan-source.mjs` дописывает общие модули диалогов к
логическому исходнику; коллектор i18n видит `help(...)` порта и `shelp(...)`
(заодно встал на учёт `radar.help`, проходивший мимо счётчика — 30 → 35).

Потолок ленивого редакторского графа 225 900 → 236 300 (замер 235 560) с
разбором в bundle-budget.mjs: словарь на четыре локали и подписи — большая
часть роста; промежуточно, итог в конце серий.

Гейты: typecheck, build, bundle:sync, npm test 2792 pass (красным был только
потолок — поднят с доводом), 10 смоков пространства зелёные,
smoke_color_picker_consumers без правок, mutation-gate --check.

Issue: #600
User-Visible: no
2026-09-20 10:28:51 +03:00

228 lines
11 KiB
JavaScript

/**
* The two "draw less" space options and the zero-wall rule (#306), plus the
* handle beads that came with them.
*
* • «Скрыть декоративный слой» hides decor everywhere EXCEPT its own editor;
* • «Скрыть проёмы» hides doors/windows everywhere EXCEPT the plan editor,
* and does not touch what an opening MEANS (the light still gets through);
* • a space that does not draw room borders draws no zero-thickness walls
* in View; every editor still does, because the complete centreline span
* must remain visible while editing;
* • both switches survive a round trip through the dialog;
* • every corner handle paints a bead a quarter of its hit radius, and the
* hit radius itself is unchanged.
*/
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 upd = async () => { c._cfgEpoch++; c.requestUpdate(); await c.updateComplete; };
const sp = () => c._serverCfg.spaces.find((s) => s.id === c._space);
const set = async (patch) => {
const s = sp();
s.settings = { ...(s.settings || {}), ...patch };
await upd();
};
const mode = async (m) => { c._setMode(m); await upd(); };
const n = (sel) => sr().querySelectorAll(sel).length;
const setThickness = async (point, value) => {
c._setMode('plan'); c._tool = 'wallthick'; await upd();
c._wallThickClick(point); await c.updateComplete;
if (!c._wallDialog) return false;
c._wallDialog = { ...c._wallDialog, value: String(value) };
c._wallThickApply(false); await c.updateComplete;
return true;
};
// a shape and an opening to look for, on the space we are on
const s0 = sp();
s0.decor = [{ id: 'hl1', kind: 'rect', x: 0.3, y: 0.3, w: 0.1, h: 0.1, color: '#ff0000', width: 3 }];
s0.openings = [{ id: 'hl2', type: 'door', x: 0.3, y: 0.2, angle: 0, length: 0.09 }];
await mode('view');
await set({ hide_decor: undefined, hide_openings: undefined, show_borders: true });
// ---- 1) the baseline: both layers are drawn ---------------------------
out.decorDrawnByDefault = n('.decorlayer .dshape') > 0;
out.openingDrawnByDefault = n('[data-hp="opening"]') > 0;
// ---- 2) hide the decor layer ------------------------------------------
await set({ hide_decor: true });
out.decorHiddenInView = n('.decorlayer .dshape') === 0;
await mode('devices');
out.decorHiddenInDevices = n('.decorlayer .dshape') === 0;
// …but its own editor still shows it, or it could not be edited
await mode('decor');
out.decorVisibleInItsEditor = n('.decorlayer .dshape') > 0;
await mode('view');
// the shape itself was never touched
out.decorShapeKept = (sp().decor || []).length === 1;
await set({ hide_decor: undefined });
out.decorBackAfterOff = n('.decorlayer .dshape') > 0;
// ---- 3) hide the openings ---------------------------------------------
const opCount = () => n('[data-hp="opening"]');
const before = opCount();
await set({ hide_openings: true });
out.openingsHiddenInView = opCount() === 0 && before > 0;
await mode('plan');
c._tool = 'opening';
await upd();
out.openingsVisibleInPlanEditor = opCount() > 0;
await mode('view');
// the opening is still an opening: it stayed in the config untouched
out.openingKept = (sp().openings || []).length === 1;
await set({ hide_openings: undefined });
out.openingsBackAfterOff = opCount() === before;
// ---- 4) zero walls follow the borders switch ---------------------------
// The demo's first two rooms share x=550. Apply zero through the public
// Thickness workflow so the smoke exercises the canonical v9 catalogue.
// The synthetic unhosted opening above has finished its own assertions and
// must not participate in the structural migration exercised here.
delete sp().openings;
await upd();
out.zeroWallCreated = await setThickness([550, 250], 0);
sp().zero_wall_style = 'dashed';
await upd();
await mode('view');
await set({ show_borders: true });
out.zeroWallsDrawnWithBorders = n('.zero-walls .zero-wall') > 0;
await set({ show_borders: false });
out.zeroWallsGoneWithoutBorders = n('.zero-walls .zero-wall') === 0;
// Every editor still draws them. The dash deliberately reaches the physical
// centreline there, even if show_borders is off; only View hides it.
await mode('plan');
await upd();
out.zeroWallsVisibleInPlanEditor = n('.zero-walls .zero-wall') > 0;
await mode('devices');
out.zeroWallsVisibleInDevicesEditor = n('.zero-walls .zero-wall') > 0;
await mode('decor');
out.zeroWallsVisibleInDecorEditor = n('.zero-walls .zero-wall') > 0;
await mode('view');
await set({ show_borders: true });
// ---- 5) the dialog carries both switches round trip --------------------
c._openSpaceDialog('edit', c._space);
await upd();
// #600 §4.2: два тумблера «скрыть» стали компактными строками «Видимые слои»
// с логикой «включено = видно» (К2). В UI инверсия, в состоянии — нет:
// проверяем и подпись строки, и что при hideX=false тумблер стоит во «вкл».
const layerRows = [...sr().querySelectorAll('hp-dialog .hpf-compact-list .hpf-toggle')];
const rowText = (r) => r.querySelector('.hpf-toggle-title')?.textContent.trim() || '';
const decorRow = layerRows.find((r) => /декорат|decorative|dekorativ|décoratif/i.test(rowText(r)));
const openingsRow = layerRows.find((r) => /двери|doors|türen|portes/i.test(rowText(r)));
out.dialogOffersHideDecor = !!decorRow && decorRow.querySelector('input[type="checkbox"]').checked === true;
out.dialogOffersHideOpenings = !!openingsRow && openingsRow.querySelector('input[type="checkbox"]').checked === true;
out.dialogReadsOff = c._spaceDialog.hideDecor === false && c._spaceDialog.hideOpenings === false;
// Клик по «видимому» слою пишет hideDecor=true — то же значение в тот же ключ (К1).
decorRow.querySelector('input[type="checkbox"]').click();
await upd();
out.layerToggleWritesInvertedKey = c._spaceDialog.hideDecor === true && c._spaceDialog.hideOpenings === false;
c._spaceDialog = { ...c._spaceDialog, hideDecor: false };
await upd();
c._spaceDialog = { ...c._spaceDialog, hideDecor: true, hideOpenings: true };
await c._saveSpaceDialog();
await upd();
out.savedBoth = sp().settings.hide_decor === true && sp().settings.hide_openings === true;
c._openSpaceDialog('edit', c._space);
await upd();
out.dialogReadsOn = c._spaceDialog.hideDecor === true && c._spaceDialog.hideOpenings === true;
c._spaceDialog = { ...c._spaceDialog, hideDecor: false, hideOpenings: false };
await c._saveSpaceDialog();
await upd();
// switched off = nothing is stored (undefined, which JSON drops on the way
// to the server), not a stored `false` — the same rule bg_color follows
out.clearedNotStored = sp().settings.hide_decor === undefined
&& sp().settings.hide_openings === undefined
&& !('hide_decor' in JSON.parse(JSON.stringify(sp().settings)));
c._spaceDialog = null;
await upd();
return out;
});
checkAll(res);
// ---- 6) the beads: a quarter of the hit radius, and the hit is unchanged --
const rad = await page.evaluate(async () => {
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
c._setMode('decor'); c._decorTool = 'backdrop';
c._cfgEpoch++; c.requestUpdate(); await c.updateComplete;
const hit = sr().querySelector('.bdframe .bdhandle');
const knob = sr().querySelector('.bdframe .bdknob');
const view = c._viewOr(c._baseVb());
const o = {
hasBoth: !!hit && !!knob,
hitR: hit ? Number(hit.getAttribute('r')) : 0,
knobR: knob ? Number(knob.getAttribute('r')) : 0,
expectHit: Math.max(view.w, view.h) * 0.02,
knobInert: knob ? getComputedStyle(knob).pointerEvents === 'none' : false,
// the bead sits exactly on the handle
sameCentre: hit && knob
&& hit.getAttribute('cx') === knob.getAttribute('cx')
&& hit.getAttribute('cy') === knob.getAttribute('cy'),
};
// #277 removed the Resize corner scale frame entirely; Backdrop keeps its
// own independent handle/bead contract above.
c._setMode('plan'); c._tool = 'resize';
c._resize.selectRoom((c._curSpaceCfg.rooms || [])[0]?.id || null);
c._cfgEpoch++; c.requestUpdate(); await c.updateComplete;
o.rszLegacyCornersAbsent = !sr().querySelector('.rszcorner, .rszknob, .rszframe');
c._setMode('view');
return o;
});
checkAll({
handleAndBead: rad.hasBoth,
hitRadiusUnchanged: Math.abs(rad.hitR - rad.expectHit) < 0.15,
beadIsAQuarter: Math.abs(rad.knobR * 4 - rad.hitR) < 0.05,
beadTakesNoPointer: rad.knobInert,
beadOnTheHandle: rad.sameCentre,
resizeLegacyCornerScaleAbsent: rad.rszLegacyCornersAbsent,
});
// ---- 7) every Background session starts on Select; image has its own tool --
const armed = await page.evaluate(async () => {
const c = window.__card;
c._setMode('view'); await c.updateComplete;
c._setMode('decor'); await c.updateComplete;
const o = {
startsOnSelect: c._decorTool === 'select',
inertUntilPictureTool: !c._bdMovable,
hasPicture: !!c._curSpaceCfg.plan_url,
};
c._decorTool = 'backdrop'; c.requestUpdate(); await c.updateComplete;
o.movable = c._bdMovable;
// and the picture really moves under it: press inside the body, drag, release
const r = c._bdRect;
const before = { x: c._curSpaceCfg.plan_x ?? 0, y: c._curSpaceCfg.plan_y ?? 0 };
const pt = [r.x + r.w / 2, r.y + r.h / 2];
const ev = (x, y) => ({ pointerId: 42, clientX: x, clientY: y, target: c._stageEl,
preventDefault() {}, stopPropagation() {}, shiftKey: false });
// _svgPoint reads clientX/Y — go through the stage geometry
const stage = (c.shadowRoot || c.renderRoot).querySelector('.stage');
const box = stage.getBoundingClientRect();
const vb = stage.querySelector('svg').getAttribute('viewBox').split(' ').map(Number);
const toScreen = (x, y) => [box.left + ((x - vb[0]) / vb[2]) * box.width,
box.top + ((y - vb[1]) / vb[3]) * box.height];
const [sx, sy] = toScreen(pt[0], pt[1]);
const [ex, ey] = toScreen(pt[0] + vb[2] * 0.1, pt[1]);
c._stagePointerDown(ev(sx, sy));
c._stagePointerMove(ev(ex, ey));
c._stagePointerUp(ev(ex, ey));
await c.updateComplete;
o.moved = (c._curSpaceCfg.plan_x ?? 0) !== before.x;
c._setMode('view');
return o;
});
checkAll({
backgroundStartsOnSelect: armed.startsOnSelect,
pictureInertUntilOwnTool: armed.inertUntilPictureTool,
pictureMovableUnderOwnTool: armed.movable,
dragMovedThePicture: armed.moved,
});
await finish(browser);