37 KiB
Issue #98 — единая система пульсаций и активности устройства
- Issue: https://github.com/Matysh/houseplan-card/issues/98
- Статус ТЗ: реализовано локально; ожидает prerelease/CI gate
- Приоритет: P1 / polish
- Оценка: сложность 7/10, пользовательская ценность 7/10, ценность для
разработки 8/10, score
(2×7+8)/7 = 3,14 - Риск: средне-высокий — общий renderer, finite edge runtime, alarm safety и parity трёх surfaces меняются одновременно
- Область: frontend presentation, runtime событий, общий renderer устройства, editor preview, static card, i18n, accessibility, документация и QA
- Модель данных: без изменений
- Связано: #22 (pending/click feedback), #73 (visual continuity), #90
(value badge), #31 (полная View accessibility),
docs/LIGHT.md,docs/ARCHITECTURE.md - Touch: View/kiosk полностью поддерживаются; device editor остаётся
desktop-first, его preview на touch — best effort по
docs/TOUCH-SUPPORT.md
1. Резюме
House Plan должен использовать один понятный визуальный язык для динамики устройства. Вокруг сохранённого marker допускаются ровно три вида анимированной индикации:
- Тревога — красная непрерывная пульсация, пока активно критическое состояние.
- Короткое событие — три конечные волны общей длительностью около 3,3 с.
- Постоянная активность — спокойная непрерывная пульсация, пока устройство действительно работает, перемещается или обнаруживает присутствие.
Статичных колец больше нет. При prefers-reduced-motion: reduce обычная
активность обозначается компактной сплошной точкой внутри подложки, а тревога
остаётся понятной по красной подложке и доступному описанию.
Семантические причины event / presence / transition / running сохраняются.
Они проецируются общим pure resolver в визуальные none / alarm / short / continuous. Один renderer используется на интерактивном плане, в
hp-device-preview и в houseplan-space-card.
Хранимые значения display, ripple_color и ripple_size не меняются.
Старый display: ripple продолжает читаться как icon_ripple.
2. Проблема текущей реализации
Сейчас единая пользовательская идея реализована несколькими путями:
device-visual.tsклассифицирует semantic activity;houseplan-card.tsиspace-card.tsотдельно держат finite runtime;device-presentation.tsскрывает обычную activity для части display modes;device-face.tsрисует.activity-ring;- тревога рисуется отдельно через
.dev.alarm::after; - presence является статичным кольцом;
- reduced motion превращает все activity-эффекты в статичное кольцо;
- preview умеет показать только один абстрактный event-demo;
- UI называет параметры «Цвет/Размер эффекта», не объясняя их область действия.
Из-за этого визуальная система расходится по типам устройств и surfaces. Пользователь не может предсказать результат выбранной опции, а новый класс устройства приходится вручную подключать к нескольким renderer/CSS-веткам.
3. Цели
- Один нормативный pipeline от HA state до визуального pulse.
- Ровно три динамических pulse-kind без статичных колец.
- Одинаковый результат на полном плане, в preview и static card.
- Чёткие названия display modes и контекстные подсказки.
- Предсказуемые приоритеты alarm, short event и continuous activity.
- Безопасный reduced-motion fallback без мигающих/движущихся колец.
- Полная совместимость существующей конфигурации.
- Детерминированные runtime, smoke и golden проверки.
4. Не входит в задачу
- новые правила выбора
primary, binding или visual sources; - новые классы устройств сверх уже распознаваемой семантики;
- optimistic/pending feedback между кликом и ответом HA — #22;
- изменение Glow, лучей из окон, room fill и light-source resolver;
- анимация live vacuum puck и его следа;
- boot/recovery/navigation animations;
- пользовательская настройка скорости pulse;
- изменение config schema и переименование
ripple_*в storage; - автоматическое включение activity mode у существующих marker;
- отдельные цвета/размеры для presence, transition и running.
5. Термины и нормативные типы
5.1. Semantic activity
Существующий тип остаётся источником причины:
type DeviceActivity =
| 'none'
| 'event'
| 'presence'
| 'transition'
| 'running';
event— наблюдённый короткий edge;presence— устойчивое обнаружение присутствия;transition— фактическое механическое движение либо короткий terminal fallback;running— устойчивая полезная работа.
5.2. Rendered pulse
Вводится визуальная проекция:
type DevicePulseKind = 'none' | 'alarm' | 'short' | 'continuous';
type DevicePulseReason =
| 'alarm'
| 'event'
| 'presence'
| 'transition'
| 'running';
interface ResolvedDevicePulse {
kind: DevicePulseKind;
reason: DevicePulseReason | null;
generation: number;
color: string | null;
diameterScale: number;
expiresAt: number | null;
reducedMotionIndicator: 'none' | 'dot';
}
kind определяет DOM/CSS. reason сохраняется для preview, diagnostics,
accessible description и будущих расширений, но не создаёт четвёртый вид
анимации. generation и expiresAt имеют смысл только для short:
continuous/alarm не должны перезапускать CSS timeline из-за обычного HA
re-render. diameterScale назван отдельно от масштаба самого marker и означает
максимальный диаметр волны в диаметрах подложки.
6. Источники семантики
6.1. Тревога
Тревога активна, пока хотя бы один разрешённый critical source имеет критическое состояние:
binary_sensordevice class:smoke,gas,carbon_monoxide,moisture,safety,tamper,problem;sirenв активном состоянии;alarm_control_panel: triggered;- остальные случаи, уже канонически возвращаемые
isAlarmState().
ТЗ не расширяет isAlarmState(). Любое последующее расширение делается в
семантическом resolver и автоматически получает общий alarm pulse.
6.2. Короткое событие
Short pulse запускается только от наблюдённого edge после установленного baseline:
| Источник | Условие |
|---|---|
| motion/vibration/sound | валидный off → on |
| door/window/garage/opening contact | валидный off → on |
| button/event | изменение валидного state/event token |
| stateless action House Plan | подтверждённый успешный service call |
| cover | прямой closed ↔ open без наблюдаемого opening/closing |
| lock | прямой locked ↔ unlocked без locking/unlocking |
| valve | прямой closed ↔ open без opening/closing |
Если в одном HA snapshot обнаружены и ordinary event, и terminal fallback,
выбирается event: порядок обхода сущностей и registry rows не влияет на
результат. Между разными snapshot более новый валидный short edge перезапускает
цикл целиком.
Событие не создаётся:
- на первом snapshot;
- при
unknown/unavailable/missing → value; - при reconnect/resume/rebuild source graph;
- при неуспешном, отменённом или неподдерживаемом service call;
- от одного только hover, focus, preview re-render или смены display mode.
6.3. Постоянная активность
Continuous pulse существует ровно пока semantic state активен:
| Причина | Примеры |
|---|---|
| presence | occupancy/presence = on |
| transition | cover/lock/valve opening/closing/locking/unlocking; moving sensor; vacuum returning |
| running | light/switch/fan/humidifier on; running/power binary sensor; climate heating/cooling/preheating/defrosting; vacuum cleaning; script on; распознанные working states техники |
| controls | хотя бы один выбранный canonical visual/control source реально работает |
Ограничения:
automation: onозначает enabled, а не running;media_playerне получает continuous pulse отon/playing/paused;coveropen/closed остаётся glyph-state, а не working status;unavailable/unknownснимает обычную activity;- transition заканчивается сразу при terminal state, без доведения до 3,3 с;
- terminal fallback, наоборот, является short pulse и ограничен finite runtime.
7. Приоритеты и projection
Нормативный порядок:
- удалённый/effective hidden marker не рендерится;
- HA-disabled design preview и orphaned binding не имеют pulse;
static_iconвозвращаетnoneдаже при alarm;- alarm возвращает
alarmи перекрывает всё; - если
live_states === false, обычный pulse отсутствует; - finite event runtime возвращает
short; - актуальная
presence/transition/runningвозвращаетcontinuous; - иначе
none.
unavailable не является device-level veto само по себе. Resolver сначала
отбрасывает недоступные samples: активный critical source другого entity всё
ещё обязан показать alarm, а доступный рабочий source — continuous. Pulse
отсутствует из-за availability только когда после фильтрации не осталось ни
одного доступного релевантного source. Это сохраняет действующий контракт
combineVisualSamples() и не позволяет одному диагностическому entity погасить
реальную тревогу устройства.
Short pulse имеет приоритет над continuous. После окончания short resolver снова проверяет актуальную семантику и без паузы возвращает continuous, если устройство продолжает работать.
Если во время terminal fallback появляется реальный transition state, finite fallback сбрасывается и сразу заменяется continuous transition. При первом входе в alarm ordinary finite runtime очищается, а текущие samples становятся новым baseline. Замораживание запрещено: после снятия тревоги старый short не возобновляется, но новый валидный edge, произошедший уже после alarm, обрабатывается обычно.
8. Матрица display modes
Хранимые токены не меняются. badge остаётся default.
| Config | RU | EN |
|---|---|---|
badge |
Значок + состояние | Icon + state |
icon_ripple |
Значок + состояние и активность | Icon + state and activity |
value |
Значение + состояние | Value + state |
static_icon |
Всегда статичный значок | Always static icon |
8.1. Полная матрица
| Режим | Нейтрально / unavailable | Working/open state | Short | Continuous | Alarm | Satellites |
|---|---|---|---|---|---|---|
| Значок + состояние | текущая тёмная/приглушённая подложка | жёлтая working, оранжевая physical open; cover меняет glyph | нет | нет | красная подложка + alarm pulse | icon/morph, value badge и LQI разрешены |
| Значок + состояние и активность | как выше | как выше | три волны | единая спокойная пульсация | красная подложка + alarm pulse | как выше |
| Значение + состояние | динамическая подложка | жёлтая/оранжевая подложка | нет | нет | красная подложка + alarm pulse | value вместо icon, fallback icon, value badge/LQI |
| Всегда статичный значок | всегда нейтральная тёмная подложка | без изменений | нет | нет | нет | только базовый icon; без morph/value/value badge/LQI/RGB/live-vacuum overlays |
Alarm является safety-исключением для всех state-aware modes. Только явный
static_icon скрывает alarm presentation; диалог сохраняет предупреждение для
alarm-capable binding.
Hover, focus outline, shadow и click action не входят в display projection и остаются одинаковыми по действующему контракту.
9. Визуальный контракт
9.1. Alarm
- системный цвет
#f25a4aлибо единый design token с тем же контрастом; - красная plate сохраняется;
- один expanding/fading pulse, непрерывно пока alarm активен;
- пользовательские
ripple_color/ripple_sizeигнорируются; - интенсивность не усиливается при hover;
- частота/opacity не создают WCAG flashing violation.
9.2. Short
- ровно три последовательные волны;
- одна волна 1,1 с, total runtime 3,3 с;
- повторный edge увеличивает generation и перезапускает весь цикл;
- старый DOM/timer не накапливается;
- цвет и максимальный диаметр берутся из ordinary activity settings.
9.3. Continuous
- один breathing pulse без статичной фазы;
- один общий tempo для presence, transition и running: рекомендуемо 2,4 с
ease-in-out infinite; - reason не меняет геометрию, скорость и интенсивность;
- выключение/idle/terminal state снимает pulse немедленно;
- смена причины
presence → runningне должна визуально вспыхивать или перезапускать timeline, если kind остаётсяcontinuous.
9.4. Цвет и размер ordinary activity
Порядок цвета:
- валидный
marker.ripple_color; - текущий RGB canonical light source;
--hp-accent.
Размер: marker.ripple_size, default 3, текущие backend limits сохраняются.
Параметры применяются только к short/continuous, не к alarm и не к
reduced-motion dot.
10. Reduced motion и accessibility
При prefers-reduced-motion: reduce:
.device-pulseс движущимися кольцами не рендерится либо полностью скрыт;- alarm читается по красной plate и accessible state;
- working/open читаются по существующим plate/glyph states;
- в
icon_rippleshort/continuous отображаются.activity-dot; - short dot живёт то же finite окно 3,3 с;
- continuous dot существует до окончания semantic state;
- у других display modes dot отсутствует;
- статичное outline/ring запрещено.
Dot находится внутри нижнего правого угла plate, а не снаружи marker:
- не конфликтует с
new-devicedot сверху справа; - не увеличивает bounding box;
- не пересекается с внешним value badge и нижним LQI;
- имеет контрастную 1 px обводку через plate/background token;
- не является интерактивной целью и имеет
aria-hidden="true".
Доступное имя/описание marker включает локализованную причину:
- alarm: «Тревога»;
- event: «Короткое событие»;
- presence: «Обнаружено присутствие»;
- transition: «Устройство перемещается/переключается»;
- running: «Устройство работает».
Визуальный эффект не должен быть единственным носителем критической
информации. В рамках #98 обновляется существующее aria-label полного marker и
role="img" preview; static card сохраняет read-only семантику. Добавление
полной клавиатурной навигации по объектам плана, нового tab order или
role="button" относится к #31 и не должно скрыто появиться в этой задаче.
Для alarm кроме motion всегда остаются красная plate и локализованный текст в доступном имени/информационной поверхности. Для short/continuous отсутствие анимации при reduced motion не меняет действие по нажатию и hit area.
11. UI настроек устройства
11.1. Названия и подсказки
Один общий marker.display_hint заменяется подсказкой для текущего mode.
Нормативный смысл:
- Значок + состояние: plate и icon показывают состояние без обычной пульсации; критическая тревога пульсирует красным.
- Значок + состояние и активность: дополнительно показывает короткие события и непрерывную реальную активность.
- Значение + состояние: выводит однозначное HA value, при невозможности — state-aware icon; alarm остаётся видимым.
- Всегда статичный значок: marker не реагирует на HA state; hover, focus и click продолжают работать.
Поля переименовать:
- RU: «Цвет пульсации активности», «Размер пульсации активности»;
- EN:
Activity pulse color,Activity pulse size.
Под полями: «Не влияет на красную тревогу» / Does not affect red alarms.
Поля показываются только при icon_ripple. Их сохранённые значения не
стираются при временном выборе другого display mode и применяются снова после
возврата.
11.2. Preview
hp-device-preview получает готовый ResolvedDevicePulse, а не вычисляет
activity заново.
В режиме icon_ripple доступны две независимые preview-команды:
- Показать короткую пульсацию — запускает local-only short demo на 3,3 с.
- Показать постоянную пульсацию — включает continuous demo до повторного нажатия, смены binding/display, появления real pulse или закрытия preview.
Правила:
- demo не меняет HA state, config и основной
_activityRt; - real alarm/short/continuous имеет приоритет и отключает demo;
- одновременно активен максимум один demo;
- кнопка continuous меняет label на «Остановить постоянную пульсацию»;
- reduced motion показывает dot и пояснение, а не кольцо;
- preview facts различают reason, даже если rendered kind один;
- fit учитывает максимальный ordinary pulse, alarm pulse и satellites без clipping.
12. Архитектура
12.1. Нормативный pipeline
HA states + registry
→ entityVisualSample(s)
→ combineVisualSamples
→ per-card finite edge runtime
→ resolveDevicePresentation
→ resolveDevicePulse
→ renderDeviceFace/device-pulse
→ interactive plan | preview | static card
Рекомендуется отдельный pure module src/device-pulse.ts:
resolveDevicePulse({
display,
visual,
liveStates,
effectiveHidden,
bindingStatus,
runtime,
rippleColor,
rippleScale,
reducedMotion,
}): ResolvedDevicePulse
Resolver не читает DOM, timers, matchMedia и global state. now передаётся
явно через runtime/presentation options для детерминированных тестов.
Полный ResolvedDevicePresentation может содержать pulse как вложенную
проекцию, но не должен одновременно оставлять второй нормативный
activity + classes path. На время одного beta допускается compatibility
adapter старых классов; бизнес-решение всегда принимается до renderer.
12.2. Runtime ownership
Finite runtime остаётся per card instance:
- interactive card и static card не делят Map;
- Map key — стабильный marker id;
- отдельная
activitySourceSignatureвключает binding и только visual/critical sources, которые способны породить semantic edge; - signature не включает display mode, value/value-badge source, LQI, temperature visibility, satellites, icon или preview-only presentation;
- source signature change устанавливает новый baseline;
- runtime хранит
startedAt,expiresAt, semantic reason и generation; - один timeout только будит render около expiry; истина определяется
now < expiresAt, а не фактом срабатывания timer; - disconnect очищает timers/Map;
- hidden tab не проигрывает накопившийся short после возврата.
Текущую presentationSourceSignature(), которая также включает value source,
нельзя без проверки объявить activity signature: смена бейджа или глобального
show_temperature не должна отменять либо искусственно перезапускать pulse.
Допустимо сохранить старую функцию для memo/snapshot и добавить узкую
activitySourceSignature() рядом с semantic resolver.
prefers-reduced-motion подписывается один раз на surface/card host, а не на
каждый marker. Host передаёт актуальный boolean в pure resolver и вызывает один
re-render при изменении media query. Full card, static card и standalone
preview самостоятельно владеют подпиской и очищают её при disconnect.
Можно сохранить совместимость текущего flashTs/flashKind/gen, если наружный
контракт даёт те же гарантии. Переименование runtime fields не является целью.
12.3. Один renderer
renderDeviceFace() рендерит один слой:
<span class="device-pulse pulse-short reason-event gen-2" aria-hidden="true">
<i></i><i></i><i></i>
</span>
или continuous/alarm variant. Reduced-motion dot создаётся тем же renderer.
Удалить:
.dev.alarm::afterкак отдельный pulse path;- статичную
.activity-ring.presenceветку; - reduced-motion static ring;
- surface-specific самостоятельное решение вида pulse.
Класс .dev.alarm сохраняется только для alarm plate. Внешние
activity-event/presence/transition/running classes можно оставить временно
для compatibility hooks на один beta-cycle, но production CSS и новые тесты не
должны от них зависеть. Перед stable их судьба фиксируется в реестре
compatibility #33: удалить либо документировать как deprecated styling hooks.
13. Сохранение и обратная совместимость
displaytokens не меняются;rippleпродолжает нормализоваться вicon_ripple;ripple_colorиripple_sizeчитаются/пишутся как раньше;- Open → Save без изменения display не материализует новые поля;
- импорт/экспорт round-trip побайтно сохраняет эти поля;
- backend validation limits не меняются;
- downgrade видит прежние поля и может показать старый ring — это ожидаемая визуальная разница, не потеря данных;
- schema/model migration не требуется.
14. Эдж-кейсы
- несколько sources одновременно: alarm > newest short > continuous;
- short приходит во время continuous и после себя возвращает актуальный continuous;
- два short подряд перезапускают три волны через generation;
- alarm возникает во время short и полностью его подавляет;
- alarm снимается после expiry short — старый short не возвращается;
- alarm onset очищает finite runtime и синхронизирует baseline всех sources;
- binding/source graph изменился — старые edges не переносятся;
- смена display, value badge, LQI и
show_temperatureне меняет activity signature и не создаёт edge; unavailable → onпосле reconnect — baseline без события;- один unavailable sample не гасит доступный alarm/continuous другого sample;
- быстрый
opening → openпрекращает continuous сразу; - прямой
closed → openзапускает short fallback; - custom color невалиден — безопасный fallback, без CSS injection;
ripple_sizemin/max, marker scale 0.5/3, value badge на четырёх сторонах;- hover/focus не меняет opacity/speed и не перезапускает animation;
static_icon → icon_rippleпоказывает только актуальную continuous семантику, но не старый finite event;- live_states on/off применяется немедленно без Save;
- preview draft не мутирует runtime полного плана;
- две карточки одного плана имеют независимый generation/timer;
- reduced-motion меняется во время активного effect без remount;
- media-query listener существует один раз на surface, а не на marker;
- HA-disabled preview ghost не получает alarm/activity dot;
- new-device dot и activity dot видны раздельно;
- theme/contrast и пользовательский accent;
- touch hover отсутствует, click target не увеличивается pulse layer.
- background/resume просрочивает short по
expiresAtдо первого нового paint; замороженная браузером CSS-анимация не продолжает старые три волны.
15. Производительность и безопасность
- один pure pulse resolve на уже построенную presentation;
- не повторять
resolvedLightSourcesради цвета внутри каждого surface; - continuous смена HA state не создаёт новые DOM nodes каждый tick;
- максимум один finite timeout на marker с short runtime;
- CSS animation использует transform/opacity, не layout properties;
- pulse layer
pointer-events: none; - color проходит существующую safe color normalization;
- нет HTML из HA state/labels;
- golden/performance fixture не должен получить дополнительный full-plan rebuild при каждом animation frame.
16. Тестирование
16.1. Unit
Обязательная таблица semantic → pulse:
- все display modes × alarm/event/presence/transition/running/none;
- alarm > short > continuous;
- live_states false;
- static_icon;
- hidden, HA-disabled, orphaned/unavailable;
- terminal fallback и замена real transition;
- initial/reconnect/unavailable baseline;
- rapid retrigger/generation;
- source signature reset;
- activity signature не зависит от display/value badge/LQI/temperature UI;
- один unavailable source рядом с доступным alarm/running source;
- alarm onset baseline reset и отсутствие stale short после alarm;
- custom/fallback color и scale;
- reduced-motion dot/none, hot media-query change, никогда static ring.
16.2. Browser smoke
- motion, contact, button/event, presence, working switch, climate, cover, lock, valve, vacuum, script, alarm;
- четыре display modes по полной матрице;
- short три волны и cleanup после 3,3 с;
- continuous появляется/исчезает по state;
- hover не перезапускает timeline;
- preview parity и обе demo-команды;
- static card parity;
- runtime независим между двумя card instances;
- hide/resume после истёкшего short не продолжает замороженную CSS-анимацию;
- reduced-motion dot geometry и absence of rings;
- спутники marker не перекрываются.
Таймеры тестируются управляемым now/fake clock; CI не ждёт реальные 3,3 с
там, где достаточно pure assertion.
16.3. Golden
Минимальная матрица:
- desktop/mobile;
- light/dark;
- alarm;
- short в фиксированном progress;
- continuous;
- reduced-motion dot;
- четыре display modes;
- presence без статичного кольца;
- max ripple scale + value badge/LQI/new-device dot.
Новые golden принимаются только из Linux CI artifact по HP-QA-01.
17. Документация и release-артефакты
В той же реализации обновить:
docs/CHANGELOG.mdиdocs/CHANGELOG.ru.mdсо ссылкой на #98;docs/ARCHITECTURE.md— presentation/pulse pipeline;docs/LIGHT.md— явно отделить marker pulse от Glow;docs/FILTERING.md— semantic sources/lifecycle;- пользовательскую документацию по четырём display modes;
docs/TESTING.md— unit/smoke/golden матрицу;- ru/en i18n parity.
Release body: значимое пользовательское изменение одной строкой + ссылки на RU/EN changelog. Поставка сначала в beta. Full golden/performance gate — перед stable release согласно действующей release policy.
18. План реализации
- Ввести
activitySourceSignature, pureResolvedDevicePulseи исчерпывающие unit tests. - Подключить pulse к
ResolvedDevicePresentationбез изменения DOM и добавить одну reduced-motion subscription на surface. - Перевести
renderDeviceFace()на единыйdevice-pulserenderer. - Удалить alarm pseudo-element, presence static ring и reduced-motion ring.
- Нормализовать finite runtime interactive/static cards.
- Обновить display copy и activity settings.
- Реализовать две preview demo-команды.
- Обновить accessibility descriptions и satellite layout.
- Добавить browser/golden coverage и документацию.
- Выпустить beta, проверить реальные motion/presence/alarm/cover fixtures.
19. Критерии приёмки
- В production есть один pure pulse resolver и один DOM renderer.
- Визуальных pulse-kind ровно три: alarm, short, continuous.
- Статичных колец нет в normal, reduced motion и preview.
- Presence показывает continuous pulse, а не кольцо.
- Real transition существует ровно пока идёт переход; terminal fallback — short около 3,3 с.
- Alarm использует единый renderer, системный красный и имеет высший приоритет.
- Матрица четырёх display modes соблюдается на всех surfaces.
live_states: falseподавляет ordinary activity, но не alarm.static_iconне показывает alarm/activity и сохраняет hover/focus/click.- Цвет/размер ordinary activity не влияют на alarm.
- Reduced motion использует внутреннюю dot только для activity mode.
- Preview показывает фактический результат и две безопасные demo-команды.
- Первый snapshot/reconnect/unavailable recovery не создают false event.
- Activity signature не зависит от value/satellite/display presentation.
- Alarm onset очищает finite runtime; один unavailable source не подавляет доступную тревогу другого source.
- Rapid retrigger не накапливает DOM/timers и корректно перезапускает цикл.
- Background/resume не продолжает просроченную short-анимацию.
- Модель, backend и существующие конфиги остаются совместимыми.
- Full plan, preview и static card дают одинаковую projection.
- ru/en, docs, unit, smoke и golden gates актуальны.