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; } }