Files
houseplan-card/docs/specs/067-light-source-controls.md

608 lines
54 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 067 — Источники света: роль, цвет, яркость и интенсивность свечения (ревизия 5)
> Правило «Всегда без собственной controllable-сущности = нет источника» из
> этой ревизии отменено issue #84. Актуальная семантика passive forced source,
> marker-связей и выбора ведущей сущности (#88) зафиксирована в
> [`084-passive-forced-light-sources.md`](084-passive-forced-light-sources.md)
> и каноническом [`../LIGHT.md`](../LIGHT.md). Остальные решения этого ТЗ
> продолжают действовать.
- Issues: **#67** (интенсивность, хаб), **#65** (роль источника), **#66** (цвет и яркость источника)
- Приоритет: P2
- Статус ТЗ: **ревизия 5 — все замечания ревью раундов 1–4 закрыты, READY FOR IMPLEMENTATION**
- Связано: #19 (аддитивное смешивание пятен), #55 (независимый Glow), #21 (контракт цвета),
#33 (реестр совместимости)
- Итоговое место файла в репозитории: `docs/specs/067-light-source-controls.md`
> **Актуализация 2026-08-11 (после #71).** Формула яркости и все её константы в
> силе без изменений: `glowAlpha` по-прежнему единственный источник альфы. Но
> эта альфа теперь — значение в ЦЕНТРЕ пятна, а `GLOW_FALLOFF` расходует её по
> всему радиусу вместо плато до 70%. Отдельного «пролива» больше не существует:
> свет за проёмом — то же самое поле источника, поэтому цвет и яркость там
> совпадают с комнатой источника по построению, а не по совпадению констант.
Три issue делаются вместе, потому что меняют одну цепочку данных
`marker config → resolvedLightSources → spatial source → цвет/яркость → alpha → Glow pool`
и один блок диалога маркера. Релиз один: половинчатая поставка даёт промежуточные,
необъяснимые состояния UI.
## 0. Ответ на ревью раундов 1–4
### Раунд 4
| # | Вердикт | Решение |
|---|---|---|
| R4-B1 `Number(null)` → 0 | **Принято** — до `Number(...)` добавлена проверка типа и непустой строки; `null`, empty/whitespace и boolean дают фолбэк 100%, конечная числовая строка сохраняет совместимость (§3.3) |
| R4-E1 статус Project | **Закрыто** — #65/#66/#67 находятся в `House Plan Backlog`, `Todo`, P2 (§10) |
| R4-E2 терминология #66 | **Закрыто** — канонический issue использует «Цвет и яркость» и `brightness`, ссылка обновлена на ревизию 5 |
### Раунд 3
| # | Вердикт | Решение |
|---|---|---|
| R3-B1 процесс | **Принято** — из #65 удалена фраза `Owner decision pending`, решение записано тем же текстом, что в §6/§10; ревизия положена в `docs/specs/067-light-source-controls.md`, из всех трёх issue дана ссылка; Project синхронизирован (§10) |
| R3-E1 `controls` в выборе сущности | **Принято, замечание точное** — порядок кандидатов действительно `controls → forced → auto` (`devices.ts:271-283`), поэтому «первая включённая» могла оказаться внешним контролом. Введён нормативный `selectSpatialGlowSource` с фильтром `castsGlow` до выбора (§5.6) |
| R3-E2 прямой переход в режим 3 | **Принято** — любой вход в режим 3 без draft снимает snapshot **и** цвета, и яркости; последовательный и прямой пути дают одинаковый результат (§4.7) |
| R3-E3 нормализация живой яркости | **Принято** — нормативная формула вынесена отдельно; отмечено, что это меняет сегодняшнее поведение при `brightness = 0` (§3.3) |
### Раунд 2
| # | Вердикт | Решение |
|---|---|---|
| R2-B1 legacy `is_light:false` | **Закрыто решением владельца 2026-08-10** — старое `false` осознанно читается как «Никогда»; пометки «на подтверждении» сняты (§6, §10) |
| R2-B2 issue расходятся с ТЗ | **Принято** — критерии и тесты #65 переписаны условно по матрице §3.1, таблица и раздел следствий #67 исправлены (1% против raw 1/255; кривая меняет и одиночные пятна). Про Project — §10 |
| R2-B3 resolver против инициализации | **Принято** — уровни разделены: `resolveGlowValues` (без on/off, для диалога) и `resolveGlowAppearance` (`null` при неактивном источнике); задан общий стабильный выбор сущности для snapshot (§5.6) |
| R2-E1 §5.4 обещает лишнее | **Принято** — два эффекта разведены: перенос групповой opacity пиксельно нейтрален для непересекающегося пятна при той же альфе, кривая отдельно меняет все диммированные |
| R2-E2 `{c, bri: null}` | **Принято** — `null` эквивалентен отсутствию поля: режим 2, следующий Save канонизирует объект в `{c}` |
| R2-E3 dormant override | **Принято** — disabled не стирает `glow_color` при Save; очистка только явным выбором «Из источника» (§4.7) |
| R2-E4 перф-политика | **Принято** — раздел приведён к уже принятой политике #69 |
### Раунд 1
| # | Вердикт | Решение |
|---|---|---|
| B1 таблица неверна для 1% | **Принято, ошибка признана** — пересчитал: `alpha(0.01) = 0.2820`, а `0.267` относится к HA raw `brightness=1` (`bri = 1/255 = 0.392%`). Строки разделены, столбец контраста пересчитан (§5.2), оба значения в тестах (§8) |
| B2 «Никогда» vs `controls` | **Принято** — матрица роль × controls внесена в нормативную часть (§3.1), критерии и смоки переформулированы условно; issue #65 исправлен |
| B3 legacy `is_light:false` | **Принято, «миграций нет» было неверно** — выбран вариант 1: старое `false` осознанно активируется как «Никогда», это read-semantics change в реестр #33 и CHANGELOG (§6). **Подтверждено владельцем 2026-08-10** |
| B4 валидация `bri` пропускает NaN | **Принято** — схема через `_finite` (`validation.py:53`), добавлено правило «невалидный override целиком → Авто, без частичного применения» (§3.2) |
| B5 инициализация ручных значений | **Принято** — snapshot текущего effective-вида, нормализация в `#RRGGBB`, draft-память диалога (§4.7) |
| B6 «Всегда» без собственной сущности | **Принято** — вводится общий pure-предикат `hasOwnSpatialSource`, гейтинг §4.4 расширен третьим случаем |
| E1 смысл подписи «Авто» | **Принято** — классификация источника, а не его состояние; off/unavailable/hidden/HA-disabled определены (§4.2) |
| E2 ownership при нескольких сущностях | **Принято** — четыре правила в §5.5 + два юнита |
| E3 «побайтно» слишком широко | **Принято** — критерий сужен до двух новых полей на каноническом фикстуре; downgrade-риск описан честно (§6) |
| E4 следы прежней модели opacity | **Принято** — §4.6 (три опции), убрана «непрозрачность при полной яркости», тест переписан на слот `bri` |
| E5 единый pure resolver | **Принято** — нормативные `resolveGlowAppearance` и `glowAlpha` (§5.6) |
| E6 static card | **Принято** — role parity обязательна, `glow_color` для неё N/A (§5.7) |
| E7 a11y-контракт | **Принято** — §4.8 |
| T1–T4 | **Приняты** — golden-список, переписывание blend-смока, негативные кейсы, политика перф-джоба (§8) |
| Backlog | **Принято** — #65/#66/#67 приведены в соответствие; членство в Project — задача владельца (§10) |
---
## 1. Что есть сейчас (проверенные факты)
**Разрешение источников.** `resolvedLightSources` (`src/devices.ts:311`) — единственный источник
истины для всех световых функций комнаты: свечение, состояние комнаты `on/off/none`
(`devices.ts:340`) и счётчик «N из M» (`devices.ts:999`). Внутри `lightEntitiesOf` порядок такой:
1. явные внешние `controls` — они намеренно **не** дают свечения (`castsGlow = via !== 'controls'`,
`devices.ts:323`), только голос в статистике комнаты;
2. `marker.is_light === true` → принудительная сущность через `forcedLightEntityOf`
(`devices.ts:274`), которая берёт **собственную** controllable-сущность маркера;
3. автоопределение: только если функциональная роль маркера — `light.*`, берутся его `light.*`
сущности. Телевизор со служебной `light.*` (подсветка логотипа) сюда намеренно не попадает.
**Цвет и альфа пятна.** `glowColorOf` (`src/logic.ts:1643`): `rgb_color` → кельвины
(`color_temp_kelvin`/`color_temp`) → общий цвет `fill_colors.glow_light.c`; яркость
`bri = clamp(brightness/255, 0.15, 1)` (`logic.ts:1647`). Альфа стопов градиента:
`colors.glow_light.a * glow.bri` (`houseplan-card.ts:11253`). Поверх всей группы пятен висит
`opacity="0.7"` (`:11289`). Дефолт токена — `glow_light: { c: '#ffd9a0', a: 0.85 }` (`logic.ts:1385`).
**Диалог маркера.** Один чекбокс «Источник света» (`houseplan-card.ts:14602-14605`), читается как
`marker.is_light === true` (`:9551`), пишется как `is_light: dlg.isLight ? true : null`
(`:9946`, `:14347`). Сразу под ним — «Радиус свечения» (`:14606-14614`).
**Хранение.** `is_light?: boolean | null` уже есть (`src/types.ts:128`), бэкенд принимает
`vol.Any(bool, None)` (`validation.py:646`). Прецедент per-marker цвета — `ripple_color`
(`validation.py:653`), пишется только когда осмысленно (`houseplan-card.ts:9937`). Для конечных
чисел в схеме есть `_finite` (`validation.py:53`, аудит B5).
## 2. Проблемы, которые закрываем
**#65.** Контрол назван как утверждение о факте, а работает как принудительное переопределение:
у настоящей лампы свечение приходит из ветки 3, флаг остаётся `false`. Запретить свечение реальной
`light.*` нельзя вовсе, хотя потребность в коде уже признана эвристикой про телевизор.
**#66.** Цвет резолвится с **одним** глобальным фолбэком, яркость и альфа — только глобальные.
Любой «бесцветный» источник (лампа за умным выключателем — ровно случай `is_light: true`) светит
одинаково на весь дом; насыщенная RGB-лента наоборот заливает комнату собой. Радиус при этом уже
per-marker — модель асимметрична.
**#67.** Итоговая альфа — произведение трёх множителей, из которых пользователь видит один.
Замер: при 20% яркости альфа 0.119, пятно ярче затемнённого пола на 19%.
---
## 3. Нормативная модель
### 3.1 Роль источника (#65)
`marker.is_light?: boolean | null` — трёхзначное:
| Значение | UI | Смысл |
|---|---|---|
| `null` / отсутствует | **Авто** (дефолт) | решает эвристика роли, как сегодня |
| `true` | **Всегда** | принудительный источник из собственной controllable-сущности |
| `false` | **Никогда** | собственный источник маркера подавлен |
**Матрица роль × `controls` (нормативная).** «Никогда» подавляет **собственный кандидат маркера**
и только его; явный список `controls` — отдельный механизм, управляемый пользователем напрямую:
| Роль | Внешние `controls` | Результат |
|---|---|---|
| Авто | нет | собственный `via:light`, если роль маркера — light |
| Всегда | нет | собственный `via:forced`, если есть подходящая controllable-сущность |
| Никогда | нет | источников от маркера нет |
| Авто | есть | только `controls` (собственный auto-кандидат подавлен текущим приоритетом) |
| Всегда | есть | `controls` + собственный `via:forced` |
| **Никогда** | **есть** | **только `controls`** — они продолжают голосовать в `on/off/none` и «N из M» |
Отсюда условная формулировка вместо прежнего безусловного обещания: «Никогда» **удаляет
собственный кандидат маркера**; агрегаты комнаты падают только если у маркера нет `controls`
и в комнате не осталось других источников.
`false` **не меняет**: тап-действие, иконку, бейдж, цвет маркера, участие в прочей статистике
комнаты (температура, влажность, LQI), дедупликацию ownership между контроллером и физическим
маркером — только собственную световую роль.
### 3.2 Цвет и яркость источника (#66)
`marker.glow_color?: { c: string; bri?: number | null } | null`. **Режим выводится из наличия
полей**, отдельного перечисления нет:
| Хранится | Режим UI | Цвет | Яркость |
|---|---|---|---|
| поля нет | **Из источника** (дефолт) | `rgb_color` → кельвины → `glow_light.c` | `brightness` → 100%, если не отдаёт |
| `{c}` | **Задать цвет** | `c` | `brightness` → 100%, если не отдаёт |
| `{c, bri}` с конечным `bri` | **Задать цвет и яркость** | `c` | фиксированная `bri` |
| `{c, bri: null}` | **Задать цвет** (как `{c}`) | `c` | `brightness` → 100%, если не отдаёт |
**Backend-схема (нормативно):**
```python
vol.Optional("glow_color"): vol.Any(
None,
vol.Schema({
vol.Required("c"): _COLOR,
vol.Optional("bri"): vol.Any(None, vol.All(_finite, vol.Range(min=0.01, max=1.0))),
}),
),
```
`_finite` обязателен: `vol.Coerce(float) + Range` пропускает `NaN` — этот класс уже закрыт
аудитом B5 в этом же файле.
**Runtime-деградация — всё или ничего:**
- поле отсутствует или `null` → Авто;
- валидный `c`, `bri` отсутствует или `null` → ручной цвет, живая яркость (режим 2); `null` нормативно
эквивалентен отсутствию поля, и следующий Save канонизирует объект в `{c}`;
- валидный `c` + конечный `bri` 0.01…1 → ручной цвет и ручная яркость;
- невалидный `c`, неконечный или выходящий за диапазон `bri`, `bri` без `c`, массив, строка,
посторонние вложенные ключи → **весь override невалиден, используется Авто**; частичное
применение запрещено.
`bri` хранится как 0.01…1, UI показывает проценты — тем же приёмом, что `glow_radius_cm` хранит
сантиметры при метрах/футах в интерфейсе. Нуль отвергается схемой: шкала начинается с 1% (§4.3).
На рендер-границе сохранённый цвет проходит **строгий** `safeStoredColor` (`#RRGGBB`), а не
широкий CSS-парсер. Автоматический `rgb(...)`, сгенерированный из кельвинов, остаётся внутренним
значением и идёт своим генератором.
UI в режиме «Из источника» **удаляет** поле, а не пишет `glow_color: null`.
Плата за выведение режима из данных, принятая осознанно: возврат в «Из источника» стирает ручные
значения из конфига; в пределах открытого диалога они помнятся (§4.7).
Ручное значение подменяет **яркость**, а не потолок альфы: `A_max` всегда общий. Per-marker
непрозрачности в модели нет.
### 3.3 Интенсивность (#67)
```ts
const GLOW_SCALE_MAX = 0.7; // потолок шкалы: 100% интенсивности == фактические 0.7
const GLOW_MIN_FRAC = 0.4; // доля потолка у еле включённой лампы
const GLOW_GAMMA = 1 / 2.2; // перцептивная кривая (sRGB-гамма)
```
```
A_max = fill_colors.glow_light.a × GLOW_SCALE_MAX // потолок ОБЩИЙ, не per-marker
bri = marker.glow_color?.bri ?? clamp(brightness / 255, 0, 1) // БЕЗ прежнего зажима 0.15
alpha = A_max × ( GLOW_MIN_FRAC + (1 − GLOW_MIN_FRAC) × bri^GLOW_GAMMA )
```
**Нормализация живой яркости (нормативно):**
```ts
const attr = state?.attributes?.brightness;
const raw = typeof attr === 'number'
? attr
: (typeof attr === 'string' && attr.trim() !== '' ? Number(attr) : Number.NaN);
const bri = Number.isFinite(raw) ? clamp(raw / 255, 0, 1) : 1;
```
Терпимость к конечной числовой строке у HA-атрибута сохраняется (`Number('128')`), но `null`,
empty/whitespace и boolean считаются отсутствующим/невалидным значением и дают `bri = 1`.
Правила строгой валидации `glow_color.bri` этим не затрагиваются. Лампа без атрибута — `bri = 1`.
**Это меняет сегодняшнее поведение в одном месте:** сейчас `briRaw > 0` в условии
(`logic.ts:1647`) отправляет `brightness = 0`, отрицательное и `NaN` в ветку «полная яркость», то
есть включённая лампа с `brightness: 0` светит сегодня на 100%. По новой формуле ноль и
отрицательное дают нижний пол, `NaN`/отсутствие — по-прежнему 100%. Полагаться на то, что ноль
встречается только при `state = off`, нельзя.
Зажим `Math.max(0.15, …)` удаляется — иначе двойной пол. Пол выражен долей потолка, а не абсолютом.
`opacity="0.7"` с группы пятен (`houseplan-card.ts:11289`) удаляется; `isolation: isolate`
(`styles.ts:508`) остаётся и объявлен независимо, поэтому изоляция screen-блендинга не ломается.
Устаревший комментарий в `styles.ts` про «outer opacity применяется после композиции» обновить.
---
## 4. Нормативный UI
### 4.1 Блок из трёх контролов
Подряд, в этом порядке:
1. **«Является источником света»** — радиокнопки **Авто / Всегда / Никогда**.
2. **«Цвет и яркость»** — три радиокнопки:
- **Из источника** — цвет и яркость от устройства; при их отсутствии цвет из общих настроек,
яркость 100%;
- **Задать цвет** — колор-пикер; яркость остаётся живой;
- **Задать цвет и яркость** — колор-пикер плюс шкала яркости 1…100%.
3. **«Радиус свечения»** — существующий контрол без изменений.
### 4.2 Расшифровка у «Авто»
`Авто (является источником)` / `Авто (не является источником)` — две полные строки перевода,
без конкатенации.
Подпись описывает **классификацию собственного spatial-источника, а не его текущее состояние**:
- `off` и `unavailable` **не** меняют текст: выключенная лампа остаётся источником света;
- временно скрытый маркер (`hidden`) тоже остаётся «является» — скрытие это отдельная ось,
подпись объясняет, что произойдёт после показа;
- HA-disabled binding не имеет runtime-сущности → «не является», вместе с уже существующим
баннером; после включения в HA пересчитывается;
- несохранённая привязка и правки `controls` в этом же диалоге учитываются живьём;
- значение берётся из общего helper-а резолвера, **никогда** из проверки домена в UI.
### 4.3 Ручная шкала яркости
Проценты — аналог значения устройства: попадают в слот `bri` формулы §3.3 и проходят кривую.
Это **не** сырая непрозрачность.
Шкала **1…100%**, нуля нет: формула на нуле даёт пол `A_max × GLOW_MIN_FRAC` ≈ 0.238 — заметное
пятно, и «0%» читалось бы как «выключить». Выключение — роль «Никогда» строкой выше.
Режим «Задать цвет и яркость» **игнорирует живую яркость**: лампа, приколотая к 40%, светит на 40%
независимо от реального диммирования. Включение/выключение продолжает действовать.
### 4.4 Гейтинг
Вводится общий pure-предикат `hasOwnSpatialSource(hass, device)` — экспортируется из `devices.ts`
и используется **и** внутри `lightEntitiesOf`, **и** диалогом. Копирование эвристики в карточку
запрещено.
| Состояние | Контролы «Цвет и яркость» и «Радиус» |
|---|---|
| Авто (является) или Всегда с собственной controllable-сущностью | активны |
| Авто (не является) | disabled, подсказка указывает на контрол выше |
| Никогда | disabled, подсказка указывает на контрол выше |
| **Всегда без собственной controllable-сущности** | **disabled**, подсказка «У привязки нет сущности, состоянием которой можно управлять свечением» |
Наличие `controls` само по себе блок не активирует: внешние цели намеренно не spatial.
Смена привязки в этом же диалоге пересчитывает состояние живьём. Контролы не прячутся — только
дизейблятся: прятанье и породило исходную жалобу #65.
### 4.5 Отсутствие молчаливой материализации
Открытие и сохранение диалога без касания новых контролов не добавляет в конфиг ни `is_light`,
ни `glow_color` (уточнённая формулировка — §7, критерий 12).
### 4.6 i18n
Ключи в обеих локалях: подпись и подсказка блока роли, **три** подписи опций роли (две из них с
расшифровкой), подпись и подсказка блока «Цвет и яркость», **три** подписи его опций, подпись
шкалы яркости, подсказка про 1% и про выключение через «Никогда», подсказки трёх disabled-случаев
§4.4. Существующие `marker.is_light` / `marker.is_light_tip` переформулировать. Конкатенация
подписей запрещена (§17 HP-UX-11).
### 4.7 Инициализация ручных значений и draft-память
- **Из источника → Задать цвет:** пикер получает snapshot текущего effective auto-цвета,
нормализованный в `#RRGGBB` (автоматический `rgb(...)`/кельвины конвертируются); если источник
цвета не даёт — общий `glow_light.c`.
- **Задать цвет → Задать цвет и яркость:** шкала получает текущую конечную `brightness/255`,
округлённую до целого процента и зажатую в 1…100; при отсутствии или невалидности — 100%.
- Выключенная/недоступная лампа: атрибуты используются, если они есть; иначе фолбэк выше.
- Snapshot берётся из `resolveGlowValues` (§5.6), а не из собственной копии цепочки в диалоге;
сущность выбирается общим `selectSpatialGlowSource` — внешние `controls` в выбор не попадают.
- **Прямой переход «Из источника» → «Задать цвет и яркость»** (радиокнопки позволяют выбрать третий
режим сразу) при отсутствии draft снимает snapshot **и цвета, и яркости** по тем же правилам.
Последовательный и прямой пути обязаны давать одинаковый draft.
- Последние ручные значения хранятся как draft диалога и переживают любые переключения режимов
до закрытия. **Cancel не пишет ничего.**
- **Dormant override.** Если блок задизейблен (роль «Никогда», «Авто (не является)» или «Всегда»
без собственной сущности), уже существующий или только что заданный `glow_color` при Save
**сохраняется**, а не стирается: он снова начнёт действовать, когда роль или привязка вернут
маркеру световую роль. Очистка — только явным выбором «Из источника».
### 4.8 Доступность
Оба набора радиокнопок — `fieldset + legend` либо эквивалентный `role="radiogroup"` с accessible
name. Disabled-состояние — настоящие `disabled`-атрибуты плюс видимая подсказка и
`aria-describedby`, а не только `title`. Шкала яркости: шаг 1%, управление стрелками, видимое
числовое значение, локализованное доступное имя. Динамическая расшифровка у «Авто» не перехватывает
фокус и не объявляется скринридером на каждом тике состояния HA — только при изменении факторов
классификации. Мобильный диалог не должен получать горизонтальный скролл или обрезанный футер.
---
## 5. Нормативный рендер
### 5.1 Ручные значения и формула
Ручной цвет заменяет всю цепочку резолва цвета; ручная яркость заменяет `brightness` устройства
в слоте `bri`. Потолок `A_max` всегда общий. В режиме «Задать цвет» пятно продолжает следовать
живой яркости источника.
### 5.2 Контрольная таблица (дефолтный токен 0.85 → `A_max` = 0.595)
| Яркость | сейчас | по формуле | светлее пола |
|---|---|---|---|
| HA raw `brightness = 1` (0.392%) | 0.089 | **0.267** | 1.42× |
| **1% на ручной шкале** | — | **0.282** | 1.44× |
| 10% | 0.090 | 0.363 | 1.57× |
| 20% | 0.119 | **0.410** | 1.64× |
| 50% | 0.299 | 0.499 | 1.78× |
| 80% | 0.476 | 0.561 | 1.87× |
| 100% | 0.595 | **0.595** | 1.93× (не меняется) |
Теоретический `bri = 0` дал бы 0.238; на ручной шкале он недостижим, на живой — только у
выключенной лампы, у которой пятна нет вовсе. «Светлее пола» — отношение яркости центра пятна к
затемнённому полу (paper → glow_base 0.78 → L ≈ 81) для тёплого `#ffc882`. Числа приёмочные:
альфа воспроизводится с точностью до 0.001.
### 5.3 Свет через проёмы
Пролив использует **тот же** разрешённый цвет и ту же альфу, что и пятно-источник.
### 5.4 Перекрытия пятен — ожидаемое изменение
Здесь работают **два независимых эффекта**, и их нельзя путать (замечание R2-E1):
1. **Перенос групповой `opacity` в альфу пула** сам по себе пиксельно нейтрален для
непересекающегося пятна **при той же рассчитанной альфе**; меняются только зоны перекрытия —
раньше группа гасилась после композиции, теперь блендятся уже приглушённые пятна.
2. **Новая перцептивная кривая** отдельно и намеренно меняет **все** диммированные пятна, включая
одиночные: при 20% альфа идёт с 0.119 на 0.410 (§5.2).
Поэтому пиксельная эквивалентность обещана только одиночному пятну при 100% яркости и неизменных
настройках. Принято владельцем 2026-08-10, golden переснимается.
### 5.5 Ownership при нескольких сущностях и маркерах
1. `glow_color` применяется к маркеру, который **владеет конкретным пулом**
(`source.device.marker`), а не к контроллеру, лишь перечислившему сущность в `controls`.
2. Если одно устройство даёт несколько включённых `light.*`, пул остаётся один; авто-цвет и
авто-яркость берутся у первого включённого кандидата в существующем стабильном порядке,
ручной override маркера перекрывает этот выбор.
3. При смене активной сущности внутри такого устройства авто-режим меняется живьём, ручной
остаётся стабильным.
4. Дедупликация room stats и передача ownership физическому маркеру этим скоупом не меняются.
### 5.6 Единый pure-резолвер
Рядом с `glowColorOf` объявляются две чистые функции, и **только** они содержат логику:
```ts
resolveGlowValues(state, markerGlowColor, fallbackColor): { c: string; bri: number }
resolveGlowAppearance(state, markerGlowColor, fallbackColor): { c: string; bri: number } | null
glowAlpha(brightnessFraction: number, paletteAlpha: number): number
```
Три уровня, разведённые намеренно (замечание R2-B3):
- `resolveGlowValues` **игнорирует on/off** и всегда возвращает значения: ручной или автоматический
цвет (RGB → кельвины → фолбэк, нормализованный в `#RRGGBB`) и ручную или живую яркость. Это то,
что берёт диалог для snapshot при первом переходе в ручной режим (§4.7) — иначе ему пришлось бы
копировать цепочку, ровно то расхождение, которое ТЗ запрещает.
- `resolveGlowAppearance` возвращает `null`, если источник неактивен, иначе делегирует первому.
Это то, что берёт рендер.
- `glowAlpha` содержит `GLOW_SCALE_MAX`, `GLOW_MIN_FRAC`, `GLOW_GAMMA`, clamp и больше ничего.
**Общий выбор сущности — `selectSpatialGlowSource(sources): ResolvedLightSource | null`.**
Отдельный pure-helper уровня источников, потому что порядок кандидатов в `lightEntitiesOf` —
`controls → forced → auto` (`devices.ts:271-283`), и «первая включённая» без фильтра вполне может
оказаться **внешним контролом**: рендер игнорирует его через `castsGlow = false`, а буквальный
snapshot взял бы его цвет и яркость — UI и план разошлись бы, и чужой выключатель окрасил бы
собственное пятно маркера.
Нормативный алгоритм:
1. оставить только **spatial**-кандидаты, то есть `castsGlow === true` (внешние `controls`
отбрасываются целиком);
2. взять первый включённый в стабильном порядке;
3. если включённых нет — взять первого spatial-кандидата (для превью и snapshot);
4. если spatial-кандидатов нет — вернуть `null`.
Рендер, превью диалога и инициализация draft обязаны пользоваться **этим** helper-ом. Внешние
`controls` участвуют в статистике комнаты, но никогда не являются источником цвета, яркости,
радиуса или snapshot.
SVG-слой получает готовые `{c, alpha}` — так формула не разойдётся между рендером и тестами.
### 5.7 Статическая карточка
Паритет **роли** обязателен: `resolvedLightSources` влияет на заливку и состояние комнаты.
`glow_color` для `houseplan-space-card` сейчас **N/A** — она не рисует пулы, и вторую
неиспользуемую реализацию заводить нельзя. Если она когда-нибудь начнёт рисовать пулы, обязана
подключить тот же `resolveGlowAppearance`.
---
## 6. Совместимость
**Данных для миграции нет, но есть изменение семантики чтения.** Сегодня проверяется только
`is_light === true` (`devices.ts:274`), поэтому сохранённое `is_light: false` фактически означает
«Авто». После этой фичи оно начнёт означать «Никогда».
**Решение владельца от 2026-08-10 (вариант 1 ревью):** активировать естественную
семантику осознанно. Исторический writer писал только `true` или `null`, поэтому затронуты почти
исключительно ручные и YAML-конфиги. Записать в реестр совместимости #33 и отдельной строкой в
CHANGELOG; тест начинается с legacy-конфига `{is_light: false}`.
**Downgrade / stale tab.** Старый фронтенд неизвестное `glow_color` отрисует без падения, но при
открытии и сохранении этого маркера может удалить поле, потому что старый writer реконструирует
объект. Новой версией задним числом это не чинится — риск описывается честно, а не прикрывается
формулировкой «не ломает рендер».
Визуальные изменения по умолчанию: сцены с диммированным светом и зоны перекрытия пятен.
При полной яркости и одиночном пятне — пиксельная эквивалентность (не побайтная: SVG-разметка
заведомо меняется из-за удаления `opacity="0.7"`).
## 7. Критерии приёмки
1. Роль по умолчанию «Авто»; `light.*`-маркер в «Авто» светит ровно как сегодня.
2. «Никогда» удаляет **собственный кандидат** маркера. У маркера без `controls` он исчезает из
свечения, состояния комнаты и счётчика; у маркера с `controls` внешние цели продолжают
голосовать — по матрице §3.1. План обновляется без перезагрузки.
3. «Всегда» на не-световом маркере с подходящей собственной сущностью ведёт себя как сегодняшний
`is_light: true`; без такой сущности блок цвета и радиуса disabled с подсказкой (§4.4).
4. Эвристика «телевизор со служебной `light.*`» в «Авто» не изменилась и переопределяется.
5. Расшифровка у «Авто» совпадает с фактическим поведением плана для: лампы, умного выключателя,
телевизора со служебной сущностью, маркера с `controls`, выключенной, недоступной, скрытой и
HA-disabled сущности.
6. «Из источника» воспроизводит сегодняшний резолв; фолбэки цвета и яркости независимы.
7. «Задать цвет» применяет цвет ко всему свету источника, яркость продолжает следовать источнику.
8. «Задать цвет и яркость» фиксирует оба параметра; пятно исчезает при выключении источника;
минимум шкалы 1%.
9. Любой невалидный `glow_color` целиком игнорируется, маркер работает в «Авто»; невалидный цвет
никогда не доходит до SVG.
10. Альфа при `bri` = 1/255, 0.01, 0.1, 0.2, 0.5, 0.8, 1.0 совпадает с §5.2 (±0.001).
11. При 100% яркости и дефолтных настройках одиночное пятно **пиксельно эквивалентно** прежнему.
12. На каноническом валидном фикстуре Open → Save без касания новых контролов **не добавляет**
отсутствующие `is_light` и `glow_color` и сохраняет уже существующие валидные значения;
прочая канонизация marker writer-а в этот критерий не входит.
13. `houseplan-space-card` не расходится в резолве **роли**; `glow_color` для неё N/A.
14. Save при задизейбленном блоке не стирает `glow_color`; после возврата роли/привязки override
снова действует.
15. `{c, bri: null}` показывается как «Задать цвет», а следующий Save канонизирует объект в `{c}`.
16. Цвет, яркость, радиус и snapshot никогда не берутся у внешнего `controls`: при
«Всегда + включённый control + выключенный собственный источник» snapshot берётся у собственного.
17. Прямой переход «Из источника» → «Задать цвет и яркость» даёт тот же draft, что и через режим 2.
## 8. Тесты
**Юниты.**
- `lightEntitiesOf`/`resolvedLightSources`: три состояния роли × наличие `controls` — все шесть
строк матрицы §3.1 с точными ожидаемыми результатами.
- Legacy-конфиг `{is_light: false}` читается как «Никогда».
- «Всегда» на virtual/`sensor.*`-привязке без controllable-сущности: источник не создаётся,
`hasOwnSpatialSource` = false.
- Подпись «Авто» для off / unavailable / hidden / HA-disabled.
- Ownership: контроллер + физическая лампа с разными `glow_color`; устройство с двумя включёнными
`light.*` и сменой активной сущности.
- `resolveGlowAppearance`: три режима × (RGB, кельвины, бесцветный, с яркостью и без);
невалидные `c`; `{c, bri: null}`; `bri` без `c`; `NaN`; `Infinity`; числовая строка; массив;
посторонние вложенные ключи — во всех случаях полный откат в Авто.
- `glowAlpha`: таблица §5.2 включая **обе** нижние точки (1/255 и 0.01); монотонность;
`glowAlpha(1, a) === a × GLOW_SCALE_MAX`; отсутствие двойного пола.
- Нормализация живой яркости: numeric `0`, отрицательное, `> 255`, `NaN`, `Infinity`, отсутствие
атрибута, `null`, empty/whitespace, boolean и конечная числовая строка — включая явную фиксацию
изменения поведения при `brightness = 0`; `null`/empty/boolean дают 1, `'128'` остаётся числом.
- `selectSpatialGlowSource`: «Всегда + включённый внешний control + выключенный собственный forced»
→ выбран собственный; устройство без spatial-кандидатов → `null`; порядок при нескольких
включённых стабилен.
- Прямой переход из режима 1 в режим 3 при RGB-, kelvin- и бесцветном источнике даёт тот же draft,
что и последовательный.
**Смоки.**
- Авто → Никогда → Авто: пятно исчезает и возвращается; для маркера с `controls` счётчик комнаты
ведёт себя по матрице.
- Ручной цвет: стопы градиента маркера несут выбранный цвет, соседняя авто-лампа — свой; свет за
проёмом того же цвета, что и в комнате источника (это одно и то же поле).
- Первое переключение в ручной режим подставляет snapshot (§4.7) и не дёргает план.
- Dormant override: ручной цвет → роль «Никогда» → Save → reopen: значение на месте и снова
применяется после возврата роли.
- Draft-память: переключение режимов туда-обратно в открытом диалоге сохраняет значения; после
Save в режиме «Из источника» конфиг чист от `glow_color`; Cancel не пишет ничего.
- Отсутствие группового `opacity` на `.glow-pools-frame` при сохранённом `isolation: isolate`.
- Disabled-состояния всех трёх случаев §4.4 с подсказками.
**Бэкенд.** `is_light: false` и `glow_color` round-trip; отвергаются `NaN`, `Infinity`, `bri: 0`,
`bri` вне 0.01…1, невалидный `c`, лишние ключи.
**Golden.** Ожидаемо меняются от новой формулы и удаления групповой opacity:
`lighting-glow-sun-dark`, `lighting-temp-glow-dark`, `lighting-temp-glow-light`,
`lighting-custom-glow-dark`, `lighting-custom-glow-light`, `lighting-temp-glow-room-override-dark`,
`hover-over-glow-dark`. Отдельно, от нового блока диалога: **`device-dialog-desktop-en`** и
**`device-dialog-mobile-ru`**. Сцена `lighting-temp-glow-no-sources-dark` (весь свет выключен)
меняться **не должна** — дешёвая проверка, что тронули ровно то, что собирались. Новая сцена:
авто-лампа + ручная лампа + видимый пролив через проём + перекрытие пятен — одним бейзлайном
закрывает #19, #55 и #66. Все бейзлайны утверждает владелец до мержа.
**`smoke_glow_blending.mjs` переписывается, а не ослабляется.** Сейчас он буквально строит
синтетический растр `<g opacity="0.7">…</g>` (`:61`) и сравнивает модель «сначала screen, потом
групповая opacity». После изменения он обязан проверять per-pool alpha и новые ожидаемые пиксели
перекрытия.
**Перф — по уже принятой политике #69.** Формула добавляет O(1) арифметику на источник, а удаление
группового `opacity` скорее снижает стоимость композитинга. На каждой бета-итерации достаточно
обычного быстрого `Validate` (frontend, backend, smoke, golden плюс candidate-only Glow-смок на
60 источниках); один ручной **Full Performance** — на финальном бета-кандидате; перед стабильным
релизом полный exact-SHA прогон обязателен и так. Бюджеты не поднимать.
## 9. Документация
`docs/USER-GUIDE.ru.md` — источники света: три состояния роли, ручной цвет и яркость, смысл 1%.
`docs/ARCHITECTURE.md` — цепочка множителей альфы в одном месте и удаление группового `opacity`.
`docs/CONFIG-COMPATIBILITY.md` — `glow_color`, трёхзначность `is_light` и **изменение семантики
чтения legacy `false`** в реестр #33. CHANGELOG en/ru — роль источника, свой цвет и яркость,
видимое свечение диммированных ламп, отдельной строкой изменение смысла `is_light: false`.
## 10. Журнал решений владельца
| Дата | Решение |
|---|---|
| 2026-08-10 | Роль источника: показывать включённым и дать выключать; контрол — Авто/Всегда/Никогда |
| 2026-08-10 | У «Авто» — расшифровка в скобках: является / не является источником |
| 2026-08-10 | Цвет и яркость разделены на три режима; порядок блока: роль → цвет и яркость → радиус |
| 2026-08-10 | Ручная шкала яркости начинается с 1%, нуля нет |
| 2026-08-10 | Интенсивность: `0.7` убрать как скрытый множитель, ввести перцептивную кривую, потолок при 100% — фактические 0.7 |
| 2026-08-10 | Пересъёмка golden с перекрытиями принята как цена изменения |
| 2026-08-10 | **B3 подтверждён: старое `is_light: false` начинает означать «Никогда» (read-semantics change)** |
Процесс: тексты #65, #66 и #67 приведены в соответствие с этой ревизией; из #65 удалена фраза
`Owner decision pending`. Ревизия положена в репозиторий по адресу
`docs/specs/067-light-source-controls.md`, все три issue на неё ссылаются. #65, #66 и #67 добавлены
в GitHub Project `Matysh/1` (`House Plan Backlog`), каждому выставлены `Status = Todo` и
`Priority = P2`; live `projectItems` перепроверены 2026-08-10.
## 11. Риски
- **Перекрытия ярче ожидаемого.** В комнате с 4–5 лампами суммарная альфа при screen-блендинге
может уйти в пересвет. Смотреть на новых бейзлайнах; регулировать `GLOW_MIN_FRAC`, а не
возвращать групповую opacity.
- **`GLOW_MIN_FRAC = 0.4` сжимает шкалу.** Разница между 10% и 100% — 0.363 против 0.595:
диммирование читается слабее, чем сейчас. Осознанный размен; если покажется плоско, снижать
до 0.3.
- **Расшифровка «Авто» — второй потребитель резолвера.** Реализация «своей» логикой в диалоге
воспроизведёт ровно тот дефект, который #65 закрывает. Тест на совпадение подписи с фактическим
поведением обязателен.
- **Legacy `is_light: false` в чужих YAML.** Пользователь, который когда-то выставил `false`
вручную, ожидая «Авто», после релиза потеряет свечение маркера. Смягчение — строка в CHANGELOG
и реестр #33; массово затронутых конфигов не ожидается.