fix: restore capsule outline for text markers

Issue: #217
User-Visible: yes
This commit is contained in:
Sergey Matyunin
2026-08-20 13:21:40 +03:00
parent c547586dc9
commit 39456dc44c
14 changed files with 157 additions and 23 deletions
File diff suppressed because one or more lines are too long
+3 -1
View File
@@ -154,6 +154,8 @@ for (const theme of ['Light', 'Dark']) {
const row = rows[0];
matrix.push({ theme, row, size, runtime: await runtimePng(theme, row, size) });
}
const textRow = rows.find((row) => row.label === 'Text');
matrix.push({ theme, row: textRow, size: 96, runtime: await runtimePng(theme, textRow, 96) });
}
const escapeHtml = (value) => String(value)
@@ -177,7 +179,7 @@ const html = `<!doctype html>
.preview img{display:block;margin:auto;max-width:300px;max-height:180px}.runtime img{image-rendering:auto}
</style></head><body>
<h1>House Plan device icons: package 1.1.1 vs runtime</h1>
<p>Issue #211. Reference SVG is loaded directly from the designer package; Runtime is a fresh browser capture. Default also covers 32/56/96 px. Dark Unlock is evaluated using the owner's amber override from #179.</p>
<p>Issues #211/#217. Reference SVG is loaded directly from the designer package; Runtime is a fresh browser capture. Default covers 32/56/96 px and Text has an additional large 96 px row so its outer stadium curvature is reviewable. Dark Unlock is evaluated using the owner's amber override from #179.</p>
<table><thead><tr><th>Theme</th><th>State/layout</th><th>Core</th><th>Reference SVG</th><th>Runtime</th></tr></thead>
<tbody>${body}</tbody></table></body></html>`;
const htmlPath = resolve(artifactDir, 'device-icons-reference-runtime.html');
+8
View File
@@ -188,6 +188,7 @@ export function prepareGoldenFixture(scenario) {
}
if (scenario.fillMode || scenario.bgMode || typeof scenario.glowEnabled === 'boolean'
|| typeof scenario.sunRays === 'boolean' || typeof scenario.showBorders === 'boolean'
|| typeof scenario.showNames === 'boolean'
|| typeof scenario.northDeg === 'number') {
const space = requireSpace();
space.settings = {
@@ -197,6 +198,7 @@ export function prepareGoldenFixture(scenario) {
...(typeof scenario.glowEnabled === 'boolean' ? { glow_enabled: scenario.glowEnabled } : {}),
...(typeof scenario.sunRays === 'boolean' ? { sun_rays: scenario.sunRays } : {}),
...(typeof scenario.showBorders === 'boolean' ? { show_borders: scenario.showBorders } : {}),
...(typeof scenario.showNames === 'boolean' ? { show_names: scenario.showNames } : {}),
...(typeof scenario.northDeg === 'number' ? { north_deg: scenario.northDeg } : {}),
...(scenario.customFill ? { custom_fill: scenario.customFill } : {}),
};
@@ -772,6 +774,12 @@ export async function prepareGoldenScenario(page, scenario) {
marker.classList.add(...classes);
}
}
if (scenario.deviceOnly) {
const markers = [...card.renderRoot.querySelectorAll('[data-hp="device"]')];
const selected = markers.find((marker) => marker.dataset.id === scenario.deviceOnly);
if (!selected) throw new Error(`golden isolated device missing: ${scenario.deviceOnly}`);
for (const marker of markers) marker.style.visibility = marker === selected ? 'visible' : 'hidden';
}
if (scenario.focusDevice) {
const marker = card.renderRoot.querySelector(
`[data-hp="device"][data-id="${CSS.escape(scenario.focusDevice)}"]`,
+19 -1
View File
@@ -1,7 +1,7 @@
import { fixtureWallKey } from '../fixtures/visual-matrix.mjs';
/** Data-only HP-QA-01 capture matrix. Bump when framing or scenarios change. */
export const GOLDEN_MATRIX_VERSION = 32;
export const GOLDEN_MATRIX_VERSION = 33;
const stage = { capture: 'stage', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0005 } };
const page = { capture: 'page', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0008 } };
@@ -248,6 +248,24 @@ export const GOLDEN_SCENARIOS = Object.freeze([
hideHoverTooltip: true,
theme, viewport: { width: 1000, height: 900 }, ...stage,
})),
...['light', 'dark'].map((theme) => ({
id: `device-text-shell-long-${theme}`, fixture: 'visual', space: 'golden-lighting', mode: 'view',
glowEnabled: false, sunRays: false, showNames: false,
markerOverrides: [{
id: 'golden-left-linkquality', binding: 'device:golden-left-linkquality',
display: 'value', size: 3,
}],
stateOverrides: {
'sensor.golden_left_linkquality': {
state: '498', attributes: { unit_of_measurement: 'ppm' },
},
},
layoutOverrides: {
'golden-left-linkquality': { s: 'golden-lighting', x: 0.50, y: 0.50 },
},
deviceOnly: 'golden-left-linkquality',
theme, viewport: { width: 1000, height: 900 }, ...stage,
})),
{ id: 'lighting-sun-window-state-only-dark', fixture: 'visual', space: 'golden-lighting', mode: 'view',
// The golden screenshot is backed by a second, sun-layer-hidden capture.
// A real painted ray must account for enough changed pixels; DOM-only
+51 -2
View File
@@ -259,10 +259,18 @@ const result = await page.evaluate(async ({ beforeUnavailable, afterUnavailable,
probe.remove();
return { value, actual: getComputedStyle(label).color, expected };
}).filter(Boolean);
const textCoreRect = rect(text?.querySelector('.device-core'));
const textFrame = text?.querySelector('.device-shell-frame');
const textCore = text?.querySelector('.device-core');
const textFrameRect = rect(textFrame);
const textCoreRect = rect(textCore);
const textFrameRadius = getComputedStyle(textFrame).borderTopLeftRadius;
const textCoreRadius = parseFloat(
getComputedStyle(text?.querySelector('.device-core')).borderTopLeftRadius,
getComputedStyle(textCore).borderTopLeftRadius,
);
const originalTextFrameInlineRadius = textFrame.style.borderRadius;
textFrame.style.borderRadius = '50%';
const mutantTextFrameRadius = getComputedStyle(textFrame).borderTopLeftRadius;
textFrame.style.borderRadius = originalTextFrameInlineRadius;
const double = node('d_temp');
const doubleShellRect = rect(double?.querySelector('.device-shell-frame'));
const doubleCoreRect = rect(double?.querySelector('.device-core'));
@@ -323,6 +331,7 @@ const result = await page.evaluate(async ({ beforeUnavailable, afterUnavailable,
const originalLightStyle = light.getAttribute('style') || '';
const originalDoubleStyle = double.getAttribute('style') || '';
const originalTextStyle = text.getAttribute('style') || '';
const sizeMatrix = [
{ configured: 32, effective: 28.8 },
{ configured: 56, effective: 50.4 },
@@ -354,6 +363,27 @@ const result = await page.evaluate(async ({ beforeUnavailable, afterUnavailable,
light.setAttribute('style', originalLightStyle);
double.setAttribute('style', originalDoubleStyle);
const textShellSizeMatrix = [24, 32, 56, 96, 112].map((effective) => {
text.style.setProperty('--device-base-size', `${effective}px`);
text.style.setProperty('--dev-scale', '1');
const frameRect = rect(textFrame);
const coreRect = rect(textCore);
const frameStyle = getComputedStyle(textFrame);
const coreStyle = getComputedStyle(textCore);
return {
effective,
frameWidth: frameRect?.width || 0,
frameHeight: frameRect?.height || 0,
coreWidth: coreRect?.width || 0,
coreHeight: coreRect?.height || 0,
frameRadius: frameStyle.borderTopLeftRadius,
coreRadius: parseFloat(coreStyle.borderTopLeftRadius),
horizontalInset: frameRect && coreRect ? (frameRect.width - coreRect.width) / 2 : 0,
verticalInset: frameRect && coreRect ? (frameRect.height - coreRect.height) / 2 : 0,
};
});
text.setAttribute('style', originalTextStyle);
light.classList.remove('theme-dark');
light.classList.add('theme-light');
const lightActiveGlyph = getComputedStyle(lightCore).color;
@@ -461,6 +491,9 @@ const result = await page.evaluate(async ({ beforeUnavailable, afterUnavailable,
focusCoreDecoration,
stateMatrix,
sizeMatrix,
textShellSizeMatrix,
textFrameRadius,
mutantTextFrameRadius,
reference,
lightHover,
ordinaryHover,
@@ -501,10 +534,26 @@ const result = await page.evaluate(async ({ beforeUnavailable, afterUnavailable,
sharedShellGeometry: !!shellRect && !!coreRect
&& Math.abs(shellRect.width / coreRect.width - 1.26875) < 0.03,
iconCoreIsCircle: !!coreRect && radiusIsHalf(coreRadiusText, coreRect),
iconShellIsCircle: !!shellRect && Math.abs(shellRect.width - shellRect.height) <= 0.5
&& activeShellStyle.borderTopLeftRadius === '50%',
textCoreIsStadium: !!textCoreRect && textCoreRect.width > textCoreRect.height
&& Math.abs(textCoreRadius - textCoreRect.height / 2) <= 0.5,
textShellIsStadiumAtRepresentativeSizes: !!textFrameRect
&& textFrameRect.width > textFrameRect.height
&& textFrameRadius === '9999px'
&& textShellSizeMatrix.every((sample) => sample.frameWidth > sample.frameHeight
&& sample.coreWidth > sample.coreHeight
&& sample.frameRadius === '9999px'
&& Number.parseFloat(sample.frameRadius) >= sample.frameHeight / 2
&& Math.abs(sample.coreRadius - sample.coreHeight / 2) <= 0.5
&& Math.abs(sample.horizontalInset - sample.verticalInset) <= 0.5),
textShellMutationGuardRejects50Percent: textFrameRadius === '9999px'
&& mutantTextFrameRadius === '50%',
valueBadgeIsPill: !!doubleValueRect
&& Math.abs(valueRadius - doubleValueRect.height / 2) <= 0.5,
doubleShellKeepsCapsule: !!doubleShellRect
&& doubleShellRect.width > doubleShellRect.height
&& getComputedStyle(double.querySelector('.device-shell-frame')).borderTopLeftRadius === '9999px',
packageShadowColor: getComputedStyle(lightFrame).boxShadow.includes('37, 40, 45'),
noBackdropBlur: getComputedStyle(lightFrame).backdropFilter === 'none',
activeUsesPackageAmber: getComputedStyle(lightCore).backgroundColor === 'rgb(240, 160, 12)'
File diff suppressed because one or more lines are too long
+2 -2
View File
File diff suppressed because one or more lines are too long
+4
View File
@@ -2,6 +2,10 @@
## Unreleased
- Fixed Text device markers whose outer outline became an ellipse instead of
matching the capsule-shaped value core and designer reference
([#217](https://github.com/Matysh/houseplan-card/issues/217)).
## v1.65.0-beta.9 — 2026-08-20
- Device marker cores now stay concentric with their outer shell at fractional
+4
View File
@@ -8,6 +8,10 @@
## Не выпущено
- Исправлена внешняя обводка текстовых маркеров устройств: она снова повторяет
капсульную форму значения и дизайнерского макета вместо овала
([#217](https://github.com/Matysh/houseplan-card/issues/217)).
## v1.65.0-beta.9 — 2026-08-20
- Core маркера устройства теперь остаётся соосным внешнему shell при дробных
+12
View File
@@ -391,6 +391,18 @@ owner's amber Dark Unlock override are recorded in that directory's README.
continuous `lqiColor()` across former 40/41 and 179/180 boundaries; bands
remain semantic metadata only.
## Text marker shell shape (#217)
- [ ] `node demo/smoke_device_icon_design.mjs` checks the external Text frame,
not only its core: a long value keeps a saturating capsule radius at
24/32/56/96/112 px, while Icon-only remains circular and Double remains a
capsule. The runtime mutation to `border-radius: 50%` must be rejected.
- [ ] `device-text-shell-long-light` and `device-text-shell-long-dark` isolate a
large `498 ppm` Text marker. Golden review must visibly confirm straight
upper/lower middle sections rather than an ellipse.
- [ ] `node demo/capture_device_icon_reference.mjs` includes an additional
96 px Text row beside the normative Light/Dark `Text Default.svg`.
## Touch support and release gates
The product contract is defined in `docs/TOUCH-SUPPORT.md`:
+11 -11
View File
@@ -1,7 +1,7 @@
{
"version": 1,
"fixture": "synthetic-only",
"sourceFingerprint": "5a28e238ae1e2e390a37b02ca0373e96a9b896ee7f9bf2bbbbc673077b4d7fc9",
"sourceFingerprint": "c96b56e1f58da21e5397d4f6361aefe2449bea1d980d01817770c51dcdd7fc3d",
"captureScriptSha256": "34f2219790d46efd8250e7a1bd829cb8fc0b0547e1260635fefa52407551b41b",
"command": "npm run build && node demo/docs/capture.mjs",
"scenarios": {
@@ -13,7 +13,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "5a28e238ae1e2e390a37b02ca0373e96a9b896ee7f9bf2bbbbc673077b4d7fc9",
"sourceSha256": "c96b56e1f58da21e5397d4f6361aefe2449bea1d980d01817770c51dcdd7fc3d",
"imageSha256": "2885f96e348b15ab7c696e56e99bddcd9bb2ee94218a883e5a2155f45f5042aa"
},
"view-touch": {
@@ -24,7 +24,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "5a28e238ae1e2e390a37b02ca0373e96a9b896ee7f9bf2bbbbc673077b4d7fc9",
"sourceSha256": "c96b56e1f58da21e5397d4f6361aefe2449bea1d980d01817770c51dcdd7fc3d",
"imageSha256": "f62d8af3617c00a5e99511bd765980d2e27badf25047a1be34046b64195abe6c"
},
"space-create": {
@@ -35,7 +35,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "5a28e238ae1e2e390a37b02ca0373e96a9b896ee7f9bf2bbbbc673077b4d7fc9",
"sourceSha256": "c96b56e1f58da21e5397d4f6361aefe2449bea1d980d01817770c51dcdd7fc3d",
"imageSha256": "c53db2e5c642a5549c13f3c93a5b359fed69bdb2621bf71a243a877ffcb95e6b"
},
"room-contour-close": {
@@ -46,7 +46,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "5a28e238ae1e2e390a37b02ca0373e96a9b896ee7f9bf2bbbbc673077b4d7fc9",
"sourceSha256": "c96b56e1f58da21e5397d4f6361aefe2449bea1d980d01817770c51dcdd7fc3d",
"imageSha256": "2f13ea645af306eee9cc7c52699c928377b72f412b08c1a3db6beb848ace1b51"
},
"plan-context-tray": {
@@ -57,7 +57,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "5a28e238ae1e2e390a37b02ca0373e96a9b896ee7f9bf2bbbbc673077b4d7fc9",
"sourceSha256": "c96b56e1f58da21e5397d4f6361aefe2449bea1d980d01817770c51dcdd7fc3d",
"imageSha256": "a6c526fede11bc3503fd2384bd4a3f6afa008f481c5c47578f06cae6c81c6f9b"
},
"device-editor": {
@@ -68,7 +68,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "5a28e238ae1e2e390a37b02ca0373e96a9b896ee7f9bf2bbbbc673077b4d7fc9",
"sourceSha256": "c96b56e1f58da21e5397d4f6361aefe2449bea1d980d01817770c51dcdd7fc3d",
"imageSha256": "9585b59add4d35b5a6f028ce5b720ef1b77a3f8b19436d192cd1a0e6fc637264"
},
"device-display-preview": {
@@ -79,7 +79,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "5a28e238ae1e2e390a37b02ca0373e96a9b896ee7f9bf2bbbbc673077b4d7fc9",
"sourceSha256": "c96b56e1f58da21e5397d4f6361aefe2449bea1d980d01817770c51dcdd7fc3d",
"imageSha256": "cfc317da4628d079a116ff71311fbf06b1eb3b181928a5ea7ba888ab936a5e8b"
},
"background-editor": {
@@ -90,7 +90,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "5a28e238ae1e2e390a37b02ca0373e96a9b896ee7f9bf2bbbbc673077b4d7fc9",
"sourceSha256": "c96b56e1f58da21e5397d4f6361aefe2449bea1d980d01817770c51dcdd7fc3d",
"imageSha256": "d7cfe70551d9260169df8efd832e32ed7df99c7f60b55d1fd4a8322bd4c47175"
},
"room-card": {
@@ -101,7 +101,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "5a28e238ae1e2e390a37b02ca0373e96a9b896ee7f9bf2bbbbc673077b4d7fc9",
"sourceSha256": "c96b56e1f58da21e5397d4f6361aefe2449bea1d980d01817770c51dcdd7fc3d",
"imageSha256": "029a3e69ec647a8a370d99e6bb7f9225833c526739076022f6b52ba54bff30ea"
},
"device-info": {
@@ -112,7 +112,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "5a28e238ae1e2e390a37b02ca0373e96a9b896ee7f9bf2bbbbc673077b4d7fc9",
"sourceSha256": "c96b56e1f58da21e5397d4f6361aefe2449bea1d980d01817770c51dcdd7fc3d",
"imageSha256": "a06cbf83f09e2f67b3566d7c0b10e973db060c3d20786f26f74ada6a21937c3e"
}
}
+1 -1
View File
@@ -2058,7 +2058,7 @@ export const cardStyles = css`
left: 0;
top: 0;
}
.device-shell:not(.with-values) .device-shell-frame {
.device-shell:not(.with-values):not(.text-shell) .device-shell-frame {
border-radius: 50%;
}
.device-shell.with-values.pos-right {
@@ -47,6 +47,22 @@ test('issue 212 Text value uses a stadium radius based on height', () => {
assert.doesNotMatch(rule, /border-radius:\s*50%/);
});
test('issue 217 keeps the Text shell stadium while icon-only stays circular', () => {
const styles = source('styles.ts');
const frame = styles.match(/\.device-shell-frame\s*\{([\s\S]*?)\}/)?.[1] || '';
const circle = styles.match(
/\.device-shell:not\(\.with-values\):not\(\.text-shell\) \.device-shell-frame\s*\{([\s\S]*?)\}/,
)?.[1] || '';
assert.match(frame, /border-radius:\s*9999px/);
assert.match(circle, /border-radius:\s*50%/);
assert.doesNotMatch(
styles,
/\.device-shell:not\(\.with-values\) \.device-shell-frame\s*\{[\s\S]*?border-radius:\s*50%/,
'the circular override must not capture a wide Text shell',
);
});
test('issue 212 feedback is owned by actual dispatch and lasts 200 ms', () => {
const card = source('houseplan-card.ts');
assert.match(card, /private _startDevicePressFeedback\(/);
+22 -1
View File
@@ -234,7 +234,7 @@ test('sun-ray golden requires browser-painted light from a state-only sun entity
assert.ok(scenario);
const fixture = prepareGoldenFixture(scenario);
const space = fixture.config.spaces.find((item) => item.id === scenario.space);
assert.equal(GOLDEN_MATRIX_VERSION, 32);
assert.equal(GOLDEN_MATRIX_VERSION, 33);
assert.equal(space.settings.sun_rays, true);
assert.equal(scenario.northDeg, 90,
'the sign-sensitive golden must keep a non-zero north direction');
@@ -336,6 +336,27 @@ test('device icon state-table goldens cover both themes and design facets', () =
}
});
test('Text shell regression goldens isolate a large long value in both themes', () => {
const scenarios = GOLDEN_SCENARIOS.filter((item) => item.id.startsWith('device-text-shell-long-'));
assert.deepEqual(new Set(scenarios.map((scenario) => scenario.theme)), new Set(['light', 'dark']));
for (const scenario of scenarios) {
assert.equal(scenario.deviceOnly, 'golden-left-linkquality');
const fixture = prepareGoldenFixture(scenario);
const marker = fixture.config.markers.find((item) => item.id === 'golden-left-linkquality');
assert.equal(marker.display, 'value');
assert.equal(marker.size, 3);
assert.equal(
fixture.config.spaces.find((item) => item.id === scenario.space).settings.show_names,
false,
);
assert.equal(fixture.states['sensor.golden_left_linkquality'].state, '498');
assert.equal(
fixture.states['sensor.golden_left_linkquality'].attributes.unit_of_measurement,
'ppm',
);
}
});
test('opening placement golden requires browser-painted preview pixels', () => {
const door = GOLDEN_SCENARIOS.find(
(item) => item.id === 'opening-placement-door-thick-wall-dark',