mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-07 06:59:46 +00:00
perf(light): reduce LED gradient paint only during scale changes
Retain the 48-band DOM and exact idle raster, paint 24 midpoint bands during actual zoom, and restore after 160 ms. Keep input, HA and lifecycle guards observable; measure the full camera/restore/restart cycle without relaxing historical budgets. Issue: #789 User-Visible: yes
This commit is contained in:
@@ -21,6 +21,10 @@
|
||||
* profile's camera scenario, benchmark_large_house.mjs) and the longest Long
|
||||
* Task of the camera — the scenario and the 100-step series, retained heap
|
||||
* after 20 A→B→C→A cycles over a warm cycle (same GC protocol).
|
||||
* #789 additionally observes cameraFullCycleLongTaskMaxMs continuously through
|
||||
* full-quality restoration, immediate restart and a 500 ms tail, with the same
|
||||
* camera ceiling. Historical camera windows remain unchanged; each row retains
|
||||
* raw full-cycle Long Tasks and phase boundaries for audit.
|
||||
*
|
||||
* Counters (exact, every sample): geometry/visibility recomputes over 100
|
||||
* unrelated HA ticks, 100 pan/zoom steps and a colour-only change (0); the
|
||||
@@ -44,6 +48,7 @@ import { dirname, resolve } from 'node:path';
|
||||
import { launch } from './serve.mjs';
|
||||
import { makeLedStripsFixture } from './performance/led-strips-fixture.mjs';
|
||||
import { ledChunkRequestName } from './performance/led-chunk-request.mjs';
|
||||
import { cameraCycleFailures, cameraCycleSampleFailures, finishLedCameraCycle } from './performance/led-camera-cycle.mjs';
|
||||
|
||||
const valueArg = (name) => process.argv.find((arg) => arg.startsWith(`--${name}=`))?.slice(name.length + 3);
|
||||
const warmupOnly = process.argv.includes('--warmup-only');
|
||||
@@ -118,6 +123,7 @@ function installLedLifecycleTracker() {
|
||||
/** The page side of one mount: the same fixture host as before, shared by the sample and the late-import run. */
|
||||
function pageHost() {
|
||||
return `
|
||||
window.__finishLedCameraCycle = ${finishLedCameraCycle.toString()};
|
||||
window.__ledHost = (fixture) => {
|
||||
const card = document.createElement('houseplan-card');
|
||||
card.setConfig({ type: 'custom:houseplan-card', title: 'LED strips', icon_size: 3.4 });
|
||||
@@ -182,7 +188,7 @@ async function sample() {
|
||||
await sleep(5);
|
||||
}
|
||||
};
|
||||
const longTasks = () => {
|
||||
const longTasks = (onEntries) => {
|
||||
const entries = [];
|
||||
const observer = new PerformanceObserver((list) => entries.push(...list.getEntries()));
|
||||
observer.observe({ type: 'longtask', buffered: false });
|
||||
@@ -190,6 +196,7 @@ async function sample() {
|
||||
await sleep(0);
|
||||
entries.push(...observer.takeRecords());
|
||||
observer.disconnect();
|
||||
onEntries?.(entries.map(({ startTime, duration }) => ({ startTime, duration })));
|
||||
return Number(Math.max(0, ...entries.map((entry) => entry.duration)).toFixed(2));
|
||||
};
|
||||
};
|
||||
@@ -250,6 +257,10 @@ async function sample() {
|
||||
const wheel = (deltaY) => stage.dispatchEvent(new WheelEvent('wheel', {
|
||||
deltaY, clientX: rect.left + rect.width / 2, clientY: rect.top + rect.height / 2, bubbles: true, cancelable: true,
|
||||
}));
|
||||
// Keep the old pan/series windows intact. This separate observer also
|
||||
// covers the first quality switch, both restores and the quick restart.
|
||||
const cameraFullCycle = { startTime: performance.now(), entries: [], phases: {} };
|
||||
const stopFullCycle = longTasks(entries => { cameraFullCycle.entries = entries; });
|
||||
const stopPan = longTasks();
|
||||
const panStarted = performance.now();
|
||||
wheel(-120);
|
||||
@@ -266,6 +277,14 @@ async function sample() {
|
||||
}
|
||||
await frame();
|
||||
const cameraSeriesLongTaskMaxMs = await stopSeries();
|
||||
cameraFullCycle.phases.seriesEnd = performance.now();
|
||||
Object.assign(cameraFullCycle.phases, await window.__finishLedCameraCycle({
|
||||
fullQuality: () => !card.hasAttribute('data-led-zoom-quality'),
|
||||
input: async () => { wheel(-60); await card.updateComplete; },
|
||||
frame, now: () => performance.now(), sleep,
|
||||
}));
|
||||
const cameraFullCycleLongTaskMaxMs = await stopFullCycle();
|
||||
cameraFullCycle.endTime = performance.now();
|
||||
const afterCamera = await stats();
|
||||
// A colour-only change rebuilds no geometry or visibility.
|
||||
host.setStates({ ...host.states, ...Object.fromEntries(floorLights.map((id) => [id,
|
||||
@@ -301,6 +320,7 @@ async function sample() {
|
||||
const key = (entry) => `${entry.shapes}/${entry.visibility}/${entry.sources}/${entry.visibilityPaths}/${entry.pathChars}`;
|
||||
return {
|
||||
firstStableRenderMs, warmSpaceReadyMs, stateUpdateMs, panZoomMs, panZoomLongTaskMaxMs, cameraSeriesLongTaskMaxMs,
|
||||
cameraFullCycleLongTaskMaxMs, cameraFullCycle,
|
||||
retainedHeapBytes: heapBefore == null || heapAfter == null ? null : Math.max(0, heapAfter - heapBefore),
|
||||
counters: strips ? {
|
||||
recomputesOnHaTicks: afterTicks.recomputes - before.recomputes,
|
||||
@@ -410,7 +430,7 @@ const metric = (name) => {
|
||||
return { median: values.length ? at(0.5) : null, p95: values.length ? at(0.95) : null, samples: values };
|
||||
};
|
||||
const METRICS = ['firstStableRenderMs', 'warmSpaceReadyMs', 'stateUpdateMs', 'panZoomMs', 'panZoomLongTaskMaxMs',
|
||||
'cameraSeriesLongTaskMaxMs', 'retainedHeapBytes'];
|
||||
'cameraSeriesLongTaskMaxMs', 'cameraFullCycleLongTaskMaxMs', 'retainedHeapBytes'];
|
||||
const report = {
|
||||
profile: PROFILE, size, strips: STRIPS, points: POINTS, sourceSha, chromium,
|
||||
samples: rows.length, warmups: discarded, cycles: CYCLES, viewport: { width: 1440, height: 1000 }, dpr: 1,
|
||||
@@ -429,6 +449,8 @@ if (!partial && (report.samples < MIN_SAMPLES || report.warmups < 1)) {
|
||||
failures.push(`${report.samples} samples after ${report.warmups} warmups: the profile needs ≥ ${MIN_SAMPLES} after ≥ 1`);
|
||||
}
|
||||
const limits = budgets.sizes[size] || {};
|
||||
failures.push(...cameraCycleSampleFailures(rows));
|
||||
failures.push(...cameraCycleFailures(report.metrics.cameraFullCycleLongTaskMaxMs, limits.cameraSeriesLongTaskMaxMs));
|
||||
for (const [name, limit] of Object.entries(limits)) {
|
||||
for (const stat of ['median', 'p95']) {
|
||||
const value = report.metrics[name]?.[stat];
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
/** Browser-side #789 witnesses. No product imports or private state writes.
|
||||
* The reference freezes the d8949cff full48 mask construction (led-strip-field
|
||||
* lines 433–465). Geometry/visibility are deliberately shared with the actual
|
||||
* DOM; smoke_led_strip_field's independent distance oracle covers those.
|
||||
* Actual raster styles are resolved on the connected node, so a leaked host
|
||||
* coarse selector cannot disappear merely because the clone left its host.
|
||||
*/
|
||||
export function installLedZoomOracle() {
|
||||
const NS = 'http://www.w3.org/2000/svg';
|
||||
const root = card => card.shadowRoot;
|
||||
const bands = field => [...field.querySelectorAll('mask > g > path')];
|
||||
const fields = card => [...root(card).querySelectorAll('[data-led-field]')];
|
||||
const attrs = node => [...node.attributes].map(a => [a.name, a.value]);
|
||||
const visibleThroughHost = (node, card) => {
|
||||
for (let current = node; current;) {
|
||||
const css = getComputedStyle(current);
|
||||
if (css.display === 'none' || css.visibility !== 'visible' || !(Number(css.opacity) > 0)) return false;
|
||||
if (current === card) return true;
|
||||
current = current.parentElement || current.getRootNode()?.host || null;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const stops = [[0, 1], [45, .88], [70, .62], [86, .32], [100, 0]];
|
||||
const grey = fraction => {
|
||||
const at = fraction * 100;
|
||||
for (let i = 1; i < stops.length; i++) {
|
||||
const [a, va] = stops[i - 1], [b, vb] = stops[i];
|
||||
if (at <= b) {
|
||||
const v = Math.round((va + (vb - va) * (at - a) / (b - a)) * 255);
|
||||
return `rgb(${v}, ${v}, ${v})`;
|
||||
}
|
||||
}
|
||||
return 'rgb(0, 0, 0)';
|
||||
};
|
||||
const snapshot = card => {
|
||||
const nodes = [...root(card).querySelectorAll('.led-fields, .led-fields *, [data-led-strip], [data-led-strip] *, [data-glow-source], [data-glow-source] *')];
|
||||
return { nodes, attributes: nodes.map(attrs), cache: [...root(card).querySelectorAll('.led-fields')]
|
||||
.map(n => [n.dataset.ledCache, n.dataset.ledRecomputes]) };
|
||||
};
|
||||
const unchanged = (card, before) => {
|
||||
const after = snapshot(card);
|
||||
const hitWidthChanges = [];
|
||||
const stable = before.nodes.length === after.nodes.length && before.nodes.every((node, i) => {
|
||||
if (node !== after.nodes[i]) return false;
|
||||
const old = before.attributes[i], now = after.attributes[i];
|
||||
if (!node.matches('.led-hit')) return JSON.stringify(old) === JSON.stringify(now);
|
||||
const oldWidth = old.find(([key]) => key === 'stroke-width')?.[1];
|
||||
const newWidth = now.find(([key]) => key === 'stroke-width')?.[1];
|
||||
if (oldWidth !== newWidth) hitWidthChanges.push({ oldWidth, newWidth });
|
||||
return JSON.stringify(old.filter(([key]) => key !== 'stroke-width'))
|
||||
=== JSON.stringify(now.filter(([key]) => key !== 'stroke-width'));
|
||||
});
|
||||
return { stable, hitWidthChanges, cacheStable: JSON.stringify(before.cache) === JSON.stringify(after.cache) };
|
||||
};
|
||||
const quality = card => ({
|
||||
coarse: card.getAttribute('data-led-zoom-quality') === 'coarse',
|
||||
ordinary: [...root(card).querySelectorAll('[data-glow-source]')].map(field => ({
|
||||
visible: visibleThroughHost(field, card),
|
||||
})),
|
||||
fields: fields(card).map(field => {
|
||||
const paths = bands(field);
|
||||
const painted = paths.filter(path => getComputedStyle(path).display !== 'none');
|
||||
const pool = field.querySelector('.led-pool');
|
||||
return { id: field.dataset.ledField, retained: paths.length, painted: painted.length,
|
||||
visible: visibleThroughHost(field, card) && Number(pool.getAttribute('fill-opacity')) > 0,
|
||||
correct24: paths.length === 48 && paths.every((path, k) => k % 2
|
||||
? getComputedStyle(path).display === 'none'
|
||||
: getComputedStyle(path).display !== 'none' && getComputedStyle(path).stroke === grey(1 - (k + 1) / 48)),
|
||||
fill: pool.getAttribute('fill'), alpha: pool.getAttribute('fill-opacity'),
|
||||
};
|
||||
}),
|
||||
});
|
||||
const clone = (card, frozen48) => {
|
||||
const original = root(card).querySelector('.led-fields');
|
||||
if (!original) throw new Error('LED raster oracle: no field');
|
||||
const copy = original.cloneNode(true);
|
||||
fields(card).forEach((field, i) => {
|
||||
const target = copy.querySelectorAll('[data-led-field]')[i];
|
||||
target.style.opacity = getComputedStyle(field).opacity;
|
||||
target.style.visibility = getComputedStyle(field).visibility;
|
||||
target.style.mixBlendMode = getComputedStyle(field).mixBlendMode;
|
||||
const actualPaths = bands(field), targetPaths = bands(target);
|
||||
if (actualPaths.length !== 48) throw new Error(`LED raster oracle: expected retained48, got ${actualPaths.length}`);
|
||||
if (frozen48) {
|
||||
const group = targetPaths[0].parentNode;
|
||||
const d = actualPaths[0].getAttribute('d');
|
||||
const radius = Number(actualPaths[0].getAttribute('stroke-width')) / 2;
|
||||
group.replaceChildren();
|
||||
for (let k = 0; k < 48; k++) {
|
||||
const path = document.createElementNS(NS, 'path');
|
||||
const outer = 1 - k / 48, inner = 1 - (k + 1) / 48;
|
||||
for (const [name, value] of Object.entries({ d, fill: 'none', stroke: grey((outer + inner) / 2),
|
||||
'stroke-width': 2 * outer * radius, 'stroke-linecap': 'round', 'stroke-linejoin': 'round' })) {
|
||||
path.setAttribute(name, String(value));
|
||||
}
|
||||
group.appendChild(path);
|
||||
}
|
||||
} else actualPaths.forEach((path, k) => {
|
||||
const css = getComputedStyle(path);
|
||||
targetPaths[k].style.stroke = css.stroke;
|
||||
targetPaths[k].style.display = css.display;
|
||||
targetPaths[k].style.visibility = css.visibility;
|
||||
});
|
||||
});
|
||||
return copy;
|
||||
};
|
||||
const raster = async (source, box, scale, background) => {
|
||||
const [x, y, w, h] = box;
|
||||
const xml = `<svg xmlns="${NS}" width="${w * scale}" height="${h * scale}" viewBox="${box.join(' ')}"><rect x="${x}" y="${y}" width="${w}" height="${h}" fill="${background}"/>${new XMLSerializer().serializeToString(source)}</svg>`;
|
||||
const img = new Image(); img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(xml)}`;
|
||||
await img.decode();
|
||||
const canvas = document.createElement('canvas'); canvas.width = w * scale; canvas.height = h * scale;
|
||||
const context = canvas.getContext('2d'); context.drawImage(img, 0, 0);
|
||||
return { data: context.getImageData(0, 0, canvas.width, canvas.height).data, canvas };
|
||||
};
|
||||
const full48 = async (card, { scales = [1, 2, 4], background = '#868d94', images = false } = {}) => {
|
||||
const pools = [...root(card).querySelectorAll('.led-pool')];
|
||||
const rects = pools.map(p => ['x', 'y', 'width', 'height'].map(a => Number(p.getAttribute(a))));
|
||||
const x = Math.floor(Math.min(...rects.map(r => r[0]))) - 2;
|
||||
const y = Math.floor(Math.min(...rects.map(r => r[1]))) - 2;
|
||||
const box = [x, y, Math.ceil(Math.max(...rects.map(r => r[0] + r[2]))) - x + 2,
|
||||
Math.ceil(Math.max(...rects.map(r => r[1] + r[3]))) - y + 2];
|
||||
const actual = clone(card, false), expected = clone(card, true), rows = [];
|
||||
for (const scale of scales) {
|
||||
const a = await raster(actual, box, scale, background), b = await raster(expected, box, scale, background);
|
||||
let differentPixels = 0, maxDelta = 0;
|
||||
for (let i = 0; i < a.data.length; i += 4) {
|
||||
let different = false;
|
||||
for (let c = 0; c < 4; c++) {
|
||||
const delta = Math.abs(a.data[i + c] - b.data[i + c]);
|
||||
maxDelta = Math.max(maxDelta, delta); different ||= delta !== 0;
|
||||
}
|
||||
if (different) differentPixels++;
|
||||
}
|
||||
rows.push({ scale, pixels: a.data.length / 4, differentPixels, maxDelta,
|
||||
...(images ? { actualPng: a.canvas.toDataURL(), expectedPng: b.canvas.toDataURL() } : {}) });
|
||||
}
|
||||
return rows;
|
||||
};
|
||||
window.__ledZoomOracle = { snapshot, unchanged, quality, full48 };
|
||||
}
|
||||
@@ -464,3 +464,18 @@ batch; all batches render as one positive-winding clip path per strip. The
|
||||
paths, excluding the joined Lit/DOM clip string. Timing and the 64 MiB
|
||||
warm-cycle heap-growth limit are unchanged; that is not an absolute browser
|
||||
or GPU memory bound.
|
||||
|
||||
Issue #789 retains every historical window and budget, and adds
|
||||
`cameraFullCycleLongTaskMaxMs`: one continuous observer starting before the
|
||||
first camera wheel, spanning the original scenario and 100-step series, full
|
||||
quality restoration, two animation frames, an immediate wheel restart, a second
|
||||
restoration and two frames, then at least 500 ms of tail. Raw Long Task entries
|
||||
and phase timestamps are saved in each sample's `cameraFullCycle`. The new
|
||||
metric inherits the existing 150 ms camera ceiling for median and p95; missing
|
||||
observations or quality that fails to restore within 1000 ms fail closed. Two
|
||||
frames are a paint opportunity, not proof of compositor presentation. The
|
||||
original `cameraSeriesLongTaskMaxMs` still ends at its original boundary. This
|
||||
addition prevents deferred restoration work from escaping all judged windows;
|
||||
it does not turn local timing into canonical evidence. Issue #789's acceptance
|
||||
additionally requires a 10% task margin (warm p95 ≤1350 ms and both camera-series
|
||||
and full-cycle p95 ≤135 ms), without changing the shared budget JSON.
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
/** Additional #789 window: do not let deferred quality restoration escape
|
||||
* the existing camera observer. Dependencies are passed explicitly so this
|
||||
* exact function runs in the page and has a deterministic Node witness.
|
||||
* Two rAFs are a paint opportunity, never proof of compositor presentation. */
|
||||
export async function finishLedCameraCycle({ fullQuality, input, frame, now, sleep }) {
|
||||
const marks = {};
|
||||
const restored = async (name) => {
|
||||
const started = now();
|
||||
marks[`${name}RestoreStart`] = started;
|
||||
while (!fullQuality()) {
|
||||
if (now() - started >= 1000) throw new Error('LED zoom quality did not restore within 1000 ms');
|
||||
await sleep(5);
|
||||
}
|
||||
marks[`${name}FullQuality`] = now();
|
||||
await frame();
|
||||
if (!fullQuality()) throw new Error('LED zoom quality changed during the final paint opportunity');
|
||||
marks[`${name}PaintOpportunity`] = now();
|
||||
};
|
||||
await restored('first');
|
||||
marks.restartInput = now();
|
||||
await input();
|
||||
await frame();
|
||||
marks.restartSettled = now();
|
||||
await restored('final');
|
||||
await sleep(500);
|
||||
await frame();
|
||||
if (!fullQuality()) throw new Error('LED zoom quality regressed in the restore tail');
|
||||
marks.tailEnd = now();
|
||||
return marks;
|
||||
}
|
||||
|
||||
/** The new full-cycle metric inherits, never relaxes, the old camera limit.
|
||||
* Missing/nonfinite observations are failures even in a partial local run. */
|
||||
export function cameraCycleFailures(metrics, limit) {
|
||||
const failures = [];
|
||||
for (const stat of ['median', 'p95']) {
|
||||
const value = metrics?.[stat];
|
||||
if (!Number.isFinite(value)) failures.push(`cameraFullCycleLongTaskMaxMs ${stat} missing`);
|
||||
else if (Number.isFinite(limit) && value > limit) failures.push(`cameraFullCycleLongTaskMaxMs ${stat} ${value} > ${limit}`);
|
||||
}
|
||||
return failures;
|
||||
}
|
||||
|
||||
/** Check every raw sample before aggregation: filtering missing values out of
|
||||
* a median/p95 must not make a partial or old report pass --merge. */
|
||||
export function cameraCycleSampleFailures(rows) {
|
||||
const failures = [];
|
||||
const phases = ['seriesEnd', 'firstRestoreStart', 'firstFullQuality', 'firstPaintOpportunity',
|
||||
'restartInput', 'restartSettled', 'finalRestoreStart', 'finalFullQuality',
|
||||
'finalPaintOpportunity', 'tailEnd'];
|
||||
rows.forEach((row, index) => {
|
||||
const cycle = row?.cameraFullCycle;
|
||||
const times = [cycle?.startTime, ...phases.map(key => cycle?.phases?.[key]), cycle?.endTime];
|
||||
const invalidTimes = times.some((time, i) => !Number.isFinite(time) || (i > 0 && time < times[i - 1]));
|
||||
const entries = cycle?.entries;
|
||||
if (invalidTimes || !Array.isArray(entries)
|
||||
|| entries.some(entry => !Number.isFinite(entry.startTime) || !Number.isFinite(entry.duration) || entry.duration < 0)
|
||||
|| !Number.isFinite(row.cameraFullCycleLongTaskMaxMs)) {
|
||||
failures.push(`sample ${index}: missing or invalid camera full-cycle evidence`);
|
||||
return;
|
||||
}
|
||||
if (cycle.phases.tailEnd - cycle.phases.finalPaintOpportunity < 500) {
|
||||
failures.push(`sample ${index}: camera full-cycle tail shorter than 500 ms`);
|
||||
}
|
||||
const maximum = Number(Math.max(0, ...entries.map(entry => entry.duration)).toFixed(2));
|
||||
if (row.cameraFullCycleLongTaskMaxMs !== maximum) {
|
||||
failures.push(`sample ${index}: camera full-cycle maximum differs from raw Long Tasks`);
|
||||
}
|
||||
});
|
||||
return failures;
|
||||
}
|
||||
@@ -6,8 +6,51 @@
|
||||
* Household exports are deliberately NOT fixtures in this public test.
|
||||
*/
|
||||
import { launch, check, finish } from './serve.mjs';
|
||||
import { installLedZoomOracle } from './helpers/led-zoom-oracle.mjs';
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const { page, browser } = await launch({ width: 1000, height: 820 }, 1);
|
||||
await page.evaluate(installLedZoomOracle);
|
||||
const exactResults = [];
|
||||
const outputDir = process.env.HP_LED_ZOOM_OUTPUT || 'artifacts/led-zoom-quality';
|
||||
async function exact48(label, capture = false) {
|
||||
// This is a CSS/raster contract, not the input/controller witness. The latter
|
||||
// is exercised with trusted wheel/touch in smoke_led_zoom_quality.mjs.
|
||||
const result = await page.evaluate(async capture => {
|
||||
const card = window.__card, oracle = window.__ledZoomOracle;
|
||||
if (card.hasAttribute('data-led-zoom-quality')) throw new Error('exact48 requires a rested owner');
|
||||
const before = oracle.snapshot(card);
|
||||
const idle = await oracle.full48(card);
|
||||
let active, coarseRaster;
|
||||
try {
|
||||
card.setAttribute('data-led-zoom-quality', 'coarse');
|
||||
active = oracle.quality(card);
|
||||
if (capture) coarseRaster = await oracle.full48(card, { scales: [2], images: true });
|
||||
} finally { card.removeAttribute('data-led-zoom-quality'); }
|
||||
const restored = await oracle.full48(card);
|
||||
return { idle, active, restored, invariants: oracle.unchanged(card, before), coarseRaster };
|
||||
}, capture);
|
||||
for (const phase of ['idle', 'restored']) for (const row of result[phase]) {
|
||||
check(`#789 ${label} ${phase} full48 scale=${row.scale}: every pixel equals frozen pre-change48`, row.differentPixels, 0);
|
||||
}
|
||||
check(`#789 ${label}: coarse paints exactly even24 midpoint bands and keeps light visible`,
|
||||
result.active.fields.length > 0 && result.active.fields.every(f => f.retained === 48 && f.painted === 24 && f.correct24 && f.visible));
|
||||
check(`#789 ${label}: quality alone preserves all DOM, attributes and cache counters`,
|
||||
result.invariants.stable && result.invariants.cacheStable);
|
||||
if (capture) {
|
||||
await mkdir(outputDir, { recursive: true });
|
||||
for (const row of result.coarseRaster) {
|
||||
// Negative witness: a leaked coarse mode must not pass the full48 oracle.
|
||||
check(`#789 ${label}: full48 oracle rejects unrecovered24`, row.differentPixels > 0);
|
||||
for (const [kind, key] of [['active24', 'actualPng'], ['full48', 'expectedPng']]) {
|
||||
await writeFile(join(outputDir, `${label.replace(/[^a-z0-9-]/gi, '-')}-${kind}.png`), Buffer.from(row[key].split(',')[1], 'base64'));
|
||||
}
|
||||
delete row.actualPng; delete row.expectedPng;
|
||||
}
|
||||
}
|
||||
exactResults.push({ label, ...result });
|
||||
}
|
||||
const cases = [
|
||||
{ name: 'short residual end', points: [[0.25, 0.35], [0.36225, 0.35]] },
|
||||
{ name: 'diagonal residual end', points: [[0.25, 0.3], [0.341, 0.365625]] },
|
||||
@@ -37,6 +80,8 @@ for (const scenario of cases) {
|
||||
}, { points, wall: scenario.wall, radiusCm });
|
||||
await page.waitForFunction(() => window.__card.shadowRoot.querySelector('[data-led-field="oracle"]'));
|
||||
await page.waitForTimeout(550);
|
||||
await exact48(`${scenario.name}-r${radiusCm}-${reversed ? 'reverse' : 'forward'}`,
|
||||
radiusCm === 60 && !reversed && ['acute outer turn', 'mixed free and blocked fans'].includes(scenario.name));
|
||||
const raster = await page.evaluate(async ({ points, wall, radiusCm }) => {
|
||||
const source = window.__card.shadowRoot.querySelector('.led-fields').cloneNode(true);
|
||||
// Isolate geometry/falloff from colour/brightness/animation, whose
|
||||
@@ -101,4 +146,60 @@ for (const scenario of cases) {
|
||||
}
|
||||
}
|
||||
}
|
||||
await finish(browser, results);
|
||||
// Additional complete-pixel cases: colour/alpha are NOT overwritten to white,
|
||||
// and overlap is composited over the same grey in reference and actual images.
|
||||
for (const extra of ['closed-door', 'colour-alpha', 'overlap']) {
|
||||
await page.evaluate(async extra => {
|
||||
const points = extra === 'closed-door'
|
||||
? [[.2, .22], [.55, .22], [.55, .5], [.2, .5], [.2, .22]]
|
||||
: [[.22, .3], [.5, .3], [.5, .45]];
|
||||
await window.__hpTest.setServerConfig(cfg => ({ ...cfg,
|
||||
spaces: [{ id: 'led-oracle', title: 'Colour and door oracle', cell_cm: 5,
|
||||
rooms: [{ id: 'room', name: 'Room', poly: [[.08, .08], [.92, .08], [.92, .72], [.08, .72]] }],
|
||||
partitions: extra === 'closed-door' ? [{ id: 'barrier', a: [.6, .1], b: [.6, .7], cm: 12 }] : [],
|
||||
openings: extra === 'closed-door' ? [{ id: 'door', type: 'door', x: .6, y: .36, angle: 90, length: .14,
|
||||
host: { kind: 'partition', id: 'barrier', t: 13 / 30 } }] : [],
|
||||
wall_segments: [], decor: [], wall_columns: [], settings: { glow_enabled: true, fill_mode: 'none' },
|
||||
led_strips: [{ id: 'oracle', marker: 'oracle-light', points }, ...(extra === 'overlap'
|
||||
? [{ id: 'overlap', marker: 'overlap-light', points: [[.3, .35], [.52, .35]] }] : [])] }],
|
||||
markers: [{ id: 'oracle-light', binding: 'virtual', is_light: true, space: 'led-oracle', room_id: 'room',
|
||||
glow_radius_cm: 120, glow_color: { c: '#75c52a', bri: .43 } },
|
||||
...(extra === 'overlap' ? [{ id: 'overlap-light', binding: 'virtual', is_light: true, space: 'led-oracle',
|
||||
room_id: 'room', glow_radius_cm: 120, glow_color: { c: '#b552dd', bri: .37 } }] : [])],
|
||||
}));
|
||||
}, extra);
|
||||
await page.waitForTimeout(550);
|
||||
if (extra === 'closed-door') {
|
||||
const doorWitness = await page.evaluate(async () => {
|
||||
const read = () => {
|
||||
const field = window.__card.shadowRoot.querySelector('[data-led-field="oracle"]');
|
||||
return { closed: field.dataset.closed,
|
||||
clip: field.querySelector('clipPath path').getAttribute('d') };
|
||||
};
|
||||
const withDoor = read();
|
||||
await window.__hpTest.setServerConfig(cfg => { cfg.spaces[0].openings = []; return cfg; });
|
||||
const withoutDoor = read();
|
||||
await window.__hpTest.setServerConfig(cfg => {
|
||||
cfg.spaces[0].openings = [{ id: 'door', type: 'door', x: .6, y: .36, angle: 90, length: .14,
|
||||
host: { kind: 'partition', id: 'barrier', t: 13 / 30 } }];
|
||||
return cfg;
|
||||
});
|
||||
const restoredDoor = read();
|
||||
return { closed: withDoor.closed === 'true',
|
||||
openingActuallyChangesVisibility: withDoor.clip !== withoutDoor.clip,
|
||||
restoredVisibility: withDoor.clip === restoredDoor.clip };
|
||||
});
|
||||
for (const [key, value] of Object.entries(doorWitness)) check(`#789 closed rectangle/door fixture: ${key}`, value);
|
||||
}
|
||||
// Explicit alpha .43 is a raster-composition fixture, independent of the
|
||||
// product's brightness→alpha mapping (covered by the live HA smoke).
|
||||
await page.evaluate(() => {
|
||||
const pools = window.__card.shadowRoot.querySelectorAll('.led-pool');
|
||||
pools[0].setAttribute('fill', '#75c52a'); pools[0].setAttribute('fill-opacity', '0.43');
|
||||
if (pools[1]) pools[1].setAttribute('fill-opacity', '0.37');
|
||||
});
|
||||
await exact48(extra, true);
|
||||
}
|
||||
check('#789: original seven × three radii × two directions × three scales × two idle phases are covered',
|
||||
exactResults.slice(0, 42).reduce((n, r) => n + r.idle.length + r.restored.length, 0), 252);
|
||||
await finish(browser, { distanceOracle: results, full48Oracle: exactResults });
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
/** #789: real input → temporary LED-only24 → exact full48. Not a benchmark. */
|
||||
import { launch, check, finish } from './serve.mjs';
|
||||
import { installLedZoomOracle } from './helpers/led-zoom-oracle.mjs';
|
||||
|
||||
const { page, browser } = await launch({ width: 1000, height: 820 }, 1, [], { hasTouch: true });
|
||||
await page.evaluate(installLedZoomOracle);
|
||||
const cdp = await page.context().newCDPSession(page);
|
||||
const results = [];
|
||||
const quality = () => page.evaluate(() => window.__ledZoomOracle.quality(window.__card));
|
||||
const idle = async () => {
|
||||
await page.waitForFunction(() => !window.__card.hasAttribute('data-led-zoom-quality')
|
||||
&& !window.__card._cameraTransition.active, undefined, { timeout: 1500 });
|
||||
await page.evaluate(() => window.__hpTest.settled());
|
||||
};
|
||||
const point = () => page.evaluate(() => {
|
||||
const r = window.__card.shadowRoot.querySelector('.stage').getBoundingClientRect();
|
||||
return { x: r.left + r.width * .55, y: r.top + r.height * .65 };
|
||||
});
|
||||
const wheel = async (dy = -80) => {
|
||||
const p = await point(); await page.mouse.move(p.x, p.y); await page.mouse.wheel(0, dy);
|
||||
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
|
||||
};
|
||||
const active = async label => {
|
||||
const q = await quality(); results.push({ label, ...q });
|
||||
check(`${label}: real scale change activates coarse`, q.coarse);
|
||||
check(`${label}: retained48 paints even24 with exact midpoint falloff, never hidden`,
|
||||
q.fields.length > 0 && q.fields.every(f => f.retained === 48 && f.painted === 24 && f.correct24 && f.visible));
|
||||
check(`${label}: ordinary light is not hidden by LED quality`, q.ordinary.length > 0 && q.ordinary.every(f => f.visible));
|
||||
return q.coarse;
|
||||
};
|
||||
const restored = async label => {
|
||||
await idle();
|
||||
const q = await quality();
|
||||
check(`${label}: owner removes coarse and paints original48`, !q.coarse
|
||||
&& q.fields.length > 0 && q.fields.every(f => f.retained === 48 && f.painted === 48 && f.visible));
|
||||
const rows = await page.evaluate(() => window.__ledZoomOracle.full48(window.__card, { scales: [1, 2] }));
|
||||
for (const row of rows) check(`${label}: all restored pixels equal frozen48 at scale ${row.scale}`, row.differentPixels, 0);
|
||||
};
|
||||
const setState = (state, attributes = {}) => page.evaluate(async ({ state, attributes }) => {
|
||||
const card = window.__card, previous = card.hass.states['light.ceiling'];
|
||||
card.hass = { ...card.hass, states: { ...card.hass.states,
|
||||
'light.ceiling': { ...previous, state, attributes: { ...previous.attributes, ...attributes } } } };
|
||||
await card.updateComplete;
|
||||
}, { state, attributes });
|
||||
const touch = (type, points) => cdp.send('Input.dispatchTouchEvent', { type,
|
||||
touchPoints: points.map(([x, y], i) => ({ x, y, id: i + 1, radiusX: 2, radiusY: 2, force: 1 })) });
|
||||
const twoFrames = () => page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
|
||||
|
||||
try {
|
||||
await setState('on', { rgb_color: [117, 197, 42], brightness: 190 });
|
||||
await page.evaluate(async () => {
|
||||
const card = window.__card, lamp = card.hass.states['light.floor_lamp'];
|
||||
card.hass = { ...card.hass, states: { ...card.hass.states,
|
||||
'light.floor_lamp': { ...lamp, state: 'on', attributes: { ...lamp.attributes, brightness: 200 } } } };
|
||||
await card.updateComplete;
|
||||
await window.__hpTest.setServerConfig(cfg => {
|
||||
const space = cfg.spaces.find(s => s.id === 'f1');
|
||||
space.settings = { ...space.settings, glow_enabled: true };
|
||||
space.led_strips = [
|
||||
{ id: 'quality', marker: 'd_light1', points: [[.15, .3], [.4, .3], [.4, .46]] },
|
||||
{ id: 'overlap', marker: 'quality-virtual', points: [[.2, .35], [.45, .35]] },
|
||||
];
|
||||
cfg.markers = [...(cfg.markers || []).filter(m => !['quality-virtual', 'd_light1', 'd_lamp'].includes(m.id)),
|
||||
{ id: 'd_light1', binding: 'device:d_light1', space: 'f1' },
|
||||
{ id: 'd_lamp', binding: 'device:d_lamp', space: 'f1' },
|
||||
{ id: 'quality-virtual', binding: 'virtual', is_light: true, space: 'f1',
|
||||
glow_radius_cm: 120, glow_color: { c: '#b552dd', bri: .43 } }];
|
||||
return cfg;
|
||||
});
|
||||
});
|
||||
await page.waitForFunction(() => window.__card.shadowRoot.querySelectorAll('[data-led-field]').length === 2);
|
||||
await page.waitForTimeout(600);
|
||||
check('fixture includes ordinary Glow outside LED quality scope', await page.evaluate(() =>
|
||||
window.__card.shadowRoot.querySelectorAll('[data-glow-source]').length > 0));
|
||||
const visibilityGuard = await page.evaluate(() => {
|
||||
const card = window.__card, parent = card.shadowRoot.querySelector('.led-fields');
|
||||
const original = parent.getAttribute('style'), result = {};
|
||||
const restore = () => original === null ? parent.removeAttribute('style') : parent.setAttribute('style', original);
|
||||
try {
|
||||
for (const [property, value] of [['opacity', '0'], ['display', 'none']]) {
|
||||
restore(); parent.style.setProperty(property, value);
|
||||
const q = window.__ledZoomOracle.quality(card);
|
||||
result[property] = q.fields.length === 2 && q.fields.every(f => !f.visible)
|
||||
&& q.ordinary.length > 0 && q.ordinary.every(f => f.visible);
|
||||
}
|
||||
} finally { restore(); }
|
||||
result.restored = parent.getAttribute('style') === original
|
||||
&& window.__ledZoomOracle.quality(card).fields.every(f => f.visible);
|
||||
return result;
|
||||
});
|
||||
for (const [kind, caught] of Object.entries(visibilityGuard))
|
||||
check(`visibility oracle negative control ${kind}: ancestor hiding cannot pass as visible light`, caught);
|
||||
for (const motion of ['no-preference', 'reduce']) {
|
||||
await page.emulateMedia({ reducedMotion: motion });
|
||||
await idle();
|
||||
await page.evaluate(() => { window.__qualityBefore = window.__ledZoomOracle.snapshot(window.__card); });
|
||||
await wheel();
|
||||
const works = await active(`${motion} trusted wheel`);
|
||||
if (process.argv.includes('--red-witness')) break;
|
||||
// Baseline fails the named active24 assertion, not setup/import/timeouts.
|
||||
if (!works) continue;
|
||||
const activeInvariant = await page.evaluate(() => window.__ledZoomOracle.unchanged(window.__card, window.__qualityBefore));
|
||||
check(`${motion}: active coarse preserves geometry, DOM and cache`, activeInvariant.stable && activeInvariant.cacheStable);
|
||||
await restored(`${motion} wheel idle`);
|
||||
const invariant = await page.evaluate(() => window.__ledZoomOracle.unchanged(window.__card, window.__qualityBefore));
|
||||
check(`${motion}: camera retains fields/masks/clips/tubes/ordinary DOM and geometry`, invariant.stable);
|
||||
check(`${motion}: camera never recomputes geometry or grows cache`, invariant.cacheStable);
|
||||
results.push({ motion, permittedTransparentHitWidthChanges: invariant.hitWidthChanges });
|
||||
await wheel();
|
||||
const still = await point(); await page.mouse.move(still.x, still.y); await page.mouse.down();
|
||||
check(`${motion}: stationary pointerdown cannot shorten an active quality lease`, (await quality()).coarse);
|
||||
await page.mouse.up();
|
||||
check(`${motion}: stationary pointerup cannot shorten an active quality lease`, (await quality()).coarse);
|
||||
await restored(`${motion} stationary pointer deadline`);
|
||||
|
||||
// Two native contacts, not _zoomAt or direct calls of pointer handlers.
|
||||
const p = await point();
|
||||
await page.evaluate(() => {
|
||||
window.__qualityTouchEvents = [];
|
||||
const stage = window.__card.shadowRoot.querySelector('.stage');
|
||||
for (const type of ['pointerdown', 'pointermove', 'pointerup', 'pointercancel', 'lostpointercapture']) {
|
||||
stage.addEventListener(type, e => window.__qualityTouchEvents.push({ type, trusted: e.isTrusted,
|
||||
id: e.pointerId, x: e.clientX, scale: window.__card._zoom }));
|
||||
}
|
||||
});
|
||||
await touch('touchStart', [[p.x - 50, p.y], [p.x + 50, p.y]]);
|
||||
await touch('touchMove', [[p.x - 75, p.y], [p.x + 75, p.y]]);
|
||||
await twoFrames();
|
||||
await active(`${motion} trusted pinch`);
|
||||
await page.waitForTimeout(230); // deadline is scale silence, not pointerup.
|
||||
check(`${motion}: held pinch restores48 after scale silence`, !(await quality()).coarse);
|
||||
await touch('touchMove', [[p.x - 90, p.y], [p.x + 90, p.y]]);
|
||||
await twoFrames();
|
||||
await active(`${motion} held pinch restart`);
|
||||
results.push({ motion, touchEvents: await page.evaluate(() => window.__qualityTouchEvents) });
|
||||
await touch('touchCancel', []);
|
||||
await restored(`${motion} cancelled pinch`);
|
||||
|
||||
await touch('touchStart', [[p.x - 50, p.y], [p.x + 50, p.y]]);
|
||||
await touch('touchMove', [[p.x - 65, p.y], [p.x + 65, p.y]]); await twoFrames();
|
||||
await active(`${motion} pinch before lost capture`);
|
||||
check(`${motion}: trusted contact capture is released`, await page.evaluate(() => {
|
||||
const stage = window.__card.shadowRoot.querySelector('.stage');
|
||||
const down = window.__qualityTouchEvents.filter(e => e.type === 'pointerdown').slice(-2);
|
||||
let released = 0;
|
||||
for (const e of down) {
|
||||
const target = [...window.__card.shadowRoot.querySelectorAll('*')].find(n => n.hasPointerCapture?.(e.id));
|
||||
if (target) { target.releasePointerCapture(e.id); released++; }
|
||||
}
|
||||
return released > 0;
|
||||
}));
|
||||
await twoFrames(); await page.waitForTimeout(230);
|
||||
check(`${motion}: lost capture cannot leave coarse stuck`, !(await quality()).coarse);
|
||||
await touch('touchCancel', []);
|
||||
|
||||
// Pan moves the centre without changing scale. A stationary two-contact
|
||||
// gesture also cannot request coarse or keep a stale deadline alive.
|
||||
await touch('touchStart', [[p.x - 50, p.y], [p.x + 50, p.y]]);
|
||||
check(`${motion}: stationary pinch stays full48`, !(await quality()).coarse);
|
||||
await touch('touchEnd', []);
|
||||
const beforePan = await page.evaluate(() => ({ zoom: window.__card._zoom, ...window.__card._view }));
|
||||
await page.mouse.move(p.x, p.y); await page.mouse.down();
|
||||
await page.mouse.move(p.x + 30, p.y + 18, { steps: 3 });
|
||||
check(`${motion}: pan stays full48`, !(await quality()).coarse);
|
||||
await page.mouse.up();
|
||||
const afterPan = await page.evaluate(() => ({ zoom: window.__card._zoom, ...window.__card._view }));
|
||||
check(`${motion}: pan witness actually moves only the centre`, beforePan.zoom === afterPan.zoom
|
||||
&& (beforePan.x !== afterPan.x || beforePan.y !== afterPan.y));
|
||||
|
||||
await page.locator('[data-hp="zoom-in"]').click();
|
||||
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
|
||||
await active(`${motion} toolbar zoom`);
|
||||
await idle();
|
||||
await page.locator('[data-hp="zoom-fit"]').click();
|
||||
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
|
||||
await active(`${motion} toolbar fit`);
|
||||
await restored(`${motion} fit idle`);
|
||||
const centre = await point();
|
||||
await page.mouse.move(centre.x, centre.y); await page.mouse.down();
|
||||
await page.mouse.move(centre.x + 45, centre.y + 20, { steps: 3 }); await page.mouse.up();
|
||||
await page.evaluate(() => {
|
||||
window.__centreBefore = { zoom: window.__card._zoom, ...window.__card._view };
|
||||
window.__centreCoarse = [];
|
||||
window.__centreObserver = new MutationObserver(rows => {
|
||||
for (const row of rows) if (row.attributeName === 'data-led-zoom-quality')
|
||||
window.__centreCoarse.push(row.oldValue, window.__card.getAttribute('data-led-zoom-quality'));
|
||||
});
|
||||
window.__centreObserver.observe(window.__card, { attributes: true, attributeOldValue: true });
|
||||
});
|
||||
await page.locator('[data-hp="zoom-fit"]').click(); await idle();
|
||||
const centred = await page.evaluate(() => {
|
||||
window.__centreObserver.disconnect();
|
||||
return { before: window.__centreBefore, after: { zoom: window.__card._zoom, ...window.__card._view },
|
||||
coarse: window.__centreCoarse };
|
||||
});
|
||||
check(`${motion}: centre-only fit actually changes centre`, centred.before.x !== centred.after.x || centred.before.y !== centred.after.y);
|
||||
check(`${motion}: centre-only fit preserves scale`, centred.before.zoom, centred.after.zoom);
|
||||
check(`${motion}: centre-only interpolated fit never requests coarse`, centred.coarse.includes('coarse'), false);
|
||||
}
|
||||
|
||||
if (!process.argv.includes('--red-witness') && results.some(r => r.coarse)) {
|
||||
// At the scale clamp a wheel event is not an actual scale change.
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
let reachedMax = false;
|
||||
for (let i = 0; i < 25; i++) {
|
||||
await wheel();
|
||||
if (await page.evaluate(() => window.__card._zoom >= 8)) { reachedMax = true; break; }
|
||||
}
|
||||
check('clamp witness reaches maximum by real wheel while coarse is active', reachedMax && (await quality()).coarse);
|
||||
await page.evaluate(() => { window.__clampStarted = performance.now(); });
|
||||
await page.waitForTimeout(45);
|
||||
await page.evaluate(() => { window.__colourCache = [...window.__card.shadowRoot.querySelectorAll('.led-fields')]
|
||||
.map(n => [n.dataset.ledCache, n.dataset.ledRecomputes]); });
|
||||
await wheel();
|
||||
check('clamped wheel during coarse does not cancel the current lease', (await quality()).coarse);
|
||||
await page.evaluate(async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, Math.max(0, 175 - (performance.now() - window.__clampStarted))));
|
||||
});
|
||||
check('clamped wheel does not extend the original lease', !(await quality()).coarse);
|
||||
await idle();
|
||||
const maxScale = await page.evaluate(() => window.__card._zoom);
|
||||
await wheel();
|
||||
check('clamped wheel leaves scale unchanged', await page.evaluate(() => window.__card._zoom), maxScale);
|
||||
check('clamped wheel neither enters coarse nor extends its deadline', !(await quality()).coarse);
|
||||
await page.locator('[data-hp="zoom-fit"]').click(); await idle();
|
||||
|
||||
await wheel();
|
||||
await setState('on', { rgb_color: [225, 70, 35], brightness: 120 });
|
||||
const changed = await quality();
|
||||
check('new RGB arrives while coarse, not after restoration', changed.coarse
|
||||
&& changed.fields.find(f => f.id === 'quality')?.fill.toLowerCase() === '#e14623');
|
||||
const rgbAlpha = Number(changed.fields.find(f => f.id === 'quality')?.alpha);
|
||||
await setState('on', { brightness: 40 });
|
||||
const dimmed = await quality(), alpha = dimmed.fields.find(f => f.id === 'quality')?.alpha;
|
||||
check('brightness-only update dims current coarse field immediately', dimmed.coarse
|
||||
&& Number(alpha) > 0 && Number(alpha) < rgbAlpha);
|
||||
await restored('latest RGB/brightness');
|
||||
check('restore retains latest RGB and brightness', await page.evaluate(alpha => {
|
||||
const pool = window.__card.shadowRoot.querySelector('[data-led-field="quality"] .led-pool');
|
||||
return pool.getAttribute('fill').toLowerCase() === '#e14623' && pool.getAttribute('fill-opacity') === alpha;
|
||||
}, alpha));
|
||||
check('RGB/brightness updates never recompute geometry or grow caches', await page.evaluate(() =>
|
||||
JSON.stringify(window.__colourCache) === JSON.stringify([...window.__card.shadowRoot.querySelectorAll('.led-fields')]
|
||||
.map(n => [n.dataset.ledCache, n.dataset.ledRecomputes]))));
|
||||
for (const state of ['off', 'unavailable']) {
|
||||
await wheel(); await setState(state); await page.waitForTimeout(650);
|
||||
check(`${state} during coarse is never resurrected by restore`, await page.evaluate(() =>
|
||||
!window.__card.shadowRoot.querySelector('[data-led-field="quality"]')));
|
||||
await wheel(); await setState('on', { rgb_color: [117, 197, 42], brightness: 190 });
|
||||
const appeared = await quality(), newFields = appeared.fields.filter(f => f.id === 'quality');
|
||||
check(`new on field during coarse uses current quality (${state})`, appeared.coarse && newFields.length === 1
|
||||
&& newFields.every(f => f.retained === 48 && f.painted === 24 && f.correct24));
|
||||
await page.waitForTimeout(650); await restored(`on after ${state}`);
|
||||
}
|
||||
|
||||
await page.evaluate(() => {
|
||||
window.__resizeCoarse = false;
|
||||
window.__resizeObserver = new MutationObserver(rows => {
|
||||
window.__resizeCoarse ||= rows.some(row => row.oldValue === 'coarse')
|
||||
|| window.__card.hasAttribute('data-led-zoom-quality');
|
||||
});
|
||||
window.__resizeObserver.observe(window.__card, { attributes: true, attributeOldValue: true,
|
||||
attributeFilter: ['data-led-zoom-quality'] });
|
||||
});
|
||||
await page.setViewportSize({ width: 1020, height: 820 }); await page.waitForTimeout(220);
|
||||
check('structural resize alone never activates coarse, including intermediate frames', await page.evaluate(() => {
|
||||
window.__resizeObserver.disconnect(); return !window.__resizeCoarse && !window.__card.hasAttribute('data-led-zoom-quality');
|
||||
}));
|
||||
await page.setViewportSize({ width: 1000, height: 820 }); await page.waitForTimeout(220);
|
||||
await page.evaluate(async () => {
|
||||
const stat = document.createElement('houseplan-space-card');
|
||||
stat.setConfig({ type: 'custom:houseplan-space-card', space: 'f1', light_pools: true, live_states: true });
|
||||
stat.hass = window.__card.hass; stat.style.cssText = 'position:fixed;left:-1200px;width:700px';
|
||||
document.body.appendChild(stat); window.__qualityStatic = stat; await stat.updateComplete;
|
||||
});
|
||||
await page.waitForTimeout(600); await wheel();
|
||||
check('static owner never inherits main owner coarse', await page.evaluate(() => {
|
||||
const q = window.__ledZoomOracle.quality(window.__qualityStatic);
|
||||
return !q.coarse && q.fields.length === 2 && q.fields.every(f => f.retained === 48 && f.painted === 48);
|
||||
}));
|
||||
await idle();
|
||||
for (const mode of ['plan', 'view']) {
|
||||
await wheel(); await page.evaluate(mode => window.__hpTest.setMode(mode), mode);
|
||||
check(`mode ${mode} resets coarse`, !(await quality()).coarse);
|
||||
}
|
||||
await wheel(); await page.evaluate(async () => { await window.__hpTest.setVolumetricView(true); });
|
||||
check('projection adoption resets coarse', !(await quality()).coarse);
|
||||
await page.evaluate(() => window.__hpTest.setVolumetricView(false));
|
||||
await wheel(); await page.evaluate(() => window.__hpTest.switchSpace('garden'));
|
||||
check('space switch resets coarse', !(await quality()).coarse);
|
||||
await page.evaluate(() => window.__hpTest.switchSpace('f1')); await page.waitForTimeout(600);
|
||||
await wheel();
|
||||
check('document hidden listener resets coarse synchronously', await page.evaluate(() => {
|
||||
// Browser visibility seam: dispatch the real listener, never call the
|
||||
// card's private visibility handler. Restore the original descriptor.
|
||||
const descriptor = Object.getOwnPropertyDescriptor(document, 'visibilityState');
|
||||
try {
|
||||
Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => 'hidden' });
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
return !window.__card.hasAttribute('data-led-zoom-quality');
|
||||
} finally {
|
||||
if (descriptor) Object.defineProperty(document, 'visibilityState', descriptor);
|
||||
else delete document.visibilityState;
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
}
|
||||
}));
|
||||
await wheel();
|
||||
check('disconnect immediately removes coarse', await page.evaluate(() => {
|
||||
const c = window.__card; window.__qualityParent = c.parentNode; c.remove();
|
||||
return !c.hasAttribute('data-led-zoom-quality');
|
||||
}));
|
||||
await page.waitForTimeout(220);
|
||||
check('cancelled callback cannot reapply coarse to disconnected owner', !(await quality()).coarse);
|
||||
await page.evaluate(async () => { window.__qualityParent.appendChild(window.__card); await window.__card.updateComplete; });
|
||||
await page.waitForTimeout(650);
|
||||
await wheel(); await active('reconnected owner new wheel'); await restored('reconnected owner');
|
||||
await page.evaluate(() => window.__qualityStatic.remove());
|
||||
}
|
||||
} finally {
|
||||
await cdp.detach();
|
||||
await finish(browser, results);
|
||||
}
|
||||
@@ -551,6 +551,14 @@ background, layer opacity or CSS transform) and lives in the core View bundle.
|
||||
The component stays the sole camera writer; ownership boundaries and timings:
|
||||
[CANVAS](CANVAS.md) › View/editor camera handoff and §5.
|
||||
|
||||
LED zoom quality (#789) is a separate owner-local scale-activity deadline,
|
||||
not another camera writer or a source transition. Actual changes to viewport
|
||||
width/height set a host attribute before live paint; 160 ms of inactivity clears
|
||||
it. CSS changes only the existing LED mask bands (48 retained, 24 painted),
|
||||
without a geometry rebuild or a second SVG. Camera/lifecycle cancellation
|
||||
clears the deadline; static cards do not participate. Ordinary Glow's pan/fade
|
||||
blur policy remains independent.
|
||||
|
||||
## Settings tiers (owner's principle, 2026-07-26)
|
||||
|
||||
Four levels: **global (`config.settings`) → space (`space.settings`) → room
|
||||
|
||||
@@ -275,6 +275,13 @@ produce a zero-sized SVG `viewBox`.
|
||||
and disconnect cancel or settle it before taking ownership. Reduced motion
|
||||
always commits the exact target immediately. View persists only the settled
|
||||
target once; editor camera remains session-only.
|
||||
* **LED gradient during zoom (#789).** Actual scale changes temporarily paint
|
||||
24 of the retained 48 mask bands; light, geometry and the coloured strip stay
|
||||
visible. Full quality returns after 160 ms of scale inactivity, including
|
||||
a paused pinch. This also covers reduced-motion wheel/fit/button zoom, but
|
||||
not pure pan, clamped no-ops, structural resize or mode transitions. Camera
|
||||
ownership changes and teardown reset the transient state; static cards are
|
||||
unaffected. The detailed light contract is in [LIGHT](LIGHT.md).
|
||||
* **Pan** — available at **every zoom**, in view mode and in every
|
||||
editor, and bounded by the content frame inflated by
|
||||
`PAN_SLACK = 1.0` of `max(view, frame)` on each side. You can walk
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
- LED Glow temporarily uses a lighter gradient while zooming, without hiding
|
||||
the light; full quality returns automatically when zooming pauses. Panning
|
||||
and stationary plans keep their original quality
|
||||
([#789](https://github.com/Matysh/houseplan-card/issues/789)).
|
||||
|
||||
- Removed redundant card updates when many lights appear together and repeated
|
||||
light-barrier calculations within a frame; the light geometry and fades are
|
||||
unchanged. Pending LED updates are also cleared when leaving their space or
|
||||
|
||||
@@ -8,6 +8,11 @@
|
||||
|
||||
## Не выпущено
|
||||
|
||||
- При зуме свечение LED-лент временно использует облегчённый градиент, не
|
||||
исчезая с плана; после паузы полное качество возвращается автоматически.
|
||||
Панорамирование и неподвижный план сохраняют прежнее качество
|
||||
([#789](https://github.com/Matysh/houseplan-card/issues/789)).
|
||||
|
||||
- Убраны лишние обновления карточки при одновременном появлении множества
|
||||
источников света и повторные расчёты препятствий в одном кадре. Форма света
|
||||
и плавность переходов сохранены. Отложенные обновления LED также очищаются
|
||||
|
||||
@@ -192,6 +192,15 @@ new `size-pack` with 467.63 MiB; the accepted upper bound is 487.63 MiB.
|
||||
that navigation witness checks its steady state, not a newly invented fade.
|
||||
The complete
|
||||
`led-strips-v1` performance gate remains separate from these correctness checks.
|
||||
- LED zoom quality (#789) keeps 48 mask paths and temporarily paints 24 while
|
||||
the camera scale changes. `zoom-scale-activity.test.mjs` uses a fake clock for
|
||||
the 160 ms deadline, no-op/pan and stale callbacks; `smoke_led_zoom_quality`
|
||||
covers real input and restoration. Full-quality pixels are compared with the
|
||||
pre-change 48-band construction, not with the coarse frame. The performance
|
||||
runner retains its old windows and adds a continuous observer through restore,
|
||||
immediate restart and a 500 ms tail; `led-camera-cycle.test.mjs` verifies that
|
||||
protocol and its fail-closed checks. Local timing is diagnostic, not a substitute
|
||||
for the exact-SHA Linux performance gate.
|
||||
- Pure backend on native Windows (with no HA plugin autoload): use the explicit
|
||||
`python -Arguments @(...)` invocation above after setting
|
||||
`$env:PYTEST_DISABLE_PLUGIN_AUTOLOAD='1'`.
|
||||
|
||||
@@ -429,6 +429,16 @@ only the geometry differs:
|
||||
luminance bands of the shared `GLOW_FALLOFF` in one mask, so corners and the
|
||||
closing of a loop neither seam nor double the brightness. Intensity and the 500 ms fade
|
||||
are the shared `glowAlpha` / `GLOW_FADE_MS`.
|
||||
- **Zoom quality (#789).** Only while the main card's actual scale changes,
|
||||
the same mask paints 24 bands at the midpoints of the same falloff. All
|
||||
48 DOM paths, their widths, visibility/floor clips, source colour and alpha
|
||||
remain intact; light is never hidden. After 160 ms without a scale change,
|
||||
the original 48-band paint returns without a new fade. A stationary pinch
|
||||
restores quality even while contacts are held. Pure pan, a clamped no-op,
|
||||
structural resize and View/editor transitions do not activate it; the
|
||||
static card always uses full quality. Space/mode/projection/adoption,
|
||||
document hiding and disconnect reset the owner-local state. HA changes
|
||||
during zoom remain authoritative when full quality returns.
|
||||
- **Scheduling.** Sources first seen by one synchronous render share one
|
||||
entering-frame callback per owner/runtime, not one complete update per
|
||||
source (#789). A microtask seals the batch so a later rAF cannot bring a
|
||||
|
||||
+1
-1
@@ -31,7 +31,7 @@ Everything computable from the tree and git; regenerate, never edit by hand
|
||||
| Branches | `main` carries stable releases only; pre-release tags point at `dev`. Work lands on `dev`, which is equal to or ahead of `main`, never behind. |
|
||||
| LED regression acceptance | #788 covers mixed face/free and fractional-coordinate joins, winding-safe field unions, retained endpoints/acute vertices and circle–wall transition events. The actual owner export is exercised locally; public fixtures are synthetic. Pixel oracles, not just SVG counts, protect the visible falloff. |
|
||||
| LED core colour | #790 makes the on core follow the resolved light colour with or without Glow. Off, unavailable and non-live static strips keep their neutral presentation; geometry and field brightness are unchanged. |
|
||||
| LED performance work | #789 batches entering callbacks per owner and reuses light-barrier revisions within one synchronous render. No change to bands, emitters, clips or raster precision. The complete 50×50 performance acceptance remains mandatory; these structural changes alone do not certify the camera budget. |
|
||||
| LED performance work | #789 batches source entry, reuses render-local barriers and temporarily paints 24 of 48 retained gradient bands during actual zoom; full quality returns after 160 ms of scale inactivity. Emitters, clips and idle pixels are unchanged. Exact-SHA 50×50 acceptance includes restoration and quick restart; implementation alone does not certify the camera budget. |
|
||||
| Warm remount | Delayed editor restoration preserves the original View camera, reserves pending chrome space, and yields synchronously to mode/space navigation (#762). Header/stage dimensions are published as one settled pair. Canonical: `docs/WARM-REMOUNT.md`. |
|
||||
| 2.5D View | Public since #649: the installation-wide General settings switch `settings.volumetric_view` (Display). Flat stays the default and byte-for-byte unchanged; editors and `houseplan-space-card` stay Flat. Canonical: `docs/ISOMETRIC.md`. |
|
||||
| Input support | Owner's rule since 2026-08-08: View and kiosk are fully supported and release-blocking on touch; the three editors are desktop-first, touch editing is best effort. Canonical: `docs/TOUCH-SUPPORT.md`. |
|
||||
|
||||
@@ -60,6 +60,12 @@ stay on one promoted compositor path from the first movement until the terminal
|
||||
frame; a budgeted redraw or an unrelated Home Assistant state update must never
|
||||
flash white, become transparent or momentarily expose the stage background.
|
||||
This contract applies equally to browsers and HA Companion WebViews.
|
||||
LED light remains continuously visible during zoom, but its gradient may use
|
||||
24 instead of 48 bands while the scale actually changes (#789, owner-approved
|
||||
2026-10-04). The original quality returns after 160 ms without a scale change,
|
||||
including a pause with fingers still down. This is the same bounded policy on
|
||||
mouse and touch, not permission to hide lighting or simplify geometry, walls,
|
||||
openings or idle frames. Pure pan and static cards keep full LED quality.
|
||||
`smoke_daycycle_layer_budget` additionally proves that the first camera move on
|
||||
a 1 cm/grid-point day-cycle plan switches its filtered paper silhouette to the
|
||||
screen-bounded fallback, leaves no implicit overlap-promoted plan layer, and
|
||||
|
||||
@@ -492,6 +492,10 @@ desktop: для точного рисования, Resize, модификато
|
||||
- щипок и панорамирование по-прежнему следуют за пальцами без задержки; при
|
||||
включённом в системе уменьшении движения любой масштаб меняется мгновенно;
|
||||
- панорамирование работает при любом масштабе;
|
||||
- при изменении масштаба свечение LED-лент временно рисуется с менее подробным
|
||||
градиентом для плавной навигации; свет остаётся видимым, а после короткой
|
||||
паузы автоматически возвращается полное качество. Простое перемещение плана
|
||||
качество не меняет;
|
||||
- вписывание комнаты учитывает видимый пол и её стены, но не устройства,
|
||||
подписи, Glow, солнечные лучи, подложку или декор; ручной масштаб/панорама и
|
||||
**«Вписать всё»** отменяют удержание выбранной комнаты при следующем resize;
|
||||
|
||||
@@ -14,11 +14,11 @@ to prove that the Node witness actually kills it.
|
||||
| --- | ---: | --- |
|
||||
| Performance threshold | 4 | The witness measures real browser wall-time or frame work; a pure assertion cannot prove the budget. |
|
||||
| Browser harness integrity | 4 | The mutation breaks page-error, round-trip or page-registration observation in the browser harness itself. |
|
||||
| Paint, cascade and layer composition | 37 | The invariant depends on computed CSS, SVG paint, clipping, stacking or pixels produced by Chromium. |
|
||||
| Pointer geometry and trusted interaction | 47 | The invariant depends on hit testing, pointer capture, touch/keyboard dispatch or live DOM geometry. |
|
||||
| Paint, cascade and layer composition | 38 | The invariant depends on computed CSS, SVG paint, clipping, stacking or pixels produced by Chromium. |
|
||||
| Pointer geometry and trusted interaction | 49 | The invariant depends on hit testing, pointer capture, touch/keyboard dispatch or live DOM geometry. |
|
||||
| Responsive DOM layout | 38 | The invariant depends on measured element boxes, responsive breakpoints, native/HA dialog shells or focusable target size. |
|
||||
| Custom-element and HA browser lifecycle | 104 | The invariant crosses Lit/custom-element lifecycle, browser storage/events, lazy loading or a complete HA-card state transition. |
|
||||
| **Total** | **234 / 200** | Above the guideline `mutation-gate --check` warns rather than fails (#699); each guard above it is held by its own reason in this inventory and its `because`. |
|
||||
| **Total** | **237 / 200** | Above the guideline `mutation-gate --check` warns rather than fails (#699); each guard above it is held by its own reason in this inventory and its `because`. |
|
||||
|
||||
## Measured effect
|
||||
|
||||
@@ -94,6 +94,7 @@ The invariant depends on computed CSS, SVG paint, clipping, stacking or pixels p
|
||||
- `iso-theme-dark-wall-rule-returns`
|
||||
- `led-badge-dropped`
|
||||
- `led-core-white-under-glow`
|
||||
- `led-zoom-coarse-midpoint-wrong`
|
||||
- `led-field-endpoint-dropped`
|
||||
- `led-field-disc-cancels-fan`
|
||||
- `led-field-compound-clip-children`
|
||||
@@ -132,6 +133,8 @@ The invariant depends on hit testing, pointer capture, touch/keyboard dispatch o
|
||||
- `furniture-wall-runtime-drops-raw-intent`
|
||||
- `led-pan-adds-point`
|
||||
- `led-pinch-calls-action`
|
||||
- `led-zoom-quality-never-coarse`
|
||||
- `led-zoom-noop-clears-lease`
|
||||
- `live-pinch-compositor-demoted-on-active-lit-commit`
|
||||
- `opening-dimension-overlay-hidden`
|
||||
- `opening-search-hides-none`
|
||||
|
||||
@@ -4,5 +4,5 @@
|
||||
"hostRefs": 5064,
|
||||
"portPrivates": 103,
|
||||
"harnessPrivates": 100,
|
||||
"bundleBytes": 2675421
|
||||
"bundleBytes": 2678324
|
||||
}
|
||||
|
||||
@@ -14234,6 +14234,51 @@ const MUTANT_DEFINITIONS = [
|
||||
replace: " ? body.slice(0, at).replace(/\\s+$/, '')",
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'led-zoom-quality-never-coarse',
|
||||
guard: 'node demo/smoke_led_zoom_quality.mjs',
|
||||
because: '#789 AC5: trusted wheel and touch input must activate the owner CSS before camera paint; '
|
||||
+ 'a helper unit cannot prove real custom-element wiring or painted band count',
|
||||
patches: [{
|
||||
file: 'src/houseplan-card.ts',
|
||||
find: " if (active) this.setAttribute('data-led-zoom-quality', 'coarse');",
|
||||
replace: " if (active) this.setAttribute('data-led-zoom-quality', 'full');",
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'led-zoom-coarse-midpoint-wrong',
|
||||
guard: 'node demo/smoke_led_zoom_quality.mjs',
|
||||
because: '#789 AC2: the actual connected SVG/CSS must paint even24 at each coarse interval midpoint; '
|
||||
+ 'retaining 48 nodes is not proof of the displayed gradient',
|
||||
patches: [{
|
||||
file: 'src/led-strip-field.ts',
|
||||
find: ' const coarse = k % 2 === 0 ? grey(falloffAt(inner)) : null;',
|
||||
replace: ' const coarse = k % 2 === 0 ? grey(falloffAt(outer)) : null;',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'led-zoom-quiet-deadline-shortened',
|
||||
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
|
||||
+ '&& node --test test/zoom-scale-activity.test.mjs',
|
||||
because: '#789 AC5: deterministic clock tests require full48 only after 160 ms since the final real scale change',
|
||||
patches: [{
|
||||
file: 'src/zoom-scale-activity.ts',
|
||||
find: 'export const ZOOM_SCALE_QUIET_MS = 160;',
|
||||
replace: 'export const ZOOM_SCALE_QUIET_MS = 80;',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'led-zoom-noop-clears-lease',
|
||||
guard: 'node demo/smoke_led_zoom_quality.mjs',
|
||||
because: '#789 AC5: a second trusted wheel at the scale clamp must not clear the active deadline; '
|
||||
+ 'the pure timer unit cannot prove how the camera no-op branch calls structural cancellation',
|
||||
patches: [{
|
||||
file: 'src/houseplan-card.ts',
|
||||
find: ' // A clamped/no-op command neither starts nor shortens the zoom lease.\n'
|
||||
+ ' this._cameraTransition.cancel(false);\n this._cameraTransitionFit = null;',
|
||||
replace: ' this._cancelCameraTransition(false);',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'glow-barrier-render-pass-wiring-skipped',
|
||||
guard: 'node demo/smoke_led_strip_glow.mjs',
|
||||
|
||||
@@ -600,6 +600,14 @@ export const SMOKE_LINKS = [
|
||||
+ 'isometric smoke inspects door/window/gate bases from the same helper, but neither '
|
||||
+ 'browser bundle exposes the helper name in its test steps',
|
||||
},
|
||||
{
|
||||
files: ['src/zoom-scale-activity.ts', 'src/led-strip-field.ts'],
|
||||
symbols: ['_applyCameraTransitionFrame', '_startCameraTransition', '_cancelCameraTransition', '_zoomAt'],
|
||||
smokes: ['smoke_led_zoom_quality.mjs'],
|
||||
because: '#789: actual wheel/pinch and camera lifecycle must select retained48/coarse24 '
|
||||
+ 'before viewport paint and restore full48 without changing LED geometry or ordinary Glow; '
|
||||
+ 'the helper unit cannot observe custom-element CSS, HA updates or real input wiring',
|
||||
},
|
||||
{
|
||||
// #780: the LED chunks are lazy modules reached only through the bundle;
|
||||
// the smokes drive the Devices editor, the View and the static card by
|
||||
|
||||
+13
-2
@@ -7,6 +7,7 @@
|
||||
* The icon layout is stored on the server (houseplan/layout/*), fallback — localStorage.
|
||||
*/
|
||||
import { ledAnchor, ledRelease, ledRuntime, ledStripsByMarker, ledVisible } from './led-strip-gate';
|
||||
import { ZoomScaleActivity } from './zoom-scale-activity';
|
||||
import { ledButton, ledEditorFor, ledHistory, ledImportNote, ledSection, ledWallsNote } from './led-strip-card';
|
||||
import { LitElement, html, svg, nothing, noChange, TemplateResult, PropertyValues, type PropertyDeclaration } from 'lit';
|
||||
import { cache as litCache } from 'lit/directives/cache.js';
|
||||
@@ -1058,6 +1059,10 @@ export class HouseplanCard extends LitElement {
|
||||
/** #82: one camera-only transition inside a settled mode. It never owns
|
||||
* chrome/background coordinates — those remain exclusive to #101. */
|
||||
private _cameraTransitionFit: ModeViewBox | null = null;
|
||||
private readonly _zoomScaleActivity = new ZoomScaleActivity((active) => {
|
||||
if (active) this.setAttribute('data-led-zoom-quality', 'coarse');
|
||||
else this.removeAttribute('data-led-zoom-quality');
|
||||
});
|
||||
private readonly _cameraTransition = new CameraTransitionController({
|
||||
frame: (state) => this._applyCameraTransitionFrame(state),
|
||||
settled: (state) => this._settleCameraTransition(state),
|
||||
@@ -1091,6 +1096,7 @@ export class HouseplanCard extends LitElement {
|
||||
|
||||
private _applyCameraTransitionFrame(state: CameraTransitionState): void {
|
||||
const presented = this._normalizeCameraState(state.presented);
|
||||
this._zoomScaleActivity.change(this._cameraState().viewBox, presented.viewBox);
|
||||
// Retargeting must start from the camera that was actually painted, not
|
||||
// from an unclamped interpolation hidden inside the controller.
|
||||
state.presented = presented;
|
||||
@@ -1123,6 +1129,7 @@ export class HouseplanCard extends LitElement {
|
||||
? this._cameraTransition.presented?.zoom
|
||||
: undefined;
|
||||
this._cameraTransition.cancel(commitTarget);
|
||||
if (!keepPresented) this._zoomScaleActivity.reset();
|
||||
this._cameraTransitionFit = null;
|
||||
if (presentedZoom !== undefined && reason !== 'room') this._saveZoom();
|
||||
}
|
||||
@@ -1148,7 +1155,9 @@ export class HouseplanCard extends LitElement {
|
||||
const runningTarget = this._cameraTransition.target;
|
||||
if (runningTarget && sameCameraState(runningTarget, target)) return false;
|
||||
if (sameCameraState(current, target)) {
|
||||
this._cancelCameraTransition(false);
|
||||
// A clamped/no-op command neither starts nor shortens the zoom lease.
|
||||
this._cameraTransition.cancel(false);
|
||||
this._cameraTransitionFit = null;
|
||||
return false;
|
||||
}
|
||||
this._activateSafeDayCycleOutline();
|
||||
@@ -2690,6 +2699,7 @@ export class HouseplanCard extends LitElement {
|
||||
this._editorRuntimeLoadingVisible = false;
|
||||
this._modeTransition.dispose();
|
||||
this._cameraTransition.dispose();
|
||||
this._zoomScaleActivity.dispose();
|
||||
this._cameraTransitionFit = null;
|
||||
this._modeTransitionVisual = null;
|
||||
this._modeTransitionPreparing = false;
|
||||
@@ -6564,9 +6574,10 @@ export class HouseplanCard extends LitElement {
|
||||
/** Immediate path for direct pinch: keep the point under the fingers. */
|
||||
private _zoomAt(sx: number, sy: number, newZoom: number): void {
|
||||
this._clearRoomFocus();
|
||||
this._cancelCameraTransition(false);
|
||||
if (this._cameraTransition.active) this._cancelCameraTransition(false);
|
||||
const result = this._cameraTargetAt(sx, sy, newZoom);
|
||||
if (!result) return;
|
||||
this._zoomScaleActivity.change(this._cameraState().viewBox, result.target.viewBox);
|
||||
if (this._tool === 'opening') {
|
||||
this._cursorPt = null;
|
||||
this._clearOpeningPlacement(false);
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
* That separation keeps walls opaque without exposing piece boundaries in the
|
||||
* gradient at straight cuts or corners.
|
||||
*/
|
||||
import { noChange, svg, type TemplateResult } from 'lit';
|
||||
import { noChange, nothing, svg, type TemplateResult } from 'lit';
|
||||
import { Directive, directive, type PartInfo } from 'lit/directive.js';
|
||||
import { repeat } from 'lit/directives/repeat.js';
|
||||
import {
|
||||
@@ -433,7 +433,10 @@ export function renderLedField(input: LedFieldInput): TemplateResult {
|
||||
const bands = Array.from({ length: LED_FIELD_BANDS }, (_, k) => {
|
||||
const outer = 1 - k / LED_FIELD_BANDS;
|
||||
const inner = 1 - (k + 1) / LED_FIELD_BANDS;
|
||||
return { half: outer, value: falloffAt((outer + inner) / 2) };
|
||||
// Even bands already have the outer widths of the 24-band intervals.
|
||||
// CSS switches only their midpoint ink; idle attributes remain exact48.
|
||||
const coarse = k % 2 === 0 ? grey(falloffAt(inner)) : null;
|
||||
return { half: outer, value: falloffAt((outer + inner) / 2), coarse };
|
||||
});
|
||||
// The performance witness (led-strips-v1) reads the bounded cache from the DOM.
|
||||
return svg`<g class="led-fields" pointer-events="none" aria-hidden="true"
|
||||
@@ -459,8 +462,9 @@ export function renderLedField(input: LedFieldInput): TemplateResult {
|
||||
x="${box.x}" y="${box.y}" width="${box.w}" height="${box.h}"
|
||||
color-interpolation="sRGB" style="mask-type:luminance">
|
||||
<rect x="${box.x}" y="${box.y}" width="${box.w}" height="${box.h}" fill="black"></rect>
|
||||
<g clip-path="url(#hp-led-visible-${id})">
|
||||
<g class="led-field-bands" clip-path="url(#hp-led-visible-${id})">
|
||||
${bands.map((band) => svg`<path d="${geometry.d}" fill="none" stroke="${grey(band.value)}"
|
||||
style=${band.coarse === null ? nothing : `--hp-led-coarse-stroke:${band.coarse}`}
|
||||
stroke-width="${2 * band.half * r}" stroke-linecap="round" stroke-linejoin="round"></path>`)}
|
||||
</g>
|
||||
</mask>
|
||||
|
||||
@@ -472,6 +472,13 @@ export const planStyles = css`
|
||||
.glow-spot.is-leaving {
|
||||
opacity: 0;
|
||||
}
|
||||
/* Zoom only changes band paint, never the field/clip DOM or source fade. */
|
||||
:host([data-led-zoom-quality="coarse"]) .led-field-bands > path:nth-child(even) {
|
||||
display: none;
|
||||
}
|
||||
:host([data-led-zoom-quality="coarse"]) .led-field-bands > path:nth-child(odd) {
|
||||
stroke: var(--hp-led-coarse-stroke);
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.glow-spot {
|
||||
transition: none;
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
/** A paint-only zoom lease: pan, invalid cameras and pointer lifetimes do not own it. */
|
||||
export const ZOOM_SCALE_QUIET_MS = 160;
|
||||
|
||||
interface Extent { w: number; h: number }
|
||||
export interface ZoomScaleClock {
|
||||
setTimeout(callback: () => void, delay: number): number;
|
||||
clearTimeout(handle: number): void;
|
||||
}
|
||||
|
||||
const browserClock: ZoomScaleClock = {
|
||||
setTimeout: (callback, delay) => window.setTimeout(callback, delay),
|
||||
clearTimeout: (handle) => window.clearTimeout(handle),
|
||||
};
|
||||
const valid = (view: Extent): boolean =>
|
||||
Number.isFinite(view.w) && view.w > 0 && Number.isFinite(view.h) && view.h > 0;
|
||||
// log/exp camera interpolation can perturb an unchanged extent by a few ULPs.
|
||||
// This is arithmetic uncertainty, not a screen-space or geometry tolerance.
|
||||
const sameExtent = (a: number, b: number): boolean =>
|
||||
Math.abs(a - b) <= 16 * Number.EPSILON * Math.max(Math.abs(a), Math.abs(b));
|
||||
|
||||
/** One owner, one timer; no camera writes, scene references or render requests. */
|
||||
export class ZoomScaleActivity {
|
||||
private timer: number | null = null;
|
||||
private generation = 0;
|
||||
private current = false;
|
||||
|
||||
constructor(
|
||||
private readonly changed: (active: boolean) => void,
|
||||
private readonly clock: ZoomScaleClock = browserClock,
|
||||
) {}
|
||||
|
||||
get active(): boolean { return this.current; }
|
||||
|
||||
change(before: Extent, after: Extent): boolean {
|
||||
if (!valid(before) || !valid(after)
|
||||
|| (sameExtent(before.w, after.w) && sameExtent(before.h, after.h))) return false;
|
||||
if (this.timer !== null) this.clock.clearTimeout(this.timer);
|
||||
const generation = ++this.generation;
|
||||
if (!this.current) { this.current = true; this.changed(true); }
|
||||
this.timer = this.clock.setTimeout(() => {
|
||||
if (generation !== this.generation) return;
|
||||
this.timer = null;
|
||||
this.current = false;
|
||||
this.changed(false);
|
||||
}, ZOOM_SCALE_QUIET_MS);
|
||||
return true;
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
++this.generation;
|
||||
if (this.timer !== null) this.clock.clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
if (this.current) { this.current = false; this.changed(false); }
|
||||
}
|
||||
|
||||
/** The same host may reconnect; a new change starts a fresh, isolated lease. */
|
||||
dispose(): void { this.reset(); }
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { cameraCycleFailures, cameraCycleSampleFailures, finishLedCameraCycle } from '../demo/performance/led-camera-cycle.mjs';
|
||||
|
||||
function clock({ stuck = false, tailFailure = false } = {}) {
|
||||
let time = 0, deadline = 160;
|
||||
const events = [];
|
||||
return { events, args: {
|
||||
now: () => time,
|
||||
fullQuality: () => !stuck && time >= deadline && !(tailFailure && time >= 800),
|
||||
sleep: async ms => { events.push(['sleep', ms]); time += ms; },
|
||||
frame: async () => { events.push(['frame', time]); time += 32; },
|
||||
input: async () => { events.push(['input', time]); deadline = time + 160; },
|
||||
} };
|
||||
}
|
||||
|
||||
test('LED full cycle waits for both restores and the >=500 ms tail without replacing the original camera series', async () => {
|
||||
const probe = clock();
|
||||
const marks = await finishLedCameraCycle(probe.args);
|
||||
assert.equal(marks.firstFullQuality, 160);
|
||||
assert.equal(marks.firstPaintOpportunity, 192);
|
||||
assert.equal(marks.restartInput, 192);
|
||||
assert.ok(marks.finalFullQuality >= 352);
|
||||
assert.ok(marks.tailEnd - marks.finalPaintOpportunity >= 500);
|
||||
assert.equal(probe.events.filter(([name]) => name === 'input').length, 1);
|
||||
assert.equal(probe.events.filter(([name]) => name === 'frame').length, 4);
|
||||
});
|
||||
|
||||
test('LED full cycle rejects a stuck coarse state instead of truncating the observer window', async () => {
|
||||
const probe = clock({ stuck: true });
|
||||
await assert.rejects(finishLedCameraCycle(probe.args), /did not restore within 1000 ms/);
|
||||
assert.equal(probe.events.filter(([name]) => name === 'input').length, 0);
|
||||
});
|
||||
|
||||
test('LED full cycle checks that a late callback does not restore coarse in the tail', async () => {
|
||||
await assert.rejects(finishLedCameraCycle(clock({ tailFailure: true }).args), /regressed in the restore tail/);
|
||||
});
|
||||
|
||||
test('LED full cycle also measures the no-LED/full-quality control with the same restart and tail', async () => {
|
||||
const probe = clock();
|
||||
const marks = await finishLedCameraCycle({ ...probe.args, fullQuality: () => true });
|
||||
assert.equal(marks.firstFullQuality, 0);
|
||||
assert.ok(marks.tailEnd - marks.finalPaintOpportunity >= 500);
|
||||
assert.equal(probe.events.filter(([name]) => name === 'input').length, 1);
|
||||
});
|
||||
|
||||
test('full cycle inherits the unchanged camera ceiling; missing/over-budget evidence cannot pass', () => {
|
||||
assert.deepEqual(cameraCycleFailures({ median: 150, p95: 150 }, 150), []);
|
||||
assert.equal(cameraCycleFailures({ median: 100, p95: 151 }, 150).length, 1);
|
||||
assert.equal(cameraCycleFailures({ median: 151, p95: 200 }, 150).length, 2);
|
||||
for (const value of [undefined, null, NaN, Infinity]) {
|
||||
assert.equal(cameraCycleFailures({ median: value, p95: value }, 150).length, 2);
|
||||
}
|
||||
assert.deepEqual(cameraCycleFailures({ median: 0, p95: 0 }, undefined), []);
|
||||
});
|
||||
|
||||
test('full-cycle evidence validates every merged row, raw maximum and phase tail', async () => {
|
||||
const probe = clock();
|
||||
const phases = { seriesEnd: 0, ...await finishLedCameraCycle(probe.args) };
|
||||
const good = { cameraFullCycleLongTaskMaxMs: 51,
|
||||
cameraFullCycle: { startTime: 0, endTime: phases.tailEnd, phases, entries: [{ startTime: 10, duration: 51 }] } };
|
||||
assert.deepEqual(cameraCycleSampleFailures([good, good]), []);
|
||||
assert.equal(cameraCycleSampleFailures([good, {}]).length, 1, 'one absent sample cannot hide in a finite median');
|
||||
for (const mutate of [
|
||||
row => { row.cameraFullCycleLongTaskMaxMs = NaN; },
|
||||
row => { delete row.cameraFullCycle.entries; },
|
||||
row => { row.cameraFullCycle.entries[0].duration = Infinity; },
|
||||
row => { row.cameraFullCycle.phases.restartInput = -1; },
|
||||
row => { row.cameraFullCycle.phases.tailEnd = row.cameraFullCycle.phases.finalPaintOpportunity + 499; },
|
||||
row => { row.cameraFullCycleLongTaskMaxMs = 0; },
|
||||
]) {
|
||||
const bad = structuredClone(good); mutate(bad);
|
||||
assert.equal(cameraCycleSampleFailures([good, bad]).length, 1);
|
||||
}
|
||||
const empty = structuredClone(good); empty.cameraFullCycle.entries = []; empty.cameraFullCycleLongTaskMaxMs = 0;
|
||||
assert.deepEqual(cameraCycleSampleFailures([empty]), [], 'no Long Tasks means zero, not missing evidence');
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { ZoomScaleActivity, ZOOM_SCALE_QUIET_MS } from '../test-build/zoom-scale-activity.js';
|
||||
import { interpolateCameraState } from '../test-build/viewport-transition.js';
|
||||
|
||||
const view = (w = 100, h = 80, x = 0, y = 0) => ({ w, h, x, y });
|
||||
|
||||
function runtime() {
|
||||
let time = 0, sequence = 0;
|
||||
const pending = new Map(), changes = [];
|
||||
const clock = {
|
||||
setTimeout(callback, delay) {
|
||||
const id = ++sequence;
|
||||
pending.set(id, { callback, at: time + delay });
|
||||
return id;
|
||||
},
|
||||
clearTimeout(id) { pending.delete(id); },
|
||||
};
|
||||
const activity = new ZoomScaleActivity((active) => changes.push({ active, at: time }), clock);
|
||||
const advance = (duration) => {
|
||||
const end = time + duration;
|
||||
for (;;) {
|
||||
const next = [...pending].sort((a, b) => a[1].at - b[1].at)[0];
|
||||
if (!next || next[1].at > end) break;
|
||||
time = next[1].at;
|
||||
pending.delete(next[0]);
|
||||
next[1].callback();
|
||||
}
|
||||
time = end;
|
||||
};
|
||||
return { activity, pending, changes, advance };
|
||||
}
|
||||
|
||||
test('only real scale changes activate the 160 ms paint lease', () => {
|
||||
assert.equal(ZOOM_SCALE_QUIET_MS, 160);
|
||||
const r = runtime();
|
||||
assert.equal(r.activity.change(view(), view()), false, 'clamped/no-op zoom');
|
||||
assert.equal(r.activity.change(view(), view(100, 80, 17, -23)), false, 'pan');
|
||||
assert.equal(r.pending.size, 0);
|
||||
assert.deepEqual(r.changes, []);
|
||||
assert.equal(r.activity.change(view(), view(50, 40)), true);
|
||||
assert.equal(r.activity.active, true);
|
||||
assert.equal(r.pending.size, 1);
|
||||
r.advance(159);
|
||||
assert.equal(r.activity.active, true);
|
||||
r.advance(1);
|
||||
assert.equal(r.activity.active, false);
|
||||
assert.equal(r.pending.size, 0);
|
||||
assert.deepEqual(r.changes, [{ active: true, at: 0 }, { active: false, at: 160 }]);
|
||||
});
|
||||
|
||||
test('either extent may change, including a small genuine scale step', () => {
|
||||
for (const after of [view(99, 80), view(100, 79), view(100 + 1e-10, 80)]) {
|
||||
const r = runtime();
|
||||
assert.equal(r.activity.change(view(), after), true);
|
||||
r.activity.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test('centre-only real camera interpolation does not mistake arithmetic roundoff for zoom', () => {
|
||||
for (const factor of [0.001, 1, 1000]) {
|
||||
const r = runtime();
|
||||
const from = { zoom: 1, viewBox: view(100 * factor, 80 * factor) };
|
||||
const to = { zoom: 1, viewBox: view(100 * factor, 80 * factor, 30, -20) };
|
||||
let previous = from;
|
||||
for (const progress of [0, 0.01, 0.25, 0.5, 0.75, 0.99, 1]) {
|
||||
const frame = interpolateCameraState(from, to, progress);
|
||||
assert.equal(r.activity.change(previous.viewBox, frame.viewBox), false,
|
||||
`centre-only factor=${factor}, progress=${progress}`);
|
||||
previous = frame;
|
||||
}
|
||||
assert.equal(r.pending.size, 0);
|
||||
assert.deepEqual(r.changes, []);
|
||||
}
|
||||
});
|
||||
|
||||
test('invalid current or target extents never start the lease', () => {
|
||||
for (const dimension of ['w', 'h']) for (const value of [NaN, Infinity, -Infinity, 0, -1]) {
|
||||
for (const invalidBefore of [false, true]) {
|
||||
const r = runtime(), invalid = { ...view(), [dimension]: value };
|
||||
assert.equal(r.activity.change(invalidBefore ? invalid : view(), invalidBefore ? view() : invalid), false);
|
||||
assert.equal(r.pending.size, 0);
|
||||
assert.deepEqual(r.changes, []);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('each scale step renews one timer without repeating the coarse mutation', () => {
|
||||
const r = runtime();
|
||||
r.activity.change(view(), view(50, 40));
|
||||
const stale = [...r.pending.values()][0].callback;
|
||||
r.advance(100);
|
||||
r.activity.change(view(50, 40), view(25, 20));
|
||||
assert.equal(r.pending.size, 1);
|
||||
stale();
|
||||
assert.equal(r.activity.active, true, 'a cancelled callback cannot end the newer zoom');
|
||||
r.advance(159);
|
||||
assert.equal(r.activity.active, true);
|
||||
r.advance(1);
|
||||
assert.deepEqual(r.changes, [{ active: true, at: 0 }, { active: false, at: 260 }]);
|
||||
});
|
||||
|
||||
test('pan, no-op and invalid updates do not extend an existing zoom', () => {
|
||||
const r = runtime();
|
||||
r.activity.change(view(), view(50, 40));
|
||||
r.advance(100);
|
||||
r.activity.change(view(50, 40), view(50, 40, 50, 50));
|
||||
r.activity.change(view(50, 40), view(50, 40));
|
||||
r.activity.change(view(50, 40), view(NaN, 40));
|
||||
r.advance(60);
|
||||
assert.equal(r.activity.active, false);
|
||||
assert.equal(r.changes.at(-1).at, 160);
|
||||
});
|
||||
|
||||
test('independent owners keep independent deadlines', () => {
|
||||
const a = runtime(), b = runtime();
|
||||
a.activity.change(view(), view(50, 40));
|
||||
b.advance(80);
|
||||
b.activity.change(view(), view(50, 40));
|
||||
a.advance(160);
|
||||
b.advance(80);
|
||||
assert.equal(a.activity.active, false);
|
||||
assert.equal(b.activity.active, true);
|
||||
b.advance(80);
|
||||
assert.equal(b.activity.active, false);
|
||||
});
|
||||
|
||||
for (const method of ['reset', 'dispose']) {
|
||||
test(`${method} cancels work and rejects stale callbacks across the next lifecycle`, () => {
|
||||
const r = runtime();
|
||||
r.activity.change(view(), view(50, 40));
|
||||
const stale = [...r.pending.values()][0].callback;
|
||||
r.advance(20);
|
||||
r.activity[method]();
|
||||
assert.equal(r.activity.active, false);
|
||||
assert.equal(r.pending.size, 0);
|
||||
stale();
|
||||
assert.deepEqual(r.changes, [{ active: true, at: 0 }, { active: false, at: 20 }]);
|
||||
r.activity[method]();
|
||||
r.activity.change(view(), view(25, 20));
|
||||
stale();
|
||||
assert.equal(r.activity.active, true);
|
||||
assert.equal(r.pending.size, 1);
|
||||
r.advance(160);
|
||||
assert.deepEqual(r.changes.slice(2), [{ active: true, at: 20 }, { active: false, at: 180 }]);
|
||||
});
|
||||
}
|
||||
|
||||
test('100 scale frames retain just one callback and restore after the final frame', () => {
|
||||
const r = runtime();
|
||||
for (let i = 0; i < 100; i++) {
|
||||
r.activity.change(view(200 - i, 80), view(199 - i, 80 + i + 1));
|
||||
assert.equal(r.pending.size, 1);
|
||||
if (i < 99) r.advance(16);
|
||||
}
|
||||
assert.deepEqual(r.changes, [{ active: true, at: 0 }]);
|
||||
r.advance(159);
|
||||
assert.equal(r.activity.active, true);
|
||||
r.advance(1);
|
||||
assert.equal(r.changes.at(-1).at, 99 * 16 + 160);
|
||||
assert.equal(r.pending.size, 0);
|
||||
});
|
||||
|
||||
test('paused pinch and instantaneous reduced-motion steps need no terminal event', () => {
|
||||
const r = runtime();
|
||||
r.activity.change(view(), view(50, 40));
|
||||
r.advance(160); // Fingers may still be down; no pointerup is required.
|
||||
assert.equal(r.activity.active, false);
|
||||
r.activity.change(view(50, 40), view(25, 20));
|
||||
assert.equal(r.activity.active, true);
|
||||
r.advance(160);
|
||||
assert.equal(r.activity.active, false);
|
||||
assert.equal(r.pending.size, 0);
|
||||
});
|
||||
+1
-1
@@ -22,7 +22,7 @@
|
||||
"src/rules.ts",
|
||||
"src/devices.ts", "src/device-inbox.ts",
|
||||
"src/virtual-light-state.ts", "src/config-store.ts", "src/config-reload-authority.ts", "src/config-write-conflict.ts", "src/summary-panel.ts", "src/summary-panel-metrics.ts", "src/summary-panel-i18n.ts", "src/summary-panel-identity.ts", "src/summary-panel-picker.ts", "src/summary-panel-runtime-loaded.ts", "src/header-menu.ts", "src/iso-materials.ts", "src/iso-first-frame.ts", "src/iso-tiles.ts", "src/iso-sun.ts",
|
||||
"src/backdrop-probe.ts",
|
||||
"src/backdrop-probe.ts", "src/zoom-scale-activity.ts",
|
||||
"src/types.ts", "src/canvas-constants.ts", "src/editors/dialog-baseline.ts", "src/editors/color-tile-ink.ts", "src/editors/general-form-state.ts", "src/editors/moon-status.ts", "src/editors/space-form-state.ts", "src/editors/marker-form-state.ts", "src/editors/room-form-state.ts",
|
||||
"src/space-geometry.ts", "src/stairs.ts", "src/clean-floor.ts", "src/stairs-editor-model.ts", "src/stairs-box.ts", "src/stairs-view.ts", "src/junction-limits.ts", "src/room-gear-drag.ts",
|
||||
"src/space-order.ts", "src/card-editor-validation.ts",
|
||||
|
||||
Reference in New Issue
Block a user