# #476 — Явное завершение выбора цвета кнопкой «ОК» - **Issue:** https://github.com/Matysh/houseplan-card/issues/476 - **Тип / приоритет:** feature + polish / P2 - **Трек:** полный; появляется новый наблюдаемый UX-контракт завершения общей поверхности выбора цвета, включая desktop, touch и fallback без Popover API - **Оценка:** пользовательская ценность 7/10; ценность для разработки 6/10; сложность 3/10; риск 3/10 - **Связано:** #57, #180; `docs/SCOPE.md`, `docs/TOUCH-SUPPORT.md` ## 1. Сценарий Персона — Home admin из `docs/SCOPE.md`. Поверхность — любой редакторский или настроечный диалог House Plan, в котором используется стандартный выбор цвета. Момент — администратор уже настроил цвет и, если доступно, прозрачность, но не понимает, каким действием завершить работу с открытой поверхностью. Desktop с мышью и клавиатурой остаётся эталонной средой редакторов. Та же кнопка доступна на touch, поскольку `hp-color-opacity` уже является общей touch-capable поверхностью и явное завершение не требует нового жеста. ## 2. Что человек увидит до и после **До:** после настройки цвета в picker нет явного действия завершения, поэтому пользователь вынужден догадываться, что поверхность закроется нажатием снаружи, повторным нажатием на образец или `Escape`. **После:** внизу picker находится заметная полноширинная кнопка «ОК»; нажатие сохраняет уже показанный результат и закрывает поверхность, а прежние способы закрытия продолжают работать. ## 3. Проблема Единый `hp-color-opacity`, созданный в #57 и распространённый на все места выбора цвета в #180, применяет корректные изменения сразу через событие `hp-color-opacity-change`. Он умеет закрываться через trigger, outside pointer, `Escape`, смену transient overlay и lifecycle родительского диалога, но не показывает ни одного явного действия внутри самой поверхности. Технически выбор уже состоялся, но визуально интерфейс выглядит незавершённым. Это повторяется во всех потребителях общего компонента: decor, room/space, общих цветах, Glow и ripple. ## 4. Решения владельца 1. Сохраняется нынешнее live-применение. «ОК» не создаёт отдельную транзакцию и не откладывает изменение родительского draft. 2. Outside click/tap, повторное нажатие swatch и `Escape` сохраняются; каждый путь оставляет последнее валидное live-применённое значение. 3. При незавершённом невалидном HEX «ОК» не закрывает picker. Последнее валидное применённое значение сохраняется, а ошибка у HEX-поля остаётся видимой до исправления. 4. Кнопка — полноширинная primary-кнопка внизу picker. ## 5. Скоуп - одна кнопка подтверждения внутри общей поверхности `hp-color-opacity`; - одинаковый interaction contract в native Popover API и portal/fallback; - одинаковое поведение для `showOpacity=true` и color-only потребителей; - локализованная подпись кнопки во всех поддерживаемых языках; - keyboard focus, screen-reader name, forced-colors и touch hit target; - сохранение действующих live events и всех прежних путей закрытия; - unit, browser smoke и reviewed golden для светлой/тёмной тем, desktop/touch; - пользовательская запись в обоих changelog. ## 6. Не входит - кнопка «Отмена», Reset, история, presets, eyedropper или новый palette UI; - транзакционный черновик цвета внутри компонента либо откат live-изменений; - изменение состава HSV/HEX/opacity controls и их порядка; - изменение формата `#rrggbb`, opacity `[0, 1]` или `hp-color-opacity-change`; - изменение Save/Cancel родительских диалогов и серверной конфигурации; - отдельные правила для decor, room, space, general settings, Glow или ripple; - новый глобальный keyboard shortcut: `Enter` в HEX-поле сохраняет свой текущий смысл, а `Enter`/`Space` на самой кнопке работает нативно; - переработка floating placement, overlay stacking либо touch parity остальных редакторских операций. Если реализация потребует изменить момент применения значения, detail события, родительские drafts или закрытие других transient overlays, задача возвращается в `S3-spec` как расширение публичного контракта. ## 7. Контракт поведения ### 7.1 Live-применение Hue, saturation, brightness, валидный HEX и opacity продолжают немедленно обновлять локальный preview и отправлять существующее bubbling/composed событие `hp-color-opacity-change` с detail `{ color, opacity }`. Родительский consumer продолжает обновлять свой draft или сохранённый editor style так же, как до #476. Открытие picker и нажатие «ОК» без нового значения не отправляют событие. Нажатие «ОК» после уже live-применённого изменения также не отправляет дублирующее событие: кнопка завершает взаимодействие, а не повторно применяет состояние. ### 7.2 Кнопка «ОК» Кнопка является последним focusable control в DOM-порядке picker и находится после opacity-row либо после HEX-control у `showOpacity=false`. Она занимает всю доступную ширину внутренней области, имеет высоту не менее 40 CSS px и следует theme tokens House Plan для primary action. В `forced-colors` используются системные цвета/граница, а не невидимый theme-only fill. Один click/tap либо нативная активация `Enter`/`Space`: 1. валидирует текущий HEX draft по существующему правилу commit; 2. при валидном draft закрывает picker тем же единым lifecycle-путём, что остальные close reasons; 3. возвращает keyboard focus на swatch trigger; 4. не передаёт click/tap нижележащему plan, toolbar или родительскому dialog. Кнопка существует и работает одинаково во всех экземплярах общего компонента; отдельного opt-in property у consumers нет. ### 7.3 Невалидный HEX Если на момент нажатия «ОК» HEX draft не нормализуется как 3- или 6-значный цвет либо поле всё ещё несёт результат предыдущей неуспешной валидации: - picker остаётся открытым; - `aria-invalid=true` и существующее локализованное сообщение ошибки остаются видимыми; - последнее валидное значение color/opacity и родительский draft не меняются; - новое `hp-color-opacity-change` не отправляется; - focus остаётся/переводится в HEX input, чтобы значение можно было исправить. После ввода валидного HEX повторное «ОК» закрывает picker по §7.2. Точное существующее правило отображения невалидного draft (нормализация поля к последнему валидному значению при commit) не меняется этой задачей. При этом повторное «ОК» без нового валидного пользовательского ввода не имеет права снять состояние ошибки только потому, что commit уже вернул в поле последнее валидное значение: поверхность остаётся открытой до реального исправления. Компонент обязан отдельно запоминать, что после неуспешного commit не было нового `input`-события HEX-поля. Этот признак не сбрасывается нормализацией draft внутри commit helper, повторным нажатием «ОК», blur или переводом фокуса; он снимается только новым пользовательским `input` с валидным HEX. ### 7.4 Остальные способы закрытия Повторное нажатие trigger, pointer down вне surface, `Escape`, открытие другой exclusive transient surface, закрытие родительского диалога, mode change, disconnect и потеря валидного anchor продолжают закрывать picker по нынешним правилам. Они не откатывают уже live-применённое значение и не требуют предварительно нажать «ОК». `Escape` из focus внутри picker закрывает сначала picker и возвращает focus на trigger; второй `Escape` принадлежит родительскому dialog. Outside pointer и trigger-close сохраняют свой текущий focus contract. ## 8. UX, touch и доступность - `hp-color-opacity` сохраняет `role="dialog"`, доступное название и одну floating surface; кнопка — нативный `button type="button"` с видимым текстом. - Полноширинная цель имеет минимум 40 px высоты. На узком viewport она остаётся в обычном scroll flow поверхности, не перекрывает HSV/HEX/opacity controls и не увеличивает ширину за viewport. - Tap по кнопке закрывает только picker и не превращается в editor action. Pointer capture HSV-поля и `pointercancel` остаются прежними. - Light/dark theme, browser zoom, visual viewport, flip/shift placement и portal fallback используют существующий `FloatingSurfaceController`. - Новых анимаций нет; `prefers-reduced-motion` не меняется. Touch editor остаётся best effort по `docs/TOUCH-SUPPORT.md`, но доступная уже поверхность выбора цвета получает ту же явную кнопку без намеренной деградации. View, kiosk и device actions не затрагиваются. ## 9. i18n `ColorPickerLabels` получает обязательное поле `confirm`; его per-card значение передаётся всем существующим picker instances через уже общий `_colorPickerLabels`. Добавить `color_picker.confirm` в синхронные словари `src/i18n/{en,ru,de,fr}.json`: - RU: `ОК`; - EN: `OK`; - DE: `OK`; - FR: `OK`. Fallback внутри presentation-only компонента — `OK`. Отдельный aria-only ключ не нужен: видимый текст является доступным именем. ## 10. Модель данных, миграция и совместимость Server config, editor drafts, storage/model version и backend schema не меняются. Новых persisted полей, compatibility aliases и миграции нет. Публичный consumer API сохраняет свойства `color`, `opacity`, `disabled`, `showOpacity`, событие и его detail. Дополнение `ColorPickerLabels.confirm` является внутренним compile-time контрактом House Plan: все production consumers уже получают единый объект labels. Для defensive runtime fallback отсутствующее/пустое `confirm` отображается как `OK`, поэтому стороннее ручное создание custom element со старым объектом labels не ломается. Downgrade возвращает прежний picker без кнопки и продолжает читать тот же config. Данные не теряются и обратная миграция не нужна. ## 11. Затронутые файлы и модули - `src/hp-color-opacity.ts` — label, кнопка, validation/close path и стили; - `src/houseplan-card.ts` — per-card localized `ColorPickerLabels`; - `src/i18n/{en,ru,de,fr}.json` — `color_picker.confirm`; - `test/color-picker.test.mjs` и i18n/source contract tests; - `demo/smoke_color_picker.mjs` — click/tap, invalid HEX, focus и события; - при необходимости существующий fallback smoke, без создания второго component implementation; - `demo/golden/matrix.mjs`/`harness.mjs` только если существующим color-picker сценам нужна подготовка для видимой кнопки; принимаемые PNG и golden index; - `docs/TESTING.md`, `docs/CHANGELOG.md`, `docs/CHANGELOG.ru.md`; - generated `dist/**` и `custom_components/houseplan/frontend/**` по manifest-driven bundle contract. User Guide и config compatibility docs не меняются: новая кнопка самообъясняющаяся, а конфигурационный контракт отсутствует. Если в реализации понадобится объяснять кнопку отдельно в руководстве, это считается сигналом, что affordance не решил исходную проблему. ## 12. Критерии приёмки - **AC1 — видимая кнопка и геометрия (unit + golden).** Каждый открытый `hp-color-opacity`, включая `showOpacity=false`, показывает последним control полноширинную primary-кнопку с высотой не менее 40 CSS px; она помещается в light/dark desktop/touch кадрах без clipping и перекрытия controls. - **AC2 — live parity (unit + smoke).** Hue/SV/HEX/opacity по-прежнему немедленно отправляют ровно существующий `{ color, opacity }` и обновляют parent draft; открытие и «ОК» не добавляют event, если значение уже применено. - **AC3 — успешное завершение (smoke).** Реальный click/tap и keyboard activation по «ОК» закрывают native popover и fallback, ставят `aria-expanded=false`, возвращают focus на trigger и не вызывают действие plan/dialog под surface. - **AC4 — невалидный HEX (unit + smoke).** При невалидном draft «ОК» оставляет picker открытым, error/`aria-invalid` видимыми, focus в HEX input, последнее валидное parent value неизменным и не отправляет событие; после исправления та же кнопка закрывает picker. Повторное «ОК» без нового валидного input также не закрывает поверхность. - **AC5 — прежние close paths (smoke).** Outside pointer, trigger и `Escape` продолжают закрывать surface и сохраняют последнее валидное live-значение; `Escape` не закрывает родительский dialog тем же нажатием. - **AC6 — i18n и a11y (unit + smoke).** EN/RU/DE/FR имеют parity, production instances получают per-card `confirm`, fallback даёт `OK`, видимый текст является accessible name, Tab достигает кнопки после последнего field. - **AC7 — touch/fallback/lifecycle (smoke + golden).** На narrow touch кнопка имеет достаточную цель, tap потребляется surface; Popover API и forced fallback дают один и тот же результат, disconnect не оставляет portal и pending emit. - **AC8 — совместимость, release и бюджеты (unit + docs gate + commands).** Нет новых config/storage/backend полей и изменений event API; оба changelog содержат пользовательскую запись, целевые docs/tests актуальны, bundle проходит действующие initial/editor gzip budgets. ## 13. План автотестов 1. Расширить `test/color-picker.test.mjs`: обязательный `confirm` label, defensive `OK`, native `type="button"`, последний DOM-control, полноширинный/40 px CSS contract и неизменный event detail. 2. Расширить `demo/smoke_color_picker.mjs` реальными действиями: - live изменить hue и opacity, запомнить число событий, нажать «ОК» и доказать close/focus без дополнительного события; - открыть снова, ввести невалидный HEX, нажать «ОК», проверить open/error/ focus/no-event, повторным «ОК» доказать отсутствие обхода, исправить и закрыть; - повторить color-only consumer; - доказать сохранение outside/trigger/`Escape`. 3. Forced-fallback path проверить существующим smoke affordance либо узким расширением color-picker smoke: portal удалён, нижележащий click не вызван. 4. Обновить существующие golden-сцены, уже покрывающие общую surface: `decor-color-popover-mobile-ru`, `decor-color-popover-desktop-en`, `general-color-popover-desktop-en`, `device-ripple-color-popover-mobile-ru`, `space-room-color-popover-desktop-ru`. Вместе они доказывают RU/EN, light/dark, desktop/touch, opacity/color-only. Не создавать дублирующую сцену только ради той же кнопки. 5. Защитные мутанты: удалить click handler, повторно emit на confirm, закрыть surface при invalid HEX, убрать stopPropagation, не передать новый label в один из языков и сделать кнопку auto-width — соответствующие AC обязаны покраснеть. ## 14. Release-артефакты - Пользовательские записи со ссылкой на #476 в `docs/CHANGELOG.md` и `docs/CHANGELOG.ru.md` в product-коммите. - `docs/TESTING.md` актуализирует общий color-picker contract. - Пять существующих color-picker golden обновляются из канонического Linux CI, явно просматриваются и принимаются с `Baseline-Reviewed` по процессу. - Документационные screenshots переснимаются/принимаются только если их visual fingerprint объявлен устаревшим обычным gate; отдельного нового пользовательского screenshot не требуется. - Performance/security artifacts не добавляются: кнопка не входит в render loop, не читает сеть и не меняет данные. Действующие bundle budget и prerelease performance gates остаются обязательными. ## 15. Производительность и безопасность Один статический button и один click handler добавляются только в уже открытый lazy editor picker. Закрытая карточка, View, camera render, geometry и backend не получают новой работы. Runtime dependency и сетевой запрос не добавляются. Кнопка не выполняет HA action, не пишет config и не обходит parent Save/Cancel. Потребление click/tap защищает от случайного действия под закрывающейся поверхностью. Существующие CSP, permissions и privacy contract не меняются. ## 16. Риски - **Случайно превратить live picker в transaction.** Снимается AC2 и прямой проверкой parent draft до «ОК». - **Повторно отправить последнее значение.** Снимается счётчиком событий до и после confirm в AC2. - **Закрыть surface с невалидным HEX.** Снимается единым validation result и AC4, без проверки только CSS-класса. - **Разойтись между Popover API и fallback.** Снимается одним `_pickerTemplate` и реальными smoke обоих путей. - **Провалить tap в план после удаления portal.** Снимается потреблением события и sentinel-action в AC3/AC7. - **Обрезать нижнюю кнопку на touch/zoom.** Снимается существующим viewport placement, scroll flow и reviewed narrow golden. - **Оставить один язык без кнопки.** Снимается type/i18n parity и AC6. ## 17. Откат Feature flag не нужен: изменение локально для общей surface и не меняет данные. Для отката удаляются button/handler, поле `confirm` и новый i18n key, а прежние close paths и live event contract остаются рабочими. Родительские consumers, config и backend откатывать не требуется. Если после выпуска понадобится другая семантика подтверждения — например, транзакционный draft с Cancel, — это отдельная продуктовая задача и миграция interaction contract, а не скрытая правка обработчика #476. ## 18. Принятые предположения Следующие технические решения предположительны и могут быть свободно изменены ревьюером без нового продуктового решения владельца: - рабочее имя нового поля — `ColorPickerLabels.confirm`, ключ — `color_picker.confirm`; - confirm может переиспользовать существующий HEX commit helper, но решение о закрытии не выводится только из нормализованного `_hexDraft`: компонент хранит отдельный признак неуспешного commit без последующего валидного пользовательского `input`. Невалидный commit устанавливает признак, а снять его может только новое `input`-событие HEX-поля с валидным значением; blur, нормализация draft и повторный confirm его не снимают. `_closePicker(true, ...)` вызывается только когда текущий draft валиден и этот признак снят; - отдельный custom event `confirm` не нужен: родители уже получили live value; - кнопка стилизуется внутри shadow DOM `hp-color-opacity` theme tokens без зависимости от HA-private web components; - тест fallback может быть частью существующего smoke, если это сохраняет реальное browser proof и не дублирует весь сценарий.