Files
houseplan-card/demo/smoke_toolbar_stable_width.mjs
T
Claude 3bdd3c5163 fix(header): the active editor tab highlight covers its close X (#666)
The accent fill of the active Plan/Devices/Decor tab is now a pseudo-element
stretched over the 2 px group gap and the fixed 24 px X slot of #660, so
the X sits inside the highlighted zone without any box moving: the tab,
the slot, the X and the header keep their geometry. The X takes the tab
text colour, and the keyboard focus ring moves to the same pseudo-element
so it outlines the whole zone instead of cutting it at the tab edge.
.modes becomes its own stacking context so the z-index:-1 fill paints above
the group background. On a phone the mode tabs, and the fill, stay hidden.

smoke_toolbar_stable_width: painted-pixel probes for the gap, the slot edge
and top, and the first pixel after the slot, in every editor at every width
above 480 px; the X colour equals the tab colour. The existing geometry
and hit checks are unchanged and green. Mutant
toolbar-active-highlight-stops-at-tab.

Screenshots: 11 frames pixel-identical, fingerprint only.

Issue: #666
User-Visible: yes
2026-09-26 14:36:14 +03:00

208 lines
12 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.
// #666: подсветка активной вкладки редактора накрывает промежуток и слот ×
// (пиксельные пробы), не выходя за слот; × на подсветке — цветом текста вкладки.
// #647/#660: основная панель — нет счётчика устройств, крестик редактора в
// постоянном слоте внутри группы режимов, ширина шапки и группы не меняются,
// выходе и переключении редакторов; кликабельная зона × не меньше 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, 1200, 1000, 768, 620, 481, 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');
// #616: на ≤ 480 px кнопки режимов живут в меню шестерёнки; соседи × в строке —
// вкладки пространств, зум и шестерёнка, их положение и меряется.
const phone = width <= 480;
const tabs = () => [...sr().querySelectorAll(phone
? '.head > .tabs, .head > .zoomctl, [data-hp="header-menu"]' : '[data-hp="mode-tab"]')];
await hp.setMode('view');
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
// Позиции — относительно левого края шапки: прокрутка страницы их не меняет.
const measure = () => {
const origin = head().getBoundingClientRect().left;
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,
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(),
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
&& (!phone || [...sr().querySelectorAll('[data-hp="mode-tab"]')].every((t) => !t.getClientRects().length));
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
&& 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, и прямые переключения между редакторами.
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();
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' });
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);
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);
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;
// #666: the active tab's highlight covers the gap and the X slot — as painted
// pixels, not as a style name. On a phone the mode tabs (and the highlight
// with them) are hidden: nothing to check there.
if (width > 480) {
for (const mode of ['plan', 'devices', 'decor']) {
const geo = await page.evaluate(async (mode) => {
const c = window.__card;
const sr = c.shadowRoot || c.renderRoot;
await window.__hpTest.setMode(mode);
await window.__hpTest.settled();
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
// The tab colour eases over 140 ms (chrome.styles .modetab transition).
await new Promise((resolve) => setTimeout(resolve, 300));
// Leaving View focuses the active tab (a11y); its focus ring sits in the
// 2 px gap and is not what this probe measures.
sr.activeElement?.blur?.();
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
const modes = sr.querySelector('.modes');
modes.scrollIntoView({ block: 'nearest', inline: 'nearest' });
const tab = sr.querySelector('.modetab.active');
const slot = sr.querySelector('.editor-close-slot');
const cross = slot?.querySelector('.closex');
const base = modes.getBoundingClientRect();
const t = tab.getBoundingClientRect();
const b = slot.getBoundingClientRect();
const rel = (x, y) => [x - base.left, y - base.top];
const midY = t.top + t.height / 2;
return {
modes: { width: base.width, height: base.height },
accent: getComputedStyle(tab, '::after').backgroundColor,
probes: {
gap: rel((t.right + b.left) / 2, midY),
slotEdge: rel(b.right - 2, midY),
slotTop: rel(b.left + b.width / 2, t.top + 2),
tabBody: rel(t.left + 3, midY),
outside: rel(b.right + 2, midY),
},
crossColour: cross ? getComputedStyle(cross).color : null,
tabColour: getComputedStyle(tab).color,
};
}, mode);
// Playwright CSS locators pierce the open shadow root of the card.
const shot = await page.locator('.head .modes').first().screenshot({ animations: 'disabled' });
const pixels = await page.evaluate(async ([bytes, info]) => {
const bitmap = await createImageBitmap(new Blob([new Uint8Array(bytes)], { type: 'image/png' }));
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
const ctx = canvas.getContext('2d');
ctx.drawImage(bitmap, 0, 0);
const sx = bitmap.width / info.modes.width;
const sy = bitmap.height / info.modes.height;
const rgb = (value) => (value.match(/[\d.]+/g) || []).slice(0, 3).map(Number);
const accent = rgb(info.accent);
const at = ([x, y]) => {
const d = ctx.getImageData(Math.round(x * sx), Math.round(y * sy), 1, 1).data;
return [d[0], d[1], d[2]];
};
const isAccent = (p) => accent.length === 3 && p.every((v, i) => Math.abs(v - accent[i]) <= 8);
return Object.fromEntries(Object.entries(info.probes).map(([k, point]) => [k, isAccent(at(point))]));
}, [[...shot], geo]);
if (!(pixels.gap && pixels.slotEdge && pixels.slotTop && pixels.tabBody)) console.log(`#666 probes w${width} ${mode}`, JSON.stringify({ pixels, geo }));
out[`w${width}_${mode}_highlightCoversGapAndSlot`] = pixels.gap && pixels.slotEdge && pixels.slotTop && pixels.tabBody;
out[`w${width}_${mode}_highlightStopsAtSlot`] = !pixels.outside;
out[`w${width}_${mode}_crossReadsOnHighlight`] = !!geo.crossColour && geo.crossColour === geo.tabColour;
}
await page.evaluate(() => window.__hpTest.setMode('view'));
}
}
// 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);