fix: polish device icons and pointer feedback
Issue: #212 User-Visible: yes
@@ -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)'
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,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
|
||||
|
||||
- Экземпляр карточки теперь можно закрепить за одним пространством параметром
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 без перезагрузки страницы.
|
||||
|
||||
### Подложка и постоянный статус
|
||||
|
||||
| Вид | Что означает | Примеры |
|
||||
|
||||
@@ -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,
|
||||
|
||||
|
Before Width: | Height: | Size: 194 KiB After Width: | Height: | Size: 192 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 292 KiB After Width: | Height: | Size: 290 KiB |
|
Before Width: | Height: | Size: 293 KiB After Width: | Height: | Size: 291 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 135 KiB After Width: | Height: | Size: 134 KiB |
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {',
|
||||
}],
|
||||
},
|
||||
];
|
||||
|
||||
// --- механика ---------------------------------------------------------------
|
||||
|
||||
@@ -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)}
|
||||
>
|
||||
|
||||
@@ -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'] {
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(' | ')}`);
|
||||
}
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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",
|
||||
|
||||