mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
608 lines
54 KiB
Markdown
608 lines
54 KiB
Markdown
# 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; массово затронутых конфигов не ожидается.
|