feat: отполировать главную панель редакторов (#660)

Issue: #660
User-Visible: yes
This commit is contained in:
Sergey Matyunin
2026-09-26 08:51:15 +00:00
committed by claude[bot]
parent cff83e43ec
commit d3c88f06f9
14 changed files with 225 additions and 63 deletions
+34 -4
View File
@@ -15,8 +15,8 @@ const res = await page.evaluate(async () => {
// 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.
// #660: X does not live inside a mode button; its slot follows the active
// button and moves to the group end (empty but same size) in View.
out.noCrossIdle = sr().querySelectorAll('.modetab .closex').length === 0
&& !sr().querySelector('.editor-close-slot .closex');
out.startView = c._mode === 'view';
@@ -51,6 +51,14 @@ const res = await page.evaluate(async () => {
&& Math.abs((barCloseIconRect.top + barCloseIconRect.height / 2)
- (barCloseRect.top + barCloseRect.height / 2)) <= 1;
out.tabCross = !!headerCross() && !tabs()[0].querySelector('.closex');
const planSummary = sr().querySelector('.summary-control');
const summaryToggle = planSummary?.querySelector('button:last-child');
const summaryBefore = summaryToggle?.getAttribute('aria-pressed');
summaryToggle?.click(); await c.updateComplete;
out.summaryControlsStayInPlan = planSummary?.querySelectorAll('button').length === 2
&& summaryToggle?.getAttribute('aria-pressed') !== summaryBefore
&& !sr().querySelector('.summary-overlay');
summaryToggle?.click(); await c.updateComplete;
// 3) повторный клик по активной вкладке — ничего
tabs()[0].click(); await c.updateComplete;
out.reclickNoop = c._mode === 'plan';
@@ -198,8 +206,8 @@ const res = await page.evaluate(async () => {
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
// 7) #660 (+#195): the header X sits in a fixed 24 x 24 slot after the active
// mode; 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 = [];
@@ -210,6 +218,8 @@ const res = await page.evaluate(async () => {
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 editorSummaryVisible = sr().querySelectorAll('.summary-control button').length === 2
&& !sr().querySelector('.summary-overlay');
const glyphStays13 = getComputedStyle(glyph).getPropertyValue('--mdc-icon-size').trim() === '13px';
hit?.dispatchEvent(new MouseEvent('click', {
bubbles: true,
@@ -226,6 +236,7 @@ const res = await page.evaluate(async () => {
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)),
summary: editorSummaryVisible,
closed: c._mode === 'view',
});
}
@@ -234,6 +245,7 @@ const res = await page.evaluate(async () => {
out.tabCrossKeepsLayoutFootprint = tabCrossChecks.every((check) => check.footprint);
out.tabCrossExpandedEdgeWorks = tabCrossChecks.every((check) => check.edgeHit && check.closed);
out.tabCrossWorks = tabCrossChecks.every((check) => check.closed);
out.summaryControlsStayInEveryEditor = tabCrossChecks.every((check) => check.summary);
// Closing the editor only drops session state: every accepted segment was
// already persisted as an ordinary partition by its own click.
@@ -358,6 +370,24 @@ const res = await page.evaluate(async () => {
c._roomDialog = savedRoomDialog;
c._nameSel = savedNameSel;
c._areaSel = savedAreaSel;
// #660: an internal Plan/Background action consumes the first Escape; the
// next neutral Escape leaves the editor. Device editor exits immediately.
c._setMode('plan', false); await settleMode();
c._tool = 'opening'; c.requestUpdate(); await c.updateComplete;
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); await c.updateComplete;
out.planEscapeKeepsInternalPriority = c._mode === 'plan' && c._tool === 'draw';
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); await settleMode();
out.planNeutralEscapeExits = c._mode === 'view';
c._setMode('devices', false); await settleMode();
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); await settleMode();
out.deviceNeutralEscapeExits = c._mode === 'view';
c._setMode('decor', false); await settleMode();
c._decorTool = 'line'; c.requestUpdate(); await c.updateComplete;
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); await c.updateComplete;
out.decorEscapeKeepsInternalPriority = c._mode === 'decor' && c._decorTool === 'select';
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); await settleMode();
out.decorNeutralEscapeExits = c._mode === 'view';
return out;
});
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
+7 -3
View File
@@ -28,9 +28,12 @@ const header = () => page.evaluate(() => {
title: shown(head.querySelector(':scope > .title')),
tabs: shown(head.querySelector(':scope > .tabs')),
zoom: shown(head.querySelector(':scope > .zoomctl')),
modeSlot: shown(head.querySelector(':scope > .modes'))
&& Math.abs(head.querySelector(':scope > .modes').getBoundingClientRect().width - 24) <= 0.5,
cross: shown(head.querySelector('[data-hp="editor-close"]')),
gear: shown(gear),
gear44: !!g && g.width >= 44 && g.height >= 44,
hiddenInline: ['.modes', '.header-action', '.summary-control', '.tabedit', '.tabadd']
hiddenInline: ['.modes .modetab', '.header-action', '.summary-control', '.tabedit', '.tabadd']
.filter((selector) => [...head.querySelectorAll(selector)].some(shown)),
tabEditInDom: !!head.querySelector('.tabedit, .tabadd'),
inlineVisible: ['.header-action', '.summary-control'].filter((selector) => [...head.querySelectorAll(selector)].some(shown)),
@@ -85,7 +88,7 @@ for (const width of [390, 320]) {
await size(width);
const h = await header();
out[`admin${width}_oneRow56`] = h.oneRow && h.height <= 56;
out[`admin${width}_rowContent`] = !h.title && h.tabs && h.zoom && h.gear && h.gear44;
out[`admin${width}_rowContent`] = !h.title && h.tabs && h.modeSlot && h.zoom && h.gear && h.gear44;
out[`admin${width}_inlineHidden`] = h.hiddenInline.length === 0 ? true : h.hiddenInline.join(',');
await openMenu();
const m = await menuState();
@@ -135,7 +138,8 @@ await clickAt('[data-hp="header-menu-item"][data-id="mode-plan"]');
await page.waitForTimeout(300);
out.editorFromMenu = (await mode()) === 'plan';
const inEditor = await header();
out.editorOneRow56 = inEditor.oneRow && inEditor.height <= 56 && inEditor.hiddenInline.length === 0;
out.editorOneRow56 = inEditor.oneRow && inEditor.height <= 56 && inEditor.modeSlot
&& inEditor.cross && inEditor.hiddenInline.length === 0;
await openMenu();
const editorMenu = await menuState();
out.editorMenuMarksCurrent = editorMenu.current.join() === 'mode-plan' && !editorMenu.ids.includes('summary-toggle');
+27 -11
View File
@@ -1,5 +1,5 @@
// #647: основная панель — нет счётчика устройств, крестик редактора в своём
// слоте, ширина шапки и положение кнопок режимов не меняются при входе,
// #647/#660: основная панель — нет счётчика устройств, крестик редактора в
// постоянном слоте внутри группы режимов, ширина шапки и группы не меняются,
// выходе и переключении редакторов; кликабельная зона × не меньше 24 × 24
// (#195); × виден на обычных ширинах (721–1100 px — там раньше скрывался
// счётчик). Режимы переключаются настоящими кнопками через фасад #629;
@@ -8,7 +8,7 @@ import { launch, check, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 1400, height: 820 }, 1);
const WIDTHS = [1400, 1000, 768, 390];
const WIDTHS = [1400, 1200, 1000, 768, 620, 481, 390];
const out = {};
for (const width of WIDTHS) {
@@ -31,13 +31,22 @@ for (const width of WIDTHS) {
// Позиции — относительно левого края шапки: прокрутка страницы их не меняет.
const measure = () => {
const origin = head().getBoundingClientRect().left;
let nextEl = slot()?.nextElementSibling;
while (nextEl && !nextEl.getClientRects().length) nextEl = nextEl.nextElementSibling;
const modes = sr().querySelector('.modes');
const zoom = sr().querySelector('.zoomctl');
const modesBox = modes?.getBoundingClientRect();
const zoomBox = zoom?.getBoundingClientRect();
const active = modes?.querySelector('.modetab.active');
return {
head: head().getBoundingClientRect().width,
tabs: tabs().map((t) => t.getBoundingClientRect().left - origin),
modesLeft: modesBox ? modesBox.left - origin : null,
modesWidth: modesBox?.width ?? null,
zoomLeft: zoomBox ? zoomBox.left - origin : null,
gap: modesBox && zoomBox ? zoomBox.left - modesBox.right : null,
slot: slot()?.getBoundingClientRect(),
next: nextEl ? nextEl.getBoundingClientRect().left - origin : null,
slotInside: slot()?.parentElement === modes,
slotOrder: c._mode === 'view'
? slot() === modes?.lastElementChild
: slot()?.previousElementSibling === active,
};
};
const visibleTabs = tabs().filter((t) => t.getBoundingClientRect().width > 0).length === 3
@@ -56,8 +65,9 @@ for (const width of WIDTHS) {
o.idleSlotKeepsSize = !!idle.slot && Math.abs(idle.slot.width - 24) <= 0.5 && Math.abs(idle.slot.height - 24) <= 0.5;
const same = (a, b) => Math.abs(a.head - b.head) <= 1
&& a.tabs.every((left, i) => Math.abs(left - b.tabs[i]) <= 1)
&& (a.next === null || Math.abs(a.next - b.next) <= 1);
&& Math.abs(a.modesLeft - b.modesLeft) <= 1
&& Math.abs(a.modesWidth - b.modesWidth) <= 1
&& Math.abs(a.zoomLeft - b.zoomLeft) <= 1;
const steps = [];
const hits = [];
// View → каждый редактор → View, и прямые переключения между редакторами.
@@ -66,7 +76,9 @@ for (const width of WIDTHS) {
await hp.setMode(next);
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
const now = measure();
steps.push({ next, stable: same(idle, now) });
const expectedGap = width <= 620 ? 40.5 : 50;
steps.push({ next, stable: same(idle, now), inside: now.slotInside,
order: now.slotOrder, gap: phone || Math.abs(now.gap - expectedGap) <= 1 });
if (next !== 'view' && cross()) {
// На 390 px карточка демо-стенда шире окна (так и на dev) — докрутить × в видимую область.
cross().scrollIntoView({ block: 'nearest', inline: 'nearest' });
@@ -81,8 +93,12 @@ for (const width of WIDTHS) {
}
}
}
// AC2/AC3: ширина шапки и левые границы кнопок режимов и контрола справа стабильны.
// AC2/AC3: ширина шапки, группы режимов и контрола справа стабильны.
o.widthAndTabsStable = steps.every((s) => s.stable);
o.closeSlotLivesInsideModes = idle.slotInside && idle.slotOrder
&& steps.every((s) => s.inside && s.order);
o.modeToZoomGapMatchesSpec = (phone || Math.abs(idle.gap - (width <= 620 ? 40.5 : 50)) <= 1)
&& steps.every((s) => s.gap);
// AC5/AC6: × виден, ≥ 24 × 24, глиф 13 px, центр слота — это ×, кнопки режимов без ×.
o.crossVisibleAndSized = hits.length === 7 && hits.every((h) => h.visible && h.glyph13);
o.crossHitTarget = hits.every((h) => h.centreHitsCross);