Files
houseplan-card/demo/smoke_static_zoom_sharpness.mjs
Claude 600330e187 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
2026-09-28 17:51:06 +03:00

150 lines
6.4 KiB
JavaScript

// 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';
import { GOLDEN_SCENARIOS } from './golden/matrix.mjs';
const scenario = GOLDEN_SCENARIOS.find((item) => item.id === 'opening-symbol-room-wall-light');
if (!scenario) throw new Error('opening-symbol-room-wall-light fixture is missing');
const sharpnessScenario = { ...scenario, extraOpenings: [{
id: 'sharpness-passage', type: 'passage', x: 0.62, y: 0.5, angle: 0, length: 0.06,
}] };
const { page, browser } = await launch({ width: 1000, height: 900 }, 1);
await prepareGoldenScenario(page, sharpnessScenario);
const settle = async () => {
await page.waitForFunction(() => !window.__goldenCard?._cameraTransition?.active);
await page.evaluate(async () => {
const card = window.__goldenCard;
await card.updateComplete;
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
});
};
const setZoom = async (zoom) => {
await page.evaluate((value) => {
const card = window.__goldenCard;
const vb = card._baseVb();
card._applyView(value, vb[0] + vb[2] / 2, vb[1] + vb[3] / 2);
card.requestUpdate();
}, zoom);
await settle();
};
const state = () => page.evaluate(() => {
const card = window.__goldenCard;
const root = card.renderRoot;
const scene = root.querySelector('.plan-svg');
const view = card._viewOr(card._baseVb());
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 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));
return {
zoom: card._zoom,
exactViewBox: scene?.getAttribute('viewBox') === exact,
noSceneTransform: scenes.every((node) => ['', 'none'].includes(getComputedStyle(node).transform)),
noSceneWillChange: scenes.every((node) => !getComputedStyle(node).willChange
|| getComputedStyle(node).willChange === 'auto'),
noLayerTransform: layers.every((node) => ['', 'none'].includes(getComputedStyle(node).transform)),
patternHatch: !!pattern && pattern.getAttribute('patternUnits') === 'userSpaceOnUse'
&& !root.querySelector('linearGradient#hp-wall-hatch'),
openingKinds: ['door', 'window', 'gate'].every((kind) => kinds.has(kind))
&& modelKinds.has('passage'),
};
});
const clip = await page.locator('houseplan-card').evaluate((card) => {
const rect = card.renderRoot.querySelector('.stage').getBoundingClientRect();
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
});
const shotHash = async () => createHash('sha256')
.update(await page.screenshot({ clip, animations: 'disabled', scale: 'css' }))
.digest('hex');
// Button: 100 -> 140%.
await setZoom(1);
await page.locator('houseplan-card').evaluate((card) =>
card.renderRoot.querySelector('[data-hp="zoom-in"]').click());
await settle();
const button = await state();
const buttonHash = await shotHash();
// A different route to the exact same target: 196 -> 140%.
await setZoom(1.96);
await page.locator('houseplan-card').evaluate((card) =>
card.renderRoot.querySelector('[data-hp="zoom-out"]').click());
await settle();
const buttonReverse = await state();
const reverseHash = await shotHash();
// Wheel: one centred notch, 100 -> 115%.
await setZoom(1);
await page.locator('houseplan-card').evaluate((card) => {
const stage = card.renderRoot.querySelector('.stage');
const rect = stage.getBoundingClientRect();
stage.dispatchEvent(new WheelEvent('wheel', {
bubbles: true, cancelable: true, deltaY: -100,
clientX: rect.left + rect.width / 2, clientY: rect.top + rect.height / 2,
}));
});
await settle();
const wheel = await state();
// Pinch: 100 -> 132%, then release both fingers and wait for the terminal Lit
// frame. The intermediate promoted transform is allowed; the returned state is
// deliberately sampled only after the two full settled frames above.
await setZoom(1);
await page.locator('houseplan-card').evaluate((card) => {
const stage = card.renderRoot.querySelector('.stage');
const rect = stage.getBoundingClientRect();
const emit = (type, id, x, y) => stage.dispatchEvent(new PointerEvent(type, {
bubbles: true, composed: true, cancelable: true,
pointerId: id, pointerType: 'touch', isPrimary: id === 71,
button: 0, buttons: type === 'pointerup' ? 0 : 1,
clientX: rect.left + x, clientY: rect.top + y,
}));
const cx = rect.width / 2, cy = rect.height / 2;
emit('pointerdown', 71, cx - 100, cy);
emit('pointerdown', 72, cx + 100, cy);
emit('pointermove', 71, cx - 132, cy);
emit('pointermove', 72, cx + 132, cy);
emit('pointerup', 71, cx - 132, cy);
emit('pointerup', 72, cx + 132, cy);
});
await settle();
const pinch = await state();
// The same architectural SVG is shared by View and all three editors.
const modes = [];
for (const mode of ['view', 'plan', 'devices', 'decor']) {
await page.evaluate((value) => window.__hpTest.setMode(value), mode);
await page.waitForTimeout(350);
await settle();
await setZoom(1.32);
modes.push(await state());
}
const terminal = (sample) => sample.exactViewBox && sample.noSceneTransform
&& sample.noSceneWillChange && sample.noLayerTransform && sample.patternHatch
&& sample.openingKinds;
const out = {
buttonSettlesAt140: Math.abs(button.zoom - 1.4) < 1e-9 && terminal(button),
reverseButtonSettlesAt140: Math.abs(buttonReverse.zoom - 1.4) < 1e-9
&& terminal(buttonReverse),
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),
allModesUseSettledPatternHatch: modes.length === 4 && modes.every(terminal),
};
checkAll(out);
await finish(browser, { ...out, buttonHash, reverseHash, button, buttonReverse, wheel, pinch, modes });