mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
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
150 lines
6.4 KiB
JavaScript
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 });
|