diff --git a/demo/smoke_toolbar_stable_width.mjs b/demo/smoke_toolbar_stable_width.mjs
index 1416044f..d2a2ef51 100644
--- a/demo/smoke_toolbar_stable_width.mjs
+++ b/demo/smoke_toolbar_stable_width.mjs
@@ -1,3 +1,5 @@
+// #666: подсветка активной вкладки редактора накрывает промежуток и слот ×
+// (пиксельные пробы), не выходя за слот; × на подсветке — цветом текста вкладки.
// #647/#660: основная панель — нет счётчика устройств, крестик редактора в
// постоянном слоте внутри группы режимов, ширина шапки и группы не меняются,
// выходе и переключении редакторов; кликабельная зона × не меньше 24 × 24
@@ -121,6 +123,73 @@ for (const width of WIDTHS) {
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 в русской локали на широком окне.
diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md
index a26781b1..faf878bb 100644
--- a/docs/CHANGELOG.md
+++ b/docs/CHANGELOG.md
@@ -2,6 +2,14 @@
## 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
over device markers and no longer swallows clicks there: the whole schematic
is inert again, as documented, with an ordinary cursor everywhere and the
diff --git a/docs/CHANGELOG.ru.md b/docs/CHANGELOG.ru.md
index fbbf39e7..81255a26 100755
--- a/docs/CHANGELOG.ru.md
+++ b/docs/CHANGELOG.ru.md
@@ -8,6 +8,14 @@
## Не выпущено
+- Крестик **×** активного редактора теперь внутри подсвеченной вкладки:
+ акцентная заливка вкладки «План», «Устройства» или «Декор» продолжается под
+ крестиком, сам крестик нарисован цветом текста вкладки, а рамка фокуса с
+ клавиатуры обводит всю подсвеченную зону. Размеры и
+ положение вкладки, крестика и шапки не изменились; на телефоне, где вкладки
+ режимов скрыты, крестик прежний
+ ([#666](https://github.com/Matysh/houseplan-card/issues/666)).
+
- Статическая карточка пространства (`houseplan-space-card`) больше не
показывает курсор-«руку» над значками устройств и не перехватывает там
клики: вся схема снова инертна, как и описано, — обычный курсор везде, а
diff --git a/docs/images/screenshots.json b/docs/images/screenshots.json
index 585e24e6..9fa5f59d 100644
--- a/docs/images/screenshots.json
+++ b/docs/images/screenshots.json
@@ -3,7 +3,7 @@
"fixture": "synthetic-only",
"chromium": "151.0.7922.34",
"oxipng": null,
- "sourceFingerprint": "121a34d5ed5269ca95a59af2f9b9f4b797a1c324dc830d20ced0140dead40b4c",
+ "sourceFingerprint": "86fc93fc5f2710f5d460e0ca9628d5b6282682227e19175aec77b195bed9c0fe",
"captureScriptSha256": "e2d14d89de793011615662af7c83c433768d4761f6cec7f0b3860c797494299e",
"command": "npm run build && node demo/docs/capture.mjs",
"scenarios": {
@@ -15,7 +15,7 @@
},
"theme": "dark",
"language": "en",
- "sourceSha256": "121a34d5ed5269ca95a59af2f9b9f4b797a1c324dc830d20ced0140dead40b4c",
+ "sourceSha256": "86fc93fc5f2710f5d460e0ca9628d5b6282682227e19175aec77b195bed9c0fe",
"imageSha256": "1bde1bff8d4ab5f197320b54ef57186b3c07cf2121d5414ada63ce357826bfa8"
},
"view-touch": {
@@ -26,7 +26,7 @@
},
"theme": "dark",
"language": "en",
- "sourceSha256": "121a34d5ed5269ca95a59af2f9b9f4b797a1c324dc830d20ced0140dead40b4c",
+ "sourceSha256": "86fc93fc5f2710f5d460e0ca9628d5b6282682227e19175aec77b195bed9c0fe",
"imageSha256": "f9dd5b54f86b814c00bc84149af84485f052b103cdcc9263b5c50bf5d32c69d6"
},
"space-create": {
@@ -37,7 +37,7 @@
},
"theme": "dark",
"language": "en",
- "sourceSha256": "121a34d5ed5269ca95a59af2f9b9f4b797a1c324dc830d20ced0140dead40b4c",
+ "sourceSha256": "86fc93fc5f2710f5d460e0ca9628d5b6282682227e19175aec77b195bed9c0fe",
"imageSha256": "aab4811bba3afa6cc849cb9d3989d126b8c327b69a2f8f853e55fc077e0e9a1b"
},
"room-contour-close": {
@@ -48,7 +48,7 @@
},
"theme": "dark",
"language": "en",
- "sourceSha256": "121a34d5ed5269ca95a59af2f9b9f4b797a1c324dc830d20ced0140dead40b4c",
+ "sourceSha256": "86fc93fc5f2710f5d460e0ca9628d5b6282682227e19175aec77b195bed9c0fe",
"imageSha256": "e106ff9db6c96adb08beba12cc299a88ac5fa47c079be316f185e5657bc656f0"
},
"plan-context-tray": {
@@ -59,7 +59,7 @@
},
"theme": "dark",
"language": "en",
- "sourceSha256": "121a34d5ed5269ca95a59af2f9b9f4b797a1c324dc830d20ced0140dead40b4c",
+ "sourceSha256": "86fc93fc5f2710f5d460e0ca9628d5b6282682227e19175aec77b195bed9c0fe",
"imageSha256": "9b0db923fa7c8aff783b5c7050ee5b410b1b04df81a9e48bbc8111a7d0d47e76"
},
"device-editor": {
@@ -70,7 +70,7 @@
},
"theme": "dark",
"language": "en",
- "sourceSha256": "121a34d5ed5269ca95a59af2f9b9f4b797a1c324dc830d20ced0140dead40b4c",
+ "sourceSha256": "86fc93fc5f2710f5d460e0ca9628d5b6282682227e19175aec77b195bed9c0fe",
"imageSha256": "198c084c0a45aa3afdf79181b80d8bb0347c95f3248b4fac9e05d69eb8a35f8f"
},
"device-display-preview": {
@@ -81,7 +81,7 @@
},
"theme": "dark",
"language": "en",
- "sourceSha256": "121a34d5ed5269ca95a59af2f9b9f4b797a1c324dc830d20ced0140dead40b4c",
+ "sourceSha256": "86fc93fc5f2710f5d460e0ca9628d5b6282682227e19175aec77b195bed9c0fe",
"imageSha256": "e9b88d65dca74133646aa849d47bc09ad85eb8b16a168c4df6930e8f5856b5f5"
},
"background-editor": {
@@ -92,7 +92,7 @@
},
"theme": "dark",
"language": "en",
- "sourceSha256": "121a34d5ed5269ca95a59af2f9b9f4b797a1c324dc830d20ced0140dead40b4c",
+ "sourceSha256": "86fc93fc5f2710f5d460e0ca9628d5b6282682227e19175aec77b195bed9c0fe",
"imageSha256": "44beadd61365ac1730c1efde846e528bfe92d8935fd167f1b6bf3bbad6af8f33"
},
"room-card": {
@@ -103,7 +103,7 @@
},
"theme": "dark",
"language": "en",
- "sourceSha256": "121a34d5ed5269ca95a59af2f9b9f4b797a1c324dc830d20ced0140dead40b4c",
+ "sourceSha256": "86fc93fc5f2710f5d460e0ca9628d5b6282682227e19175aec77b195bed9c0fe",
"imageSha256": "d47988b03e6a078508b27b5612b6eafe8ecc33c891b589ddda1cc7c71f43b7c8"
},
"device-info": {
@@ -114,7 +114,7 @@
},
"theme": "dark",
"language": "en",
- "sourceSha256": "121a34d5ed5269ca95a59af2f9b9f4b797a1c324dc830d20ced0140dead40b4c",
+ "sourceSha256": "86fc93fc5f2710f5d460e0ca9628d5b6282682227e19175aec77b195bed9c0fe",
"imageSha256": "4ec8c6df671742b90ca55222917076c47c5035a6df0587282e60873c0ce5f64e"
},
"pdf-export": {
@@ -125,7 +125,7 @@
},
"theme": "light",
"language": "en",
- "sourceSha256": "121a34d5ed5269ca95a59af2f9b9f4b797a1c324dc830d20ced0140dead40b4c",
+ "sourceSha256": "86fc93fc5f2710f5d460e0ca9628d5b6282682227e19175aec77b195bed9c0fe",
"imageSha256": "ad3d735135b180ce015c9cda07fdcec08d7c8707abe0665734ef7b450a8a7093"
}
},
diff --git a/scripts/mutation-registry.mjs b/scripts/mutation-registry.mjs
index b2d1bbb9..a6a661da 100644
--- a/scripts/mutation-registry.mjs
+++ b/scripts/mutation-registry.mjs
@@ -7603,6 +7603,16 @@ const MUTANT_DEFINITIONS = [
replace: " ${this._t(('mode.' + m) as any)}${this._mode === m ? html`` : nothing}\n ${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',
guard: 'node demo/smoke_toolbar_stable_width.mjs',
diff --git a/src/styles/chrome.styles.ts b/src/styles/chrome.styles.ts
index e224ebf7..5ae3966b 100644
--- a/src/styles/chrome.styles.ts
+++ b/src/styles/chrome.styles.ts
@@ -205,11 +205,18 @@ const chromeCoreStyles = css`
}
.decorbar hp-color-opacity { flex: 0 0 auto; }
.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;
gap: var(--sp-1);
background: rgba(127, 127, 127, 0.12);
border-radius: var(--rad-l);
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 {
display: inline-flex;
@@ -236,7 +243,6 @@ const chromeCoreStyles = css`
/* #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. */
.editor-close-slot {
- --hp-editor-close-size: 24px;
box-sizing: border-box;
display: inline-flex;
flex: none;
@@ -283,10 +289,39 @@ const chromeCoreStyles = css`
line-height: 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 {
- background: var(--hp-accent);
+ position: relative;
+ background: transparent;
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) {
.modetab .ml { display: none; }
}