mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-02 04:38:55 +00:00
652 lines
49 KiB
Markdown
652 lines
49 KiB
Markdown
# ТЗ #51 — Пользовательские изображения в декоративном слое
|
||
|
||
- Issue: https://github.com/Matysh/houseplan-card/issues/51
|
||
- Приоритет и тип: 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-заголовков;
|
||
- файл может использоваться несколькими объектами и пространствами, поэтому
|
||
удаление по факту исчезновения одной ссылки разрушительно.
|
||
|
||
## Цель
|
||
|
||
Добавить 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, `<image>`, внешние/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
|
||
`<image>`, никогда как 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
|
||
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
|
||
}
|
||
```
|
||
|
||
`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.
|
||
|
||
### Asset identity и catalog
|
||
|
||
- `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.
|
||
|
||
## Backend API и capability
|
||
|
||
Точные transport names являются частью реализации и покрываются contract tests:
|
||
|
||
| 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/_/<name>` | authenticated/signed inert streaming response с exact MIME/security headers |
|
||
|
||
`houseplan/config/get` добавляет неперсистентную capability
|
||
`decor_assets_api: 1`. Кнопка и upload/edit flows доступны только при exact
|
||
поддерживаемой версии. Missing/invalid/unknown capability fail-closed оставляет
|
||
существующие image records как missing и показывает при попытке редактирования
|
||
локализованное требование обновить карточку и интеграцию; существующие редакторы
|
||
не ломаются.
|
||
|
||
Upload/list/delete не принимают `space_id`, owner или refcount как authority.
|
||
Resolve ограничен 200 уникальными корректными ids за вызов; frontend batching
|
||
не превышает лимит `content/sign`.
|
||
|
||
## Import/export и совместимость
|
||
|
||
- 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`.
|
||
|
||
### 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 запрещено.
|
||
|
||
## UX, accessibility, touch и kiosk
|
||
|
||
**Touch editor: best effort / intentionally degraded.**
|
||
|
||
- 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 контракт останутся теми же.
|