v1.60.1-beta.1: harden editing and device state

This commit is contained in:
Matysh
2026-08-07 14:19:02 +03:00
parent 6db9eb0a66
commit fe5f5b6a24
31 changed files with 1312 additions and 373 deletions
+21 -1
View File
@@ -72,6 +72,13 @@ const IDLE_STATES = new Set([
'completed', 'stopped', 'ready', 'sleeping',
]);
/** Standard HA HVAC modes which mean the climate entity is enabled. The
* entity's own `hvac_modes` attribute extends this set for integrations with
* custom modes; `off`/idle states are always excluded. */
const CLIMATE_ENABLED_MODES = new Set([
'heat', 'cool', 'heat_cool', 'auto', 'dry', 'fan_only',
]);
const unavailable = (state: string): boolean =>
state === '' || state === 'unknown' || state === 'unavailable' || state === '__missing__';
@@ -147,7 +154,20 @@ export function entityVisualSample(hass: any, eid: string): EntityVisualSample {
if (domain === 'climate') {
const action = workAction(st.attributes);
return WORKING_STATES.has(action)
// `hvac_action` (or an equivalent action attribute) is the precise answer
// when an integration exposes it: a thermostat may be in heat mode while
// currently idle. Some valid climate integrations expose only the entity
// state/HVAC mode, though. In that case use HA's own `hvac_modes` contract
// as the best available enabled-state fallback instead of leaving every
// non-off air conditioner permanently neutral.
const advertisedModes = Array.isArray(st.attributes?.hvac_modes)
? st.attributes.hvac_modes.map(lower) : [];
const enabledMode = !IDLE_STATES.has(state) && (
CLIMATE_ENABLED_MODES.has(state)
|| WORKING_STATES.has(state)
|| advertisedModes.includes(state)
);
return (action ? WORKING_STATES.has(action) : enabledMode)
? { ...base, status: 'working', activity: 'running' }
: base;
}
+56 -12
View File
@@ -181,12 +181,32 @@ export function effectiveMarkerControls(
controls: readonly string[] | null | undefined,
ownEntities: readonly string[] = [],
): string[] {
const own = new Set(ownEntities);
if (binding?.startsWith('entity:')) own.add(binding.slice('entity:'.length));
// An entity marker owns only its exact binding. Registry rebuilds may attach
// sibling entities from the parent device to DevItem; treating every sibling
// as self would silently drop an explicitly configured external target.
const own = binding?.startsWith('entity:')
? new Set([binding.slice('entity:'.length)])
: new Set(ownEntities);
return [...new Set(controls || [])]
.filter((eid) => isControllable(eid) && !own.has(eid));
}
/**
* Before `is_light` existed, putting a marker's own switch into `controls`
* was the supported way to say that the relay drives a real fixture. Keep that
* intent alive until Save/Optimize converts the legacy representation.
*/
export function hasLegacySelfLightIntent(
binding: string | null | undefined,
controls: readonly string[] | null | undefined,
ownEntities: readonly string[] = [],
): boolean {
const own = binding?.startsWith('entity:')
? new Set([binding.slice('entity:'.length)])
: new Set(ownEntities);
return (controls || []).some((eid) => eid.startsWith('switch.') && own.has(eid));
}
function lightSourceBelongsToRoom(d: LightSourceDevice, room: LightSourceRoom): boolean {
if (typeof room === 'string') return d.area === room;
// An explicit marker-to-room binding is more precise than the HA area and
@@ -196,25 +216,48 @@ function lightSourceBelongsToRoom(d: LightSourceDevice, room: LightSourceRoom):
return !!room.area && d.area === room.area;
}
function lightEntitiesOf(hass: any, d: LightSourceDevice): { eids: string[]; via: ResolvedLightSource['via'] } {
interface LightEntityCandidate {
eid: string;
via: ResolvedLightSource['via'];
}
function forcedLightEntityOf(d: LightSourceDevice): string | null {
const bound = d.marker?.binding?.startsWith('entity:')
? d.marker.binding.slice('entity:'.length) : null;
const controllable = d.entities.filter(isControllable);
const primary = d.primary && isControllable(d.primary) ? d.primary : null;
// An entity-bound legacy self-control names the physical relay exactly.
// Do not let a different registry-derived primary entity steal that intent.
return (bound && isControllable(bound) ? bound : null) || primary || controllable[0] || null;
}
function lightEntitiesOf(hass: any, d: LightSourceDevice): LightEntityCandidate[] {
const controls = effectiveMarkerControls(
d.marker?.binding,
d.controls ?? d.marker?.controls,
d.entities,
);
if (controls.length) return { eids: controls, via: 'controls' };
const out: LightEntityCandidate[] = controls.map((eid) => ({ eid, via: 'controls' }));
if (d.marker?.is_light === true) {
if (d.marker?.is_light === true || hasLegacySelfLightIntent(
d.marker?.binding, d.marker?.controls, d.entities,
)) {
// A forced source is a smart switch (or light) driving real fixtures.
// Prefer its primary entity, but never treat measurements or service
// entities as light switches merely because they belong to the device.
const controllable = d.entities.filter(isControllable);
const primary = d.primary && isControllable(d.primary) ? d.primary : null;
// The marker is one physical source, so do not count its auxiliary
// controllable entities as additional lamps in room statistics.
return { eids: primary ? [primary] : controllable.slice(0, 1), via: 'forced' };
const forced = forcedLightEntityOf(d);
if (forced && !out.some((candidate) => candidate.eid === forced)) {
out.push({ eid: forced, via: 'forced' });
}
}
// Explicit external controls and the marker's own explicit source are
// additive. Automatic discovery remains the fallback only when neither was
// configured, so a device status LED still cannot leak into the room light set.
if (out.length) return out;
// Automatic light discovery describes the DEVICE, not every auxiliary
// entity it happens to expose. TVs, soundbars, air cleaners and similar
// devices often publish a light.* for a status LED / display illumination;
@@ -223,8 +266,9 @@ function lightEntitiesOf(hass: any, d: LightSourceDevice): { eids: string[]; via
// Explicit controls/is_light above still override it when the user says the
// auxiliary light really is a plan light.
const role = d.primary || resolvedDeviceStateEntities(hass, d.entities)[0];
if (role && !role.startsWith('light.')) return { eids: [], via: 'light' };
return { eids: d.entities.filter((eid) => eid.startsWith('light.')), via: 'light' };
if (role && !role.startsWith('light.')) return [];
return d.entities.filter((eid) => eid.startsWith('light.'))
.map((eid) => ({ eid, via: 'light' as const }));
}
/**
@@ -242,8 +286,8 @@ export function resolvedLightSources<D extends LightSourceDevice>(
const seen = new Set<string>();
for (const d of devices) {
if (d.hidden || (room != null && !lightSourceBelongsToRoom(d, room))) continue;
const { eids, via } = lightEntitiesOf(hass, d);
for (const eid of eids) {
const candidates = lightEntitiesOf(hass, d);
for (const { eid, via } of candidates) {
if (!eid || seen.has(eid)) continue;
seen.add(eid);
out.push({ eid, device: d, via, on: hass.states[eid]?.state === 'on' });
+153 -59
View File
@@ -32,7 +32,7 @@ import {
} from './logic';
import {
planEdgeDrag, applyEdgeDrag, clampEdgeDrag, applyRoomScale, clampRoomScale,
simplifyPoly, areaM2, formatArea, MIN_ROOM_CM, type EdgeDragPlan,
simplifyPoly, polyIsSimple, areaM2, formatArea, MIN_ROOM_CM, type EdgeDragPlan,
} from './resize';
import {
computeSunRays, dayPhase, northDegOf, bgModeOf, sunRaysOn, weatherEntityOf,
@@ -75,7 +75,7 @@ import {
areaTemp, areaHum, sourceValue, areaClimateMap,
resolvedLightSources, resolvedLightState, resolvedLightStats,
resolvedDeviceStateEntities, removedPlanBindings, isRemovedPlanEntity,
deletePlanMarkerRecords, effectiveMarkerControls, resolveIcon,
deletePlanMarkerRecords, effectiveMarkerControls, hasLegacySelfLightIntent, resolveIcon,
type AreaClimate,
} from './devices';
import type {
@@ -108,9 +108,10 @@ import {
type DecorBox, type SnapGeometry,
} from './editors/decor/geometry';
const CARD_VERSION = '1.60.0';
const CARD_VERSION = '1.60.1-beta.1';
/** Keeps every previously valid scale at the maximum 20 cm grid scale lossless. */
const DECOR_TEXT_CM_MAX = 2000;
const CELL_CM_MIN = 0.1;
const CELL_CM_MAX = 1000;
/** HP-1552 boot-veil timing (AUD-1552-02). The veil holds for at least
* BOOT_MIN_MS; every stage-height change restarts a BOOT_QUIET_MS
@@ -530,13 +531,29 @@ class HouseplanCard extends LitElement {
}
/** Kiosk auto-carousel: advance to the next space every `cycle` seconds. */
/**
* Which way the plan should fly out when the space changes. Empty means no
* animation — a direct pick from the tabs should not slide anywhere.
*/
/** Which way the incoming plan moves when the active space changes. */
private _slide: '' | 'left' | 'right' = '';
private _slideTimer?: number;
/** Short shared transition for View ↔ editor and editor ↔ editor changes. */
private _navMotion: '' | 'enter' | 'exit' | 'swap' = '';
private _navMotionTimer?: number;
/** Last editor kept in the collapsing chrome so leaving it can animate out. */
private _editorChromeMode: 'plan' | 'devices' | 'decor' = 'plan';
private _startNavMotion(kind: 'enter' | 'exit' | 'swap'): void {
clearTimeout(this._navMotionTimer);
if (window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches) {
this._navMotion = '';
return;
}
this._navMotion = kind;
this._navMotionTimer = window.setTimeout(() => {
this._navMotion = '';
this.requestUpdate();
}, 190);
}
/** Change the space with the usual sideways transition. */
private _slideTo(id: string, dir: 'left' | 'right'): void {
if (id === this._space) return;
@@ -547,11 +564,23 @@ class HouseplanCard extends LitElement {
if (reduce) return;
this._slide = dir;
clearTimeout(this._slideTimer);
// long enough to be read as motion, short enough not to be in the way
this._slideTimer = window.setTimeout(() => { this._slide = ''; this.requestUpdate(); }, 260);
this._slideTimer = window.setTimeout(() => { this._slide = ''; this.requestUpdate(); }, 190);
this.requestUpdate();
}
/** Direct space tabs use the same motion as swipe/carousel navigation. */
private _pickSpace(id: string): void {
if (id === this._space) return;
const ids = this._model.map((sp) => sp.id);
const from = ids.indexOf(this._space);
const to = ids.indexOf(id);
this._navApplied = true;
this._showFar = false; // the hint is per space (docs/CANVAS.md §4.1)
this._frame = null;
this._slideTo(id, from >= 0 && to < from ? 'right' : 'left');
this._saveNav();
}
private _cycleTick(): void {
if (!this._kiosk || !(Number(this._config?.cycle) > 0)) return;
if (Date.now() >= this._cyclePausedUntil && this._model.length > 1 && this._zoom <= 1.001) {
@@ -1040,6 +1069,7 @@ class HouseplanCard extends LitElement {
this._signer.dispose();
clearTimeout(this._toastTimer);
clearTimeout(this._slideTimer);
clearTimeout(this._navMotionTimer);
clearTimeout(this._bootTimer);
this._bootTimer = undefined; // AUD-1552-01: a cleared id must not block the reconnect watcher
clearTimeout(this._bootSoftTimer);
@@ -1077,6 +1107,10 @@ class HouseplanCard extends LitElement {
clearTimeout(this._warmReviveTimer);
this._warmReviveTimer = undefined;
this._warmRelease();
// R1: the rAF is the only normal owner that clears this flag. Reset only
// after the disconnect snapshot (which must still skip unstable geometry),
// so a same-element reattach can start fresh instead of keeping the veil.
this._resumeSettling = false;
super.disconnectedCallback();
}
@@ -1109,13 +1143,20 @@ class HouseplanCard extends LitElement {
return;
}
}
const target = e.target as HTMLElement | null;
const inField = !!target?.closest?.('input, textarea, select, [contenteditable="true"]');
// At the window listener `target` may be retargeted to the card host by
// Shadow DOM. The composed path still contains the actual focused field.
const inField = (e.composedPath?.() || [e.target]).some((node: any) =>
node?.matches?.('input, textarea, select, [contenteditable="true"]'),
);
const mod = e.ctrlKey || e.metaKey;
const key = e.key.toLowerCase();
// `key` becomes «я» on a Russian layout. `code` keeps Ctrl/Cmd+Z and
// Ctrl/Cmd+Y tied to the expected physical shortcuts in every layout.
const isZ = e.code === 'KeyZ' || key === 'z';
const isY = e.code === 'KeyY' || key === 'y';
const redo = mod && ((isZ && e.shiftKey) || isY);
const undo = mod && isZ && !e.shiftKey;
if (this._mode === 'decor') {
const mod = e.ctrlKey || e.metaKey;
const key = e.key.toLowerCase();
const redo = mod && (key === 'y' || (key === 'z' && e.shiftKey));
const undo = mod && key === 'z' && !e.shiftKey;
if ((undo || redo) && inField) return;
if (redo) { e.preventDefault(); this._redoGeometry(); return; }
if (undo) {
@@ -1148,10 +1189,6 @@ class HouseplanCard extends LitElement {
return;
}
if (!this._markup) return;
const mod = e.ctrlKey || e.metaKey;
const key = e.key.toLowerCase();
const redo = mod && (key === 'y' || (key === 'z' && e.shiftKey));
const undo = mod && key === 'z' && !e.shiftKey;
if ((undo || redo) && inField) return; // keep native text-field history
if (redo) {
e.preventDefault();
@@ -3500,11 +3537,12 @@ class HouseplanCard extends LitElement {
private _setMode(mode: 'view' | 'plan' | 'devices' | 'decor'): void {
if (this._kiosk && mode !== 'view') return; // wall devices never edit
if (this._mode === mode) return;
this._bootSoftCancel(); // editor bars change the stage height DELIBERATELY — snap, no glide
this._bootSoftCancel(); // navigation owns its own short, bounded transition
if ((mode === 'plan' || mode === 'decor') && !this._norm) {
this._showToast(this._t('toast.markup_needs_server'));
return;
}
const previousMode = this._mode;
// A live decor transform is a transaction. Switching tabs must cancel and
// restore it, not merely forget its pointer record after the config has
// already been mutated by move/resize.
@@ -3522,6 +3560,8 @@ class HouseplanCard extends LitElement {
};
}
this._mode = mode;
this._editorChromeMode = mode === 'view' ? previousMode as 'plan' | 'devices' | 'decor' : mode;
this._startNavMotion(previousMode === 'view' ? 'enter' : mode === 'view' ? 'exit' : 'swap');
if (baseChanges) {
// refit against the new base: the editors measure from the full square,
// the view from the content frame — a view clamped to one is nonsense
@@ -3873,6 +3913,54 @@ class HouseplanCard extends LitElement {
);
}
/** A room ring may meet only at the endpoints of neighbouring edges. */
private _contourSelfIntersects(poly: number[][]): boolean {
if (!polyIsSimple(poly)) return true; // transverse crossings
const n = poly.length;
const eps = 0.001;
for (let i = 0; i < n; i++) {
const a = poly[i], b = poly[(i + 1) % n];
for (let j = i + 1; j < n; j++) {
// Neighbours deliberately share one endpoint, including first ↔ last.
if (j === i + 1 || (i === 0 && j === n - 1)) continue;
const c = poly[j], d = poly[(j + 1) % n];
const cd = [c[0], c[1], d[0], d[1]];
const ab = [a[0], a[1], b[0], b[1]];
// This also catches a closing edge passing through an older vertex and
// collinear overlap, which a proper-crossing test intentionally omits.
if (distToSegment(a, cd) <= eps || distToSegment(b, cd) <= eps ||
distToSegment(c, ab) <= eps || distToSegment(d, ab) <= eps) return true;
}
}
return false;
}
/** Validate and close the draft without using the closing click as a vertex. */
private _closeRoomContour(showMinimumError = false): void {
// Three placed vertices mean two existing edges; the closing edge becomes
// the third one. Anything shorter cannot enclose a room.
if (this._path.length < 3) {
if (showMinimumError) this._showToast(this._t('toast.contour_min_edges'));
return;
}
if (this._contourSelfIntersects(this._path) || polygonArea(this._path) <= 1e-6) {
this._showToast(this._t('toast.contour_cannot_close'));
return; // keep the draft editable
}
// A contour can enclose an existing room without any vertex inside it.
const clash = this._overlapRoom(this._path);
if (clash) {
this._showToast(this._t('toast.room_overlap', { name: clash.name || '' }));
return;
}
this._path = [...this._path, [...this._path[0]]];
this._cursorPt = null;
this._nameSel = '';
this._areaSel = '';
this._resetRoomDialogFields();
this._roomDialog = true;
}
private _markupClick(ev: MouseEvent): void {
if (this._vacFit) return; // the fit overlay owns all pointer input
if (!this._markup) return;
@@ -3924,6 +4012,11 @@ class HouseplanCard extends LitElement {
// draw: clicks on grid points build the outline. Nothing is written to the config
// until the contour closes — an abandoned outline leaves no lines behind.
const pt = this._snap(raw);
if (ev.ctrlKey || ev.metaKey) {
ev.preventDefault();
this._closeRoomContour(true);
return;
}
const closing = this._path.length >= 3 && this._samePt(pt, this._path[0]);
// Island rooms (v1.34.0): drawing INSIDE an existing room is legal — the
// contour may become a nested room (a column, an inner room). Partial
@@ -3936,18 +4029,7 @@ class HouseplanCard extends LitElement {
const last = this._path[this._path.length - 1];
if (this._samePt(pt, last)) return; // repeated click on the same point
if (closing) {
// a contour can enclose an existing room without any vertex inside it
const clash = this._overlapRoom(this._path);
if (clash) {
this._showToast(this._t('toast.room_overlap', { name: clash.name || '' }));
return; // leave the outline open so it can be corrected
}
this._path = [...this._path, pt];
this._cursorPt = null;
this._nameSel = '';
this._areaSel = '';
this._resetRoomDialogFields();
this._roomDialog = true;
this._closeRoomContour();
return;
}
this._path = [...this._path, pt];
@@ -4507,7 +4589,11 @@ class HouseplanCard extends LitElement {
this._furnPlace(this._svgPoint(ev), ev.shiftKey);
return true;
}
this._decorSel = null; // select/erase on empty space clears the selection
// Empty-space selection belongs only to Select. Erase is an object
// command: a miss must be a true no-op. With SVG text its painted glyphs
// do not cover the whole logical label box, so clearing here made a click
// between letters appear to erase only the selection outline.
if (t === 'select') this._decorSel = null;
// …and under its own tool the picture is grabbable by its body
// (docs/BACKDROP.md §2). Only INSIDE the image rect: press beside the
// picture and the plane still pans with one finger.
@@ -6425,7 +6511,7 @@ class HouseplanCard extends LitElement {
// Bake the wall colour into the pattern — CSS vars on <pattern> content
// do not inherit from the filled path, so var(--room-stroke) fell back
// to grey while the wall outline used the real border colour.
const stroke = color || 'var(--hp-muted, #607d8b)';
const stroke = color || '#607d8b';
return svg`<defs>
<pattern id="hp-wall-hatch" patternUnits="userSpaceOnUse" width="8" height="8"
patternTransform="rotate(45) scale(${inv.toFixed(3)})">
@@ -6452,7 +6538,7 @@ class HouseplanCard extends LitElement {
const stage = this._stageEl;
const v = this._viewOr(this._baseVb());
const px = stage && stage.clientWidth && v.w ? stage.clientWidth / v.w : 1;
const stroke = disp?.color || 'var(--hp-muted, #607d8b)';
const stroke = disp?.color || '#607d8b';
const solid = wallBodyNeedsSolid(united.depthUnits, px);
const wf = this._fillColors.wall_fill;
// Fill colour UNDER the hatch (owner 2026-08-05): both, never one instead
@@ -7200,11 +7286,14 @@ class HouseplanCard extends LitElement {
tapConfirm: d.marker?.tap_confirm === true,
runFilter: '',
defaultTap: d.primary?.split('.')[0] === 'light' ? 'toggle' : 'info',
// Keep temporarily inactive external targets for lossless editing, but
// discard the invalid legacy self-reference of an entity marker.
// Keep temporarily inactive external targets for lossless editing.
// A legacy self-reference is represented by isLight below instead of
// remaining duplicated in the external-target list.
controls: effectiveMarkerControls(d.marker?.binding, d.marker?.controls, d.entities),
controlsFilter: '',
isLight: d.marker?.is_light === true,
isLight: d.marker?.is_light === true || hasLegacySelfLightIntent(
d.marker?.binding, d.marker?.controls, d.entities,
),
useClimateTemp: d.marker?.use_climate_temp === true,
glowRadius: Number(d.marker?.glow_radius_cm) > 0
? String(this._imperial
@@ -7962,7 +8051,7 @@ class HouseplanCard extends LitElement {
label_light: d.labelLight,
};
sp.cell_cm = Number.isFinite(d.cellCm) && d.cellCm > 0
? Math.min(CELL_CM_MAX, d.cellCm) : 5;
? Math.max(CELL_CM_MIN, Math.min(CELL_CM_MAX, d.cellCm)) : 5;
// Nothing to clean up from here: the backend collects the superseded
// file inside the same locked transaction that accepted this config
// (review R3-1). A cleanup driven from the client could not be ordered
@@ -9129,6 +9218,7 @@ class HouseplanCard extends LitElement {
const decorMeasure = this._decorMeasure;
const bdLive = this._bdLive;
const furnLive = this._furnLive;
const editorChromeMode = this._mode === 'view' ? this._editorChromeMode : this._mode;
return html`
<ha-card>
@@ -9143,15 +9233,7 @@ class HouseplanCard extends LitElement {
(s) => html`<button
data-hp="space-tab" data-id="${s.id}"
class="tab ${this._space === s.id ? 'active' : ''}"
@click=${() => {
this._space = s.id;
this._selId = null;
this._navApplied = true;
this._showFar = false; // the hint is per space (docs/CANVAS.md §4.1)
this._frame = null;
this._restoreZoom();
this._saveNav();
}}
@click=${() => this._pickSpace(s.id)}
>
${s.title}${this._norm && this._canEdit
? html`<ha-icon class="tabedit" icon="mdi:cog-outline"
@@ -9207,14 +9289,26 @@ class HouseplanCard extends LitElement {
</button>`
: nothing}
</div>
${this._markup ? this._renderMarkupBar() : this._mode === 'devices' ? this._renderDevicesBar() : this._mode === 'decor' ? this._renderDecorBar() : nothing}
${''/* the palette lives UNDER the bar, above the stage: it is part of
the tool, not a modal — the plan stays visible while a symbol is
chosen, because where a sofa goes is a question about the plan */}
${this._mode === 'decor' && this._decorTool === 'furniture' ? this._renderFurnPalette() : nothing}
${this._canEdit && !this._kiosk
? html`<div class="editorchrome ${this._editing ? 'open' : ''}${this._navMotion ? ' nav-' + this._navMotion : ''}" aria-hidden=${this._editing ? 'false' : 'true'}>
<div class="editorchrome-inner ${this._navMotion ? 'nav-' + this._navMotion : ''}">
${editorChromeMode === 'plan'
? this._renderMarkupBar()
: editorChromeMode === 'devices'
? this._renderDevicesBar()
: this._renderDecorBar()}
${''/* the palette lives UNDER the bar, above the stage: it is part of
the tool, not a modal — the plan stays visible while a symbol is
chosen, because where a sofa goes is a question about the plan */}
${editorChromeMode === 'decor' && this._decorTool === 'furniture'
? this._renderFurnPalette()
: nothing}
</div>
</div>`
: nothing}
</div>
<div class="stage ${this._markup ? 'markup tool-' + this._tool + (this._tool === 'split' && !this._splitSel ? ' pickstage' : '') + ((this._tool === 'openwall' || this._tool === 'closewall') && this._openWallHover ? ' wallhot' : '') + (this._tool === 'wallthick' && this._wallThickHover ? ' wallhot' : '') : ''} ${this._mode === 'decor' ? 'dtool-' + this._decorTool : ''} ${space.bg ? '' : 'noplan'} mode-${this._mode}${this._bdMovable ? ' bdgrab' : ''}${this._bdDrag ? ' bdgrabbing' : ''}${dayNight ? ' daynight' : ''}${dayNight && this._skySnap ? ' skysnap' : ''}${this._booting ? ' hpboot' : ''}${this._bootSoft ? ' hpsettle' : ''}${this._resumeSettling && this._mode === 'view' && !this._kiosk ? ' hpresume' : ''}"
<div class="stage ${this._markup ? 'markup tool-' + this._tool + (this._tool === 'split' && !this._splitSel ? ' pickstage' : '') + ((this._tool === 'openwall' || this._tool === 'closewall') && this._openWallHover ? ' wallhot' : '') + (this._tool === 'wallthick' && this._wallThickHover ? ' wallhot' : '') : ''} ${this._mode === 'decor' ? 'dtool-' + this._decorTool : ''} ${space.bg ? '' : 'noplan'} mode-${this._mode}${this._bdMovable ? ' bdgrab' : ''}${this._bdDrag ? ' bdgrabbing' : ''}${dayNight ? ' daynight' : ''}${dayNight && this._skySnap ? ' skysnap' : ''}${this._booting ? ' hpboot' : ''}${this._bootSoft ? ' hpsettle' : ''}${this._navMotion ? ' hpnav' : ''}${this._resumeSettling && this._mode === 'view' && !this._kiosk ? ' hpresume' : ''}"
style="height:${this._kiosk ? '100dvh' : `calc(100dvh - ${this._hdrH}px)`}${stageBg ? `;background:${stageBg}` : ''};--wall-fill:${this._fillColors.wall_fill.c};--wall-fill-op:${this._fillColors.wall_fill.a}"
@click=${(e: MouseEvent) => this._markupClick(e)}
@wheel=${(e: WheelEvent) => this._onWheel(e)}
@@ -9222,7 +9316,7 @@ class HouseplanCard extends LitElement {
@pointermove=${(e: PointerEvent) => this._stagePointerMove(e)}
@pointerup=${(e: PointerEvent) => this._stagePointerUp(e)}
@pointercancel=${(e: PointerEvent) => this._stagePointerCancel(e)}>
<div class="zoomwrap ${this._slide ? 'slide-' + this._slide : ''}"
<div class="zoomwrap ${this._slide ? 'slide-' + this._slide : ''}${this._navMotion ? ' nav-' + this._navMotion : ''}"
style="${dayNight ? `filter:brightness(${(1 - planDim).toFixed(3)})` : ''}">
<svg viewBox="${view.x} ${view.y} ${view.w} ${view.h}" preserveAspectRatio="xMidYMid meet">
${''/* THE PAPER IS THE ROOMS (docs/BACKDROP.md §3, owner
@@ -11716,13 +11810,13 @@ class HouseplanCard extends LitElement {
<label>${this._t('space.scale_label')}</label>
<div class="colorrow">
<input class="namein tempin" type="number" min="0.1" max=${CELL_CM_MAX}
<input class="namein tempin" type="number" min=${CELL_CM_MIN} max=${CELL_CM_MAX}
step="0.1" .value=${String(d.cellCm)}
@input=${(e: Event) => {
const n = parseFloat((e.target as HTMLInputElement).value);
this._spaceDialog = {
...d, cellCm: Number.isFinite(n) && n > 0
? Math.min(CELL_CM_MAX, n) : d.cellCm,
? Math.max(CELL_CM_MIN, Math.min(CELL_CM_MAX, n)) : d.cellCm,
};
}} />
<span class="opl">${this._t('space.scale_unit')}</span>
@@ -11968,7 +12062,7 @@ class HouseplanCard extends LitElement {
const cur = this.hass.areas[this._areaSel];
if (cur) areas.unshift(cur);
}
return html`<hp-dialog .hass=${this.hass}
return html`<hp-dialog class="roomdialog" .hass=${this.hass} wide
.title=${edit ? this._t('room.settings_title') : this._t('room.new')}
icon=${edit ? 'mdi:cog-outline' : 'mdi:floor-plan'} @hp-close=${this._roomDialogCancel}>
<div class="body">
@@ -12019,7 +12113,7 @@ class HouseplanCard extends LitElement {
this._roomLabelScale,
)}
</div>
<div class="row" slot="footer">
<div class="row roomfooter" slot="footer">
<button class="btn ghost" @click=${this._roomDialogCancel}>${this._t('btn.cancel')}</button>
<span class="spacer"></span>
${edit
+83 -18
View File
@@ -15,7 +15,7 @@ export class HpColorOpacity extends LitElement {
public disabled = false;
private _open = false;
private _alignEnd = false;
private _pickerRaf = 0;
static properties = {
label: { type: String },
@@ -24,7 +24,6 @@ export class HpColorOpacity extends LitElement {
opacity: { type: Number },
disabled: { type: Boolean, reflect: true },
_open: { state: true },
_alignEnd: { state: true },
};
static styles = css`
@@ -71,24 +70,31 @@ export class HpColorOpacity extends LitElement {
pointer-events: none;
}
.picker {
position: absolute;
z-index: 120;
top: calc(100% + 7px);
/* The popover attribute promotes this surface into the browser top layer, outside
hp-dialog's scrolling/clipping body. JS supplies viewport coordinates. */
position: fixed;
z-index: 2147483647;
inset: auto;
top: 0;
left: 0;
width: 226px;
width: min(226px, calc(100vw - 16px));
max-height: calc(100vh - 16px);
margin: 0;
box-sizing: border-box;
display: grid;
gap: 10px;
padding: 12px;
overflow: auto;
color: var(--primary-text-color, #fff);
background: var(--card-background-color, #202126);
border: 1px solid var(--primary-color, #03a9f4);
border-radius: 10px;
box-shadow: 0 10px 28px rgb(0 0 0 / 0.34);
}
.picker.end {
right: 0;
left: auto;
.picker[popover]:not(:popover-open) {
/* Keep the author-level display:grid from exposing the surface for one
frame before showPopover() promotes it into the top layer. */
display: none;
}
.row {
display: flex;
@@ -145,36 +151,95 @@ export class HpColorOpacity extends LitElement {
connectedCallback(): void {
super.connectedCallback();
document.addEventListener('pointerdown', this._outsidePointerDown, true);
document.addEventListener('scroll', this._queuePickerPosition, true);
window.addEventListener('resize', this._queuePickerPosition);
this.addEventListener('keydown', this._keyDown, true);
}
disconnectedCallback(): void {
document.removeEventListener('pointerdown', this._outsidePointerDown, true);
document.removeEventListener('scroll', this._queuePickerPosition, true);
window.removeEventListener('resize', this._queuePickerPosition);
this.removeEventListener('keydown', this._keyDown, true);
if (this._pickerRaf) cancelAnimationFrame(this._pickerRaf);
this._pickerRaf = 0;
super.disconnectedCallback();
}
private _outsidePointerDown = (event: PointerEvent): void => {
if (this._open && !event.composedPath().includes(this)) this._open = false;
if (this._open && !event.composedPath().includes(this)) this._closePicker();
};
private _keyDown = (event: KeyboardEvent): void => {
if (!this._open || event.key !== 'Escape') return;
event.preventDefault();
event.stopImmediatePropagation();
this._open = false;
this.updateComplete.then(() => this.renderRoot.querySelector<HTMLButtonElement>('.trigger')?.focus());
this._closePicker(true);
};
private _toggle(): void {
if (this.disabled) return;
this._open = !this._open;
this._alignEnd = false;
if (this._open) {
this._closePicker();
return;
}
this._open = true;
this.updateComplete.then(() => this._positionPicker());
}
private _closePicker(refocus = false): void {
const popup = this.renderRoot.querySelector<HTMLElement>('.picker') as any;
if (popup?.hidePopover) {
try {
if (popup.matches(':popover-open')) popup.hidePopover();
} catch {
/* older engines simply remove the fallback surface on the next render */
}
}
this._open = false;
if (refocus) {
this.updateComplete.then(() => this.renderRoot.querySelector<HTMLButtonElement>('.trigger')?.focus());
}
}
private _queuePickerPosition = (): void => {
if (!this._open) return;
this.updateComplete.then(() => {
const popup = this.renderRoot.querySelector<HTMLElement>('.picker');
if (popup && popup.getBoundingClientRect().right > window.innerWidth - 8) this._alignEnd = true;
if (this._pickerRaf) cancelAnimationFrame(this._pickerRaf);
this._pickerRaf = requestAnimationFrame(() => {
this._pickerRaf = 0;
this._positionPicker();
});
};
private _positionPicker(): void {
if (!this._open) return;
const trigger = this.renderRoot.querySelector<HTMLElement>('.trigger');
const popup = this.renderRoot.querySelector<HTMLElement>('.picker') as any;
if (!trigger || !popup) return;
popup.style.visibility = 'hidden';
if (popup.showPopover) {
try {
if (!popup.matches(':popover-open')) popup.showPopover();
} catch {
/* fixed-position fallback remains usable without the Popover API */
}
}
const anchor = trigger.getBoundingClientRect();
const box = popup.getBoundingClientRect();
const gap = 7;
const edge = 8;
const maxLeft = Math.max(edge, window.innerWidth - box.width - edge);
let left = anchor.left;
if (left + box.width > window.innerWidth - edge) left = anchor.right - box.width;
left = Math.min(maxLeft, Math.max(edge, left));
const below = anchor.bottom + gap;
const above = anchor.top - gap - box.height;
let top = below;
if (below + box.height > window.innerHeight - edge && above >= edge) top = above;
else top = Math.min(Math.max(edge, top), Math.max(edge, window.innerHeight - box.height - edge));
popup.style.left = `${Math.round(left)}px`;
popup.style.top = `${Math.round(top)}px`;
popup.style.visibility = '';
}
private _emit(color: string, opacity: number): void {
@@ -202,7 +267,7 @@ export class HpColorOpacity extends LitElement {
<span class="swatch" style=${`background:${this.color};opacity:${pct / 100}`}></span>
</button>
${this._open && !this.disabled ? html`
<div class="picker ${this._alignEnd ? 'end' : ''}" role="dialog" aria-label=${this.label || 'Color'}>
<div class="picker" popover="manual" role="dialog" aria-label=${this.label || 'Color'}>
<div class="row">
<span class="caption">${this.label || 'Color'}</span>
<input type="color" .value=${this.color} aria-label=${this.label || 'Color'}
+3 -1
View File
@@ -88,7 +88,7 @@
"opening.no_entities": "No sensors bound — a static symbol on the plan.",
"toast.opening_no_wall": "Click next to a room wall — openings sit on walls",
"markup.delete": "Delete",
"markup.hint_points": "points: {n} · Esc/Ctrl+Z — undo a dot · close the outline by clicking the first one",
"markup.hint_points": "points: {n} · Esc/Ctrl+Z — undo a dot · first point or Ctrl/Cmd+click — close",
"markup.hint_start": "click a grid dot to start the outline",
"tip.lqi": "average zigbee signal:",
"tip.area": "area: {value}",
@@ -157,6 +157,8 @@
"toast.conflict": "Config was changed in another window — data refreshed, repeat your last action",
"toast.cfg_save_failed": "Failed to save config: {err}",
"toast.room_overlap": "The outline overlaps room “{name}” — rooms must not overlap",
"toast.contour_min_edges": "Draw at least two edges before closing the outline",
"toast.contour_cannot_close": "The outline cannot close because it is degenerate or intersects itself",
"toast.merge_not_adjacent": "Only rooms that share a wall can be merged",
"toast.rooms_merged": "Rooms merged into “{name}”",
"toast.split_pick_wall": "Start the cut on the room’s wall",
+3 -1
View File
@@ -88,7 +88,7 @@
"opening.no_entities": "Датчики не привязаны — статичный символ на плане.",
"toast.opening_no_wall": "Кликните рядом со стеной комнаты — проёмы ставятся на стены",
"markup.delete": "Удалить",
"markup.hint_points": "точек: {n} · Esc/Ctrl+Z — убрать точку · замкните контур кликом по первой",
"markup.hint_points": "точек: {n} · Esc/Ctrl+Z — убрать точку · клик по первой или Ctrl+клик — замкнуть",
"markup.hint_start": "кликните точку сетки, чтобы начать контур",
"tip.lqi": "средний сигнал zigbee:",
"tip.area": "площадь: {value}",
@@ -157,6 +157,8 @@
"toast.conflict": "Конфиг изменён в другом окне — данные обновлены, повторите последнее действие",
"toast.cfg_save_failed": "Не удалось сохранить конфиг: {err}",
"toast.room_overlap": "Контур накладывается на комнату «{name}» — комнаты не должны накладываться",
"toast.contour_min_edges": "Чтобы замкнуть контур, сначала нарисуйте минимум две грани",
"toast.contour_cannot_close": "Контур нельзя замкнуть: он вырожден или пересекает сам себя",
"toast.merge_not_adjacent": "Объединять можно только комнаты с общей стеной",
"toast.rooms_merged": "Комнаты объединены в «{name}»",
"toast.split_pick_wall": "Начните разрез на стене комнаты",
+27 -5
View File
@@ -9,7 +9,7 @@
*/
import { alignAllToGrid, type AlignReport } from './align-grid';
import { effectiveMarkerControls } from './devices';
import { hasLegacySelfLightIntent } from './devices';
import {
DECOR_TEXT_BASE, decorTextScale, liveTextReference, liveTextToken, roomPoly,
} from './logic';
@@ -26,8 +26,10 @@ import {
} from './wall-thickness';
/** Bump when a new lossless maintenance pass is added. */
export const PLAN_MODEL_VERSION = 3;
export const PLAN_MODEL_VERSION = 4;
const DEFAULT_CELL_CM = 5;
const CELL_CM_MIN = 0.1;
const CELL_CM_MAX = 1000;
const DECOR_WIDTH_CM_MIN = 0.1;
const DECOR_WIDTH_CM_MAX = 100;
const DECOR_TEXT_CM_MIN = 0.1;
@@ -86,7 +88,18 @@ const migrateLosslessly = (config: any): number => {
for (const marker of config.markers || []) {
if (marker.display === 'ripple') { marker.display = 'icon_ripple'; n++; }
if (Array.isArray(marker.controls)) {
const controls = effectiveMarkerControls(marker.binding, marker.controls);
// R2: preserve the old, once-supported declaration that an entity-bound
// switch is itself a light source before removing its redundant control.
if (hasLegacySelfLightIntent(marker.binding, marker.controls) && marker.is_light !== true) {
marker.is_light = true;
n++;
}
// R6: maintenance knows only the direct entity binding. Do not run the
// runtime filter here: YAML-only targets and duplicates are user data,
// not garbage that a "lossless" optimiser may silently discard.
const self = typeof marker.binding === 'string' && marker.binding.startsWith('entity:')
? marker.binding.slice('entity:'.length) : '';
const controls = self ? marker.controls.filter((eid: unknown) => eid !== self) : marker.controls;
if (JSON.stringify(controls) !== JSON.stringify(marker.controls)) {
marker.controls = controls.length ? controls : null;
n++;
@@ -114,8 +127,17 @@ const migrateLosslessly = (config: any): number => {
}
}
const rawCellCm = Number(space.cell_cm);
const cellCm = Number.isFinite(rawCellCm) && rawCellCm > 0
? rawCellCm : DEFAULT_CELL_CM;
if (own(space, 'cell_cm') && (
!Number.isFinite(rawCellCm) || rawCellCm < CELL_CM_MIN || rawCellCm > CELL_CM_MAX
)) {
space.cell_cm = Number.isFinite(rawCellCm)
? clamp(rawCellCm, CELL_CM_MIN, CELL_CM_MAX)
: DEFAULT_CELL_CM;
n++;
}
const repairedCellCm = Number(space.cell_cm);
const cellCm = Number.isFinite(repairedCellCm) && repairedCellCm > 0
? repairedCellCm : DEFAULT_CELL_CM;
for (const shape of space.decor || []) {
if (own(shape, 'width')) {
const legacyWidth = Number(shape.width);
+4 -3
View File
@@ -219,6 +219,7 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
: null;
const pxPerUnit = o.stageWidth && vb[2] ? o.stageWidth / vb[2] : 1;
const solidWall = !!wallUnion && wallBodyNeedsSolid(wallUnion.depthUnits, pxPerUnit);
const wallStroke = disp.color || '#607d8b';
return html`
<div class="hp-static-stage" style="aspect-ratio:${vb[2]}/${vb[3]}${stageBg ? ';background:' + stageBg : ''};--wall-fill:${colors.wall_fill.c};--wall-fill-op:${colors.wall_fill.a}">
@@ -226,7 +227,7 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
${wallUnion ? svg`<defs>
<pattern id="hp-wall-hatch" patternUnits="userSpaceOnUse" width="8" height="8"
patternTransform="rotate(45)">
<path d="M0 0 L0 8" stroke="${disp.color}" stroke-width="2"></path>
<path d="M0 0 L0 8" stroke="${wallStroke}" stroke-width="2"></path>
</pattern>
</defs>` : nothing}
${paperShapes.map((sh) =>
@@ -243,13 +244,13 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
: nothing}
${roomShapes}
${wallUnion
? svg`<g class="wallbodies" style="--room-stroke:${disp.color}">
? svg`<g class="wallbodies" style="--room-stroke:${wallStroke}">
<path class="wallbody-fill" d="${wallUnion.d}"
fill="${colors.wall_fill.c}" fill-opacity="${colors.wall_fill.a}" fill-rule="evenodd"
stroke="none" pointer-events="none"></path>
<path class="wallbody ${solidWall ? 'solid' : ''}" data-hp="wall" data-id="union" data-kind="union"
d="${wallUnion.d}" fill="${solidWall ? 'none' : 'url(#hp-wall-hatch)'}" fill-rule="evenodd"
stroke="${disp.color || 'var(--hp-muted, #607d8b)'}" stroke-width="0.6" pointer-events="none"></path>
stroke="${wallStroke}" stroke-width="0.6" pointer-events="none"></path>
</g>`
: nothing}
</svg>
+118 -8
View File
@@ -178,6 +178,14 @@ export const cardStyles = css`
touch-action: none; /* custom pinch/pan gestures */
background: var(--ha-card-background, var(--card-background-color, #111));
}
/* A deliberate navigation change gets one short visual settle. The class
is transient, so normal day/night breathing keeps its slow cadence. */
.stage.hpnav,
.stage.daynight.hpnav {
transition:
background-color 0.18s ease,
outline-color 0.18s ease;
}
.zoomwrap {
position: absolute;
inset: 0;
@@ -679,20 +687,41 @@ export const cardStyles = css`
gap: 0.25em;
font-size: calc(1em * var(--rl-name, 1));
}
/* Switching spaces by swipe or on the kiosk carousel: the plan flies out
the way the finger went and the next one arrives from the other side. */
/* Space changes keep their direction, but travel only a few percent: the
motion explains what changed without making the whole plan fly around. */
@keyframes hp-slide-left {
0% { transform: translateX(22%); opacity: 0; }
0% { transform: translateX(4%); opacity: 0.72; }
100% { transform: translateX(0); opacity: 1; }
}
@keyframes hp-slide-right {
0% { transform: translateX(-22%); opacity: 0; }
0% { transform: translateX(-4%); opacity: 0.72; }
100% { transform: translateX(0); opacity: 1; }
}
.zoomwrap.slide-left { animation: hp-slide-left 0.26s cubic-bezier(0.22, 0.61, 0.36, 1); }
.zoomwrap.slide-right { animation: hp-slide-right 0.26s cubic-bezier(0.22, 0.61, 0.36, 1); }
@keyframes hp-editor-enter {
0% { transform: translateY(5px) scale(0.997); opacity: 0.72; }
100% { transform: translateY(0) scale(1); opacity: 1; }
}
@keyframes hp-editor-exit {
0% { transform: translateY(-4px) scale(1.002); opacity: 0.72; }
100% { transform: translateY(0) scale(1); opacity: 1; }
}
@keyframes hp-editor-swap {
0% { transform: scale(0.997); opacity: 0.68; }
100% { transform: scale(1); opacity: 1; }
}
.zoomwrap.slide-left { animation: hp-slide-left 0.18s cubic-bezier(0.2, 0.7, 0.2, 1); }
.zoomwrap.slide-right { animation: hp-slide-right 0.18s cubic-bezier(0.2, 0.7, 0.2, 1); }
.zoomwrap.nav-enter { animation: hp-editor-enter 0.18s cubic-bezier(0.2, 0.7, 0.2, 1); }
.zoomwrap.nav-exit { animation: hp-editor-exit 0.18s cubic-bezier(0.2, 0.7, 0.2, 1); }
.zoomwrap.nav-swap { animation: hp-editor-swap 0.16s ease-out; }
@media (prefers-reduced-motion: reduce) {
.zoomwrap.slide-left, .zoomwrap.slide-right { animation: none; }
.stage.hpnav,
.stage.daynight.hpnav { transition: none; }
.zoomwrap.slide-left,
.zoomwrap.slide-right,
.zoomwrap.nav-enter,
.zoomwrap.nav-exit,
.zoomwrap.nav-swap { animation: none; }
}
/* The name is the anchor: the label box is centred on the room point, so
anything that takes part in its layout SHIFTS THE NAME. The gear button
@@ -951,8 +980,15 @@ export const cardStyles = css`
new label on top of the old one. Only labels: a line or a rectangle
under the text tool stays inert, so the press reaches the stage and a
new label is created there. */
.stage.mode-decor.dtool-text .decorlayer .dshape.dtext,
.stage.mode-decor.dtool-select .decorlayer .dshape.dtext,
.stage.mode-decor.dtool-erase .decorlayer .dshape.dtext {
/* A label is one logical decor object. SVG's visiblePainted would hit
only the ink of individual glyphs, so spaces, counters and the area
inside its selection glow behaved like empty canvas. */
pointer-events: bounding-box;
}
.stage.mode-decor.dtool-text .decorlayer .dshape.dtext {
pointer-events: visiblePainted;
cursor: text;
}
.decorlayer .dsel {
@@ -1301,7 +1337,9 @@ export const cardStyles = css`
font-weight: 600;
cursor: pointer;
font-family: inherit;
transition: background-color 0.14s ease, color 0.14s ease, transform 0.14s ease;
}
.modetab:active { transform: scale(0.97); }
.modetab ha-icon { --mdc-icon-size: 15px; }
.modetab .closex {
--mdc-icon-size: 13px;
@@ -2331,6 +2369,37 @@ export const cardStyles = css`
width: 100%;
box-sizing: border-box;
}
/* Room settings contains long radio labels, two source pickers and live
previews, so it deliberately uses hp-dialog's medium width. Keep its
content shrinkable as well: the generic srcrow rule is nowrap because
compact switch rows need it, but here that created a horizontal scroll
box even on a wide desktop viewport. */
hp-dialog.roomdialog .body {
min-width: 0;
overflow-x: hidden;
}
hp-dialog.roomdialog .body > * {
min-width: 0;
max-width: 100%;
box-sizing: border-box;
}
hp-dialog.roomdialog .srcrow {
min-width: 0;
align-items: flex-start;
}
hp-dialog.roomdialog .srcrow > span:first-of-type {
min-width: 0;
white-space: normal;
overflow-wrap: anywhere;
line-height: 1.35;
}
hp-dialog.roomdialog .dropbtn > b,
hp-dialog.roomdialog .dropbtn > .ref {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
hp-dialog .row {
display: flex;
justify-content: flex-end;
@@ -2343,6 +2412,47 @@ export const cardStyles = css`
align-items: center;
flex-wrap: wrap;
}
hp-dialog .row.roomfooter {
align-items: center;
flex-wrap: wrap;
}
/* Keep the last editor bar mounted while the row collapses. This makes
both entering and leaving an editor change the card geometry gradually;
the header ResizeObserver keeps the stage fitted throughout. */
.editorchrome {
display: grid;
grid-template-rows: 0fr;
opacity: 0;
visibility: hidden;
overflow: hidden;
transition:
grid-template-rows 0.18s cubic-bezier(0.2, 0.7, 0.2, 1),
opacity 0.12s ease,
visibility 0s linear 0.18s;
}
.editorchrome.open {
grid-template-rows: 1fr;
opacity: 1;
visibility: visible;
overflow: visible;
transition-delay: 0s;
}
.editorchrome.open.nav-enter {
overflow: hidden;
}
.editorchrome-inner {
min-height: 0;
}
.editorchrome-inner.nav-enter { animation: hp-editor-enter 0.18s cubic-bezier(0.2, 0.7, 0.2, 1); }
.editorchrome-inner.nav-exit { animation: hp-editor-exit 0.18s cubic-bezier(0.2, 0.7, 0.2, 1); }
.editorchrome-inner.nav-swap { animation: hp-editor-swap 0.16s ease-out; }
@media (prefers-reduced-motion: reduce) {
.modetab { transition: none; }
.editorchrome { transition: none; }
.editorchrome-inner.nav-enter,
.editorchrome-inner.nav-exit,
.editorchrome-inner.nav-swap { animation: none; }
}
/* Device info can have Edit + Open in HA + Close. A small HA dialog is
narrower than those three Russian-labelled actions; without wrapping,
HA's end-aligned footer pushed the first button outside the surface. */