From 80cba308950a5139fe8de3a2d763f3b25c9b5053 Mon Sep 17 00:00:00 2001 From: Matysh Date: Sun, 4 Oct 2026 15:54:34 +0300 Subject: [PATCH] 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 --- demo/benchmark_led_strips.mjs | 26 +- demo/helpers/led-zoom-oracle.mjs | 141 ++++++++ demo/performance/README.md | 15 + demo/performance/led-camera-cycle.mjs | 71 ++++ demo/smoke_led_strip_field.mjs | 103 +++++- demo/smoke_led_zoom_quality.mjs | 322 ++++++++++++++++++ docs/ARCHITECTURE.md | 8 + docs/CANVAS.md | 7 + docs/CHANGELOG.md | 5 + docs/CHANGELOG.ru.md | 5 + docs/DEVELOPMENT.md | 9 + docs/LIGHT.md | 10 + docs/STATUS.md | 2 +- docs/TOUCH-SUPPORT.md | 6 + docs/USER-GUIDE.ru.md | 4 + docs/testing-notes/mutation-browser-guards.md | 9 +- scripts/monolith-baseline.json | 2 +- scripts/mutation-registry.mjs | 45 +++ scripts/smoke-links.mjs | 8 + src/houseplan-card.ts | 15 +- src/led-strip-field.ts | 10 +- src/styles/plan.styles.ts | 7 + src/zoom-scale-activity.ts | 58 ++++ test/led-camera-cycle.test.mjs | 77 +++++ test/zoom-scale-activity.test.mjs | 174 ++++++++++ tsconfig.test.json | 2 +- 26 files changed, 1127 insertions(+), 14 deletions(-) create mode 100644 demo/helpers/led-zoom-oracle.mjs create mode 100644 demo/performance/led-camera-cycle.mjs create mode 100644 demo/smoke_led_zoom_quality.mjs create mode 100644 src/zoom-scale-activity.ts create mode 100644 test/led-camera-cycle.test.mjs create mode 100644 test/zoom-scale-activity.test.mjs diff --git a/demo/benchmark_led_strips.mjs b/demo/benchmark_led_strips.mjs index d2b8fc4f..51304df6 100644 --- a/demo/benchmark_led_strips.mjs +++ b/demo/benchmark_led_strips.mjs @@ -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]; diff --git a/demo/helpers/led-zoom-oracle.mjs b/demo/helpers/led-zoom-oracle.mjs new file mode 100644 index 00000000..aacae671 --- /dev/null +++ b/demo/helpers/led-zoom-oracle.mjs @@ -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 = `${new XMLSerializer().serializeToString(source)}`; + 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 }; +} diff --git a/demo/performance/README.md b/demo/performance/README.md index f158aa17..13a54bde 100644 --- a/demo/performance/README.md +++ b/demo/performance/README.md @@ -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. diff --git a/demo/performance/led-camera-cycle.mjs b/demo/performance/led-camera-cycle.mjs new file mode 100644 index 00000000..aa6009e53 --- /dev/null +++ b/demo/performance/led-camera-cycle.mjs @@ -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; +} diff --git a/demo/smoke_led_strip_field.mjs b/demo/smoke_led_strip_field.mjs index 28074c78..d366bde7 100644 --- a/demo/smoke_led_strip_field.mjs +++ b/demo/smoke_led_strip_field.mjs @@ -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 }); diff --git a/demo/smoke_led_zoom_quality.mjs b/demo/smoke_led_zoom_quality.mjs new file mode 100644 index 00000000..12a2a12e --- /dev/null +++ b/demo/smoke_led_zoom_quality.mjs @@ -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); +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index f45088e7..796326ba 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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 diff --git a/docs/CANVAS.md b/docs/CANVAS.md index acb2a07d..8c3b0b1e 100644 --- a/docs/CANVAS.md +++ b/docs/CANVAS.md @@ -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 diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 8c4bcff2..07f11c73 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -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 diff --git a/docs/CHANGELOG.ru.md b/docs/CHANGELOG.ru.md index 7fabcb75..5822871f 100755 --- a/docs/CHANGELOG.ru.md +++ b/docs/CHANGELOG.ru.md @@ -8,6 +8,11 @@ ## Не выпущено +- При зуме свечение LED-лент временно использует облегчённый градиент, не + исчезая с плана; после паузы полное качество возвращается автоматически. + Панорамирование и неподвижный план сохраняют прежнее качество + ([#789](https://github.com/Matysh/houseplan-card/issues/789)). + - Убраны лишние обновления карточки при одновременном появлении множества источников света и повторные расчёты препятствий в одном кадре. Форма света и плавность переходов сохранены. Отложенные обновления LED также очищаются diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index bf87dabc..4285debb 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -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'`. diff --git a/docs/LIGHT.md b/docs/LIGHT.md index 953247a1..1f525e0d 100644 --- a/docs/LIGHT.md +++ b/docs/LIGHT.md @@ -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 diff --git a/docs/STATUS.md b/docs/STATUS.md index e0710595..d31065b1 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -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`. | diff --git a/docs/TOUCH-SUPPORT.md b/docs/TOUCH-SUPPORT.md index 74f3134e..c51c8ed1 100644 --- a/docs/TOUCH-SUPPORT.md +++ b/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 diff --git a/docs/USER-GUIDE.ru.md b/docs/USER-GUIDE.ru.md index 5810a214..2026f7ed 100644 --- a/docs/USER-GUIDE.ru.md +++ b/docs/USER-GUIDE.ru.md @@ -492,6 +492,10 @@ desktop: для точного рисования, Resize, модификато - щипок и панорамирование по-прежнему следуют за пальцами без задержки; при включённом в системе уменьшении движения любой масштаб меняется мгновенно; - панорамирование работает при любом масштабе; +- при изменении масштаба свечение LED-лент временно рисуется с менее подробным + градиентом для плавной навигации; свет остаётся видимым, а после короткой + паузы автоматически возвращается полное качество. Простое перемещение плана + качество не меняет; - вписывание комнаты учитывает видимый пол и её стены, но не устройства, подписи, Glow, солнечные лучи, подложку или декор; ручной масштаб/панорама и **«Вписать всё»** отменяют удержание выбранной комнаты при следующем resize; diff --git a/docs/testing-notes/mutation-browser-guards.md b/docs/testing-notes/mutation-browser-guards.md index f228a329..47850ee6 100644 --- a/docs/testing-notes/mutation-browser-guards.md +++ b/docs/testing-notes/mutation-browser-guards.md @@ -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` diff --git a/scripts/monolith-baseline.json b/scripts/monolith-baseline.json index aa94112f..d7bd7b8c 100644 --- a/scripts/monolith-baseline.json +++ b/scripts/monolith-baseline.json @@ -4,5 +4,5 @@ "hostRefs": 5064, "portPrivates": 103, "harnessPrivates": 100, - "bundleBytes": 2675421 + "bundleBytes": 2678324 } diff --git a/scripts/mutation-registry.mjs b/scripts/mutation-registry.mjs index d7b55776..387c5d76 100644 --- a/scripts/mutation-registry.mjs +++ b/scripts/mutation-registry.mjs @@ -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', diff --git a/scripts/smoke-links.mjs b/scripts/smoke-links.mjs index 002f1dc2..6e6ede42 100644 --- a/scripts/smoke-links.mjs +++ b/scripts/smoke-links.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 diff --git a/src/houseplan-card.ts b/src/houseplan-card.ts index 3af19a35..e7b9c67e 100755 --- a/src/houseplan-card.ts +++ b/src/houseplan-card.ts @@ -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); diff --git a/src/led-strip-field.ts b/src/led-strip-field.ts index cd3c6b86..e46a4983 100644 --- a/src/led-strip-field.ts +++ b/src/led-strip-field.ts @@ -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`