fix(2.5d): room metrics keep their distance from the name at every zoom (#665)

The raised room label in 2.5D View sized its own box to the 44 px touch
minimum (min-height + justify-content: center). The name therefore sat
centred in 44 px while the metrics row, placed absolutely from the label
bottom, hung (44 - name height) / 2 below it: 18.3 px at a 9 px name and
6.4 px at a 39 px one on the demo stand, against a constant 0.1 name
height on the flat plan.

The 44 x 44 px floor moves into an invisible ::before (the door-lock
pattern), z-index -1 inside the label stacking context; the label box is
sized by its text again.

demo/smoke_iso_room_label_metrics.mjs measures gap / name height at two
cameras (font x4) in Flat and 2.5D, and the 44 px floor and area-link hit
targets on labels no raised device covers. Mutant
iso-room-label-44-box-centres-name.

Screenshots: 11 frames pixel-identical, fingerprint only.

Issue: #665
User-Visible: yes
This commit is contained in:
Claude
2026-09-26 15:16:14 +03:00
parent 72d49040bf
commit c93b3a90ef
7 changed files with 178 additions and 15 deletions
+126
View File
@@ -0,0 +1,126 @@
// #665: in 2.5D the metrics row under a room name keeps the same distance
// from the name at every zoom — the same share of the name height as in Flat.
//
// The regression: the raised label's 44 px touch floor was the label box
// itself (min-height + centring), so the name sat centred in 44 px and the
// absolutely placed metrics row hung (44 - name height) / 2 below it. The
// distance therefore shrank as zooming in grew the font. The floor now lives
// in ::before; this smoke measures the painted boxes and the hit target.
import { launch, check, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 1000, height: 850 });
const out = await page.evaluate(async () => {
const card = window.__card;
const root = () => card.renderRoot;
const frame = () => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
// Metrics rows need an explicit opt-in; the demo fixture shows names only.
for (const config of [card._serverCfg, card._config].filter(Boolean)) {
for (const space of config.spaces || []) {
space.settings = { ...(space.settings || {}), show_names: true, label_temp: true, label_light: true };
}
}
card.requestUpdate();
await card.updateComplete;
await frame();
const labels = () => [...root().querySelectorAll('.roomlabel')]
.filter((label) => label.querySelector('.rlname') && label.querySelector('.rlmetrics'));
const measure = () => labels().map((label) => {
const name = label.querySelector('.rlname').getBoundingClientRect();
const metrics = label.querySelector('.rlmetrics').getBoundingClientRect();
return { id: label.dataset.id, name: name.height, gap: metrics.top - name.bottom };
});
// Two cameras around the same centre: the label font follows the zoom.
const atZooms = async () => {
const base = { ...card._view };
const rows = [];
for (const factor of [1.6, 0.35]) {
const w = base.w * factor;
const h = base.h * factor;
card._view = { x: base.x + (base.w - w) / 2, y: base.y + (base.h - h) / 2, w, h };
card.requestUpdate();
await card.updateComplete;
await frame();
await frame();
rows.push(measure());
}
card._view = base;
card.requestUpdate();
await card.updateComplete;
await frame();
return rows;
};
await window.__hpTest.setVolumetricView(false);
await card.updateComplete;
await frame();
const flat = await atZooms();
await window.__hpTest.setVolumetricView(true);
if (typeof card._ensureIsoSceneRuntime === 'function') await card._ensureIsoSceneRuntime();
await card.updateComplete;
await frame();
const iso = await atZooms();
// Touch floor (AC2): a raised label still owns 44 x 44 px around its centre,
// and the area link inside it stays a separate, clickable target. Raised
// device tiles are deliberately above names (z-index 2), so a label a device
// happens to overlap in this fixture cannot answer: judge the labels no
// device covers.
const cls = (el) => el ? `${el.tagName.toLowerCase()}.${[...el.classList].join('.')}` : null;
const underDevice = (el) => !!el?.closest?.('.dev, .oplock');
const probes = [...root().querySelectorAll('.stage.projection-iso.mode-view .roomlabel')].map((label) => {
const rect = label.getBoundingClientRect();
const pseudo = getComputedStyle(label, '::before');
const centre = [rect.left + rect.width / 2, rect.top + rect.height / 2];
const hits = [[0, 0], [-21, 0], [21, 0], [0, -21], [0, 21]]
.map(([dx, dy]) => root().elementFromPoint(centre[0] + dx, centre[1] + dy));
const areaLink = label.querySelector('.rlgo');
const linkRect = areaLink?.getBoundingClientRect();
const linkHit = linkRect
? root().elementFromPoint(linkRect.left + linkRect.width / 2, linkRect.top + linkRect.height / 2)
: null;
return {
id: label.dataset.id,
boxHeight: rect.height,
floor: { width: Number.parseFloat(pseudo.width), height: Number.parseFloat(pseudo.height) },
covered: hits.some(underDevice) || underDevice(linkHit),
floorHits: hits.map((hit) => !!hit && (hit === label || label.contains(hit))),
link: areaLink ? (!!linkHit && (linkHit === areaLink || areaLink.contains(linkHit))) : null,
hitNames: hits.map(cls),
};
});
const free = probes.filter((probe) => !probe.covered);
const ratio = (row) => row.gap / row.name;
return {
flat,
iso,
probes,
freeLabels: free.length,
floorIs44: free.length > 0 && free.every((probe) => probe.floor.width >= 44 && probe.floor.height >= 44),
floorOwnsTheTarget: free.length > 0 && free.every((probe) => probe.floorHits.every(Boolean)),
areaLinkStaysClickable: free.some((probe) => probe.link === true)
&& free.every((probe) => probe.link !== false),
ratios: {
flat: flat.map((rows) => rows.map(ratio)),
iso: iso.map((rows) => rows.map(ratio)),
},
};
});
const pairs = out.iso.flatMap((rows, zoom) => rows.map((row, index) => ({
zoom, id: row.id, iso: row.gap / row.name, flat: out.flat[zoom][index].gap / out.flat[zoom][index].name,
})));
check('labelsMeasured', pairs.length >= 2 && out.iso.every((rows) => rows.length > 0));
// The font really changed between the two cameras, or the test proves nothing.
check('zoomChangesTheFont', out.iso[1][0].name >= out.iso[0][0].name * 2);
// AC1: the 2.5D share equals the Flat share at each zoom, and is the same at both zooms.
check('gapMatchesFlatAtEveryZoom', pairs.every((pair) => Math.abs(pair.iso - pair.flat) <= 0.02));
check('gapIsZoomIndependent', out.iso[0].every((row, index) =>
Math.abs(row.gap / row.name - out.iso[1][index].gap / out.iso[1][index].name) <= 0.02));
// AC2: 44 px floor kept, as painted-box-independent hit testing.
check('freeLabelsExist', out.freeLabels >= 1);
check('floorIs44', out.floorIs44);
check('floorOwnsTheTarget', out.floorOwnsTheTarget);
check('areaLinkStaysClickable', out.areaLinkStaysClickable);
await finish(browser, { ...out, pairs });
+5
View File
@@ -2,6 +2,11 @@
## Unreleased ## Unreleased
- In the 2.5D plan the metrics row under a room name (temperature, humidity,
Zigbee signal, lights) no longer drifts while zooming: it keeps the same
distance from the name at every zoom, exactly as on the flat plan. The room
label keeps its 44 × 44 px touch target
([#665](https://github.com/Matysh/houseplan-card/issues/665)).
- The close **×** of the active editor now sits inside the highlighted tab: the - 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 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 cross, which is drawn in the tab's text colour, and the keyboard focus ring
+5
View File
@@ -8,6 +8,11 @@
## Не выпущено ## Не выпущено
- На объёмном плане (2.5D) строка показателей под названием комнаты
(температура, влажность, сигнал Zigbee, свет) больше не «плавает» при зуме:
расстояние до названия одно и то же на любом масштабе, как на плоском
плане. Цель касания подписи комнаты по-прежнему 44 × 44 px
([#665](https://github.com/Matysh/houseplan-card/issues/665)).
- Крестик **×** активного редактора теперь внутри подсвеченной вкладки: - Крестик **×** активного редактора теперь внутри подсвеченной вкладки:
акцентная заливка вкладки «План», «Устройства» или «Декор» продолжается под акцентная заливка вкладки «План», «Устройства» или «Декор» продолжается под
крестиком, сам крестик нарисован цветом текста вкладки, а рамка фокуса с крестиком, сам крестик нарисован цветом текста вкладки, а рамка фокуса с
+3
View File
@@ -324,6 +324,9 @@ byte-for-byte unchanged. Side-by-side acceptance frames:
- Light or dark floor is decided per room: the room fill at its opacity over - Light or dark floor is decided per room: the room fill at its opacity over
the plan paper, luma > 0.55 is light (`isoLightFloorRooms`). A marker belongs the plan paper, luma > 0.55 is light (`isoLightFloorRooms`). A marker belongs
to the room of its overlay owner or, without walls, to the room under it. to the room of its overlay owner or, without walls, to the room under it.
- A raised room label keeps its 44 × 44 px touch floor in an invisible
`::before`, like door locks; the label box itself is sized by its text, so
the metrics row keeps the Flat distance from the name at every zoom (#665).
- Hover / focus-visible / selected / alert frames hug tile and edge - Hover / focus-visible / selected / alert frames hug tile and edge
(bbox + 0.075 D per side, + edge height) and float with the tile: `#0C82F0`, (bbox + 0.075 D per side, + edge height) and float with the tile: `#0C82F0`,
`#0C82F0`, `#F0A00C`, `#F0410C`, priority Alert > Focus > Selected > Hover. `#0C82F0`, `#F0A00C`, `#F0410C`, priority Alert > Focus > Selected > Hover.
+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": "86fc93fc5f2710f5d460e0ca9628d5b6282682227e19175aec77b195bed9c0fe", "sourceFingerprint": "25eae6908d0b7e5b154910505e483bc772e2f0406fed852c21b6ee910808a383",
"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": "86fc93fc5f2710f5d460e0ca9628d5b6282682227e19175aec77b195bed9c0fe", "sourceSha256": "25eae6908d0b7e5b154910505e483bc772e2f0406fed852c21b6ee910808a383",
"imageSha256": "1bde1bff8d4ab5f197320b54ef57186b3c07cf2121d5414ada63ce357826bfa8" "imageSha256": "1bde1bff8d4ab5f197320b54ef57186b3c07cf2121d5414ada63ce357826bfa8"
}, },
"view-touch": { "view-touch": {
@@ -26,7 +26,7 @@
}, },
"theme": "dark", "theme": "dark",
"language": "en", "language": "en",
"sourceSha256": "86fc93fc5f2710f5d460e0ca9628d5b6282682227e19175aec77b195bed9c0fe", "sourceSha256": "25eae6908d0b7e5b154910505e483bc772e2f0406fed852c21b6ee910808a383",
"imageSha256": "f9dd5b54f86b814c00bc84149af84485f052b103cdcc9263b5c50bf5d32c69d6" "imageSha256": "f9dd5b54f86b814c00bc84149af84485f052b103cdcc9263b5c50bf5d32c69d6"
}, },
"space-create": { "space-create": {
@@ -37,7 +37,7 @@
}, },
"theme": "dark", "theme": "dark",
"language": "en", "language": "en",
"sourceSha256": "86fc93fc5f2710f5d460e0ca9628d5b6282682227e19175aec77b195bed9c0fe", "sourceSha256": "25eae6908d0b7e5b154910505e483bc772e2f0406fed852c21b6ee910808a383",
"imageSha256": "aab4811bba3afa6cc849cb9d3989d126b8c327b69a2f8f853e55fc077e0e9a1b" "imageSha256": "aab4811bba3afa6cc849cb9d3989d126b8c327b69a2f8f853e55fc077e0e9a1b"
}, },
"room-contour-close": { "room-contour-close": {
@@ -48,7 +48,7 @@
}, },
"theme": "dark", "theme": "dark",
"language": "en", "language": "en",
"sourceSha256": "86fc93fc5f2710f5d460e0ca9628d5b6282682227e19175aec77b195bed9c0fe", "sourceSha256": "25eae6908d0b7e5b154910505e483bc772e2f0406fed852c21b6ee910808a383",
"imageSha256": "e106ff9db6c96adb08beba12cc299a88ac5fa47c079be316f185e5657bc656f0" "imageSha256": "e106ff9db6c96adb08beba12cc299a88ac5fa47c079be316f185e5657bc656f0"
}, },
"plan-context-tray": { "plan-context-tray": {
@@ -59,7 +59,7 @@
}, },
"theme": "dark", "theme": "dark",
"language": "en", "language": "en",
"sourceSha256": "86fc93fc5f2710f5d460e0ca9628d5b6282682227e19175aec77b195bed9c0fe", "sourceSha256": "25eae6908d0b7e5b154910505e483bc772e2f0406fed852c21b6ee910808a383",
"imageSha256": "9b0db923fa7c8aff783b5c7050ee5b410b1b04df81a9e48bbc8111a7d0d47e76" "imageSha256": "9b0db923fa7c8aff783b5c7050ee5b410b1b04df81a9e48bbc8111a7d0d47e76"
}, },
"device-editor": { "device-editor": {
@@ -70,7 +70,7 @@
}, },
"theme": "dark", "theme": "dark",
"language": "en", "language": "en",
"sourceSha256": "86fc93fc5f2710f5d460e0ca9628d5b6282682227e19175aec77b195bed9c0fe", "sourceSha256": "25eae6908d0b7e5b154910505e483bc772e2f0406fed852c21b6ee910808a383",
"imageSha256": "198c084c0a45aa3afdf79181b80d8bb0347c95f3248b4fac9e05d69eb8a35f8f" "imageSha256": "198c084c0a45aa3afdf79181b80d8bb0347c95f3248b4fac9e05d69eb8a35f8f"
}, },
"device-display-preview": { "device-display-preview": {
@@ -81,7 +81,7 @@
}, },
"theme": "dark", "theme": "dark",
"language": "en", "language": "en",
"sourceSha256": "86fc93fc5f2710f5d460e0ca9628d5b6282682227e19175aec77b195bed9c0fe", "sourceSha256": "25eae6908d0b7e5b154910505e483bc772e2f0406fed852c21b6ee910808a383",
"imageSha256": "e9b88d65dca74133646aa849d47bc09ad85eb8b16a168c4df6930e8f5856b5f5" "imageSha256": "e9b88d65dca74133646aa849d47bc09ad85eb8b16a168c4df6930e8f5856b5f5"
}, },
"background-editor": { "background-editor": {
@@ -92,7 +92,7 @@
}, },
"theme": "dark", "theme": "dark",
"language": "en", "language": "en",
"sourceSha256": "86fc93fc5f2710f5d460e0ca9628d5b6282682227e19175aec77b195bed9c0fe", "sourceSha256": "25eae6908d0b7e5b154910505e483bc772e2f0406fed852c21b6ee910808a383",
"imageSha256": "44beadd61365ac1730c1efde846e528bfe92d8935fd167f1b6bf3bbad6af8f33" "imageSha256": "44beadd61365ac1730c1efde846e528bfe92d8935fd167f1b6bf3bbad6af8f33"
}, },
"room-card": { "room-card": {
@@ -103,7 +103,7 @@
}, },
"theme": "dark", "theme": "dark",
"language": "en", "language": "en",
"sourceSha256": "86fc93fc5f2710f5d460e0ca9628d5b6282682227e19175aec77b195bed9c0fe", "sourceSha256": "25eae6908d0b7e5b154910505e483bc772e2f0406fed852c21b6ee910808a383",
"imageSha256": "d47988b03e6a078508b27b5612b6eafe8ecc33c891b589ddda1cc7c71f43b7c8" "imageSha256": "d47988b03e6a078508b27b5612b6eafe8ecc33c891b589ddda1cc7c71f43b7c8"
}, },
"device-info": { "device-info": {
@@ -114,7 +114,7 @@
}, },
"theme": "dark", "theme": "dark",
"language": "en", "language": "en",
"sourceSha256": "86fc93fc5f2710f5d460e0ca9628d5b6282682227e19175aec77b195bed9c0fe", "sourceSha256": "25eae6908d0b7e5b154910505e483bc772e2f0406fed852c21b6ee910808a383",
"imageSha256": "4ec8c6df671742b90ca55222917076c47c5035a6df0587282e60873c0ce5f64e" "imageSha256": "4ec8c6df671742b90ca55222917076c47c5035a6df0587282e60873c0ce5f64e"
}, },
"pdf-export": { "pdf-export": {
@@ -125,7 +125,7 @@
}, },
"theme": "light", "theme": "light",
"language": "en", "language": "en",
"sourceSha256": "86fc93fc5f2710f5d460e0ca9628d5b6282682227e19175aec77b195bed9c0fe", "sourceSha256": "25eae6908d0b7e5b154910505e483bc772e2f0406fed852c21b6ee910808a383",
"imageSha256": "ad3d735135b180ce015c9cda07fdcec08d7c8707abe0665734ef7b450a8a7093" "imageSha256": "ad3d735135b180ce015c9cda07fdcec08d7c8707abe0665734ef7b450a8a7093"
} }
}, },
+10
View File
@@ -3391,6 +3391,16 @@ const MUTANT_DEFINITIONS = [
replace: "", replace: "",
}], }],
}, },
{
id: 'iso-room-label-44-box-centres-name',
guard: 'node demo/smoke_iso_room_label_metrics.mjs',
because: '#665: sizing the raised label box to 44 px centres the name in it and pushes the metrics row away by (44 - name) / 2, so the gap drifts with zoom',
patches: [{
file: 'src/styles/plan.styles.ts',
find: " .stage.projection-iso.mode-view .roomlabel {\n box-sizing: border-box;\n",
replace: " .stage.projection-iso.mode-view .roomlabel {\n box-sizing: border-box;\n min-height: 44px;\n justify-content: center;\n",
}],
},
{ {
id: 'static-card-descendants-hit-testable', id: 'static-card-descendants-hit-testable',
guard: 'node demo/smoke_space_card.mjs', guard: 'node demo/smoke_space_card.mjs',
+17 -3
View File
@@ -691,15 +691,29 @@ export const planStyles = css`
} }
.stage.projection-iso.mode-view .roomlabel { .stage.projection-iso.mode-view .roomlabel {
box-sizing: border-box; box-sizing: border-box;
min-width: 44px;
min-height: 44px;
justify-content: center;
text-shadow: none; text-shadow: none;
filter: none; filter: none;
-webkit-text-stroke: 0 transparent; -webkit-text-stroke: 0 transparent;
/* #649 3b: the configured room label colour (inline, as in Flat) is /* #649 3b: the configured room label colour (inline, as in Flat) is
kept in both themes; 2.5D only drops stroke, shadow and halo. */ kept in both themes; 2.5D only drops stroke, shadow and halo. */
} }
/* #665: the 44 x 44 px touch floor of a raised room label is an invisible
pseudo-element, as on door locks: sizing the label box itself centred the
name in 44 px and pushed the absolutely placed metrics row away from it
by (44 - name height) / 2, so the metrics drifted as the zoom changed the
font. z-index -1 paints the floor below the label content, inside the
label's own stacking context (z-index 1). */
.stage.projection-iso.mode-view .roomlabel::before {
content: '';
position: absolute;
left: 50%;
top: 50%;
width: max(44px, 100%);
height: max(44px, 100%);
transform: translate(-50%, -50%);
z-index: -1;
pointer-events: auto;
}
/* Interactive raised overlays stay above passive room names. Collision /* Interactive raised overlays stay above passive room names. Collision
placement never needs to move or promote a label to make a device usable. */ placement never needs to move or promote a label to make a device usable. */
.stage.projection-iso.mode-view .dev, .stage.projection-iso.mode-view .dev,