mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-28 19:01:34 +00:00
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
208 lines
12 KiB
JavaScript
208 lines
12 KiB
JavaScript
// #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);
|