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
File diff suppressed because one or more lines are too long
+38 -1
View File
@@ -315,7 +315,7 @@ export async function prepareGoldenScenario(page, scenario) {
await stableEnvironment(page, scenario);
const fixture = prepareGoldenFixture(scenario);
return page.evaluate(async ({ fixture, scenario }) => {
const result = await page.evaluate(async ({ fixture, scenario }) => {
const wait = (ms) => new Promise((done) => setTimeout(done, ms));
const frame = () => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
const until = async (predicate, timeout = 10000) => {
@@ -759,6 +759,22 @@ export async function prepareGoldenScenario(page, scenario) {
trigger.click();
await picker.updateComplete;
}
if (scenario.deviceClassOverrides) {
for (const [id, classes] of Object.entries(scenario.deviceClassOverrides)) {
const marker = card.renderRoot.querySelector(`[data-hp="device"][data-id="${CSS.escape(id)}"]`);
if (!marker || !Array.isArray(classes) || !classes.length) {
throw new Error(`invalid golden device class override: ${scenario.id}/${id}`);
}
marker.classList.add(...classes);
}
}
if (scenario.focusDevice) {
const marker = card.renderRoot.querySelector(
`[data-hp="device"][data-id="${CSS.escape(scenario.focusDevice)}"]`,
);
if (!marker) throw new Error(`golden focus device missing: ${scenario.focusDevice}`);
marker.focus({ focusVisible: true });
}
if (scenario.dayCycle) {
const environment = card.renderRoot.querySelector('.hp-day-cycle-env');
const active = card.renderRoot.querySelector('.hp-day-cycle-bg.active');
@@ -792,6 +808,27 @@ export async function prepareGoldenScenario(page, scenario) {
} } : {}),
};
}, { fixture, scenario });
if (scenario.hoverDevice) {
const point = await page.evaluate((id) => {
const marker = window.__goldenCard?.renderRoot?.querySelector(
`[data-hp="device"][data-id="${CSS.escape(id)}"]`,
);
if (!marker) return null;
const rect = marker.getBoundingClientRect();
return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
}, scenario.hoverDevice);
if (!point) throw new Error(`golden hover device missing: ${scenario.hoverDevice}`);
await page.mouse.move(point.x, point.y);
if (scenario.hideHoverTooltip) {
await page.evaluate(async () => {
const card = window.__goldenCard;
card._tip = null;
card.requestUpdate();
await card.updateComplete;
});
}
}
return result;
}
export async function goldenClip(page, capture) {
+52 -1
View File
@@ -1,7 +1,7 @@
import { fixtureWallKey } from '../fixtures/visual-matrix.mjs';
/** Data-only HP-QA-01 capture matrix. Bump when framing or scenarios change. */
export const GOLDEN_MATRIX_VERSION = 31;
export const GOLDEN_MATRIX_VERSION = 32;
const stage = { capture: 'stage', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0005 } };
const page = { capture: 'page', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0008 } };
@@ -197,6 +197,57 @@ export const GOLDEN_SCENARIOS = Object.freeze([
'golden-climate': { s: 'golden-lighting', x: 0.80, y: 0.28 },
},
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
...['light', 'dark'].map((theme) => ({
id: `device-icon-state-table-${theme}`, fixture: 'visual', space: 'golden-lighting', mode: 'view',
glowEnabled: false, sunRays: false,
markerOverrides: [
{ id: 'golden-light-one', binding: 'device:golden-light-one' },
{ id: 'golden-light-two', binding: 'device:golden-light-two' },
{ id: 'golden-light-three', binding: 'device:golden-light-three' },
{ id: 'golden-presence', binding: 'device:golden-presence' },
{ id: 'golden-climate', binding: 'device:golden-climate' },
{ id: 'golden-left-temperature', binding: 'device:golden-left-temperature', display: 'value' },
{ id: 'golden-right-temperature', binding: 'device:golden-right-temperature', display: 'static_icon' },
{ id: 'golden-left-linkquality', binding: 'device:golden-left-linkquality', value_badge: {
enabled: true,
source: { kind: 'entity_state', entity_id: 'sensor.golden_left_linkquality' },
position: 'right',
} },
{ id: 'golden-right-linkquality', binding: 'device:golden-right-linkquality', value_badge: {
enabled: true,
source: { kind: 'entity_state', entity_id: 'sensor.golden_right_linkquality' },
position: 'bottom',
} },
],
stateOverrides: {
'light.golden_light_one': { attributes: { lqi: 40 } },
'light.golden_light_two': { attributes: { lqi: 41 } },
'light.golden_light_three': { attributes: { lqi: 180 } },
'sensor.golden_left_temperature': { state: 'Complete long localized state' },
'sensor.golden_right_temperature': { state: 'unavailable' },
},
layoutOverrides: {
'golden-light-one': { s: 'golden-lighting', x: 0.16, y: 0.25 },
'golden-light-two': { s: 'golden-lighting', x: 0.34, y: 0.25 },
'golden-light-three': { s: 'golden-lighting', x: 0.66, y: 0.25 },
'golden-presence': { s: 'golden-lighting', x: 0.84, y: 0.25 },
'golden-climate': { s: 'golden-lighting', x: 0.16, y: 0.72 },
'golden-left-temperature': { s: 'golden-lighting', x: 0.34, y: 0.72 },
'golden-right-temperature': { s: 'golden-lighting', x: 0.66, y: 0.72 },
'golden-left-linkquality': { s: 'golden-lighting', x: 0.77, y: 0.55 },
'golden-right-linkquality': { s: 'golden-lighting', x: 0.84, y: 0.72 },
},
deviceClassOverrides: {
'golden-light-two': ['lock-locked'],
'golden-light-three': ['lock-unlocked'],
'golden-presence': ['virtual'],
'golden-climate': ['alarm', 'sel'],
},
focusDevice: 'golden-climate',
hoverDevice: 'golden-presence',
hideHoverTooltip: true,
theme, viewport: { width: 1000, height: 900 }, ...stage,
})),
{ id: 'lighting-sun-window-state-only-dark', fixture: 'visual', space: 'golden-lighting', mode: 'view',
// The golden screenshot is backed by a second, sun-layer-hidden capture.
// A real painted ray must account for enough changed pixels; DOM-only
+1 -1
View File
@@ -174,7 +174,7 @@ const out = await page.evaluate(async () => {
o.onlyTheCoverOnThePlan = sr().querySelectorAll('.dev').length === 1;
o.pulseWhileOpening = sr().querySelectorAll('.dev.activity-transition').length === 1;
const a1 = afterAnim();
o.pulseIsBreathingRing = a1?.name === 'hp-pulse-continuous' && a1?.dur === '2.4s';
o.pulseUsesPackageContinuousRing = a1?.name === 'hp-pulse-continuous' && a1?.dur === '3.6s';
// the plate stays NEUTRAL while travelling: yellow is reserved for «включено»
o.movingPlateNotYellow = getComputedStyle(devEl()).backgroundColor !== YELLOW;
+175
View File
@@ -0,0 +1,175 @@
// Issue #179: package shell/state/motion/a11y contract in the real shared renderer.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch(
{ width: 1120, height: 900 }, 1, [], { colorScheme: 'dark' },
);
await page.evaluate(async () => {
const c = window.__card;
const marker = (id, patch) => ({
...(c._serverCfg.markers || []).find((item) => item.id === id),
id, binding: `device:${id}`, ...patch,
});
const replacements = new Map([
['d_light1', marker('d_light1', { display: 'badge' })],
['d_motion', marker('d_motion', { display: 'icon_ripple' })],
['d_tv', marker('d_tv', { display: 'value' })],
['d_temp', marker('d_temp', {
display: 'badge',
value_badge: {
enabled: true,
source: { kind: 'entity_state', entity_id: 'sensor.living_temp' },
position: 'right',
},
})],
['d_kettle', marker('d_kettle', { display: 'icon_ripple', tap_action: 'more-info' })],
]);
c._serverCfg.markers = [
...(c._serverCfg.markers || []).filter((item) => !replacements.has(item.id)),
...replacements.values(),
];
c.hass = {
...c.hass,
themes: { ...(c.hass.themes || {}), darkMode: true },
states: {
...c.hass.states,
'binary_sensor.hall_motion': {
...c.hass.states['binary_sensor.hall_motion'],
state: 'on',
attributes: {
...c.hass.states['binary_sensor.hall_motion'].attributes,
device_class: 'presence', linkquality: 64,
},
},
'media_player.tv': {
...c.hass.states['media_player.tv'],
state: 'A very long localized device value without clipping',
},
'sensor.living_temp': {
...c.hass.states['sensor.living_temp'],
state: '12345678901234567890',
attributes: {
...c.hass.states['sensor.living_temp'].attributes,
linkquality: 30,
},
},
'switch.kettle': {
...c.hass.states['switch.kettle'],
state: 'unavailable',
},
},
};
c._regSignature = '';
c._cfgEpoch++;
c._maybeRebuildDevices();
c._setMode('view');
c.requestUpdate();
await c.updateComplete;
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
});
await page.waitForTimeout(220);
const selector = (id) => `.dev[data-id="${id}"]`;
const beforeUnavailable = await page.$eval(selector('d_kettle'), (node) => ({
core: getComputedStyle(node.querySelector('.device-core')).backgroundColor,
shell: getComputedStyle(node.querySelector('.device-shell')).borderColor,
}));
await page.hover(selector('d_kettle'));
const afterUnavailable = await page.$eval(selector('d_kettle'), (node) => ({
core: getComputedStyle(node.querySelector('.device-core')).backgroundColor,
shell: getComputedStyle(node.querySelector('.device-shell')).borderColor,
}));
const out = await page.evaluate(async ({ beforeUnavailable, afterUnavailable }) => {
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const node = (id) => sr().querySelector(`.dev[data-id="${id}"]`);
const rect = (element) => element?.getBoundingClientRect();
const light = node('d_light1');
const lightShell = light?.querySelector('.device-shell');
const lightCore = light?.querySelector('.device-core');
const shellRect = rect(lightShell);
const coreRect = rect(lightCore);
const presence = node('d_motion');
const unavailable = node('d_kettle');
const text = node('d_tv');
const double = node('d_temp');
const doubleShellRect = rect(double?.querySelector('.device-shell'));
const doubleCoreRect = rect(double?.querySelector('.device-core'));
const doubleValueRect = rect(double?.querySelector('.value-badge'));
const pseudo = getComputedStyle(light, '::before');
const lockNode = node('d_lock');
lockNode.querySelector('.device-core').style.transition = 'none';
const darkThemeClassProjected = lockNode.classList.contains('theme-dark');
const darkThemeCore = getComputedStyle(lockNode.querySelector('.device-core')).backgroundColor;
lockNode.classList.remove('theme-dark');
lockNode.classList.add('theme-light');
const lightThemeCore = getComputedStyle(lockNode.querySelector('.device-core')).backgroundColor;
lockNode.classList.remove('theme-light');
lockNode.classList.add('theme-dark');
let moreInfo = 0;
c._openMoreInfo = () => { moreInfo++; };
const enter = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true });
unavailable.dispatchEvent(enter);
const space = new KeyboardEvent('keydown', { key: ' ', bubbles: true, cancelable: true });
unavailable.dispatchEvent(space);
const aria = unavailable.getAttribute('aria-label') || '';
const viewKeyboard = unavailable.getAttribute('role') === 'button'
&& unavailable.getAttribute('tabindex') === '0'
&& enter.defaultPrevented && space.defaultPrevented && moreInfo === 2;
await c._setMode('devices');
await c.updateComplete;
const editorNode = node('d_light1');
editorNode.dispatchEvent(new KeyboardEvent('keydown', {
key: 'Enter', bubbles: true, cancelable: true,
}));
await c.updateComplete;
const editorKeyboardOpensSettings = c._markerDialog?.devId === 'd_light1';
c._markerDialog = null;
await c._setMode('plan');
await c.updateComplete;
const planNode = node('d_light1');
const planNotInTabOrder = !planNode?.hasAttribute('role') && !planNode?.hasAttribute('tabindex');
return {
sharedShellGeometry: !!shellRect && !!coreRect
&& Math.abs(shellRect.width / coreRect.width - 1.26875) < 0.03,
packageShadowColor: getComputedStyle(lightShell).boxShadow.includes('37, 40, 45'),
noBackdropBlur: getComputedStyle(lightShell).backdropFilter === 'none',
activeUsesPackageAmber: getComputedStyle(lightCore).backgroundColor === 'rgb(240, 160, 12)',
lightThemeCoreIsWhite: lightThemeCore === 'rgb(255, 255, 255)',
darkThemeCoreIs252525: darkThemeClassProjected && darkThemeCore === 'rgb(37, 37, 37)',
hitAreaAtLeast44: parseFloat(pseudo.width) >= 44 && parseFloat(pseudo.height) >= 44,
presenceContinuousGreen: presence?.querySelector('.device-pulse.continuous.reason-presence')
&& getComputedStyle(presence.querySelector('.device-pulse i')).borderColor === 'rgb(29, 194, 29)'
&& getComputedStyle(presence.querySelector('.device-pulse i')).animationDuration === '3.6s',
lockedStateProjected: node('d_lock')?.classList.contains('lock-locked')
&& node('d_lock')?.getAttribute('data-state') === 'locked',
lqiBandsProjected: node('d_temp')?.getAttribute('data-lqi-band') === 'low'
&& node('d_window')?.getAttribute('data-lqi-band') === 'mid'
&& node('d_lamp')?.getAttribute('data-lqi-band') === 'high',
unavailableHasNoHoverOrMotion: JSON.stringify(beforeUnavailable) === JSON.stringify(afterUnavailable)
&& !unavailable.querySelector('.device-pulse'),
unavailableAriaIsReadable: /unavailable/i.test(aria) && /LQI|signal/i.test(aria),
textIsComplete: text?.querySelector('.valtext')?.textContent
=== 'A very long localized device value without clipping'
&& getComputedStyle(text.querySelector('.valtext')).textOverflow !== 'ellipsis'
&& getComputedStyle(text.querySelector('.valtext')).overflow === 'visible'
&& text.getAttribute('aria-label')?.includes('A very long localized device value without clipping'),
doubleUsesOneShell: !!doubleShellRect && !!doubleCoreRect && !!doubleValueRect
&& doubleShellRect.left <= Math.min(doubleCoreRect.left, doubleValueRect.left)
&& doubleShellRect.right >= Math.max(doubleCoreRect.right, doubleValueRect.right),
viewKeyboard,
editorKeyboardOpensSettings,
planNotInTabOrder,
};
}, { beforeUnavailable, afterUnavailable });
checkAll(out);
await finish(browser, out);
+1 -1
View File
@@ -12,7 +12,7 @@ const res = await page.evaluate(async () => {
const semantic = ['on', 'open', 'alarm', 'unavail', 'valonly', 'activity-running', 'activity-event', 'activity-presence', 'activity-transition'];
return {
classes: semantic.filter((name) => node.classList.contains(name)),
icon: node.querySelector(':scope > ha-icon')?.getAttribute('icon') || '',
icon: node.querySelector('.device-core > ha-icon')?.getAttribute('icon') || '',
value: node.querySelector('.valtext')?.textContent?.trim() || '',
badge: node.querySelector('.value-badge')?.textContent?.trim() || '',
badgePosition: [...(node.querySelector('.value-badge')?.classList || [])]
+2 -2
View File
@@ -18,10 +18,10 @@ const out = await page.evaluate(async () => {
'static-icon', 'on', 'open', 'alarm', 'unavail', 'valonly',
'activity-running', 'activity-event', 'activity-presence', 'activity-transition',
].includes(name)),
icon: node.querySelector(':scope > ha-icon')?.getAttribute('icon') || '',
icon: node.querySelector('.device-core > ha-icon')?.getAttribute('icon') || '',
satellites: node.querySelectorAll('.tval,.hval,.lqi,.valtext').length,
scale: node.style.getPropertyValue('--dev-scale'),
angle: node.querySelector(':scope > ha-icon')?.style.transform || '',
angle: node.querySelector('.device-core > ha-icon')?.style.transform || '',
});
const show = (device, states) => {
const visibleSnapshot = card._visibleDeviceSnapshot;
File diff suppressed because one or more lines are too long
+499 -372
View File
File diff suppressed because one or more lines are too long
+18 -7
View File
@@ -235,31 +235,42 @@ Per-marker appearance: `display: badge|icon_ripple|value|static_icon`. Entity se
projection (sources, value, icon, classes, metrics and explanation),
`src/device-pulse.ts` is the single pure projection from semantic activity to
`none|alarm|short|continuous`, and
`src/device-face.ts` renders that projection on the full plan, device preview and static
space card. `badge`
shows the icon/morph and status plate; `icon_ripple` additionally shows three finite
`src/device-face.ts` renders one package-derived shell/core DOM on the full plan,
device preview and static space card. The saved coordinate remains the icon-core
centre; Text is shell-centred, while a Double shell extends around the anchored
core. The 101.5/80 shell/core ratio, Light/Dark context, full-text fitting and
44×44 interactive hit area are renderer facts rather than surface-specific DOM.
`badge` shows the icon/morph and semantic core; `icon_ripple` additionally shows three finite
event waves or one continuous wave for presence, mechanical transition and actual work;
`value` replaces the icon
with the HA-formatted numeric or text value. Ambiguous/missing/unavailable sources fall
back to the icon instead of selecting an arbitrary registry row. A critical alarm is red
in every dynamic presentation. `static_icon` deliberately keeps the configured/automatic
base icon on one neutral dark plate: state morphing, work/open/alarm/unavailable paint,
base icon on one neutral theme-aware core: state morphing, work/open/alarm/unavailable paint,
activity, RGB, value, temperature/humidity/LQI badges and live vacuum overlays are all
suppressed. Hover/focus, taps, controls and light aggregation keep their normal behaviour.
An optional `marker.value_badge` adds one independent boxed state/attribute,
derived LQI or canonical `marker:<id>` light state at right/bottom/left/top.
An optional `marker.value_badge` adds a state/attribute, derived LQI or canonical
`marker:<id>` light-state section at right/bottom/left/top inside that shell.
Explicit settings override the global legacy temperature gate; explicit off
suppresses legacy output. Bottom badges stack above system LQI, and a derived
LQI badge de-duplicates that system row. `hp-device-preview` fits and centres
the complete face bounding box rather than allowing satellites to clip.
`normalizeDeviceDisplay()` is the mandatory compatibility gate for every consumer and maps
legacy `ripple` to `icon_ripple`. The marker dialog builds its unsaved draft through `buildDevices`,
legacy `ripple` to `icon_ripple`. `markerLqiBand()` / `markerLqiColor()` are
deliberately marker-only (`<=40` red, `41…179` amber, `>=180` green); room fill
keeps the continuous `logic.ts::lqiColor()` gradient. The marker dialog builds
its unsaved draft through `buildDevices`,
then `hp-device-preview` shows the actual projection, integration provenance from
registry/config-entry metadata and isolated short/continuous activity demonstrations.
Runtime baselines are seeded as soon as a rebuilt registry becomes authoritative, before
the next HA snapshot is classified; source-key changes reset any finite effect immediately.
The backend accepts legacy `display: ripple` only for compatibility. `ripple_color` and `ripple_size` remain the
stored names used by every unified pulse kind (alarm keeps its safety-red colour).
Absent pulse size resolves to 1.5; explicit persisted color/size wins, followed
by live RGB and the presence-green/work-amber/transition-blue fallback.
Continuous/short/alarm durations are 3.6/3.3/2.4 seconds. Enter/Space on an
interactive marker calls the same `_clickDevice()` path as pointer activation,
so secure confirmation and Device-editor routing cannot drift.
`size` (icon multiplier via the
`--dev-size` CSS var — value badges scale along) and `angle` rotate/scale a single icon.
Room drawing shows a live **ruler** (`segmentCm` +
+9
View File
@@ -2,6 +2,15 @@
## v1.65.0-beta.5 — 2026-08-19
- Device markers now use the designer-supplied shared shell/core system in
light and dark themes, with distinct focus, selected, locked, unlocked,
active, alarm, virtual and unavailable states. Text and adjacent values live
in one shell and are no longer ellipsized; marker LQI uses red at 0–40,
amber at 41–179 and green at 180+. Activity motion follows the new package
timings and colors, while saved custom pulse settings remain authoritative.
Interactive markers also have a 44×44 px target and reuse their existing
click/confirmation path from Enter or Space
([#179](https://github.com/Matysh/houseplan-card/issues/179)).
- The new-space dialog now shows the defaults it will actually save: image
spaces start with borders and names off, while switching to a hand-drawn
plan visibly turns both on. After either switch is edited, changing the plan
+10
View File
@@ -8,6 +8,16 @@
## v1.65.0-beta.5 — 2026-08-19
- Маркеры устройств теперь используют единый дизайн shell/core из пакета
дизайнера в светлой и тёмной теме, с отдельными состояниями фокуса, выбора,
блокировки, разблокировки, работы, тревоги, виртуального и недоступного
устройства. Текст и соседние значения находятся в одной подложке и больше
не сокращаются многоточием; LQI маркера окрашивается красным при 0–40,
жёлтым при 41–179 и зелёным при 180+. Пульсации получили новые тайминги и
цвета, а явно сохранённые пользовательские настройки сохранили приоритет.
Интерактивный маркер также имеет область нажатия не меньше 44×44 px и по
Enter/Space использует прежний click/confirmation path
([#179](https://github.com/Matysh/houseplan-card/issues/179)).
- Диалог нового пространства теперь показывает именно те значения, которые
сохранит: для плана с изображением границы и названия изначально выключены,
а при переходе к ручному плану оба переключателя видимо включаются. После
+29
View File
@@ -32,6 +32,35 @@
полный прогон — workflow `mutation-gate.yml`, перед стабильным релизом и по
понедельникам. Дешёвая половина идёт с юнитами: `test/mutation-gate.test.mjs`.
## Device icon design package (#179)
- [ ] Pure presentation tests cover lock/unlock, exact marker-only LQI bands
`0/40/41/179/180`, unchanged room gradient, package pulse defaults,
semantic colors and reduced motion
[unit: `device-presentation.test.mjs`, `device-pulse.test.mjs`].
- [ ] Shared face tests cover shell/core DOM, four Double positions, a third
legacy section, deterministic font fitting, full text and safe CSS color
variables [unit: `device-face.test.mjs`].
- [ ] The browser renders the exact shell ratio and shadow color, Light/Dark
cores without backdrop blur, state/LQI colors, 3.6 s presence pulse,
unavailable no-hover, full Text/Double values, 44×44 target, View and
Device-editor keyboard paths, and no Plan tab stop
[auto: `smoke_device_icon_design.mjs`].
- [ ] Full plan, preview and static card preserve the same face after the DOM
redesign; static mode, state/value and disabled-device contracts stay
green [auto: `smoke_device_preview_parity.mjs`, `smoke_static_icon.mjs`,
`smoke_state_value.mjs`, `smoke_disabled_device.mjs`].
- [ ] Restoring unavailable hover, shifting the LQI boundary, restoring value
ellipsis or bypassing `_clickDevice()` makes its guard red
[mutation: `device-unavailable-hover-restored`,
`device-marker-lqi-low-boundary-shifted`,
`device-long-value-ellipsis-restored`,
`device-keyboard-bypasses-click-path`].
- [ ] Pre-beta golden reviews desktop/mobile Light/Dark states, combo states,
Text, four Double positions, long and legacy values, LQI, reduced motion,
sizes 32/56/96 and colored backgrounds. Full smoke/golden/performance
remains a Linux release gate.
## Empty-space lifecycle (#113)
- [ ] Active selection keeps active-or-first compatibility, while an empty
+24 -10
View File
@@ -399,15 +399,16 @@ group; without a selection existing groups remain external-only.
## 12. Device visual states
Presentation has three independent layers: stable marker background, icon or
value, and optional activity pulse. Priority is **alarm → working → open →
neutral**.
Presentation uses one shared outer shell around three independent layers:
stable core, icon or value, and optional activity pulse. Visual priority is
**alarm → keyboard focus → selected → hover → semantic state → neutral**.
| State | Meaning | Examples |
|---|---|---|
| Red alarm | Critical condition, even with live states disabled | Smoke, gas, CO, leak, tamper/problem/safety, triggered alarm |
| Yellow | Device is doing its main job | Light/switch/fan on, active climate, vacuum cleaning, known appliance work |
| Orange | Physically open or unlocked | Door/window contact, unlocked lock, opening valve |
| Yellow | Device is doing its main job or a lock is unlocked | Light/switch/fan on, active climate, vacuum cleaning, known appliance work, unlocked lock |
| Black lock glyph | Lock is secured | Locked lock |
| Orange | Physically open | Door/window contact, opening valve |
| Faded | Data unavailable | All relevant entities unknown, unavailable or absent |
| Neutral | No alarm, work or open condition | Off, closed, idle, standby, docked |
@@ -421,17 +422,30 @@ are not treated as independent proof of work, and an ordinary lone relay keeps
its existing yellow-on behaviour.
Activity may be a finite three-wave event, persistent presence, a travelling
transition, or persistent work. `prefers-reduced-motion` replaces ordinary
motion with a compact indicator while the alarm remains clear.
transition, or persistent work. Continuous motion uses a 3.6 s cycle (green
presence, amber work, blue neutral transition), a short event lasts 3.3 s, and
the two-wave red alarm cycles in 2.4 s. Explicit saved pulse color/size remains
authoritative; the package size default is 1.5 diameters. `prefers-reduced-motion`
replaces ordinary motion with a compact colored indicator while the static red
alarm remains clear.
The four display choices are icon + state; icon + state + activity; value +
state; and always-static icon. A separate value badge can show an entity state,
useful attribute, average LQI or linked light state on any side of the marker.
Text and adjacent values are sections of the same shell. They shrink to a
readable floor and then expand the shell; they are never ellipsized.
Virtual devices use the ordinary neutral/hover background with a dashed outer
circle. They cannot have active state or pulse. Unavailable keeps the ordinary
presentation with the standard icon opacity reduction. Zigbee LQI remains a
separate badge.
circle. An HA-less virtual device does not invent unavailable or activity;
a linked virtual light may still follow its real controller. Unavailable keeps the ordinary
presentation with the standard icon opacity reduction, no visual hover and no
motion; its existing click/tap still opens information or settings. Marker LQI
is red at 0–40, amber at 41–179 and green at 180+, without changing the room
fill gradient.
Interactive View/kiosk and Device-editor markers have at least a 44×44 CSS px
target. Enter and Space reuse the exact current click and confirmation path;
Plan, Background, preview and the read-only static card add no tab stop.
## 13. Room fills and light
+37 -12
View File
@@ -738,21 +738,28 @@ House Plan заново определяет состояние и направ
## 12. Визуальные состояния устройств
В динамических режимах система состоит из трёх независимых слоёв:
В динамических режимах система состоит из общего внешнего shell и трёх
независимых слоёв:
1. **подложка маркера** сообщает устойчивый статус;
2. **иконка/число** сообщает тип или значение;
3. **пульсация активности** в режиме «Значок + состояние и активность» сообщает событие, присутствие, движение или работу.
Приоритет статусов: **авария → работа → открыто → нейтрально**. Если все сущности, реально описывающие маркер, недоступны, маркер бледнеет. У медиаплеера `off` использует то же приглушённое отображение, что `unknown` и `unavailable`, без отдельного визуального состояния.
Приоритет оформления: **авария → клавиатурный фокус → выбранный маркер →
hover → состояние → нейтрально**. Если все сущности, реально описывающие
маркер, недоступны, маркер бледнеет и не получает визуальный hover или
пульсацию, но его обычный клик/тап по-прежнему открывает информацию либо
настройки. У медиаплеера `off` используется то же приглушённое отображение, что
`unknown` и `unavailable`, без отдельного визуального состояния.
### Подложка и постоянный статус
| Вид | Что означает | Примеры |
|---|---|---|
| Красная авария | Критическое событие; показывается даже при выключенных живых состояниях | Дым, газ, CO, протечка, tamper/problem/safety, сирена `on`, охранная панель `triggered` |
| Жёлтая подложка | Устройство сейчас выполняет работу | Свет/реле/вентилятор/увлажнитель `on`; climate с распознанным action `heating`, `cooling`, `preheating` или `defrosting`; при отсутствии распознанного action — включённый HVAC-режим как лучшее доступное приближение; vacuum cleaning/returning; script on; техника washing/drying/etc. |
| Оранжевая подложка | Физически открыто/разблокировано | Датчик двери/окна/гаража `on`, замок unlocked/open, клапан open/opening/closing |
| Жёлтая подложка | Устройство сейчас выполняет работу либо замок разблокирован | Свет/реле/вентилятор/увлажнитель `on`; climate с распознанным action `heating`, `cooling`, `preheating` или `defrosting`; при отсутствии распознанного action — включённый HVAC-режим как лучшее доступное приближение; vacuum cleaning/returning; script on; техника washing/drying/etc.; lock unlocked/open |
| Чёрный значок замка | Замок заблокирован | lock locked |
| Оранжевая подложка | Физически открыто | Датчик двери/окна/гаража `on`, клапан open/opening/closing |
| Бледный маркер | Данные недоступны либо медиаплеер выключен | Все рабочие сущности маркера `unknown`, `unavailable` или отсутствуют; `media_player.* = off` |
| Нейтральный | Нет аварии, работы и открытого состояния | Выключено, закрыто, idle/standby/docked и т. п. |
@@ -769,7 +776,14 @@ House Plan заново определяет состояние и направ
| Постоянная: переход | Пока идёт движение | Cover opening/closing, lock locking/unlocking, valve opening/closing, moving sensor, vacuum returning |
| Постоянная: работа | Пока устройство работает | Свет, switch, fan, humidifier, climate action (либо включённый HVAC-режим при отсутствии action), vacuum cleaning, script и известные рабочие состояния техники |
Цвет и размер пульсации можно изменить для конкретного маркера. Если цвет не задан, RGB-свет может передать ей свой текущий цвет. Статичных колец нет; при системном уменьшении движения обычная активность обозначается компактной точкой, а тревога остаётся понятной по красной подложке.
Постоянная пульсация длится 3,6 секунды на цикл: присутствие зелёное, работа
жёлтая, нейтральный переход синий. Короткое событие — три волны за 3,3 секунды;
красная тревога использует две волны с циклом 2,4 секунды. Цвет и максимальный
размер пульсации можно изменить для конкретного маркера; явно сохранённые
значения имеют приоритет, а новый размер по умолчанию равен 1,5 диаметра. Если
цвет не задан, RGB-свет может передать ей свой текущий цвет. Статичных колец
нет; при системном уменьшении движения обычная активность обозначается
компактной цветной точкой, а тревога остаётся понятной по красной подложке.
### Четыре варианта «Отображение»
@@ -778,13 +792,13 @@ House Plan заново определяет состояние и направ
| Значок + состояние | Да | Да | Только тревога | Отдельные компактные °/% и LQI возможны |
| Значок + состояние и активность | Да | Да | Да | Отдельные компактные °/% и LQI возможны |
| Значение + состояние | Если значение получить нельзя или источник неоднозначен | Да | Только тревога | Температура/влажность либо числовое или текстовое состояние, отформатированное HA |
| Всегда статичный значок | Да | Всегда нейтральная тёмная | Нет | Нет |
| Всегда статичный значок | Да | Нейтральная в цветах текущей темы | Нет | Нет |
В режиме «Значение вместо иконки» поддерживаются и числа, и локализованные HA
текстовые состояния. Если источник отсутствует, недоступен или неоднозначен,
карточка возвращается к иконке; предпросмотр объясняет причину. Длинный текст
сокращается многоточием, полный остаётся в подсказке и accessibility-описании.
LQI остаётся отдельным бейджем.
уменьшается до читаемого минимума, затем общий shell расширяется: многоточие и
скрытие хвоста не используются. LQI остаётся отдельной строкой под shell.
### Бейдж со значением рядом с устройством
@@ -794,14 +808,15 @@ LQI остаётся отдельным бейджем.
текущая температура, яркость, заряд, положение или громкость), средний LQI и
состояния связанных источников света. Изменения сразу видны в предпросмотре.
Это независимый элемент: он может показываться и рядом с обычной иконкой, и
рядом с режимом «Значение вместо иконки». Если бейдж расположен снизу, системный
LQI автоматически становится второй строкой; если сам бейдж показывает LQI,
Это независимая секция общего shell: она может показываться и рядом с обычной
иконкой, и рядом с режимом «Значение вместо иконки». Если секция расположена
снизу, системный LQI автоматически становится второй строкой; если сама секция показывает LQI,
дублирующая строка скрывается. Недоступный сохранённый источник не заменяется
другим молча — отображается `—`, а редактор позволяет сохранить связь или
выбрать новую.
Для старых маркеров без этой настройки прежние автоматические °/% сохраняются.
Для старых маркеров без этой настройки прежние автоматические °/% сохраняются
как дополнительная секция того же shell.
Явно выключенный бейдж подавляет эту legacy-эвристику. Явный бейдж не зависит
от общей настройки показа температуры; «Всегда статичный значок» временно
скрывает его, не удаляя выбор. Настройка «Учитывать температуру устройства в
@@ -817,6 +832,16 @@ Glow и заливка «Свет» продолжают работать по
настройкам устройства. Для привязок, способных сообщать о тревоге, редактор
показывает неблокирующее предупреждение перед сохранением этого режима.
Число Zigbee LQI под маркером имеет три фиксированных диапазона: **0–40 —
красный, 41–179 — жёлтый, 180 и выше — зелёный**. Это не меняет плавный
градиент LQI-заливки комнаты.
В Просмотре/киоске и редакторе устройств интерактивный маркер получает
клавиатурный фокус и область действия не меньше 44×44 CSS px. Enter и Space
выполняют ровно тот же путь, что клик: в Просмотре сохраняются выбранное
действие и все подтверждения, в редакторе открываются настройки. План,
Background, read-only static card и предпросмотр не добавляют ложных tab-stop.
Живой блок предпросмотра в настройках marker использует несохранённые поля
формы и текущее состояние HA. Он отдельно показывает интеграцию привязки и
фактическую сущность отображения. Для «Значок + состояние и активность» можно
Binary file not shown.

Before

Width:  |  Height:  |  Size: 182 KiB

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 KiB

After

Width:  |  Height:  |  Size: 291 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 286 KiB

After

Width:  |  Height:  |  Size: 293 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 128 KiB

After

Width:  |  Height:  |  Size: 135 KiB

+17 -17
View File
@@ -1,7 +1,7 @@
{
"version": 1,
"fixture": "synthetic-only",
"sourceFingerprint": "19144400828da11db7664f41050e83728b14d7dafaea55c65766f4f43bf8bd17",
"sourceFingerprint": "4789f3351376f16116faa2c6102238a0ca1bbd6dabb9c5d1d5e07c71ccab3aba",
"captureScriptSha256": "34f2219790d46efd8250e7a1bd829cb8fc0b0547e1260635fefa52407551b41b",
"command": "npm run build && node demo/docs/capture.mjs",
"scenarios": {
@@ -13,8 +13,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "19144400828da11db7664f41050e83728b14d7dafaea55c65766f4f43bf8bd17",
"imageSha256": "d36b6f9f8139f31ef73a780c6511a640a26055efd9d7a24c24fd48b1d8379bf0"
"sourceSha256": "4789f3351376f16116faa2c6102238a0ca1bbd6dabb9c5d1d5e07c71ccab3aba",
"imageSha256": "e2d31553c2541fd7f0725db348e3d0210867d4e2489532372270820bc0050794"
},
"view-touch": {
"file": "02-view-touch.png",
@@ -24,8 +24,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "19144400828da11db7664f41050e83728b14d7dafaea55c65766f4f43bf8bd17",
"imageSha256": "358e25ff9984d0fb0c03cfbb848df40c425fdfe64cd5f4f613b1e754ca9d4249"
"sourceSha256": "4789f3351376f16116faa2c6102238a0ca1bbd6dabb9c5d1d5e07c71ccab3aba",
"imageSha256": "c4d5bd72e177b45737dbf1c3b09f52a31bb0c63829d8d652655cfb54f154c2ff"
},
"space-create": {
"file": "03-space-create.png",
@@ -35,7 +35,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "19144400828da11db7664f41050e83728b14d7dafaea55c65766f4f43bf8bd17",
"sourceSha256": "4789f3351376f16116faa2c6102238a0ca1bbd6dabb9c5d1d5e07c71ccab3aba",
"imageSha256": "c53db2e5c642a5549c13f3c93a5b359fed69bdb2621bf71a243a877ffcb95e6b"
},
"room-contour-close": {
@@ -46,7 +46,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "19144400828da11db7664f41050e83728b14d7dafaea55c65766f4f43bf8bd17",
"sourceSha256": "4789f3351376f16116faa2c6102238a0ca1bbd6dabb9c5d1d5e07c71ccab3aba",
"imageSha256": "2f13ea645af306eee9cc7c52699c928377b72f412b08c1a3db6beb848ace1b51"
},
"plan-context-tray": {
@@ -57,7 +57,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "19144400828da11db7664f41050e83728b14d7dafaea55c65766f4f43bf8bd17",
"sourceSha256": "4789f3351376f16116faa2c6102238a0ca1bbd6dabb9c5d1d5e07c71ccab3aba",
"imageSha256": "a6c526fede11bc3503fd2384bd4a3f6afa008f481c5c47578f06cae6c81c6f9b"
},
"device-editor": {
@@ -68,8 +68,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "19144400828da11db7664f41050e83728b14d7dafaea55c65766f4f43bf8bd17",
"imageSha256": "7a22a81e2abedf5a520bd36b7509d953cbfa9962b48c0ffbc233666a6215c04b"
"sourceSha256": "4789f3351376f16116faa2c6102238a0ca1bbd6dabb9c5d1d5e07c71ccab3aba",
"imageSha256": "189287256dae5721cc8fac76b82180f1eba36281434032824aa616426fcad6d7"
},
"device-display-preview": {
"file": "06-device-display-preview.png",
@@ -79,8 +79,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "19144400828da11db7664f41050e83728b14d7dafaea55c65766f4f43bf8bd17",
"imageSha256": "df3a54395c83bffd7a161f1fb3de0143e12e76567535aedf9883873ead74df91"
"sourceSha256": "4789f3351376f16116faa2c6102238a0ca1bbd6dabb9c5d1d5e07c71ccab3aba",
"imageSha256": "112afb49df2f4398e5b3de29c21fd26af02fba007649e810786c97b3da42a0fd"
},
"background-editor": {
"file": "07-background-editor.png",
@@ -90,7 +90,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "19144400828da11db7664f41050e83728b14d7dafaea55c65766f4f43bf8bd17",
"sourceSha256": "4789f3351376f16116faa2c6102238a0ca1bbd6dabb9c5d1d5e07c71ccab3aba",
"imageSha256": "d7cfe70551d9260169df8efd832e32ed7df99c7f60b55d1fd4a8322bd4c47175"
},
"room-card": {
@@ -101,8 +101,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "19144400828da11db7664f41050e83728b14d7dafaea55c65766f4f43bf8bd17",
"imageSha256": "176abba71d41cfb045a33f82a794d9fbb2a5d3c48e6df66e6ec3a8e448311b11"
"sourceSha256": "4789f3351376f16116faa2c6102238a0ca1bbd6dabb9c5d1d5e07c71ccab3aba",
"imageSha256": "e7e3f0609a5dfa117e366b0388bd2d5286865110fd5798c02ffa752b2c285f95"
},
"device-info": {
"file": "09-device-info.png",
@@ -112,8 +112,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "19144400828da11db7664f41050e83728b14d7dafaea55c65766f4f43bf8bd17",
"imageSha256": "2199ed88b215bf2bff63bf665028f78b2aa6a92c3032b803931790f2ef071893"
"sourceSha256": "4789f3351376f16116faa2c6102238a0ca1bbd6dabb9c5d1d5e07c71ccab3aba",
"imageSha256": "b61541f0f53db632c99bf082d875ec1f84f77b534147b769e382ca54d8fe5c3e"
}
}
}
+46
View File
@@ -515,6 +515,52 @@ export const MUTANTS = [
replace: ' resumed = False',
}],
},
{
id: 'device-unavailable-hover-restored',
guard: 'node demo/smoke_device_icon_design.mjs',
because: 'unavailable keeps click/keyboard access but must never regain the blue visual hover '
+ 'which makes an offline device look live',
patches: [{
file: 'src/styles.ts',
find: '.dev:not(.unavail):hover .device-core {',
replace: '.dev:hover .device-core {',
}],
},
{
id: 'device-marker-lqi-low-boundary-shifted',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test --test-name-pattern="marker LQI is categorical" '
+ 'test/device-presentation.test.mjs',
because: 'marker LQI must keep the owner-approved inclusive <=40 red boundary without '
+ 'changing the separate room-fill gradient',
patches: [{
file: 'src/device-presentation.ts',
find: ' if (lqi <= 40) return \'low\';',
replace: ' if (lqi < 40) return \'low\';',
}],
},
{
id: 'device-long-value-ellipsis-restored',
guard: 'node demo/smoke_device_icon_design.mjs',
because: 'Text and Double must expose the complete dynamic value; the new shared shell '
+ 'must not regress to the old clipped satellite',
patches: [{
file: 'src/styles.ts',
find: ' .dev .valtext {\n overflow: visible;',
replace: ' .dev .valtext {\n overflow: hidden;\n text-overflow: ellipsis;',
}],
},
{
id: 'device-keyboard-bypasses-click-path',
guard: 'node demo/smoke_device_icon_design.mjs',
because: 'Enter and Space must reuse the current surface click path so Device editor opens '
+ 'settings and secure View actions keep their existing confirmation policy',
patches: [{
file: 'src/houseplan-card.ts',
find: ' this._clickDevice(ev, d);',
replace: ' if (d.primary) this._openMoreInfo(d.primary);',
}],
},
];
// --- механика ---------------------------------------------------------------
+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). */
+35 -2
View File
@@ -1,7 +1,8 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
deviceFaceStyle, legacySupplementalMetrics, lqiClassName, renderDeviceFace,
deviceFaceStyle, deviceTextScale, deviceThemeClass,
legacySupplementalMetrics, lqiClassName, renderDeviceFace,
valueBadgeClassName,
} from '../test-build/device-face.js';
@@ -25,6 +26,12 @@ test('device face never emits an arbitrary persisted ripple declaration', () =>
assert.ok(!hostile.join(';').includes('position'));
});
test('HA darkMode selects an exact package theme and otherwise leaves CSS fallback in charge', () => {
assert.equal(deviceThemeClass({ themes: { darkMode: true } }), 'theme-dark');
assert.equal(deviceThemeClass({ themes: { darkMode: false } }), 'theme-light');
assert.equal(deviceThemeClass({}), '');
});
test('untouched legacy temperature and humidity keep the second satellite', () => {
const presentation = {
valueBadge: {
@@ -65,7 +72,7 @@ test('rendered pulse retains the documented activity-ring compatibility hook', (
const presentation = {
...face('#ff9800'), classes: [], icon: 'mdi:lightbulb', angle: 0,
valueText: null, valueFullText: null, valueBadge: null,
lqiText: null, lqiColor: null, haDisabled: false,
lqiText: null, lqiColor: null, lqiBand: null, haDisabled: false,
tempText: null, humText: null,
};
const root = renderDeviceFace(presentation, { surface: 'preview' });
@@ -73,3 +80,29 @@ test('rendered pulse retains the documented activity-ring compatibility hook', (
assert.ok(nested);
assert.match(nested.strings.join(''), /device-pulse activity-ring/);
});
test('Text and Double use one shared shell and deterministic full-text fitting', () => {
assert.equal(deviceTextScale('42 °C'), 0.45);
assert.equal(deviceTextScale('A very long localized device value'), 0.25);
const presentation = {
...face('#ff9800'), classes: [], icon: 'mdi:lightbulb', angle: 0,
valueText: null, valueFullText: null,
valueBadge: {
configured: true,
position: 'left', availability: 'available', tone: 'default',
text: '12345678901234567890 W', fullText: '12345678901234567890 W',
},
lqiText: '180', lqiColor: '#1DC21D', lqiBand: 'high', haDisabled: false,
tempText: null, humText: null,
};
const root = renderDeviceFace(presentation, { surface: 'preview' });
const flatten = (value) => value?.strings
? value.strings.join('') + value.values.map(flatten).join('')
: Array.isArray(value) ? value.map(flatten).join('') : String(value ?? '');
const markup = flatten(root);
assert.match(markup, /device-shell/);
assert.match(markup, /device-core/);
assert.match(markup, /device-sections/);
assert.match(markup, /band-/);
});
+35
View File
@@ -3,10 +3,14 @@ import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import {
activitySourceSignature,
deviceA11yState,
markerLqiBand,
markerLqiColor,
presentationSourceSignature,
resolveDevicePresentation,
resolvePresentationSources,
} from '../test-build/device-presentation.js';
import { lqiColor } from '../test-build/logic.js';
import {
resolveBindingProviders,
resolveEntityProvider,
@@ -47,6 +51,37 @@ const options = {
showSignal: true,
};
test('marker LQI is categorical without changing the room-fill gradient', () => {
assert.deepEqual([0, 40, 41, 179, 180].map(markerLqiBand), [
'low', 'low', 'mid', 'mid', 'high',
]);
assert.deepEqual([40, 41, 179, 180].map(markerLqiColor), [
'#F0410C', '#F0A00C', '#F0A00C', '#1DC21D',
]);
assert.equal(lqiColor(110), 'hsl(60, 85%, 55%)');
});
test('lock presentation distinguishes locked and unlocked without changing generic open', () => {
const h = hass({
'lock.front': state('lock.front', 'locked'),
}, {
'lock.front': { entity_id: 'lock.front', device_id: 'd1', platform: 'demo' },
});
const lock = device({
icon: 'mdi:lock', entities: ['lock.front'], primary: 'lock.front',
});
const locked = resolveDevicePresentation(h, lock, options);
assert.equal(locked.lockState, 'locked');
assert.ok(locked.classes.includes('lock-locked'));
assert.equal(deviceA11yState(locked), 'locked');
h.states['lock.front'].state = 'unlocked';
const unlocked = resolveDevicePresentation(h, lock, options);
assert.equal(unlocked.lockState, 'unlocked');
assert.ok(unlocked.classes.includes('lock-unlocked'));
assert.equal(deviceA11yState(unlocked), 'unlocked');
});
test('shares one lifecycle presentation between full and space cards', () => {
const h = hass({
'sensor.washer_status': state('sensor.washer_status', 'start'),
+16 -3
View File
@@ -45,7 +45,7 @@ test('reduced motion replaces ordinary waves with a dot and keeps alarm semantic
});
assert.equal(ordinary.animated, false);
assert.equal(ordinary.reducedMotionIndicator, 'dot');
assert.equal(ordinary.color, null);
assert.equal(ordinary.color, '#123456');
assert.equal(ordinary.diameterScale, 1);
const short = resolve({
shortReason: 'event', shortExpiresAt: 2_000, reducedMotion: true,
@@ -61,8 +61,21 @@ test('reduced motion replaces ordinary waves with a dot and keeps alarm semantic
assert.equal(alarm.kind, 'alarm');
assert.equal(alarm.animated, false);
assert.equal(alarm.reducedMotionIndicator, 'none');
assert.equal(alarm.color, '#f25a4a');
assert.equal(alarm.diameterScale, 3);
assert.equal(alarm.color, '#F0410C');
assert.equal(alarm.diameterScale, 1.5);
});
test('package defaults map presence, running and neutral transition to distinct colors', () => {
const presence = resolve({ semanticActivity: 'presence' });
assert.equal(presence.color, '#1DC21D');
assert.equal(presence.diameterScale, 1.5);
assert.equal(resolve({ semanticActivity: 'running' }).color, '#F0A00C');
assert.equal(resolve({
semanticActivity: 'transition', visual: visual('neutral', 'transition'),
}).color, '#0C82F0');
assert.equal(resolve({
semanticActivity: 'presence', color: '#123456', diameterScale: 4,
}).color, '#123456');
});
test('expired finite runtime never leaves a static ring', () => {
+27 -1
View File
@@ -234,7 +234,7 @@ test('sun-ray golden requires browser-painted light from a state-only sun entity
assert.ok(scenario);
const fixture = prepareGoldenFixture(scenario);
const space = fixture.config.spaces.find((item) => item.id === scenario.space);
assert.equal(GOLDEN_MATRIX_VERSION, 31);
assert.equal(GOLDEN_MATRIX_VERSION, 32);
assert.equal(space.settings.sun_rays, true);
assert.equal(scenario.northDeg, 90,
'the sign-sensitive golden must keep a non-zero north direction');
@@ -310,6 +310,32 @@ test('value badge golden covers four positions and bottom badge with separate LQ
);
});
test('device icon state-table goldens cover both themes and design facets', () => {
const scenarios = GOLDEN_SCENARIOS.filter((item) => item.id.startsWith('device-icon-state-table-'));
assert.deepEqual(new Set(scenarios.map((scenario) => scenario.theme)), new Set(['light', 'dark']));
for (const scenario of scenarios) {
assert.equal(scenario.focusDevice, 'golden-climate');
assert.equal(scenario.hoverDevice, 'golden-presence');
assert.equal(scenario.hideHoverTooltip, true);
assert.deepEqual(scenario.deviceClassOverrides['golden-climate'], ['alarm', 'sel']);
assert.deepEqual(scenario.deviceClassOverrides['golden-presence'], ['virtual']);
assert.deepEqual(scenario.deviceClassOverrides['golden-light-two'], ['lock-locked']);
assert.deepEqual(scenario.deviceClassOverrides['golden-light-three'], ['lock-unlocked']);
const fixture = prepareGoldenFixture(scenario);
assert.equal(fixture.states['light.golden_light_one'].attributes.lqi, 40);
assert.equal(fixture.states['light.golden_light_two'].attributes.lqi, 41);
assert.equal(fixture.states['light.golden_light_three'].attributes.lqi, 180);
assert.equal(
fixture.config.markers.find((marker) => marker.id === 'golden-left-temperature').display,
'value',
);
assert.equal(
fixture.config.markers.find((marker) => marker.id === 'golden-right-temperature').display,
'static_icon',
);
}
});
test('opening placement golden requires browser-painted preview pixels', () => {
const door = GOLDEN_SCENARIOS.find(
(item) => item.id === 'opening-placement-door-thick-wall-dark',