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
This commit is contained in:
Claude
2026-09-26 14:36:14 +03:00
parent 42f437cafe
commit 3bdd3c5163
6 changed files with 144 additions and 14 deletions
+69
View File
@@ -1,3 +1,5 @@
// #666: подсветка активной вкладки редактора накрывает промежуток и слот ×
// (пиксельные пробы), не выходя за слот; × на подсветке — цветом текста вкладки.
// #647/#660: основная панель — нет счётчика устройств, крестик редактора в // #647/#660: основная панель — нет счётчика устройств, крестик редактора в
// постоянном слоте внутри группы режимов, ширина шапки и группы не меняются, // постоянном слоте внутри группы режимов, ширина шапки и группы не меняются,
// выходе и переключении редакторов; кликабельная зона × не меньше 24 × 24 // выходе и переключении редакторов; кликабельная зона × не меньше 24 × 24
@@ -121,6 +123,73 @@ for (const width of WIDTHS) {
return o; return o;
}, width); }, width);
for (const [key, value] of Object.entries(r)) if (key !== 'width') out[`w${width}_${key}`] = value; 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 в русской локали на широком окне. // AC1 в русской локали на широком окне.
+8
View File
@@ -2,6 +2,14 @@
## Unreleased ## Unreleased
- The close **×** of the active editor now sits inside the highlighted tab: the
accent fill of the active Plan, Devices or Decor tab continues over the
cross, which is drawn in the tab's text colour, and the keyboard focus ring
outlines the whole highlighted zone. The tab, the cross and the
header keep their size and position; on a phone, where the mode tabs are
hidden, the cross is unchanged
([#666](https://github.com/Matysh/houseplan-card/issues/666)).
- The static space card (`houseplan-space-card`) no longer shows a hand cursor - The static space card (`houseplan-space-card`) no longer shows a hand cursor
over device markers and no longer swallows clicks there: the whole schematic over device markers and no longer swallows clicks there: the whole schematic
is inert again, as documented, with an ordinary cursor everywhere and the is inert again, as documented, with an ordinary cursor everywhere and the
+8
View File
@@ -8,6 +8,14 @@
## Не выпущено ## Не выпущено
- Крестик **×** активного редактора теперь внутри подсвеченной вкладки:
акцентная заливка вкладки «План», «Устройства» или «Декор» продолжается под
крестиком, сам крестик нарисован цветом текста вкладки, а рамка фокуса с
клавиатуры обводит всю подсвеченную зону. Размеры и
положение вкладки, крестика и шапки не изменились; на телефоне, где вкладки
режимов скрыты, крестик прежний
([#666](https://github.com/Matysh/houseplan-card/issues/666)).
- Статическая карточка пространства (`houseplan-space-card`) больше не - Статическая карточка пространства (`houseplan-space-card`) больше не
показывает курсор-«руку» над значками устройств и не перехватывает там показывает курсор-«руку» над значками устройств и не перехватывает там
клики: вся схема снова инертна, как и описано, — обычный курсор везде, а клики: вся схема снова инертна, как и описано, — обычный курсор везде, а
+12 -12
View File
@@ -3,7 +3,7 @@
"fixture": "synthetic-only", "fixture": "synthetic-only",
"chromium": "151.0.7922.34", "chromium": "151.0.7922.34",
"oxipng": null, "oxipng": null,
"sourceFingerprint": "121a34d5ed5269ca95a59af2f9b9f4b797a1c324dc830d20ced0140dead40b4c", "sourceFingerprint": "86fc93fc5f2710f5d460e0ca9628d5b6282682227e19175aec77b195bed9c0fe",
"captureScriptSha256": "e2d14d89de793011615662af7c83c433768d4761f6cec7f0b3860c797494299e", "captureScriptSha256": "e2d14d89de793011615662af7c83c433768d4761f6cec7f0b3860c797494299e",
"command": "npm run build && node demo/docs/capture.mjs", "command": "npm run build && node demo/docs/capture.mjs",
"scenarios": { "scenarios": {
@@ -15,7 +15,7 @@
}, },
"theme": "dark", "theme": "dark",
"language": "en", "language": "en",
"sourceSha256": "121a34d5ed5269ca95a59af2f9b9f4b797a1c324dc830d20ced0140dead40b4c", "sourceSha256": "86fc93fc5f2710f5d460e0ca9628d5b6282682227e19175aec77b195bed9c0fe",
"imageSha256": "1bde1bff8d4ab5f197320b54ef57186b3c07cf2121d5414ada63ce357826bfa8" "imageSha256": "1bde1bff8d4ab5f197320b54ef57186b3c07cf2121d5414ada63ce357826bfa8"
}, },
"view-touch": { "view-touch": {
@@ -26,7 +26,7 @@
}, },
"theme": "dark", "theme": "dark",
"language": "en", "language": "en",
"sourceSha256": "121a34d5ed5269ca95a59af2f9b9f4b797a1c324dc830d20ced0140dead40b4c", "sourceSha256": "86fc93fc5f2710f5d460e0ca9628d5b6282682227e19175aec77b195bed9c0fe",
"imageSha256": "f9dd5b54f86b814c00bc84149af84485f052b103cdcc9263b5c50bf5d32c69d6" "imageSha256": "f9dd5b54f86b814c00bc84149af84485f052b103cdcc9263b5c50bf5d32c69d6"
}, },
"space-create": { "space-create": {
@@ -37,7 +37,7 @@
}, },
"theme": "dark", "theme": "dark",
"language": "en", "language": "en",
"sourceSha256": "121a34d5ed5269ca95a59af2f9b9f4b797a1c324dc830d20ced0140dead40b4c", "sourceSha256": "86fc93fc5f2710f5d460e0ca9628d5b6282682227e19175aec77b195bed9c0fe",
"imageSha256": "aab4811bba3afa6cc849cb9d3989d126b8c327b69a2f8f853e55fc077e0e9a1b" "imageSha256": "aab4811bba3afa6cc849cb9d3989d126b8c327b69a2f8f853e55fc077e0e9a1b"
}, },
"room-contour-close": { "room-contour-close": {
@@ -48,7 +48,7 @@
}, },
"theme": "dark", "theme": "dark",
"language": "en", "language": "en",
"sourceSha256": "121a34d5ed5269ca95a59af2f9b9f4b797a1c324dc830d20ced0140dead40b4c", "sourceSha256": "86fc93fc5f2710f5d460e0ca9628d5b6282682227e19175aec77b195bed9c0fe",
"imageSha256": "e106ff9db6c96adb08beba12cc299a88ac5fa47c079be316f185e5657bc656f0" "imageSha256": "e106ff9db6c96adb08beba12cc299a88ac5fa47c079be316f185e5657bc656f0"
}, },
"plan-context-tray": { "plan-context-tray": {
@@ -59,7 +59,7 @@
}, },
"theme": "dark", "theme": "dark",
"language": "en", "language": "en",
"sourceSha256": "121a34d5ed5269ca95a59af2f9b9f4b797a1c324dc830d20ced0140dead40b4c", "sourceSha256": "86fc93fc5f2710f5d460e0ca9628d5b6282682227e19175aec77b195bed9c0fe",
"imageSha256": "9b0db923fa7c8aff783b5c7050ee5b410b1b04df81a9e48bbc8111a7d0d47e76" "imageSha256": "9b0db923fa7c8aff783b5c7050ee5b410b1b04df81a9e48bbc8111a7d0d47e76"
}, },
"device-editor": { "device-editor": {
@@ -70,7 +70,7 @@
}, },
"theme": "dark", "theme": "dark",
"language": "en", "language": "en",
"sourceSha256": "121a34d5ed5269ca95a59af2f9b9f4b797a1c324dc830d20ced0140dead40b4c", "sourceSha256": "86fc93fc5f2710f5d460e0ca9628d5b6282682227e19175aec77b195bed9c0fe",
"imageSha256": "198c084c0a45aa3afdf79181b80d8bb0347c95f3248b4fac9e05d69eb8a35f8f" "imageSha256": "198c084c0a45aa3afdf79181b80d8bb0347c95f3248b4fac9e05d69eb8a35f8f"
}, },
"device-display-preview": { "device-display-preview": {
@@ -81,7 +81,7 @@
}, },
"theme": "dark", "theme": "dark",
"language": "en", "language": "en",
"sourceSha256": "121a34d5ed5269ca95a59af2f9b9f4b797a1c324dc830d20ced0140dead40b4c", "sourceSha256": "86fc93fc5f2710f5d460e0ca9628d5b6282682227e19175aec77b195bed9c0fe",
"imageSha256": "e9b88d65dca74133646aa849d47bc09ad85eb8b16a168c4df6930e8f5856b5f5" "imageSha256": "e9b88d65dca74133646aa849d47bc09ad85eb8b16a168c4df6930e8f5856b5f5"
}, },
"background-editor": { "background-editor": {
@@ -92,7 +92,7 @@
}, },
"theme": "dark", "theme": "dark",
"language": "en", "language": "en",
"sourceSha256": "121a34d5ed5269ca95a59af2f9b9f4b797a1c324dc830d20ced0140dead40b4c", "sourceSha256": "86fc93fc5f2710f5d460e0ca9628d5b6282682227e19175aec77b195bed9c0fe",
"imageSha256": "44beadd61365ac1730c1efde846e528bfe92d8935fd167f1b6bf3bbad6af8f33" "imageSha256": "44beadd61365ac1730c1efde846e528bfe92d8935fd167f1b6bf3bbad6af8f33"
}, },
"room-card": { "room-card": {
@@ -103,7 +103,7 @@
}, },
"theme": "dark", "theme": "dark",
"language": "en", "language": "en",
"sourceSha256": "121a34d5ed5269ca95a59af2f9b9f4b797a1c324dc830d20ced0140dead40b4c", "sourceSha256": "86fc93fc5f2710f5d460e0ca9628d5b6282682227e19175aec77b195bed9c0fe",
"imageSha256": "d47988b03e6a078508b27b5612b6eafe8ecc33c891b589ddda1cc7c71f43b7c8" "imageSha256": "d47988b03e6a078508b27b5612b6eafe8ecc33c891b589ddda1cc7c71f43b7c8"
}, },
"device-info": { "device-info": {
@@ -114,7 +114,7 @@
}, },
"theme": "dark", "theme": "dark",
"language": "en", "language": "en",
"sourceSha256": "121a34d5ed5269ca95a59af2f9b9f4b797a1c324dc830d20ced0140dead40b4c", "sourceSha256": "86fc93fc5f2710f5d460e0ca9628d5b6282682227e19175aec77b195bed9c0fe",
"imageSha256": "4ec8c6df671742b90ca55222917076c47c5035a6df0587282e60873c0ce5f64e" "imageSha256": "4ec8c6df671742b90ca55222917076c47c5035a6df0587282e60873c0ce5f64e"
}, },
"pdf-export": { "pdf-export": {
@@ -125,7 +125,7 @@
}, },
"theme": "light", "theme": "light",
"language": "en", "language": "en",
"sourceSha256": "121a34d5ed5269ca95a59af2f9b9f4b797a1c324dc830d20ced0140dead40b4c", "sourceSha256": "86fc93fc5f2710f5d460e0ca9628d5b6282682227e19175aec77b195bed9c0fe",
"imageSha256": "ad3d735135b180ce015c9cda07fdcec08d7c8707abe0665734ef7b450a8a7093" "imageSha256": "ad3d735135b180ce015c9cda07fdcec08d7c8707abe0665734ef7b450a8a7093"
} }
}, },
+10
View File
@@ -7603,6 +7603,16 @@ const MUTANT_DEFINITIONS = [
replace: " <ha-icon icon=${ic}></ha-icon><span class=\"ml\">${this._t(('mode.' + m) as any)}</span>${this._mode === m ? html`<ha-icon class=\"closex\" icon=\"mdi:close\"></ha-icon>` : nothing}\n </button>${this._mode === m ? editorClose : nothing}`", replace: " <ha-icon icon=${ic}></ha-icon><span class=\"ml\">${this._t(('mode.' + m) as any)}</span>${this._mode === m ? html`<ha-icon class=\"closex\" icon=\"mdi:close\"></ha-icon>` : nothing}\n </button>${this._mode === m ? editorClose : nothing}`",
}], }],
}, },
{
id: 'toolbar-active-highlight-stops-at-tab',
guard: 'node demo/smoke_toolbar_stable_width.mjs',
because: '#666: the active editor tab highlight must cover the gap and the X slot, measured as painted pixels',
patches: [{
file: 'src/styles/chrome.styles.ts',
find: " right: calc(-1 * (var(--sp-1) + var(--hp-editor-close-size)));",
replace: " right: 0;",
}],
},
{ {
id: 'toolbar-close-slot-idle-focusable', id: 'toolbar-close-slot-idle-focusable',
guard: 'node demo/smoke_toolbar_stable_width.mjs', guard: 'node demo/smoke_toolbar_stable_width.mjs',
+37 -2
View File
@@ -205,11 +205,18 @@ const chromeCoreStyles = css`
} }
.decorbar hp-color-opacity { flex: 0 0 auto; } .decorbar hp-color-opacity { flex: 0 0 auto; }
.modes { .modes {
/* #666: one source for the X slot size — the slot and the active tab's
highlight both read it. */
--hp-editor-close-size: 24px;
display: inline-flex; display: inline-flex;
gap: var(--sp-1); gap: var(--sp-1);
background: rgba(127, 127, 127, 0.12); background: rgba(127, 127, 127, 0.12);
border-radius: var(--rad-l); border-radius: var(--rad-l);
padding: var(--sp-2); padding: var(--sp-2);
/* #666: a stacking context of its own, so the active tab's highlight
(z-index -1) paints above the group background and below the tab and
X contents. */
isolation: isolate;
} }
.modetab { .modetab {
display: inline-flex; display: inline-flex;
@@ -236,7 +243,6 @@ const chromeCoreStyles = css`
/* #660: the fixed X slot lives inside the mode group, immediately after /* #660: the fixed X slot lives inside the mode group, immediately after
the active editor; in View the same reserve sits at the group end. */ the active editor; in View the same reserve sits at the group end. */
.editor-close-slot { .editor-close-slot {
--hp-editor-close-size: 24px;
box-sizing: border-box; box-sizing: border-box;
display: inline-flex; display: inline-flex;
flex: none; flex: none;
@@ -283,10 +289,39 @@ const chromeCoreStyles = css`
line-height: 0; line-height: 0;
} }
.editbar .barclose ha-icon { flex: none; margin: 0; } .editbar .barclose ha-icon { flex: none; margin: 0; }
/* #666: the highlight of the active editor tab also covers the X that
closes it. It is a pseudo-element stretched over the group gap and the
fixed X slot, so no box moves: the tab, the slot and the X keep their
#660 geometry, and the X keeps its own 24 px target. On a phone the mode
tabs are hidden, and with them the highlight. */
.modetab.active { .modetab.active {
background: var(--hp-accent); position: relative;
background: transparent;
color: var(--text-primary-color, #fff); color: var(--text-primary-color, #fff);
} }
.modetab.active::after {
content: '';
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: calc(-1 * (var(--sp-1) + var(--hp-editor-close-size)));
z-index: -1;
border-radius: var(--rad-m);
background: var(--hp-accent);
pointer-events: none;
}
/* The keyboard focus ring follows the highlight, so it does not cut the
zone between the tab and its X; the UA ring style is kept. */
.modetab.active:focus-visible { outline: none; }
.modetab.active:focus-visible::after { outline: auto; }
.modetab.active + .editor-close-slot .closex {
color: var(--text-primary-color, #fff);
opacity: 0.9;
}
.modetab.active + .editor-close-slot .closex:focus-visible {
outline-color: var(--text-primary-color, #fff);
}
@media (max-width: 720px) { @media (max-width: 720px) {
.modetab .ml { display: none; } .modetab .ml { display: none; }
} }