fix: polish device icons and pointer feedback

Issue: #212
User-Visible: yes
This commit is contained in:
Sergey Matyunin
2026-08-20 08:42:34 +03:00
parent 5cae1fdf82
commit 120d41317c
34 changed files with 1210 additions and 506 deletions
File diff suppressed because one or more lines are too long
+52 -2
View File
@@ -190,6 +190,10 @@ const result = await page.evaluate(async ({ beforeUnavailable, afterUnavailable,
const presence = node('d_motion');
const unavailable = node('d_kettle');
const text = node('d_tv');
const textCoreRect = rect(text?.querySelector('.device-core'));
const textCoreRadius = parseFloat(
getComputedStyle(text?.querySelector('.device-core')).borderTopLeftRadius,
);
const double = node('d_temp');
const doubleShellRect = rect(double?.querySelector('.device-shell'));
const doubleCoreRect = rect(double?.querySelector('.device-core'));
@@ -299,12 +303,51 @@ const result = await page.evaluate(async ({ beforeUnavailable, afterUnavailable,
const focusCoreDecoration = getComputedStyle(text.querySelector('.device-core')).boxShadow;
text.blur();
const originalCallService = c.hass.callService;
let actionDispatches = 0;
c.hass.callService = async () => { actionDispatches++; };
light.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true }));
await Promise.resolve();
const pressAnimation = lightShell.getAnimations().find((animation) =>
animation.effect?.getTiming().duration === 200);
const pressFrames = pressAnimation?.effect?.getKeyframes?.() || [];
const acceptedActionFeedback = actionDispatches === 1
&& pressAnimation?.effect?.getTiming().duration === 200
&& pressFrames.some((frame) => frame.scale === '0.95');
await new Promise((resolve) => setTimeout(resolve, 240));
const feedbackLifecycleBounded = !lightShell.getAnimations().some((animation) =>
animation.effect?.getTiming().duration === 200);
c._reducedMotion = true;
light.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true }));
await Promise.resolve();
const reducedAnimation = lightShell.getAnimations().find((animation) =>
animation.effect?.getTiming().duration === 200);
const reducedFrames = reducedAnimation?.effect?.getKeyframes?.() || [];
const reducedMotionUsesNoScale = !!reducedAnimation
&& reducedFrames.every((frame) => frame.scale == null)
&& reducedFrames.some((frame) => frame.outlineWidth === '2px');
c._cancelDevicePressFeedback();
c._reducedMotion = false;
const enterAction = new KeyboardEvent('keydown', {
key: 'Enter', bubbles: true, composed: true, cancelable: true,
});
light.dispatchEvent(enterAction);
await Promise.resolve();
const keyboardAnimation = lightShell.getAnimations().find((animation) =>
animation.effect?.getTiming().duration === 200);
const keyboardActionFeedback = enterAction.defaultPrevented && actionDispatches === 3
&& keyboardAnimation?.effect?.getKeyframes?.().some((frame) => frame.scale === '0.95');
c._cancelDevicePressFeedback();
c.hass.callService = originalCallService;
let moreInfo = 0;
c._openMoreInfo = () => { moreInfo++; };
const enter = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true });
unavailable.dispatchEvent(enter);
const space = new KeyboardEvent('keydown', { key: ' ', bubbles: true, cancelable: true });
unavailable.dispatchEvent(space);
const informationalActionHasNoFeedback = unavailable.querySelector('.device-shell')
.getAnimations().every((animation) => animation.effect?.getTiming().duration !== 200);
const aria = unavailable.getAttribute('aria-label') || '';
const viewKeyboard = unavailable.getAttribute('role') === 'button'
@@ -350,7 +393,7 @@ const result = await page.evaluate(async ({ beforeUnavailable, afterUnavailable,
.every((state) => state.core.startsWith('rgb')
&& state.glyph.startsWith('rgb') && state.shell.startsWith('rgb'))),
geometryMatchesAt32_56_96: sizeMatrix.every((sample) =>
Math.abs(sample.core - sample.requested) <= 0.5
Math.abs(sample.core - sample.requested * 0.9) <= 0.5
&& Math.abs(sample.shell / sample.core - 1.26875) < 0.03
&& sample.coreRadius === '50%'
&& Math.abs(sample.valueHeight / sample.core - 0.7875) < 0.015
@@ -364,7 +407,7 @@ const result = await page.evaluate(async ({ beforeUnavailable, afterUnavailable,
&& stateMatrix[theme][state].glyph === reference[theme][state].glyph
&& stateMatrix[theme][state].shell === reference[theme][state].shell
&& Math.abs(stateMatrix[theme][state].shellWidth
- Math.max(1, reference[theme][state].shellWidth * 56)) <= 0.5)),
- Math.max(1, Math.floor(reference[theme][state].shellWidth * 56 * 0.9))) <= 0.5)),
referenceHoverMatches: lightHover.core === reference.light.hover.core
&& lightHover.glyph === reference.light.hover.glyph
&& lightHover.shell === reference.light.default.shell
@@ -379,6 +422,8 @@ 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),
textCoreIsStadium: !!textCoreRect && textCoreRect.width > textCoreRect.height
&& Math.abs(textCoreRadius - textCoreRect.height / 2) <= 0.5,
valueBadgeIsPill: !!doubleValueRect
&& Math.abs(valueRadius - doubleValueRect.height / 2) <= 0.5,
packageShadowColor: getComputedStyle(lightShell).boxShadow.includes('37, 40, 45'),
@@ -396,6 +441,11 @@ const result = await page.evaluate(async ({ beforeUnavailable, afterUnavailable,
&& selectedCoreDecoration.includes('240, 160, 12'),
focusDecoratesCoreNotShell: focusVisibleMatched && focusShell === defaultShell
&& focusCoreDecoration.includes('12, 130, 240'),
acceptedActionFeedback,
feedbackLifecycleBounded,
reducedMotionUsesNoScale,
keyboardActionFeedback,
informationalActionHasNoFeedback,
lightThemeCoreIsWhite: lightThemeCore === 'rgb(255, 255, 255)',
darkThemeCoreIs252525: darkThemeClassProjected && darkThemeCore === 'rgb(37, 37, 37)',
darkLockUsesWhiteGlyphAndDarkShell: darkLockGlyph === 'rgb(255, 255, 255)'
+6
View File
@@ -160,6 +160,11 @@ const res = await page.evaluate(async () => {
const staticNode = card.renderRoot.querySelector('.dev[data-id="d_light1"]');
const staticFace = face(staticNode);
const staticBadgeFace = face(card.renderRoot.querySelector('.dev[data-id="d_temp"]'));
const visualFactorParity = [
sr().querySelector('.dev[data-id="d_light1"]'),
persistedPreview?.renderRoot?.querySelector('.dev'),
staticNode,
].every((node) => getComputedStyle(node).getPropertyValue('--device-visual-factor').trim() === '0.9');
return {
allFacesPresent: !!planFace && !!previewFace && !!staticFace,
@@ -175,6 +180,7 @@ const res = await page.evaluate(async () => {
badgeBounds,
valueBadgePlanPreviewParity: JSON.stringify(planBadgeFace) === JSON.stringify(persistedPreviewFace),
valueBadgePlanStaticParity: JSON.stringify(planBadgeFace) === JSON.stringify(staticBadgeFace),
visualFactorParity,
bottomBadgeLqiStacked,
staticBindingHook: staticNode?.getAttribute('data-binding-status') === 'active',
};
+18 -4
View File
@@ -38,12 +38,26 @@ const res = await page.evaluate(async () => {
const metaSz = parseFloat(getComputedStyle(lbl.querySelector('.rlmetrics')).fontSize);
out.metricsRatio = Math.round((metaSz / nameSz) * 100) / 100;
} else out.metricsRatio = 'no-metrics';
// 4) касание помечает сессию как тач и гасит тултип
// 4) modality lives on this card: touch clears transient hover, a synthetic
// compatibility mouse cannot restore it, and a real mouse can.
c._tip = { x: 1, y: 1, title: 't', meta: 'm' };
c._hoverRoom = { space: c._space, room: c._curSpaceCfg.rooms[0] };
c._notePointer(new PointerEvent('pointerdown', { pointerType: 'touch' }));
out.touchClearsTip = c._tip === null;
c._showTip(new MouseEvent('mousemove', { clientX: 5, clientY: 5 }), 'x', 'y');
out.noTipAfterTouch = !c._tip;
out.touchClearsTransientHover = c._tip === null && c._hoverRoom === null
&& !c.hasAttribute('data-pointer-hover');
const compatibilityMouse = new PointerEvent('pointermove', {
pointerType: 'mouse', clientX: 5, clientY: 5,
});
Object.defineProperty(compatibilityMouse, 'sourceCapabilities', {
value: { firesTouchEvents: true },
});
c._showTip(compatibilityMouse, 'compatibility', 'ignored');
out.compatibilityMouseIgnored = !c._tip && !c.hasAttribute('data-pointer-hover');
c._showTip(new PointerEvent('pointermove', {
pointerType: 'mouse', clientX: 7, clientY: 7,
}), 'real mouse', 'restored');
out.realMouseRestoresHover = c._tip?.title === 'real mouse'
&& c.hasAttribute('data-pointer-hover');
return out;
});
check('metricsRatio 0.75', res.metricsRatio, 0.75);
+10 -2
View File
@@ -47,6 +47,14 @@ const res = await page.evaluate(async () => {
return help?.shadowRoot?.querySelector('.tooltip')
|| root().querySelector('hp-dialog')?.shadowRoot?.querySelector('[data-hp-overlay="help"]')?.shadowRoot?.querySelector('.tooltip');
};
const enterWithRealMouse = (target) => {
target?.dispatchEvent(new PointerEvent('pointerover', {
pointerType: 'mouse', bubbles: true, composed: true,
}));
target?.dispatchEvent(new PointerEvent('pointerenter', {
pointerType: 'mouse', bubbles: false, composed: true,
}));
};
// Empty or incomplete content must not leave a dead focus target behind.
const incompleteHelp = document.createElement('hp-help');
@@ -103,7 +111,7 @@ const res = await page.evaluate(async () => {
&& !roleButton.hasAttribute('aria-describedby');
const beforeHoverGeometry = scrollGeometry();
roleButton?.dispatchEvent(new PointerEvent('pointerenter', { pointerType: 'mouse', bubbles: true, composed: true }));
enterWithRealMouse(roleButton);
await wait(330);
out.mouseHover = roleButton?.getAttribute('aria-expanded') === 'true'
&& roleButton.getAttribute('aria-describedby') === roleDescription?.id
@@ -118,7 +126,7 @@ const res = await page.evaluate(async () => {
// away from the form control the user was editing.
const nameInput = root().querySelector('hp-dialog .namein');
nameInput?.focus();
roleButton?.dispatchEvent(new PointerEvent('pointerenter', { pointerType: 'mouse', bubbles: true, composed: true }));
enterWithRealMouse(roleButton);
await wait(330);
nameInput?.dispatchEvent(new KeyboardEvent('keydown', {
key: 'Escape', bubbles: true, composed: true, cancelable: true,
+44 -13
View File
@@ -1,22 +1,53 @@
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
// эмуляция тач-устройства: переопределяем matchMedia ДО загрузки бандла
await page.addInitScript(() => {
const orig = window.matchMedia.bind(window);
window.matchMedia = (q) => q.includes('hover: none')
? { matches: true, media: q, addEventListener() {}, removeEventListener() {}, addListener() {}, removeListener() {}, onchange: null, dispatchEvent: () => false }
: orig(q);
});
await page.reload();
await page.waitForFunction(() => window.__card && window.__card._devices?.length, null, { timeout: 15000 });
const { page, browser } = await launch(
{ width: 390, height: 760 }, 1, [], { hasTouch: true, isMobile: true },
);
const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
c._setMode('view'); await c.updateComplete;
// "тап" по комнате → mousemove-эмуляция → тултип НЕ должен появиться
c._showTip(new MouseEvent('mousemove', { clientX: 100, clientY: 100 }), 'Room', 'meta');
out.touchHardwareHasNoFineHover = !matchMedia(
'(any-hover: hover) and (any-pointer: fine)',
).matches;
c._tip = { x: 1, y: 1, title: 'stale', meta: 'stale' };
c._hoverRoom = { space: c._space, room: c._curSpaceCfg.rooms[0] };
c._notePointer(new PointerEvent('pointerdown', {
pointerType: 'touch', pointerId: 9, clientX: 100, clientY: 100,
}));
await c.updateComplete;
out.noTipOnTouch = c._tip === null || c._tip === undefined || !c._tip;
out.touchClearsAllTransientHover = !c._tip && !c._hoverRoom
&& !c.hasAttribute('data-pointer-hover');
c._showTip(new PointerEvent('pointermove', {
pointerType: 'touch', pointerId: 9, clientX: 100, clientY: 100,
}), 'Room', 'meta');
out.noTipOnTouch = !c._tip;
c._notePointer(new PointerEvent('pointermove', {
pointerType: 'mouse', clientX: 110, clientY: 110,
}));
out.touchOnlyHardwareRejectsMouseHover = !c.hasAttribute('data-pointer-hover');
out.nestedHoverGateIsOff = [...c.renderRoot.querySelectorAll(
'hp-dialog, hp-help, hp-color-opacity',
)].every((node) => !node.hasAttribute('data-pointer-hover'));
const marker = c.renderRoot.querySelector('.dev[data-id="d_light1"]');
const shell = marker?.querySelector('.device-shell');
const originalCallService = c.hass.callService;
let dispatches = 0;
c.hass.callService = async () => { dispatches++; };
marker?.dispatchEvent(new PointerEvent('pointerdown', {
pointerType: 'touch', pointerId: 10, bubbles: true, composed: true,
}));
marker?.dispatchEvent(new PointerEvent('pointerup', {
pointerType: 'touch', pointerId: 10, bubbles: true, composed: true,
}));
marker?.click();
await Promise.resolve();
const touchFeedback = shell?.getAnimations().find((animation) =>
animation.effect?.getTiming().duration === 200);
out.touchDispatchGetsFeedback = dispatches === 1
&& touchFeedback?.effect?.getKeyframes?.().some((frame) => frame.scale === '0.95');
c._cancelDevicePressFeedback();
c.hass.callService = originalCallService;
return out;
});
checkAll(res);
File diff suppressed because one or more lines are too long
+145 -126
View File
File diff suppressed because one or more lines are too long
+7
View File
@@ -2,6 +2,13 @@
## Unreleased
- Device markers are now 10% more compact without moving their saved centres or
shrinking the 44×44 px interaction floor, and wide Text markers keep uniform
capsule insets. A real toggle/run dispatch gives a smooth 5% press response
for 200 ms (with a reduced-motion alternative). Touch and pen no longer leave
room, marker or shared-control hover stuck; a real mouse restores hover on
hybrid hardware without reloading ([#212](https://github.com/Matysh/houseplan-card/issues/212)).
## v1.65.0-beta.7 — 2026-08-20
- A card instance can now be pinned to one space with `floor`: use a stable
+8
View File
@@ -8,6 +8,14 @@
## Не выпущено
- Маркеры устройств стали на 10% компактнее без сдвига сохранённого центра и
без уменьшения области нажатия 44×44 px; широкий Text-маркер получил ровные
отступы капсулы. Реальная отправка toggle/run-команды даёт плавный отклик
уменьшением на 5% за 0,2 с с отдельным вариантом для reduced motion. Касание
пальцем или пером больше не оставляет hover комнаты, маркера и общих контролов
«прилипшим», а настоящая мышь возвращает hover на гибридном устройстве без
перезагрузки ([#212](https://github.com/Matysh/houseplan-card/issues/212)).
## v1.65.0-beta.7 — 2026-08-20
- Экземпляр карточки теперь можно закрепить за одним пространством параметром
+24 -3
View File
@@ -61,6 +61,26 @@
sizes 32/56/96 and colored backgrounds. Full smoke/golden/performance
remains a Linux release gate.
## Device marker polish and pointer modality (#212)
- [ ] Shared icon geometry applies one 0.9 visual factor after card/per-marker
sizing, keeps the saved centre and 44×44 hit floor unchanged, and gives
wide Text cores a radius equal to half their height
[unit: `device-marker-polish-contract.test.mjs`; auto:
`smoke_device_icon_design.mjs`].
- [ ] Only a genuinely dispatched toggle/run action produces one
`1 → .95 → 1` feedback cycle lasting 200 ms. Info, editor, confirmation
before acceptance, unavailable/secure/no-target and cancelled gestures do
not; reduced motion has no scale tween
[auto: `smoke_device_icon_design.mjs`].
- [ ] Pointer authority is isolated per card. Touch/pen and compatibility mouse
input clear JS/CSS hover, while a later real mouse restores it only on
fine/hover hardware; mode/space/visibility/disconnect cleanup remains
bounded [unit: `pointer-modality.test.mjs`; auto: `smoke_feedback_v2.mjs`].
- [ ] Pre-beta visual review covers Light/Dark desktop and touch matrices for
ordinary, Text, Double, unavailable and vacuum markers; full golden and
performance gates remain release work.
## Empty-space lifecycle (#113)
- [ ] Active selection keeps active-or-first compatibility, while an empty
@@ -384,9 +404,10 @@ missing destructive confirmation or an editor exception that breaks View.
Room settings [auto: smoke_feedback_v2]
- [ ] Metrics readability (v1.43.3): the metrics line is 0.75 of the room name
(was 0.62 — unreadable on tablets); per-room sliders still apply on top [auto: smoke_feedback_v2]
- [ ] Touch tooltips, take two (v1.43.3): a hover tooltip never appears after
ANY touch/pen pointer event, even if the browser claims `hover: hover`
(stylus, paired mouse, vendor skins) [auto: smoke_feedback_v2]
- [ ] Touch tooltips: touch/pen immediately clears hover even if the browser
claims hover support; compatibility mouse is ignored, while a later real
paired-mouse event restores desktop hover without reload
[auto: smoke_feedback_v2]
- [ ] Light-source flag (v1.44.0, user feedback): a smart SWITCH driving dumb
fixtures creates a Glow pool only once "This device is a
+11
View File
@@ -40,6 +40,17 @@ On phones, tablets, wall panels and HA Companion apps, the ordinary View must:
A touch-only failure in View is a product defect, not an accepted limitation of
the editor policy.
## Pointer modality and hover ownership
Hover is instance-local and follows the latest real pointer input. It is
enabled only after a mouse event when the browser also reports fine,
hover-capable hardware. Touch and pen input immediately clear transient room
and device hover, including tooltips; browser-generated compatibility mouse
events must not restore it. A later real mouse move on a hybrid device restores
desktop hover without a reload. Space/mode changes, page hiding and remounts
also discard transient hover. Keyboard focus and explicit click/tap surfaces
remain independent of this visual hover gate.
## What “best-effort editors” means
On a coarse-pointer or no-hover device, an editor operation may:
+17
View File
@@ -558,6 +558,11 @@ House Plan читает реестры устройств, сущностей и
После первичного заполнения новые устройства отмечаются красной точкой. Точка исчезает после первого открытия маркера в редакторе устройств.
Визуальная часть маркера рисуется на 10% компактнее выбранного базового и
индивидуального размера; сохранённая точка остаётся его центром, а область
нажатия не уменьшается. У режима **Значение** внутренняя подложка числа имеет
ровные полукруглые торцы и одинаковый отступ внутри внешнего shell.
### Виды привязки
| Вид | Что выбирается | Особенности |
@@ -687,6 +692,13 @@ HA. «Показать» заблокировано до активации. П
| Долгое нажатие, 600 мс | Открывает внутреннюю карточку House Plan | Не используется для управления |
| Правый клик | Открывает стандартный HA more-info основной сущности | Открывает контекст редактора/браузера по текущему поведению |
Когда короткое нажатие действительно отправляет команду устройству, маркер
мягко уменьшается на 5% и возвращается за 0,2 секунды. Открытие информации,
редактора или подтверждения само по себе такого отклика не даёт; при включённом
подтверждении он появляется только после согласия и фактической отправки
команды. При системной настройке уменьшения движения масштабирование заменено
коротким немигающим акцентом.
### Действия
| Действие | Что происходит | Защита |
@@ -753,6 +765,11 @@ hover → состояние → нейтрально**. Если все сущ
настройки. У медиаплеера `off` используется то же приглушённое отображение, что
`unknown` и `unavailable`, без отдельного визуального состояния.
Hover включается только после события от настоящей мыши на устройстве, которое
поддерживает точный hover. Касание пальцем или пером сразу снимает подсказку и
подсветку и не оставляет их «прилипшими»; последующее движение реальной мыши
возвращает desktop-hover без перезагрузки страницы.
### Подложка и постоянный статус
| Вид | Что означает | Примеры |
+8
View File
@@ -71,6 +71,14 @@ the only way to operate a lock from the card; plan-icon taps never toggle locks)
room-card link icon → HA area (room taps do nothing since v1.40.1), room hover
highlight, hover tooltips (name, clean-floor area, temperature, signal).
Room, device, opening and shared-control hover is a mouse-only transient layer:
the card enables it per instance after real mouse input on fine/hover hardware,
clears it on touch/pen and on mode, space or lifecycle boundaries, and restores
it when a real mouse is used again. Keyboard focus, selection and semantic
device state do not depend on that gate. A device action that actually dispatches
briefly scales its painted shell to 95% and back over 200 ms; informational,
editor and no-op paths do not imitate a successful action.
Removed from this mode (they move, not die):
- icon dragging ("drag anywhere", v1.9 — consciously reversed),
- room-label dragging,
Binary file not shown.

Before

Width:  |  Height:  |  Size: 194 KiB

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 292 KiB

After

Width:  |  Height:  |  Size: 290 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 293 KiB

After

Width:  |  Height:  |  Size: 291 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 135 KiB

After

Width:  |  Height:  |  Size: 134 KiB

+17 -17
View File
@@ -1,7 +1,7 @@
{
"version": 1,
"fixture": "synthetic-only",
"sourceFingerprint": "cbfb54d4fcba73572180857de759b4def182c2d4487a9996d70b7aa7d8bf91d9",
"sourceFingerprint": "040ec09a494568481fd2a4853a4dd8183c7950152dc9674d0a458a323f8bdf21",
"captureScriptSha256": "34f2219790d46efd8250e7a1bd829cb8fc0b0547e1260635fefa52407551b41b",
"command": "npm run build && node demo/docs/capture.mjs",
"scenarios": {
@@ -13,8 +13,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "cbfb54d4fcba73572180857de759b4def182c2d4487a9996d70b7aa7d8bf91d9",
"imageSha256": "4fba7e8a5da04bbb92d747530c76c2447bb9989daf55ad5fcd26208b0145b7c9"
"sourceSha256": "040ec09a494568481fd2a4853a4dd8183c7950152dc9674d0a458a323f8bdf21",
"imageSha256": "8780c6f05a9f2af55b97512dfa8d277bb77ab9ae0dafb50640cc3237028055dc"
},
"view-touch": {
"file": "02-view-touch.png",
@@ -24,8 +24,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "cbfb54d4fcba73572180857de759b4def182c2d4487a9996d70b7aa7d8bf91d9",
"imageSha256": "60153a758d9cedff1b938e79db90471cfe47f104858d7ef729f1dae1db48c49f"
"sourceSha256": "040ec09a494568481fd2a4853a4dd8183c7950152dc9674d0a458a323f8bdf21",
"imageSha256": "5e41c81ded9eb67deea49840c260eaaa404cab61abf014a1a275586dafe66e53"
},
"space-create": {
"file": "03-space-create.png",
@@ -35,7 +35,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "cbfb54d4fcba73572180857de759b4def182c2d4487a9996d70b7aa7d8bf91d9",
"sourceSha256": "040ec09a494568481fd2a4853a4dd8183c7950152dc9674d0a458a323f8bdf21",
"imageSha256": "c53db2e5c642a5549c13f3c93a5b359fed69bdb2621bf71a243a877ffcb95e6b"
},
"room-contour-close": {
@@ -46,7 +46,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "cbfb54d4fcba73572180857de759b4def182c2d4487a9996d70b7aa7d8bf91d9",
"sourceSha256": "040ec09a494568481fd2a4853a4dd8183c7950152dc9674d0a458a323f8bdf21",
"imageSha256": "2f13ea645af306eee9cc7c52699c928377b72f412b08c1a3db6beb848ace1b51"
},
"plan-context-tray": {
@@ -57,7 +57,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "cbfb54d4fcba73572180857de759b4def182c2d4487a9996d70b7aa7d8bf91d9",
"sourceSha256": "040ec09a494568481fd2a4853a4dd8183c7950152dc9674d0a458a323f8bdf21",
"imageSha256": "a6c526fede11bc3503fd2384bd4a3f6afa008f481c5c47578f06cae6c81c6f9b"
},
"device-editor": {
@@ -68,8 +68,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "cbfb54d4fcba73572180857de759b4def182c2d4487a9996d70b7aa7d8bf91d9",
"imageSha256": "ba778862a50661784ec4ff28bb299d112701d77c1b5b6e8533535073c42d530e"
"sourceSha256": "040ec09a494568481fd2a4853a4dd8183c7950152dc9674d0a458a323f8bdf21",
"imageSha256": "bfba4647c04bdf4b46dc9cea3b42e349b3ea67007bb38e16880983dbee14653e"
},
"device-display-preview": {
"file": "06-device-display-preview.png",
@@ -79,8 +79,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "cbfb54d4fcba73572180857de759b4def182c2d4487a9996d70b7aa7d8bf91d9",
"imageSha256": "a51f030125e08ce596e8a7339d471f587d5c45a2f27bbd1ff008d6516d48caed"
"sourceSha256": "040ec09a494568481fd2a4853a4dd8183c7950152dc9674d0a458a323f8bdf21",
"imageSha256": "8cdfe00ead5558d013265617104997fdd138337322aa82f6a3e1d15eeb36cf5e"
},
"background-editor": {
"file": "07-background-editor.png",
@@ -90,7 +90,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "cbfb54d4fcba73572180857de759b4def182c2d4487a9996d70b7aa7d8bf91d9",
"sourceSha256": "040ec09a494568481fd2a4853a4dd8183c7950152dc9674d0a458a323f8bdf21",
"imageSha256": "d7cfe70551d9260169df8efd832e32ed7df99c7f60b55d1fd4a8322bd4c47175"
},
"room-card": {
@@ -101,8 +101,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "cbfb54d4fcba73572180857de759b4def182c2d4487a9996d70b7aa7d8bf91d9",
"imageSha256": "c77649bf0ab595be3c7a86fa38d96b94a02823ee44367ca3229c713f282639e4"
"sourceSha256": "040ec09a494568481fd2a4853a4dd8183c7950152dc9674d0a458a323f8bdf21",
"imageSha256": "ab29f6fdb31d3117e935a93a237105d42a5103687ac892ef426a4887ae7960c4"
},
"device-info": {
"file": "09-device-info.png",
@@ -112,8 +112,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "cbfb54d4fcba73572180857de759b4def182c2d4487a9996d70b7aa7d8bf91d9",
"imageSha256": "efe0dfa845e1f6b0a870ce861e7ed92cd65166a4d144b7c8ddc6ed0f71b12b2f"
"sourceSha256": "040ec09a494568481fd2a4853a4dd8183c7950152dc9674d0a458a323f8bdf21",
"imageSha256": "30bf3f2a1311fc2bffeac6359773925fea22150df2845015c630e009016f095f"
}
}
}
+3
View File
@@ -547,3 +547,6 @@ radius, feedback и hover path. Данных, backend migration и rollback stor
`cubic-bezier(.22,.61,.36,1)`; exact easing не является config surface.
7. Reduced-motion accent использует существующий theme/focus token и один
painted-frame owner, не новую пользовательскую строку.
8. Формулировка #22 `plan-wide setting` означает одну общую политику feedback
для всех actionable markers плана, а не новую пользовательскую настройку
либо per-marker override.
+48
View File
@@ -572,6 +572,54 @@ export const MUTANTS = [
replace: ' if (d.primary) this._openMoreInfo(d.primary);',
}],
},
{
id: 'device-visual-factor-removed',
guard: 'node --test --test-name-pattern="issue 212 applies one visual" '
+ 'test/device-marker-polish-contract.test.mjs',
because: 'the owner-requested 10% correction must stay in the shared marker geometry '
+ 'without changing stored icon_size or the interaction target',
patches: [{
file: 'src/styles.ts',
find: ' --device-visual-factor: 0.9;',
replace: ' --device-visual-factor: 1;',
}],
},
{
id: 'device-text-capsule-radius-restored',
guard: 'node --test --test-name-pattern="issue 212 Text value" '
+ 'test/device-marker-polish-contract.test.mjs',
because: 'a wide Text core must keep a radius based on its height instead of reverting '
+ 'to the elliptical 50% rule',
patches: [{
file: 'src/styles.ts',
find: ' border-radius: calc(var(--dev-size, var(--icon-size, 2.5cqw)) / 2);',
replace: ' border-radius: 50%;',
}],
},
{
id: 'device-press-duration-drifted',
guard: 'node --test --test-name-pattern="issue 212 feedback" '
+ 'test/device-marker-polish-contract.test.mjs',
because: 'accepted device actions must retain the owner-specified bounded 200 ms feedback '
+ 'instead of silently drifting with unrelated animation timings',
patches: [{
file: 'src/houseplan-card.ts',
find: ' duration: 200,',
replace: ' duration: 350,',
}],
},
{
id: 'device-touch-hover-gate-removed',
guard: 'node --test --test-name-pattern="issue 212 removes the global touch latch" '
+ 'test/device-marker-polish-contract.test.mjs',
because: 'touch must not leave a browser-matched device hover painted after the JS tooltip '
+ 'has already been cleared',
patches: [{
file: 'src/styles.ts',
find: ' :host([data-pointer-hover]) .dev:not(.unavail):hover {',
replace: ' .dev:not(.unavail):hover {',
}],
},
];
// --- механика ---------------------------------------------------------------
+186 -44
View File
@@ -192,6 +192,7 @@ import {
visualFrameFingerprint,
type PageVisibilitySignal,
} from './visual-continuity';
import { PointerModalityController } from './pointer-modality';
import {
entityVisualSample, entityVisualSamplesForDevice,
type DeviceActivity, type DeviceVisualState, type EntityVisualSample,
@@ -729,6 +730,12 @@ class HouseplanCard extends LitElement {
/** Room whose physical perimeter is highlighted in View. The explicit
* overlay is needed because thick wall bodies paint above room shapes. */
private _hoverRoom: { space: string; room: RoomCfg } | null = null;
private readonly _pointerModality = new PointerModalityController(
this,
() => this._syncPointerHoverTargets(),
);
private _pointerHoverObserver?: MutationObserver;
private _devicePressAnimations = new Map<string, Animation>();
private _selId: string | null = null;
private _toast = '';
private _toastTimer?: number;
@@ -860,6 +867,7 @@ class HouseplanCard extends LitElement {
private _motionMedia?: MediaQueryList;
private _onMotionChange = (event: MediaQueryListEvent): void => {
this._reducedMotion = event.matches;
this._cancelDevicePressFeedback();
// A preference change to reduced motion is authoritative immediately: do
// not leave a 220 ms camera/chrome tween running until its old deadline.
if (event.matches && this._modeTransitionPreparing) {
@@ -1181,6 +1189,10 @@ class HouseplanCard extends LitElement {
private _commitSpace(id: string, authority = false): boolean {
if (!this._canCommitSpace(id, authority)) return false;
if (id !== this._space) {
this._clearTransientHover(true);
this._cancelDevicePressFeedback();
}
this._space = id;
return true;
}
@@ -1755,6 +1767,8 @@ class HouseplanCard extends LitElement {
this._continuity.visibility(signal);
this._dayCycleVisibility(signal);
if (signal.kind === 'hidden') {
this._clearTransientHover(true);
this._cancelDevicePressFeedback();
if (this._modeTransitionPreparing) {
// There is no measured controller endpoint to commit yet. Keep the
// already queued measurement but force its synchronous atomic path;
@@ -1994,6 +2008,16 @@ class HouseplanCard extends LitElement {
this._continuityUnsub?.();
this._continuityUnsub = subscribePageVisibility(this.ownerDocument, this._pageVisibility);
super.connectedCallback();
this._pointerModality.connect(this.ownerDocument.defaultView);
const PointerHoverObserver = this.ownerDocument.defaultView?.MutationObserver;
if (PointerHoverObserver) {
this._pointerHoverObserver = new PointerHoverObserver((records) => {
for (const record of records) {
for (const node of record.addedNodes) this._syncPointerHoverSubtree(node);
}
});
this._pointerHoverObserver.observe(this.renderRoot, { childList: true, subtree: true });
}
this._motionMedia = window.matchMedia?.('(prefers-reduced-motion: reduce)');
this._reducedMotion = !!this._motionMedia?.matches;
this._motionMedia?.addEventListener?.('change', this._onMotionChange);
@@ -2164,6 +2188,11 @@ class HouseplanCard extends LitElement {
this._touchContacts.clear();
this._touchSequenceMultitouch = false;
this._touchClickBlockUntil = 0;
this._clearTransientHover(true);
this._cancelDevicePressFeedback();
this._pointerHoverObserver?.disconnect();
this._pointerHoverObserver = undefined;
this._pointerModality.disconnect();
this._editorSecondary.reset();
// R1: the rAF is the only normal owner that clears this flag. Reset only
// after the disconnect snapshot (which must still skip unstable geometry),
@@ -3388,6 +3417,7 @@ class HouseplanCard extends LitElement {
}
protected updated(): void {
this._pruneDevicePressFeedback();
this._syncDayCycleClock();
this._warmSnapshot(); // DEV-B703-03: the memo follows what is on screen
this._dtMeasure(); // the selected label's frame follows the glyphs
@@ -4639,6 +4669,7 @@ class HouseplanCard extends LitElement {
const operation = toggleOperation(intent);
if (!operation) return;
if (operation.kind === 'virtual-light') {
this._startDevicePressFeedback(actionDevice.id);
this.hass.callWS({
type: 'houseplan/virtual_light/toggle',
marker_id: operation.markerId,
@@ -4655,6 +4686,7 @@ class HouseplanCard extends LitElement {
return;
}
const { command } = operation;
this._startDevicePressFeedback(actionDevice.id);
this.hass.callService(command.domain, command.service, command.data)
.catch((e: any) => this._showToast(this._t('toast.error', { err: this._errText(e) })));
};
@@ -4704,6 +4736,7 @@ class HouseplanCard extends LitElement {
const name = st.attributes?.friendly_name || target;
guarded(this._t('confirm.tap_run', { name }), () => {
if (!this._deviceBindingActive(actionDevice) || !this._planEntityAvailable(target)) return;
this._startDevicePressFeedback(actionDevice.id);
this.hass
.callService(svc.domain, svc.service, { entity_id: target })
.then(() => {
@@ -4731,6 +4764,65 @@ class HouseplanCard extends LitElement {
this._clickDevice(ev, d);
}
/** One retargetable visual acknowledgement owned by actual action dispatch. */
private _startDevicePressFeedback(markerId: string): void {
const marker = [...this.renderRoot.querySelectorAll<HTMLElement>('.dev[data-id]')]
.find((candidate) => candidate.dataset.id === markerId);
const body = marker?.querySelector<HTMLElement>('.device-shell');
if (!body || typeof body.animate !== 'function') return;
const paintedScale = Number.parseFloat(
this.ownerDocument.defaultView?.getComputedStyle(body).scale || '',
);
const restartScale = Number.isFinite(paintedScale)
? Math.max(0.95, Math.min(1, paintedScale))
: 1;
this._devicePressAnimations.get(markerId)?.cancel();
const frames: Keyframe[] = this._reducedMotion
? [
{ outlineColor: 'transparent', outlineStyle: 'solid', outlineWidth: '0px' },
{
outlineColor: 'var(--hp-accent, #3ea6ff)', outlineStyle: 'solid',
outlineWidth: '2px', outlineOffset: '2px', offset: 0.5,
},
{ outlineColor: 'transparent', outlineStyle: 'solid', outlineWidth: '0px' },
]
: [
{ scale: String(restartScale) },
{ scale: '0.95', offset: 0.5 },
{ scale: '1' },
];
const animation = body.animate(frames, {
duration: 200,
easing: 'cubic-bezier(.22,.61,.36,1)',
});
this._devicePressAnimations.set(markerId, animation);
const clear = (): void => {
if (this._devicePressAnimations.get(markerId) === animation) {
this._devicePressAnimations.delete(markerId);
}
};
animation.addEventListener('finish', clear, { once: true });
animation.addEventListener('cancel', clear, { once: true });
}
private _cancelDevicePressFeedback(): void {
for (const animation of this._devicePressAnimations.values()) animation.cancel();
this._devicePressAnimations.clear();
}
private _pruneDevicePressFeedback(): void {
if (!this._devicePressAnimations.size) return;
const visible = new Set(
[...this.renderRoot.querySelectorAll<HTMLElement>('.dev[data-id]')]
.map((marker) => marker.dataset.id || ''),
);
for (const [markerId, animation] of this._devicePressAnimations) {
if (visible.has(markerId)) continue;
animation.cancel();
this._devicePressAnimations.delete(markerId);
}
}
/** Translate a key in the card's current language. */
private _t(key: I18nKey, vars?: Record<string, string | number>): string {
return t(langOf(this.hass, this._config?.language), key, vars);
@@ -5753,31 +5845,45 @@ class HouseplanCard extends LitElement {
}, 3500);
}
/**
* Touch-first surface: no hover tooltips.
*
* The media query alone was not enough (field report, 2026-07-27: tooltips
* still stuck on a OnePlus). Some devices/skins report `hover: hover`, and a
* stylus or a paired mouse flips it too. So this also latches on the FIRST
* touch pointer event and never unlatches for that session — a device that
* has been touched once is a touch device.
*/
private static _touchSeen = false;
private static readonly _noHoverMq =
typeof window !== 'undefined' &&
typeof window.matchMedia === 'function' &&
window.matchMedia('(hover: none)').matches;
private get _noHover(): boolean {
return HouseplanCard._noHoverMq || HouseplanCard._touchSeen;
/** Publish the instance-local input authority to nested shared controls. */
private _syncPointerHoverTargets(): void {
const enabled = this._pointerModality.hoverEnabled;
for (const target of this.renderRoot.querySelectorAll<HTMLElement>(
'hp-dialog, hp-help, hp-color-opacity',
)) {
target.toggleAttribute('data-pointer-hover', enabled);
}
}
/** Any touch anywhere marks the session as touch-first and kills open tips. */
private _syncPointerHoverSubtree(node: Node): void {
if (node.nodeType !== Node.ELEMENT_NODE) return;
const element = node as HTMLElement;
const enabled = this._pointerModality.hoverEnabled;
if (element.matches('hp-dialog, hp-help, hp-color-opacity')) {
element.toggleAttribute('data-pointer-hover', enabled);
}
for (const target of element.querySelectorAll<HTMLElement>(
'hp-dialog, hp-help, hp-color-opacity',
)) {
target.toggleAttribute('data-pointer-hover', enabled);
}
}
/** Remove visual state that can only be owned by a live mouse hover. */
private _clearTransientHover(suspend = false): void {
if (suspend) this._pointerModality.suspend();
if (this._tip) this._tip = null;
if (this._hoverRoom) this._hoverRoom = null;
}
/** The latest real pointer event, not a page-global first-touch latch, owns hover. */
private _notePointer(ev: PointerEvent): void {
this._boundaryPointerType = ev.pointerType || 'mouse';
if (ev.pointerType === 'touch' || ev.pointerType === 'pen') {
HouseplanCard._touchSeen = true;
if (this._tip) this._tip = null;
const previous = this._pointerModality.modality;
const modality = this._pointerModality.note(ev);
if ((modality === 'touch' || modality === 'pen')
&& (previous !== modality || !!this._tip || !!this._hoverRoom)) {
this._clearTransientHover();
}
}
@@ -5800,8 +5906,8 @@ class HouseplanCard extends LitElement {
return;
}
const pointer = ev as PointerEvent;
if (pointer.pointerType !== 'touch') return;
this._notePointer(pointer);
if (pointer.pointerType !== 'touch') return;
if (ev.type === 'pointerdown') {
this._touchContacts.set(pointer.pointerId, {
x: pointer.clientX,
@@ -5809,6 +5915,7 @@ class HouseplanCard extends LitElement {
inStage: !!(pointer.target as Element | null)?.closest?.('.stage'),
});
if (this._touchContacts.size >= 2) {
this._clearTransientHover();
this._touchSequenceMultitouch = true;
this._touchClickBlockUntil = Number.POSITIVE_INFINITY;
clearTimeout(this._holdTimer);
@@ -5855,7 +5962,9 @@ class HouseplanCard extends LitElement {
}
return;
}
if (ev.type !== 'pointerup' && ev.type !== 'pointercancel') return;
if (ev.type !== 'pointerup' && ev.type !== 'pointercancel'
&& ev.type !== 'lostpointercapture') return;
this._clearTransientHover();
this._touchContacts.delete(pointer.pointerId);
if (this._touchSequenceMultitouch) {
this._touchClickBlockUntil = Date.now() + 500;
@@ -5875,14 +5984,15 @@ class HouseplanCard extends LitElement {
}
private _showTip(
ev: MouseEvent,
ev: PointerEvent,
title: string,
meta: string,
lqi?: number | null,
temp?: number | null,
hum?: number | null,
): void {
if (this._noHover) return;
this._notePointer(ev);
if (!this._pointerModality.hoverEnabled) return;
if (this._drag) return;
this._tip = { x: ev.clientX, y: ev.clientY, title, meta, lqi, temp, hum };
}
@@ -6058,6 +6168,8 @@ class HouseplanCard extends LitElement {
}
if (this._wallFaceBatch) this._roomDialogCancel();
if (this._mode === 'plan' && this._tool === 'draw' && !this._finishWallChain()) return;
this._clearTransientHover(true);
this._cancelDevicePressFeedback();
const previousMode = this._mode;
const previousProjection = this._effectiveProjection();
const capturedVisual = this._currentModeVisual(previousMode);
@@ -15585,10 +15697,12 @@ class HouseplanCard extends LitElement {
data-ha-registry-access=${diagnostics.registry.access}
data-ha-disabled-bindings=${diagnostics.bindings.ha_disabled}
data-ha-unverified-bindings=${diagnostics.bindings.unverified}
@pointerover=${(event: PointerEvent) => this._notePointer(event)}
@pointerdown=${this._touchGestureGuard}
@pointermove=${this._touchGestureGuard}
@pointerup=${this._touchGestureGuard}
@pointercancel=${this._touchGestureGuard}
@lostpointercapture=${this._touchGestureGuard}
@click=${this._touchGestureGuard}>
<div class="hdr ${this._kiosk ? 'kioskhide' : ''}">
<div class="head">
@@ -15789,7 +15903,7 @@ class HouseplanCard extends LitElement {
style = st.join(';');
}
let areaText: string | null | undefined;
const tip = (e: MouseEvent) => {
const tip = (e: PointerEvent) => {
if (this._mode !== 'view') return;
if (areaText === undefined) areaText = this._roomArea(r);
this._showTip(
@@ -15834,34 +15948,59 @@ class HouseplanCard extends LitElement {
? svg`<path class="${cls}" style="${style}" fill-rule="evenodd"
data-hp="room" data-id=${hpId} data-area=${hpArea}
d="${[obstaclePath, ...holes.map(pathD)].join(' ')}"
@mouseenter=${() => (this._hoverRoom = { space: space.id, room: r })}
@mousemove=${tip}
@mouseleave=${() => { this._tip = null; this._hoverRoom = null; }}></path>`
@pointerenter=${(event: PointerEvent) => {
this._notePointer(event);
if (this._pointerModality.hoverEnabled) {
this._hoverRoom = { space: space.id, room: r };
}
}}
@pointermove=${tip}
@pointerleave=${() => this._clearTransientHover()}></path>`
: holes.length && fillPoly
? svg`<path class="${cls}" style="${style}" fill-rule="evenodd"
data-hp="room" data-id=${hpId} data-area=${hpArea}
d="${[fillPoly, ...holes].map(pathD).join(' ')}"
@mouseenter=${() => (this._hoverRoom = { space: space.id, room: r })}
@mousemove=${tip}
@mouseleave=${() => { this._tip = null; this._hoverRoom = null; }}></path>`
@pointerenter=${(event: PointerEvent) => {
this._notePointer(event);
if (this._pointerModality.hoverEnabled) {
this._hoverRoom = { space: space.id, room: r };
}
}}
@pointermove=${tip}
@pointerleave=${() => this._clearTransientHover()}></path>`
: fillPoly && fillPoly !== myPoly
? svg`<polygon class="${cls}" style="${style}" points="${fillPoly.map((p) => p.join(',')).join(' ')}"
data-hp="room" data-id=${hpId} data-area=${hpArea}
@mouseenter=${() => (this._hoverRoom = { space: space.id, room: r })}
@mousemove=${tip}
@mouseleave=${() => { this._tip = null; this._hoverRoom = null; }}></polygon>`
@pointerenter=${(event: PointerEvent) => {
this._notePointer(event);
if (this._pointerModality.hoverEnabled) {
this._hoverRoom = { space: space.id, room: r };
}
}}
@pointermove=${tip}
@pointerleave=${() => this._clearTransientHover()}></polygon>`
: r.poly
? svg`<polygon class="${cls}" style="${style}" points="${r.poly.map((p) => p.join(',')).join(' ')}"
data-hp="room" data-id=${hpId} data-area=${hpArea}
@mouseenter=${() => (this._hoverRoom = { space: space.id, room: r })}
@mousemove=${tip}
@mouseleave=${() => { this._tip = null; this._hoverRoom = null; }}></polygon>`
@pointerenter=${(event: PointerEvent) => {
this._notePointer(event);
if (this._pointerModality.hoverEnabled) {
this._hoverRoom = { space: space.id, room: r };
}
}}
@pointermove=${tip}
@pointerleave=${() => this._clearTransientHover()}></polygon>`
: svg`<rect class="${cls}" style="${style}"
data-hp="room" data-id=${hpId} data-area=${hpArea}
x="${r.x}" y="${r.y}" width="${r.w}" height="${r.h}" rx="${Math.min(r.w!, r.h!) * 0.03}"
@mouseenter=${() => (this._hoverRoom = { space: space.id, room: r })}
@mousemove=${tip}
@mouseleave=${() => { this._tip = null; this._hoverRoom = null; }}></rect>`;
@pointerenter=${(event: PointerEvent) => {
this._notePointer(event);
if (this._pointerModality.hoverEnabled) {
this._hoverRoom = { space: space.id, room: r };
}
}}
@pointermove=${tip}
@pointerleave=${() => this._clearTransientHover()}></rect>`;
const trimmed = edgeCuts.length && myPoly
? outlineWithout(myPoly, edgeCuts, this._gridPitch * 0.02)
: null;
@@ -16911,11 +17050,14 @@ class HouseplanCard extends LitElement {
@click=${(e: MouseEvent) => this._clickDevice(e, d)}
@keydown=${(e: KeyboardEvent) => this._keyDevice(e, d)}
@contextmenu=${(e: MouseEvent) => this._ctxDevice(e, d)}
@mousemove=${(e: MouseEvent) =>
@pointerover=${(e: PointerEvent) =>
this._showTip(e, d.name, presentation.haDisabled ? ghostLabel : metrics)}
@mouseleave=${() => (this._tip = null)}
@pointerleave=${() => this._clearTransientHover()}
@pointerdown=${(e: PointerEvent) => this._pointerDown(e, d)}
@pointermove=${(e: PointerEvent) => this._pointerMove(e, d)}
@pointermove=${(e: PointerEvent) => {
this._pointerMove(e, d);
this._showTip(e, d.name, presentation.haDisabled ? ghostLabel : metrics);
}}
@pointerup=${(e: PointerEvent) => this._pointerUp(e, d)}
@pointercancel=${(e: PointerEvent) => this._pointerUp(e, d)}
>
+1 -1
View File
@@ -96,7 +96,7 @@ export class HpColorOpacity extends LitElement {
linear-gradient(45deg, #b8b8b8 25%, #eee 25%) 5px 5px / 10px 10px;
cursor: pointer;
}
.trigger:hover {
:host([data-pointer-hover]) .trigger:hover {
border-color: var(--primary-color, #03a9f4);
}
.trigger[aria-expanded='true'] {
+1 -1
View File
@@ -354,7 +354,7 @@ class HpDevicePreview extends LitElement {
}
.previewfit { position: absolute; left: 50%; top: 50%; transform-origin: center; }
.previewstage .dev { left: 0; top: 0; cursor: default; }
.previewstage .dev:hover { z-index: 2; }
:host([data-pointer-hover]) .previewstage .dev:hover { z-index: 2; }
.previewfacts { min-width: 0; display: grid; align-content: start; gap: 8px; }
.previewfacts > div { display: grid; grid-template-columns: minmax(115px, 0.8fr) minmax(0, 1.3fr); gap: 8px; }
.previewfacts span { color: var(--secondary-text-color, #9aa0aa); }
+1 -1
View File
@@ -187,7 +187,7 @@ export class HpDialog extends LitElement {
cursor: pointer;
}
.close:hover,
:host([data-pointer-hover]) .close:hover,
.close:focus-visible {
background: rgb(127 127 127 / 0.16);
}
+22 -5
View File
@@ -18,6 +18,7 @@ export class HpHelp extends LitElement {
static properties = {
text: { type: String },
ariaLabel: { type: String, attribute: 'aria-label' },
pointerHover: { type: Boolean, attribute: 'data-pointer-hover', reflect: true },
_open: { state: true },
_forceFallback: { state: true },
};
@@ -57,7 +58,7 @@ export class HpHelp extends LitElement {
fill: currentColor;
}
.trigger:hover,
:host([data-pointer-hover]) .trigger:hover,
.trigger:focus-visible,
.trigger[aria-expanded='true'] {
color: var(--primary-text-color, #fff);
@@ -150,12 +151,14 @@ export class HpHelp extends LitElement {
text = '';
ariaLabel = '';
pointerHover = false;
private _open = false;
private _openTimer = 0;
private _closeTimer = 0;
private _positionRaf = 0;
private _forceFallback = false;
private _hoverOwned = false;
private _overlayDispose: (() => void) | null = null;
private _scrollDialog: HpDialog | null = null;
private readonly _descriptionId = `hp-help-description-${++helpSequence}`;
@@ -181,6 +184,12 @@ export class HpHelp extends LitElement {
}
protected updated(changed: PropertyValues): void {
if (changed.has('pointerHover') && !this.pointerHover) {
const win = this._window();
if (this._openTimer) win?.clearTimeout(this._openTimer);
this._openTimer = 0;
if (this._hoverOwned) this._closeHelp(false, 'outside');
}
if (!this._hasContent() && this._open) {
this._closeHelp();
return;
@@ -217,12 +226,13 @@ export class HpHelp extends LitElement {
private _scheduleOpen(): void {
const win = this._window();
if (!win || this._open || this._openTimer) return;
if (!this.pointerHover || !win || this._open || this._openTimer) return;
if (this._closeTimer) win.clearTimeout(this._closeTimer);
this._closeTimer = 0;
this._openTimer = win.setTimeout(() => {
this._openTimer = 0;
if (!this.isConnected) return;
this._hoverOwned = true;
void this._openHelp();
}, 300);
}
@@ -242,7 +252,7 @@ export class HpHelp extends LitElement {
}
private _triggerPointerEnter(event: PointerEvent): void {
if (event.pointerType === 'mouse') this._scheduleOpen();
if (this.pointerHover && event.pointerType === 'mouse') this._scheduleOpen();
}
private _triggerPointerLeave(event: PointerEvent): void {
@@ -262,7 +272,10 @@ export class HpHelp extends LitElement {
private _triggerFocus(): void {
queueMicrotask(() => {
const trigger = this.renderRoot.querySelector<HTMLButtonElement>('.trigger');
if (trigger?.matches(':focus-visible')) void this._openHelp();
if (trigger?.matches(':focus-visible')) {
this._hoverOwned = false;
void this._openHelp();
}
});
}
@@ -272,7 +285,10 @@ export class HpHelp extends LitElement {
private _triggerClick(): void {
if (this._open) this._closeHelp();
else void this._openHelp();
else {
this._hoverOwned = false;
void this._openHelp();
}
}
private _outsidePointerDown = (event: PointerEvent): void => {
@@ -381,6 +397,7 @@ export class HpHelp extends LitElement {
}
}
this._open = false;
this._hoverOwned = false;
this._unsubscribeOpenListeners();
this._floating.destroy();
if (refocus) {
+101
View File
@@ -0,0 +1,101 @@
/** Instance-local pointer modality and CSS-hover gate. */
export type PointerModality = 'unknown' | 'mouse' | 'touch' | 'pen';
export const POINTER_HOVER_QUERY = '(any-hover: hover) and (any-pointer: fine)';
type PointerLike = {
pointerType?: string;
sourceCapabilities?: { firesTouchEvents?: boolean } | null;
};
export function pointerModalityOf(pointerType: unknown): PointerModality | null {
if (pointerType === 'mouse' || pointerType === 'touch' || pointerType === 'pen') {
return pointerType;
}
return null;
}
/** Compatibility input derived from touch must never re-enable mouse hover. */
export function nextPointerModality(
current: PointerModality,
event: PointerLike,
): PointerModality {
const next = pointerModalityOf(event.pointerType);
if (!next) return current;
if (next === 'mouse' && event.sourceCapabilities?.firesTouchEvents) return current;
return next;
}
export function pointerHoverAllowed(modality: PointerModality, hoverCapable: boolean): boolean {
return modality === 'mouse' && hoverCapable;
}
type HoverHost = Pick<Element, 'toggleAttribute'>;
/**
* Keeps one card instance's input authority out of Lit render state. Pointer
* move may be frequent; the host attribute changes only when the gate changes.
*/
export class PointerModalityController {
private _modality: PointerModality = 'unknown';
private _gate = false;
private _media: MediaQueryList | null = null;
private _connected = false;
constructor(
private readonly _host: HoverHost,
private readonly _onGateChange: (enabled: boolean) => void = () => undefined,
) {}
get modality(): PointerModality {
return this._modality;
}
get hoverEnabled(): boolean {
return this._gate;
}
connect(win: Window | null | undefined = globalThis.window): void {
if (this._connected) return;
this._connected = true;
this._modality = 'unknown';
this._media = typeof win?.matchMedia === 'function'
? win.matchMedia(POINTER_HOVER_QUERY)
: null;
this._media?.addEventListener?.('change', this._onMediaChange);
this._setGate(false);
}
disconnect(): void {
this._media?.removeEventListener?.('change', this._onMediaChange);
this._media = null;
this._connected = false;
this._modality = 'unknown';
this._setGate(false);
}
note(event: PointerLike): PointerModality {
this._modality = nextPointerModality(this._modality, event);
this._syncGate();
return this._modality;
}
/** Disable stale CSS hover until the next real pointer event revalidates it. */
suspend(): void {
this._setGate(false);
}
private readonly _onMediaChange = (): void => this._syncGate();
private _syncGate(): void {
this._setGate(pointerHoverAllowed(this._modality, !!this._media?.matches));
}
private _setGate(enabled: boolean): void {
if (this._gate === enabled) return;
this._gate = enabled;
this._host.toggleAttribute('data-pointer-hover', enabled);
this._onGateChange(enabled);
}
}
+13 -3
View File
@@ -35,6 +35,7 @@ import {
import {
bgModeOf, resolveDayCycle, dayCycleFingerprint, type DayCycleState,
} from './sun';
import { PointerModalityController } from './pointer-modality';
import './space-editor';
const fireEvent = (node: EventTarget, type: string, detail?: unknown) => {
@@ -101,6 +102,7 @@ class HouseplanSpaceCard extends LitElement {
private _capturedSnapshotVirtual = '';
private _activityRuntime = new Map<string, FiniteActivityRuntime>();
private _reducedMotion = false;
private readonly _pointerModality = new PointerModalityController(this);
private _motionMedia?: MediaQueryList;
private _onMotionChange = (event: MediaQueryListEvent): void => {
this._reducedMotion = event.matches;
@@ -187,7 +189,10 @@ class HouseplanSpaceCard extends LitElement {
private _pageVisibility = (signal: PageVisibilitySignal): void => {
this._continuity.visibility(signal);
this._dayCycleVisibility(signal);
if (signal.kind === 'hidden') return;
if (signal.kind === 'hidden') {
this._pointerModality.suspend();
return;
}
if (!signal.long) {
const now = Date.now();
let expired = false;
@@ -274,6 +279,7 @@ class HouseplanSpaceCard extends LitElement {
this._continuityPaintToken = -1;
}
super.connectedCallback();
this._pointerModality.connect(this.ownerDocument.defaultView);
this._motionMedia = window.matchMedia?.('(prefers-reduced-motion: reduce)');
this._reducedMotion = !!this._motionMedia?.matches;
this._motionMedia?.addEventListener?.('change', this._onMotionChange);
@@ -290,6 +296,7 @@ class HouseplanSpaceCard extends LitElement {
}
public disconnectedCallback(): void {
this._pointerModality.disconnect();
this._continuityUnsub?.();
this._continuityUnsub = undefined;
this._motionMedia?.removeEventListener?.('change', this._onMotionChange);
@@ -806,7 +813,10 @@ class HouseplanSpaceCard extends LitElement {
data-continuity-token=${this._continuity.token}
data-frame-fingerprint=${this._continuity.frameFingerprint || nothing}
data-device-snapshot-sequence=${deviceSnapshot?.sourceSequence ?? nothing}
data-recovery-reason=${recoveryReason || nothing}>
data-recovery-reason=${recoveryReason || nothing}
@pointerover=${(event: PointerEvent) => this._pointerModality.note(event)}
@pointerdown=${(event: PointerEvent) => this._pointerModality.note(event)}
@pointermove=${(event: PointerEvent) => this._pointerModality.note(event)}>
${title ? html`<div class="hp-static-title">${title}</div>` : nothing}
<div class="hp-static-body">
${stage}
@@ -900,7 +910,7 @@ class HouseplanSpaceCard extends LitElement {
font-weight: 600;
cursor: pointer;
}
.hp-static-btn:hover {
:host([data-pointer-hover]) .hp-static-btn:hover {
filter: brightness(1.08);
}
.hp-static-error {
+45 -31
View File
@@ -10,6 +10,7 @@ export const cardStyles = css`
--hp-accent: var(--primary-color, #3ea6ff);
--hp-on: #ffd45c;
--hp-open: #ff9f43;
--device-visual-factor: 0.9;
/* design tokens (UI chrome only). The icon/plan scale math stays on
--icon-size/--dev-size cqw units and never uses these. */
/* spacing scale, fact-based; stray 3/5/7/9/13/14px values are unified
@@ -126,7 +127,7 @@ export const cardStyles = css`
transition: 0.15s;
font-family: inherit;
}
.tab:hover {
:host([data-pointer-hover]) .tab:hover {
color: var(--hp-txt);
}
.tab.active {
@@ -157,7 +158,7 @@ export const cardStyles = css`
.btn ha-icon {
--mdc-icon-size: 17px;
}
.btn:hover {
:host([data-pointer-hover]) .btn:hover {
border-color: var(--hp-accent);
}
.btn.on {
@@ -639,7 +640,7 @@ export const cardStyles = css`
stroke: transparent;
stroke-width: 2;
}
.stage.mode-view .room.overlay:not(.styled):hover {
:host([data-pointer-hover]) .stage.mode-view .room.overlay:not(.styled):hover {
stroke: var(--hp-accent);
stroke-opacity: 1;
}
@@ -648,7 +649,7 @@ export const cardStyles = css`
stroke: #4b8c5a;
stroke-width: 2;
}
.stage.mode-view .room.yard:not(.styled):hover {
:host([data-pointer-hover]) .stage.mode-view .room.yard:not(.styled):hover {
stroke: var(--hp-accent);
stroke-opacity: 1;
}
@@ -696,7 +697,7 @@ export const cardStyles = css`
/* The explicit late room-hover layer owns the wash and halo. Keeping CSS
filters off room paths prevents Chromium from recompositing the sibling
screen-blended Glow layer for one bright frame on every hover. */
.stage.mode-view .room.styled:hover {
:host([data-pointer-hover]) .stage.mode-view .room.styled:hover {
stroke: var(--hp-accent);
stroke-opacity: 1;
}
@@ -717,7 +718,7 @@ export const cardStyles = css`
transition: opacity 0.15s;
pointer-events: none;
}
.stage.markup g.opening:hover .op-outline {
:host([data-pointer-hover]) .stage.markup g.opening:hover .op-outline {
opacity: 0.9;
}
/* openings are pure status graphics outside Plan mode: no cursor, no hover,
@@ -958,7 +959,7 @@ export const cardStyles = css`
z-index: 2;
}
.rlgearbtn { transition: opacity 0.15s, filter 0.15s; }
.rlgearbtn:hover { opacity: 1; filter: brightness(1.18); }
:host([data-pointer-hover]) .rlgearbtn:hover { opacity: 1; filter: brightness(1.18); }
.rlgearbtn ha-icon { --mdc-icon-size: calc(var(--gear-h) * 0.55); display: inline-flex; }
.rlgear {
--mdc-icon-size: 0.9em;
@@ -968,7 +969,7 @@ export const cardStyles = css`
cursor: pointer;
pointer-events: auto;
}
.rlgear:hover { opacity: 1; }
:host([data-pointer-hover]) .rlgear:hover { opacity: 1; }
.rlgo {
--mdc-icon-size: 0.85em;
display: inline-flex;
@@ -978,7 +979,7 @@ export const cardStyles = css`
pointer-events: auto;
cursor: pointer;
}
.stage.mode-view .rlgo:hover { opacity: 1; }
:host([data-pointer-hover]) .stage.mode-view .rlgo:hover { opacity: 1; }
.roomlabel .rlmetrics {
position: absolute; /* below the name, outside the centring math */
top: calc(100% + 0.15em);
@@ -1049,7 +1050,9 @@ export const cardStyles = css`
border: 0; background: transparent; color: var(--hp-txt);
padding: var(--sp-3) var(--sp-4); border-radius: var(--rad-s); cursor: pointer; font-family: inherit; font-size: var(--fs-m);
}
.ctrlopt:hover { background: var(--secondary-background-color, rgba(128,128,128,0.15)); }
:host([data-pointer-hover]) .ctrlopt:hover {
background: var(--secondary-background-color, rgba(128,128,128,0.15));
}
.ctrlopt .sub { color: var(--hp-muted); font-size: var(--fs-s); margin-left: auto; }
.ctrlopt ha-icon { --mdc-icon-size: 16px; }
.ctrlstates { display: flex; flex-direction: column; gap: var(--sp-2); }
@@ -1099,7 +1102,7 @@ export const cardStyles = css`
.rlhandle.br { right: -6px; bottom: -6px; cursor: nwse-resize; }
.rlhandle.tr { right: -6px; top: -6px; cursor: nesw-resize; }
.rlhandle.bl { left: -6px; bottom: -6px; cursor: nesw-resize; }
.stage.markup .roomlabel:hover .rlhandle { display: block; }
:host([data-pointer-hover]) .stage.markup .roomlabel:hover .rlhandle { display: block; }
.stage.markup .roomlabel { pointer-events: auto; }
.roomlabel:active { cursor: grabbing; }
.measurelayer {
@@ -1269,7 +1272,10 @@ export const cardStyles = css`
vector-effect: non-scaling-stroke;
pointer-events: none;
}
.bdframe .bdhandle:hover + .bdknob { fill: #fff; stroke: var(--hp-accent); }
:host([data-pointer-hover]) .bdframe .bdhandle:hover + .bdknob {
fill: #fff;
stroke: var(--hp-accent);
}
.bdframe .bd-nwse { cursor: nwse-resize; }
.bdframe .bd-nesw { cursor: nesw-resize; }
/* the picture itself is the drag target for a move (grab, then grabbing) */
@@ -1316,7 +1322,10 @@ export const cardStyles = css`
vector-effect: non-scaling-stroke;
pointer-events: none;
}
.dtframe .dthandle:hover + .dtknob { fill: #fff; stroke: var(--hp-accent); }
:host([data-pointer-hover]) .dtframe .dthandle:hover + .dtknob {
fill: #fff;
stroke: var(--hp-accent);
}
.dtframe .dt-nwse { cursor: nwse-resize; }
.dtframe .dt-nesw { cursor: nesw-resize; }
.dtframe .dtrot { cursor: grab; }
@@ -1637,7 +1646,7 @@ export const cardStyles = css`
cursor: pointer;
border-radius: var(--rad-s);
}
.modetab .closex:hover { opacity: 1; }
:host([data-pointer-hover]) .modetab .closex:hover { opacity: 1; }
.editbar .barclose {
box-sizing: border-box;
width: 40px;
@@ -1813,6 +1822,7 @@ export const cardStyles = css`
width: max-content;
min-width: var(--dev-size, var(--icon-size, 2.5cqw));
padding-inline: calc(var(--dev-size, var(--icon-size, 2.5cqw)) * 0.16);
border-radius: calc(var(--dev-size, var(--icon-size, 2.5cqw)) / 2);
}
.dev .valtext {
overflow: visible;
@@ -1872,7 +1882,9 @@ export const cardStyles = css`
.dev {
position: absolute;
/* per-device multiplier on top of the card-wide icon size */
--dev-size: calc(var(--icon-size, 2.5cqw) * var(--dev-scale, 1));
--dev-size: calc(
var(--icon-size, 2.5cqw) * var(--dev-scale, 1) * var(--device-visual-factor, 0.9)
);
/* 101.5/80 is the package shell/core ratio, including its stroke. */
--device-shell-size: calc(var(--dev-size) * 1.26875);
--device-shell-stroke-ratio: 0.01875;
@@ -2072,14 +2084,14 @@ export const cardStyles = css`
}
/* Interaction wins ordinary state colours. Alarm keeps priority through
the more-specific rule below. Unavailable has no visual hover. */
.dev:not(.unavail):hover {
:host([data-pointer-hover]) .dev:not(.unavail):hover {
--device-face-bg: #0C82F0;
--device-face-fg: light-dark(#fff, #252525);
--device-shell-stroke: var(--device-shell-base-stroke);
}
.dev.theme-light:not(.unavail):hover { --device-face-fg: #fff; }
.dev.theme-dark:not(.unavail):hover { --device-face-fg: #252525; }
.dev:hover,
:host([data-pointer-hover]) .dev.theme-light:not(.unavail):hover { --device-face-fg: #fff; }
:host([data-pointer-hover]) .dev.theme-dark:not(.unavail):hover { --device-face-fg: #252525; }
:host([data-pointer-hover]) .dev:hover,
.dev:focus-visible { z-index: 5; }
.dev.unavail {
opacity: 0.35;
@@ -2220,7 +2232,7 @@ export const cardStyles = css`
}
/* Alert stays above focus, selection, hover and ordinary semantic paint. */
.dev.alarm,
.dev.alarm:hover,
:host([data-pointer-hover]) .dev.alarm:hover,
.dev.alarm:focus-visible {
--device-face-bg: #F0410C;
--device-face-fg: light-dark(#fff, #252525);
@@ -2304,7 +2316,7 @@ export const cardStyles = css`
margin-left: var(--sp-3);
opacity: 0.4;
}
.tab:hover .tabedit {
:host([data-pointer-hover]) .tab:hover .tabedit {
opacity: 0.9;
}
.tab.tabadd {
@@ -2553,7 +2565,9 @@ export const cardStyles = css`
position: absolute;
/* the base badge, but round and 20% smaller — the owner's wording:
«иконка похожа на иконку базы, только круглая и чуть меньше» */
--puck-size: calc(var(--dev-size, var(--icon-size, 2.5cqw)) * 0.8);
--puck-size: calc(
var(--icon-size, 2.5cqw) * var(--device-visual-factor, 0.9) * 0.8
);
width: var(--puck-size);
height: var(--puck-size);
border-radius: 50%;
@@ -2702,7 +2716,7 @@ export const cardStyles = css`
}
.rszhalo { stroke: var(--hp-bg); stroke-width: 6; }
.rszink { stroke: var(--hp-accent); stroke-width: 2; }
.rszhandle:hover + .rszicon .rszink { stroke-width: 3; }
:host([data-pointer-hover]) .rszhandle:hover + .rszicon .rszink { stroke-width: 3; }
/* corner (scale-frame) handles: hit circle invisible, .rszknob is the bead */
.rszcorner {
fill: transparent;
@@ -2716,7 +2730,7 @@ export const cardStyles = css`
vector-effect: non-scaling-stroke;
pointer-events: none;
}
.rszcorner:hover + .rszknob { fill: var(--hp-accent); }
:host([data-pointer-hover]) .rszcorner:hover + .rszknob { fill: var(--hp-accent); }
.rszcorner:active { cursor: nwse-resize; }
.rszframe {
fill: none;
@@ -2793,7 +2807,7 @@ export const cardStyles = css`
text-align: center;
cursor: pointer;
}
.furnitem:hover { background: rgba(127, 127, 127, 0.18); }
:host([data-pointer-hover]) .furnitem:hover { background: rgba(127, 127, 127, 0.18); }
.furnitem.on {
border-color: var(--hp-accent);
background: rgba(38, 166, 154, 0.18);
@@ -2874,7 +2888,7 @@ export const cardStyles = css`
border-radius: var(--rad-s);
font-size: var(--fs-m);
}
.cand:hover {
:host([data-pointer-hover]) .cand:hover {
background: rgba(127, 127, 127, 0.15);
}
.cand.sel {
@@ -2922,7 +2936,7 @@ export const cardStyles = css`
cursor: pointer;
color: var(--hp-muted);
}
.pdftag .x:hover {
:host([data-pointer-hover]) .pdftag .x:hover {
color: #ff7a5c;
}
.entlist {
@@ -3059,8 +3073,8 @@ export const cardStyles = css`
color: var(--hp-muted);
cursor: pointer;
}
.rrow .ract:hover { color: var(--hp-txt); }
.rrow .ract.del:hover { color: #ff7a5c; }
:host([data-pointer-hover]) .rrow .ract:hover { color: var(--hp-txt); }
:host([data-pointer-hover]) .rrow .ract.del:hover { color: #ff7a5c; }
.gsrow .gsl {
min-width: 150px;
@@ -3144,7 +3158,7 @@ export const cardStyles = css`
font-size: var(--fs-m);
padding: var(--sp-1) 0;
}
.aboutlink:hover { text-decoration: underline; }
:host([data-pointer-hover]) .aboutlink:hover { text-decoration: underline; }
.aboutlink ha-icon { --mdc-icon-size: 18px; line-height: 1; }
hp-dialog .body {
padding: var(--sp-5) var(--sp-6);
@@ -3419,7 +3433,7 @@ export const cardStyles = css`
--mdc-icon-size: 16px;
color: var(--hp-muted);
}
.menu .it:hover {
:host([data-pointer-hover]) .menu .it:hover {
background: rgba(127, 127, 127, 0.15);
}
.menu .it.all {
@@ -0,0 +1,54 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
const source = (name) => readFileSync(new URL(`../src/${name}`, import.meta.url), 'utf8');
test('issue 212 applies one visual 0.9 factor without shrinking the hit target', () => {
const styles = source('styles.ts');
assert.match(styles, /--device-visual-factor:\s*0\.9\s*;/);
assert.match(
styles,
/--dev-size:\s*calc\([^;]*var\(--device-visual-factor,\s*0\.9\)[^;]*\);/,
);
assert.match(styles, /width:\s*max\(44px,\s*var\(--device-shell-size\)\)/);
assert.match(
styles,
/--puck-size:\s*calc\([^;]*var\(--device-visual-factor,\s*0\.9\)[^;]*\);/,
);
});
test('issue 212 Text value uses a stadium radius based on height', () => {
const styles = source('styles.ts');
const rule = styles.match(/\.dev\.valonly \.device-core\s*\{([\s\S]*?)\}/)?.[1] || '';
assert.match(rule, /border-radius:\s*calc\(var\(--dev-size[^;]*\/\s*2\)/);
assert.doesNotMatch(rule, /border-radius:\s*50%/);
});
test('issue 212 feedback is owned by actual dispatch and lasts 200 ms', () => {
const card = source('houseplan-card.ts');
assert.match(card, /private _startDevicePressFeedback\(/);
assert.match(card, /duration:\s*200/);
assert.ok(
card.match(/_startDevicePressFeedback\(actionDevice\.id\)/g)?.length >= 2,
'HA and virtual/run dispatch paths must all opt into the same feedback owner',
);
assert.match(card, /private _cancelDevicePressFeedback\(/);
});
test('issue 212 removes the global touch latch and gates every shared hover selector', () => {
const card = source('houseplan-card.ts');
assert.doesNotMatch(card, /private static _touchSeen/);
assert.match(card, /PointerModalityController/);
assert.match(card, /_clearTransientHover/);
for (const name of ['styles.ts', 'hp-dialog.ts', 'hp-help.ts', 'hp-color-opacity.ts', 'space-card.ts']) {
const text = source(name);
const selectors = [...text.matchAll(/(^|\})([^{}]+)\{/gm)].map((match) => match[2]);
const naked = selectors.flatMap((selector) => selector.split(','))
.map((selector) => selector.trim())
.filter((selector) => selector.includes(':hover'))
.filter((selector) => !selector.includes(':host([data-pointer-hover])'));
assert.deepEqual(naked, [], `${name} has ungated hover selectors: ${naked.join(' | ')}`);
}
});
+77
View File
@@ -0,0 +1,77 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
nextPointerModality, pointerHoverAllowed, pointerModalityOf, PointerModalityController,
} from '../test-build/pointer-modality.js';
const harness = () => {
const host = {
enabled: false,
toggleAttribute(_name, enabled) { this.enabled = enabled; },
};
const listeners = new Set();
const media = {
matches: true,
addEventListener(_type, listener) { listeners.add(listener); },
removeEventListener(_type, listener) { listeners.delete(listener); },
};
return {
host,
media,
win: { matchMedia: () => media },
change(matches) {
media.matches = matches;
for (const listener of listeners) listener({ matches });
},
};
};
test('pointer modality accepts only explicit pointer types', () => {
assert.equal(pointerModalityOf('mouse'), 'mouse');
assert.equal(pointerModalityOf('touch'), 'touch');
assert.equal(pointerModalityOf('pen'), 'pen');
assert.equal(pointerModalityOf(''), null);
assert.equal(pointerModalityOf(undefined), null);
});
test('touch compatibility input cannot restore mouse hover', () => {
assert.equal(nextPointerModality('unknown', { pointerType: 'touch' }), 'touch');
assert.equal(nextPointerModality('touch', {
pointerType: 'mouse', sourceCapabilities: { firesTouchEvents: true },
}), 'touch');
assert.equal(nextPointerModality('touch', { pointerType: 'mouse' }), 'mouse');
assert.equal(nextPointerModality('mouse', { pointerType: 'pen' }), 'pen');
});
test('hover requires both a real mouse authority and hover-capable hardware', () => {
assert.equal(pointerHoverAllowed('mouse', true), true);
assert.equal(pointerHoverAllowed('mouse', false), false);
assert.equal(pointerHoverAllowed('unknown', true), false);
assert.equal(pointerHoverAllowed('touch', true), false);
assert.equal(pointerHoverAllowed('pen', true), false);
});
test('controllers isolate card instances and revalidate hybrid input', () => {
const a = harness();
const b = harness();
const first = new PointerModalityController(a.host);
const second = new PointerModalityController(b.host);
first.connect(a.win);
second.connect(b.win);
first.note({ pointerType: 'touch' });
second.note({ pointerType: 'mouse' });
assert.equal(a.host.enabled, false);
assert.equal(b.host.enabled, true);
first.note({ pointerType: 'mouse' });
assert.equal(a.host.enabled, true);
a.change(false);
assert.equal(a.host.enabled, false);
a.change(true);
assert.equal(a.host.enabled, true);
first.suspend();
assert.equal(a.host.enabled, false);
first.disconnect();
second.disconnect();
});
+1 -1
View File
@@ -21,7 +21,7 @@
"src/types.ts",
"src/space-geometry.ts",
"src/signing.ts", "src/initial-load.ts", "src/space-model-selection.ts", "src/space-dialog.ts",
"src/visual-continuity.ts", "src/mode-transition.ts",
"src/visual-continuity.ts", "src/mode-transition.ts", "src/pointer-modality.ts",
"src/render-device-snapshot.ts",
"src/command-stack.ts",
"src/align-grid.ts",