# 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` переписывается, а не ослабляется.** Сейчас он буквально строит синтетический растр `…` (`: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; массово затронутых конфигов не ожидается.