Files
houseplan-card/demo/smoke_editor_tabs.mjs
T
Claudeandclaude[bot] f0ba7c5da9 Основная панель: без счётчика устройств, × редактора в своём слоте — ширина шапки не меняется (#647)
Счётчик «N устр.» и ключ count.devices удалены вместе с правилом
`.head .count` (скрывало счётчик на ≤1100 px). Крестик закрытия редактора
вынесен из активной кнопки режима в отдельный слот `.editor-close-slot`
сразу после группы кнопок режимов: 24 × 24 px (--hp-editor-close-size),
одинаковый в редакторе и вне его; вне редактора слот пуст, aria-hidden,
без фокуса и pointer-events. Кликабельная зона × — весь слот (≥24×24, #195),
глиф 13 px; × виден на любой ширине, где видны кнопки режимов.

Смок smoke_toolbar_stable_width (1400/1000/768/390 px, en/ru): ширина
шапки и отступы кнопок стабильны во всех переходах, × виден, попадание
в край зоны закрывает редактор, нет нового overflow. smoke_editor_tabs,
smoke_edge_cases, smoke_hidden_flag переведены на слот/список устройств.
6 мутантов. docs: UX-MODES.md, STYLING-HOOKS.md, data-hp-contract.json.

Issue: #647
User-Visible: yes
2026-09-25 08:35:57 +00:00

368 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 tabs = () => [...sr().querySelectorAll('.modetab')];
const headerCross = () => sr().querySelector('.editor-close-slot .closex');
const settleMode = async () => {
const started = performance.now();
do { await new Promise((resolve) => requestAnimationFrame(resolve)); }
while (c._modeTransitionBusy && performance.now() - started < 1500);
await c.updateComplete;
};
// 1) две вкладки, Просмотра нет, крестиков в неактивных нет
out.twoTabs = tabs().length === 3; // третья — Редактор подложки (v1.33.0)
out.labels = tabs().map((t) => t.textContent.trim());
// #647: X no longer lives inside a mode tab; its own slot after the tabs is
// empty (but keeps its size) outside an editor.
out.noCrossIdle = sr().querySelectorAll('.modetab .closex').length === 0
&& !sr().querySelector('.editor-close-slot .closex');
out.startView = c._mode === 'view';
// 2) клик по «Редактор плана» → активна, панель с крестиком
tabs()[0].click(); await c.updateComplete;
out.planActive = c._mode === 'plan' && tabs()[0].classList.contains('active');
out.planBar = !!sr().querySelector('.editbar');
out.planBarClose = !!sr().querySelector('.editbar .barclose');
const barClose = sr().querySelector('.editbar .barclose');
const barCloseIcon = barClose?.querySelector('ha-icon');
const barCloseRect = barClose?.getBoundingClientRect();
const barCloseIconRect = barCloseIcon?.getBoundingClientRect();
const enteringChrome = sr().querySelector('.editorchrome');
// Regression #95 follow-up: the bar is already painted during the 220 ms
// mode transition, so its explicit Close command must not be inert together
// with the editing tools.
out.closeRemainsInteractiveDuringEnter = c._modeTransitionBusy
&& enteringChrome?.inert === false
&& sr().querySelector('.editbar-tools')?.inert === true
&& getComputedStyle(barClose).pointerEvents === 'auto';
const enteringStage = sr().querySelector('.stage');
const editorSceneLayer = enteringStage?.querySelector('.hp-editor-only-layer');
const enteringVisual = c._modeTransitionVisual;
out.sceneLayersUseTransitionWeights = c._modeTransitionBusy && !!enteringVisual
&& !!editorSceneLayer
&& Math.abs(Number(editorSceneLayer.getAttribute('opacity')) - enteringVisual.editorWeight) < 0.001
&& Math.abs(Number(getComputedStyle(enteringStage).getPropertyValue('--hp-mode-view-weight'))
- enteringVisual.viewWeight) < 0.001;
out.planBarCloseIconIsCentered = !!barCloseRect && !!barCloseIconRect
&& Math.abs((barCloseIconRect.left + barCloseIconRect.width / 2)
- (barCloseRect.left + barCloseRect.width / 2)) <= 1
&& Math.abs((barCloseIconRect.top + barCloseIconRect.height / 2)
- (barCloseRect.top + barCloseRect.height / 2)) <= 1;
out.tabCross = !!headerCross() && !tabs()[0].querySelector('.closex');
// 3) повторный клик по активной вкладке — ничего
tabs()[0].click(); await c.updateComplete;
out.reclickNoop = c._mode === 'plan';
const enteringTabCross = headerCross();
out.tabCrossCloseStartsDuringEnter = c._modeTransitionBusy;
enteringTabCross.click();
await settleMode();
out.tabCrossCloseDuringEnterWorks = c._mode === 'view';
tabs()[0].click(); await settleMode();
// HP-UX-11: transient tool controls live in a stage overlay. Opening the
// thickness controls must not resize/refit the stage or move pinned Close.
const stageBeforeContext = sr().querySelector('.stage').getBoundingClientRect();
const closeBeforeContext = sr().querySelector('.editbar .barclose').getBoundingClientRect();
const hdrBeforeContext = c._hdrH;
c._tool = 'draw'; c.requestUpdate(); await c.updateComplete;
await new Promise((resolve) => setTimeout(resolve, 40));
const stageWithContext = sr().querySelector('.stage').getBoundingClientRect();
const closeWithContext = sr().querySelector('.editbar .barclose').getBoundingClientRect();
const secondary = sr().querySelector('.editor-secondary-host.open .editor-secondary');
out.contextTrayLivesInStage = !!secondary
&& secondary.closest('.stage') === sr().querySelector('.stage')
&& !secondary.closest('.editorchrome');
out.contextDoesNotResizeStage = Math.abs(stageWithContext.top - stageBeforeContext.top) <= 1
&& Math.abs(stageWithContext.height - stageBeforeContext.height) <= 1
&& c._hdrH === hdrBeforeContext;
out.contextDoesNotMoveClose = Math.abs(closeWithContext.left - closeBeforeContext.left) <= 1
&& Math.abs(closeWithContext.top - closeBeforeContext.top) <= 1;
out.closeHasPinnedEndCap = !!sr().querySelector('.editbar > .editbar-end > .barclose');
// Replace the real Opening group with a side-effect-free command group to
// pin the generic launcher/keyboard/restore contract independently of any
// one editor tool's business logic.
let groupInvokes = 0;
Object.defineProperty(c, '_editorToolbarGroups', { configurable: true, get: () => [{
id: 'smoke-group', label: 'Smoke group', icon: 'mdi:dots-grid', items: [{
id: 'smoke-command', label: 'Smoke command', icon: 'mdi:check',
role: 'command', invoke: () => { groupInvokes += 1; },
}],
}] });
c.requestUpdate(); await c.updateComplete;
const groupLauncher = sr().querySelector('[data-editor-group="smoke-group"]');
groupLauncher?.focus();
groupLauncher?.dispatchEvent(new KeyboardEvent('keydown', {
key: 'ArrowDown', bubbles: true, composed: true, cancelable: true,
}));
await c.updateComplete;
await new Promise((resolve) => requestAnimationFrame(resolve));
out.groupUsesSharedTray = !!sr().querySelector('.editor-secondary.kind-group')
&& sr().querySelectorAll('.editor-secondary-host').length === 1;
out.groupArrowDownMovesFocus = sr().activeElement?.classList?.contains('editor-group-item') === true;
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
await c.updateComplete;
await new Promise((resolve) => requestAnimationFrame(resolve));
out.groupEscapeRestoresToolContext = c._editorSecondary.hasOpenGroup === false
&& !!sr().querySelector('.editor-secondary.kind-tool')
&& sr().activeElement === groupLauncher;
groupLauncher?.click(); await c.updateComplete;
sr().querySelector('.editor-group-item')?.click(); await c.updateComplete;
out.groupCommandClosesBeforeInvoke = groupInvokes === 1
&& c._editorSecondary.hasOpenGroup === false
&& !!sr().querySelector('.editor-secondary.kind-tool');
groupLauncher?.click(); await c.updateComplete;
const outsideTool = document.createElement('button');
const unrelatedTool = document.createElement('button');
let outsideInvokes = 0;
let unrelatedInvokes = 0;
outsideTool.addEventListener('click', () => outsideInvokes++);
unrelatedTool.addEventListener('click', () => unrelatedInvokes++);
document.body.append(outsideTool, unrelatedTool);
outsideTool.dispatchEvent(new PointerEvent('pointerdown', {
pointerId: 301, pointerType: 'mouse', button: 0,
bubbles: true, composed: true, cancelable: true,
}));
outsideTool.dispatchEvent(new MouseEvent('click', {
button: 0, bubbles: true, composed: true, cancelable: true,
}));
unrelatedTool.click();
await c.updateComplete;
out.groupOutsideDismissIsConsumed = c._editorSecondary.hasOpenGroup === false
&& outsideInvokes === 0 && c._tool === 'draw';
out.dismissTailDoesNotEatUnrelatedClick = unrelatedInvokes === 1;
outsideTool.remove();
unrelatedTool.remove();
// An open grouped submenu still consumes ordinary outside clicks, but an
// explicit editor navigation command has higher priority and must close both
// surfaces in the same pointer sequence.
groupLauncher?.click(); await c.updateComplete;
const closeWithOpenGroup = sr().querySelector('.editbar .barclose');
closeWithOpenGroup?.dispatchEvent(new PointerEvent('pointerdown', {
pointerId: 302, pointerType: 'mouse', button: 0,
bubbles: true, composed: true, cancelable: true,
}));
closeWithOpenGroup?.dispatchEvent(new MouseEvent('click', {
button: 0, bubbles: true, composed: true, cancelable: true,
}));
await c.updateComplete;
out.editorNavigationOutranksGroupDismiss = c._mode === 'view'
&& c._editorSecondary.hasOpenGroup === false;
await settleMode();
tabs()[0].click(); await settleMode();
delete c._editorToolbarGroups;
c._tool = 'select'; c.requestUpdate(); await c.updateComplete;
// 4) прямое переключение План → Устройства
// Deliberately make the outgoing bar taller: the editor transition must
// interpolate real content height, not merely fade two equal-height rows.
const planBarBeforeSwap = sr().querySelector('.editorchrome .editbar');
planBarBeforeSwap.style.minHeight = `${planBarBeforeSwap.getBoundingClientRect().height + 36}px`;
tabs()[1].click(); await c.updateComplete;
out.directSwitch = c._mode === 'devices';
out.devBar = !!sr().querySelector('.editbar.devbar');
const deviceToolbarButtons = [...sr().querySelectorAll('.editbar.devbar .btn:not(.barclose)')];
const addDeviceButton = deviceToolbarButtons[0];
out.devBarBtns = deviceToolbarButtons.length === 5;
out.deviceHistoryControlsArePersistent = !!sr().querySelector('[data-device-position-history="undo"]')
&& !!sr().querySelector('[data-device-position-history="redo"]')
&& sr().querySelector('[data-device-position-history="undo"]').disabled
&& sr().querySelector('[data-device-position-history="redo"]').disabled;
out.addDeviceShortcutUsesNativeKeyboardSemantics = addDeviceButton?.tagName === 'BUTTON'
&& addDeviceButton.disabled === false;
out.addDeviceShortcut = addDeviceButton?.textContent.trim() === c._t('devbar.add')
&& addDeviceButton.title === c._t('title.add_device')
&& addDeviceButton.querySelector('ha-icon')?.getAttribute('icon') === 'mdi:plus-box-outline'
&& deviceToolbarButtons[1]?.textContent.trim() === c._t('device_inbox.button');
const swapChrome = sr().querySelector('.editorchrome');
const swapInner = swapChrome.querySelector('.editorchrome-inner');
out.editorSwapAnimatesHeight = c._modeTransitionBusy
&& sr().querySelector('.stage').classList.contains('mode-transition');
out.editorSwapAnimatesContent = c._modeTransitionBusy && !!swapInner
&& (c._modeTransitionVisual?.toolbarContentOpacity ?? 1) < 1;
await settleMode();
addDeviceButton.click(); await c.updateComplete;
out.addDeviceShortcutOpensNewDialog = !!c._markerDialog
&& c._markerDialog.name === ''
&& c._markerDialog.bindingMode === 'virtual'
&& !Object.prototype.hasOwnProperty.call(c._markerDialog, 'devId');
c._markerDialog = null; await c.updateComplete;
// 5) инструменты устройств из шапки исчезли (в .bar их больше нет)
out.headerCleanInDev = !sr().querySelector('.bar > .btn[title*="' + (c._t('title.add_device')) + '"]');
// 6) крестик на панели → Просмотр
sr().querySelector('.editbar .barclose').click(); await c.updateComplete;
const chrome = sr().querySelector('.editorchrome');
out.barCloseStarts = c._mode === 'view' && c._modeTransitionBusy
&& sr().querySelector('.stage').inert
&& sr().querySelector('.stage').classList.contains('mode-transition');
await settleMode();
out.barCloseWorks = c._mode === 'view'
&& getComputedStyle(chrome).visibility === 'hidden'
&& chrome.getBoundingClientRect().height < 1;
// 7) #647 (+#195): the header X sits in a fixed 24 x 24 slot after the mode
// tabs; the slot is its hit target around a 13 px glyph and has the same size
// outside an editor, so the header never changes width. Exercise the slot's
// edge rather than calling the element's centre programmatically.
const tabCrossChecks = [];
for (let index = 0; index < tabs().length; index++) {
tabs()[index].click(); await settleMode();
const cross = headerCross();
const rect = cross.getBoundingClientRect();
const slot = sr().querySelector('.editor-close-slot').getBoundingClientRect();
const glyph = cross.querySelector('ha-icon');
const hit = sr().elementFromPoint(rect.left + 1, rect.top + rect.height / 2);
const glyphStays13 = getComputedStyle(glyph).getPropertyValue('--mdc-icon-size').trim() === '13px';
hit?.dispatchEvent(new MouseEvent('click', {
bubbles: true,
composed: true,
clientX: rect.left + 1,
clientY: rect.top + rect.height / 2,
}));
await settleMode();
const idleSlot = sr().querySelector('.editor-close-slot').getBoundingClientRect();
tabCrossChecks.push({
target: rect.width >= 24 && rect.height >= 24,
glyph: glyphStays13,
// The slot is the footprint: same box in the editor and after closing it.
footprint: Math.abs(slot.width - idleSlot.width) <= 0.5
&& Math.abs(slot.height - idleSlot.height) <= 0.5 && Math.abs(slot.width - 24) <= 0.5,
edgeHit: !!hit && (hit === cross || cross.contains(hit)),
closed: c._mode === 'view',
});
}
out.tabCrossTargetsAtLeast24 = tabCrossChecks.every((check) => check.target);
out.tabCrossGlyphStays13 = tabCrossChecks.every((check) => check.glyph);
out.tabCrossKeepsLayoutFootprint = tabCrossChecks.every((check) => check.footprint);
out.tabCrossExpandedEdgeWorks = tabCrossChecks.every((check) => check.edgeHit && check.closed);
out.tabCrossWorks = tabCrossChecks.every((check) => check.closed);
// Closing the editor only drops session state: every accepted segment was
// already persisted as an ordinary partition by its own click.
tabs()[0].click(); await settleMode();
const chainSpace = c._curSpaceCfg;
const savedPartitions = chainSpace.partitions ? [...chainSpace.partitions] : undefined;
const savedSaveConfig = c._saveConfig;
c._saveConfig = () => {};
const partitionCount = (chainSpace.partitions || []).length;
c._tool = 'draw';
const tabChainIds = ['tab-chain-a', 'tab-chain-b'];
chainSpace.partitions = [...(chainSpace.partitions || []),
{ id: tabChainIds[0], a: [0.1, 0.1], b: [0.2, 0.1], cm: 15 },
{ id: tabChainIds[1], a: [0.2, 0.1], b: [0.25, 0.15], cm: 15 }];
c._activeWallChainId = 'tab-chain';
c._activeWallChainPartitionIds = tabChainIds;
c._path = [[100, 100], [200, 100], [250, 150]];
c._wallChainSegmentCms = [15, 15];
headerCross().click(); await settleMode();
out.tabCrossFinishesWallChain = c._mode === 'view'
&& (chainSpace.partitions || []).length === partitionCount + 2
&& c._path.length === 0 && !c._activeWallChainId;
chainSpace.partitions = savedPartitions;
out.tabCrossDoesNotWriteAtPartitionLimit = true;
c._saveConfig = savedSaveConfig;
c._setMode('view', false); await c.updateComplete;
// Layout-independent editor history: physical KeyZ works when `key` is the
// Russian «я», while an input keeps its native browser history.
tabs()[0].click(); await settleMode();
c._tool = 'draw'; c.requestUpdate(); await c.updateComplete;
const realUndo = c._undoGeometry;
const realRedo = c._redoGeometry;
let undoCalls = 0;
let redoCalls = 0;
c._undoGeometry = () => { undoCalls += 1; };
c._redoGeometry = () => { redoCalls += 1; };
window.dispatchEvent(new KeyboardEvent('keydown', {
key: 'я', code: 'KeyZ', ctrlKey: true, bubbles: true, composed: true,
}));
out.layoutIndependentUndo = undoCalls === 1;
const editorInput = sr().querySelector('.editor-secondary input');
editorInput.dispatchEvent(new KeyboardEvent('keydown', {
key: 'я', code: 'KeyZ', ctrlKey: true, bubbles: true, composed: true,
}));
out.inputKeepsNativeUndo = undoCalls === 1;
// QWERTZ swaps the physical Y/Z codes. The labelled key wins when it is a
// Latin letter; otherwise both isZ/isY became true and Redo ran first.
window.dispatchEvent(new KeyboardEvent('keydown', {
key: 'z', code: 'KeyY', ctrlKey: true, bubbles: true, composed: true,
}));
out.qwertzCtrlZIsUndo = undoCalls === 2 && redoCalls === 0;
window.dispatchEvent(new KeyboardEvent('keydown', {
key: 'y', code: 'KeyZ', ctrlKey: true, bubbles: true, composed: true,
}));
out.qwertzCtrlYIsRedo = undoCalls === 2 && redoCalls === 1;
// On AZERTY the physical KeyZ carries W: Ctrl+W must remain the browser's
// close-tab shortcut, never an editor Undo.
window.dispatchEvent(new KeyboardEvent('keydown', {
key: 'w', code: 'KeyZ', ctrlKey: true, bubbles: true, composed: true,
}));
out.azertyCtrlWIsNotUndo = undoCalls === 2 && redoCalls === 1;
c._undoGeometry = realUndo;
c._redoGeometry = realRedo;
// Ctrl+click closes a valid active chain without adding an arbitrary point;
// the same gesture must refuse a chain with fewer than two existing edges.
const savedPath = c._path;
const savedRoomDialog = c._roomDialog;
const savedNameSel = c._nameSel;
const savedAreaSel = c._areaSel;
const ctrlClick = () => {
let prevented = false;
c._markupClick({
ctrlKey: true, metaKey: false, clientX: 0, clientY: 0,
preventDefault() { prevented = true; }, composedPath() { return []; },
});
return prevented;
};
c._tool = 'draw';
c._roomDialog = false;
const beforeCtrlPartitions = [...(chainSpace.partitions || [])];
chainSpace.partitions = [...beforeCtrlPartitions,
{ id: 'ctrl-short', a: [-0.12, -0.12], b: [-0.08, -0.12], cm: 15 }];
c._activeWallChainId = 'ctrl-short-chain';
c._activeWallChainPartitionIds = ['ctrl-short'];
c._wallChainSegmentCms = [15];
c._path = [[-120, -120], [-80, -120]];
const shortBefore = JSON.stringify(c._path);
out.ctrlCloseRequiresTwoEdges = ctrlClick()
&& !c._roomDialog && JSON.stringify(c._path) === shortBefore;
chainSpace.partitions = [...beforeCtrlPartitions,
{ id: 'ctrl-a', a: [-0.12, -0.12], b: [-0.08, -0.12], cm: 15 },
{ id: 'ctrl-b', a: [-0.08, -0.12], b: [-0.08, -0.08], cm: 15 }];
c._activeWallChainId = 'ctrl-chain';
c._activeWallChainPartitionIds = ['ctrl-a', 'ctrl-b'];
c._wallChainSegmentCms = [15, 15];
c._path = [[-120, -120], [-80, -120], [-80, -80]];
c._cfgEpoch++; c._modelCache = null;
out.ctrlCloseValidContour = ctrlClick() && c._roomDialog
&& c._path.length === 4
&& c._path[0][0] === c._path[3][0] && c._path[0][1] === c._path[3][1];
await c.updateComplete;
const roomDialog = sr().querySelector('hp-dialog.roomdialog');
const roomBody = roomDialog?.querySelector('.body');
out.roomDialogUsesMediumWidth = roomDialog?.wide === true
&& roomDialog.hasAttribute('wide');
out.roomDialogHasNoHorizontalScroll = !!roomBody
&& roomBody.scrollWidth <= roomBody.clientWidth + 1;
c._nameSel = 'No-area room';
c._areaSel = '';
c.requestUpdate();
await c.updateComplete;
const noAreaSave = sr().querySelector('hp-dialog.roomdialog .room-save');
out.noAreaUsesRegularSave = !!noAreaSave && !noAreaSave.disabled
&& sr().querySelectorAll('hp-dialog.roomdialog .room-save').length === 1;
c._path = savedPath;
chainSpace.partitions = beforeCtrlPartitions;
c._activeWallChainId = null;
c._activeWallChainPartitionIds = [];
c._wallChainSegmentCms = [];
c._roomDialog = savedRoomDialog;
c._nameSel = savedNameSel;
c._areaSel = savedAreaSel;
return out;
});
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
checkAll(res, {
"labels": ["Plan editor", "Device editor", "Background editor"],
});
await finish(browser, res);