ux v1.43.3: room gear discoverability, bigger metrics, touch tooltips take two

- the room gear became a fixed-size pill button (was 0.9em/60% opacity
  inside the label — invisible in practice, field report); shown on
  unnamed rooms too, which is where you name them
- metrics line 0.62em -> 0.75em (unreadable on tablets)
- tooltips: latch on the first touch/pen pointer event instead of
  trusting (hover: none) alone; any touch drops an open tip
- smoke_feedback_v2.mjs; docs same-commit
This commit is contained in:
Matysh
2026-07-27 12:37:57 +03:00
parent e63b7882a6
commit a841d17543
14 changed files with 239 additions and 48 deletions
+1 -1
View File
@@ -13,7 +13,7 @@ FILES_URL = "/houseplan_files/files"
CONTENT_URL = "/api/houseplan/content"
FILES_DIR = "houseplan/files"
CONF_ADMIN_ONLY = "admin_only"
VERSION = "1.43.2"
VERSION = "1.43.3"
DEFAULT_CONFIG: dict = {
"spaces": [],
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -16,5 +16,5 @@
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
"requirements": [],
"single_config_entry": true,
"version": "1.43.2"
"version": "1.43.3"
}
+49
View File
@@ -0,0 +1,49 @@
import { launch, check, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
// 1) кнопка настроек комнаты в редакторе плана: заметная, фиксированного размера
c._setMode('plan'); await c.updateComplete;
const btn = sr().querySelector('.rlgearbtn');
out.gearButtonShown = !!btn;
const cs = btn ? getComputedStyle(btn) : null;
out.gearReadable = cs ? parseFloat(cs.fontSize) >= 10 && cs.pointerEvents === 'auto' : null;
out.gearHasLabel = btn ? btn.textContent.trim().length > 0 : null;
const box = btn?.getBoundingClientRect();
out.gearTapTarget = box ? box.height >= 18 && box.width >= 40 : null;
btn.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true }));
await c.updateComplete;
out.gearOpensDialog = c._roomDialog === true && !!c._roomEditId;
c._roomDialogCancel(); await c.updateComplete;
// 2) комната без имени тоже получает кнопку (её там и называют)
const room = c._curSpaceCfg.rooms[0];
const savedName = room.name;
room.name = '';
c._saveConfig(); c.requestUpdate(); await c.updateComplete;
out.unnamedStillHasGear = sr().querySelectorAll('.rlgearbtn').length >= 1;
room.name = savedName; c._saveConfig(); c.requestUpdate(); await c.updateComplete;
// 3) метрики стали крупнее: 0.75em вместо 0.62em
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== c._space ? s : ({
...s, settings: { ...(s.settings || {}), show_names: true, label_temp: true } })) };
c._setMode('view'); c._saveConfig(); c.requestUpdate(); await c.updateComplete;
await new Promise((r) => setTimeout(r, 150));
const lbl = [...sr().querySelectorAll('.roomlabel')].find((l) => l.querySelector('.rlmetrics'));
if (lbl) {
const nameSz = parseFloat(getComputedStyle(lbl.querySelector('.rlname')).fontSize);
const metaSz = parseFloat(getComputedStyle(lbl.querySelector('.rlmetrics')).fontSize);
out.metricsRatio = Math.round((metaSz / nameSz) * 100) / 100;
} else out.metricsRatio = 'no-metrics';
// 4) касание помечает сессию как тач и гасит тултип
c._tip = { x: 1, y: 1, title: 't', meta: 'm' };
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;
return out;
});
check('metricsRatio 0.75', res.metricsRatio, 0.75);
delete res.metricsRatio;
checkAll(res);
await finish(browser, res);
+1 -1
View File
@@ -10,7 +10,7 @@ const res = await page.evaluate(async () => {
...s, settings: { ...(s.settings || {}), show_names: true, fill_mode: 'temp', label_temp: true } })) };
c._setMode('plan'); c.requestUpdate(); await c.updateComplete;
// 1) шестерёнка на карточке комнаты в редакторе плана
const gear = sr().querySelector('.rlgear');
const gear = sr().querySelector('.rlgearbtn');
out.gearShown = !!gear;
gear.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true }));
await c.updateComplete;
File diff suppressed because one or more lines are too long
+32 -9
View File
File diff suppressed because one or more lines are too long
+16
View File
@@ -1,5 +1,21 @@
# Changelog
## v1.43.3 — 2026-07-27 (user feedback: discoverability and touch)
- **Room settings were unfindable.** The gear added in v1.42.0 lived inside the
room label at 0.9em of its font and 60% opacity — a few pale pixels on a
normal plan. It is now a pill button "⚙ Room" of a fixed, readable size that
does not shrink with the card font, and it appears on **unnamed rooms too**
(that is where you name them). This also unblocks the font-size sliders,
which nobody could reach.
- **Metrics line enlarged** from 0.62 to 0.75 of the room name — the reporter
could scale the name but the sensor line stayed unreadable on a tablet. The
per-room and per-space multipliers still apply on top.
- **Touch tooltips, take two.** The `(hover: none)` guard was not enough: some
devices, skins, styluses and paired mice report `hover: hover`, so tips still
stuck under the finger. The card now also latches on the first touch/pen
pointer event and drops any open tooltip on touch.
## v1.43.2 — 2026-07-27 (external audit: the test layer)
- **The smoke suite can finally fail (T1).** All 48 headless-browser smokes used
+10
View File
@@ -20,6 +20,16 @@
bundle. Sanity ritual: break one invariant on purpose (e.g. remove the
kiosk editor guard) and confirm the matching smoke goes red [auto: CI job "smoke"]
- [ ] Room gear discoverability (v1.43.3, user feedback): in the Plan editor
every room card carries a pill button "⚙ Room" of a FIXED readable size
(independent of the card font) — including rooms without a name; it opens
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]
## Environments matrix
Run the *core flows* (marked ★ below) in each environment at least once per minor release:
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "houseplan-card",
"version": "1.43.2",
"version": "1.43.3",
"description": "Interactive house plan Lovelace card for Home Assistant",
"license": "MIT",
"type": "module",
+36 -14
View File
@@ -32,7 +32,7 @@ import './space-card';
import { cardStyles } from './styles';
import { langOf, t, type I18nKey } from './i18n';
const CARD_VERSION = '1.43.2';
const CARD_VERSION = '1.43.3';
const LS_KEY = 'houseplan_card_layout_v1';
const LS_CFG = 'houseplan_card_cfg_v1'; // cache of the server config+layout for instant rendering
const LS_ZOOM = 'houseplan_card_zoom_v1';
@@ -1350,17 +1350,35 @@ class HouseplanCard extends LitElement {
}, 3500);
}
/** True on touch-first devices (tablets/phones): no real hover there. */
private static readonly _noHover =
/**
* 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;
}
/** Any touch anywhere marks the session as touch-first and kills open tips. */
private _notePointer(ev: PointerEvent): void {
if (ev.pointerType === 'touch' || ev.pointerType === 'pen') {
HouseplanCard._touchSeen = true;
if (this._tip) this._tip = null;
}
}
private _showTip(ev: MouseEvent, title: string, meta: string, lqi?: number | null, temp?: number | null): void {
// Field feedback: on tablets every tap synthesized a mousemove and popped
// the hover tooltip over the finger. Touch devices get NO hover tooltips —
// the same data lives in room cards and the long-press device card.
if (HouseplanCard._noHover) return;
if (this._noHover) return;
if (this._drag) return;
this._tip = { x: ev.clientX, y: ev.clientY, title, meta, lqi, temp };
}
@@ -3475,7 +3493,7 @@ class HouseplanCard extends LitElement {
style="height:${this._kiosk ? '100dvh' : 'calc(100dvh - 118px)'}"
@click=${(e: MouseEvent) => this._markupClick(e)}
@wheel=${(e: WheelEvent) => this._onWheel(e)}
@pointerdown=${(e: PointerEvent) => this._stagePointerDown(e)}
@pointerdown=${(e: PointerEvent) => { this._notePointer(e); this._stagePointerDown(e); }}
@pointermove=${(e: PointerEvent) => this._stagePointerMove(e)}
@pointerup=${(e: PointerEvent) => this._stagePointerUp(e)}
@pointercancel=${(e: PointerEvent) => this._stagePointerUp(e)}>
@@ -3920,7 +3938,9 @@ class HouseplanCard extends LitElement {
private _renderRoomLabel(
r: RoomCfg, space: SpaceModel, view: { x: number; y: number; w: number; h: number }, disp: SpaceDisplay,
): TemplateResult | typeof nothing {
if (!r.name) return nothing;
// audit/feedback: rooms without a name still need their gear in the Plan
// editor — that is where you name them (field report, 2026-07-27)
if (!r.name && !this._markup) return nothing;
const p = this._labelPos(r, space.id);
const left = ((p.x - view.x) / view.w) * 100;
const top = ((p.y - view.y) / view.h) * 100;
@@ -3959,12 +3979,14 @@ class HouseplanCard extends LitElement {
@pointermove=${(e: PointerEvent) => this._labelMove(e, r, space.id)}
@pointerup=${() => this._labelUp(r)}
@pointercancel=${() => this._labelUp(r)}
><span class="rlname">${this._markup && r.id
? html`<ha-icon class="rlgear" icon="mdi:cog-outline"
title=${this._t('room.settings_title')}
>${this._markup && r.id
? html`<button class="rlgearbtn" title=${this._t('room.settings_title')}
@pointerdown=${(e: Event) => e.stopPropagation()}
@click=${(e: Event) => { e.stopPropagation(); this._openRoomEdit(r); }}></ha-icon>`
: nothing}${r.name}${!this._markup && r.area
@click=${(e: Event) => { e.stopPropagation(); this._openRoomEdit(r); }}>
<ha-icon icon="mdi:cog-outline"></ha-icon>
<span class="rlgeartext">${this._t('room.settings_short')}</span>
</button>`
: nothing}<span class="rlname">${r.name || (this._markup ? this._t('room.unnamed') : '')}${!this._markup && r.area
? html`<ha-icon class="rlgo" icon="mdi:open-in-new"
title=${this._t('room.open_area')}
@click=${(e: Event) => { e.stopPropagation(); this._clickRoom(r); }}
+3 -1
View File
@@ -323,5 +323,7 @@
"room.name_scale": "Room name size",
"room.label_scale": "Metrics size",
"preview.room_name": "Living room",
"toast.cfg_reload_failed": "Could not reload the plan from the server: {err}"
"toast.cfg_reload_failed": "Could not reload the plan from the server: {err}",
"room.settings_short": "Room",
"room.unnamed": "Unnamed room"
}
+3 -1
View File
@@ -323,5 +323,7 @@
"room.name_scale": "Размер названия",
"room.label_scale": "Размер подписей",
"preview.room_name": "Гостиная",
"toast.cfg_reload_failed": "Не удалось перечитать план с сервера: {err}"
"toast.cfg_reload_failed": "Не удалось перечитать план с сервера: {err}",
"room.settings_short": "Комната",
"room.unnamed": "Комната без имени"
}
+22 -1
View File
@@ -383,6 +383,27 @@ export const cardStyles = css`
gap: 0.25em;
font-size: calc(1em * var(--rl-name, 1));
}
.rlgearbtn {
display: inline-flex;
align-items: center;
gap: 4px;
margin-bottom: 3px;
padding: 3px 8px;
border: 0;
border-radius: 999px;
background: var(--hp-accent);
color: var(--text-primary-color, #fff);
font: inherit;
font-size: 11px;
font-weight: 600;
line-height: 1;
cursor: pointer;
pointer-events: auto;
opacity: 0.92;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.35);
}
.rlgearbtn:hover { opacity: 1; }
.rlgearbtn ha-icon { --mdc-icon-size: 14px; display: inline-flex; }
.rlgear {
--mdc-icon-size: 0.9em;
display: inline-flex;
@@ -406,7 +427,7 @@ export const cardStyles = css`
display: flex;
align-items: center;
gap: 0.55em;
font-size: calc(0.62em * var(--rl-meta, 1));
font-size: calc(0.75em * var(--rl-meta, 1)); /* feedback: 0.62 was unreadable on a tablet */
font-weight: 600;
letter-spacing: 0.02em;
opacity: 0.9;