Files
houseplan-card/demo/smoke_moon.mjs
T
Sergey MatyuninandClaude Opus 5.5 c8c470ed57 feat(view): the moon in its phase over the "Follow the Sun" background (#661)
At dawn, dusk and night the environment shows the moon in the top-left
corner of the scene, behind the plan: computed in the card from the home
coordinates and the browser clock (short Meeus series + topocentric
parallax, within 1.4° / 1.8 pp of JPL Horizons), one designer image under
a continuous phase mask with the lit side always on the left (owner
2026-09-29), a feathered terminator, 3°/3 % thresholds and the 2 s fade of
the window rays. Everything but a small gate lives in the lazy
moon-runtime chunk, with its own 30 s ticker. General settings: "Sun"
becomes "Sun and Moon" with one switch, on for new installations
(DEFAULT_CONFIG), off for existing ones.

The initial View graph sat 728 B under its budget: the gate is paid for by
moving fifteen dialog-only strings of General settings into the lazy
settings dictionary (#459) and by one build fingerprint literal instead of
three, so the graph ends 4 B above dev. Golden: two new moon scenes, and
the two General settings help frames show «Sun and Moon»; the WSL artifact
test fixture now models scenes whose first capture awaits acceptance.

Issue: #661
User-Visible: yes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-09-30 12:25:46 +03:00

147 lines
7.0 KiB
JavaScript

// #661 AC4/AC5: the moon over the "Follow the Sun" background in a real
// browser. The page clock is Playwright's (`page.clock`), the home is Moscow
// through `hass.config`, the environment phase comes from `sun.sun`; the moon
// is switched on the way another client's save arrives (`__hpTest`).
//
// Moscow 2026-10-21 (waxing, 77 %): the moon sets through 3° between 22:37Z
// (3.03°) and 22:38Z (2.90°) — 22:30Z is above, 22:45Z below.
import { launch, checkAll, finish } from './serve.mjs';
const ABOVE = new Date('2026-10-21T22:30:00Z');
const BELOW = new Date('2026-10-21T22:45:00Z');
const { page, browser } = await launch({ width: 1000, height: 760 });
await page.clock.setFixedTime(ABOVE);
/** A state update, as Home Assistant pushes one: new hass object, same card. */
const pushHass = (patch) => page.evaluate(async ({ sun, config }) => {
const card = window.__card;
card.hass = {
...card.hass,
...(config ? { config: { ...(card.hass.config || {}), ...config } } : {}),
states: sun ? { ...card.hass.states, 'sun.sun': { entity_id: 'sun.sun', state: 'below_horizon', attributes: sun } } : card.hass.states,
};
await card.updateComplete;
}, patch);
const moonState = () => page.evaluate(() => {
const root = window.__card.renderRoot;
const moon = root.querySelector('.hp-moon');
if (!moon) return null;
const style = getComputedStyle(moon);
return {
on: moon.classList.contains('on'),
opacity: Number(style.opacity),
duration: style.transitionDuration,
parent: moon.parentElement?.className || '',
phase: root.querySelector('.hp-day-cycle-env')?.dataset.dayCyclePhase || null,
count: root.querySelectorAll('.hp-moon').length,
};
});
const waitMoon = (predicate, what) => page.waitForFunction(predicate, null, { timeout: 8000 })
.catch(() => { throw new Error(`moon did not ${what}`); });
await pushHass({ config: { latitude: 55.75, longitude: 37.62 }, sun: { azimuth: 0, elevation: -12, rising: false } });
await page.evaluate(() => window.__hpTest.setServerConfig((cfg) => {
cfg.settings = { ...(cfg.settings || {}), bg_mode: 'daynight', moon: true };
for (const space of cfg.spaces) if (space.settings) delete space.settings.bg_mode;
return cfg;
}));
await waitMoon(() => !!window.__card.renderRoot.querySelector('.hp-moon'), 'appear after the chunk loaded');
const first = await moonState();
const checks = {};
// C4: the first appearance after the chunk load is not animated.
checks.firstAppearanceIsImmediate = first?.on === true && first.opacity === 1;
checks.oneMoonInsideTheEnvironment = first?.count === 1 && first.parent === 'hp-day-cycle-env';
checks.twoSecondFade = first?.duration === '2s';
// AC4 in the browser: three 30 s ticks over an unchanged sky touch nothing.
await page.evaluate(() => {
const moon = window.__card.renderRoot.querySelector('.hp-moon');
window.__hpMoonMutations = 0;
window.__hpMoonObserver = new MutationObserver((records) => { window.__hpMoonMutations += records.length; });
window.__hpMoonObserver.observe(moon, { attributes: true, childList: true, subtree: true, characterData: true });
});
for (let tick = 0; tick < 3; tick++) await page.clock.runFor(30_000);
checks.threeQuietTicksNoRender = await page.evaluate(() => window.__hpMoonMutations) === 0;
// AC5: the ticker alone carries the moon through 3° — it fades out over 2 s.
await page.clock.setFixedTime(BELOW);
await page.clock.runFor(30_000);
await waitMoon(() => !window.__card.renderRoot.querySelector('.hp-moon')?.classList.contains('on'), 'set by the ticker');
await page.waitForTimeout(250);
const fading = await moonState();
checks.setsWithAFade = fading.on === false && fading.opacity > 0 && fading.opacity < 1;
await page.waitForTimeout(2200);
checks.setsToZero = (await moonState()).opacity === 0;
await page.evaluate(() => window.__hpMoonObserver.disconnect());
// Back above 3° on the next render (a state update): fades in to 1.
await page.clock.setFixedTime(ABOVE);
await pushHass({ sun: { azimuth: 2, elevation: -12.1, rising: false } });
await page.waitForTimeout(250);
const rising = await moonState();
checks.risesWithAFade = rising.on === true && rising.opacity > 0 && rising.opacity < 1;
await page.waitForTimeout(2200);
checks.risesToOne = (await moonState()).opacity === 1;
// C6 / owner decision 7: the plan paints over the moon. Along the disc's
// diameter, find one point where plan content is hit and one where only the
// empty scene is; then compare the painted pixel with the moon shown and
// hidden. Under the plan it must not change, in the open it must.
const probe = await page.evaluate(() => {
const root = window.__card.renderRoot;
const moon = root.querySelector('.hp-moon');
const box = moon.getBoundingClientRect();
const cy = Math.round(box.top + box.height / 2);
const disc = box.width * 0.47;
let covered = null;
let open = null;
for (let x = box.left + box.width / 2 - disc * 0.9; x <= box.left + box.width / 2 + disc * 0.9; x += 2) {
const hit = root.elementFromPoint(x, cy);
const svg = hit?.closest('svg.plan-svg');
if (svg && hit !== svg) covered ??= Math.round(x);
else if (!svg || hit === svg) open ??= Math.round(x);
}
return { covered, open, cy, pointerEvents: getComputedStyle(moon).pointerEvents };
});
/** One painted pixel, as PNG bytes (the encoder is deterministic). */
const pixel = async (x, y) => (await page.screenshot({ clip: { x, y, width: 1, height: 1 } })).toString('base64');
const withMoonHidden = async (fn) => {
await page.evaluate(() => {
const style = document.createElement('style');
style.id = 'hp-moon-probe';
style.textContent = '.hp-moon{visibility:hidden!important}';
window.__card.renderRoot.appendChild(style);
});
try { return await fn(); } finally {
await page.evaluate(() => window.__card.renderRoot.querySelector('#hp-moon-probe')?.remove());
}
};
const shown = {
covered: probe.covered === null ? null : await pixel(probe.covered, probe.cy),
open: probe.open === null ? null : await pixel(probe.open, probe.cy),
};
const hidden = await withMoonHidden(async () => ({
covered: probe.covered === null ? null : await pixel(probe.covered, probe.cy),
open: probe.open === null ? null : await pixel(probe.open, probe.cy),
}));
checks.planCoversPartOfTheDisc = probe.covered !== null && probe.open !== null;
checks.moonChangesNoPixelUnderThePlan = shown.covered !== null && shown.covered === hidden.covered;
checks.moonIsPaintedWhereThePlanIsNot = shown.open !== null && shown.open !== hidden.open;
checks.moonIgnoresThePointer = probe.pointerEvents === 'none';
// Daytime: the environment turns to day and the moon fades out.
await pushHass({ sun: { azimuth: 180, elevation: 40, rising: false } });
await page.waitForTimeout(2300);
const day = await moonState();
checks.dayHidesTheMoon = day?.phase === 'day' && day.on === false && day.opacity === 0;
// Reduced motion: no transition at all.
await page.emulateMedia({ reducedMotion: 'reduce' });
checks.reducedMotionNoTransition = (await moonState()).duration === '0s';
console.log(JSON.stringify({ first, fading, rising, day, probe }, null, 2));
checkAll(checks);
await finish(browser, checks);