mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-28 19:01:34 +00:00
fix(daycycle): stage-sized plan layers without a frozen raster; revert #685 hatch
With the day/night background the plan went blurry after zooming from 100 %
(sharp when the page was opened at 800 %) and navigating a strongly zoomed
plan flashed the page white. Both came from #582's composition, not from the
wall hatch that #685 replaced:
- `.stage.daycycle.hp-safe-daycycle-outline .plan-svg` promoted the scene
with `will-change: transform`; Chromium freezes the raster scale of such a
layer, so the 100 % raster was shown stretched. The explicit layer #582
needs is now `will-change: opacity` (re-rasters at the current scale).
- The filtered outline had `overflow: visible` and a gesture exposed every
scene (#544) without bound, so the promoted layers grew with zoom squared
(CDP LayerTree, ~460 %: plan-svg 15.9x, outline 13.2x the stage; 39x after
navigating at 800 %). The full card clips its outline to its box and marks
it data-hp-live-overflow="clip" (never exposed); the live viewport bounds
every other exposure with an inline clip-path: inset(-25%) that leaves
with it, so idle DOM stays byte-identical (#531).
Owner-verified in Chrome 152 (built-in browser, DPR 2): sharp after 100 ->
800 %, no white flashes after reloading at 800 %.
Owner decision: #685's analytic gradient is reverted (13af1d5e), the single
<pattern> is back at every zoom; its close-up golden scenes stay and check
the pattern, its terminal-frame smoke checks the pattern.
Witnesses: demo/smoke_daycycle_zoom_layers.mjs (800 % x DPR 2: layers vs
stage, the hint, reload path, button/wheel/pinch); #582/#532 smokes now pin
the opacity hint; test/live-viewport.test.mjs (bounded exposure, clipped
scene); test/daycycle-layers.test.mjs (cascade). Four Node-guarded mutants.
Issue: #689
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:
@@ -0,0 +1,64 @@
|
||||
// #689: day/night composition must not freeze the plan raster nor grow with
|
||||
// zoom². The browser proof lives in demo/smoke_static_zoom_sharpness.mjs and
|
||||
// demo/smoke_daycycle_layer_budget.mjs; this suite pins the cascade without
|
||||
// Chromium so the mutants that guard it stay off the capped browser
|
||||
// inventory (#659).
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { planStyles } from '../test-build/styles.js';
|
||||
|
||||
/** Top-level and @media rules of one stylesheet as [selectors[], decls{}]. */
|
||||
const rulesOf = (cssText) => {
|
||||
const text = cssText.replace(/\/\*[\s\S]*?\*\//g, '');
|
||||
const out = [];
|
||||
const walk = (chunk) => {
|
||||
let i = 0;
|
||||
while (i < chunk.length) {
|
||||
const open = chunk.indexOf('{', i);
|
||||
if (open === -1) break;
|
||||
const header = chunk.slice(i, open).replace(/\s+/g, ' ').trim();
|
||||
let depth = 1, j = open + 1;
|
||||
while (j < chunk.length && depth > 0) {
|
||||
if (chunk[j] === '{') depth++;
|
||||
else if (chunk[j] === '}') depth--;
|
||||
j++;
|
||||
}
|
||||
const body = chunk.slice(open + 1, j - 1);
|
||||
if (header.startsWith('@media') || header.startsWith('@supports')) walk(body);
|
||||
else if (!header.startsWith('@')) {
|
||||
const decls = {};
|
||||
for (const part of body.split(';')) {
|
||||
const colon = part.indexOf(':');
|
||||
if (colon > 0) decls[part.slice(0, colon).trim()] = part.slice(colon + 1).trim();
|
||||
}
|
||||
out.push([header.split(',').map((s) => s.trim()), decls]);
|
||||
}
|
||||
i = j;
|
||||
}
|
||||
};
|
||||
walk(text);
|
||||
return out;
|
||||
};
|
||||
|
||||
const RULES = rulesOf(planStyles.cssText);
|
||||
const touching = (predicate) => RULES.filter(([sels]) => sels.some(predicate));
|
||||
|
||||
test('#689 K1: no plan-svg rule carries a will-change: transform hint', () => {
|
||||
const hinted = touching((s) => /\.plan-svg\b/.test(s))
|
||||
.filter(([, d]) => /\btransform\b/.test(d['will-change'] || ''));
|
||||
assert.deepEqual(hinted.map(([sels]) => sels.join(', ')), []);
|
||||
});
|
||||
|
||||
test('#689 K1: the safe day/night scene keeps an explicit layer via an opacity hint', () => {
|
||||
const decls = RULES.filter(([sels]) => sels.includes('.stage.daycycle.hp-safe-daycycle-outline .plan-svg'))
|
||||
.map(([, d]) => d);
|
||||
assert.ok(decls.some((d) => d['will-change'] === 'opacity'), 'the #582 explicit layer must stay');
|
||||
});
|
||||
|
||||
test('#689 K3: the full card clips its filtered outline to its own box', () => {
|
||||
const clip = RULES.filter(([sels]) => sels.includes('.stage .hp-paper-outline-svg')).map(([, d]) => d);
|
||||
assert.ok(clip.some((d) => d.overflow === 'hidden'), '.stage .hp-paper-outline-svg must be overflow: hidden');
|
||||
const opened = touching((s) => /^\.stage\b/.test(s) && /\.hp-paper-outline-svg\b/.test(s))
|
||||
.filter(([, d]) => d.overflow === 'visible');
|
||||
assert.deepEqual(opened.map(([sels]) => sels.join(', ')), []);
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
LIVE_SCENE_EXPOSURE_CLIP,
|
||||
isIdentityLiveLayerProjection,
|
||||
liveLayerProjection,
|
||||
liveViewBoxText,
|
||||
@@ -234,3 +235,42 @@ test('#531 изометрия: камера и пол проецируются
|
||||
assert.equal(root.camera.style.transform, undefined);
|
||||
assert.equal(root.floor.style.transform, undefined);
|
||||
});
|
||||
|
||||
// #689. Unbounded `overflow: visible` let a projected scene layer grow with
|
||||
// zoom² (CDP LayerTree, ~460 %: plan-svg 15.9× and the day-cycle outline
|
||||
// 13.2× the stage); at 800 % × DPR 2 that is hundreds of MB of GPU memory
|
||||
// and the page flashed white. The exposure is bounded and leaves with it.
|
||||
test('#689 AC3: exposure is bounded by a clip that leaves together with it', () => {
|
||||
const root = fakeRoot();
|
||||
const painted = frame(0, 0);
|
||||
let anchor = paintLiveViewport(root, painted, painted, null, { now: 0 });
|
||||
anchor = paintLiveViewport(root, painted, frame(10, 0), anchor, { now: 10 });
|
||||
assert.equal(LIVE_SCENE_EXPOSURE_CLIP, 'inset(-25%)');
|
||||
for (const scene of [root.camera, root.cameraPeer, root.floor]) {
|
||||
assert.equal(scene.style.overflow, 'visible', 'the incoming edge is still exposed (#544)');
|
||||
assert.equal(scene.style.clipPath, LIVE_SCENE_EXPOSURE_CLIP, 'but only within the bound');
|
||||
}
|
||||
assert.equal(root.layer.style.clipPath, undefined, 'HTML layers get no scene clip');
|
||||
paintLiveViewport(root, painted, painted, anchor, { now: 20, force: true });
|
||||
for (const scene of [root.camera, root.cameraPeer, root.floor]) {
|
||||
assert.equal(scene.style.overflow, undefined, 'idle DOM keeps no exposure (#531)');
|
||||
assert.equal(scene.style.clipPath, undefined, 'idle DOM keeps no clip (#531)');
|
||||
assert.equal(scene.style.transform, undefined);
|
||||
assert.ok(scene.styleRemovals.includes('clip-path'));
|
||||
}
|
||||
});
|
||||
|
||||
test('#689 AC3: a scene marked clip is projected but never exposed', () => {
|
||||
const root = fakeRoot();
|
||||
root.floor.attrs['data-hp-live-overflow'] = 'clip';
|
||||
const painted = frame(0, 0);
|
||||
let anchor = paintLiveViewport(root, painted, painted, null, { now: 0, keepSceneLayer: true });
|
||||
anchor = paintLiveViewport(root, painted, frame(10, 0), anchor, { now: 10, keepSceneLayer: true });
|
||||
assert.match(root.floor.style.transform, /^translate\(-1%,0%\) scale\(1,1\)$/, 'it still follows the gesture');
|
||||
assert.equal(root.floor.style.overflow, undefined, 'the filtered outline is never opened');
|
||||
assert.equal(root.floor.style.clipPath, undefined);
|
||||
assert.equal(root.camera.style.clipPath, LIVE_SCENE_EXPOSURE_CLIP, 'unmarked scenes are still exposed');
|
||||
paintLiveViewport(root, painted, painted, anchor, { now: 20, force: true });
|
||||
assert.equal(root.floor.style.transform, undefined);
|
||||
assert.equal(root.floor.styleRemovals.includes('overflow'), false, 'nothing to remove, nothing written');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user