feat(view): the moon in its phase over the "Follow the Sun" background (#661)

At dawn, dusk and night the environment shows the moon in the top-left
corner of the scene, behind the plan: computed in the card from the home
coordinates and the browser clock (short Meeus series + topocentric
parallax, within 1.4° / 1.8 pp of JPL Horizons), one designer image under
a continuous phase mask with the lit side always on the left (owner
2026-09-29), a feathered terminator, 3°/3 % thresholds and the 2 s fade of
the window rays. Everything but a small gate lives in the lazy
moon-runtime chunk, with its own 30 s ticker. General settings: "Sun"
becomes "Sun and Moon" with one switch, on for new installations
(DEFAULT_CONFIG), off for existing ones.

The initial View graph sat 728 B under its budget: the gate is paid for by
moving fifteen dialog-only strings of General settings into the lazy
settings dictionary (#459) and by one build fingerprint literal instead of
three, so the graph ends 4 B above dev. Golden: two new moon scenes, and
the two General settings help frames show «Sun and Moon»; the WSL artifact
test fixture now models scenes whose first capture awaits acceptance.

Issue: #661
User-Visible: yes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
This commit is contained in:
Sergey Matyunin
2026-09-30 12:25:46 +03:00
co-authored by Claude Opus 5.5
parent e515bdaab8
commit c8c470ed57
58 changed files with 1682 additions and 126 deletions
+9
View File
@@ -734,6 +734,13 @@ export function assertBundleBudget(
if (!manifest.lazyPdfFiles?.length) {
throw new Error('bundle has no lazy PDF graph');
}
// #661 C7/AC8: the moon's math, art and template load only at night.
if (!manifest.lazyMoonFiles?.length) {
throw new Error('bundle has no lazy moon graph');
}
if (manifest.initialViewFiles.some((path) => manifest.lazyMoonFiles.includes(path))) {
throw new Error('initial View graph overlaps lazy moon 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) {
@@ -806,6 +813,7 @@ export function assertBundleBudget(
lazyIsometricGzipBytes: manifest.lazyIsometricGzipBytes,
lazyFurnitureArtGzipBytes: manifest.lazyFurnitureArtGzipBytes,
lazyPdfGzipBytes: manifest.lazyPdfGzipBytes,
lazyMoonGzipBytes: manifest.lazyMoonGzipBytes,
};
}
@@ -840,6 +848,7 @@ if (import.meta.url === pathToFileURL(process.argv[1] || '').href) {
`lazy locale: ${result.lazyLocaleGzipBytes} B gzip`,
`lazy isometric: ${result.lazyIsometricGzipBytes} B gzip`,
`lazy PDF: ${result.lazyPdfGzipBytes} B gzip`,
`lazy moon: ${result.lazyMoonGzipBytes} B gzip`,
];
for (const line of lines) console.log(line);
const warning = lowHeadroomWarning(headroom);
+21 -2
View File
@@ -11,6 +11,7 @@ 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 PDF_RETRY_ASSET_TOKEN = '__HOUSEPLAN_PDF_RETRY_ASSET__';
const MOON_RETRY_ASSET_TOKEN = '__HOUSEPLAN_MOON_RETRY_ASSET__';
/**
* #627: ru/de/fr of the three lazy dictionary namespaces are one chunk per
@@ -110,6 +111,9 @@ export function buildBundleManifest(bundle, fingerprint) {
? 'isometric'
: modules.some((id) => id.endsWith('/src/pdf/pdf-export.ts'))
? 'pdf'
// #661: the moon's math, art and template — loaded only at night.
: modules.some((id) => id.endsWith('/src/moon-runtime.ts'))
? 'moon'
// #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'))
@@ -153,6 +157,8 @@ export function buildBundleManifest(bundle, fingerprint) {
const furnitureArtRoots = dynamicRoots.filter((path) => byPath.get(path)?._role === 'furniture-art');
const pdfRoots = dynamicRoots.filter((path) => byPath.get(path)?._role === 'pdf'
|| path.includes('pdf-export-'));
const moonRoots = dynamicRoots.filter((path) => byPath.get(path)?._role === 'moon'
|| path.includes('moon-runtime-'));
// #627: namespace dictionaries are dynamic imports of LAZY chunks, never of
// the initial graph, so they are found by their module, not as a root.
const namespaceLocaleRoots = files.filter((file) => file._role === 'namespace-locale')
@@ -170,6 +176,7 @@ export function buildBundleManifest(bundle, fingerprint) {
const lazyIsometric = graphFrom(isometricRoots);
const lazyFurnitureArt = graphFrom(furnitureArtRoots);
const lazyPdf = graphFrom(pdfRoots);
const lazyMoon = graphFrom(moonRoots);
const lazyNamespaceLocale = graphFrom(namespaceLocaleRoots);
const sum = (paths) => [...paths]
.reduce((total, path) => total + (byPath.get(path)?.gzipBytes || 0), 0);
@@ -198,6 +205,8 @@ export function buildBundleManifest(bundle, fingerprint) {
lazyFurnitureArtGzipBytes: sum(lazyFurnitureArt),
lazyPdfFiles: [...lazyPdf].sort(),
lazyPdfGzipBytes: sum(lazyPdf),
lazyMoonFiles: [...lazyMoon].sort(),
lazyMoonGzipBytes: sum(lazyMoon),
lazyNamespaceLocaleFiles: [...lazyNamespaceLocale].sort(),
lazyNamespaceLocaleGzipBytes: sum(lazyNamespaceLocale),
files: files.map(({ _role, ...file }) => file),
@@ -257,6 +266,8 @@ export function editorRuntimeRetryUrlPlugin() {
.some((id) => id.replaceAll('\\', '/').endsWith('/src/furniture-plan-art.generated.ts')));
const pdf = chunks.find((chunk) => Object.keys(chunk.modules)
.some((id) => id.replaceAll('\\', '/').endsWith('/src/pdf/pdf-export.ts')));
const moon = chunks.find((chunk) => Object.keys(chunk.modules)
.some((id) => id.replaceAll('\\', '/').endsWith('/src/moon-runtime.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');
@@ -264,6 +275,7 @@ export function editorRuntimeRetryUrlPlugin() {
if (!french) throw new Error('French locale chunk was not emitted');
if (!furnitureArt) throw new Error('furniture artwork chunk was not emitted');
if (!pdf) throw new Error('PDF export runtime chunk was not emitted');
if (!moon) throw new Error('moon runtime chunk was not emitted');
const namespaceLocales = NAMESPACE_LOCALE_CHUNKS.map((entry) => {
const chunk = chunks.find((candidate) => Object.keys(candidate.modules)
.some((id) => id.replaceAll('\\', '/').endsWith(entry.module)));
@@ -279,6 +291,7 @@ export function editorRuntimeRetryUrlPlugin() {
let germanReplacements = 0;
let frenchReplacements = 0;
let pdfReplacements = 0;
let moonReplacements = 0;
for (const chunk of chunks) {
if (chunk.code.includes(EDITOR_RETRY_ASSET_TOKEN)) {
let asset = posix.relative(posix.dirname(chunk.fileName), editor.fileName);
@@ -322,6 +335,12 @@ export function editorRuntimeRetryUrlPlugin() {
pdfReplacements += chunk.code.split(PDF_RETRY_ASSET_TOKEN).length - 1;
chunk.code = chunk.code.replaceAll(PDF_RETRY_ASSET_TOKEN, asset);
}
if (chunk.code.includes(MOON_RETRY_ASSET_TOKEN)) {
let asset = posix.relative(posix.dirname(chunk.fileName), moon.fileName);
if (!asset.startsWith('.')) asset = `./${asset}`;
moonReplacements += chunk.code.split(MOON_RETRY_ASSET_TOKEN).length - 1;
chunk.code = chunk.code.replaceAll(MOON_RETRY_ASSET_TOKEN, asset);
}
for (const entry of namespaceLocales) {
if (!chunk.code.includes(entry.token)) continue;
let asset = posix.relative(posix.dirname(chunk.fileName), entry.chunk.fileName);
@@ -332,9 +351,9 @@ export function editorRuntimeRetryUrlPlugin() {
}
if (editorReplacements !== 1 || onboardingReplacements !== 1 || isometricReplacements !== 1
|| germanReplacements !== 1 || frenchReplacements !== 1 || furnitureArtReplacements !== 1
|| pdfReplacements !== 1) {
|| pdfReplacements !== 1 || moonReplacements !== 1) {
throw new Error('lazy retry URL placeholder counts are '
+ `${editorReplacements}/${onboardingReplacements}/${isometricReplacements}/${germanReplacements}/${frenchReplacements}/${furnitureArtReplacements}/${pdfReplacements}, expected 1/1/1/1/1/1/1`);
+ `${editorReplacements}/${onboardingReplacements}/${isometricReplacements}/${germanReplacements}/${frenchReplacements}/${furnitureArtReplacements}/${pdfReplacements}/${moonReplacements}, expected 1/1/1/1/1/1/1/1`);
}
// #627: the same strict rule for every namespace × language token —
// exactly one second-attempt URL each, never zero and never two.
+1 -1
View File
@@ -342,7 +342,7 @@ export const CHECKS = {
entries: ['test/*.test.mjs', 'scripts/no-new-any.mjs', 'scripts/no-new-private-writes.mjs', 'scripts/render-layout-read.mjs', 'scripts/bundle-budget.mjs',
'scripts/fix-test-build.mjs', 'scripts/unused-locals-gate.mjs'],
// demo/helpers/** — область no-new-private-writes (#629): гейт читает их текст.
roots: [...BUILD_INPUTS, 'assets/furniture/**', 'test/**', 'tsconfig*.json', 'scripts/monolith-baseline.json', 'demo/smoke_*.mjs', 'demo/benchmark_*.mjs', 'demo/guard/*.mjs', 'demo/helpers/**', ...WORKFLOW],
roots: [...BUILD_INPUTS, 'assets/furniture/**', 'assets/moon/**', 'test/**', 'tsconfig*.json', 'scripts/monolith-baseline.json', 'demo/smoke_*.mjs', 'demo/benchmark_*.mjs', 'demo/guard/*.mjs', 'demo/helpers/**', ...WORKFLOW],
},
changed_mutants: {
entries: ['scripts/mutation-*.mjs', 'scripts/*-guard.mjs', 'test/*.test.mjs', 'demo/smoke_*.mjs', 'tests_backend/**/*.py'],
+4
View File
@@ -413,6 +413,10 @@
"required": true,
"type": "str"
},
"config.settings.moon": {
"required": false,
"type": "bool"
},
"config.settings.new_device_ids": {
"required": false
},
+99
View File
@@ -0,0 +1,99 @@
// Designer moon pack → `src/moon-art.generated.ts` (#661, variant A).
//
// The pack is one full-moon SVG; the card applies the phase as a mask (C5).
// The generator is the gate for what the designer's file may contain: a square
// 512 viewBox with the documented disc, vector shapes and gradients only — no
// filters, raster, fonts, scripts or external references — and at most 30 KB.
// Ids are prefixed so the art can live in the card's shadow root next to
// everything else. `--check` fails when the committed module has drifted.
import { createHash } from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import process from 'node:process';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
export const MOON_PACK_DIR = path.join(ROOT, 'assets', 'moon', 'houseplan-1.0.0');
const OUT = path.join(ROOT, 'src', 'moon-art.generated.ts');
/** Brief to the designer: ≤ 30 KB before compression. */
export const MOON_SVG_MAX_BYTES = 30 * 1024;
const ALLOWED_TAGS = new Set([
'svg', 'title', 'defs', 'g', 'path', 'circle', 'ellipse', 'mask', 'radialGradient', 'linearGradient', 'stop',
]);
const ID_PREFIX = 'hp-moon-';
const fail = (message) => { throw new Error(`Moon pack: ${message}`); };
/** pack.json: identity, licence and the disc geometry the mask is built for. */
export function validateMoonPack(pack) {
if (!pack || typeof pack !== 'object' || Array.isArray(pack)) fail('pack.json must be an object');
if (pack.schema_version !== 1) fail('schema_version must be 1');
if (pack.pack_id !== 'houseplan') fail('pack_id must be "houseplan"');
if (typeof pack.pack_version !== 'string' || !/^\d+\.\d+\.\d+$/.test(pack.pack_version)) fail('pack_version must be semver');
if (pack.license !== 'MIT') fail('license must be MIT');
if (typeof pack.source_url !== 'string' || !pack.source_url.startsWith('https://')) fail('source_url must be https');
if (pack.file !== 'moon.svg') fail('file must be moon.svg');
if (JSON.stringify(pack.view_box) !== '[0,0,512,512]') fail('view_box must be [0, 0, 512, 512]');
if (JSON.stringify(pack.disc) !== '{"cx":256,"cy":256,"r":240}') fail('disc must be centred at 256 with r 240');
if (pack.mirror !== false) fail('mirror must be false (owner 2026-09-29)');
if (pack.lit_side !== 'left') fail('lit_side must be "left" (owner 2026-09-29)');
return pack;
}
/** The SVG body the card inlines: validated, title dropped, ids prefixed. */
export function moonArtFromSvg(source) {
const text = String(source).replace(/^/, '').trim();
if (Buffer.byteLength(text, 'utf8') > MOON_SVG_MAX_BYTES) fail(`moon.svg is larger than ${MOON_SVG_MAX_BYTES} bytes`);
if (/<!DOCTYPE|<!ENTITY|<\?|<!--|<!\[CDATA/i.test(text)) fail('moon.svg contains active XML');
if (/href\s*=|url\((?!#)|@import|base64|font-family|style\s*=/i.test(text)) fail('moon.svg references outside itself or carries styles');
for (const [, name] of text.matchAll(/<\/?([A-Za-z][\w:-]*)/g)) {
if (!ALLOWED_TAGS.has(name)) fail(`moon.svg contains <${name}>`);
}
const root = text.match(/^<svg\b([^>]*)>([\s\S]*)<\/svg>$/);
if (!root) fail('moon.svg is not one SVG root');
if (!/\bviewBox="0 0 512 512"/.test(root[1])) fail('moon.svg must have viewBox="0 0 512 512"');
let body = root[2].replace(/<title>[\s\S]*?<\/title>/g, '');
const ids = [...body.matchAll(/\bid="([^"]+)"/g)].map((match) => match[1]);
if (new Set(ids).size !== ids.length) fail('moon.svg repeats an id');
for (const [, ref] of body.matchAll(/url\(#([^)]+)\)/g)) {
if (!ids.includes(ref)) fail(`moon.svg references a missing id: ${ref}`);
}
if (/[`\\]|\$\{/.test(body)) fail('moon.svg contains template-literal syntax');
body = body
.replace(/\bid="([^"]+)"/g, (_, id) => `id="${ID_PREFIX}${id}"`)
.replace(/url\(#([^)]+)\)/g, (_, id) => `url(#${ID_PREFIX}${id})`);
return body;
}
export function generateMoonModule(packDir = MOON_PACK_DIR) {
const pack = validateMoonPack(JSON.parse(fs.readFileSync(path.join(packDir, 'pack.json'), 'utf8')));
const svg = fs.readFileSync(path.join(packDir, pack.file), 'utf8');
const art = moonArtFromSvg(svg);
const sha = createHash('sha256').update(svg).digest('hex');
return [
'/* Generated by scripts/generate-moon-assets.mjs. DO NOT EDIT. */',
`// Designer pack assets/moon/${pack.pack_id}-${pack.pack_version} (#661). Lazy: imported only by`,
'// moon-runtime.ts, never by the initial View graph (C7).',
'// A static Lit template: no directive, no binding, nothing parsed at runtime but the art.',
"import { svg } from 'lit';",
`export const MOON_PACK = ${JSON.stringify(`${pack.pack_id}-${pack.pack_version}`)};`,
`export const MOON_ART_SHA256 = ${JSON.stringify(sha)};`,
`export const MOON_ART = svg\`${art}\`;`,
'',
].join('\n');
}
if (fileURLToPath(import.meta.url) === path.resolve(process.argv[1] || '')) {
const next = generateMoonModule();
if (process.argv.includes('--check')) {
const current = fs.existsSync(OUT) ? fs.readFileSync(OUT, 'utf8') : '';
if (current !== next) {
console.error('src/moon-art.generated.ts is stale: run node scripts/generate-moon-assets.mjs');
process.exit(1);
}
console.log('moon art is up to date');
} else {
fs.writeFileSync(OUT, next);
console.log(`wrote ${path.relative(ROOT, OUT)}`);
}
}
+1 -1
View File
@@ -4,5 +4,5 @@
"hostRefs": 4880,
"portPrivates": 94,
"harnessPrivates": 100,
"bundleBytes": 2583806
"bundleBytes": 2616267
}
+106 -2
View File
@@ -5432,8 +5432,8 @@ const MUTANT_DEFINITIONS = [
+ 'правка пакета не выбирает frontend и не запускает сверку generated-модулей (#671)',
patches: [{
file: 'scripts/check-inputs.mjs',
find: " roots: [...BUILD_INPUTS, 'assets/furniture/**', 'test/**',",
replace: " roots: [...BUILD_INPUTS, 'test/**', // mutant: furniture package is not a frontend input",
find: " roots: [...BUILD_INPUTS, 'assets/furniture/**', 'assets/moon/**', 'test/**',",
replace: " roots: [...BUILD_INPUTS, 'assets/moon/**', 'test/**', // mutant: furniture package is not a frontend input",
}],
},
{
@@ -6214,6 +6214,110 @@ const MUTANT_DEFINITIONS = [
replace: ' kind: \'device\', core, presentation,',
}],
},
{
id: 'moon-threshold-off',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test --test-name-pattern="#661" test/moon.test.mjs',
because: '#661 AC2: the moon keeps the window rays\' 3° horizon; at 2.9° it is not shown',
patches: [{
file: 'src/moon.ts',
find: " return phase !== 'day' && altitude >= MOON_ELEVATION_MIN && fraction >= MOON_MIN_ILLUMINATION;",
replace: " return phase !== 'day' && altitude >= 0 && fraction >= MOON_MIN_ILLUMINATION;",
}],
},
{
id: 'moon-new-moon-shown',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test --test-name-pattern="#661" test/moon.test.mjs',
because: '#661 AC2: below 3 % illumination (around new moon) there is no moon',
patches: [{
file: 'src/moon.ts',
find: " return phase !== 'day' && altitude >= MOON_ELEVATION_MIN && fraction >= MOON_MIN_ILLUMINATION;",
replace: " return phase !== 'day' && altitude >= MOON_ELEVATION_MIN;",
}],
},
{
id: 'moon-day-visible',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test --test-name-pattern="#661" test/moon.test.mjs',
because: '#661 AC2: the day phase shows no moon, however high it stands',
patches: [{
file: 'src/moon.ts',
find: " return phase !== 'day' && altitude >= MOON_ELEVATION_MIN && fraction >= MOON_MIN_ILLUMINATION;",
replace: " return altitude >= MOON_ELEVATION_MIN && fraction >= MOON_MIN_ILLUMINATION;",
}],
},
{
id: 'moon-parallax-off',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test --test-name-pattern="#661" test/moon.test.mjs',
because: '#661 AC1: without the topocentric parallax the altitude by the horizon is off by '
+ 'up to 2° — the 2026-10-31 Moscow point leaves the 1.5° tolerance',
patches: [{
file: 'src/moon.ts',
find: ' const altitude = geocentric - parallax * Math.cos(geocentric);',
replace: ' const altitude = geocentric;',
}],
},
{
id: 'moon-lit-side-flips',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test --test-name-pattern="#661" test/moon.test.mjs',
because: '#661 AC3 (owner 2026-09-29): the lit side is always the left one, in both hemispheres',
patches: [{
file: 'src/moon.ts',
find: ' return `M${c} 0A${c} ${c} 0 0 0 ${c} ${MOON_BOX}L${c} ${bottom}${terminator}L${c} 0Z`;',
replace: ' return `M${c} 0A${c} ${c} 0 0 1 ${c} ${MOON_BOX}L${c} ${bottom}${terminator}L${c} 0Z`;',
}],
},
{
id: 'moon-tick-renders-every-time',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test --test-name-pattern="#661" test/moon.test.mjs',
because: '#661 AC4: a 30 s tick over an unchanged moon must not cost a render',
patches: [{
file: 'src/moon-runtime.ts',
find: ' if (key === watch.key) return;\n',
replace: '',
}],
},
{
id: 'moon-lazy-gate-off',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test --test-name-pattern="#661" test/moon.test.mjs',
because: '#661 C7: the moon chunk is not loaded by day — the initial View graph pays only the gate',
patches: [{
file: 'src/moon-gate.ts',
find: " if (!runtime && state.phase !== 'day' && Date.now() >= nextAttempt) {",
replace: ' if (!runtime && Date.now() >= nextAttempt) {',
}],
},
{
id: 'moon-over-paper',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test --test-name-pattern="#661" test/moon.test.mjs',
because: '#661 C6 / owner decision 7: the moon covers only the background — it is the last child '
+ 'of the environment and never stacks by itself; taken out and raised, it would paint over the plan',
patches: [{
file: 'src/day-cycle-render.ts',
find: ' })}${moon}\n </div>`;',
replace: ' })}\n </div>${moon}`;',
}, {
file: 'src/moon-runtime.ts',
find: " + '.hp-moon{--hp-moon-box:min(200px,25cqmin);position:absolute;'",
replace: " + '.hp-moon{--hp-moon-box:min(200px,25cqmin);position:absolute;z-index:2;'",
}],
},
{
id: 'moon-validation-accepts-anything',
guard: 'python3 -m pytest tests_backend/test_settings_moon.py -q -p no:cacheprovider',
because: '#661 AC9: the backend accepts only a boolean moon',
patches: [{
file: 'custom_components/houseplan/validation.py',
find: ' vol.Optional("moon"): bool,\n',
replace: ' vol.Optional("moon"): object,\n',
}],
},
{
id: 'wall-isolated-extra-discarded',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+11
View File
@@ -27,6 +27,17 @@
/** @type {SmokeLink[]} */
export const SMOKE_LINKS = [
{
symbols: [
'moonLayer', 'renderMoon', 'moonTick', 'moonView', 'moonShownAt', 'moonPhasePath',
'moonPosition', 'moonIllumination', 'moonFingerprint', 'renderDayCycleEnvironment',
],
smokes: ['smoke_moon.mjs', 'smoke_daycycle_layer_budget.mjs'],
because: '#661: the moon is observed only as `.hp-moon` in the production bundle — the lazy '
+ 'chunk arriving at night, the 30 s ticker and state updates carrying it through 3° with the '
+ '2 s fade, the plan painting over it pixel for pixel, and no extra composited layer (CDP); '
+ 'no smoke names the functions',
},
{
symbols: ['memoIsoLightFloorRooms', 'IsoFirstFrameState', 'isoPaperContext'],
smokes: ['smoke_iso_first_frame.mjs'],