perf(iso): keep the theme paper across floor switches in 2.5D (#739)

In 2.5D with a backdrop image every floor switch rendered the card twice
before the first frame. The paper key of the first-frame state (#654)
held the space id, so each switch cleared the ready paper: the first
render inserted the loading veil, updated() probed the computed card
background with a temporary span and asked for a second full update,
which removed the veil again. The colour itself never changed: it is the
theme card background, and no space sets those variables. Locally this
second pass was about 50 ms per warm switch on the large house.

The paper under a backdrop is now resolved once per theme identity
(dark mode, default and dark default theme, theme) and card mode. The
state keeps the resolved paper of the current theme and mode beside the
current paper, so a floor with a backdrop is ready in prepare() when that
paper is known -- also after a drawn floor in between -- and the switch
renders once: no veil, no probe, no second update. A drawn plan keeps its
white paper without the DOM. Any change of the theme identity or the
mode, also one made in Flat or in an editor, drops the kept paper, so the
first backdrop floor after load, a theme change and a trip to an editor
take the #654 path unchanged. isoPaperContext still takes the floor; it
deliberately leaves it out of the identity.

Witnesses: the #739 unit test is red on dev at "a floor switch shows no
veil" and on a key-only variant (space dropped, no theme cache) at
"drawn -> backdrop keeps the known theme paper"; the new
smoke_iso_floor_switch is red on dev (2 updates, 1 colour probe and a
veil insertion in every click task). The iso-paper-resolved-per-floor
mutant puts the floor back into the theme identity.

Issue: #739
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
This commit is contained in:
Claude
2026-10-01 10:06:38 +00:00
committed by claude[bot]
parent 37238f3990
commit 9324d81b5f
6 changed files with 214 additions and 12 deletions
+85
View File
@@ -0,0 +1,85 @@
// #739: in 2.5D a floor switch between floors with a backdrop image keeps the
// known theme paper. The card renders once per switch: no second update pass,
// no new computed-colour probe and no first-frame veil (#654) in the click's
// task, and the light floors are the ones the floor had on its first show.
// The cold path (first paper after load, theme change, chunk failure) stays
// with smoke_iso_first_frame.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 1280, height: 800 }, 1, [], { reducedMotion: 'reduce' });
const out = await page.evaluate(async () => {
const card = window.__card;
const hp = window.__hpTest;
const root = card.renderRoot;
const stage = () => root.querySelector('.stage');
const frames = () => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
const task = () => new Promise((done) => setTimeout(done, 0));
// A raised tile and its floor shadow both carry the class; the shadow names its owner.
const lightDevices = () => [...root.querySelectorAll('.dev.iso-floor-light')]
.map((node) => node.getAttribute('data-shadow-of')
? `shadow:${node.getAttribute('data-shadow-of')}`
: node.closest('[data-id]')?.getAttribute('data-id') || '?')
.sort().join(',');
const result = {};
document.documentElement.style.setProperty('--ha-card-background', '#fff');
await hp.switchSpace('f1');
await hp.setVolumetricView(true);
for (let guard = 0; guard < 120 && stage()?.dataset.hpIsoReadiness !== 'ready'; guard++) await frames();
await hp.settled();
await frames();
const tabs = [...root.querySelectorAll('[data-hp="space-tab"]')].map((tab) => tab.getAttribute('data-id'));
result.demoHasTwoBackdropFloors = tabs.includes('f1') && tabs.includes('garden')
&& ['f1', 'garden'].every((id) => !!card._model.find((space) => space.id === id)?.bg);
result.isoReadyWithResolvedPaper = !!stage()?.classList.contains('projection-iso')
&& stage()?.dataset.hpIsoReadiness === 'ready' && !root.querySelector('.bootveil:not(.off)');
let updates = 0;
const update = card.update.bind(card);
card.update = (changed) => { updates += 1; return update(changed); };
let paperProbes = 0;
const cssColor = card._cssColor.bind(card);
card._cssColor = (...args) => { paperProbes += 1; return cssColor(...args); }; // private-ok: счётчик проходов и поиска бумаги (#739)
let veils = 0;
const observer = new MutationObserver((records) => {
for (const record of records) for (const node of record.addedNodes) {
if (node.nodeType === 1 && (node.matches('.bootveil') || node.querySelector('.bootveil'))) veils += 1;
}
});
observer.observe(root, { childList: true, subtree: true });
const firstShow = new Map([['f1', lightDevices()]]);
const switches = [];
for (const id of ['garden', 'f1', 'garden', 'f1', 'garden', 'f1']) {
updates = 0;
paperProbes = 0;
veils = 0;
root.querySelector(`[data-hp="space-tab"][data-id="${id}"]`)?.click();
await card.updateComplete;
await task();
const inTask = { updates, paperProbes, veils };
await frames();
await hp.settled();
const light = lightDevices();
if (!firstShow.has(id)) firstShow.set(id, light);
switches.push({
id, ...inTask,
active: root.querySelector('[data-hp="space-tab"][aria-current="page"]')?.getAttribute('data-id'),
readiness: stage()?.dataset.hpIsoReadiness || '',
light, sameLight: light === firstShow.get(id),
});
}
observer.disconnect();
result.switches = switches;
result.everySwitchReachesItsFloor = switches.every((row) => row.active === row.id);
result.everySwitchRendersOnce = switches.every((row) => row.updates === 1);
result.noSwitchProbesThePaperAgain = switches.every((row) => row.paperProbes === 0);
result.noSwitchInsertsTheVeil = switches.every((row) => row.veils === 0);
result.everySwitchEndsReady = switches.every((row) => row.readiness === 'ready');
result.lightFloorsMatchFirstShow = switches.every((row) => row.sameLight)
&& [...firstShow.values()].some((light) => light.length > 0);
return result;
});
const { switches, ...checks } = out;
checkAll(checks);
await finish(browser, { ...checks, switches });
+7 -1
View File
@@ -35,7 +35,13 @@ change the saved setting. Flat View and every editor do not wait for the 2.5D
runtime. Paper colour is resolved after the DOM commit (white for a drawn plan,
the theme card background under an image plan) and the resulting light-floor
set is reused until the paper, resolved room fills or room membership changes
([#654](https://github.com/Matysh/houseplan-card/issues/654)).
([#654](https://github.com/Matysh/houseplan-card/issues/654)). The theme
card background is resolved once per theme identity (dark mode, default and
dark default theme, theme) and card mode, not per floor: switching between
floors — with an image plan or a drawn one — reuses it, so a floor switch
renders the card once and shows no loading surface; a theme or mode change
resolves it again on the path above
([#739](https://github.com/Matysh/houseplan-card/issues/739)).
There is no toggle on the card and no alpha entry: `iso` is gone from
`LABS_FLAGS`, the header `projection-toggle` and the phone-menu item
+13
View File
@@ -13652,6 +13652,19 @@ const MUTANT_DEFINITIONS = [
replace: " return previous?.key === '__never__'",
}],
},
{
id: 'iso-paper-resolved-per-floor',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test --test-name-pattern="#654|#739" test/iso-stage6.test.mjs',
because: '#739 AC1: the 2.5D paper under a backdrop is the theme card background, a function of '
+ 'the theme and the card mode; keyed by the floor again, every floor switch resolves the '
+ 'computed colour after commit and renders the card twice',
patches: [{
file: 'src/iso-first-frame.ts',
find: ' mode, themes?.darkMode ?? null, themes?.default_theme ?? null,\n',
replace: ' space, mode, themes?.darkMode ?? null, themes?.default_theme ?? null,\n',
}],
},
{
id: 'shutdown-skips-deferred-store-flush',
guard: 'node scripts/backend-test-guard.mjs '
+8 -1
View File
@@ -56,13 +56,20 @@ export const SMOKE_LINKS = [
+ 'no smoke names the functions',
},
{
symbols: ['memoIsoLightFloorRooms', 'IsoFirstFrameState', 'isoPaperContext'],
symbols: ['memoIsoLightFloorRooms', 'IsoFirstFrameState', 'isoPaperContext', 'commitPaper', 'themePaper'],
smokes: ['smoke_iso_first_frame.mjs'],
because: '#654: the production-bundle witness delays and rejects the lazy 2.5D chunk, then '
+ 'samples every animation frame in normal, kiosk and Flat views; no pure import can prove '
+ 'that the loading veil hides the transient Flat render or that the first visible marker '
+ 'already uses the settled paper-dependent floor class',
},
{
symbols: ['IsoFirstFrameState', 'isoPaperContext', 'commitPaper', 'themePaper'],
smokes: ['smoke_iso_floor_switch.mjs'],
because: '#739: a warm floor switch between backdrop floors in 2.5D is observed only in the '
+ 'production bundle — one card update, no computed-colour probe and no veil insertion in '
+ 'the click task, the light floors of the first show; no smoke names the paper state',
},
{
symbols: ['baseStyles', '_containerOwnedHeight', 'measuredCardHeaderHeight',
'settleSoftStageLayout'],
+29 -10
View File
@@ -4,6 +4,8 @@ import {
export interface IsoPaperContext {
readonly key: string;
/** Identity of the theme card background that an image-backed floor shows. */
readonly theme: string;
readonly imagePlan: boolean;
}
@@ -14,17 +16,20 @@ interface ThemeIdentity {
readonly theme?: unknown;
}
/** All non-DOM inputs that can change the computed plan-paper colour. */
/**
* All non-DOM inputs that can change the computed plan-paper colour. The floor
* the caller passes is deliberately not one of them (#739): no space sets the
* card background variables, so a floor switch keeps the paper of the theme
* and mode instead of resolving it again with a second update pass.
*/
export function isoPaperContext(
space: string, mode: string, imagePlan: boolean, themes?: ThemeIdentity,
): IsoPaperContext {
return {
imagePlan,
key: JSON.stringify([
space, mode, imagePlan, themes?.darkMode ?? null, themes?.default_theme ?? null,
themes?.default_dark_theme ?? null, themes?.theme ?? null,
]),
};
const theme = JSON.stringify([
mode, themes?.darkMode ?? null, themes?.default_theme ?? null,
themes?.default_dark_theme ?? null, themes?.theme ?? null,
]);
return { imagePlan, theme, key: JSON.stringify([imagePlan, theme]) };
}
/**
@@ -37,15 +42,24 @@ export class IsoFirstFrameState {
private paperContext = '';
private paperReady = false;
private floorMemo: IsoLightFloorMemo | null = null;
/** #739: the last resolved theme paper, kept across drawn and image floors. */
private themePaper: { readonly theme: string; readonly rgb: Rgb } | null = null;
public runtimeLoading(): void { this.runtimeFailed = false; }
public runtimeReady(): void { this.runtimeFailed = false; }
public runtimeFailure(): void { this.runtimeFailed = true; }
public prepare(desired: 'flat' | 'iso', context: IsoPaperContext): void {
// Only the current theme and mode are kept: any change of either — also one
// made in Flat or in an editor — resolves the paper again on the #654 path.
if (this.themePaper && this.themePaper.theme !== context.theme) this.themePaper = null;
if (context.key === this.paperContext) return;
this.paperReady = false;
if (desired === 'iso' && !context.imagePlan) this.commitPaper(context.key, [255, 255, 255]);
if (desired !== 'iso') return;
if (!context.imagePlan) this.commitPaper(context.key, [255, 255, 255]);
// #739: a known theme paper is ready before the first render of the floor:
// no veil, no resolver, no second update pass.
else if (this.themePaper?.theme === context.theme) this.commitPaper(context.key, this.themePaper.rgb);
}
/** Returns true when a committed render must consume a newly resolved paper. */
@@ -53,7 +67,12 @@ export class IsoFirstFrameState {
desired: 'flat' | 'iso', context: IsoPaperContext, resolveThemePaper: () => Rgb,
): boolean {
if (desired !== 'iso' || this.paperReady && context.key === this.paperContext) return false;
this.commitPaper(context.key, context.imagePlan ? resolveThemePaper() : [255, 255, 255]);
let rgb: Rgb = [255, 255, 255];
if (context.imagePlan) {
rgb = resolveThemePaper();
this.themePaper = { theme: context.theme, rgb };
}
this.commitPaper(context.key, rgb);
return true;
}
+72
View File
@@ -110,6 +110,78 @@ test('#654 a drawn plan has deterministic white paper before its first render',
}), false);
});
test('#739 AC1 a known theme paper survives floor switches; theme and mode resolve it again', () => {
const state = new IsoFirstFrameState();
const light = { darkMode: false, theme: 'light' };
const calls = [];
const resolver = (label, rgb) => () => { calls.push(label); return rgb; };
// An unfilled room is light on white paper and not light on a dark card
// background, so the classification shows which paper the state holds.
const fills = new Map([['unfilled', null]]);
const themePaper = [30, 30, 30];
// (а) floor A with a backdrop: the #654 cold path — veil until the paper is resolved.
const a = isoPaperContext('A', 'view', true, light);
state.prepare('iso', a);
assert.equal(state.pending('iso', true), true, 'the first image floor waits for its paper');
assert.equal(state.sync('iso', a, resolver('A', themePaper)), true);
assert.deepEqual(calls, ['A']);
assert.equal(state.pending('iso', true), false);
assert.deepEqual([...state.lightFloors(fills)], []);
// (б) floor B with a backdrop, same theme and mode: ready before its first render.
const b = isoPaperContext('B', 'view', true, light);
state.prepare('iso', b);
assert.equal(state.pending('iso', true), false, 'a floor switch shows no veil');
assert.equal(state.readiness('iso', 'iso', true), 'ready');
assert.equal(state.sync('iso', b, resolver('B', [255, 255, 255])), false,
'no second update pass for a floor switch');
assert.deepEqual(calls, ['A'], 'a floor switch does not ask the DOM again');
// (в) floor C with a drawn plan: white paper without the DOM.
const c = isoPaperContext('C', 'view', false, light);
state.prepare('iso', c);
assert.equal(state.pending('iso', true), false);
assert.equal(state.sync('iso', c, resolver('C', themePaper)), false);
assert.deepEqual(calls, ['A']);
assert.deepEqual([...state.lightFloors(fills)], ['unfilled'], 'drawn plans stay on white paper');
// (г) back to B: the theme paper is still known and the floor classifies on it.
state.prepare('iso', b);
assert.equal(state.pending('iso', true), false, 'drawn → backdrop keeps the known theme paper');
assert.equal(state.sync('iso', b, resolver('B again', [255, 255, 255])), false);
assert.deepEqual(calls, ['A']);
assert.deepEqual([...state.lightFloors(fills)], [], 'the theme paper, not white, classifies B');
// (д) a theme change is the #654 path again: veil, one resolve.
const dark = isoPaperContext('B', 'view', true, { darkMode: true, theme: 'dark' });
state.prepare('iso', dark);
assert.equal(state.pending('iso', true), true, 'a theme change hides the stale classification');
assert.equal(state.sync('iso', dark, resolver('dark', [250, 250, 250])), true);
assert.deepEqual(calls, ['A', 'dark']);
assert.deepEqual([...state.lightFloors(fills)], ['unfilled']);
// (е) another card mode resolves once more.
const plan = isoPaperContext('B', 'plan', true, { darkMode: true, theme: 'dark' });
state.prepare('iso', plan);
assert.equal(state.pending('iso', true), true);
assert.equal(state.sync('iso', plan, resolver('mode', [250, 250, 250])), true);
assert.deepEqual(calls, ['A', 'dark', 'mode']);
// A trip to an editor and back is a mode change too, whatever floors it passes.
const drawnView = isoPaperContext('C', 'view', false, light);
const imageView = isoPaperContext('B', 'view', true, light);
state.prepare('iso', imageView);
state.sync('iso', imageView, resolver('view', themePaper));
state.prepare('iso', drawnView);
assert.equal(state.pending('iso', true), false);
state.prepare('flat', isoPaperContext('B', 'plan', true, light));
state.prepare('iso', imageView);
assert.equal(state.pending('iso', true), true, 'back from the editor the paper is resolved again');
assert.equal(state.sync('iso', imageView, resolver('back', themePaper)), true);
assert.deepEqual(calls, ['A', 'dark', 'mode', 'view', 'back']);
});
test('#649 п.1 tile numbers are the lab units / 80 and the scale 1.12', () => {
assert.equal(ISO_ICON_SCALE, 1.12);
assert.deepEqual({ ...ISO_TILE }, {