docs: spec #474 — арт мебели в ленивый чанк

Issue: #474
User-Visible: no
This commit is contained in:
Codex
2026-09-06 16:09:50 +03:00
parent ab01586b32
commit c3716b6930
2 changed files with 228 additions and 0 deletions
+227
View File
@@ -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<Record<string, { d, viewW, viewH }>>` и
`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<void>` | идемпотентно; один 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 с запасом.
+1
View File
@@ -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