Files
houseplan-card/demo/smoke_toolbar_stable_width.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

117 lines
6.6 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.
// #647: основная панель — нет счётчика устройств, крестик редактора в своём
// слоте, ширина шапки и положение кнопок режимов не меняются при входе,
// выходе и переключении редакторов; кликабельная зона × не меньше 24 × 24
// (#195); × виден на обычных ширинах (721–1100 px — там раньше скрывался
// счётчик). Режимы переключаются настоящими кнопками через фасад #629;
// приватные поля карточки только читаются.
import { launch, check, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 1400, height: 820 }, 1);
const WIDTHS = [1400, 1000, 768, 390];
const out = {};
for (const width of WIDTHS) {
await page.setViewportSize({ width, height: 820 });
const r = await page.evaluate(async (width) => {
const o = {};
const c = window.__card;
const hp = window.__hpTest;
const sr = () => c.shadowRoot || c.renderRoot;
const head = () => sr().querySelector('.head');
const slot = () => sr().querySelector('.editor-close-slot');
const cross = () => sr().querySelector('.editor-close-slot .closex');
const tabs = () => [...sr().querySelectorAll('[data-hp="mode-tab"]')];
await hp.setMode('view');
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
// Позиции — относительно левого края шапки: прокрутка страницы их не меняет.
const measure = () => {
const origin = head().getBoundingClientRect().left;
const nextEl = slot()?.nextElementSibling;
return {
head: head().getBoundingClientRect().width,
tabs: tabs().map((t) => t.getBoundingClientRect().left - origin),
slot: slot()?.getBoundingClientRect(),
next: nextEl ? nextEl.getBoundingClientRect().left - origin : null,
};
};
const visibleTabs = tabs().filter((t) => t.getBoundingClientRect().width > 0).length === 3;
o.tabsVisible = visibleTabs;
// AC1: числа нет — ни узла, ни текста вида «N dev.».
o.noHeaderCount = !head().querySelector('.count')
&& !/\d+\s*(dev\.|устр\.)/.test(head().textContent || '');
// AC4 (вне редактора): слот пуст, не фокусируется, скрыт от скринридера.
const idle = measure();
o.idleSlotInert = !!slot() && !cross()
&& slot().getAttribute('aria-hidden') === 'true'
&& !slot().querySelector('button, [tabindex], a, input');
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);
const steps = [];
const hits = [];
// View → каждый редактор → View, и прямые переключения между редакторами.
for (const sequence of [['plan', 'view'], ['devices', 'view'], ['decor', 'view'], ['plan', 'devices', 'decor', 'plan', 'view']]) {
for (const next of sequence) {
await hp.setMode(next);
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
const now = measure();
steps.push({ next, stable: same(idle, now) });
if (next !== 'view' && cross()) {
// На 390 px карточка демо-стенда шире окна (так и на dev) — докрутить × в видимую область.
cross().scrollIntoView({ block: 'nearest', inline: 'nearest' });
const rect = cross().getBoundingClientRect();
const centre = sr().elementFromPoint(rect.left + rect.width / 2, rect.top + rect.height / 2);
hits.push({
visible: rect.width >= 24 && rect.height >= 24 && getComputedStyle(cross()).display !== 'none',
centreHitsCross: !!centre && (centre === cross() || cross().contains(centre)),
glyph13: getComputedStyle(cross().querySelector('ha-icon')).getPropertyValue('--mdc-icon-size').trim() === '13px',
noCrossInTabs: !sr().querySelector('.modetab .closex'),
});
}
}
}
// AC2/AC3: ширина шапки и левые границы кнопок режимов и контрола справа стабильны.
o.widthAndTabsStable = steps.every((s) => s.stable);
// 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);
o.noCrossInsideTabs = hits.every((h) => h.noCrossInTabs);
// AC4/AC5: клик в точку в 10 px от центра глифа по горизонтали закрывает редактор.
await hp.setMode('devices');
cross().scrollIntoView({ block: 'nearest', inline: 'nearest' });
const rect = cross().getBoundingClientRect();
const x = rect.left + rect.width / 2 - 10;
const y = rect.top + rect.height / 2;
const edge = sr().elementFromPoint(x, y);
edge?.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true, clientX: x, clientY: y }));
await hp.settled();
o.edgeClickCloses = sr().querySelector('ha-card')?.getAttribute('data-hp-mode') === 'view';
// AC7: нового горизонтального overflow шапки нет.
o.noHeadOverflow = head().scrollWidth <= head().clientWidth + 1;
o.width = width;
return o;
}, width);
for (const [key, value] of Object.entries(r)) if (key !== 'width') out[`w${width}_${key}`] = value;
}
// AC1 в русской локали на широком окне.
await page.setViewportSize({ width: 1400, height: 820 });
out.ru_noHeaderCount = await page.evaluate(async () => {
const c = window.__card;
c.hass = { ...c.hass, language: 'ru', locale: { ...(c.hass.locale || {}), language: 'ru' } };
await window.__hpTest.settled();
await new Promise((resolve) => setTimeout(resolve, 300));
const head = (c.shadowRoot || c.renderRoot).querySelector('.head');
return !head.querySelector('.count') && !/\d+\s*устр\./.test(head.textContent || '');
});
for (const [name, value] of Object.entries(out)) check(name, value);
await finish(browser, out);