# ТЗ #19 — Аддитивное смешивание Glow-источников
- Issue: https://github.com/Matysh/houseplan-card/issues/19
- Приоритет: P2
- Статус ТЗ: реализовано в кандидате v1.61.0-beta.2; exact-SHA verification/golden approval — release gate
- Связано: независимый Glow overlay #55 повторно использует эту композицию
## Цель
Пересекающиеся radial pools смешиваются как свет, а не перекрывают друг друга
по DOM order:
- тёплый и холодный источник дают смешанный цвет в пересечении;
- два одинаковых dim-источника дают более светлое пересечение;
- room/data fill, Glow base, opening tunnel fills, backdrop и sun не участвуют в
аддитивной группе.
Модель источников, `resolvedLightSources(room)`, радиусы, clipping светом стен и
сохранённый config не меняются.
## Scope guards
- blend применяется только к radial pools;
- никакого UA sniffing и программного pixel-by-pixel polyfill;
- unsupported или сомнительный engine получает текущий normal layering;
- feature не имеет пользовательского/скрытого toggle: при провале correctness
или performance gate issue возвращается в research;
- #55 не создаёт вторую blend group и не смешивает Glow base с pools.
## Render architecture
Все pools одного lighting layer переносятся в одну плоскую изолированную
группу. Нормативная структура:
```html
…
```
При fallback группа получает `blend-normal` и `data-blend="normal"`. Одно
capability-решение действует сразу на все pools документа: смешанных
screen/normal состояний между источниками не бывает. `data-blend` находится на
группе только для диагностики и тестов, не является persisted настройкой.
```css
.glow-pools { isolation: isolate; }
.glow-spot { isolation: isolate; }
.glow-pools.blend-screen > .glow-spot { mix-blend-mode: screen; }
.glow-pools.blend-normal > .glow-spot { mix-blend-mode: normal; }
```
После #71 screen-композиция принадлежит `.glow-spot`, а единственная форма
источника — один circle, обрезанный полигоном видимости, пересечённым с
полом. Per-source luminance mask и отдельный shadow layer удалены: тень — это
пол, не попавший в visibility polygon.
`glow_base`, resolved room/data fill, opening tunnel fills и sun rays
остаются sibling layers вне `glow-pools-frame`. Их порядок относительно друг
друга сохраняется по #55.
Каждый circle сохраняет собственный visibility `clip-path` после re-parenting. Clip ids
остаются уникальными и стабильными в пределах SVG; объединять per-source clips
в один общий clip запрещено, иначе свет начнёт проходить сквозь чужие стены.
## Нормативная alpha/blend семантика
Альфа отдельного pool задаётся только `stop-opacity` его radial gradient:
```text
Acenter = clamp(fill_colors.glow_light.a * 0.7 * (0.4 + 0.6 * sourceBrightness^(1/2.2)), 0, 1)
A(r = 0%) = Acenter
A(r = 45%) = 0.88 * Acenter
A(r = 70%) = 0.62 * Acenter
A(r = 86%) = 0.32 * Acenter
A(r = 100%) = 0
screen(S, D) = 1 - (1 - S) * (1 - D) // отдельно для R, G, B в 0..1
```
На `.glow-pool` запрещены `opacity`, `fill-opacity`, filter и дополнительная
alpha: полупрозрачность должна участвовать в blend через gradient stops, а не
создавать отдельную element-композицию. Коэффициент `0.7` уже входит в
`Acenter`; внешний `glow-pools-frame` не имеет постоянного opacity и не применяет его
повторно.
`.glow-spot` может временно анимировать opacity 0→1/1→0 в течение 500 мс.
Это только transition появления/исчезновения; в steady-state opacity равна 1 и не
меняет alpha-формулу.
Glow base использует собственный alpha-контракт #55 и не входит в эту формулу.
Blend не меняет base/tunnel opacity и не осветляет paper/backdrop.
## Runtime capability probe
`CSS.supports('mix-blend-mode','screen')` разрешён только как быстрый отрицательный
pre-check. Положительный ответ не считается доказательством поддержки SVG.
Фактическая capability определяется одноразовым render probe:
1. Создать детерминированный мини-SVG с той же парой
`isolation:isolate` + `mix-blend-mode:screen`, двумя частично
перекрывающимися фигурами известных непрозрачных RGB-цветов и контрольным
фоном.
2. Без внешних ресурсов сериализовать SVG, растеризовать его в canvas с
фиксированными физическими размерами и прочитать source/overlap/background
pixels через `getImageData`.
3. Для overlap вычислить ожидаемый RGB по формуле `screen`; каждый канал должен
совпасть с допуском не более 2/255. Source pixels должны совпасть со своими
цветами, background — остаться неизменным. Проверка только overlap
недостаточна: артефакт isolation не должен дать false positive.
4. Любая ошибка, timeout, transparent/zero sample, security exception или
несовпадение переводит capability в `false`.
Результат кешируется как `Promise` в `WeakMap` и
исполняется не более одного раза per document. Он не сохраняется в config,
localStorage или по UA: обновившийся WebView должен пройти пробу заново.
Пока Promise pending, карточка рисует normal fallback, не пустой слой. После
успешного результата все смонтированные карточки получают один update и
переходят на screen; при `false` повторного update нет. Print/screenshot path и
`houseplan-space-card`, если она в будущем начнёт показывать live pools,
используют то же capability-решение. `prefers-reduced-motion` не влияет на
статичное смешивание.
Для тестов capability dependency можно инъецировать как `true|false`; это
неэкспортируемый test hook, не пользовательская настройка.
## Fallback contract
Fallback сохраняет текущую визуальную семантику:
- pools идут в normal DOM layering;
- gradient alpha (включая единственный коэффициент `0.7`), radius и per-source
clips не меняются;
- порядок остальных SVG layers идентичен screen-path;
- отсутствие поддержки не создаёт warning/toast: это штатная деградация.
Baseline fallback проверяется отдельно, а не выводится из golden screen-path.
## Детерминированная fixture
Добавляется общая fixture `test/fixtures/glow/additive-pools.json` со сценариями
1, 10, 30 и 60 перекрывающихся pools. Она содержит schema-valid Houseplan
config и отдельный детерминированный HA state snapshot:
- фиксированные geometry, colors, brightness, radii и marker order;
- минимум два помещения с физической стеной и разными per-source clip paths;
- warm/cool и identical-dim контрольные пары;
- отключены sun/daynight/transition и другие недетерминированные эффекты.
Один и тот же JSON загружается Node-тестом и Python backend schema test. Ни
frontend, ни performance runner не держат собственную расходящуюся копию.
## Performance gate до включения
Создаётся отдельный профиль `large-light-blend-v1`; существующий
`large-house-v1` не меняет смысл молча. Baseline и candidate строятся из точных
SHA и запускаются последовательно одним runner/process family на pinned
Chromium при:
- DPR = 1;
- CPU throttling ×4 как воспроизводимом proxy старого kiosk-железа;
- минимум одном warm-up и семи measured samples на вариантах 1/10/30/60 pools.
Отчёт содержит `stateUpdate` p50/p95, render count, Long Tasks, heap/cache
growth и screenshot capture time. Ship gate применяет одновременно
relative-to-base и отдельные absolute budgets этого профиля; более строгий
предел побеждает. Budget нельзя ослаблять только ради прохождения #19 без
письменного обоснования по артефактам нескольких запусков.
Неизмеримый критерий «не более 10% на старом reference WebView» удалён: такого
устройства нет в CI. Correctness сомнительных SVG-движков закрывает runtime
probe, а производительность старого железа — throttled профиль. Полевая beta на
реальных kiosk WebView желательна, но не заменяет автоматический gate.
При провале correctness или performance feature не ship-ится и возвращается в
research без скрытого fallback toggle.
## Разделение визуальных проверок
### Pure unit
- формула `screen` на известных RGB, clamp и округление;
- gradient alpha использует только `stop-opacity`;
- capability cache вызывается один раз per document;
- pending/false/true state machine и единое решение для всех pools.
### Browser pixel smoke
`demo/smoke_glow_blending.mjs` работает при фиксированном DPR=1 и сэмплирует
реальные pixels детерминированной SVG-сцены, а не сравнивает только DOM:
- warm+cool overlap соответствует screen-формуле с установленным допуском;
- две одинаковые dim-лампы дают overlap светлее одного pool без channel clip;
- background/base и opening tunnel fill сохраняют baseline pixels;
- перестановка markers не меняет overlap;
- принудительный test-only fallback совпадает с current normal baseline;
- per-room clips переживают re-parenting: pool не появляется за физической
стеной и остаётся видимым в разрешённой части своего clip.
Smoke использует canvas/PNG pixel sampling. Он не заменяется golden: обычный
screenshot diff может заметить изменение, но не доказывает математическую
формулу смеси.
### Golden
Golden-сцена фиксирует целостную композицию в light/dark theme, 1/2/много
источников, tunnel рядом с overlap и интеграцию с независимым overlay #55.
Candidate утверждается владельцем глазами и после принятия становится
визуальным контрактом; capture никогда не перезаписывает baseline сам.
## Edge cases
- 0 и 1 pool не создают лишнего визуального изменения;
- несколько карточек Houseplan в одном document используют один probe;
- разные комнаты, nested holes, doors/gates и physical/virtual walls;
- hidden/removed/disabled/unavailable источники не попадают в pools по
существующему resolver;
- dark/light theme, print/screenshot, kiosk и browser zoom;
- порядок markers, одинаковые цвета и полностью совпавшие центры;
- probe pending/fail/timeout и восстановление после reload документа.
## Документация и release artifacts
В том же feature commit обязательны:
- `docs/CHANGELOG.md` и `docs/CHANGELOG.ru.md`;
- пользовательская документация Glow с объяснением смешивания и штатного
fallback старых движков;
- обновление ARCHITECTURE/render-order, если структура слоя меняется;
- owner-reviewed golden и performance comparison artifacts.
Release body описывает видимую пользователю смесь света; probe, классы и кеши
относятся к small fixes/improvements, если не требуют отдельного предупреждения.
## Приёмка
1. Положительный screen-path включается только после успешного фактического
SVG render probe; `CSS.supports` сам по себе недостаточен.
2. Все pools документа используют одно screen/normal решение; смешанного режима
нет.
3. Альфа steady-state живёт в gradient stops; outer-frame opacity
отсутствует, а opacity `.glow-spot` используется только для 500 ms transition;
коэффициент `0.7` применяется ровно один раз внутри формулы stop-opacity.
4. Glow base, data fill, paper/backdrop, opening tunnel fills и sun остаются вне
isolated group и сохраняют baseline pixels.
5. Каждый pool сохраняет собственный clip-path после re-parenting.
6. Shared fixture 1/10/30/60 проходит frontend и backend schema tests.
7. Pixel smoke доказывает screen-формулу, isolation, order independence,
clipping и normal fallback; golden фиксирует целостную картинку отдельно.
8. `large-light-blend-v1` проходит relative и absolute budgets при CPU ×4.
9. #55 повторно использует ту же pool group и не создаёт второе смешивание.
10. Документация и ru/en changelog обновлены.
Фактическая структура после #71 зафиксирована выше; каноническая модель
транспорта света и её ограничения живут в `docs/LIGHT.md`.