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
+4 -9
View File
@@ -1400,8 +1400,7 @@ export async function prepareGoldenScenario(page, scenario) {
const exactViewBox = `${view.x} ${view.y} ${view.w} ${view.h}`;
const scenes = [...root.querySelectorAll('[data-hp-live-viewbox]')];
const layers = [...root.querySelectorAll('[data-hp-live-layer="camera"]')];
const gradient = root.querySelector('linearGradient#hp-wall-hatch');
const stops = [...(gradient?.querySelectorAll('stop') || [])];
const pattern = root.querySelector('defs > pattern#hp-wall-hatch');
const visibleKinds = new Set([...root.querySelectorAll('.opening[data-kind]')]
.map((node) => node.getAttribute('data-kind')));
const modelKinds = new Set((card._openingsR || []).map((opening) => opening.type));
@@ -1410,15 +1409,11 @@ export async function prepareGoldenScenario(page, scenario) {
&& scenes.every((node) => !getComputedStyle(node).willChange
|| getComputedStyle(node).willChange === 'auto')
&& layers.every((node) => ['', 'none'].includes(getComputedStyle(node).transform));
const analytic = !!gradient
&& gradient.getAttribute('gradientUnits') === 'userSpaceOnUse'
&& gradient.getAttribute('spreadMethod') === 'repeat'
&& stops.length === 4
&& stops[1].getAttribute('stop-opacity') === '0'
&& stops[2].getAttribute('stop-opacity') === '0';
// #689: the historical pattern at every scale (#685's gradient reverted).
const hatch = !!pattern && !root.querySelector('linearGradient#hp-wall-hatch');
const openings = ['door', 'window', 'gate'].every((kind) => visibleKinds.has(kind))
&& modelKinds.has('passage');
if (!settled || !analytic || !openings
if (!settled || !hatch || !openings
|| Math.abs(card._zoom - scenario.zoom) > 1e-9) {
throw new Error(`static hatch sharpness contract failed: ${scenario.id}`);
}
+4 -1
View File
@@ -289,7 +289,10 @@ checks.terminalFrameKeepsBudget = settled.layers.every(
checks.settledPlanLayerIsExplicitAndBounded = !!settledPlanLayer
&& settledPlanLayer.width <= active.stage.width * 1.5 + 64
&& settledPlanLayer.height <= active.stage.height * 1.5 + 64
&& settledPlanLayer.reasons.some((reason) => reason.includes('will-change: transform'))
// #689: explicit, but through the opacity hint — the transform hint
// freezes the raster scale and the plan stays blurry after a zoom.
&& settledPlanLayer.reasons.some((reason) => reason.includes('will-change: opacity'))
&& settledPlanLayer.reasons.every((reason) => !reason.includes('will-change: transform'))
&& settledPlanLayer.reasons.every((reason) => !reason.includes('Overlaps other composited content'));
checks.capturedPresentedPinchFrames = frameMetrics.length >= 3;
checks.presentedFramesHaveNoWhiteTile = frameMetrics.every(
+3 -1
View File
@@ -166,7 +166,9 @@ out.dayCycleOutlineFiltered = /drop-shadow/.test(safeDayStyle?.filter || '');
out.dayCycleOutlinePromoted = /filter/.test(safeDayStyle?.willChange || '');
out.dayCyclePaperStaysUnfiltered = (safeDayStyle?.paperFilter || 'none') === 'none'
&& !/filter/.test(safeDayStyle?.paperWillChange || '');
out.safePlanLayerIsExplicit = /transform/.test(safeDayStyle?.planWillChange || '');
// #689: the explicit scene layer is an opacity hint; a transform hint would
// freeze its raster scale after a zoom.
out.safePlanLayerIsExplicit = (safeDayStyle?.planWillChange || '') === 'opacity';
out.rasterTasksObserved = [...staticSamples, ...dayCycleSamples].every((sample) => sample.tasks > 0);
const staticMedianMs = median(staticSamples.map((sample) => sample.ms));
+236
View File
@@ -0,0 +1,236 @@
// #689: with the day/night background the plan layers must stay stage-sized at
// any zoom, and the scene must not keep a will-change: transform hint (its explicit layer is
// an opacity hint). Before
// the fix, CDP LayerTree measured (~460 %, DPR 2) plan-svg at 15.9× and the
// filtered outline at 13.2× the stage during a gesture, the outline at 12.8×
// at rest; at 800 % that is hundreds of MB of GPU memory and navigation
// flashed white. The same will-change: transform hint froze the raster scale:
// zooming from 100 % showed the 100 % raster stretched. Headless Chromium can
// see neither the blur nor the flashes (owner-verified in real Chrome), so this
// witness measures the state that causes them: layer sizes and the hint.
import { launch, checkAll, finish } from './serve.mjs';
import { installHpTestOnPage } from './helpers/hp-test.mjs';
const { page, browser } = await launch({ width: 820, height: 760 }, 2);
const cdp = await page.context().newCDPSession(page);
let latestLayers = [];
cdp.on('LayerTree.layerTreeDidChange', ({ layers }) => { latestLayers = layers; });
await cdp.send('LayerTree.enable');
const frames = [];
cdp.on('Page.screencastFrame', (event) => {
if (frames.length < 120) frames.push(event.data);
void cdp.send('Page.screencastFrameAck', { sessionId: event.sessionId });
});
const settle = async () => {
await page.waitForTimeout(1200);
await page.evaluate(async () => {
await window.__card.updateComplete;
await new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
});
};
const dayNight = async () => {
// A dark custom room fill keeps the near-white frame sample meaningful at
// 800 %, where the demo's own light paper would otherwise fill the stage.
await page.evaluate(() => window.__hpTest.setServerConfig((cfg) => ({
...cfg,
settings: { ...(cfg.settings || {}), bg_mode: 'daynight', glow_enabled: false },
spaces: cfg.spaces.map((space) => ({
...space, settings: { ...(space.settings || {}), fill_mode: 'custom', custom_fill: '#435468' },
})),
})));
await page.evaluate(async () => {
const card = window.__card;
card.hass = { ...card.hass, states: { ...card.hass.states, 'sun.sun': {
entity_id: 'sun.sun', state: 'above_horizon',
attributes: { azimuth: 180, elevation: 40, rising: false },
} } };
await card.updateComplete;
});
await settle();
};
const stageBox = () => page.evaluate(() => {
const rect = window.__card.renderRoot.querySelector('.stage').getBoundingClientRect();
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
});
const snapshot = async () => {
await page.waitForTimeout(80);
const state = await page.evaluate(() => {
const root = window.__card.renderRoot;
const stage = root.querySelector('.stage');
const plan = root.querySelector('.plan-svg');
const outline = root.querySelector('.hp-paper-outline-svg');
return {
zoom: window.__card._zoom,
daycycle: stage.classList.contains('daycycle'),
safe: stage.classList.contains('hp-safe-daycycle-outline'),
planWillChange: getComputedStyle(plan).willChange,
planTransform: getComputedStyle(plan).transform,
outlineOverflow: outline ? getComputedStyle(outline).overflow : null,
outlineInline: outline ? `${outline.style.overflow}|${outline.style.clipPath}` : null,
sceneClips: [...root.querySelectorAll('[data-hp-live-viewbox]')].map((node) => node.style.clipPath || ''),
};
});
const layers = [];
for (const layer of latestLayers.filter((item) => item.drawsContent)) {
let name = '';
if (layer.backendNodeId) {
try {
const { node } = await cdp.send('DOM.describeNode', { backendNodeId: layer.backendNodeId });
const attrs = node.attributes || [];
const index = attrs.indexOf('class');
name = index >= 0 ? attrs[index + 1] : node.nodeName;
} catch { /* detached between the event and inspection */ }
}
let reasons = [];
try {
({ compositingReasons: reasons } = await cdp.send('LayerTree.compositingReasons', {
layerId: layer.layerId,
}));
} catch { /* retired layers may have no reason record */ }
layers.push({ name, width: layer.width, height: layer.height, reasons });
}
return { state, layers };
};
// Everything the card paints; the page root and the sticky header are not plan.
const planLayers = (snap) => snap.layers.filter((layer) => layer.name
&& layer.name !== '#document' && !/\bhdr\b/.test(layer.name));
const within = (snap, stage, factor) => planLayers(snap).every((layer) =>
layer.width <= stage.width * factor + 240 && layer.height <= stage.height * factor + 240);
const largest = (snap) => planLayers(snap).reduce((max, layer) =>
Math.max(max, layer.width * layer.height), 0);
const wheelAt = async (stage, deltaY, count) => {
// Near a corner: the hover tooltip stays out of the central frame sample.
await page.mouse.move(stage.x + 40, stage.y + 40);
for (let i = 0; i < count; i++) {
await page.mouse.wheel(0, deltaY);
await page.waitForTimeout(24);
}
};
await dayNight();
const stage = await stageBox();
await cdp.send('Page.startScreencast', { format: 'png', everyNthFrame: 2, maxWidth: 820, maxHeight: 760 });
// A: page loaded at 100 %, zoomed to 800 % with the wheel (owner's blur path).
await wheelAt(stage, -240, 30);
const zoomingIn = await snapshot();
await settle();
const zoomedIn = await snapshot();
// The buttons and a pinch settle into the same hint-free scene (AC1).
const clickZoom = (hook) => page.evaluate((hook) =>
window.__card.renderRoot.querySelector(`[data-hp="${hook}"]`).click(), hook);
await clickZoom('zoom-out'); await settle();
await clickZoom('zoom-in'); await settle();
const buttons = await snapshot();
await page.evaluate(() => {
const stage = window.__card.renderRoot.querySelector('.stage');
const rect = stage.getBoundingClientRect();
stage.setPointerCapture = () => {};
stage.releasePointerCapture = () => {};
const emit = (type, id, x) => stage.dispatchEvent(new PointerEvent(type, {
bubbles: true, composed: true, cancelable: true, pointerId: id, pointerType: 'touch',
isPrimary: id === 6891, button: type === 'pointerdown' ? 0 : -1,
buttons: type === 'pointerup' ? 0 : 1, clientX: x, clientY: rect.top + rect.height / 2,
}));
const cx = rect.left + rect.width / 2;
emit('pointerdown', 6891, cx - 120); emit('pointerdown', 6892, cx + 120);
emit('pointermove', 6891, cx - 90); emit('pointermove', 6892, cx + 90);
emit('pointerup', 6891, cx - 90); emit('pointerup', 6892, cx + 90);
});
await settle();
const pinched = await snapshot();
await wheelAt(stage, -240, 12);
await settle();
// B: reload at 800 % (the camera is restored), day/night again, then navigate
// (owner's white-flash path).
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => window.__card?._model?.length > 0, { timeout: 9000 });
await installHpTestOnPage(page);
await dayNight();
const reloaded = await snapshot();
await wheelAt(stage, 240, 3);
const navigating = await snapshot();
await wheelAt(stage, -240, 6);
await settle();
const navigated = await snapshot();
await cdp.send('Page.stopScreencast');
const nearWhite = await page.evaluate(async ({ encoded, stage }) => {
const out = [];
for (const data of encoded) {
const image = await new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = reject;
img.src = `data:image/png;base64,${data}`;
});
const sx = image.width / innerWidth, sy = image.height / innerHeight;
const w = Math.max(1, Math.floor(stage.width * sx)), h = Math.max(1, Math.floor(stage.height * sy));
const canvas = document.createElement('canvas');
canvas.width = w; canvas.height = h;
const context = canvas.getContext('2d', { willReadFrequently: true });
context.drawImage(image, Math.floor(stage.x * sx), Math.floor(stage.y * sy), w, h, 0, 0, w, h);
const px = context.getImageData(0, 0, w, h).data;
let sampled = 0, white = 0;
for (let y = Math.floor(h * 0.25); y < h * 0.75; y += 3) {
for (let x = Math.floor(w * 0.25); x < w * 0.75; x += 3) {
const o = (y * w + x) * 4;
sampled++;
if (px[o] >= 250 && px[o + 1] >= 250 && px[o + 2] >= 250) white++;
}
}
out.push(white / Math.max(1, sampled));
}
return out;
}, { encoded: frames, stage });
const planReasons = (snap) => planLayers(snap).find((layer) => /\bplan-svg\b/.test(layer.name))?.reasons || [];
const out = {
dayNightSafeAt800: zoomedIn.state.daycycle && zoomedIn.state.safe && zoomedIn.state.zoom === 8,
reloadRestores800Untouched: reloaded.state.daycycle && !reloaded.state.safe && reloaded.state.zoom === 8,
// K1: no frozen raster — the explicit layer is a trivial 3D transform.
settledSceneHasNoWillChangeHint: [zoomedIn, buttons, pinched, navigated]
.every((snap) => snap.state.planWillChange === 'opacity'),
buttonAndPinchChangedTheCamera: buttons.state.safe && pinched.state.zoom < 8,
// The scene stays an explicit layer (#582), but no reason is the hint that
// freezes the raster scale, and it is never an implicit overlap layer.
settledSceneLayerIsExplicitWithoutHint: planReasons(zoomedIn).some((reason) => /will-change: opacity/.test(reason))
&& planReasons(zoomedIn).every((reason) => !/will-change: transform/.test(reason)
&& !/Overlaps other composited content/.test(reason)),
// K3: layers stay stage-sized at 800 %.
gestureLayersWithinBudget: within(zoomingIn, stage, 1.5) && within(navigating, stage, 1.5),
settledLayersWithinBudget: [zoomedIn, buttons, pinched, reloaded, navigated]
.every((snap) => within(snap, stage, 1)),
outlineClippedToItsBox: zoomedIn.state.outlineOverflow === 'hidden'
&& navigating.state.outlineInline === '|' && navigated.state.outlineInline === '|',
idleScenesKeepNoClip: [zoomedIn, buttons, pinched, reloaded, navigated].every((snap) => snap.state.sceneClips.every((clip) => clip === '')),
capturedFrames: nearWhite.length >= 3,
// A white/transparent frame covers the stage; white glyphs of 800 % device
// markers stay a few per cent of the central sample.
noWhiteFrames: nearWhite.every((ratio) => ratio < 0.25),
};
console.log(JSON.stringify({
stage,
largest: {
zoomingIn: largest(zoomingIn), zoomedIn: largest(zoomedIn), reloaded: largest(reloaded),
navigating: largest(navigating), navigated: largest(navigated),
},
layers: { zoomingIn: planLayers(zoomingIn), zoomedIn: planLayers(zoomedIn), navigating: planLayers(navigating) },
states: {
zoomingIn: zoomingIn.state, zoomedIn: zoomedIn.state, buttons: buttons.state,
pinched: pinched.state, reloaded: reloaded.state, navigated: navigated.state,
},
whiteMax: Math.max(0, ...nearWhite),
}, null, 1));
checkAll(out);
await cdp.send('LayerTree.disable');
await finish(browser, out);
+9 -14
View File
@@ -1,7 +1,7 @@
// Issue #685: a settled zoom must be a fresh vector frame, and the hatch must
// not be a bitmap pattern which a WebView can keep resampling after the live
// compositor transform is gone. This production-bundle witness owns both the
// analytic SVG paint server and its live-viewport wiring.
// Issue #685: a settled zoom must be a fresh vector frame after every route
// (button, wheel, pinch, every mode). #689 reverted #685's analytic gradient —
// the blur came from a frozen compositor raster, not from the hatch — so the
// wall hatch is again the one historical <pattern> at every scale.
import { createHash } from 'node:crypto';
import { launch, checkAll, finish } from './serve.mjs';
import { prepareGoldenScenario } from './golden/harness.mjs';
@@ -43,8 +43,7 @@ const state = () => page.evaluate(() => {
const exact = `${view.x} ${view.y} ${view.w} ${view.h}`;
const scenes = [...root.querySelectorAll('[data-hp-live-viewbox]')];
const layers = [...root.querySelectorAll('[data-hp-live-layer="camera"]')];
const gradient = root.querySelector('linearGradient#hp-wall-hatch');
const stops = [...(gradient?.querySelectorAll('stop') || [])];
const pattern = root.querySelector('defs > pattern#hp-wall-hatch');
const kinds = new Set([...root.querySelectorAll('.opening[data-kind]')]
.map((node) => node.getAttribute('data-kind')));
const modelKinds = new Set((card._openingsR || []).map((opening) => opening.type));
@@ -55,12 +54,8 @@ const state = () => page.evaluate(() => {
noSceneWillChange: scenes.every((node) => !getComputedStyle(node).willChange
|| getComputedStyle(node).willChange === 'auto'),
noLayerTransform: layers.every((node) => ['', 'none'].includes(getComputedStyle(node).transform)),
analyticHatch: !!gradient
&& gradient.getAttribute('gradientUnits') === 'userSpaceOnUse'
&& gradient.getAttribute('spreadMethod') === 'repeat'
&& stops.length === 4
&& stops[1].getAttribute('stop-opacity') === '0'
&& stops[2].getAttribute('stop-opacity') === '0',
patternHatch: !!pattern && pattern.getAttribute('patternUnits') === 'userSpaceOnUse'
&& !root.querySelector('linearGradient#hp-wall-hatch'),
openingKinds: ['door', 'window', 'gate'].every((kind) => kinds.has(kind))
&& modelKinds.has('passage'),
};
@@ -138,7 +133,7 @@ for (const mode of ['view', 'plan', 'devices', 'decor']) {
}
const terminal = (sample) => sample.exactViewBox && sample.noSceneTransform
&& sample.noSceneWillChange && sample.noLayerTransform && sample.analyticHatch
&& sample.noSceneWillChange && sample.noLayerTransform && sample.patternHatch
&& sample.openingKinds;
const out = {
buttonSettlesAt140: Math.abs(button.zoom - 1.4) < 1e-9 && terminal(button),
@@ -147,7 +142,7 @@ const out = {
sameScaleIsDeterministic: buttonHash === reverseHash,
wheelSettlesAt115: Math.abs(wheel.zoom - 1.15) < 1e-9 && terminal(wheel),
pinchSettlesAt132: Math.abs(pinch.zoom - 1.32) < 1e-9 && terminal(pinch),
allModesUseSettledAnalyticHatch: modes.length === 4 && modes.every(terminal),
allModesUseSettledPatternHatch: modes.length === 4 && modes.every(terminal),
};
checkAll(out);
+24 -51
View File
@@ -1,11 +1,8 @@
// Issue #230: the hatch step is a physical distance, not a coordinate one.
//
// The units own the arithmetic; this smoke owns the wiring — that both renderers
// actually put the computed step into the paint server, that neither of them
// scales it back by zoom, and that the two agree geometrically. The full card
// uses #685's analytic repeating gradient while the non-interactive card keeps
// the historical stroked pattern, so the comparison deliberately normalises
// both representations to the same physical contract.
// actually put the computed step into the pattern, that neither of them scales
// it back by zoom, and that the two agree with each other.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 1000, height: 900 }, 1);
const res = await page.evaluate(async () => {
@@ -16,34 +13,14 @@ const res = await page.evaluate(async () => {
await c.updateComplete;
};
const space = () => c._serverCfg.spaces.find((s) => s.id === c._space);
const paint = (root) => root.querySelector('#hp-wall-hatch');
const read = (server) => {
if (!server) return null;
if (server.localName === 'linearGradient') {
const x1 = Number(server.getAttribute('x1'));
const y1 = Number(server.getAttribute('y1'));
const x2 = Number(server.getAttribute('x2'));
const y2 = Number(server.getAttribute('y2'));
const stops = [...server.querySelectorAll('stop')];
const edge = Number(stops[1]?.getAttribute('offset'));
const step = Math.hypot(x2 - x1, y2 - y1);
const rotation = Number((server.getAttribute('gradientTransform') || '')
.match(/rotate\(([-+0-9.eE]+)/)?.[1] || 0);
return {
step,
stripe: 2 * edge * step,
angle: Math.atan2(y2 - y1, x2 - x1) * 180 / Math.PI + rotation,
origin: `${x1},${y1}`,
};
}
const width = Number(server.getAttribute('width'));
return {
step: width,
stripe: Number(server.querySelector('path')?.getAttribute('stroke-width')),
angle: Number((server.getAttribute('patternTransform') || '').match(/rotate\(([-+0-9.eE]+)/)?.[1]),
origin: '0,0',
};
};
const pattern = (root) => root.querySelector('#hp-wall-hatch');
const read = (p) => p && ({
width: Number(p.getAttribute('width')),
height: Number(p.getAttribute('height')),
transform: p.getAttribute('patternTransform') || '',
stroke: Number(p.querySelector('path')?.getAttribute('stroke-width')),
d: p.querySelector('path')?.getAttribute('d') || '',
});
c._mode = 'plan'; c.requestUpdate(); await settle();
await new Promise((r) => setTimeout(r, 400));
@@ -64,16 +41,16 @@ const res = await page.evaluate(async () => {
out.wallBodyIsRendered = !!c.shadowRoot.querySelector('.wallbody');
// Reference scale: exactly the historical numbers, so old plans do not move.
const atFive = read(paint(c.shadowRoot));
out.referenceStepIsEight = Math.abs((atFive?.step ?? 0) - 8) < 1e-9;
out.referenceStrokeIsTwo = Math.abs((atFive?.stripe ?? 0) - 2) < 1e-9;
out.referenceAngleIsFortyFive = Math.abs((atFive?.angle ?? 0) - 45) < 1e-9;
const atFive = read(pattern(c.shadowRoot));
out.referenceStepIsEight = atFive?.width === 8 && atFive?.height === 8;
out.referenceStrokeIsTwo = atFive?.stroke === 2;
out.noZoomScaleAtReference = !!atFive && !/scale/.test(atFive.transform);
// Zoom must not touch the pattern any more — that is the whole point.
c._applyView(3); c.requestUpdate(); await settle();
const zoomed = read(paint(c.shadowRoot));
c._applyView(3); await settle();
const zoomed = read(pattern(c.shadowRoot));
out.zoomDoesNotChangeThePattern = JSON.stringify(zoomed) === JSON.stringify(atFive);
c._applyView(1); c.requestUpdate(); await settle();
c._applyView(1); await settle();
// A coarse grid: the step follows the centimetres, so it shrinks in units.
space().cell_cm = 25;
@@ -82,10 +59,10 @@ const res = await page.evaluate(async () => {
c._saveConfig();
await new Promise((r) => setTimeout(r, 500));
c.requestUpdate(); await settle();
const atTwentyFive = read(paint(c.shadowRoot));
out.coarseGridShrinksTheStep = Math.abs(atTwentyFive.step - 1.6) < 1e-9;
out.coarseGridScalesTheStroke = Math.abs(atTwentyFive.stripe - 0.4) < 1e-9;
out.coarseGridKeepsTheAngle = Math.abs(atTwentyFive.angle - 45) < 1e-9;
const atTwentyFive = read(pattern(c.shadowRoot));
out.coarseGridShrinksTheStep = Math.abs(atTwentyFive.width - 1.6) < 1e-9;
out.coarseGridScalesTheStroke = Math.abs(atTwentyFive.stroke - 0.4) < 1e-9;
out.coarseGridStripeSpansTheCell = atTwentyFive.d === `M0 0 L0 ${atTwentyFive.width}`;
// The static renderer is the second path that draws a wall body, and it used
// to carry its own hard-coded 8 (spec §8.2, AC12).
@@ -101,14 +78,10 @@ const res = await page.evaluate(async () => {
await new Promise((r) => setTimeout(r, 80));
}
await card.updateComplete;
const staticPattern = read(paint(card.renderRoot));
const staticPattern = read(pattern(card.renderRoot));
out.staticRendererFollowsTheCell = !!staticPattern
&& Math.abs(staticPattern.step - 1.6) < 1e-9;
out.bothRenderersAgree = !!staticPattern
&& Math.abs(staticPattern.step - atTwentyFive.step) < 1e-9
&& Math.abs(staticPattern.stripe - atTwentyFive.stripe) < 1e-9
&& Math.abs(staticPattern.angle - atTwentyFive.angle) < 1e-9
&& staticPattern.origin === atTwentyFive.origin;
&& Math.abs(staticPattern.width - 1.6) < 1e-9;
out.bothRenderersAgree = JSON.stringify(staticPattern) === JSON.stringify(atTwentyFive);
return out;
});
+6 -8
View File
@@ -595,20 +595,18 @@ the last settled Lit frame; both land on the current view, keeping #451's
one-CSS-pixel marker contract on every frame. `.stage` stays the outer clip, but
a transformed scene SVG gets inline `overflow: visible` so rasterised content
covers the incoming edge, even with the pointer held still (#544); HTML layers
never do. From the first live paint to the terminal commit a scene SVG stays in
never do. The exposure is bounded by an inline `clip-path: inset(-25%)`
(`LIVE_SCENE_EXPOSURE_CLIP`) set and removed with it — beyond the 15 % refresh
threshold, but never the whole plan: unbounded, a promoted scene grew with
zoom² and at 800 % × DPR 2 exhausted GPU memory (white frames, #689). A scene
marked `data-hp-live-overflow="clip"` — the filtered day-cycle outline — is
projected but never exposed. From the first live paint to the terminal commit a scene SVG stays in
one compositor lifecycle: a refresh or Lit frame may replace its anchor but never
demote/re-promote it — HA Companion WebView shows that as a blank frame (#579).
The day-cycle paper outline joins these roots before the first camera move; the
static card uses its stage-sized form from the first frame (#582, Key decision 7).
Unchanged values are never rewritten, so idle frames stay byte-identical;
`commitHouseplanViewport` removes the transforms and forces the final `viewBox`.
After the camera leaves its byte-compatible 100% view, the flat wall hatch uses
an analytic user-space repeating gradient rather than a repeated stroked bitmap
tile (#685). Therefore the terminal transform-free SVG is rasterised directly
for its final fractional scale; the live transformed frames may still be
temporarily soft, but the idle frame must not retain a resampled hatch texture.
Door, window and gate strokes share that terminal SVG, and passages remain real
negative wall geometry.
## English and Russian ship whole (#400)
+7
View File
@@ -14,6 +14,13 @@
screen pixels, remains uniform when a stair is resized or rotated, and uses
the same physical value in View, the Plan editor and PDF
([#688](https://github.com/Matysh/houseplan-card/issues/688)).
- With the day/night background, the plan no longer turns blurry after zooming
in: walls, hatching and openings stay as sharp as when the page is opened at
that zoom. Navigating a strongly zoomed plan no longer makes the page flash
white. The wall hatching of
[#685](https://github.com/Matysh/houseplan-card/issues/685) is reverted to
its earlier pattern, which it did not fix and which gained stepped stripe
edges ([#689](https://github.com/Matysh/houseplan-card/issues/689)).
## v1.78.0-beta.8 — 2026-09-28
+7
View File
@@ -21,6 +21,13 @@
остаётся постоянной в экранных пикселях, не искажается при изменении размера
и повороте и одинаково применяется в просмотре, редакторе плана и PDF
([#688](https://github.com/Matysh/houseplan-card/issues/688)).
- С фоном день/ночь план больше не становится размытым после приближения:
стены, штриховка и проёмы остаются такими же чёткими, как при открытии
страницы сразу на этом масштабе. Навигация по сильно приближенному плану
больше не вызывает белого мигания страницы. Штриховка стен из
[#685](https://github.com/Matysh/houseplan-card/issues/685) возвращена к
прежнему узору: она не исправляла проблему и добавила ступенчатые края полос
([#689](https://github.com/Matysh/houseplan-card/issues/689)).
## v1.78.0-beta.8 — 2026-09-28
+12
View File
@@ -93,6 +93,18 @@ The public setting remains a two-value selector. `static` uses `bg_color`.
neither the filter nor the hint. The non-interactive static space card uses
the same stage-sized day-cycle outline from its first frame because it has no
camera gesture that could activate a fallback later (#582).
- The sibling stays stage-sized at every zoom (#689). The full card clips it to
its own box (`.stage .hp-paper-outline-svg { overflow: hidden }`) and marks it
`data-hp-live-overflow="clip"`, so a gesture never exposes it either: a filter
layer precedes any `clip-path`, and an open outline spanned the whole paper
at the current zoom — 12.8× the stage at ~460 % and hundreds of MB at 800 % ×
DPR 2, which made navigation flash white. Its glow may be missing on an
incoming edge for at most one budgeted `viewBox` refresh. The visible scene
keeps the explicit layer #582 requires, but through `will-change: opacity`:
a `will-change: transform` hint froze its raster scale, so after zooming from
100 % to 800 % the plan showed the stretched 100 % raster while a page opened
at 800 % was sharp. Both were verified by the owner in Chrome 152;
`demo/smoke_daycycle_zoom_layers.mjs` measures the layers and the hint.
- Only the environment and the zero-offset alpha-aware outline outside the
grouped plan-paper footprint change. The plan, paper, floors, room fills,
Glow/spill, devices, labels, decor/backdrop, vacuum, hover, and window rays
-12
View File
@@ -294,18 +294,6 @@ thinner than `HATCH_MIN_STEP_PX = 2` (`wallHatchNeedsSolid`). The step is
clamped to `[0.5, 80]` units so a pathological `cell_cm` cannot degenerate the
pattern.
At the byte-compatible 100% view the interactive flat card keeps that
historical pattern. Once the settled camera actually scales the plan, it paints
the unchanged geometry as an analytic repeating `linearGradient` (#685), not as
a small stroked SVG tile. The gradient vector is normal to the stripes, has
exactly one physical hatch step of length, and its two hard-stop edges preserve
the historical stroke/gap ratio and phase. This distinction is raster-only: it
prevents Chromium/HA WebView from keeping a soft resampled tile at a fractional
settled zoom. Stored walls, centimetre depth, hatch angle and density do not
change. The non-interactive space card may keep the historical pattern because
it has no live camera; the density smoke normalises both paint servers to the
same geometry.
A variable-offset join where exactly one adjacent edge has zero depth is a
local flat cap, not a mitre. Both `inset` and `outset` retain the physical
edge's offset point followed by the untouched zero-edge vertex (or the reverse
@@ -82,9 +82,9 @@ The invariant depends on computed CSS, SVG paint, clipping, stacking or pixels p
- `device-unavailable-hover-restored`
- `golden-filled-tunnel-removed`
- `golden-lamp-out-of-reach`
- `hatch-static-gradient-repeat-disabled`
- `hatch-static-renderer-untouched`
- `hatch-stroke-not-scaled`
- `hatch-zoom-compensation-back`
- `iso-first-frame-reveals-flat-during-lazy-load`
- `iso-room-label-44-box-centres-name`
- `iso-sun-card-drops-occluders`
+65 -12
View File
@@ -4004,6 +4004,59 @@ const MUTANT_DEFINITIONS = [
replace: '',
}],
},
{
id: 'daycycle-scene-transform-hint-back',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test test/daycycle-layers.test.mjs',
because: '#689 K1: a will-change: transform hint freezes the scene raster scale; after '
+ 'zooming from 100 % the plan shows the stretched 100 % raster (owner, Chrome 152). '
+ 'Browser proof: demo/smoke_daycycle_zoom_layers.mjs',
patches: [{
file: 'src/styles/plan.styles.ts',
find: ' .stage.daycycle.hp-safe-daycycle-outline .plan-svg {\n'
+ ' will-change: opacity;\n',
replace: ' .stage.daycycle.hp-safe-daycycle-outline .plan-svg {\n'
+ ' will-change: transform;\n',
}],
},
{
id: 'daycycle-outline-overflow-visible',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test test/daycycle-layers.test.mjs',
because: '#689 K3: an unclipped filtered outline covers the whole paper at the current '
+ 'zoom (12.8x the stage at ~460 %, far more at 800 %) and navigation flashes white',
patches: [{
file: 'src/styles/plan.styles.ts',
find: ' .stage .hp-paper-outline-svg { overflow: hidden; }\n',
replace: ' .stage .hp-paper-outline-svg { overflow: visible; }\n',
}],
},
{
id: 'live-exposure-unbounded',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test test/live-viewport.test.mjs',
because: '#689 K3: without the clip a projected scene exposes its whole content and the '
+ 'promoted layer grows with zoom squared (plan-svg 15.9x the stage at ~460 %)',
patches: [{
file: 'src/live-viewport.ts',
find: ' if (expose && style.clipPath !== LIVE_SCENE_EXPOSURE_CLIP) {\n'
+ ' style.clipPath = LIVE_SCENE_EXPOSURE_CLIP;\n'
+ ' }\n',
replace: '',
}],
},
{
id: 'live-exposure-opens-clipped-scene',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test test/live-viewport.test.mjs',
because: '#689 K3: the filtered day/night outline must never be opened by a gesture: its '
+ 'filter layer precedes any clip-path and would again span the whole paper',
patches: [{
file: 'src/live-viewport.ts',
find: " && (layer as unknown as Element).getAttribute?.(SCENE_OVERFLOW_ATTRIBUTE) !== 'clip';",
replace: ' && true;',
}],
},
{
id: 'opening-light-quantum-identity',
guard: 'node --test --test-name-pattern="#366" test/logic.test.mjs',
@@ -7824,24 +7877,24 @@ const MUTANT_DEFINITIONS = [
{
id: 'hatch-stroke-not-scaled',
guard: 'node demo/smoke_wall_hatch_density.mjs',
because: 'штриховка обязана следовать за шагом: иначе на мелкой клетке '
+ 'полосы слипаются в сплошное пятно, а на крупной расходятся',
because: 'штрих обязан следовать за шагом: иначе на мелкой клетке полосы '
+ 'слипаются в сплошное пятно, а на крупной становятся волосяными',
patches: [{
file: 'src/houseplan-card.ts',
find: 'x2=${hatchStep} gradientTransform=rotate(45)',
replace: 'x2=${hatchStep * 7 / 8} gradientTransform=rotate(45)',
find: ' const stripe = 2 * (hatchStep / HATCH_BASE_STEP_UNITS);',
replace: ' const stripe = 2;',
}],
},
{
id: 'hatch-static-gradient-repeat-disabled',
guard: 'node demo/smoke_static_zoom_sharpness.mjs',
because: 'без repeat аналитический paint-server оставляет одну полосу и '
+ 'возвращает масштабозависимую пустую/мягкую штриховку #685; терминальный '
+ 'smoke обязан доказывать стабилизацию, а не только конечный viewBox',
id: 'hatch-zoom-compensation-back',
guard: 'node demo/smoke_wall_hatch_density.mjs',
because: 'компенсация 1/zoom возвращает ровно то, ради устранения чего '
+ 'задача и делалась: стена меняет вид при зуме (решение владельца §4.2)',
patches: [{
file: 'src/houseplan-card.ts',
find: 'gradientTransform=rotate(45) spreadMethod=repeat',
replace: 'gradientTransform=rotate(45) spreadMethod=pad',
find: ' width="${hatchStep}" height="${hatchStep}" patternTransform="rotate(45)">',
replace: ' width="${hatchStep}" height="${hatchStep}"\n'
+ ' patternTransform="rotate(45) scale(${Math.max(0.4, 1 / Math.max(this._zoom, 0.4)).toFixed(3)})">',
}],
},
{
@@ -9728,7 +9781,7 @@ const MUTANT_DEFINITIONS = [
+ 'until pointerup or a budgeted viewBox refresh; terminal screenshots miss the defect',
patches: [{
file: 'src/live-viewport.ts',
find: " if (options.exposeSceneOverflow && style.overflow !== 'visible') {\n"
find: " if (expose && style.overflow !== 'visible') {\n"
+ " style.overflow = 'visible';\n"
+ ' }',
replace: '',
+6 -6
View File
@@ -59,7 +59,7 @@ import {
innerContourForRoom, roomWallProfile, outsetContour, openingInnerFaceOffsetFromIndex,
openingTunnelGeometriesFromIndex, openingWallIndex as buildOpeningWallIndex, drawWallPreviewD,
DRAW_WALL_DEFAULT_CM, normalizeWallIntervals, intervalCmAt, wallBodyNeedsSolid,
wallHatchNeedsSolid, wallHatchStepUnits, type OpeningTunnelGeometry,
wallHatchNeedsSolid, wallHatchStepUnits, HATCH_BASE_STEP_UNITS, type OpeningTunnelGeometry,
type OpeningWallIndex, type WallEntry, type WallInterval,
} from './wall-thickness';
import type { JunctionLimitViolation, JunctionSharedGeometry } from './junction-limits';
@@ -10736,11 +10736,11 @@ export class HouseplanCard extends LitElement {
);
const transitionStageBg = modeVisual?.stageColor || stageBg;
const transitionBrightness = modeVisual?.sceneBrightness ?? 1;
// View owns the eager hatch; 100% stays byte-compatible, scaled views avoid a cached tile.
// View owns the eager hatch: one pattern at every zoom (#689 reverted #685's gradient).
const hatchStep = wallHatchStepUnits(this._cellCm);
const wallHatch = svg`<defs>${this._zoom === 1
? svg`<pattern id=hp-wall-hatch patternUnits=userSpaceOnUse width=${hatchStep} height=${hatchStep} patternTransform=rotate(45)><path d="M0 0V${hatchStep}" stroke=${disp.color} stroke-width=${hatchStep / 4}/></pattern>`
: svg`<linearGradient id=hp-wall-hatch gradientUnits=userSpaceOnUse x2=${hatchStep} gradientTransform=rotate(45) spreadMethod=repeat><stop offset=.125 stop-color=${disp.color}/><stop offset=.125 stop-opacity=0 /><stop offset=.875 stop-opacity=0 /><stop offset=.875 stop-color=${disp.color}/></linearGradient>`}</defs>`;
const stripe = 2 * (hatchStep / HATCH_BASE_STEP_UNITS);
const wallHatch = svg`<defs><pattern id="hp-wall-hatch" patternUnits="userSpaceOnUse"
width="${hatchStep}" height="${hatchStep}" patternTransform="rotate(45)"><path d="M0 0 L0 ${hatchStep}" stroke="${disp.color || '#607d8b'}" stroke-width="${stripe}"></path></pattern></defs>`;
const editorClose = html`<span class="editor-close-slot" aria-hidden=${this._mode === 'view' ? 'true' : nothing}>${this._mode !== 'view' ? html`<button
class="closex" title=${this._t('title.close_editor')} aria-label=${this._t('title.close_editor')} data-hp="editor-close" data-editor-navigation="view"
@click=${(e: Event) => { e.stopPropagation(); this._setMode('view'); }}><ha-icon icon="mdi:close"></ha-icon></button>`
@@ -10884,7 +10884,7 @@ export class HouseplanCard extends LitElement {
${isoFrame?.underlay ?? nothing}
</svg>` : nothing}
${dayCycle && paperShapes.length ? svg`<svg class="hp-paper-outline-svg"
data-hp-live-viewbox=${iso ? 'camera' : 'floor'}
data-hp-live-viewbox=${iso ? 'camera' : 'floor'} data-hp-live-overflow="clip"
viewBox="${view.x} ${view.y} ${view.w} ${view.h}"
preserveAspectRatio="xMidYMid meet" aria-hidden="true" pointer-events="none">
<g transform=${iso ? isoFloorMatrixCss() : nothing}>
+33 -5
View File
@@ -109,19 +109,43 @@ const projectionText = (projection: LiveLayerProjection): string =>
`translate(${projection.translateXPercent}%,${projection.translateYPercent}%)`
+ ` scale(${projection.scaleX},${projection.scaleY})`;
/**
* #689: how far an exposed scene may paint beyond its own viewport box while
* it is projected. The budgeted `viewBox` refresh fires at a 15 % shift or
* scale change (`LIVE_VIEWBOX_REFRESH_SHIFT`), so 25 % covers every incoming
* edge #544 needs. Unbounded `overflow: visible` let a promoted scene layer
* grow with zoom² — 15.9× the stage at ~460 %, hundreds of MB of GPU memory at
* 800 % × DPR 2 — and the page flashed white.
*/
export const LIVE_SCENE_EXPOSURE_CLIP = 'inset(-25%)';
/** A scene marked so is clipped to its own box and never exposed (#689). */
const SCENE_OVERFLOW_ATTRIBUTE = 'data-hp-live-overflow';
const exposesOverflow = (
layer: ElementCSSInlineStyle,
options: { exposeSceneOverflow?: boolean },
): boolean => options.exposeSceneOverflow === true
&& (layer as unknown as Element).getAttribute?.(SCENE_OVERFLOW_ATTRIBUTE) !== 'clip';
const setLayerProjection = (
layer: ElementCSSInlineStyle,
projection: LiveLayerProjection | null,
options: { exposeSceneOverflow?: boolean } = {},
): void => {
const style = layer.style;
const expose = exposesOverflow(layer, options);
if (!projection) {
// #544: the SVG viewport may be opened only while it is being projected.
// `.stage` remains the outer clip, while removing this inline value keeps
// the settled DOM and filter/compositing path byte-equivalent to #531.
if (options.exposeSceneOverflow && style.overflow === 'visible') {
// `.stage` remains the outer clip, while removing these inline values
// keeps the settled DOM and filter/compositing path byte-equivalent to
// #531. The bound (#689) leaves together with the exposure it bounds.
if (expose && style.overflow === 'visible') {
style.removeProperty('overflow');
}
if (expose && style.clipPath === LIVE_SCENE_EXPOSURE_CLIP) {
style.removeProperty('clip-path');
}
// #531: снимать только то, что стоит. Лишняя запись в стиль — это
// инвалидация, а тихий кадр обязан оставлять DOM нетронутым.
if (style.transform) {
@@ -134,10 +158,14 @@ const setLayerProjection = (
// A transformed SVG keeps its old viewport box. Without exposing the scene
// beyond that internal box, the incoming edge shows `.stage` background
// until the next budgeted viewBox refresh (#544). The stage still clips the
// complete card, so no scene pixels escape the visible plan surface.
if (options.exposeSceneOverflow && style.overflow !== 'visible') {
// complete card, so no scene pixels escape the visible plan surface; the
// clip keeps the promoted layer stage-sized at any zoom (#689).
if (expose && style.overflow !== 'visible') {
style.overflow = 'visible';
}
if (expose && style.clipPath !== LIVE_SCENE_EXPOSURE_CLIP) {
style.clipPath = LIVE_SCENE_EXPOSURE_CLIP;
}
const text = projectionText(projection);
// Promotion is gesture-scoped, not a per-frame hint (#579). Avoid even
// equal writes while the projection changes: WebViews may treat them as
+17 -1
View File
@@ -113,8 +113,17 @@ export const planStyles = css`
.stage.daycycle.hp-safe-daycycle-outline .hp-paper-outline-svg {
transition: none;
}
/* #689: the scene keeps its explicit stage-sized layer (#582: never an
implicit overlap layer over the promoted outline), but through an
opacity hint, not a transform hint. Chromium freezes the raster scale
of a will-change: transform layer, so after zooming from 100 % to 800 %
the plan showed the 100 % raster stretched eightfold, while a page
loaded at 800 % was sharp (owner-verified in Chrome 152). An opacity
hint re-rasters at the current scale; translateZ(0) computes to a 2D
identity there and is no stable layer reason. The live viewport's
inline transform hint still owns a gesture (#579). */
.stage.daycycle.hp-safe-daycycle-outline .plan-svg {
will-change: transform;
will-change: opacity;
}
@media (prefers-reduced-motion: reduce) {
.hp-day-cycle-bg,
@@ -235,6 +244,13 @@ export const planStyles = css`
visibility: hidden;
}
.stage.hp-safe-daycycle-outline .hp-paper-outline-svg { visibility: visible; }
/* #689: the full card's filtered outline is clipped to its own box. With
overflow visible its filter layer covered the whole paper at the
current zoom — 12.8× the stage at ~460 %, hundreds of MB at 800 % ×
DPR 2 — and navigation flashed white. It is marked
data-hp-live-overflow="clip" so a gesture never exposes it either; the
static card keeps its fixed-view outline unchanged. */
.stage .hp-paper-outline-svg { overflow: hidden; }
.hp-static-stage .hp-paper-outline-svg { visibility: visible; }
.iso-underlay-svg { z-index: 0; overflow: visible; }
.iso-shadows-svg { z-index: 3; overflow: visible; }
+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');
});