feat: redesign device marker faces

Issue: #179
User-Visible: yes
This commit is contained in:
Sergey Matyunin
2026-08-19 22:37:25 +03:00
parent 6731691ad8
commit 48bcdafab9
37 changed files with 2484 additions and 1330 deletions
+51 -18
View File
@@ -18,6 +18,13 @@ export interface LegacySupplementalMetric {
suffix: string;
}
/** HA theme state is authoritative when available; CSS color-scheme remains the fallback. */
export function deviceThemeClass(hass: any): string {
return typeof hass?.themes?.darkMode === 'boolean'
? hass.themes.darkMode ? 'theme-dark' : 'theme-light'
: '';
}
/**
* Issue #90 replaced the automatic temperature/humidity satellites with one
* configurable value badge. Untouched legacy markers, however, could show
@@ -62,6 +69,16 @@ export function lqiClassName(badge: ResolvedValueBadge | null | undefined): stri
return `lqi${badge?.position === 'bottom' ? ' below-value-badge' : ''}`;
}
/** Deterministic fit without DOM measurement or a per-marker ResizeObserver. */
export function deviceTextScale(text: string): number {
const units = [...String(text)].reduce((sum, char) => {
if (/\s/.test(char)) return sum + 0.35;
return sum + (char.codePointAt(0)! > 0xff ? 1 : 0.62);
}, 0);
if (units <= 8) return 0.45;
return Math.max(0.25, Math.round((0.45 * 8 / units) * 1000) / 1000);
}
/**
* Render only the face contents. The owning surface keeps coordinates,
* pointer handlers, tooltip and selection semantics; this fragment is shared.
@@ -73,6 +90,15 @@ export function renderDeviceFace(
const pulse = presentation.pulse;
const gen2 = pulse.generation % 2 === 0;
const legacyMetrics = legacySupplementalMetrics(presentation);
const badge = presentation.valueBadge;
const shellPosition = badge?.position || 'right';
const hasSections = !!badge || legacyMetrics.length > 0;
const shellClasses = [
'device-shell',
presentation.valueText != null ? 'text-shell' : '',
hasSections ? `with-values pos-${shellPosition}` : '',
legacyMetrics.length ? 'with-legacy' : '',
].filter(Boolean).join(' ');
return html`
${pulse.kind !== 'none' && pulse.reducedMotionIndicator !== 'dot'
? html`<span class="device-pulse activity-ring ${pulse.kind} ${pulse.reason} reason-${pulse.reason} ${gen2 ? 'gen2' : ''}"
@@ -87,25 +113,32 @@ export function renderDeviceFace(
${presentation.haDisabled
? html`<span class="habadge" title=${options.disabledTitle || ''} aria-hidden="true"><ha-icon icon="mdi:power-plug-off-outline"></ha-icon></span>`
: nothing}
${presentation.valueText != null
? html`<span class="valtext" title=${presentation.valueFullText || presentation.valueText}
aria-label=${presentation.valueFullText || presentation.valueText}>${presentation.valueText}</span>`
: html`<ha-icon icon=${presentation.icon}
style=${presentation.angle ? `transform:rotate(${presentation.angle}deg)` : nothing}></ha-icon>`}
${presentation.valueBadge
? html`<span
class=${valueBadgeClassName(presentation.valueBadge)}
title=${valueBadgeTitle(presentation.valueBadge)}
aria-hidden="true"
>${presentation.valueBadge.text}</span>`
: nothing}
${legacyMetrics.map((metric) => html`<span
class="value-badge legacy-secondary pos-right available tone-${metric.kind}"
title=${metric.text + metric.suffix}
aria-hidden="true"
>${metric.text}${metric.suffix}</span>`)}
<span class=${shellClasses} aria-hidden="true">
<span class="device-core">
${presentation.valueText != null
? html`<span class="valtext" title=${presentation.valueFullText || presentation.valueText}
style=${`--value-font-scale:${deviceTextScale(presentation.valueFullText || presentation.valueText)}`}
>${presentation.valueText}</span>`
: html`<ha-icon icon=${presentation.icon}
style=${presentation.angle ? `transform:rotate(${presentation.angle}deg)` : nothing}></ha-icon>`}
</span>
${hasSections ? html`<span class="device-sections">
${badge
? html`<span
class=${valueBadgeClassName(badge)}
title=${valueBadgeTitle(badge)}
style=${`--value-font-scale:${deviceTextScale(badge.fullText || badge.text)}`}
>${badge.text}</span>`
: nothing}
${legacyMetrics.map((metric) => html`<span
class="value-badge legacy-secondary available tone-${metric.kind}"
title=${metric.text + metric.suffix}
style=${`--value-font-scale:${deviceTextScale(metric.text + metric.suffix)}`}
>${metric.text}${metric.suffix}</span>`)}
</span>` : nothing}
</span>
${presentation.lqiText != null
? html`<span class=${lqiClassName(presentation.valueBadge)}
? html`<span class="${lqiClassName(presentation.valueBadge)}${presentation.lqiBand ? ` band-${presentation.lqiBand}` : ''}"
style=${presentation.lqiColor ? `color:${presentation.lqiColor}` : nothing}>${presentation.lqiText}</span>`
: nothing}
`;
+48 -5
View File
@@ -16,14 +16,16 @@ import {
type DeviceActivity, type DeviceVisualState, type EntityVisualSample,
} from './device-visual';
import {
hassValue, lightColorOf, lqiColor, normalizeDeviceDisplay, stateIcon, valueWithUnit,
hassValue, lightColorOf, normalizeDeviceDisplay, stateIcon, valueWithUnit,
type DeviceDisplayMode,
} from './logic';
import { resolveToggleIntent, toggleCoverEntity } from './device-toggle';
import type { DevItem } from './types';
import { safeStoredColor } from './color';
import { resolveDeviceValueBadge, type ResolvedValueBadge } from './device-value-badge';
import { resolveDevicePulse, type ResolvedDevicePulse } from './device-pulse';
import {
DEVICE_PULSE_DEFAULT_SCALE, resolveDevicePulse, type ResolvedDevicePulse,
} from './device-pulse';
import { isManualVirtualLightMarker } from './virtual-light-state';
export type PresentationSourceKind =
@@ -36,6 +38,35 @@ export type PresentationSourceRole =
export type ValueFallbackReason =
| 'value_no_state' | 'value_ambiguous_sources' | 'value_non_scalar' | 'value_virtual';
export type DeviceLockState = 'locked' | 'unlocked' | null;
export type DeviceLqiBand = 'low' | 'mid' | 'high';
export type DeviceA11yState =
| 'neutral' | 'working' | 'open' | 'locked' | 'unlocked' | 'alarm' | 'unavailable';
export function markerLqiBand(lqi: number): DeviceLqiBand {
if (lqi <= 40) return 'low';
if (lqi < 180) return 'mid';
return 'high';
}
/** Marker numbers are categorical. Room fill deliberately keeps logic.ts's gradient. */
export function markerLqiColor(lqi: number): string {
const band = markerLqiBand(lqi);
return band === 'low' ? '#F0410C' : band === 'mid' ? '#F0A00C' : '#1DC21D';
}
export function deviceA11yState(presentation: Pick<
ResolvedDevicePresentation, 'visual' | 'lockState' | 'display'
>): DeviceA11yState {
if (presentation.display === 'static_icon') return 'neutral';
if (presentation.visual.status === 'alarm') return 'alarm';
if (presentation.visual.availability === 'unavailable') return 'unavailable';
if (presentation.lockState) return presentation.lockState;
if (presentation.visual.status === 'working') return 'working';
if (presentation.visual.status === 'open') return 'open';
return 'neutral';
}
export type PresentationReason =
| 'neutral'
| 'working'
@@ -123,6 +154,7 @@ export interface ResolvedDevicePresentation {
sourceSignature: string;
visual: DeviceVisualState;
lockState: DeviceLockState;
display: DeviceDisplayMode;
icon: string;
valueText: string | null;
@@ -138,6 +170,7 @@ export interface ResolvedDevicePresentation {
valueBadge: ResolvedValueBadge | null;
lqiText: string | null;
lqiColor: string | null;
lqiBand: DeviceLqiBand | null;
lightColor: string | null;
scale: number;
angle: number;
@@ -522,7 +555,7 @@ function explanationReason(
export function presentationClasses(presentation: Pick<
ResolvedDevicePresentation,
'visual' | 'activity' | 'display' | 'effectiveHidden' | 'activityGeneration' | 'pulse'
'visual' | 'lockState' | 'activity' | 'display' | 'effectiveHidden' | 'activityGeneration' | 'pulse'
>): string[] {
if (presentation.effectiveHidden) return [];
if (presentation.display === 'static_icon') return ['static-icon'];
@@ -532,6 +565,7 @@ export function presentationClasses(presentation: Pick<
else if (visual.availability === 'unavailable') classes.push('unavail');
else if (visual.status === 'working') classes.push('on');
else if (visual.status === 'open') classes.push('open');
if (presentation.lockState) classes.push(`lock-${presentation.lockState}`);
// Keep the semantic class names as styling/debug hooks during the beta;
// they no longer select a separate renderer or animation implementation.
if (presentation.pulse.reason !== 'none' && presentation.pulse.reason !== 'alarm') {
@@ -563,6 +597,12 @@ export function resolveDevicePresentation(
const userHidden = d.userHidden === true || d.marker?.hidden === true;
const effectiveHidden = haDisabled || (userHidden && !options.designPreview);
const combined = combineVisualSamples(sources.samples);
const lockSource = sources.visualSources.find((source) => source.eid.startsWith('lock.'));
const lockState: DeviceLockState = lockSource
? ['unlocked', 'open'].includes(lockSource.state.toLowerCase())
? 'unlocked'
: lockSource.state.toLowerCase() === 'locked' ? 'locked' : null
: null;
let visual = combined;
if (effectiveHidden) visual = { availability: 'available', status: 'neutral', activity: 'none' };
else if (staticIcon) visual = { availability: 'available', status: 'neutral', activity: 'none' };
@@ -626,7 +666,8 @@ export function resolveDevicePresentation(
: null;
const scale = Number(d.marker?.size) > 0 ? Number(d.marker!.size) : 1;
const angle = Number(d.marker?.angle) || 0;
const rippleScale = Number(d.marker?.ripple_size) > 0 ? Number(d.marker!.ripple_size) : 3;
const rippleScale = Number(d.marker?.ripple_size) > 0
? Number(d.marker!.ripple_size) : DEVICE_PULSE_DEFAULT_SCALE;
const configuredRippleColor = safeStoredColor(d.marker?.ripple_color, null);
const rippleColor = staticIcon ? null : configuredRippleColor || lightColor || null;
const pulse = resolveDevicePulse({
@@ -673,6 +714,7 @@ export function resolveDevicePresentation(
valueSource: value.source,
sourceSignature,
visual,
lockState,
display,
icon,
valueText,
@@ -686,7 +728,8 @@ export function resolveDevicePresentation(
humText: hum == null ? null : String(hum),
valueBadge,
lqiText: lqi == null || valueBadge?.isLqi ? null : String(lqi),
lqiColor: lqi == null ? null : lqiColor(lqi),
lqiColor: lqi == null ? null : markerLqiColor(lqi),
lqiBand: lqi == null ? null : markerLqiBand(lqi),
lightColor,
scale,
angle,
+24 -8
View File
@@ -31,8 +31,24 @@ export interface ResolveDevicePulseOptions {
diameterScale?: number;
}
const ALARM_COLOR = '#f25a4a';
const ALARM_DIAMETER_SCALE = 3;
export const DEVICE_ACTIVITY_BLUE = '#0C82F0';
export const DEVICE_ACTIVITY_AMBER = '#F0A00C';
export const DEVICE_ACTIVITY_GREEN = '#1DC21D';
export const DEVICE_ALERT_RED = '#F0410C';
export const DEVICE_PULSE_DEFAULT_SCALE = 1.5;
const ALARM_DIAMETER_SCALE = DEVICE_PULSE_DEFAULT_SCALE;
function semanticPulseColor(
visual: DeviceVisualState,
semanticActivity: DeviceActivity,
): string {
if (semanticActivity === 'presence') return DEVICE_ACTIVITY_GREEN;
if (semanticActivity === 'running' || visual.status === 'working' || visual.status === 'open') {
return DEVICE_ACTIVITY_AMBER;
}
return DEVICE_ACTIVITY_BLUE;
}
function noPulse(
generation: number,
@@ -60,8 +76,8 @@ export function resolveDevicePulse(options: ResolveDevicePulseOptions): Resolved
} = options;
const generation = Math.max(1, Math.trunc(options.shortGeneration || 1));
const diameterScale = Number.isFinite(options.diameterScale)
? Math.max(1, Number(options.diameterScale)) : 3;
const color = options.color || null;
? Math.max(1, Number(options.diameterScale)) : DEVICE_PULSE_DEFAULT_SCALE;
const color = options.color || semanticPulseColor(visual, semanticActivity);
if (effectiveHidden || bindingUnavailable || visual.availability === 'unavailable'
|| display === 'static_icon') {
return noPulse(generation, color, diameterScale);
@@ -72,7 +88,7 @@ export function resolveDevicePulse(options: ResolveDevicePulseOptions): Resolved
if (visual.status === 'alarm') {
return {
kind: 'alarm', reason: 'alarm', generation, expiresAt: null,
color: ALARM_COLOR, diameterScale: ALARM_DIAMETER_SCALE, animated: !reducedMotion,
color: DEVICE_ALERT_RED, diameterScale: ALARM_DIAMETER_SCALE, animated: !reducedMotion,
reducedMotionIndicator: 'none',
};
}
@@ -83,7 +99,7 @@ export function resolveDevicePulse(options: ResolveDevicePulseOptions): Resolved
if (options.shortReason && expiresAt != null && expiresAt > now) {
return {
kind: 'short', reason: options.shortReason, generation, expiresAt,
color: reducedMotion ? null : color,
color,
diameterScale: reducedMotion ? 1 : diameterScale,
animated: !reducedMotion,
reducedMotionIndicator: reducedMotion ? 'dot' : 'none',
@@ -95,7 +111,7 @@ export function resolveDevicePulse(options: ResolveDevicePulseOptions): Resolved
|| semanticActivity === 'running') {
return {
kind: 'continuous', reason: semanticActivity, generation, expiresAt: null,
color: reducedMotion ? null : color,
color,
diameterScale: reducedMotion ? 1 : diameterScale,
animated: !reducedMotion,
reducedMotionIndicator: reducedMotion ? 'dot' : 'none',
@@ -117,7 +133,7 @@ export function withDemoPulse(
reason: kind === 'short' ? 'event' : 'running',
generation: Math.max(1, Math.trunc(generation)),
expiresAt: kind === 'short' ? expiresAt : null,
color: reducedMotion ? null : source.color,
color: source.color,
diameterScale: reducedMotion ? 1 : source.diameterScale,
animated: !reducedMotion,
reducedMotionIndicator: reducedMotion ? 'dot' : 'none',
+31 -10
View File
@@ -196,7 +196,7 @@ import {
type DeviceActivity, type DeviceVisualState, type EntityVisualSample,
} from './device-visual';
import {
activitySourceSignature, presentationClasses, resolveDevicePresentation,
activitySourceSignature, deviceA11yState, presentationClasses, resolveDevicePresentation,
resolvePresentationSources, type ResolvedDevicePresentation,
} from './device-presentation';
import {
@@ -211,7 +211,7 @@ import {
createRenderDeviceSnapshot, presentationSnapshotKey, renderDeviceSnapshotPositions,
type RenderDeviceSnapshot,
} from './render-device-snapshot';
import { deviceFaceStyle, renderDeviceFace } from './device-face';
import { deviceFaceStyle, deviceThemeClass, renderDeviceFace } from './device-face';
import {
ModeTransitionController, viewportFromViewBox,
type HouseplanMode, type ModeTransitionState, type ModeVisualState, type ModeViewBox,
@@ -1527,7 +1527,7 @@ class HouseplanCard extends LitElement {
autoIcon: string; // the icon the rules would give — picker placeholder
display: DeviceDisplayMode;
rippleColor: string; // '' = accent
rippleSize: number; // in icon diameters
rippleSize: number; // in icon diameters; package default 1.5
size: number; // icon size multiplier
angle: number; // icon rotation, degrees
/** UI projection. A legacy persisted `cover` is shown as `toggle`. */
@@ -4528,7 +4528,7 @@ class HouseplanCard extends LitElement {
else this._infoCard = d;
}
private _clickDevice(ev: MouseEvent, d: DevItem): void {
private _clickDevice(ev: Event, d: DevItem): void {
ev.stopPropagation();
if (this._drag?.moved || this._suppressClick || this._holdFired) return;
if (this._mode === 'plan') return;
@@ -4644,6 +4644,13 @@ class HouseplanCard extends LitElement {
this._infoCard = actionDevice;
}
private _keyDevice(ev: KeyboardEvent, d: DevItem): void {
if (ev.key !== 'Enter' && ev.key !== ' ') return;
if (this._mode !== 'view' && this._mode !== 'devices') return;
ev.preventDefault();
this._clickDevice(ev, d);
}
/** 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);
@@ -12446,7 +12453,7 @@ class HouseplanCard extends LitElement {
autoIcon: d.icon || '',
display: normalizeDeviceDisplay(d.marker?.display),
rippleColor: safeStoredColor(d.marker?.ripple_color, ''),
rippleSize: Number(d.marker?.ripple_size) > 0 ? Number(d.marker!.ripple_size) : 3,
rippleSize: Number(d.marker?.ripple_size) > 0 ? Number(d.marker!.ripple_size) : 1.5,
size: Number(d.marker?.size) > 0 ? Number(d.marker!.size) : 1,
angle: Number(d.marker?.angle) || 0,
tapAction: projectedTapAction(d.marker?.tap_action, d.primary?.split('.')[0]),
@@ -12508,7 +12515,7 @@ class HouseplanCard extends LitElement {
this._markerDialog = {
name: '', binding: 'virtual', bindingMode: 'virtual', bindingOpen: false,
showEntities: false, bindingFilter: '', icon: '', autoIcon: '',
display: 'badge', rippleColor: '', rippleSize: 3, size: 1, angle: 0,
display: 'badge', rippleColor: '', rippleSize: 1.5, size: 1, angle: 0,
tapAction: 'info', tapActionTouched: false,
originalHasTapAction: false, originalTapAction: undefined, tapHintAnnouncement: '',
toggleEntity: '', toggleEntityTouched: false,
@@ -12997,7 +13004,7 @@ class HouseplanCard extends LitElement {
icon: dlg.icon || null,
display: dlg.display !== 'badge' ? dlg.display : null,
ripple_color: dlg.display === 'icon_ripple' && dlg.rippleColor ? dlg.rippleColor : null,
ripple_size: dlg.display === 'icon_ripple' && dlg.rippleSize !== 3 ? dlg.rippleSize : null,
ripple_size: dlg.display === 'icon_ripple' && dlg.rippleSize !== 1.5 ? dlg.rippleSize : null,
size: dlg.size !== 1 ? dlg.size : null,
angle: dlg.angle ? dlg.angle : null,
...this._markerTapActionFields(dlg),
@@ -16740,11 +16747,20 @@ class HouseplanCard extends LitElement {
const ghostLabel = presentation.haDisabled
? this._t((`marker.ha_disabled_${disabledReason}`) as any)
: d.userHidden ? this._t('marker.hidden_ghost') : d.name;
const a11yState = deviceA11yState(presentation);
const interactive = this._mode === 'view' || this._mode === 'devices';
const deviceAriaLabel = [
ghostLabel,
!presentation.haDisabled
? this._t((`marker.state_a11y_${a11yState}`) as I18nKey) : '',
presentation.pulse.kind !== 'none'
? this._t((`marker.pulse_a11y_${presentation.pulse.reason}`) as I18nKey) : '',
presentation.valueFullText || presentation.valueText || '',
valueBadgeTitle(presentation.valueBadge),
presentation.lqiText != null && presentation.lqiBand
? this._t((`marker.lqi_a11y_${presentation.lqiBand}`) as I18nKey, {
value: presentation.lqiText,
}) : '',
].filter(Boolean).join(', ');
const metrics = [
d.model,
@@ -16762,10 +16778,15 @@ class HouseplanCard extends LitElement {
data-area=${d.area || nothing}
data-binding-status=${presentation.haDisabled ? 'ha-disabled' : d.bindingStatus?.kind || 'active'}
data-disabled-reason=${disabledReason ? disabledReason.replace('_', '-') : nothing}
data-state=${a11yState}
data-lqi-band=${presentation.lqiText != null ? presentation.lqiBand || nothing : nothing}
role=${interactive ? 'button' : nothing}
tabindex=${interactive ? '0' : nothing}
aria-label=${deviceAriaLabel}
class="dev ${presentation.classes.join(' ')} ${this._selId === d.id ? 'sel' : ''} ${d.virtual ? 'virtual' : ''} ${d.hidden ? 'ghost' : ''} ${presentation.haDisabled ? 'ha-disabled' : ''} ${presentation.valueText != null ? 'valonly' : ''}"
class="dev ${deviceThemeClass(this._renderPlanHass)} ${presentation.classes.join(' ')} ${this._selId === d.id ? 'sel' : ''} ${d.virtual ? 'virtual' : ''} ${d.hidden ? 'ghost' : ''} ${presentation.haDisabled ? 'ha-disabled' : ''} ${presentation.valueText != null ? 'valonly' : ''}"
style="${st.join(';')}"
@click=${(e: MouseEvent) => this._clickDevice(e, d)}
@keydown=${(e: KeyboardEvent) => this._keyDevice(e, d)}
@contextmenu=${(e: MouseEvent) => this._ctxDevice(e, d)}
@mousemove=${(e: MouseEvent) =>
this._showTip(e, d.name, presentation.haDisabled ? ghostLabel : metrics)}
@@ -18445,7 +18466,7 @@ class HouseplanCard extends LitElement {
icon: d.icon || null,
display: d.display !== 'badge' ? d.display : null,
ripple_color: d.display === 'icon_ripple' && d.rippleColor ? d.rippleColor : null,
ripple_size: d.display === 'icon_ripple' && d.rippleSize !== 3 ? d.rippleSize : null,
ripple_size: d.display === 'icon_ripple' && d.rippleSize !== 1.5 ? d.rippleSize : null,
size: d.size !== 1 ? d.size : null,
angle: d.angle || null,
...this._markerTapActionFields(d),
@@ -19416,7 +19437,7 @@ class HouseplanCard extends LitElement {
</div>
<div class="colorrow ripple-sizerow">
<span class="opl">${this._t('marker.ripple_size')}</span>
${this._rangeInput(2, 8, 0.5, d.rippleSize, (n) => (this._markerDialog = { ...d, rippleSize: n }))}
${this._rangeInput(1, 8, 0.5, d.rippleSize, (n) => (this._markerDialog = { ...d, rippleSize: n }))}
<span class="opv">×${d.rippleSize}</span>
</div>
<p class="muted" role="note">${this._t('marker.activity_alarm_note')}</p>`
+5 -2
View File
@@ -1,7 +1,7 @@
/** Live, read-only device display preview used by the marker editor. */
import { LitElement, css, html, nothing, type PropertyValues, type TemplateResult } from 'lit';
import { cardStyles } from './styles';
import { deviceFaceStyle, renderDeviceFace } from './device-face';
import { deviceFaceStyle, deviceThemeClass, renderDeviceFace } from './device-face';
import {
type PresentationReason,
type ResolvedDevicePresentation,
@@ -231,7 +231,7 @@ class HpDevicePreview extends LitElement {
fit < 1 ? `transform:scale(${fit})` : '',
].filter(Boolean).join(';');
const rootClasses = [
'dev', ...shown.classes,
'dev', deviceThemeClass(this.hass), ...shown.classes,
shown.binding === 'virtual' ? 'virtual' : '',
shown.haDisabled ? 'ghost ha-disabled' : '',
shown.valueText != null ? 'valonly' : '',
@@ -248,6 +248,9 @@ class HpDevicePreview extends LitElement {
result,
actual.valueFullText || '',
valueBadgeTitle(actual.valueBadge),
actual.lqiText != null && actual.lqiBand
? this._t((`marker.lqi_a11y_${actual.lqiBand}`) as any, { value: actual.lqiText })
: '',
].filter(Boolean).join('. ');
return html`<section class="devicepreview">
+11 -1
View File
@@ -176,7 +176,7 @@
"marker.display_hint_icon": "The icon and dynamic plate show device state without the ordinary activity pulse. Red alarms remain visible.",
"marker.display_hint_icon_ripple": "The icon, dynamic plate and pulse show a short pulse for events and a continuous pulse for work, motion or presence. Red alarms have separate priority.",
"marker.display_hint_value": "One unambiguous Home Assistant value replaces the icon while the plate continues to show state. Red alarms remain visible.",
"marker.display_hint_static_icon": "The black plate and icon always stay the same. State, activity, unavailability and alarms do not change the face.",
"marker.display_hint_static_icon": "The theme-aware shell and icon always stay the same. State, activity, unavailability and alarms do not change the face.",
"marker.static_alarm_warning": "This device can report alarms. Static display hides the marker's visual alarm indication.",
"marker.preview.title": "Display preview",
"marker.preview.actual": "Now",
@@ -235,6 +235,16 @@
"marker.pulse_a11y_presence": "Presence detected",
"marker.pulse_a11y_transition": "Changing state",
"marker.pulse_a11y_running": "Working",
"marker.state_a11y_neutral": "Default state",
"marker.state_a11y_working": "Working",
"marker.state_a11y_open": "Open",
"marker.state_a11y_locked": "Locked",
"marker.state_a11y_unlocked": "Unlocked",
"marker.state_a11y_alarm": "Alarm",
"marker.state_a11y_unavailable": "Unavailable",
"marker.lqi_a11y_low": "LQI {value}, low signal",
"marker.lqi_a11y_mid": "LQI {value}, medium signal",
"marker.lqi_a11y_high": "LQI {value}, high signal",
"marker.size_label": "Icon size / rotation",
"marker.angle_label": "Rotate",
"marker.model_label": "Model",
+11 -1
View File
@@ -176,7 +176,7 @@
"marker.display_hint_icon": "Значок и динамическая подложка показывают состояние устройства без обычной пульсации активности. Красная тревога сохраняется.",
"marker.display_hint_icon_ripple": "Значок, динамическая подложка и пульсация: короткая — для событий, постоянная — для работы, движения или присутствия. Красная тревога имеет отдельный приоритет.",
"marker.display_hint_value": "Значок заменяется одним однозначным значением Home Assistant; подложка продолжает показывать состояние. Красная тревога сохраняется.",
"marker.display_hint_static_icon": "Всегда одинаковые чёрная подложка и значок. Состояния, активность, недоступность и тревоги не меняют отображение.",
"marker.display_hint_static_icon": "Подложка в цветах темы и значок всегда остаются одинаковыми. Состояния, активность, недоступность и тревоги не меняют отображение.",
"marker.static_alarm_warning": "Это устройство может сообщать о тревогах. Статичный режим скрывает визуальную тревогу маркера.",
"marker.preview.title": "Предпросмотр отображения",
"marker.preview.actual": "Сейчас",
@@ -235,6 +235,16 @@
"marker.pulse_a11y_presence": "Обнаружено присутствие",
"marker.pulse_a11y_transition": "Изменение состояния",
"marker.pulse_a11y_running": "Работает",
"marker.state_a11y_neutral": "Обычное состояние",
"marker.state_a11y_working": "Работает",
"marker.state_a11y_open": "Открыто",
"marker.state_a11y_locked": "Заблокировано",
"marker.state_a11y_unlocked": "Разблокировано",
"marker.state_a11y_alarm": "Тревога",
"marker.state_a11y_unavailable": "Недоступно",
"marker.lqi_a11y_low": "LQI {value}, слабый сигнал",
"marker.lqi_a11y_mid": "LQI {value}, средний сигнал",
"marker.lqi_a11y_high": "LQI {value}, сильный сигнал",
"marker.size_label": "Размер / поворот значка",
"marker.angle_label": "Поворот",
"marker.model_label": "Модель",
+14 -4
View File
@@ -33,10 +33,11 @@ import {
} from './render/opening-symbol';
import { activeRegistryHass, fullRegistryHass, type HaRegistrySnapshot } from './ha-binding-status';
import {
resolveDevicePresentation, type PresentationActivityRuntime, type ResolvedDevicePresentation,
deviceA11yState, resolveDevicePresentation,
type PresentationActivityRuntime, type ResolvedDevicePresentation,
} from './device-presentation';
import { presentationSnapshotKey } from './render-device-snapshot';
import { deviceFaceStyle, renderDeviceFace } from './device-face';
import { deviceFaceStyle, deviceThemeClass, renderDeviceFace } from './device-face';
import { valueBadgeTitle } from './device-value-badge';
import { contentFingerprint } from './visual-continuity';
import type { VirtualLightSnapshot } from './virtual-light-state';
@@ -361,15 +362,24 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
reducedMotion: o.reducedMotion,
});
const st = [`left:${left}%`, `top:${top}%`, ...deviceFaceStyle(presentation)];
const a11yState = deviceA11yState(presentation);
const deviceAriaLabel = [
d.name,
t(o.lang, (`marker.state_a11y_${a11yState}`) as any),
presentation.pulse.kind !== 'none'
? t(o.lang, (`marker.pulse_a11y_${presentation.pulse.reason}`) as any) : '',
presentation.valueFullText || presentation.valueText || '',
valueBadgeTitle(presentation.valueBadge),
presentation.lqiText != null && presentation.lqiBand
? t(o.lang, (`marker.lqi_a11y_${presentation.lqiBand}`) as any, {
value: presentation.lqiText,
}) : '',
].filter(Boolean).join(', ');
return html`<div class="dev ${presentation.classes.join(' ')} ${d.virtual ? 'virtual' : ''} ${presentation.valueText != null ? 'valonly' : ''}"
return html`<div class="dev ${deviceThemeClass(planHass)} ${presentation.classes.join(' ')} ${d.virtual ? 'virtual' : ''} ${presentation.valueText != null ? 'valonly' : ''}"
data-hp="device" data-id="${d.id}" data-entity=${d.primary || nothing} data-area=${d.area || nothing}
aria-label=${deviceAriaLabel}
role="img" aria-label=${deviceAriaLabel}
data-state=${a11yState}
data-lqi-band=${presentation.lqiText != null ? presentation.lqiBand || nothing : nothing}
data-binding-status=${d.bindingStatus?.kind === 'ha_disabled' ? 'ha-disabled' : d.bindingStatus?.kind || 'active'}
data-disabled-reason=${presentation.disabledReason ? presentation.disabledReason.replace('_', '-') : nothing}
style="${st.join(';')}">
+219 -106
View File
@@ -806,8 +806,8 @@ export const cardStyles = css`
position: absolute;
left: 50%;
top: 50%;
width: calc(var(--dev-size) * var(--ripple-scale, 3));
height: calc(var(--dev-size) * var(--ripple-scale, 3));
width: var(--dev-size);
height: var(--dev-size);
transform: translate(-50%, -50%);
pointer-events: none;
z-index: 0;
@@ -821,39 +821,41 @@ export const cardStyles = css`
}
/* A witnessed edge: exactly three waves over the 3.3 s runtime window. */
.device-pulse.short i {
animation: hp-pulse-short 1.1s ease-out 1 forwards;
animation: hp-pulse-short 1.1s cubic-bezier(.22,.61,.36,1) 1 forwards;
}
.device-pulse.short i:nth-child(2) { animation-delay: 1.1s; }
.device-pulse.short i:nth-child(3) { animation-delay: 2.2s; }
.device-pulse.short.gen2 i { animation-name: hp-pulse-short-b; }
.device-pulse.continuous i:nth-child(n + 2),
.device-pulse.alarm i:nth-child(n + 2) { display: none; }
.device-pulse.alarm i:nth-child(n + 3) { display: none; }
.device-pulse.continuous i:first-child {
animation: hp-pulse-continuous 2.4s ease-in-out infinite;
animation: hp-pulse-continuous 3.6s cubic-bezier(.45,.05,.55,.95) infinite;
}
.device-pulse.alarm i:first-child {
border-color: #f25a4a;
animation: hp-pulse-alarm 1s ease-out infinite;
.device-pulse.alarm i {
border-width: 3px;
border-color: #F0410C;
animation: hp-pulse-alarm 2.4s cubic-bezier(.22,.61,.36,1) infinite;
}
.device-pulse.alarm i:nth-child(2) { animation-delay: 1.2s; }
@keyframes hp-pulse-short {
0% { transform: scale(0.18); opacity: 0.7; }
0% { transform: scale(1); opacity: 0.55; }
70% { opacity: 0.22; }
100% { transform: scale(1); opacity: 0; }
100% { transform: scale(var(--ripple-scale, 1.5)); opacity: 0; }
}
/* Alternate identity: a rapid retrigger restarts the browser timeline. */
@keyframes hp-pulse-short-b {
0% { transform: scale(0.18); opacity: 0.7; }
0% { transform: scale(1); opacity: 0.55; }
70% { opacity: 0.22; }
100% { transform: scale(1); opacity: 0; }
100% { transform: scale(var(--ripple-scale, 1.5)); opacity: 0; }
}
@keyframes hp-pulse-continuous {
0% { transform: scale(0.26); opacity: 0.5; }
0% { transform: scale(1); opacity: 0.55; }
65% { opacity: 0.18; }
100% { transform: scale(1); opacity: 0; }
100% { transform: scale(var(--ripple-scale, 1.5)); opacity: 0; }
}
@keyframes hp-pulse-alarm {
0% { transform: scale(0.3); opacity: 0.95; }
100% { transform: scale(1); opacity: 0; }
0% { transform: scale(1); opacity: 0.72; }
100% { transform: scale(1.5); opacity: 0; }
}
.activity-dot {
position: absolute;
@@ -1789,23 +1791,19 @@ export const cardStyles = css`
width: 130px;
}
.dev.valonly {
/* all satellite metrics from --dev-size, not --icon-size: the per-device
size multiplier must scale the value plate with its marker — same bug
class as the v1.51.3 glyph fix (pinned to base size, the multiplier
grew nothing). */
width: auto;
min-width: var(--dev-size, var(--icon-size, 2.5cqw));
max-width: calc(var(--dev-size, var(--icon-size, 2.5cqw)) * 4);
box-sizing: border-box;
padding: 0 calc(var(--dev-size, var(--icon-size, 2.5cqw)) * 0.16);
/* Saved coordinates remain the centre of a Text shell. The shell may
expand, while the invisible anchor stays one core diameter wide. */
width: var(--dev-size, var(--icon-size, 2.5cqw));
}
.dev.valonly .valtext {
min-width: 0;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
font-size: calc(var(--dev-size, var(--icon-size, 2.5cqw)) * 0.45);
font-weight: 700;
.dev.valonly .device-core {
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);
}
.dev .valtext {
overflow: visible;
font-size: calc(var(--dev-size, var(--icon-size, 2.5cqw)) * var(--value-font-scale, .45));
font-weight: 600;
white-space: nowrap;
}
/* RGB lights: the bulb takes the light's actual color */
@@ -1836,14 +1834,6 @@ export const cardStyles = css`
.sunlayer, .sunlayer.out { animation: none; }
.sunlayer.out { opacity: 0; }
}
/* alarms pulse red over everything */
.dev.alarm,
.dev.alarm:hover {
background: #6f2325;
border-color: #f25a4a;
color: #fff;
box-shadow: 0 0 10px rgba(242, 90, 74, 0.65);
}
@media (prefers-reduced-motion: reduce) {
.device-pulse { display: none; }
}
@@ -1869,22 +1859,122 @@ export const cardStyles = css`
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));
/* центр квадрата (включая рамку 1px) точно на точке привязки: -(size/2 + border) */
/* 101.5/80 shell/core ratio includes the 1 px stroke on both sides. */
--device-shell-pad: max(0px, calc(var(--dev-size) * 0.134375 - 1px));
--device-shell-size: calc(var(--dev-size) * 1.26875);
--device-core-bg: var(--card-background-color, #fff);
--device-core-fg: var(--primary-text-color, #252525);
--device-core-bg: light-dark(#fff, #252525);
--device-core-fg: light-dark(#252525, #fff);
/* The saved point is the exact centre of the icon core. */
width: var(--dev-size);
height: var(--dev-size);
margin: calc(var(--dev-size) / -2) 0 0 calc(var(--dev-size) / -2);
border: 0;
background: transparent;
display: block;
color: var(--device-core-fg);
cursor: pointer;
pointer-events: auto;
transition: opacity 0.2s;
box-shadow: none;
outline: none;
z-index: 2;
}
.dev.theme-light {
--device-core-bg: #fff;
--device-core-fg: #252525;
}
.dev.theme-dark {
--device-core-bg: #252525;
--device-core-fg: #fff;
}
.dev::before {
content: '';
position: absolute;
left: 50%;
top: 50%;
width: max(44px, var(--device-shell-size));
height: max(44px, var(--device-shell-size));
transform: translate(-50%, -50%);
border-radius: 50%;
pointer-events: auto;
}
.device-shell {
position: absolute;
box-sizing: border-box;
display: flex;
align-items: center;
gap: calc(var(--dev-size) * 0.1);
padding: var(--device-shell-pad);
min-width: var(--device-shell-size);
min-height: var(--device-shell-size);
border: 1px solid #BCBCBC;
border-radius: calc(var(--device-shell-size) / 2);
background: transparent;
box-shadow:
0 1px 2px rgb(37 40 45 / 12%),
0 4px 8px -1.07px rgb(37 40 45 / 18%);
transition: border-color .15s, box-shadow .15s, opacity .2s;
pointer-events: none;
/* Normative production fallback: never add a per-marker backdrop blur. */
backdrop-filter: none;
}
.device-shell:not(.with-values) {
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
}
.device-shell.with-values.pos-right {
left: calc(50% - var(--device-shell-size) / 2);
top: 50%;
transform: translateY(-50%);
}
.device-shell.with-values.pos-left {
right: calc(50% - var(--device-shell-size) / 2);
top: 50%;
transform: translateY(-50%);
flex-direction: row-reverse;
}
.device-shell.with-values.pos-bottom {
left: 50%;
top: calc(50% - var(--device-shell-size) / 2);
transform: translateX(-50%);
flex-direction: column;
}
.device-shell.with-values.pos-top {
left: 50%;
bottom: calc(50% - var(--device-shell-size) / 2);
transform: translateX(-50%);
flex-direction: column-reverse;
}
.device-core {
position: relative;
z-index: 1;
box-sizing: border-box;
flex: 0 0 auto;
width: var(--dev-size);
height: var(--dev-size);
margin: calc(var(--dev-size) / -2 - 1px) 0 0 calc(var(--dev-size) / -2 - 1px);
border-radius: 22%;
background: var(--hp-bg);
border: 1px solid var(--hp-line);
display: flex;
align-items: center;
justify-content: center;
color: var(--hp-txt);
cursor: pointer;
pointer-events: auto;
transition: background 0.15s, border-color 0.15s, opacity 0.2s;
box-shadow: var(--shadow-1);
z-index: 2;
border-radius: 28%;
background: var(--device-core-bg);
color: var(--device-core-fg);
line-height: 0;
transition: background .15s, color .15s, opacity .2s;
}
.device-sections {
display: flex;
flex-direction: column;
align-items: stretch;
justify-content: center;
gap: calc(var(--dev-size) * .08);
min-width: 0;
}
.device-shell.pos-top .device-sections,
.device-shell.pos-bottom .device-sections {
flex-direction: row;
}
.dev ha-icon {
/* from --dev-size, NOT --icon-size: the per-device size multiplier must
@@ -1899,26 +1989,39 @@ export const cardStyles = css`
}
.stage.mode-devices .dev { cursor: grab; }
.stage.mode-devices .dev:active { cursor: grabbing; }
.dev.on {
background: var(--hp-on);
border-color: var(--hp-on);
.dev.on .device-core {
background: #F0A00C;
color: #503c00;
}
.dev.open {
.dev.open .device-core {
background: var(--hp-open);
border-color: var(--hp-open);
color: #4a2800;
}
.dev.lock-locked .device-core {
background: var(--device-core-bg);
color: #000;
}
.dev.lock-unlocked .device-core {
background: #F0A00C;
color: #4a2800;
}
/* Interaction wins ordinary state colours. Alarm keeps priority through
the more-specific .dev.alarm:hover rule above. */
.dev:hover {
background: var(--hp-accent);
the more-specific rule below. Unavailable has no visual hover. */
.dev:not(.unavail):hover .device-core {
background: #0C82F0;
color: var(--text-primary-color, #fff);
z-index: 5;
}
.dev:hover,
.dev:focus-visible { z-index: 5; }
.dev:not(.unavail):hover .device-shell {
border-color: #0C82F0;
}
.dev.unavail {
opacity: 0.35;
}
.dev.unavail .device-core {
background: #B5BAC1;
}
.physical-hit {
fill: transparent;
stroke: transparent;
@@ -1971,7 +2074,7 @@ export const cardStyles = css`
.drawwall.invalid .rangehint { color: var(--error-color, #db4437); opacity: 1; }
.stage.markup.tool-partition,
.stage.markup.tool-column { cursor: crosshair; }
.dev.virtual {
.dev.virtual .device-shell {
border-style: dashed;
}
/* "hide from plan" flag, shown only in the device editor with the
@@ -1981,18 +2084,28 @@ export const cardStyles = css`
(owner's request). */
.dev.ghost {
opacity: 0.6;
color: var(--hp-accent);
}
.dev.ghost .device-shell {
border-style: dashed;
border-color: var(--hp-accent);
box-shadow: none;
}
.dev.ghost .device-core {
background: rgba(62, 166, 255, 0.22); /* fallback for old WebViews */
background: color-mix(in srgb, var(--hp-accent) 30%, var(--card-background-color, #1c2530));
color: var(--hp-accent);
box-shadow: none;
}
/* HA-disabled is not a user hide: neutral grey + a power-off badge keeps
the two ghosts distinguishable without relying on colour alone. */
.dev.ghost.ha-disabled {
opacity: 0.62;
color: var(--secondary-text-color, #9aa0aa);
}
.dev.ghost.ha-disabled .device-shell {
border-color: var(--secondary-text-color, #9aa0aa);
}
.dev.ghost.ha-disabled .device-core {
background: rgba(120, 124, 134, 0.2);
background: color-mix(in srgb, var(--secondary-text-color, #9aa0aa) 24%, var(--card-background-color, #1c2530));
color: var(--secondary-text-color, #9aa0aa);
@@ -2030,74 +2143,74 @@ export const cardStyles = css`
background: color-mix(in srgb, var(--secondary-text-color, #9aa0aa) 10%, transparent);
}
.habindingbanner.limited > ha-icon { color: var(--secondary-text-color, #9aa0aa); }
.dev.sel {
border-color: #ffc14d;
box-shadow: 0 0 0 3px rgba(255, 193, 77, 0.35);
.dev.sel .device-shell {
border-color: #0C82F0;
box-shadow:
0 0 0 2px var(--card-background-color, #fff),
0 0 0 4px rgb(12 130 240 / 65%),
0 4px 8px -1.07px rgb(37 40 45 / 18%);
}
.dev:focus-visible .device-shell {
border-color: #0C82F0;
box-shadow:
0 0 0 3px rgb(12 130 240 / 42%),
0 0 0 6px rgb(12 130 240 / 18%),
0 4px 8px -1.07px rgb(37 40 45 / 18%);
}
/* Alert stays above focus, selection, hover and ordinary semantic paint. */
.dev.alarm .device-core,
.dev.alarm:hover .device-core,
.dev.alarm:focus-visible .device-core {
background: #F0410C;
color: #fff;
}
.dev.alarm .device-shell,
.dev.alarm:hover .device-shell,
.dev.alarm:focus-visible .device-shell {
border-color: #F0410C;
box-shadow: 0 0 0 3px rgb(240 65 12 / 24%),
0 4px 8px -1.07px rgb(37 40 45 / 18%);
}
.dev .value-badge {
position: absolute;
position: relative;
z-index: 2;
box-sizing: border-box;
max-width: calc(var(--dev-size, var(--icon-size, 2.5cqw)) * 4);
overflow: hidden;
text-overflow: ellipsis;
background: var(--card-background-color, var(--hp-bg));
border: 1px solid var(--hp-accent, #ff9800);
width: max-content;
min-width: calc(var(--dev-size, var(--icon-size, 2.5cqw)) * .7875);
height: calc(var(--dev-size, var(--icon-size, 2.5cqw)) * .7875);
display: flex;
align-items: center;
justify-content: center;
overflow: visible;
background: var(--device-core-bg);
border: 0;
border-radius: calc(var(--dev-size, var(--icon-size, 2.5cqw)) * 0.18);
padding: 0 calc(var(--dev-size, var(--icon-size, 2.5cqw)) * 0.14);
font-size: calc(var(--dev-size, var(--icon-size, 2.5cqw)) * 0.45);
font-weight: 700;
line-height: calc(var(--dev-size, var(--icon-size, 2.5cqw)) * 0.68);
color: var(--hp-txt);
font-size: calc(var(--dev-size, var(--icon-size, 2.5cqw)) * var(--value-font-scale, .45));
font-weight: 600;
line-height: 1;
color: var(--device-core-fg);
white-space: nowrap;
pointer-events: none;
}
.dev .value-badge.tone-humidity { border-color: #4fc3f7; }
.dev .value-badge.unavailable,
.dev .value-badge.missing { opacity: 0.66; }
.dev .value-badge.pos-right {
left: 100%;
top: 50%;
transform: translateY(-50%);
margin-left: calc(var(--dev-size, var(--icon-size, 2.5cqw)) * 0.1);
}
.dev .value-badge.legacy-secondary.pos-right {
top: calc(50% + var(--dev-size, var(--icon-size, 2.5cqw)) * 0.78);
}
.dev .value-badge.pos-left {
right: 100%;
top: 50%;
transform: translateY(-50%);
margin-right: calc(var(--dev-size, var(--icon-size, 2.5cqw)) * 0.1);
}
.dev .value-badge.pos-top {
left: 50%;
bottom: 100%;
transform: translateX(-50%);
margin-bottom: calc(var(--dev-size, var(--icon-size, 2.5cqw)) * 0.1);
}
.dev .value-badge.pos-bottom {
left: 50%;
top: 100%;
transform: translateX(-50%);
margin-top: calc(var(--dev-size, var(--icon-size, 2.5cqw)) * 0.1);
}
.dev .lqi {
position: absolute;
top: 100%;
top: calc(50% + var(--device-shell-size) / 2 + var(--dev-size) * .05);
left: 50%;
transform: translateX(-50%);
margin-top: calc(var(--dev-size, var(--icon-size, 2.5cqw)) * 0.05);
font-size: calc(var(--dev-size, var(--icon-size, 2.5cqw)) * 0.38);
font-weight: 700;
font-weight: 600;
line-height: 1;
text-shadow: 0 0 3px rgba(0, 0, 0, 0.9), 0 0 2px rgba(0, 0, 0, 0.9);
white-space: nowrap;
pointer-events: none;
}
.dev .lqi.below-value-badge {
margin-top: calc(var(--dev-size, var(--icon-size, 2.5cqw)) * 0.88);
margin-top: calc(var(--dev-size, var(--icon-size, 2.5cqw)) * 0.8875);
}
.editbar {
display: grid;
+1 -1
View File
@@ -129,7 +129,7 @@ export interface Marker {
/** `ripple` is legacy read compatibility; UI maps it to icon_ripple. */
display?: 'badge' | 'ripple' | 'icon_ripple' | 'value' | 'static_icon' | null; // how the device is drawn
ripple_color?: string | null;
ripple_size?: number | null; // max ring diameter, in icon diameters (default 3)
ripple_size?: number | null; // max ring diameter, in icon diameters (default 1.5)
size?: number | null; // icon size multiplier (default 1)
angle?: number | null; // icon rotation, degrees
/** Entities this icon toggles as a group (wall switch → its lights). */