diff --git a/docs/specs/474-lazy-furniture-art.md b/docs/specs/474-lazy-furniture-art.md new file mode 100755 index 00000000..9ee5c6fe --- /dev/null +++ b/docs/specs/474-lazy-furniture-art.md @@ -0,0 +1,227 @@ +# #474 — Стартовый граф: арт мебели уходит в ленивый чанк + +- **Issue:** https://github.com/Matysh/houseplan-card/issues/474 +- **Тип / приоритет:** infra, tech-debt / P2 +- **Трек:** полный — путь отрисовки View, новая ленивая граница, бут-вуаль +- **Оценка:** ценность 7/10 (единственный рычаг размера, не требующий решения по #367); сложность 4/10; риск 3/10 (класс #352–#355) +- **Связано:** #367 (закрыт рекалибровкой), #352–#355 (ленивые модули и их отказы), #438 (храповик потолка), #451 (вынос спасает ядро), #425 (потолок ядер) + +## 1. Проблема + +Стартовый граф View — 299 658 Б gzip при стене 301 066: запас 1 408 Б, то есть +ноль. Три релиза подряд ответом на упор в границу был перенос границы +(аудиты 05.09 и 06.09, M1). Решение по #367 «резать монолит» не принято. + +Внутри монолита есть один кусок, который платит каждый план, а нужен только +планам с мебелью: `src/furniture.ts` статически импортирует +`GENERATED_FURNITURE_PLAN` из `furniture-plan-art.generated.ts` — 44 +дизайнерских символа с SVG-путями (`art.d`, 31 716 знаков). Замер на дереве +(S2, `90b185f6`): без `art.d` initial View = **289 062 Б, −10 596 Б**. Меню-арт +палитры уже ленивый (его импортирует только редактор); арт плана — нет, потому +что View рисует предметы сам. + +## 1.1. Сценарий + +Персона — владелец плана в HA, открывает вкладку с карточкой на настенном +планшете или в телефоне. У большинства планов мебели нет; у тех, где есть, +предметы видны с первого кадра. + +## 1.2. Что человек увидит до и после + +До: ничего не видно — 10,6 КБ арта грузятся всегда, вместе с монолитом. + +После: **визуально ничего не меняется.** План без мебели не грузит арт вовсе. +План с мебелью запускает загрузку при приёме конфига, до первого кадра, и +бут-вуаль первого открытия держится, пока арт не осел, — предметы появляются +вместе с планом, а не всплывают позже. Если чанк не загрузился (сеть, чужая +сборка во вкладке), предметы рисуются как неизвестные символы — ничего, — +и один раз показывается тост о недоступной части карточки; план, устройства и +редактор работают. Для инженера: initial View ≈ 289 КБ, потолок-храповик +опускается, запас до стены ≈ 12 КБ. + +## 2. Скоуп + +1. Разрез библиотеки мебели: **каталог** (id, группа, категория, w, h, и + примитивная геометрия 12 legacy-символов) остаётся в стартовом графе; + **арт** 44 дизайнерских символов уходит в ленивый чанк. +2. `FurnitureArtRuntime` — page-scoped загрузчик по контракту `LanguageRuntime` + (§4). +3. Точка запуска при приёме конфига и ожидание в буте (§4.3). +4. Бюджет бандла: ленивый чанк арта — отдельная роль в манифесте; потолок + initial View опускается храповиком (§4.5). +5. Доказательства: unit на рантайм и разрез, мутанты на защитные контракты, + смоки мебели и golden ждут `ready`. + +## 3. Не-скоуп + +- Изменение самого арта, каталога, размеров по умолчанию, палитры редактора. +- Другие кандидаты на вынос (варианты B и C из issue: словарь `en`, стили, + топология) — отдельными задачами после этой. +- Пересмотр стены `INITIAL_VIEW_GZIP_BUDGET`: стена не трогается, опускается + только потолок-храповик по правилу #438. +- Ленивая загрузка *по символу* (44 чанка): один чанк, один запрос. +- `houseplan-space-card`: статическая карточка пространства не рисует декор + сама (см. `smoke_space_card_decor_capability`); если рисует — она проходит + через тот же `furnitureGraphic` и получает поведение бесплатно, отдельного + контракта нет. + +## 4. Контракт + +### 4.1. Разрез генератора + +`scripts/generate-furniture-assets.mjs` пишет два файла вместо одного: + +- `src/furniture-plan-catalog.generated.ts` — `GENERATED_FURNITURE_CATALOG: + readonly { id, group, category, w, h }[]` (eager); +- `src/furniture-plan-art.generated.ts` — `GENERATED_FURNITURE_ART: + Readonly>` и + `FURNITURE_ART_FINGERPRINT` = отпечаток сборки (как `ISO_SCENE_RUNTIME_FINGERPRINT`), + импортируется **только** динамически из `furniture-art-runtime.ts` и + статически из редактора (`decor-image-editor.ts` — палитра), который сам + ленивый. Rollup сложит оба пути в один общий чанк; манифест обязан видеть + его как ленивый (§4.5). + +Инвариант генератора: множество id в каталоге == множество ключей арта; тест +читает оба файла и сравнивает. + +### 4.2. `FurnitureArtRuntime` (`src/furniture-art-runtime.ts`) + +Контракт `LanguageRuntime`/`EditorRuntimeLoader`, свёрнутый под одну сущность: + +| член | поведение | +|---|---| +| `state(): 'ready' \| 'pending' \| 'fallback'` | `pending` до первого `ensure()` и во время загрузки; `fallback` — осевший отказ (оба attempt'а или несовпадение отпечатка) | +| `art(id): FurnitureGraphic \| undefined` | синхронно; `undefined` в `pending`/`fallback` | +| `ensure(): Promise` | идемпотентно; один in-flight промис; attempt 0 — `import('./furniture-plan-art.generated')`, attempt 1 — тот же URL с нонсом `hp_retry` (Chromium кэширует упавший модуль навсегда, #352–#355); несовпадение `FURNITURE_ART_FINGERPRINT` с `ENTRY_BUILD_FINGERPRINT` — терминальный отказ без повторного импорта | +| `onSettled(cb)` | хук для хоста: `requestUpdate()` и одноразовый тост при `fallback` | + +`fallback` — **осевшее** состояние: карточка никогда не остаётся за вуалью +из-за арта. Page-scoped синглтон `FURNITURE_ART_RUNTIME`: несколько карточек на +дашборде грузят чанк один раз. + +`furnitureGraphic(id)` в `furniture.ts`: legacy-символ → примитивная геометрия +как сейчас; дизайнерский → `FURNITURE_ART_RUNTIME.art(id) ?? null`. Неизвестный +id → `null` (данные, не сбой — контракт сохраняется). `furniturePathD` — +аналогично. + +**`furniture-placement.ts`** сейчас проверяет существование символа через +`furnitureGraphic(symbol)`; с ленивым артом это дало бы отказ магнита в +`pending`. Проверка переводится на `furnitureSymbol(symbol)` — магниту нужен +каталог, не арт. Свидетель обязателен (§4.6). + +### 4.3. Запуск и бут + +- **Приём конфига** (`setConfig`/применение конфига в `houseplan-card.ts`): если + в любом пространстве есть `decor[].kind === 'furniture'` с дизайнерским + символом — `void FURNITURE_ART_RUNTIME.ensure()`. Проверку «есть ли мебель» + делает модуль (`configNeedsFurnitureArt(config)`), ядро — один вызов. +- **Бут-вуаль**: условие «бут осел» (`_bootSettle`, ветка + `if (!this._booting || this._bootSettling) return;`) дополняется + `furnitureArtBootGate(config)`: пока рантайм в `pending` и план нуждается в + арте — не оседать. Окно ожидания ограничено самим рантаймом: два attempt'а, + дальше `fallback`, и вуаль снимается. Планы без мебели гейт не замечают. +- **После бута**: смена конфига (пользователь добавил первый предмет через + редактор) → редактор уже импортировал арт статически, чанк в кэше модулей, + `ensure()` из приёма конфига разрешается мгновенно; для View без редактора + (конфиг обновился с сервера) — `requestUpdate()` из `onSettled`. +- **Ядро не растёт**: `houseplan-card.ts` стоит ровно на потолке 13 659 + (`core-file-budget`); вызовы складываются в существующие строки условий, либо + за каждую новую строку выносится строка. Число в дифе. + +### 4.4. Отказ + +`fallback` → предметы = `nothing` (уже штатно для символа из более новой +карточки); тост `toast.furniture_art_load_failed` один раз на страницу; в +консоли `console.warn` с `safeRuntimeDiagnostic`. При несовпадении отпечатка +тост не дублирует плашку согласования версий #462 (`hasCurrentMismatchNotice` +— как у редактора). + +### 4.5. Бюджет бандла + +- `scripts/bundle-manifest.mjs`: роль `furniture-art` по модулю + `src/furniture-plan-art.generated.ts`, выход `lazyFurnitureArtFiles`. +- `scripts/bundle-budget.mjs`: `assertBundleBudget` требует непустой + `lazyFurnitureArtFiles`, ни один его файл не в `initialViewFiles`; потолок + `INITIAL_VIEW_GZIP_CEILING` опускается по правилу #438 к измеренному факту + (ожидаемо ≈ 289 100 → потолок ≈ 289 500); стена не меняется; комментарий про + «13,6 КБ» заменяется измеренным. +- Три копии бандла пересобираются (fingerprint включает `package.json` — не + трогается, но `src/**` меняется). + +### 4.6. Свидетели (мутанты, каждый ловится) + +| id | патч | гард | +|---|---|---| +| `furniture-art-eager-import` | `furniture.ts` импортирует арт статически | `bundle:budget` (арт в initial) | +| `furniture-art-fallback-never-settles` | `fallback` не выставляется после второго attempt'а | unit: после двух отказов `state()==='fallback'`, `ensure()` разрешён | +| `furniture-art-no-retry-nonce` | attempt 1 импортирует тот же URL без нонса | unit на loader (как у `de`/`fr`) | +| `furniture-art-boot-gate-ignored` | гейт бута не ждёт `pending` | unit `furnitureArtBootGate` / смок первого кадра | +| `furniture-placement-needs-art` | магнит снова через `furnitureGraphic` | unit: размещение в `pending` даёт результат | +| `furniture-art-fingerprint-unchecked` | рантайм принимает чужой отпечаток | unit | + +## 5. Совместимость и откат + +Конфиг, схема, сохранённые координаты — без изменений. Откат = один revert: +статический импорт возвращается, чанк растворяется в монолите, потолок +поднимается вручную с объяснением (правило храповика). + +## 6. Критерии приёмки + +| AC | Критерий | Доказательство | +|---|---|---| +| AC1 | initial View ≤ 290 000 Б gzip; `lazyFurnitureArtFiles` непуст и не пересекается с initial; потолок опущен | `npm run bundle:budget`, число в issue | +| AC2 | План с мебелью: предметы в первом показанном кадре (после снятия вуали), без позднего всплытия | смок `smoke_furniture_lazy_art` (новый): мутирует момент, снимает `hpboot`→кадр, `.dfurn` уже есть | +| AC3 | План без мебели: чанк арта не запрашивается | тот же смок, сетевые запросы | +| AC4 | Отказ загрузки: вуаль снимается, план и устройства живы, предметы = `nothing`, тост один раз | смок с перехватом чанка (`route.abort`) — образец `smoke_lazy_*` #352 | +| AC5 | Чужая сборка (отпечаток): терминальный `fallback`, один импорт, без повторов | unit | +| AC6 | Магнит к стенам работает в `pending` | unit `furniture-placement` | +| AC7 | Редактор: палитра и размещение как прежде | смоки `smoke_furniture`, `smoke_furniture_polish`, `smoke_decor` | +| AC8 | Golden: 0 расхождений (сцены с мебелью ждут `ready` в harness) | `golden:verify` | +| AC9 | Шесть свидетелей §4.6 в реестре, каждый «поймано 1 из 1» | отрицательные прогоны `--id=` | +| AC10 | `houseplan-card.ts` не выше потолка; строки в дифе | `core-file-budget` | +| AC11 | Несколько карточек на странице — один запрос чанка | смок AC2 с двумя карточками | + +## 6.1. UX, модель данных, i18n + +UX не меняется (AC2). Модель данных не меняется. i18n: одна строка +`toast.furniture_art_load_failed` в en/ru/de/fr («Не удалось загрузить +изображения мебели. Обновите страницу.» — по образцу `toast.locale_load_failed`). + +## 6.2. Риски и меры + +| Риск | Мера | +|---|---| +| Всплытие предметов после первого кадра на медленной сети | гейт бута (§4.3) + смок AC2 с искусственной задержкой чанка | +| Rollup вытянет арт в общий чанк с редактором и он не будет «ленивым» с точки зрения манифеста | роль по модулю, не по имени файла; AC1 проверяет по графу | +| Chromium кэширует упавший модуль | нонс на attempt 1 (#352–#355), свидетель | +| Магнит/резайз мебели зависят от арта | аудит вызовов `furnitureGraphic` (S2: только `houseplan-card.ts:8915` рисует; placement — размеры) + AC6 | +| Ядро на потолке | §4.3 «ядро не растёт», AC10 | +| Golden-сцены с мебелью станут недетерминированными | harness ждёт `FURNITURE_ART_RUNTIME.state()==='ready'` перед кадром, как ждёт язык | + +## 7. Release-артефакты + +`User-Visible: no`. Changelog не трогается. `docs/ARCHITECTURE.md` — абзац о +ленивых границах (арт мебели рядом с языком и изометрией). `docs/FURNITURE.md` +— примечание о загрузке арта. + +## 8. Затронутые файлы + +- `scripts/generate-furniture-assets.mjs`, `src/furniture-plan-catalog.generated.ts` (новый), `src/furniture-plan-art.generated.ts`; +- `src/furniture-art-runtime.ts` (новый), `src/furniture.ts`, `src/furniture-placement.ts`; +- `src/houseplan-card.ts` — два вызова, +0 строк; +- `src/decor-image-editor.ts` — импорт арта для палитры; +- `src/i18n/*.json` — тост; +- `scripts/bundle-manifest.mjs`, `scripts/bundle-budget.mjs`; +- `demo/golden/harness.mjs`, `demo/smoke_furniture_lazy_art.mjs` (новый); +- `test/furniture-art-runtime.test.mjs` (новый), `test/furniture*.test.mjs`, `test/bundle-budget.test.mjs`; +- `scripts/mutation-gate.mjs` — шесть мутантов; +- `docs/ARCHITECTURE.md`, `docs/FURNITURE.md`. + +## 9. Принятые предположения + +- Владелец согласился с вариантом A (S2, без возражений до S3). +- Rollup при динамическом импорте модуля, который редактор импортирует + статически, создаёт общий чанк, достижимый из initial только динамически — + это уже так для `iso-scene-render` и словарей. +- Замер −10 596 Б снят на `90b185f6`; после #471 и #473 цифра сдвинется на + сотни байт, не на килобайты; AC1 фиксирует порог 290 000 с запасом. diff --git a/docs/specs/README.md b/docs/specs/README.md index 88bc11c7..ba03d3fe 100644 --- a/docs/specs/README.md +++ b/docs/specs/README.md @@ -202,6 +202,7 @@ GitHub Issues и GitHub Projects (v2) остаются единственным | [#423](https://github.com/Matysh/houseplan-card/issues/423) Полиш support pipeline и защитных инструментов v1.70.0 | [423-v170-polish.md](423-v170-polish.md) | | [#434](https://github.com/Matysh/houseplan-card/issues/434) Полиш аудита v1.71.0-beta.1 | [434-v171-polish-audit.md](434-v171-polish-audit.md) | | [#443](https://github.com/Matysh/houseplan-card/issues/443) Полиш маршрутов карт робота | [443-vacuum-route-polish.md](443-vacuum-route-polish.md) | +| [#474](https://github.com/Matysh/houseplan-card/issues/474) Стартовый граф: арт мебели уходит в ленивый чанк | [474-lazy-furniture-art.md](474-lazy-furniture-art.md) | ## P3