feat: арт мебели в ленивый чанк — стартовый граф 299 658 → 289 727 Б

Каталог (id, группа, размеры) остаётся eager; SVG-пути 44 дизайнерских
символов — отдельный чанк за FurnitureArtRuntime (ready|pending|fallback,
нонс на повторе, отпечаток сборки, осевший отказ). Запуск при приёме плана,
бут-вуаль ждёт арт в пределах BOOT_MAX_MS, редактор отдаёт арт рантайму
синхронно (adopt при загрузке чанка). Магнит проверяет каталог, не арт.
Потолок initial View 300 500 → 290 500. Семь свидетелей, смок
smoke_furniture_lazy_art, golden-harness требует все предметы после бута.
Ядро −2 строки.

Issue: #474
User-Visible: no
This commit is contained in:
Codex
2026-09-06 16:10:46 +03:00
parent a31d8981a7
commit 9b4e1efb1d
55 changed files with 1811 additions and 969 deletions
+17 -1
View File
@@ -150,8 +150,15 @@ export const LOW_HEADROOM_WARNING_BYTES = 15_000;
* измеренный initial факт до 299 501 Б и оставила лишь 499 Б сверху — меньше
* обязательного шумового запаса. Новый центр оставляет 999 Б сверху и
* 1 001 Б снизу; продуктовый граф при этом не расширялся.
*
* 2026-09-06, #474: потолок опущен 300 500 → 290 500 без изменения общего
* бюджета. Арт 44 дизайнерских символов мебели (`furniture-plan-art.generated`)
* вынесен в ленивый чанк `lazyFurnitureArtFiles` (10 252 Б gzip): его грузят
* только планы с мебелью, редактор отдаёт его рантайму синхронно. Измеренный
* initial факт 289 690 Б оставляет 810 Б сверху и 1 190 Б до нижней границы
* полосы; запас до стены 301 066 — 11 376 Б.
*/
export const INITIAL_VIEW_GZIP_CEILING = 300_500;
export const INITIAL_VIEW_GZIP_CEILING = 290_500;
export const INITIAL_VIEW_CEILING_BAND = 2_000;
/**
@@ -268,6 +275,14 @@ export function assertBundleBudget(manifest, budget = INITIAL_VIEW_GZIP_BUDGET)
if (!manifest.lazyIsometricFiles?.length) {
throw new Error('bundle has no lazy isometric graph');
}
// #474: designer furniture artwork is lazy; a static import anywhere in the
// View graph would pull ~10 KB gzip back into the initial graph silently.
if (!manifest.lazyFurnitureArtFiles?.length) {
throw new Error('bundle has no lazy furniture artwork graph');
}
if (manifest.initialViewFiles.some((path) => manifest.lazyFurnitureArtFiles.includes(path))) {
throw new Error('initial View graph overlaps lazy furniture artwork graph');
}
if (manifest.initialViewFiles.some((path) => manifest.lazyEditorFiles.includes(path))) {
throw new Error('initial View graph overlaps lazy editor graph');
}
@@ -291,6 +306,7 @@ export function assertBundleBudget(manifest, budget = INITIAL_VIEW_GZIP_BUDGET)
lazyEditorGzipBytes: manifest.lazyEditorGzipBytes,
lazyLocaleGzipBytes: manifest.lazyLocaleGzipBytes,
lazyIsometricGzipBytes: manifest.lazyIsometricGzipBytes,
lazyFurnitureArtGzipBytes: manifest.lazyFurnitureArtGzipBytes,
};
}
+22 -3
View File
@@ -9,6 +9,7 @@ const ONBOARDING_RETRY_ASSET_TOKEN = '__HOUSEPLAN_ONBOARDING_RETRY_ASSET__';
const ISO_RETRY_ASSET_TOKEN = '__HOUSEPLAN_ISO_RETRY_ASSET__';
const DE_RETRY_ASSET_TOKEN = '__HOUSEPLAN_DE_RETRY_ASSET__';
const FR_RETRY_ASSET_TOKEN = '__HOUSEPLAN_FR_RETRY_ASSET__';
const FURNITURE_ART_RETRY_ASSET_TOKEN = '__HOUSEPLAN_FURNITURE_ART_RETRY_ASSET__';
const sha256 = (value) => createHash('sha256').update(value).digest('hex');
@@ -39,7 +40,11 @@ export function buildBundleManifest(bundle, fingerprint) {
? 'editor'
: modules.some((id) => id.endsWith('/src/iso-scene-render.ts'))
? 'isometric'
: undefined;
// #474: designer furniture artwork — its own lazy chunk, shared by
// the editor (static import) and the View runtime (dynamic).
: modules.some((id) => id.endsWith('/src/furniture-plan-art.generated.ts'))
? 'furniture-art'
: undefined;
return {
path: chunk.fileName.replaceAll('\\', '/'),
sha256: sha256(contents),
@@ -71,6 +76,7 @@ export function buildBundleManifest(bundle, fingerprint) {
&& !localeRoots.includes(path) && !onboardingRoots.includes(path));
const isometricRoots = dynamicRoots.filter((path) => byPath.get(path)?._role === 'isometric'
|| path.includes('iso-scene-render-'));
const furnitureArtRoots = dynamicRoots.filter((path) => byPath.get(path)?._role === 'furniture-art');
const graphFrom = (roots) => {
const graph = new Set();
for (const root of roots) {
@@ -82,6 +88,7 @@ export function buildBundleManifest(bundle, fingerprint) {
const lazyOnboarding = graphFrom(onboardingRoots);
const lazyLocale = graphFrom(localeRoots);
const lazyIsometric = graphFrom(isometricRoots);
const lazyFurnitureArt = graphFrom(furnitureArtRoots);
const sum = (paths) => [...paths]
.reduce((total, path) => total + (byPath.get(path)?.gzipBytes || 0), 0);
return {
@@ -100,6 +107,8 @@ export function buildBundleManifest(bundle, fingerprint) {
lazyLocaleGzipBytes: sum(lazyLocale),
lazyIsometricFiles: [...lazyIsometric].sort(),
lazyIsometricGzipBytes: sum(lazyIsometric),
lazyFurnitureArtFiles: [...lazyFurnitureArt].sort(),
lazyFurnitureArtGzipBytes: sum(lazyFurnitureArt),
files: files.map(({ _role, ...file }) => file),
};
}
@@ -153,11 +162,15 @@ export function editorRuntimeRetryUrlPlugin() {
.some((id) => id.replaceAll('\\', '/').endsWith('/src/i18n/de.ts')));
const french = chunks.find((chunk) => Object.keys(chunk.modules)
.some((id) => id.replaceAll('\\', '/').endsWith('/src/i18n/fr.ts')));
const furnitureArt = chunks.find((chunk) => Object.keys(chunk.modules)
.some((id) => id.replaceAll('\\', '/').endsWith('/src/furniture-plan-art.generated.ts')));
if (!editor) throw new Error('editor runtime chunk was not emitted');
if (!onboarding) throw new Error('onboarding runtime chunk was not emitted');
if (!isometric) throw new Error('isometric runtime chunk was not emitted');
if (!german) throw new Error('German locale chunk was not emitted');
if (!french) throw new Error('French locale chunk was not emitted');
if (!furnitureArt) throw new Error('furniture artwork chunk was not emitted');
let furnitureArtReplacements = 0;
let editorReplacements = 0;
let onboardingReplacements = 0;
let isometricReplacements = 0;
@@ -194,11 +207,17 @@ export function editorRuntimeRetryUrlPlugin() {
frenchReplacements += chunk.code.split(FR_RETRY_ASSET_TOKEN).length - 1;
chunk.code = chunk.code.replaceAll(FR_RETRY_ASSET_TOKEN, asset);
}
if (chunk.code.includes(FURNITURE_ART_RETRY_ASSET_TOKEN)) {
let asset = posix.relative(posix.dirname(chunk.fileName), furnitureArt.fileName);
if (!asset.startsWith('.')) asset = `./${asset}`;
furnitureArtReplacements += chunk.code.split(FURNITURE_ART_RETRY_ASSET_TOKEN).length - 1;
chunk.code = chunk.code.replaceAll(FURNITURE_ART_RETRY_ASSET_TOKEN, asset);
}
}
if (editorReplacements !== 1 || onboardingReplacements !== 1 || isometricReplacements !== 1
|| germanReplacements !== 1 || frenchReplacements !== 1) {
|| germanReplacements !== 1 || frenchReplacements !== 1 || furnitureArtReplacements !== 1) {
throw new Error('lazy retry URL placeholder counts are '
+ `${editorReplacements}/${onboardingReplacements}/${isometricReplacements}/${germanReplacements}/${frenchReplacements}, expected 1/1/1/1/1`);
+ `${editorReplacements}/${onboardingReplacements}/${isometricReplacements}/${germanReplacements}/${frenchReplacements}/${furnitureArtReplacements}, expected 1/1/1/1/1/1`);
}
},
};
+15 -4
View File
@@ -8,6 +8,9 @@ const PACK = path.join(ROOT, 'assets', 'furniture', 'houseplan-0.3.0');
// Deliberately not named *manifest.json: HACS rejects a repository that
// contains any second file matching that suffix outside the integration.
const MANIFEST = path.join(PACK, 'pack.json');
// #474: каталог (id, группа, категория, размеры) — в стартовом графе, арт
// (SVG-пути, ~10 КБ gzip) — отдельный модуль, который View грузит лениво.
const CATALOG_OUT = path.join(ROOT, 'src', 'furniture-plan-catalog.generated.ts');
const PLAN_OUT = path.join(ROOT, 'src', 'furniture-plan-art.generated.ts');
const MENU_OUT = path.join(ROOT, 'src', 'furniture-menu-art.generated.ts');
const CHECK = process.argv.includes('--check');
@@ -146,11 +149,18 @@ function loadPack() {
}
const header = `/* Generated by scripts/generate-furniture-assets.mjs. DO NOT EDIT. */\n`;
function renderCatalog(symbols) {
const runtime = symbols.map(({ id, group, category, w, h }) => ({ id, group, category, w, h }));
return header + `export const GENERATED_FURNITURE_CATALOG = ${JSON.stringify(runtime)} as const;\n`;
}
function renderPlan(symbols) {
const runtime = symbols.map(({ id, group, category, w, h, art }) => ({
id, group, category, w, h, art,
}));
return header + `export const GENERATED_FURNITURE_PLAN = ${JSON.stringify(runtime)} as const;\n`;
const runtime = Object.fromEntries(symbols.map(({ id, art }) => [id, art]));
return header
+ '// Lazy chunk (#474): imported dynamically by furniture-art-runtime.ts and\n'
+ '// statically by the editor. The fingerprint token is replaced at build time\n'
+ '// so a chunk from another build is rejected instead of half-applied.\n'
+ `export const FURNITURE_ART_FINGERPRINT = '__HOUSEPLAN_SOURCE_FINGERPRINT__';\n`
+ `export const GENERATED_FURNITURE_ART: Readonly<Record<string, { d: string; viewW: number; viewH: number }>> = ${JSON.stringify(runtime)};\n`;
}
function renderMenu(menu) {
const runtime = menu.map(({ id, group, art }) => ({ id, group, art }));
@@ -166,6 +176,7 @@ function output(file, content) {
}
const pack = loadPack();
output(CATALOG_OUT, renderCatalog(pack.symbols));
output(PLAN_OUT, renderPlan(pack.symbols));
output(MENU_OUT, renderMenu(pack.menu));
console.log(`Furniture pack OK: ${pack.symbols.length} plan symbols, ${pack.menu.length} menu icons${CHECK ? ' (generated files current)' : ''}.`);
+96 -3
View File
@@ -1434,10 +1434,10 @@ const MUTANT_DEFINITIONS = [
+ 'a German user on a stale tab silently reading English is the N7 hole (#354 К2)',
patches: [{
file: 'src/houseplan-card.ts',
find: " this._languageFailureUnsub = subscribeLanguageLoadFailures(() => {\n"
find: " this._languageFailureUnsub = composeUnsub(subscribeLanguageLoadFailures(() => {\n"
+ " this._showToast(this._t('toast.locale_load_failed'));\n"
+ ' });',
replace: ' this._languageFailureUnsub = undefined;',
+ ' }), ',
replace: ' this._languageFailureUnsub = composeUnsub(',
}],
},
{
@@ -6541,6 +6541,99 @@ const MUTANT_DEFINITIONS = [
replace: ' if (true',
}],
},
// #474: designer furniture artwork is a lazy chunk. Each protective contract
// of the runtime and its integration gets one witness.
{
id: 'furniture-art-eager-import',
guard: 'npm run build && node scripts/bundle-budget.mjs',
because: 'a static import of the artwork anywhere in the View graph silently pulls ~10 KB gzip '
+ 'back into the initial graph — the exact debt #474 removes; the budget must see the chunk as lazy',
patches: [{
file: 'src/furniture.ts',
find: "import { FURNITURE_ART_RUNTIME, type FurnitureArtHost } from './furniture-art-runtime';",
replace: "import { FURNITURE_ART_RUNTIME, type FurnitureArtHost } from './furniture-art-runtime';\n"
+ "import './furniture-plan-art.generated'; // mutant: eager artwork",
}],
},
{
id: 'furniture-art-fallback-never-settles',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test --test-name-pattern="settle into fallback" test/furniture-art-runtime.test.mjs',
because: 'fallback must be a SETTLED state: a runtime that stays pending after two failed attempts '
+ 'keeps the boot gate waiting and re-imports forever (#352–#355 class)',
patches: [{
file: 'src/furniture-art-runtime.ts',
find: ' if (this._art) return;\n this._failed = true;',
replace: ' if (this._art) return;\n this._failed = false; // mutant: never settle',
}],
},
{
id: 'furniture-art-no-retry-nonce',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test --test-name-pattern="first attempt failing and the second succeeding" test/furniture-art-runtime.test.mjs',
because: 'Chromium caches a failed module forever; the second attempt must be a different URL '
+ 'and must exist at all — a single attempt turns every transient failure into fallback',
patches: [{
file: 'src/furniture-art-runtime.ts',
find: ' for (const attempt of [0, 1] as const) {\n try {\n const module = await this.options.load(attempt);',
replace: ' for (const attempt of [0] as const) {\n try {\n const module = await this.options.load(attempt);',
}],
},
{
id: 'furniture-art-boot-gate-ignored',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test --test-name-pattern="boot gate waits only while pending" test/furniture-art-runtime.test.mjs',
because: 'the boot veil must hold while a plan with furniture waits for its artwork; a gate that '
+ 'never reports pending lets the first revealed frame miss every piece (#474 AC2)',
patches: [{
file: 'src/furniture-art-runtime.ts',
find: " return runtime.state() === 'pending' && configNeedsFurnitureArt(config, isDesigner);",
replace: ' return false; // mutant: veil never waits for artwork',
}],
},
{
id: 'furniture-placement-needs-art',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test --test-name-pattern="wall magnet places a designer piece" test/furniture-art-runtime.test.mjs',
because: 'the magnet needs the catalogue, not the artwork: checking the artwork refuses every '
+ 'designer placement while the chunk is pending (#474 AC6)',
patches: [{
file: 'src/furniture-placement.ts',
find: ' if (!furnitureSymbol(symbol) || !(canvasW > 0) || !(canvasH > 0)',
replace: " if (!(furnitureSymbol(symbol) && (furnitureSymbol(symbol)?.g || FURNITURE_ART_RUNTIME.art(symbol))) || !(canvasW > 0) || !(canvasH > 0)",
}, {
file: 'src/furniture-placement.ts',
find: "import { clampFurnSize, cmToNorm, furnitureSymbol } from './furniture';",
replace: "import { clampFurnSize, cmToNorm, furnitureSymbol } from './furniture';\nimport { FURNITURE_ART_RUNTIME } from './furniture-art-runtime';",
}],
},
{
id: 'furniture-art-fingerprint-unchecked',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test --test-name-pattern="foreign build fingerprint is terminal|adopt is synchronous, rejects" test/furniture-art-runtime.test.mjs',
because: 'artwork from another build must be rejected on both paths (load and adopt): a stale '
+ 'chunk half-applied to a newer card is the #353 failure mode',
patches: [{
file: 'src/furniture-art-runtime.ts',
find: ' if (module.FURNITURE_ART_FINGERPRINT !== this.options.expectedFingerprint) {',
replace: ' if (false) { // mutant: any build will do',
}, {
file: 'src/furniture-art-runtime.ts',
find: ' if (fingerprint !== this.options.expectedFingerprint) {',
replace: ' if (false) { // mutant: any build will do',
}],
},
{
id: 'furniture-art-editor-adopt-skipped',
guard: 'npm run bundle:sync && node demo/smoke_furniture.mjs',
because: 'the editor imports the artwork statically and must hand it over synchronously; without '
+ 'adopt the palette previews and the placement ghost render empty on a plan without furniture (#474 r1)',
patches: [{
file: 'src/houseplan-editor-runtime.ts',
find: 'FURNITURE_ART_RUNTIME.adopt(GENERATED_FURNITURE_ART, FURNITURE_ART_FINGERPRINT);',
replace: 'void [FURNITURE_ART_RUNTIME, GENERATED_FURNITURE_ART, FURNITURE_ART_FINGERPRINT]; // mutant: no handover',
}],
},
{
id: 'iso-aabb-rejects-touching-wall',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '