diff --git a/docs/specs/051-custom-decor-images.md b/docs/specs/051-custom-decor-images.md index 82e75420..17f5f6cf 100644 --- a/docs/specs/051-custom-decor-images.md +++ b/docs/specs/051-custom-decor-images.md @@ -1,76 +1,649 @@ # ТЗ #51 — Пользовательские изображения в декоративном слое - Issue: https://github.com/Matysh/houseplan-card/issues/51 -- Приоритет: P2 -- Статус ТЗ: draft, security dependencies обязательны -- Зависимости: color/CSS audit #21; large raster pipeline #39 +- Приоритет и тип: P2, `feature` + `security` +- Маршрут: полный; задача затрагивает новый UX-контракт, persisted config, + backend content API, untrusted SVG, импорт/экспорт и обе View-поверхности, + поэтому не проходит критерии лёгкого трека «одна поверхность», «нет нового + UX-контракта» и «нет compatibility-полей» +- Реализованные зависимости: #21 (CSS/content security), #39 (large-raster + diagnostics/downscale), #50 (portable export/import), #383 (мебельные + transforms) +- Закрытый дубль: #46 + +## Сценарий + +Домашний администратор на поверхности **Редактор подложки** хочет добавить на +план собственный ковёр, растение, логотип или другой пассивный визуальный +элемент, которого нет во встроенной библиотеке мебели. Он нажимает одну кнопку +**«Изображение»**, выбирает уже загруженный файл либо загружает PNG, JPEG, WebP +или SVG, видит будущий размер и положение, размещает картинку и затем двигает, +масштабирует, отражает, вращает и переставляет её по слоям как мебель. + +Житель и гость встречают результат в обычном View или +`houseplan-space-card`: картинка является только частью плана, не реагирует на +нажатия и не участвует в состояниях Home Assistant. + +## Что человек увидит до и после + +**До:** пользователь ограничен встроенными фигурами и библиотекой мебели; +собственную картинку можно сделать только подложкой всего пространства или +подготовить план во внешнем редакторе. + +**После:** одна кнопка в Редакторе подложки позволяет безопасно загрузить либо +повторно выбрать своё изображение, поставить его на план и редактировать теми же +привычными жестами, что мебель; если локального файла больше нет, View не рисует +сломанный значок, а редактор сохраняет место объекта и предлагает замену. + +## Проблема + +`space.decor[]` сейчас принимает только `line`, `rect`, `ellipse`, `text` и +`furniture`. Имеющийся `DecorImageTransform` — только неиспользуемая заготовка: +у неё нет persisted kind, ссылки на content asset, backend validation, рендера, +palette или lifecycle. + +Прямое сохранение пользовательского URL либо data URL неприемлемо: + +- внешний URL раскрывает адрес Home Assistant третьей стороне и может исчезнуть; +- data URL раздувает 2 МиБ config, дублирует один файл в каждом объекте и + усложняет export; +- SVG является активным документом при прямом открытии same-origin URL и требует + одновременно строгой проверки содержимого и sandbox-заголовков; +- файл может использоваться несколькими объектами и пространствами, поэтому + удаление по факту исчезновения одной ссылки разрушительно. ## Цель -Размещать PNG/JPEG/WebP/SVG как decor element с теми же move/resize/rotate, -opacity, layer order и per-space lifecycle, что у остальных объектов. +Добавить reusable decor assets и `kind: image`, сохранив четыре инварианта: -## Модель +1. файл загружается, читается и удаляется только через аутентифицированный + House Plan content lifecycle с fail-closed проверкой; +2. конфигурация хранит один стабильный идентификатор, а не bytes, signed URL или + исходный путь; +3. размещение и transforms повторяют мебель, кроме специального притягивания + задней гранью к стене; +4. ни удаление/замена объекта, ни удаление пространства, ни импорт никогда не + удаляют asset по предположению. + +## Скоуп + +В скоупе: + +- статические PNG, JPEG/JPG, WebP и безопасный поднабор SVG; +- одна новая кнопка **«Изображение»** на основной панели Редактора подложки; +- palette загруженных изображений с загрузкой, повторным выбором и явным + удалением неиспользуемого asset; +- one-shot preview/placement и transforms по контракту мебели #383; +- свойства изображения: файл, прозрачность, физический размер, угол, + горизонтальное/вертикальное отражение и действующие действия слоя; +- отображение в полном View и `houseplan-space-card`, соблюдение + `hide_decor` и существующего layer order; +- missing-asset placeholder и замена файла в Редакторе подложки; +- новый content namespace, upload/list/resolve/delete API, квоты, подписание, + reference counting и конкурентно безопасное удаление; +- backend raster validation и SVG validation/canonicalization; +- full, space и plan-only export/import без вложения файлов; +- rolling-version guard, i18n RU/EN/DE/FR, документация, unit/backend/smoke, + golden и performance coverage. + +## Не-скоуп + +- GIF, AVIF, TIFF, BMP, PDF, анимированный raster или SVG animation; +- загрузка по URL, drag-and-drop со страницы, clipboard paste, камера или + встроенный поиск изображений; +- векторный/SVG-редактор, перекраска содержимого, crop, маска пользователя, + фильтры, blend modes или удаление фона; +- HA entity bindings, hover/click actions, состояния устройств, Glow, солнце, + стены, комнаты, проёмы или физическая мебель; +- автоматическое удаление orphan-файлов по возрасту, при удалении объекта, + замене картинки, удалении пространства или импорте; +- перенос bytes в JSON, ZIP или новый архивный backup-формат; +- wall magnet: у произвольной картинки нет семантической «задней» грани; +- изменение поведения существующих инструментов, общей палитры цвета, + картинки-подложки пространства и marker attachments. + +## Контракт поведения + +### 1. Единая кнопка и palette + +- На основной панели Background editor рядом с мебелью появляется ровно одна + новая локализованная кнопка **«Изображение»**. Отдельной кнопки «Файлы» или + отдельного постоянного asset manager нет. +- Кнопка открывает overlay/palette в существующем overlay host и не меняет fit + либо camera плана. Вверху palette находится **«Загрузить файл»**, ниже — + ранее загруженные assets, доступные всем пространствам текущей House Plan + integration. +- Строка asset показывает безопасно экранированное исходное имя, тип, размеры, + thumbnail и число использующих его decor-объектов. Сортировка — новый upload + первым; повторная загрузка тех же canonical bytes не создаёт второй asset. +- Выбор строки закрывает palette и вооружает одно размещение. Успешный upload + добавляет/обновляет строку и сразу вооружает тот же preview. Cancel file picker, + закрытие palette или `Esc` ничего не размещают и ничего не удаляют. +- `Esc`, смена инструмента, пространства или редактора до клика снимает preview. + После одного клика создаётся один объект, он остаётся выбранным, а инструмент + возвращается в **Select** — тот же one-shot контракт, что у мебели. + +### 2. Upload и форматы + +- Расширение и заявленный browser MIME не являются доказательством формата. + Backend определяет PNG/JPEG/WebP по signature и успешному bounded decode; + SVG — только по успешному безопасному XML/SVG pipeline. Несовпадение + расширения, MIME и bytes отклоняет upload понятной ошибкой. +- Raster до отправки проходит существующий #39 header probe. Порог декодированной + памяти 128 МиБ, target longest side 4096 px и hard side 16384 px остаются + едиными с картинкой-подложкой. SVG никогда не растеризуется. +- Один **сохранённый canonical asset** ограничен 2 МиБ. Если исходный raster не + укладывается либо попал под warning #39, UI предлагает создать уменьшенную + копию и показывает исходный/будущий размер; потеря качества никогда не + происходит молча. Alpha PNG/WebP сохраняется, непрозрачный результат может + кодироваться JPEG тем же проверенным pipeline. Если безопасный результат всё + ещё больше 2 МиБ, upload отклоняется без изменения config/palette. +- Backend повторно проверяет фактический format, полное декодирование raster, + положительные bounded dimensions, pixel/decompression limits и итоговый + размер. Доверия к client probe, metadata или имени нет. +- Canonical bytes записываются copy-on-write во временный файл, `fsync`/atomic + rename завершают upload. Любой parse, quota, I/O, cancellation или validation + failure удаляет temporary и не создаёт видимую запись. +- Upload, list metadata и delete требуют обычного House Plan write permission. + Чтение файла требует HA session либо ограниченную signed content-ссылку. + +### 3. Безопасный SVG + +SVG принимается только как один статический, самодостаточный документ. Проверка +не использует regex sanitizer и выполняется на backend до появления preview: + +- XML parser запрещает DTD, entity declarations/resolution, processing + instructions и внешние ресурсы; +- разрешён только SVG namespace и фиксированный allowlist статической + геометрии/групп/defs, локальных gradients, `clipPath` и `mask`; +- запрещены `script`, `foreignObject`, `iframe`, `object`, `embed`, SVG animation, + event attributes, неизвестные namespaces, ``, внешние/data/blob URLs, + CSS imports и любые ссылки кроме локального `#id` на разрешённый элемент; +- безопасные geometry, transform, fill/stroke, opacity, gradient, clip и mask + attributes имеют allowlist и числовые/строковые bounds; element count, + nesting depth, attribute count/length и path/text length ограничены; +- документ обязан иметь конечный положительный `viewBox` либо конечные + положительные intrinsic width/height, из которых backend создаёт canonical + `viewBox`; иначе aspect ratio определить нельзя и файл отклоняется; +- после проверки дерево сериализуется заново в canonical UTF-8 SVG и повторно + парсится тем же строгим parser. Asset id считается уже от этих bytes. + +Обнаружение любого запрещённого или неподдерживаемого элемента, атрибута, URL +или namespace отклоняет **весь файл** с локализованной причиной. Ничего не +вырезается молча и визуально изменённая версия не сохраняется. + +SVG content response имеет точный `image/svg+xml`, +`X-Content-Type-Options: nosniff` и sandbox CSP без script, object, navigation, +forms, network и same-origin privilege. Те же заголовки действуют при обычной +аутентификации и по signed URL. В карточке SVG вставляется только как SVG +``, никогда как inline markup. + +### 4. Первичное размещение + +- Preview центрируется на текущем указателе и проходит общий decor/room smart + magnet и grid snap. Специального furniture wall magnet и автоматического + поворота к стене нет. +- Начальная физическая ширина равна 100 см, высота вычисляется по intrinsic + aspect ratio. Если высота получилась больше 200 см, высота становится 200 см, + а ширина пропорционально уменьшается. Значения переводятся в normalized + geometry через текущие `cell_cm`/grid helpers и не зависят от camera zoom, + DPI или числа пикселей файла. +- Начальный angle — 0°, `flip_h/flip_v` отсутствуют, opacity — 1.0. Общий + color/opacity picker не перекрашивает и не делает новый image прозрачным; + opacity меняется в свойствах конкретного изображения. +- Preview показывает exact будущие bounds, content и transform. Клик на + валидной позиции append-ит объект наверх текущего decor order и создаёт одну + именованную Undo-команду. Save failure использует действующий rollback + server-backed snapshot и не оставляет фантомный объект. + +### 5. Выбор и transforms + +- В **Select** и **Erase** hit area изображения — весь повёрнутый прямоугольник, + включая полностью прозрачные пиксели. Alpha hit-test не применяется. +- Move, smooth corner resize, четыре middle handles, crossing, rotation cursors, + `Esc`, Undo/Redo и pointer-capture/cancel повторяют мебель #383: + - углы по умолчанию сохраняют aspect ratio, `Shift` разрешает независимые оси; + - middle handle меняет только одну ось; + - crossing хранит положительный extent и переключает `flip_h`/`flip_v`; + - rotation свободный, `Shift` привязывает к ближайшим 45°; + - move остаётся grid-bound и использует обычный decor/room magnet; + - pinch, pan и `pointercancel` не создают лишней операции и не сохраняют + промежуточный transform. +- Double click открывает свойства с thumbnail/именем, **«Заменить изображение»**, + opacity, signed width/height, двумя checkbox отражения, angle и действующими + действиями слоя. Signed size и checkbox меняют те же `flip_h/flip_v`, что + crossing; persisted `w/h` остаются строго положительными. +- Замена выбирает существующий либо новый asset, меняет только `asset_id` + выбранного объекта и сохраняет geometry/style/layer. Другие объекты с прежним + asset не меняются; старый файл не удаляется. + +### 6. Отображение и слои + +- Image — пассивный decor kind. Он участвует в том же `space.decor[]` order и + находится там же, где мебель: над plan image и room/data fills, но под Glow, + солнцем, стенами, проёмами, устройствами и подписями комнат. +- `display.hide_decor` скрывает image в обоих View, но Background editor + продолжает показывать и редактировать его по действующему правилу скрытого + декора. +- Полный View и `houseplan-space-card` используют одну projection-функцию, + одинаковые signed URLs и одинаковые rotate/flip/opacity. Image не получает + hover, focus, tooltip, click/action или HA subscription. +- В Plan/Devices editors и при неактивном Background tool изображение ведёт себя + как обычный decor context согласно `DECOR-EDITOR.md`; device/input ownership + не меняется. +- Все четыре повёрнутые вершины участвуют в content bounds. Прозрачные поля + файла не отсекаются и тоже входят в bounds. +- Resolve/sign выполняется один раз на уникальный `asset_id`; несколько объектов + используют один painted URL. Signed URL и raw URL никогда не пишутся в config. + +### 7. Missing asset и repair + +- Неизвестный `asset_id`, отсутствующий/повреждённый файл, несовпавший hash либо + отказ resolve считается `missing`, а не поводом удалить decor record. +- В View/kiosk/`houseplan-space-card` missing image не создаёт broken-image icon, + placeholder, сетевой запрос к догаданному пути или пустой интерактивный слой; + остальные части плана продолжают отображаться. +- В Background editor на сохранённых bounds виден нейтральный локализованный + placeholder **«Изображение недоступно»**. Он selectable/erasable, сохраняет + frame/handles/properties/layer и предлагает **«Заменить изображение»**. +- Замена repair-ит только этот объект. Исчезновение asset во время открытой + сессии переводит все его экземпляры в placeholder после следующего + authoritative resolve; geometry/history не меняются. + +### 8. Явное удаление asset + +- Удаление decor image удаляет только объект. Замена, Undo удаления, удаление + пространства и удаление последней ссылки не показывают и не запускают + удаление файла. +- В palette у asset с нулём ссылок доступно отдельное **«Удалить файл»** с + подтверждением имени. У используемого asset действие disabled и показывает + число ссылок. +- Backend внутри config write lock заново считает ссылки во всех пространствах. + Если между list и delete появилась ссылка, delete отвечает стабильным + `in_use`, файл остаётся, palette обновляется. Client-supplied count не + авторизует удаление. +- Удаление без ссылок атомарно убирает content и catalog record. Повторное + удаление отсутствующего id идемпотентно сообщает `removed: false`. +- Нет scheduled/age collector для promoted assets. Рост ограничивается upload + quota и явным удалением. + +## Модель данных + +### Persisted decor record ```ts -type ImageDecor = { - id: string; kind: 'image'; asset_id: string; - x: number; y: number; w: number; h: number; - angle?: number; opacity?: number; - preserve_aspect?: boolean; -}; +interface DecorImage { + id: string; + kind: 'image'; + asset_id: string; // 64 lowercase hex SHA-256 canonical bytes + x: number; + y: number; + w: number; // positive normalized extent + h: number; // positive normalized extent + angle?: number; // normalized to the existing decor range + opacity?: number; // absent = 1 + flip_h?: boolean; // absent = false + flip_v?: boolean; // absent = false +} ``` -Config хранит stable opaque `asset_id`, не signed URL. Render запрашивает -короткоживущий same-origin content URL существующим signer. Asset metadata -backend: id, sanitized original name, MIME, bytes, width/height для raster, -sha256, created/owner space. Blob не в config/layout. +`color`, `width`, `width_cm`, fill и furniture `symbol` к image неприменимы и +не пишутся новым frontend. Backend schema валидирует id, finite/ranges, +положительные размеры, opacity и boolean flags. Missing physical asset не делает +config structurally invalid: это необходимый контракт для переноса без bytes и +repair. -## Storage и transaction +### Asset identity и catalog -- Новый decor asset namespace под существующим House Plan content root; -- admin/write permission, streaming limit default 2 MiB после преобразования; -- upload staging → validate/sanitize → promote только при successful config save; -- отменённый dialog/failed save очищает staging; referenced asset не удаляется; -- explicit element delete предлагает удалить unreferenced asset; shared refs - считаются, inference/age deletion запрещены; -- HA backup включает content folder автоматически. +- `asset_id` — SHA-256 **canonical stored bytes**. Благодаря этому одинаковый + upload переиспользуется, а случайное/злонамеренное совпадение id с другими + bytes невозможно. +- Dedicated namespace находится под House Plan content root и попадает в HA + backup. Blob не хранится в `.storage/houseplan.config`. +- Catalog хранит `asset_id`, sanitized display name, exact MIME/extension, + canonical byte size, intrinsic width/height, created timestamp и schema + version. Ни browser path, signed token, user id, space id, refcount, thumbnail + bytes или исходный unsanitized filename не являются persisted config. +- Refcount/`used_by` всегда вычисляется из authoritative config. Один image asset + может использоваться любым числом объектов и пространств в пределах общего + лимита decor. +- Квота namespace: максимум 200 promoted assets и 256 МиБ canonical bytes; + один asset — максимум 2 МиБ. Общая проверка учитывает concurrent uploads и + существующий reserve/low-disk guard. Identical-byte upload не расходует новый + file slot или bytes. -## Raster +## Backend API и capability -PNG/JPEG/WebP проходит #39 diagnostics. Oversized source downscale до safe -target; existing plan/config не меняется до success. EXIF orientation -нормализуется. Initial `w/h` сохраняют aspect и разумный размер относительно -current view; пользователь может снять preserve-aspect в properties. +Точные transport names являются частью реализации и покрываются contract tests: -## SVG security +| Endpoint | Контракт | +|---|---| +| `POST /api/houseplan/assets/upload` | один multipart file; write permission; validate/canonicalize; `{asset, reused}` | +| `houseplan/assets/list` | metadata newest-first + authoritative `used_by`; write permission | +| `houseplan/assets/resolve` | bounded unique `ids[]`; возвращает metadata/content path либо `missing`; authenticated read | +| `houseplan/assets/delete` | `asset_id`; write permission; server ref-check; `{removed}` либо `in_use` | +| `houseplan/content/sign` | дополнительно подписывает только canonical asset content paths | +| `GET /api/houseplan/content/assets/_/` | authenticated/signed inert streaming response с exact MIME/security headers | -Не использовать regex sanitizer. XML parser запрещает DTD/entities, удаляет -`script`, `foreignObject`, animation, event attributes, external/data URLs, -`style` с unsafe constructs и неизвестные namespaces; локальные paint/geometry -элементы allowlisted. После sanitization файл повторно парсится, сериализуется -и обслуживается с `image/svg+xml`, `nosniff`, CSP sandbox. При невозможности -гарантировать sanitizer SVG отклоняется, а не сохраняется как есть. +`houseplan/config/get` добавляет неперсистентную capability +`decor_assets_api: 1`. Кнопка и upload/edit flows доступны только при exact +поддерживаемой версии. Missing/invalid/unknown capability fail-closed оставляет +существующие image records как missing и показывает при попытке редактирования +локализованное требование обновить карточку и интеграцию; существующие редакторы +не ломаются. -## Editor UX +Upload/list/delete не принимают `space_id`, owner или refcount как authority. +Resolve ограничен 200 уникальными корректными ids за вызов; frontend batching +не превышает лимит `content/sign`. -- Background → Image → выбрать файл → preview → клик по плану; -- selection frame/handles/context tray общие с rect/furniture; -- double-click properties: replace asset, opacity, aspect lock, numeric size, - angle, layer actions; -- replace transactional и не удаляет старый asset до successful save; -- missing asset показывает bounded placeholder и repair action, не broken icon. +## Import/export и совместимость -## Export/import и lifecycle +- Full, single-space и plan-only export сохраняют image decor records. +- JSON по-прежнему не содержит blob, base64, signed URL или исходный файл. + `content_manifest` получает canonical строку на каждый image record с + `asset_id`, MIME/hash metadata на момент export и `exists_at_export`. +- Export format повышается до v2, потому что v1 importer не знает `kind:image` + и exact manifest rows. Новый importer продолжает принимать v1. Старый export + без images остаётся побайтово совместимым по payload после нормализации. +- Preview не доверяет supplied manifest: заново собирает ожидаемые image refs из + payload, валидирует форму/hash и проверяет local catalog/blob. Asset считается + доступным на target только если canonical bytes дают тот же content hash. +- Доступный asset переиспользуется без копирования. Для отсутствующих preview + показывает число **уникальных файлов** и число затронутых объектов и требует + то же явное подтверждение missing content, что #50. +- После подтверждения import сохраняет `asset_id`, geometry, opacity, flips и + decor order без detach. Такие объекты становятся repair-placeholder в + Background editor и не рисуются в View до замены или появления exact asset. +- Duplicate/replace policy для spaces не меняет asset id. Collision обычных + decor ids решается действующим remap; content-addressed asset id не remap-ится. +- Optimize, copy/paste/duplicate, room operations и layout writes сохраняют + `asset_id` и flip flags. Замена конкретного объекта — единственный обычный UI + путь изменения его `asset_id`. -JSON export #50 перечисляет asset в manifest, но не переносит bytes. Будущий -portable asset bundle использует stable id remap. Space/delete cleanup только -явный и reference-aware. +### Rolling compatibility и откат версии -## Проверки и приёмка +- Новый backend принимает все старые configs без миграции и добавляет новый + decor schema branch. +- Новый frontend со старым backend не разрешает создавать/загружать image. +- Старый frontend не обязан рисовать новый kind. Пока config содержит image, + downgrade является read-only best effort: редактировать/сохранять план старым + комплектом нельзя, потому что старый backend может отвергнуть новый kind. +- Автоматически превращать image в plan backdrop, furniture, external URL или + удалять его при downgrade запрещено. -- all MIME signatures, spoofed extension, size/quota, corrupt and SVG corpus; -- staging promote/rollback, shared references, delete/replace/reload; -- transforms, undo/redo, copy/paste/optimization and unbounded canvas; -- no external request/script/style breakout from SVG; -- mobile View renders image; touch editing best-effort documented. +## UX, accessibility, touch и kiosk + +- Toolbar button, upload control, asset rows, delete/replace actions и dialog + используют native button semantics, видимый focus, localized `aria-label` и + минимум 44×44 CSS px для touch targets. +- Palette имеет dialog name, focus trap, `Esc`, возврат focus на кнопку и + keyboard selection `Enter`/`Space`. Thumbnail получает имя файла как доступное + описание, но декоративный image в View не входит в tab order и имеет + `aria-hidden`. +- Загрузка показывает busy/progress state и блокирует повторный submit, но не + блокирует закрытие редактора. Ошибка сообщается текстом и не только цветом. +- Background editing на touch остаётся best effort по `TOUCH-SUPPORT.md`, но + safety floor обязателен: single pointer не оставляет drag stuck, второй + pointer переводит жест в pan/pinch без commit, `pointercancel` откатывает live + transform, View/kiosk не получают новых interaction targets. +- Light/dark theme меняет chrome/placeholder, но не пиксели пользовательского + файла, его alpha или opacity. + +## i18n + +Новые или расширенные семейства ключей добавляются синхронно в RU/EN/DE/FR: + +- toolbar/palette: Image, Upload file, Previously uploaded, Used in N objects, + Delete file, Replace image; +- state: Uploading, Processing, Image unavailable, No uploaded images; +- validation: supported formats, file too large, unsafe/unsupported SVG, + corrupt/mismatched raster, dimensions unavailable, quota/file-count/low-disk; +- confirmation/errors: delete unused file, asset became in use, upload/save/ + resolve failed, compatible integration required; +- history: add/move/resize/rotate/flip/replace/delete image. + +Placeholder plurals и параметры имеют одинаковые множества во всех четырёх +локалях. UI не показывает raw backend exception, путь на диске, stack, hash или +signed URL. + +## Производительность + +- Bytes не входят в config, export или JS bundle. Asset tools остаются в lazy + editor graph; core View получает только минимальный image projection/resolve. +- На config revision frontend дедуплицирует ids, resolve/sign batching выполняет + не больше одного запроса/подписи на уникальный asset. Повторные объекты + переиспользуют URL и browser cache. +- Content-addressed response может быть immutable-кэшируемым, но приватным; + смена bytes всегда означает новый id/URL. +- Large-house fixture с 1000 image records на ограниченном наборе assets не + делает 1000 resolve/sign calls и остаётся внутри действующего frame/heap + performance budget. Missing ids также negative-cache-ятся на текущую config + revision. +- Raster decode/downscale не выполняется в render loop. SVG никогда не + инлайнится и не клонирует свой DOM в основной plan SVG. +- `npm run bundle:budget` не повышается. Любое увеличение initial View graph + измеряется и обосновывается в review; editor-only palette не имеет права + попасть в initial graph. + +## Затрагиваемые поверхности и ожидаемые файлы + +Ожидаемо, точная раскладка может меняться при реализации: + +- `src/editors/decor/types.ts`, geometry/projection helpers; +- `src/houseplan-card.ts`, lazy editor runtime и decor dialogs/styles; +- `src/space-render.ts`, `src/space-geometry.ts`, content signing/cache; +- новый frontend helper asset metadata/upload/resolve; +- `custom_components/houseplan/{const,http_api,websocket_api,validation,plans}.py` + и отдельный pure asset/SVG module; +- `custom_components/houseplan/import_export.py`, integration strings/translations; +- `src/i18n/{en,ru,de,fr}.json`; +- frontend unit, pure backend, HA-harness и demo smoke fixtures; +- `docs/DECOR-EDITOR.md`, `docs/ARCHITECTURE.md`, + `docs/CONFIG-COMPATIBILITY.md`, `docs/USER-GUIDE.md`, + `docs/USER-GUIDE.ru.md`, `docs/TESTING.md`, `docs/TESTING-DEMO.md`; +- оба changelog в implementation commit. + +## Критерии приёмки + +- **AC1.** Background editor имеет ровно одну новую кнопку «Изображение»; + palette загружает PNG/JPEG/WebP/SVG и повторно выбирает общий asset. Выбор + вооружает one-shot preview, клик создаёт один выбранный image и возвращает + Select; Cancel/Esc/tool/space/editor switch ничего не создают. + **Доказательство:** component unit + `demo/smoke_decor_images.mjs`. +- **AC2.** Новый объект получает ширину 100 см и aspect-preserving height с cap + 200 см независимо от camera/DPI; preview и committed bounds совпадают при + разных `cell_cm`, zoom и intrinsic ratios. **Доказательство:** geometry unit + + browser smoke. +- **AC3.** Image повторяет furniture move/smooth resize/side handles/crossing/ + flips/free rotation/Shift 45°/Undo/Esc/pointercancel, но не получает wall + magnet. Hit area — полный rotated rectangle. **Доказательство:** transform + unit matrix + pointer smoke с отрицательной wall-magnet проверкой. +- **AC4.** Properties изменяют opacity, signed size, H/V flips, angle, layer и + asset replacement; replace сохраняет geometry и не меняет другие refs. + Удаление/замена объекта не удаляет файл. **Доказательство:** unit + smoke + + backend file assertions. +- **AC5.** Full View и `houseplan-space-card` одинаково рисуют raster/SVG с + alpha, opacity, flips, rotation и decor order; image пассивен и подчиняется + `hide_decor`. **Доказательство:** shared projection unit + smoke + reviewed + light/dark golden. +- **AC6.** Backend принимает только signature/decode-confirmed PNG/JPEG/WebP и + строгий canonical SVG, ограничивает canonical asset 2 МиБ, store 200 files / + 256 МиБ и low-disk, не доверяет extension/MIME/client metadata. Ошибка или + cancellation не оставляют temp/catalog/blob. **Доказательство:** pure backend + corpus + HA endpoint tests, включая concurrency. +- **AC7.** SVG corpus сохраняет обычную geometry, local gradients, + clipPath/mask и transparency, но целиком отклоняет DTD/entities, scripts, + events, `foreignObject`, animation, image, external/data/blob URL, CSS import, + unknown namespace и resource-limit cases. Content response всегда имеет + exact MIME, `nosniff` и sandbox CSP. **Доказательство:** malicious/benign + corpus + authenticated/signed HTTP tests. +- **AC8.** Asset id равен hash canonical bytes; identical upload idempotently + переиспользует asset. Resolve/sign дедуплицированы; config содержит только + id/transform. **Доказательство:** pure storage tests + config serialization + unit + request-count smoke. +- **AC9.** Palette удаляет только asset с нулём ссылок после подтверждения. + Backend повторно проверяет все пространства под lock; reference race даёт + `in_use`. Никакой age/space/object/replace cleanup promoted assets нет. + **Доказательство:** lifecycle/concurrency HA tests. +- **AC10.** Missing/corrupt/mismatched asset не рисуется и не действует в обеих + View; Background editor показывает bounded selectable placeholder и позволяет + replace без потери geometry/order. **Доказательство:** unit + smoke с + disappeared file и failed resolve. +- **AC11.** Export v2 перечисляет каждый image ref, не содержит bytes/signed URL + и сохраняет records во всех трёх режимах. Import принимает v1/v2, fail-closed + проверяет manifest, переиспользует exact local hash, а после подтверждения + сохраняет missing image placeholder и сообщает unique asset/object counts. + **Доказательство:** pure + HA full/space/plan-only round-trip tests. +- **AC12.** New backend читает old config без migration; missing/unknown + `decor_assets_api` запрещает создание/upload и не ломает остальные редакторы. + **Доказательство:** compatibility fixtures обеих rolling directions. +- **AC13.** RU/EN/DE/FR имеют полный одинаковый key/placeholder set; keyboard, + focus restore, 44×44 targets и touch cancellation соблюдены; View/kiosk не + получают focus/pointer targets. **Доказательство:** i18n/accessibility unit + + mouse/touch smoke. +- **AC14.** 1000 image records с повторяющимися/missing ids используют bounded + batched resolve/sign, не выполняют decode в render и проходят действующие + frame/heap ceilings; initial bundle budget не повышен. **Доказательство:** + targeted performance smoke + `npm run bundle:budget`. +- **AC15.** Typecheck, unit и build зелёные; перед code review локально зелёный + targeted image smoke. Перед бетой зелёные полный smoke, golden, performance и + Linux HA harness на exact SHA. **Доказательство:** команды и CI artifacts. +- **AC16.** Canonical docs и оба User Guide описывают кнопку, форматы, + transforms, missing/import/delete правила и лимиты; оба changelog обновлены в + том же User-Visible implementation commit. **Доказательство:** docs diff, + provenance и code review. + +## План автотестов и отрицательной проверки + +### Frontend unit + +- расширить decor schema/type guards/canonicalization fixtures image record; +- initial-size matrix: landscape/portrait/square, height cap, разные `cell_cm`; +- furniture-parity transform matrix: corners, four sides, crossing, flips, + signed properties, rotation Shift, opposite point, pointercancel; +- no-wall-magnet witness при сохранённом decor/room/grid magnet; +- rotated rectangular hit-test, bounds, hide_decor, layer order; +- unique-id resolver/cache, config revision invalidation, missing projection; +- export preview copy and compatibility capability predicate. + +### Pure/backend и HA harness + +- raster magic/MIME/extension/corruption/dimension/pixel/size boundaries ±1; +- benign SVG corpus и отдельный malicious case на каждый запрет, включая nested + local reference cycles и resource bounds; +- hash/dedupe, quota/file count/free disk, atomic promote/temp cleanup, + simultaneous identical/different uploads; +- auth/write/signed-read/content-header matrix; +- list/resolve/delete refs across two objects/two spaces, stale `used_by` race; +- config validation, v1→v2 export compatibility и full/space/plan-only import + с available/missing/hash-mismatch assets. + +### Browser + +`demo/smoke_decor_images.mjs` использует production bundle и synthetic raster, +SVG и missing asset. Он проверяет upload palette, one-shot preview, exact commit, +transform/property/replace/delete/Undo, no wall magnet, pointer ownership, +light/dark View и static-card parity. Network counters доказывают batching и +отсутствие внешних SVG-запросов. + +Golden получает одну детерминированную сцену с прозрачным raster, безопасным SVG, +поворотом/отражением/layer overlap и editor placeholder в light/dark. Baseline +принимается только из полного Linux CI artifact по действующему reviewed flow. + +### Mutation/negative witnesses + +1. Разрешить SVG event/external URL или убрать CSP/`nosniff` — corpus/HTTP test + обязан покраснеть. +2. Подменить bytes при том же id — hash resolve/import test обязан дать missing. +3. Довериться client `used_by:0` — конкурентный delete test обязан потерять файл + и покраснеть. +4. Вернуть auto cleanup последней ссылки — lifecycle test обязан увидеть + исчезнувший asset. +5. Добавить wall magnet либо alpha hit-test — browser negative witness краснеет. +6. Detach missing record при import — round-trip теряет geometry и краснеет. +7. Делать resolve/sign на каждый объект — request-count/performance witness + превышает bounded unique-id count. + +## Риски + +- **Same-origin SVG XSS/SSRF.** Двойная защита: strict reject/canonical reparse и + sandboxed content response; SVG никогда не инлайнится. +- **XML/raster resource exhaustion.** Streaming byte cap ставится до parse, + затем element/depth/attribute/path и decoded-pixel limits; тяжёлое выполняется + вне HA event loop. +- **Потеря shared asset.** Delete авторизуется только server-side ref scan под + config lock; никаких inferred collectors. +- **Гонка upload/catalog.** Content-addressed identity, atomic reserve/promote и + idempotent identical upload не допускают overwrite. +- **Import показывает чужие bytes при совпавшем id.** Id — полный hash canonical + bytes, resolve дополнительно проверяет catalog/blob consistency. +- **Большой initial bundle.** Palette и upload остаются lazy; core содержит + только projection/resolve, budget и owner-graph покрыты тестом. +- **Сотни изображений задерживают View.** Один resolve/sign на unique id, cache, + immutable content и bounded asset count; performance fixture фиксирует ceiling. +- **Старая версия перезапишет неизвестный kind.** Capability fail-closed и + документированный read-only downgrade; никакой lossy migration. +- **Прозрачный asset невозможно выбрать.** Полный прямоугольный hit area и + selection frame делают выбор предсказуемым. + +## Откат + +У feature нет автоматической persisted migration: старые записи не меняются, +новые `kind:image` additive. Откат frontend скрывает изображения, но не должен +удалять records или files. Откат backend ниже версии, принимающей image schema, +делает config read-only; перед намеренным постоянным downgrade пользователь +должен текущей версией удалить image objects, после чего отдельно удалить +неиспользуемые assets из palette. Автоматическая очистка недопустима. + +Если implementation откатывается до публичной беты, dedicated assets остаются +в backup/content root и могут быть удалены только явной recovery-командой с тем +же reference guard. Export v2 importer остаётся backward-compatible с v1; +понижать уже опубликованный export version нельзя. + +## Release-артефакты + +- `docs/CHANGELOG.md` и `docs/CHANGELOG.ru.md` в том же `User-Visible: yes` + implementation commit; +- актуальные `DECOR-EDITOR.md`, `ARCHITECTURE.md`, + `CONFIG-COMPATIBILITY.md`, `USER-GUIDE.md`, `USER-GUIDE.ru.md`, `TESTING.md` + и `TESTING-DEMO.md`; +- targeted `demo/smoke_decor_images.mjs` до code review; +- reviewed light/dark golden scenario, full browser smoke, performance и Linux + HA-harness artifacts перед бетой; +- security evidence: benign/malicious SVG corpus и exact signed HTTP header + tests; +- issue остаётся открытым в `S8-merged` до пакетного закрытия при выпуске беты. + +## Решения владельца + +- Одна кнопка открывает upload и palette; размещение one-shot как у мебели. +- Начальная ширина 100 см, высота по aspect ratio с cap 200 см. +- Все мебельные transforms применяются, но wall magnet отсутствует. +- Удаление/замена объекта не удаляет файл; удалить можно только явно из palette + и только при отсутствии ссылок. +- Export остаётся JSON без bytes; missing import сохраняет geometry/order как + editor-only repair placeholder. +- SVG с любой запрещённой/неподдерживаемой частью отклоняется целиком; ничего не + вырезается молча. + +## Принято предположительно, поменять свободно + +- `asset_id` выбран content-addressed SHA-256, а не random UUID: это убирает + collision/remap ambiguity и позволяет безопасно переиспользовать уже имеющийся + файл на target. Владелец наблюдает только reuse, не форму id. +- Dedicated quota принята равной plan store: 200 assets / 256 МиБ, при + подтверждённом владельцем лимите 2 МиБ на canonical asset. +- SVG без определимого aspect ratio отклоняется вместо browser default 300×150; + молчаливый искусственный ratio сделал бы placement зависимым от renderer. +- Новый image стартует с opacity 1.0 и не использует session color/opacity + picker: picker задаёт цвет рисуемых элементов, а не пиксели пользовательского + файла. +- Upload, закрытый без размещения, остаётся reusable в palette до явного + удаления: promoted asset неотличим от файла, который пользователь намеренно + подготовил для следующего пространства. +- Export format повышается до v2, новый importer продолжает читать v1. Это + честнее, чем называть совместимым документ с новым decor kind и manifest, + который старый importer обязан отвергнуть. +- Точные имена helper/module/catalog storage и конкретный безопасный XML parser + может изменить реализация после review, если поведение, bounds, доказательства + и fail-closed контракт останутся теми же.