Files
houseplan-card/test/daycycle-layers.test.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

65 lines
2.7 KiB
JavaScript

// #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(', ')), []);
});