@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
@@ -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 || [])]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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` +
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)).
|
||||
- Диалог нового пространства теперь показывает именно те значения, которые
|
||||
сохранит: для плана с изображением границы и названия изначально выключены,
|
||||
а при переходе к ручному плану оба переключателя видимо включаются. После
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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. Он отдельно показывает интеграцию привязки и
|
||||
фактическую сущность отображения. Для «Значок + состояние и активность» можно
|
||||
|
||||
|
Before Width: | Height: | Size: 182 KiB After Width: | Height: | Size: 194 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 282 KiB After Width: | Height: | Size: 291 KiB |
|
Before Width: | Height: | Size: 286 KiB After Width: | Height: | Size: 293 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 128 KiB After Width: | Height: | Size: 135 KiB |
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);',
|
||||
}],
|
||||
},
|
||||
];
|
||||
|
||||
// --- механика ---------------------------------------------------------------
|
||||
|
||||
@@ -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}
|
||||
`;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>`
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Модель",
|
||||
|
||||
@@ -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(';')}">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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-/);
|
||||
});
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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',
|
||||
|
||||