Files
houseplan-card/demo/smoke_grid_scale_invariance.mjs
Claude e5ab4659f6 feat: 2.5D stage 6 — public setting, raised tiles, soft sun, theme-free walls (#649)
- settings.volumetric_view (General settings › Display, third switch) replaces
  the alpha entry, the header projection toggle and the phone-menu item; one
  rule for card, sidebar page and kiosk; editors stay Flat; backend accepts
  only a boolean, support package copies it.
- Raised tiles in 2.5D View: no ring, rounded body min(0.275 D, 0.3 h), edge
  0.1 D with the lab filters, ×1.12 size also in layout/collision, lift
  0.075 D, one floor-shadow layer under all markers (theme × floor table),
  frames hugging tile and edge (Alert > Focus > Selected > Hover), forced
  colours without edge and shadow (src/iso-tiles.ts, styles/iso-tiles).
- Soft sun wash instead of projected Flat wedges (src/iso-sun.ts): same gates
  and windows as sun_rays, length from elevation, parallelogram along the sun,
  tone and streaks by floor lightness, sill line.
- Walls take the user's wall colour (top opaque, sides × .77/.68/.60); theme
  rules for walls/openings/labels removed; furniture uses the Flat stroke rule.
- Smokes smoke_iso_tiles/iso_sun/iso_theme_walls/volumetric_setting, 16 new
  mutants, acceptance scenes STAGE6_ACCEPTANCE_SCENARIOS (golden with their
  Linux CI baselines), ACCEPTANCE.md side by side.

Issue: #649
User-Visible: yes
2026-09-25 18:55:14 +03:00

492 lines
23 KiB
JavaScript

// Issue #239: a finer coordinate grid changes precision, never the visible plan.
import { launch, checkAll, finish } from './serve.mjs';
import { coherentGridScaleStaticPatch } from './grid-scale-static-fixture.mjs';
const { page, browser } = await launch({ width: 1000, height: 860 }, 1, [], {
reducedMotion: 'reduce',
});
await page.evaluate(async () => {
const card = window.__card;
const base = {
id: 'f1', title: 'Grid invariant', plan_url: '/assets/f1.svg', plan_aspect: 1.25,
view_box: [0, 0, 1, 1], cell_cm: 5,
settings: {
show_borders: true, show_names: true, fill_mode: 'custom',
custom_fill: { c: '#d8e2e8', a: 0.22 }, room_color: '#2d8fce', room_opacity: 0.8,
hide_decor: false, hide_openings: false, glow_enabled: true, sun_rays: false,
},
rooms: [{
id: 'r1', name: 'Living room', area: 'living_room',
poly: [[0.10, 0.12], [0.90, 0.12], [0.90, 0.88], [0.10, 0.88]],
}],
openings: [
{ id: 'grid-door', type: 'door', x: 0.50, y: 0.12, angle: 0, length: 0.16 },
{ id: 'grid-window', type: 'window', x: 0.90, y: 0.48, angle: 90, length: 0.14 },
{ id: 'grid-gate', type: 'gate', x: 0.38, y: 0.88, angle: 0, length: 0.22 },
],
partitions: [{ id: 'grid-partition', a: [0.28, 0.34], b: [0.72, 0.34], cm: 15 }],
wall_columns: [{ id: 'grid-column', shape: 'circle', center: [0.72, 0.68], cm: 32 }],
decor: [{
id: 'grid-decor', kind: 'line', x1: 0.20, y1: 0.72, x2: 0.58, y2: 0.72,
color: '#6b4d32', opacity: 0.9, width_cm: 1.2, line_style: 'dashed',
}],
};
const baseLayout = {
d_light1: { s: 'f1', x: 0.24, y: 0.24 },
d_lamp: { s: 'f1', x: 0.42, y: 0.54 },
d_tv: { s: 'f1', x: 0.70, y: 0.54 },
d_temp: { s: 'f1', x: 0.72, y: 0.24 },
};
const sharedLayout = card._layout;
await card._ensureEditorRuntime();
const runtime = card._editorRuntime;
const originalGridLevels = runtime._gridLevels.bind(runtime);
const scalePoint = (point, factor) => point.map((value) => value * factor);
const scaledFixture = (cellCm) => {
const factor = 5 / cellCm;
const space = structuredClone(base);
space.cell_cm = cellCm;
space.view_box = [0, 0, factor, factor];
space.plan_scale_x = factor;
space.plan_scale_y = factor;
// Keep an intentionally transformed backdrop in both fixtures. The 1.25
// image starts at y=.1; both its offset and that origin scale physically.
space.plan_x = 0.02 * factor;
space.plan_y = 0.11 * factor - 0.1;
space.rooms.forEach((room) => { room.poly = room.poly.map((point) => scalePoint(point, factor)); });
space.openings.forEach((opening) => {
opening.x *= factor; opening.y *= factor; opening.length *= factor;
});
space.partitions.forEach((partition) => {
partition.a = scalePoint(partition.a, factor);
partition.b = scalePoint(partition.b, factor);
});
space.wall_columns.forEach((column) => { column.center = scalePoint(column.center, factor); });
space.decor.forEach((shape) => {
for (const key of ['x1', 'y1', 'x2', 'y2']) shape[key] *= factor;
});
return { space, factor };
};
const settle = async () => {
card.requestUpdate();
await card.updateComplete;
for (let index = 0; index < 3; index++) {
await new Promise((resolve) => requestAnimationFrame(resolve));
}
// Room paths deliberately animate between View and Plan styles for 120 ms.
// Compare settled render states, not two different interpolation frames.
await new Promise((resolve) => setTimeout(resolve, 150));
};
window.__applyGridScaleFixture = async (cellCm, mode = 'view', projection = 'flat') => {
const { space, factor } = scaledFixture(cellCm);
card._serverCfg.spaces.splice(0, card._serverCfg.spaces.length, space);
card._space = 'f1';
for (const key of Object.keys(sharedLayout)) delete sharedLayout[key];
Object.assign(sharedLayout, Object.fromEntries(Object.entries(baseLayout).map(([id, pos]) => [id, {
...pos, x: pos.x * factor, y: pos.y * factor,
}])));
card._layout = sharedLayout;
card._cfgEpoch++;
card._modelCache = null;
card._frame = null;
card._view = null;
card._showFar = false;
card._regSignature = '';
card._maybeRebuildDevices();
card._mode = mode;
card._tool = mode === 'plan' ? 'draw' : null;
card._decorTool = mode === 'decor' ? 'backdrop' : 'select';
// Grid density is the one allowed difference. Mask it in every editor
// raster pair and prove the precision ratio separately below.
runtime._gridLevels = mode === 'view' ? originalGridLevels : () => null;
card._gridLevels = () => runtime._gridLevels();
await window.__hpHarnessProjection(card, projection);
card._isoFallback.clear();
card._isoGeometryCache.clear();
await settle();
card._fitAll();
await settle();
};
window.__gridScaleMetrics = () => {
const root = card.renderRoot;
const number = (value) => Number.parseFloat(value || '0');
const style = (selector) => {
const node = root.querySelector(selector);
return node ? getComputedStyle(node) : null;
};
const rect = (selector) => {
const node = root.querySelector(selector);
if (!node) return null;
const box = node.getBoundingClientRect();
return [box.width, box.height];
};
const opening = root.querySelector('[data-id="grid-door"]');
const outline = opening?.querySelector('.op-outline');
const hit = opening?.querySelector('.op-hit');
const leaf = opening?.querySelector('.op-leaf rect');
return {
factor: number(style('.stage')?.getPropertyValue('--hp-cell-visual-scale')),
roomStroke: number(style('.room.styled')?.strokeWidth),
wallStroke: number(style('.wallbody')?.strokeWidth),
arcStroke: number(style('.op-arc')?.strokeWidth),
openingLeafHeight: number(leaf?.getAttribute('height')),
openingOutlineWidth: number(outline?.getAttribute('width')),
openingHitWidth: number(hit?.getAttribute('width')),
openingBox: rect('[data-id="grid-door"]'),
wallBox: rect('.wallbody'),
deviceBox: rect('[data-hp="device"]'),
roomLabelBox: rect('[data-hp="room-label"]'),
decorBox: rect('[data-hp="decor"][data-id="grid-decor"]'),
glowBox: rect('.glow-spot'),
snapNodeBox: rect('.plan-snap-node[data-kind="endpoint"]'),
backdropHandleBox: rect('.bdhandle'),
backdropKnobBox: rect('.bdknob'),
backdropBox: rect('.bdbox'),
backdropBoxStroke: number(style('.bdbox')?.strokeWidth),
backdropBoxDash: style('.bdbox')?.strokeDasharray ?? '',
isoWallBox: rect('[data-hp="iso-walls"]'),
isoFloorBox: rect('[data-hp="iso-underlay"]'),
planSnapLineStroke: number(style('.plan-snap-line')?.strokeWidth),
snapIntervalsPerMeter: 100 / card._cellCm,
projection: card._effectiveProjection(),
projectionToggleCount: root.querySelectorAll('[data-hp="projection-toggle"]').length,
visualScaleNodes: root.querySelectorAll('.room, .wallbody, .opening').length,
};
};
window.__makeStaticGridCard = async () => {
document.querySelector('#grid-static-host')?.remove();
const host = document.createElement('div');
host.id = 'grid-static-host';
host.style.width = '780px';
document.body.appendChild(host);
const compact = document.createElement('houseplan-space-card');
compact.setConfig({ type: 'custom:houseplan-space-card', space: 'f1', show_button: false });
compact.hass = card.hass;
host.appendChild(compact);
const deadline = Date.now() + 6000;
while (!compact.renderRoot?.querySelector('.hp-static-stage') && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 30));
}
while ((compact._loading || !compact._snap) && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 30));
}
await compact.updateComplete;
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
return {
config: card._serverCfg,
layout: sharedLayout,
revision: card._cfgEpoch,
};
};
});
const stage = page.locator('houseplan-card').first().locator('.stage');
const staticStage = () => page.locator('#grid-static-host houseplan-space-card .hp-static-stage');
const themeVars = {
light: {
'--primary-color': '#0b73b8', '--primary-text-color': '#202124',
'--secondary-text-color': '#5f6368', '--card-background-color': '#ffffff',
'--ha-card-background': '#ffffff', '--divider-color': '#d7d9de',
},
dark: {
'--primary-color': '#3ea6ff', '--primary-text-color': '#e6e7eb',
'--secondary-text-color': '#9aa4ad', '--card-background-color': '#202126',
'--ha-card-background': '#202126', '--divider-color': '#3a3d45',
},
};
const setTheme = async (theme) => {
await page.emulateMedia({ reducedMotion: 'reduce', colorScheme: theme });
await page.evaluate(async ({ variables, dark }) => {
for (const [name, value] of Object.entries(variables)) {
document.documentElement.style.setProperty(name, value);
}
document.documentElement.style.colorScheme = dark ? 'dark' : 'light';
const card = window.__card;
card.hass = { ...card.hass, themes: { ...(card.hass.themes || {}), darkMode: dark } };
await card.updateComplete;
await document.fonts.ready;
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
}, { variables: themeVars[theme], dark: theme === 'dark' });
};
const stableScreenshot = async (locator) => {
let previous = null;
for (let attempt = 0; attempt < 6; attempt++) {
await page.evaluate(async () => {
await window.__card.updateComplete;
await document.fonts.ready;
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
});
const current = await locator.screenshot({ animations: 'disabled' });
if (previous?.equals(current)) return current;
previous = current;
}
throw new Error('grid scale fixture did not reach two identical consecutive paints');
};
const capture = async (cellCm, mode, {
theme = 'light', projection = 'flat', staticCard = false, hide = [],
} = {}) => {
await setTheme(theme);
await page.evaluate(([cell, nextMode, nextProjection]) =>
window.__applyGridScaleFixture(cell, nextMode, nextProjection), [cellCm, mode, projection]);
const metrics = await page.evaluate(() => window.__gridScaleMetrics());
if (mode === 'decor') {
// The browser resamples the same SVG backdrop at two user-space sizes with
// small raster differences. This pair is about Background chrome; the
// backdrop geometry itself is proved by its shared frame and handle boxes.
await page.evaluate(() => {
const backdrop = window.__card.renderRoot.querySelector('.hp-backdrop');
if (backdrop) backdrop.style.visibility = 'hidden';
});
}
await page.evaluate((selectors) => {
for (const selector of selectors) {
for (const node of window.__card.renderRoot.querySelectorAll(selector)) node.style.visibility = 'hidden';
}
}, hide);
await page.evaluate(() => window.scrollTo(0, 0));
const pixels = await stableScreenshot(stage);
let staticPixels = null;
if (staticCard) {
const source = await page.evaluate(() => window.__makeStaticGridCard());
const patch = coherentGridScaleStaticPatch(source);
await page.evaluate(async (nextPatch) => {
const compact = document.querySelector('#grid-static-host houseplan-space-card');
if (!compact) throw new Error('grid-scale static fixture card disappeared');
if (!compact._snap) throw new Error('grid-scale static fixture did not load its base snapshot');
// Preserve runtime-only Set/Map values from the in-page snapshot. Only
// config+layout cross the Playwright boundary; both come from one scale.
compact._snap = { ...compact._snap, ...nextPatch };
compact._loadedOnce = true;
compact._refreshDevices();
compact.requestUpdate();
await compact.updateComplete;
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
}, patch);
staticPixels = await stableScreenshot(staticStage());
}
return { metrics, pixels, staticPixels };
};
const near = (left, right, tolerance = 0.35) => Math.abs(left - right) <= tolerance;
const worldUnitKeys = new Set([
'roomStroke', 'wallStroke', 'arcStroke', 'openingLeafHeight',
'openingOutlineWidth', 'openingHitWidth',
]);
const metricPairsNear = (left, right) => Object.keys(left).every((key) => {
if (key === 'factor' || key === 'snapIntervalsPerMeter') return true;
const a = left[key], b = right[key];
if (Array.isArray(a) && Array.isArray(b)) return a.every((value, index) => near(value, b[index]));
if (worldUnitKeys.has(key)) return near(a * (right.factor / left.factor), b, 0.01);
return typeof a === 'number' && typeof b === 'number' ? near(a, b) : a === b;
});
const pixelDiff = async (left, right) => page.evaluate(async ([a, b]) => {
const decode = async (base64) => createImageBitmap(await (await fetch(`data:image/png;base64,${base64}`)).blob());
const [first, second] = await Promise.all([decode(a), decode(b)]);
if (first.width !== second.width || first.height !== second.height) {
return {
sameSize: false,
firstSize: [first.width, first.height],
secondSize: [second.width, second.height],
changed: Infinity,
maxDelta: Infinity,
meanDelta: Infinity,
};
}
const canvas = document.createElement('canvas');
canvas.width = first.width; canvas.height = first.height;
const context = canvas.getContext('2d', { willReadFrequently: true });
context.drawImage(first, 0, 0);
const firstPixels = context.getImageData(0, 0, canvas.width, canvas.height).data;
context.clearRect(0, 0, canvas.width, canvas.height);
context.drawImage(second, 0, 0);
const secondPixels = context.getImageData(0, 0, canvas.width, canvas.height).data;
let changed = 0, maxDelta = 0, totalDelta = 0;
let minX = first.width, minY = first.height, maxX = -1, maxY = -1;
for (let index = 0; index < firstPixels.length; index += 4) {
let delta = 0;
for (let channel = 0; channel < 4; channel++) {
delta = Math.max(delta, Math.abs(firstPixels[index + channel] - secondPixels[index + channel]));
}
if (delta > 2) {
changed++;
const pixel = index / 4;
const x = pixel % first.width, y = Math.floor(pixel / first.width);
minX = Math.min(minX, x); minY = Math.min(minY, y);
maxX = Math.max(maxX, x); maxY = Math.max(maxY, y);
}
maxDelta = Math.max(maxDelta, delta);
totalDelta += delta;
}
return {
sameSize: true,
changed,
maxDelta,
meanDelta: totalDelta / (firstPixels.length / 4),
pixels: firstPixels.length / 4,
changedBounds: changed ? [minX, minY, maxX, maxY] : null,
};
}, [left.toString('base64'), right.toString('base64')]);
const pair = async (mode, options) => ({
reference: await capture(5, mode, options),
detailed: await capture(1, mode, options),
});
const referenceView = await capture(5, 'view', { staticCard: true });
const detailedView = await capture(1, 'view', { staticCard: true });
const referencePlan = await capture(5, 'plan');
const detailedPlan = await capture(1, 'plan');
const darkView = await pair('view', { theme: 'dark' });
const devices = await pair('devices');
// Chromium rasterises non-scaling dashed SVG rectangles differently under
// equivalent user-space transforms. Compare the rest of Background pixel for
// pixel; the masked box and openings are covered by critical metrics and by
// the View/Plan raster pairs respectively.
const background = await pair('decor', { hide: ['.bdbox', '.opening'] });
const isoLight = await pair('view', { projection: 'iso' });
const isoDark = await pair('view', { projection: 'iso', theme: 'dark' });
const openingEdgeAction = async (cellCm) => {
await setTheme('light');
await page.evaluate((cell) => window.__applyGridScaleFixture(cell, 'plan', 'flat'), cellCm);
const point = await page.evaluate(() => {
const card = window.__card;
const hit = card.renderRoot.querySelector('[data-id="grid-door"] .op-hit');
const box = hit.getBoundingClientRect();
const x = box.right - 0.75;
const y = box.top + box.height / 2;
const stack = card.renderRoot.elementsFromPoint(x, y).slice(0, 5).map((node) => ({
tag: node.tagName.toLowerCase(),
class: node.getAttribute('class') || '',
id: node.closest?.('[data-id]')?.getAttribute('data-id') || '',
}));
return { x, y, stack };
});
const before = await page.evaluate(() => JSON.stringify(window.__card._serverCfg));
await page.mouse.click(point.x, point.y);
await page.evaluate(() => window.__card.updateComplete);
const ok = await page.evaluate((snapshot) => {
const card = window.__card;
const opened = card._openingDialog?.id === 'grid-door';
card._openingDialog = null;
card.requestUpdate();
return opened && JSON.stringify(card._serverCfg) === snapshot;
}, before);
return { ok, cellCm, stack: point.stack };
};
const backgroundCancel = async (cellCm) => {
await setTheme('light');
await page.evaluate((cell) => window.__applyGridScaleFixture(cell, 'decor', 'flat'), cellCm);
return page.evaluate(async () => {
const card = window.__card;
const root = card.renderRoot;
const handle = root.querySelector('.bdhandle[data-corner="1,1"]');
const stageNode = root.querySelector('.stage');
const box = handle?.getBoundingClientRect();
if (!handle || !stageNode || !box) return false;
const before = structuredClone(card._serverCfg);
handle.dispatchEvent(new PointerEvent('pointerdown', {
bubbles: true, composed: true, cancelable: true, pointerId: 239,
pointerType: 'touch', clientX: box.left + box.width / 2, clientY: box.top + box.height / 2,
}));
const startedPid = card._bdDrag?.pid ?? null;
const cancelEvent = new PointerEvent('pointercancel', {
pointerId: 239, pointerType: 'touch',
});
card._stagePointerCancel(cancelEvent);
await card.updateComplete;
const after = structuredClone(card._serverCfg);
const canonical = (value) => {
if (Array.isArray(value)) return value.map(canonical);
if (value && typeof value === 'object') return Object.fromEntries(
Object.keys(value).sort().map((key) => [key, canonical(value[key])]),
);
return value;
};
const changedKeys = [...new Set([...Object.keys(before), ...Object.keys(after)])]
.filter((key) => JSON.stringify(canonical(before[key])) !== JSON.stringify(canonical(after[key])));
return {
ok: startedPid === 239 && cancelEvent.pointerId === 239
&& !card._bdDrag && changedKeys.length === 0,
startedPid, cancelPointerId: cancelEvent.pointerId,
dragEnded: !card._bdDrag,
configSame: changedKeys.length === 0,
changedKeys,
};
});
};
const referenceOpeningEdgeActs = await openingEdgeAction(5);
const detailedOpeningEdgeActs = await openingEdgeAction(1);
const referenceBackgroundCancel = await backgroundCancel(5);
const detailedBackgroundCancel = await backgroundCancel(1);
const flatDiff = await pixelDiff(referenceView.pixels, detailedView.pixels);
const staticDiff = await pixelDiff(referenceView.staticPixels, detailedView.staticPixels);
const planDiff = await pixelDiff(referencePlan.pixels, detailedPlan.pixels);
const darkViewDiff = await pixelDiff(darkView.reference.pixels, darkView.detailed.pixels);
const devicesDiff = await pixelDiff(devices.reference.pixels, devices.detailed.pixels);
const backgroundDiff = await pixelDiff(background.reference.pixels, background.detailed.pixels);
const isoLightDiff = await pixelDiff(isoLight.reference.pixels, isoLight.detailed.pixels);
const isoDarkDiff = await pixelDiff(isoDark.reference.pixels, isoDark.detailed.pixels);
const pixelEquivalent = (diff) => diff.sameSize
&& diff.changed <= 150 && diff.maxDelta <= 40 && diff.meanDelta <= 0.05;
// Diagnostic only: the raw diff numbers behind every pixel verdict, so a CI
// failure reports HOW FAR a pair drifted instead of a bare boolean (#302).
console.error('pixel-diffs', JSON.stringify({
flat: flatDiff, static: staticDiff, plan: planDiff, darkView: darkViewDiff,
devices: devicesDiff, background: backgroundDiff,
isoLight: isoLightDiff, isoDark: isoDarkDiff,
}));
const out = {
referenceFactorIsOne: referenceView.metrics.factor === 1,
detailedFactorIsFive: detailedView.metrics.factor === 5,
flatCriticalMetricsMatch: metricPairsNear(referenceView.metrics, detailedView.metrics),
flatPixelsMatch: pixelEquivalent(flatDiff),
staticPixelsMatch: pixelEquivalent(staticDiff),
planCriticalMetricsMatch: metricPairsNear(referencePlan.metrics, detailedPlan.metrics),
planPixelsMatchWithEquivalentGridHidden: pixelEquivalent(planDiff),
snapPrecisionIsFiveTimesFiner: detailedPlan.metrics.snapIntervalsPerMeter
=== referencePlan.metrics.snapIntervalsPerMeter * 5,
screenFixedPlanSnapStrokeIsNotDoubleScaled: referencePlan.metrics.planSnapLineStroke === 1
&& detailedPlan.metrics.planSnapLineStroke === 1,
openingEdgeHitAndActionMatch: referenceOpeningEdgeActs.ok && detailedOpeningEdgeActs.ok,
darkViewCriticalMetricsMatch: metricPairsNear(darkView.reference.metrics, darkView.detailed.metrics),
darkViewPixelsMatch: pixelEquivalent(darkViewDiff),
devicesCriticalMetricsMatch: metricPairsNear(devices.reference.metrics, devices.detailed.metrics),
devicesPixelsMatch: pixelEquivalent(devicesDiff),
backgroundCriticalMetricsMatch: metricPairsNear(background.reference.metrics, background.detailed.metrics),
backgroundPixelsMatch: pixelEquivalent(backgroundDiff),
backgroundPointerCancelIsMutationFree: referenceBackgroundCancel.ok && detailedBackgroundCancel.ok,
isoLightCriticalMetricsMatch: metricPairsNear(isoLight.reference.metrics, isoLight.detailed.metrics),
isoLightPixelsMatch: pixelEquivalent(isoLightDiff),
isoDarkCriticalMetricsMatch: metricPairsNear(isoDark.reference.metrics, isoDark.detailed.metrics),
isoDarkPixelsMatch: pixelEquivalent(isoDarkDiff),
// #649: 2.5D is the General settings switch; the card never shows a toggle.
isoHasNoCardToggle: referenceView.metrics.projectionToggleCount === 0
&& isoLight.reference.metrics.projection === 'iso'
&& isoLight.reference.metrics.projectionToggleCount === 0,
};
if (Object.values(out).some((value) => !value)) {
console.error('grid scale evidence:', JSON.stringify({
referenceView: referenceView.metrics,
detailedView: detailedView.metrics,
referencePlan: referencePlan.metrics,
detailedPlan: detailedPlan.metrics,
darkView: { reference: darkView.reference.metrics, detailed: darkView.detailed.metrics },
devices: { reference: devices.reference.metrics, detailed: devices.detailed.metrics },
background: { reference: background.reference.metrics, detailed: background.detailed.metrics },
isoLight: { reference: isoLight.reference.metrics, detailed: isoLight.detailed.metrics },
isoDark: { reference: isoDark.reference.metrics, detailed: isoDark.detailed.metrics },
flatDiff, staticDiff, planDiff, darkViewDiff, devicesDiff, backgroundDiff,
isoLightDiff, isoDarkDiff,
referenceBackgroundCancel, detailedBackgroundCancel,
referenceOpeningEdgeActs, detailedOpeningEdgeActs,
}, null, 2));
}
checkAll(out);
await finish(browser, out);