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:
Claude
2026-09-28 17:51:06 +03:00
parent c1e05e465b
commit 600330e187
18 changed files with 538 additions and 121 deletions
+64
View File
@@ -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(', ')), []);
});
+40
View File
@@ -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');
});