feat(moon): the moon with any background, and its status in General settings (#718)

The owner decided on 30.09 that the moon is not part of the "Follow the Sun"
environment but a switch of its own: with a static background (global or a
space's own) the card showed no moon even with the switch on, and the switch
said nothing about why the moon was missing right now.

With a static background there is no environment, so the moon stands in its
own layer, `.hp-moon-sky`: the first child of `.stage` / `.hp-static-stage`,
the whole scene, no z-index, filter or will-change, under the plan by DOM
order, fading with the #101 View weight. Inside is the very #661 element, so
place, size, art and fades are unchanged, and a background switch moves it to
its new parent in the same render without a flicker. The phase comes from the
same `resolveDayCycle`, computed only while the moon is on and on View; without
`sun.sun` both cards keep their 30 s clock ticker and re-render only when the
phase changes (the environment is still compared by its whole fingerprint).

General settings get a second caption line under the moon switch
(`data-moon-status`): one snapshot per opening, judged by the lazy chunk as if
the switch were on, first reason wins (no home, day, below 3°, under 3 %),
numbers rounded and clamped below the threshold they missed. `moonStatus`
decides "shown" with the same `moonShownAt` as the element. It lives in a
WeakMap beside the draft, so it never makes the dialog dirty; a closed
opening's result is dropped. The dialog loads the chunk through the gate's
loader (`withMoon`), now shared by every caller while a load is in flight, so
there is still one fingerprint check and one retry token.

Bundle (same build, against origin/dev): initial View 300 072 -> 300 248 B gzip
(+176 B, under the 500 B of the spec; budget and ceiling not raised); lazy
editor 238 558 -> 238 991 B (+433 B, the line and English strings); lazy moon
11 385 -> 11 712 B (+327 B, layer CSS and status). `src/moon.ts` stays out of
the initial and the editor graph; bundle-budget now refuses an editor/moon
overlap. Monolith metrics: hostRefs 4 885 -> 4 888 — the three `host.` reads of
`src/editors/moon-status.ts` (hass, `_settingsDialog`, requestUpdate) through
its own three-member interface, not the editor port; the other five metrics
are unchanged. houseplan-editor-runtime.ts grows by two lines (import, call).

Tests: AC9/AC10/AC15 and the sky layer in test/moon.test.mjs (the #661
"static -> nothing" check inverted), AC14 and the opening lifecycle in
test/moon-settings.test.mjs, smokes demo/smoke_moon_static.mjs (AC1-AC6; AC1
and AC3 were red on dev) and demo/smoke_moon_status.mjs (AC11/AC12), AC7 in
smoke_daycycle_layer_budget. Golden: two new scenes
(static-bg-moon-gibbous-white-light, static-bg-moon-crescent-south-dark,
matrix v70), the harness checks the moon's parent by background and waits for
the status line in the General settings frames. Four new mutants; the clock
ticker one is a browser guard (201 at the guideline of 200).

Issue: #718
User-Visible: yes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
This commit is contained in:
Claude
2026-10-01 05:25:21 +00:00
committed by claude[bot]
parent 712d41b6d6
commit b998b0b34a
33 changed files with 1553 additions and 146 deletions
+31 -2
View File
@@ -686,7 +686,7 @@ export function prepareGoldenFixture(scenario) {
throw new Error(`golden deviceName references missing device: ${scenario.deviceId || '<empty>'}`);
fixture.devices[scenario.deviceId].name = scenario.deviceName;
}
if (scenario.fillMode || scenario.bgMode || typeof scenario.glowEnabled === 'boolean'
if (scenario.fillMode || scenario.bgMode || scenario.bgColor || typeof scenario.glowEnabled === 'boolean'
|| typeof scenario.sunRays === 'boolean' || typeof scenario.showBorders === 'boolean'
|| typeof scenario.showNames === 'boolean'
|| typeof scenario.northDeg === 'number') {
@@ -695,6 +695,8 @@ export function prepareGoldenFixture(scenario) {
...(space.settings || {}),
...(scenario.fillMode ? { fill_mode: scenario.fillMode } : {}),
...(scenario.bgMode ? { bg_mode: scenario.bgMode } : {}),
// #718: the scene colour of a static background, deterministic in both themes.
...(scenario.bgColor ? { bg_color: scenario.bgColor } : {}),
...(typeof scenario.glowEnabled === 'boolean' ? { glow_enabled: scenario.glowEnabled } : {}),
...(typeof scenario.sunRays === 'boolean' ? { sun_rays: scenario.sunRays } : {}),
...(typeof scenario.showBorders === 'boolean' ? { show_borders: scenario.showBorders } : {}),
@@ -933,6 +935,23 @@ export async function prepareGoldenScenario(page, scenario) {
await card.updateComplete;
await frame();
};
/**
* #718 AC8: General settings grow a status line under the moon switch once
* the lazy moon chunk judged the opening. Wait for it, so the frame does
* not depend on the chunk's timing; at phone width it wraps, it never
* scrolls the dialog sideways.
*/
const moonStatusSettled = async (card) => {
await until(() => !!card.renderRoot.querySelector('hp-dialog [data-moon-status]'));
await card.updateComplete;
await frame();
const dialog = card.renderRoot.querySelector('hp-dialog');
const body = dialog?.querySelector('.body');
if (innerWidth <= 390 && (!dialog || dialog.scrollWidth > dialog.clientWidth + 1
|| (body && body.scrollWidth > body.clientWidth + 1))) {
throw new Error(`golden general-settings dialog overflows horizontally: ${scenario.id}`);
}
};
const settleCamera = async (card) => {
await until(() => !card._cameraTransition?.active, 1500);
await card.updateComplete;
@@ -2022,6 +2041,7 @@ export async function prepareGoldenScenario(page, scenario) {
} else if (scenario.dialog === 'general-color') {
card._openSettingsDialog();
await card.updateComplete;
await moonStatusSettled(card);
const dialog = card.renderRoot.querySelector('hp-dialog');
const picker = [...(dialog?.querySelectorAll('hp-color-opacity') || [])]
.find((item) => item.label === card._t('gs.light_on'));
@@ -2035,6 +2055,7 @@ export async function prepareGoldenScenario(page, scenario) {
} else if (scenario.dialog === 'general-help') {
card._openSettingsDialog();
await card.updateComplete;
await moonStatusSettled(card);
const dialog = card.renderRoot.querySelector('hp-dialog');
const help = dialog?.querySelector(`hp-help[data-help-key="${scenario.openHelp}"]`);
await help?.updateComplete;
@@ -2216,7 +2237,15 @@ export async function prepareGoldenScenario(page, scenario) {
// The moon chunk is lazy: wait for it, then for the expected phase.
await until(() => card.renderRoot.querySelector('.hp-moon')?.dataset.moonVisible === 'true');
const moon = card.renderRoot.querySelector('.hp-moon');
if (moon.dataset.moonK !== scenario.moon.k || moon.parentElement !== card.renderRoot.querySelector('.hp-day-cycle-env')) {
// #718: over "Follow the Sun" the moon is in the environment; over a
// static background in its own sky layer, the first child of the scene.
const stage = card.renderRoot.querySelector('.stage');
const parent = scenario.bgMode === 'daynight'
? card.renderRoot.querySelector('.hp-day-cycle-env')
: card.renderRoot.querySelector('.hp-moon-sky');
const staticSky = scenario.bgMode === 'daynight' || (stage?.firstElementChild === parent
&& !card.renderRoot.querySelector('.hp-day-cycle-env'));
if (moon.dataset.moonK !== scenario.moon.k || !parent || moon.parentElement !== parent || !staticSky) {
throw new Error(`golden moon contract did not render: ${scenario.id} (k ${moon.dataset.moonK})`);
}
await frame();
+15 -1
View File
@@ -1,7 +1,7 @@
import { fixtureWallKey } from '../fixtures/visual-matrix.mjs';
/** Data-only HP-QA-01 capture matrix. Bump when framing or scenarios change. */
export const GOLDEN_MATRIX_VERSION = 69;
export const GOLDEN_MATRIX_VERSION = 70;
const stage = { capture: 'stage', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0005 } };
const page = { capture: 'page', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0008 } };
@@ -562,6 +562,20 @@ export const GOLDEN_SCENARIOS = Object.freeze([
moon: { clock: '2026-10-14T09:00:00Z', latitude: -33.87, longitude: 151.21, k: '0.14' },
stateOverrides: { 'sun.sun': { attributes: { azimuth: 265, elevation: -2, rising: false } } },
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
// #718 AC8: the moon with a static background — its own sky layer at the
// top-left of the scene, the plan over part of the disc, the chosen scene
// colour around the plan unchanged. White in the light theme, dark grey in
// the dark one; the same art and lit side as over "Follow the Sun".
{ id: 'static-bg-moon-gibbous-white-light', fixture: 'visual', space: 'golden-geometry', mode: 'view',
bgMode: 'static', bgColor: '#ffffff',
moon: { clock: '2026-10-21T18:00:00Z', latitude: 55.75, longitude: 37.62, k: '0.79' },
stateOverrides: { 'sun.sun': { attributes: { azimuth: 0, elevation: -12, rising: false } } },
theme: 'light', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'static-bg-moon-crescent-south-dark', fixture: 'visual', space: 'golden-geometry', mode: 'view',
bgMode: 'static', bgColor: '#202126',
moon: { clock: '2026-10-14T09:00:00Z', latitude: -33.87, longitude: 151.21, k: '0.14' },
stateOverrides: { 'sun.sun': { attributes: { azimuth: 265, elevation: -2, rising: false } } },
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'geometry-plan-editor-dark', fixture: 'visual', space: 'golden-geometry', mode: 'plan',
theme: 'dark', viewport: { width: 1180, height: 900 }, ...page },
{ id: 'safe-resize-handles-clamp-light', fixture: 'visual', space: 'golden-safe-resize',
+29
View File
@@ -242,6 +242,31 @@ await page.waitForFunction(() => !!window.__card.renderRoot.querySelector('.hp-m
await transitionsDone();
const moonLayers = (await layerSnapshot()).layers;
// #718 AC7: a static background at night — the moon stands in its own sky
// layer (no z-index, filter or will-change) and adds no composited layer
// either. Night without the moon against night with it, both settled.
const setStaticMoon = (moon) => page.evaluate(async (moon) => {
const card = window.__card;
card._serverCfg = { ...card._serverCfg, settings: { ...card._serverCfg.settings, bg_mode: 'static', moon } }; // private-ok: the synthetic 1 cm/point space exists only in the card config, a server push would reload the demo plan
card.requestUpdate();
await card.updateComplete;
}, moon);
await setStaticMoon(false);
await settle();
await transitionsDone();
const staticNightLayers = (await layerSnapshot()).layers;
await setStaticMoon(true);
await page.waitForFunction(() => !!window.__card.renderRoot.querySelector('.hp-moon-sky > .hp-moon.on'), null, { timeout: 8000 });
await transitionsDone();
const staticMoonLayers = (await layerSnapshot()).layers;
await page.evaluate(async () => {
const card = window.__card;
card._serverCfg = { ...card._serverCfg, settings: { ...card._serverCfg.settings, bg_mode: 'daynight', moon: true } }; // private-ok: back to the #661 night for the static card below
card.requestUpdate();
await card.updateComplete;
});
await settle();
// Contract item 6: the non-interactive houseplan-space-card never needs a
// gesture to become safe. Its day-cycle silhouette is stage-bounded from its
// first frame, while the visible paper group owns no filter layer.
@@ -370,6 +395,8 @@ checks.staticCardOutlineLayerIsStageBounded = !!staticOutlineLayer
checks.staticCardHasNo4096ContentLayer = staticOversized.length === 0;
checks.moonAddsNoCompositedLayer = moonLayers.length === nightLayers.length
&& !moonLayers.some((layer) => layer.className.includes('hp-moon'));
checks.staticMoonAddsNoCompositedLayer = staticMoonLayers.length === staticNightLayers.length
&& !staticMoonLayers.some((layer) => layer.className.includes('hp-moon'));
console.log(JSON.stringify({
stage: active.stage,
@@ -389,6 +416,8 @@ console.log(JSON.stringify({
staticOversized,
nightLayers: nightLayers.length,
moonLayers: moonLayers.length,
staticNightLayers: staticNightLayers.length,
staticMoonLayers: staticMoonLayers.length,
}, null, 2));
checkAll(checks);
await cdp.send('LayerTree.disable');
+389
View File
@@ -0,0 +1,389 @@
// #718 AC1–AC6: the moon with any background, in the production bundle. With
// a static background there is no environment: the moon stands in its own
// layer `.hp-moon-sky`, the first child of the scene, under the plan by DOM
// order. The home is Moscow through `hass.config`; the clock is Playwright's
// (`page.clock`), the time zone the context's (`timezoneId`).
//
// Moscow 2026-10-21 18:00Z: the moon at 23.9°, 79 % lit; 2026-10-03 07:59Z:
// 31.5°, 53 %. AC1 and AC3 are the witnesses: red on the #661 code, where a
// static background leaves no moon at all.
import { launch, checkAll, finish } from './serve.mjs';
const MOSCOW = { latitude: 55.75, longitude: 37.62 };
const NIGHT_SUN = { azimuth: 0, elevation: -12, rising: false };
const DAY_SUN = { azimuth: 180, elevation: 40, rising: false };
const checks = {};
const report = {};
const isMoonAsset = (url) => /\/moon-runtime-[^/?]*\.js(?:\?|$)/.test(url);
async function open(contextOptions = {}) {
const run = await launch({ width: 1000, height: 760 }, 1, [], contextOptions);
run.moonRequests = [];
run.page.on('request', (request) => { if (isMoonAsset(request.url())) run.moonRequests.push(request.url()); });
return run;
}
/** A state update, as Home Assistant pushes one; `sun: null` removes `sun.sun`. */
const pushHass = (page, { config, sun } = {}) => page.evaluate(async ({ config, sun }) => {
const card = window.__card;
const states = { ...card.hass.states };
if (sun === null) delete states['sun.sun'];
else if (sun) {
states['sun.sun'] = {
entity_id: 'sun.sun', state: sun.elevation > 0 ? 'above_horizon' : 'below_horizon', attributes: sun,
};
}
card.hass = {
...card.hass,
...(config ? { config: { ...(card.hass.config || {}), ...config } } : {}),
states,
};
await card.updateComplete;
}, { config, sun });
/**
* Another client's save: global settings merged (`null` removes a key), every
* space's own `bg_mode` dropped, then `spaces[id]` applied to that space.
*/
const setConfig = (page, settings, spaces = {}) => page.evaluate(({ settings, spaces }) => (
window.__hpTest.setServerConfig((cfg) => {
cfg.settings = { ...(cfg.settings || {}), ...settings };
for (const [key, value] of Object.entries(settings)) if (value === null) delete cfg.settings[key];
for (const space of cfg.spaces) {
space.settings = { ...(space.settings || {}) };
delete space.settings.bg_mode;
Object.assign(space.settings, spaces[space.id] || {});
}
return cfg;
})), { settings, spaces });
const moonState = (page) => page.evaluate(() => {
const root = window.__card.renderRoot;
const stage = root.querySelector('.stage');
const moons = root.querySelectorAll('.hp-moon');
const moon = moons[0] || null;
const style = moon ? getComputedStyle(moon) : null;
const box = moon?.getBoundingClientRect();
return {
count: moons.length,
on: !!moon?.classList.contains('on'),
opacity: style ? Number(style.opacity) : null,
k: moon?.dataset.moonK ?? null,
parent: moon?.parentElement?.className ?? null,
parentIsFirstChild: !!moon && moon.parentElement === stage.firstElementChild,
sky: root.querySelectorAll('.hp-moon-sky').length,
env: !!root.querySelector('.hp-day-cycle-env'),
outline: !!root.querySelector('.hp-paper-outline-svg'),
stageClasses: [...stage.classList].filter((name) => name === 'daycycle' || name.startsWith('phase-')),
stageBg: getComputedStyle(stage).backgroundColor,
box: box ? [box.left, box.top, box.width, box.height] : null,
running: moon ? moon.getAnimations()
.filter((animation) => animation instanceof CSSTransition && animation.playState === 'running').length : 0,
pointerEvents: style?.pointerEvents ?? null,
};
});
const settle = (page) => page.evaluate(async () => {
await window.__card.updateComplete;
await new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
});
/** Soft wait: a missing moon is a named red check, not an aborted run. */
const until = (page, predicate, arg, timeout = 8000) => page.waitForFunction(predicate, arg, { timeout })
.then(() => true, () => false);
const moonOn = () => !!window.__card.renderRoot.querySelector('.hp-moon.on');
const sameBox = (a, b) => !!a && !!b && a.every((value, index) => Math.abs(value - b[index]) <= 1);
// ─── Part A: full card, `sun.sun` from Home Assistant (AC1, AC2, AC4, AC5) ───
{
const { page, browser, moonRequests } = await open();
await page.clock.setFixedTime(new Date('2026-10-21T18:00:00Z'));
await pushHass(page, { config: MOSCOW, sun: NIGHT_SUN });
await setConfig(page, { bg_mode: 'static', moon: null });
await settle(page);
await page.waitForTimeout(400);
const moonOff = await moonState(page);
// AC2: night with the moon switched off asks for nothing.
checks.ac2_nightMoonOffNoChunk = moonOff.count === 0 && moonRequests.length === 0;
// AC2: switched on by day — no moon and still no request.
await pushHass(page, { sun: DAY_SUN });
await setConfig(page, { bg_mode: 'static', moon: true });
await settle(page);
await page.waitForTimeout(400);
const day = await moonState(page);
checks.ac2_dayNoMoonNoChunk = !day.on && moonRequests.length === 0;
// AC2: the state update at night brings the moon.
await pushHass(page, { sun: NIGHT_SUN });
checks.ac2_nightPushShowsTheMoon = await until(page, moonOn);
const first = await moonState(page);
report.ac1 = { moonOff, first, requests: moonRequests.length };
// AC1 (witness): one moon in its own layer, the first child of the scene.
checks.ac1_oneMoonOnAtFullOpacity = first.count === 1 && first.on && first.opacity === 1;
checks.ac1_parentIsTheSkyLayer = first.sky === 1 && first.parent === 'hp-moon-sky' && first.parentIsFirstChild;
checks.ac1_noEnvironment = !first.env && !first.outline && first.stageClasses.length === 0;
checks.ac1_backgroundUnchanged = first.stageBg === moonOff.stageBg;
checks.ac1_moonIgnoresThePointer = first.pointerEvents === 'none';
// AC1: the #661 probe along the disc's diameter — under the plan the moon
// changes no pixel, in the open it does.
const probe = await page.evaluate(() => {
const root = window.__card.renderRoot;
const moon = root.querySelector('.hp-moon');
if (!moon) return { covered: null, open: null, cy: 0 };
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 };
});
const pixel = async (x, y) => (await page.screenshot({ clip: { x, y, width: 1, height: 1 } })).toString('base64');
const pixels = async () => ({
covered: probe.covered === null ? null : await pixel(probe.covered, probe.cy),
open: probe.open === null ? null : await pixel(probe.open, probe.cy),
});
const shown = await pixels();
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);
});
const hidden = await pixels();
await page.evaluate(() => window.__card.renderRoot.querySelector('#hp-moon-probe')?.remove());
report.ac1.probe = probe;
checks.ac1_planCoversPartOfTheDisc = probe.covered !== null && probe.open !== null;
checks.ac1_noPixelChangesUnderThePlan = shown.covered !== null && shown.covered === hidden.covered;
checks.ac1_moonPaintedWhereThePlanIsNot = shown.open !== null && shown.open !== hidden.open;
// AC2: daytime again — the moon fades out over 2 s.
await pushHass(page, { sun: DAY_SUN });
await page.waitForTimeout(250);
const fading = await moonState(page);
await page.waitForTimeout(2050);
const gone = await moonState(page);
report.ac2 = { day, fading, gone };
checks.ac2_dayFades = fading.count === 1 && !fading.on && fading.opacity > 0 && fading.opacity < 1;
checks.ac2_dayFadesToZero = gone.count === 1 && gone.opacity === 0;
// AC4: global "Follow the Sun", the garden has its own static background.
await pushHass(page, { sun: NIGHT_SUN });
await setConfig(page, { bg_mode: 'daynight', moon: true }, { garden: { bg_mode: 'static' } });
await page.evaluate(() => window.__hpTest.switchSpace('f1'));
await until(page, moonOn);
await page.waitForTimeout(2200);
const inEnv = await moonState(page);
const tabs = [];
for (const id of ['garden', 'f1', 'garden', 'f1']) {
await page.evaluate((space) => window.__hpTest.switchSpace(space), id);
await settle(page);
tabs.push({ id, ...(await moonState(page)) });
}
report.ac4 = { inEnv, tabs };
checks.ac4_startsInsideTheEnvironment = inEnv.count === 1 && inEnv.on && inEnv.parent === 'hp-day-cycle-env';
checks.ac4_tabsKeepOneMoonInItsPlace = tabs.every((tab) => tab.count === 1 && tab.on && tab.opacity === 1
&& tab.parent === (tab.id === 'garden' ? 'hp-moon-sky' : 'hp-day-cycle-env')
&& sameBox(tab.box, inEnv.box) && tab.running === 0);
// AC4: the background segment previewed in the open General settings.
await page.evaluate(async () => { window.__card._openSettingsDialog(); await window.__card.updateComplete; });
const pick = (value) => page.evaluate(async (value) => {
const card = window.__card;
const input = card.renderRoot.querySelector(`hp-dialog input[name="gs-bg-mode"][value="${value}"]`);
input?.click();
await card.updateComplete;
return !!input;
}, value);
const segmentFound = await pick('static');
await settle(page);
const previewed = await moonState(page);
await pick('daynight');
await settle(page);
const restored = await moonState(page);
await page.evaluate(async () => {
const dialog = window.__card.renderRoot.querySelector('hp-dialog[data-kind="settings"]');
if (dialog) await window.__hpTest.close(dialog);
});
report.ac4.previewed = previewed;
checks.ac4_segmentPreviewKeepsTheMoon = segmentFound && previewed.count === 1 && previewed.on
&& previewed.opacity === 1 && previewed.parent === 'hp-moon-sky' && sameBox(previewed.box, inEnv.box)
&& previewed.running === 0;
checks.ac4_segmentBackKeepsTheMoon = restored.count === 1 && restored.on
&& restored.parent === 'hp-day-cycle-env' && sameBox(restored.box, inEnv.box);
// AC5: static background, View → plan editor and back (#101 transition).
await setConfig(page, { bg_mode: 'static', moon: true });
await settle(page);
checks.ac5_staticMoonBeforeTheEditor = await until(page,
() => window.__card.renderRoot.querySelector('.hp-moon.on')?.parentElement?.className === 'hp-moon-sky');
const sample = () => page.evaluate(() => {
const card = window.__card;
window.__hpSkySamples = [];
window.__hpSkyStop = false;
const loop = () => {
const root = card.renderRoot;
const sky = root.querySelector('.hp-moon-sky');
const moon = root.querySelector('.hp-moon');
const stage = root.querySelector('.stage');
window.__hpSkySamples.push({
busy: !!card._modeTransitionBusy,
weight: Number(getComputedStyle(stage).getPropertyValue('--hp-mode-view-weight')),
sky: sky ? Number(getComputedStyle(sky).opacity) : null,
moon: moon ? { on: moon.classList.contains('on'), opacity: Number(getComputedStyle(moon).opacity) } : null,
});
if (!window.__hpSkyStop) requestAnimationFrame(loop);
};
requestAnimationFrame(loop);
});
const stopSampling = () => page.evaluate(() => { window.__hpSkyStop = true; return window.__hpSkySamples; });
await sample();
await page.evaluate(() => window.__hpTest.setMode('plan'));
await settle(page);
const toEditor = await stopSampling();
const inEditor = await moonState(page);
await sample();
await page.evaluate(() => window.__hpTest.setMode('view'));
await settle(page);
const toView = await stopSampling();
const backInView = await moonState(page);
const between = toEditor.filter((frame) => frame.sky !== null && frame.weight > 0 && frame.weight < 1);
const firstBack = toView.find((frame) => frame.moon);
report.ac5 = { between: between.length, toEditor: toEditor.length, inEditor, firstBack, backInView };
checks.ac5_skyFollowsTheViewWeight = between.length > 0
&& toEditor.every((frame) => frame.sky === null || Math.abs(frame.sky - frame.weight) <= 0.02);
checks.ac5_noMoonInTheEditor = inEditor.count === 0 && inEditor.sky === 0;
checks.ac5_backInViewAtOnce = !!firstBack && firstBack.moon.on && firstBack.moon.opacity === 1
&& backInView.on && backInView.opacity === 1 && backInView.parent === 'hp-moon-sky';
await browser.close();
}
// ─── Part B: the browser clock, no `sun.sun`, UTC (AC3) ───
{
const { page, browser, moonRequests } = await open({ timezoneId: 'UTC' });
await page.clock.install({ time: new Date('2026-10-21T17:59:00Z') });
await pushHass(page, { config: MOSCOW, sun: null });
await setConfig(page, { bg_mode: 'static', moon: true });
await settle(page);
await page.waitForTimeout(400);
const before = await moonState(page);
checks.ac3_dayByTheClockNoMoonNoChunk = !before.on && moonRequests.length === 0;
// Witness: only the card's own clock ticker can bring the moon at 18:00.
await page.clock.runFor(90_000);
const dusk = await until(page, moonOn);
const evening = await moonState(page);
checks.ac3_duskByTheClockShowsTheMoon = dusk && evening.count === 1 && evening.k === '0.79'
&& evening.parent === 'hp-moon-sky';
await page.clock.setSystemTime(new Date('2026-10-03T07:59:00Z'));
await page.clock.runFor(30_000);
const dawnShown = await until(page,
() => window.__card.renderRoot.querySelector('.hp-moon.on')?.dataset.moonK === '0.53');
const dawn = await moonState(page);
checks.ac3_dawnByTheClockShowsTheMoon = dawnShown && dawn.on && dawn.k === '0.53';
await page.clock.runFor(60_000);
const dayHides = await until(page, () => {
const moon = window.__card.renderRoot.querySelector('.hp-moon');
return !!moon && !moon.classList.contains('on');
});
checks.ac3_dayByTheClockHidesTheMoon = dayHides;
report.ac3 = { before, evening, dawn, requests: moonRequests.length };
await browser.close();
}
// ─── Part C: houseplan-space-card with its own static background (AC6) ───
{
const { page, browser } = await open({ timezoneId: 'UTC' });
await page.clock.install({ time: new Date('2026-10-21T18:00:00Z') });
await setConfig(page, { bg_mode: 'daynight', moon: true }, { f1: { bg_mode: 'static' } });
await page.evaluate(async ({ config, sun }) => {
await customElements.whenDefined('houseplan-space-card');
const full = window.__card;
full.style.display = 'none';
const host = document.createElement('div');
host.style.cssText = 'position:fixed;left:0;top:0;width:820px';
document.body.appendChild(host);
const card = document.createElement('houseplan-space-card');
card.setConfig({ type: 'custom:houseplan-space-card', space: 'f1', title: '', show_button: false });
card.hass = {
...full.hass,
config: { ...(full.hass.config || {}), ...config },
states: { ...full.hass.states, 'sun.sun': { entity_id: 'sun.sun', state: 'below_horizon', attributes: sun } },
};
host.appendChild(card);
window.__hpSpaceCard = card;
}, { config: MOSCOW, sun: NIGHT_SUN });
const spaceMoon = () => page.evaluate(() => {
const card = window.__hpSpaceCard;
const moon = card.renderRoot?.querySelector('.hp-static-stage > .hp-moon-sky > .hp-moon');
return {
count: card.renderRoot?.querySelectorAll('.hp-moon').length ?? 0,
nested: !!moon,
firstChild: !!moon && moon.parentElement === moon.parentElement.parentElement.firstElementChild,
on: !!moon?.classList.contains('on'),
k: moon?.dataset.moonK ?? null,
env: !!card.renderRoot?.querySelector('.hp-day-cycle-env'),
};
});
const spaceOn = () => !!window.__hpSpaceCard?.renderRoot?.querySelector('.hp-static-stage > .hp-moon-sky > .hp-moon.on');
const nightShown = await until(page, spaceOn);
const night = await spaceMoon();
checks.ac6_spaceCardMoonInTheSkyLayer = nightShown && night.count === 1 && night.nested && night.firstChild
&& !night.env;
// Stacking: with both hit-testable, the plan wins over the part of the disc it covers.
const stacking = await page.evaluate(() => {
const root = window.__hpSpaceCard.renderRoot;
if (!root?.querySelector('.hp-moon')) return { plan: 0, disc: 0 };
const style = document.createElement('style');
style.id = 'hp-moon-hit';
style.textContent = '.hp-static-plan-svg *,.hp-moon,.hp-moon *{pointer-events:auto!important}';
root.appendChild(style);
const moon = root.querySelector('.hp-moon');
const box = moon.getBoundingClientRect();
const r = box.width * 0.47 * 0.9;
const cx = box.left + box.width / 2;
const cy = box.top + box.height / 2;
let plan = 0;
let disc = 0;
for (let y = cy - r; y <= cy + r; y += 3) {
for (let x = cx - r; x <= cx + r; x += 3) {
if ((x - cx) ** 2 + (y - cy) ** 2 > r * r) continue;
const hit = root.elementFromPoint(x, y);
if (hit?.closest('.hp-static-plan-svg')) plan++;
else if (hit?.closest('.hp-moon')) disc++;
}
}
style.remove();
return { plan, disc };
});
report.ac6 = { night, stacking };
checks.ac6_planCoversPartOfTheDisc = stacking.plan > 0 && stacking.disc > 0;
const pushSpace = (sun) => page.evaluate(async (sun) => {
const card = window.__hpSpaceCard;
const states = { ...card.hass.states };
if (sun) states['sun.sun'] = { entity_id: 'sun.sun', state: 'above_horizon', attributes: sun };
else delete states['sun.sun'];
card.hass = { ...card.hass, states };
await card.updateComplete;
}, sun);
await pushSpace(DAY_SUN);
checks.ac6_dayHidesTheMoon = await until(page,
() => window.__hpSpaceCard.renderRoot.querySelector('.hp-moon')?.classList.contains('on') === false);
// Without `sun.sun`: 17:59 is day by the clock; 18:00 comes without a state update.
await page.clock.setSystemTime(new Date('2026-10-21T17:59:00Z'));
await pushSpace(null);
await page.waitForTimeout(300);
const clockDay = await spaceMoon();
await page.clock.runFor(90_000);
const clockDusk = await until(page, spaceOn);
report.ac6.clockDay = clockDay;
checks.ac6_clockDuskShowsTheMoon = !clockDay.on && clockDusk;
console.log(JSON.stringify(report, null, 1));
checkAll(checks);
await finish(browser, checks);
}
+200
View File
@@ -0,0 +1,200 @@
// #718 AC11/AC12: the «Now: …» line under the moon switch in General
// settings, in the production bundle. One snapshot per opening, judged by the
// lazy moon chunk as if the switch were on; kept outside the draft. Moscow
// through `hass.config`, the page clock at 2026-10-21 18:00Z (the moon at
// 23.9°, 79 % lit), the demo in English.
import { launch, checkAll, finish } from './serve.mjs';
const MOSCOW = { latitude: 55.75, longitude: 37.62 };
const NIGHT_SUN = { azimuth: 0, elevation: -12, rising: false };
const HINT = 'Shown behind the plan with any background, from 3° above the horizon, except around new moon; '
+ 'computed from the home coordinates in Home Assistant.';
const SHOWN = 'Now: shown (24° above the horizon, 79% lit).';
const DAY_SUN = 'Now: not shown (the sun is 25° above the horizon; the moon shows once it is below 6°).';
const checks = {};
const report = {};
const isMoonAsset = (url) => /\/moon-runtime-[^/?]*\.js(?:\?|$)/.test(url);
/**
* A fresh page (the chunk is loaded once per page). `route` intercepts the
* moon chunk on top of the harness's `route('**\/*')`.
*/
async function open({ moon = false, route = null } = {}) {
const run = await launch({ width: 1000, height: 760 });
run.moonRequests = [];
run.page.on('request', (request) => { if (isMoonAsset(request.url())) run.moonRequests.push(request.url()); });
if (route) await run.page.route('**/moon-runtime-*.js', route);
await run.page.clock.setFixedTime(new Date('2026-10-21T18:00:00Z'));
await pushHass(run.page, { config: MOSCOW, sun: NIGHT_SUN });
await run.page.evaluate((moon) => window.__hpTest.setServerConfig((cfg) => {
cfg.settings = { ...(cfg.settings || {}), bg_mode: 'static' };
if (moon) cfg.settings.moon = true; else delete cfg.settings.moon;
return cfg;
}), moon);
return run;
}
const pushHass = (page, { config, sun }) => page.evaluate(async ({ config, sun }) => {
const card = window.__card;
card.hass = {
...card.hass,
...(config ? { config } : {}),
states: sun
? { ...card.hass.states, 'sun.sun': { entity_id: 'sun.sun', state: sun.elevation > 0 ? 'above_horizon' : 'below_horizon', attributes: sun } }
: card.hass.states,
};
await card.updateComplete;
}, { config, sun });
/** Open General settings; what the first rendered frame shows. */
const openDialog = (page) => page.evaluate(async () => {
const card = window.__card;
card._openSettingsDialog();
await card.updateComplete;
const lines = card.renderRoot.querySelectorAll('hp-dialog [data-moon-status]');
return {
lines: lines.length,
caption: card.renderRoot.querySelector('hp-dialog #gs-moon-caption')?.textContent.trim() ?? null,
};
});
const line = (page) => page.evaluate(() => {
const root = window.__card.renderRoot;
const nodes = root.querySelectorAll('hp-dialog [data-moon-status]');
const node = nodes[0] || null;
return {
count: nodes.length,
reason: node?.dataset.moonStatus ?? null,
text: node?.textContent.trim() ?? null,
describedBy: root.querySelector('hp-dialog #gs-moon')?.getAttribute('aria-describedby') ?? null,
inCaption: !!node?.closest('#gs-moon-caption'),
live: !!node?.closest('[aria-live]'),
saveDisabled: !!root.querySelector('hp-dialog [data-hp="dialog-confirm"]')?.disabled,
};
});
const waitLine = (page, timeout = 2000) => page.waitForFunction(
() => !!window.__card.renderRoot.querySelector('hp-dialog [data-moon-status]'), null, { timeout },
).then(() => true, () => false);
const closeDialog = (page) => page.evaluate(async () => {
const dialog = window.__card.renderRoot.querySelector('hp-dialog[data-kind="settings"]');
if (!dialog) return { closed: false, confirm: false };
const result = await window.__hpTest.close(dialog);
return { closed: result.closed, confirm: !!result.confirm };
});
// ─── AC11 and AC12 (chunk already loaded by the View at night) ───
{
const { page, browser } = await open({ moon: true });
const planMoon = await page.waitForFunction(
() => window.__card.renderRoot.querySelector('.hp-moon.on')?.dataset.moonK, null, { timeout: 8000 },
).then((handle) => handle.jsonValue(), () => null);
const first = await openDialog(page);
// AC12: the View loaded the chunk — the line is in the dialog's first frame.
checks.ac12_preloadedLineInTheFirstFrame = first.lines === 1;
const shown = await line(page);
report.ac11 = { planMoon, first, shown };
checks.ac11_shownText = shown.text === SHOWN && shown.reason === 'shown';
checks.ac11_percentIsThePlanMoon = planMoon !== null && Math.round(Number(planMoon) * 100) === 79;
checks.ac11_lineIsTheSecondCaptionLine = shown.inCaption && shown.describedBy === 'gs-moon-caption'
&& !shown.live && first.caption === `${HINT}${SHOWN}`;
checks.ac11_lineLeavesTheDraftClean = shown.saveDisabled;
const toggled = [];
for (let i = 0; i < 2; i++) {
await page.evaluate(async () => {
window.__card.renderRoot.querySelector('hp-dialog #gs-moon')?.click();
await window.__card.updateComplete;
});
toggled.push(await line(page));
}
report.ac11.toggled = toggled;
checks.ac11_switchDoesNotChangeTheText = toggled.every((state) => state.text === SHOWN && state.count === 1);
const closed = await closeDialog(page);
checks.ac11_closesWithoutAsking = closed.closed && !closed.confirm;
await pushHass(page, { sun: { azimuth: 200, elevation: 25.4, rising: false } });
await openDialog(page);
const day = await line(page);
checks.ac11_daySunText = day.count === 1 && day.reason === 'day_sun' && day.text === DAY_SUN;
await closeDialog(page);
await pushHass(page, { config: { unit_system: { length: 'km' } } });
await openDialog(page);
const noHome = await line(page);
report.ac11.day = day;
report.ac11.noHome = noHome;
checks.ac11_noHomeReason = noHome.count === 1 && noHome.reason === 'no_home';
await closeDialog(page);
await browser.close();
}
// ─── AC12: the chunk arrives a second late ───
{
const delay = async (route) => { await new Promise((done) => setTimeout(done, 1000)); await route.fallback(); };
const { page, browser, moonRequests } = await open({ route: delay });
const first = await openDialog(page);
checks.ac12_noLineWhileLoading = first.lines === 0 && first.caption === HINT;
checks.ac12_lineArrives = await waitLine(page, 2000);
const late = await line(page);
report.ac12 = { first, late, requests: moonRequests.length };
// The switch is off here: the dialog alone asked for the chunk, and the line
// still says what the switch would show when on.
checks.ac12_dialogLoadsTheChunkWithTheMoonOff = moonRequests.length === 1 && late.reason === 'shown'
&& late.saveDisabled;
await closeDialog(page);
await browser.close();
}
// ─── AC12: opened while loading, closed, sun changed, opened again ───
{
const delay = async (route) => { await new Promise((done) => setTimeout(done, 1000)); await route.fallback(); };
const { page, browser } = await open({ route: delay });
await openDialog(page);
const closed = await closeDialog(page);
await pushHass(page, { sun: { azimuth: 180, elevation: 40, rising: false } });
await openDialog(page);
const arrived = await waitLine(page, 3000);
await page.waitForTimeout(300);
const again = await line(page);
report.ac12.reopened = { closed, again };
checks.ac12_reopenShowsOnlyTheSecondSnapshot = closed.closed && arrived && again.count === 1
&& again.reason === 'day_sun';
await closeDialog(page);
await browser.close();
}
// ─── AC12: the chunk request is refused ───
{
const { page, browser } = await open({ route: (route) => route.abort() });
const first = await openDialog(page);
await page.waitForTimeout(800);
const refused = await line(page);
report.ac12.refused = { first, refused };
checks.ac12_refusedLeavesNoLine = first.lines === 0 && refused.count === 0;
// The dialog keeps working: a change enables Save and saves; Cancel closes.
const saved = await page.evaluate(async () => {
const card = window.__card;
const root = card.renderRoot;
root.querySelector('hp-dialog #gs-moon')?.click();
await card.updateComplete;
const save = root.querySelector('hp-dialog [data-hp="dialog-confirm"]');
const enabled = !!save && !save.disabled;
save?.click();
const deadline = Date.now() + 4000;
while (root.querySelector('hp-dialog[data-kind="settings"]') && Date.now() < deadline) {
await new Promise((done) => setTimeout(done, 50));
}
return { enabled, closed: !root.querySelector('hp-dialog[data-kind="settings"]'), moon: card._serverCfg.settings.moon === true };
});
await openDialog(page);
const cancelled = await page.evaluate(async () => {
const dialog = window.__card.renderRoot.querySelector('hp-dialog[data-kind="settings"]');
const result = await window.__hpTest.close(dialog, { via: 'cancel' });
return { closed: result.closed, confirm: !!result.confirm };
});
report.ac12.saved = saved;
report.ac12.cancelled = cancelled;
checks.ac12_saveStillWorks = saved.enabled && saved.closed && saved.moon;
checks.ac12_cancelStillWorks = cancelled.closed && !cancelled.confirm;
console.log(JSON.stringify(report, null, 1));
checkAll(checks);
await finish(browser, checks);
}
+11
View File
@@ -2,6 +2,17 @@
## Unreleased
- The moon now appears with any background, not only "Follow the Sun": with a
static background — the theme's or a colour of your own, for the whole
installation or a single space — it stands in the same top-left corner behind
the plan at dawn, dusk and night, in View, kiosk, the panel and the space
card, while the background keeps its colour. If "Moon over the plan at dusk
and night" was already on, the moon appears on static backgrounds after this
update; switch it off in General settings › Sun and Moon to hide it. Under
that switch a new line tells whether the moon is shown right now and, if not,
why — daytime, the moon below 3°, new moon or no home location in Home
Assistant ([#718](https://github.com/Matysh/houseplan-card/issues/718)).
## v1.79.0-beta.1 — 2026-09-30
- In 2.5D View the plan no longer moves: rooms, furniture, other decor and room
+11
View File
@@ -8,6 +8,17 @@
## Не выпущено
- Луна теперь видна при любом фоне, а не только на «Следует за Солнцем»: при
статичном фоне — фоне темы или своём цвете, для всей установки или одного
пространства — она стоит в том же левом верхнем углу за планом в сумерках и
ночью, в просмотре, киоске, панели и карточке пространства, а фон сохраняет
свой цвет. Если переключатель «Луна на плане в сумерках и ночью» уже был
включён, после обновления луна появится и на статичном фоне; чтобы её убрать,
выключите его в общих настройках, раздел «Солнце и Луна». Под переключателем
появилась строка: видна ли луна сейчас, а если нет — почему (день, луна ниже
3°, новолуние или в Home Assistant не заданы координаты дома)
([#718](https://github.com/Matysh/houseplan-card/issues/718)).
## v1.79.0-beta.1 — 2026-09-30
- В объёмном 2.5D-виде план больше не сдвигается: комнаты, мебель, прочий декор
+13 -8
View File
@@ -134,21 +134,26 @@ An older frontend ignores the field and shows Flat. An older backend preserves
it through the unknown-settings policy. Full backup/import carries `settings`
whole, so the value survives.
## Moon (#661)
## Moon (#661, #718)
`settings.moon` is an optional global boolean: the moon on the "Follow the Sun"
background for every space, user, device and kiosk. Only exact `true` switches
it on; absence, `false` or anything else read as off. Saving `false` removes
the key. There is no per-space moon: a space shows it only when its effective
`bg_mode` is `daynight`. The backend accepts only a boolean
`settings.moon` is an optional global boolean: the moon behind the plan at
dawn, dusk and night for every space, user, device and kiosk, with any
background (#718 — before it, only on "Follow the Sun"). Only exact `true`
switches it on; absence, `false` or anything else read as off. Saving `false`
removes the key. There is no per-space moon switch, and the effective
`bg_mode` of a space is no longer a condition: a space with a static
background, its own or the global one, shows the moon too. The backend accepts only a boolean
(`vol.Optional("moon"): bool`), and the privacy-safe support projection copies
only a validated boolean. New installations get `moon: true` in
`DEFAULT_CONFIG`; an existing config has no key and keeps its look after the
update (docs/SUN.md). "Reset to defaults" in General settings sets `true`. The
store and model versions do not change; there is no migration.
An older frontend ignores the field and shows no moon. An older backend
preserves it through the unknown-settings policy. Full backup/import carries
An older frontend ignores the field and shows no moon; a #661 frontend with a
#718 config shows it only on `daynight`, as before. #718 adds no field and no
migration: an installation that already has `moon: true` with a static
background sees the moon there after the update, and the same switch turns it
off. An older backend preserves it through the unknown-settings policy. Full backup/import carries
`settings` whole, so the value survives; a one-space export does not carry
global settings.
+86 -30
View File
@@ -376,26 +376,26 @@ The wash lives where the Flat `.sunlayer` lives (floor group, above room fills
and Glow) as `.sunlayer.iso-sunwash`, one `.iso-sunbeam[data-opening]` per lit
window. Witness: `demo/smoke_iso_sun.mjs`.
## Moon — `settings.moon` (#661)
## Moon — `settings.moon` (#661, any background since #718)
At dawn, dusk and night on the "Follow the Sun" background, the moon in its
current phase stands in the top-left corner of the scene: a thin crescent, a
half, a full disc. General settings › Sun and Moon › «Moon over the plan at
dusk and night» switches it for the whole installation.
At dawn, dusk and night, with any background, the moon in its current phase
stands in the top-left corner of the scene: a thin crescent, a half, a full
disc. General settings › Sun and Moon › «Moon over the plan at dusk and night»
switches it for the whole installation; there is no per-space moon switch.
**When it is shown** — all at once, otherwise there is no moon:
- `settings.moon === true` (global; absent, `false` or anything else is off);
- the effective `bg_mode` of the space is `daynight` — the moon lives in the
four-phase environment, so a space with its own `static` has no moon, and
there is no per-space moon switch;
- the environment phase is `dawn`, `dusk` or `night` (the same
`resolveDayCycle`, browser-clock fallback included);
- the day-cycle phase is `dawn`, `dusk` or `night`: `resolveDayCycle(hass,
now)`, whatever the background — with a valid `sun.sun` (`dayCycleSunOf`:
finite azimuth and elevation, boolean `rising`) day is elevation ≥ 6°
(exactly 6° is day), otherwise the browser-local clock, day 08:00–18:00. The
effective `bg_mode` of the space is not a condition (#718 K1);
- the topocentric altitude is ≥ 3° (`MOON_ELEVATION_MIN = RAY_ELEVATION_MIN`);
- the illuminated fraction is ≥ 3 % (`MOON_MIN_ILLUMINATION`, about ±1.5 days
around new moon);
- a View surface: View, kiosk or `houseplan-space-card` (editors have no
environment);
- a View surface: View, kiosk, panel or `houseplan-space-card` (editors, the
PDF export and the space dialog preview have no moon);
- `hass.config.latitude/longitude` are finite numbers.
**Where the numbers come from.** Home Assistant publishes no moon altitude
@@ -423,12 +423,29 @@ quantised to 0.01 only so the element changes when the fingerprint does. The
dark side is the same art at 8 % opacity.
**Place, size, layer.** Fixed top-left corner, box `min(200px, 25cqmin)` of the
scene (the environment is the size container), inset 5 % of the box; it does
not move with pan or zoom and never takes the pointer. It is the last child of
`.hp-day-cycle-env`: above the phase gradients and the sun glow, below the plan
paper, rooms, devices, labels and UI. A plan that fills the scene covers the
moon partly or entirely — that is the environment's norm, like the sun glow
(owner decision 7).
scene, inset 5 % of the box; it does not move with pan or zoom and never takes
the pointer. Over "Follow the Sun" it is the last child of `.hp-day-cycle-env`:
above the phase gradients and the sun glow, below the plan paper, rooms,
devices, labels and UI. A plan that fills the scene covers the moon partly or
entirely — that is the environment's norm, like the sun glow (owner
decision 7).
**Static background (#718 K3).** No environment is created — no
`.hp-day-cycle-env`, no `daycycle`/`phase-*` classes, no
`.hp-paper-outline-svg`; the scene keeps the chosen colour or the theme's. The
same `.hp-moon` element stands in its own layer `<div class="hp-moon-sky"
aria-hidden="true">`, the first child of `.stage` (full card) or
`.hp-static-stage` (space card) — where the environment would be. The layer is
the whole scene (`position:absolute; inset:0; overflow:hidden;
pointer-events:none; container-type:size`), so the box and place are the same;
it has no `z-index`, `filter` or `will-change` and lies under the plan by DOM
order (`.zoomwrap` and the space card's plan are `z-index:1`). The layer's
`opacity` is the View weight of the #101 transition, as the environment's;
editors have neither. Switching between `daynight` and `static` (a space tab,
the background segment previewed in the open dialog, a config push) moves the
element to its new parent in the same render, in its final state, on the same
box — no flicker. The chunk renders and styles the layer; until it is here
there is no layer.
**Movement.** Opacity only, 2 s on the background curve (`RAY_FADE_MS`),
none under `prefers-reduced-motion`: rising through 3°, setting through it,
@@ -439,13 +456,47 @@ the ticker keeps the last rendered inputs, and an equal fingerprint
(`visible | k`) costs no render. A card that renders without a moon, leaves the
page or is hidden drops or pauses its ticker.
**Weight.** Astronomy, art (≈ 9 KB gzip) and template are one lazy chunk,
`moon-runtime-*`, loaded when the moon is switched on, the environment exists
and it is not daytime; until it arrives there is no moon, a failed load is
retried at most every 30 s through the exact-build loader of the isometric
runtime. The initial View graph carries only the gate (`src/moon-gate.ts`).
One element, no CSS filter and no `will-change`: the composited layer count
does not change (`demo/smoke_daycycle_layer_budget.mjs`).
With a static background the phase for the moon comes from the same
`resolveDayCycle` (`moonSkyState` in `src/moon-gate.ts`), computed only while
the moon is on and the surface is View. With `sun.sun` it follows the Home
Assistant state updates the cards already re-render on. Without it the card
keeps its 30 s clock ticker (`_syncDayCycleClock`, both cards) and re-renders
only when the phase changes (`dayCycleClock`: the environment is compared by
its whole fingerprint, the moon's sky by its phase), so the moon leaves at
08:00 and comes at 18:00 without a state update. The moon switched off or an
editor: no phase, no ticker.
**Weight.** Astronomy, art (≈ 9 KB gzip), template, the static-background
layer and the status function are one lazy chunk, `moon-runtime-*`, loaded
when the moon is switched on on a View surface and it is not daytime — with any
background — and when General settings open (for the status line, by day and
with the moon off too). One load per page through the gate's loader
(`withMoon`): a chunk of another build is never installed, a failed load is
retried at most every 30 s, and every caller meanwhile waits for the same load.
The initial View graph carries only the gate (`src/moon-gate.ts`); the editor
graph only the status line and its strings (`src/editors/moon-status.ts`) —
`src/moon.ts` is in neither (`scripts/bundle-budget.mjs` refuses an overlap of
the moon graph with the initial or the editor graph). One element, no CSS
filter and no `will-change`: the composited layer count does not change, with
either background (`demo/smoke_daycycle_layer_budget.mjs`).
**Status line (#718 K7).** Under the switch in General settings a second
caption line, inside `aria-describedby`, no `aria-live`, anchored
`data-moon-status="shown|no_home|day_sun|day_clock|low|new"`: «Now: shown (24°
above the horizon, 79% lit).» or «Now: not shown (reason).», the first reason
that holds — no home coordinates; day (by `sun.sun` with its elevation, or by
the clock); the moon below 3°; under 3 % lit. It is judged once per opening on
a snapshot taken when the dialog opens (`now`, `hass.config`, `sun.sun`), as
if the switch were on — the moon no longer depends on the background, so one
status serves the installation; the switch, «Reset» and the background segment
do not change it. `moonStatus` in `src/moon.ts` decides «shown» with the same
`moonShownAt` as the element (AC10 checks the equivalence every hour of a
month), rounds to whole numbers and keeps a hidden reason's number below its
threshold (2.6° reads «2°»). The status lives beside the draft, never in it:
the line arriving leaves «Save» disabled. While the chunk loads, or when it
failed, there is no line; a closed opening's result is dropped. The line
belongs to the browser the dialog is open in — a wall tablet with another
clock or time zone may differ.
**Limits (documented, not bugs).**
@@ -455,6 +506,8 @@ does not change (`demo/smoke_daycycle_layer_budget.mjs`).
not follow the hemisphere (owner decision): the crescent is "vertical".
- Earthshine is not modelled; the dark side is an 8 % silhouette for legibility.
- The moon does not follow its azimuth — fixed corner of the scene.
- One art for every background (owner decision, #718): on a mid-grey custom
colour the disc has less contrast than on white or at night.
- A dense layout (plan filling the screen) shows only the part of the disc in
the margins.
- Coordinates come from Home Assistant: an installation that kept the default
@@ -504,11 +557,14 @@ saving General settings removes it.
- `src/day-cycle-render.ts` — the shared constant environment layers and
plan-outline variables for full and static cards.
- `src/moon.ts`, `src/moon-runtime.ts`, `src/moon-gate.ts`,
`src/moon-art.generated.ts` — the moon (#661): pure astronomy and mask, the
lazy chunk with the element and its ticker, the initial-graph gate, the
generated art (`node scripts/generate-moon-assets.mjs` from
`assets/moon/houseplan-1.0.0`); unit-tested in `test/moon.test.mjs`,
end-to-end in `demo/smoke_moon.mjs`.
`src/moon-art.generated.ts` — the moon (#661, #718): pure astronomy, mask and
status, the lazy chunk with the element, the static-background layer and the
ticker, the initial-graph gate with the page-wide loader, the generated art
(`node scripts/generate-moon-assets.mjs` from `assets/moon/houseplan-1.0.0`);
`src/editors/moon-status.ts` — the General settings line. Unit-tested in
`test/moon.test.mjs` and `test/moon-settings.test.mjs`, end-to-end in
`demo/smoke_moon.mjs`, `demo/smoke_moon_static.mjs` and
`demo/smoke_moon_status.mjs`.
- `src/houseplan-card.ts` — the memoised wedge layer, four-phase background
lifecycle, and both settings dialogs (compass dial included).
- `src/space-render.ts` / `src/space-card.ts` — static-card environment and
+37 -8
View File
@@ -1657,19 +1657,27 @@ choice. Shadows from trees, awnings or other building wings are not modelled.
### Moon
On the **Follow the sun** background, at dawn, dusk and night, the moon in its
current phase stands in the top-left corner of the scene — a thin crescent, a
half, a full disc. One switch in General settings turns it on: **Sun and Moon**
→ **Moon over the plan at dusk and night**. It is on for new installations and
off for upgraded ones until switched on: an update never changes how a plan
looks.
With any background — **Follow the sun**, the theme background or a colour of
your own — at dawn, dusk and night the moon in its current phase stands in the
top-left corner of the scene — a thin crescent, a half, a full disc. One switch
in General settings turns it on: **Sun and Moon** → **Moon over the plan at dusk
and night**. It is on for new installations and off for upgraded ones until
switched on: an update never changes how a plan looks. If the switch was
already on with a static background, the moon appears on that background after
the update; the same switch removes it.
Dusk and night are decided as for the Follow the sun background: with a valid
`sun.sun` by the sun's elevation (below 6°), without it by the browser clock
(day is 08:00–18:00). A static background stays as it is: the chosen colour
around the plan, no gradients and no outline glow — only the moon is added.
| Condition | Result |
|---|---|
| Daytime, moon below 3° above the horizon, or new moon (under 3 % lit) | No moon |
| The moon rises above 3° or sets below it | Fades in/out over 2 seconds; immediately with reduced motion |
| Phase | Changes continuously, every day; the lit side is always on the left, waning runs the same states backwards |
| Space with its own static background | No moon — it belongs to the Follow the sun background |
| Space with its own static background | The same moon: same place, size and phase; the background keeps its colour |
| Changing the background, or a tab with another background | The moon does not flicker and stays in place |
| The plan covers the corner of the scene | The moon is behind the plan: the part of the disc in the margins shows; the plan, devices and labels are always on top |
| Editors | No moon |
| Full card, kiosk, static space card | The same |
@@ -1680,7 +1688,28 @@ integration or external data. A Home Assistant that kept the default location
shows someone else's moon — as with `sun.sun`; a wrong tablet clock gives a
wrong phase and moment. Position accuracy is about 1°, so crossing 3° may
differ from ephemerides by a few minutes. The terminator tilt and earthshine
are not modelled; the dark side is a faint silhouette so a crescent reads.
are not modelled; the dark side is a faint silhouette so a crescent reads. One
image serves every background: on a mid-grey colour of your own the moon has
less contrast.
Under the switch a «Now: …» line says whether the moon is shown this minute
and, if not, why — the first reason that holds:
| Line | When |
|---|---|
| Now: shown (24° above the horizon, 79% lit). | The moon is shown; altitude and illumination in whole numbers |
| Now: not shown (the home location is not set in Home Assistant). | No home latitude and longitude — even by day |
| Now: not shown (the sun is 25° above the horizon; the moon shows once it is below 6°). | Daytime by `sun.sun` |
| Now: not shown (daytime by the clock, 08:00–18:00: Home Assistant has no sun.sun data). | Daytime by the browser clock |
| Now: not shown (the moon is at 2°; it shows from 3° above the horizon). | The moon is below 3° |
| Now: not shown (new moon, 2% lit; it shows from 3%). | Under 3 % lit |
The line is computed once when the dialog opens and tells what the switch would
show when on: the switch itself, the background chosen in the dialog and the
space settings do not change it. It belongs to the browser the dialog is open
in: a wall tablet with another clock or time zone may show or hide its moon at
another moment. While the moon's data loads, or if loading failed, there is no
line — the general hint stays.
## 16. Robot vacuums
+38 -8
View File
@@ -1797,7 +1797,7 @@ WebP и безопасный SVG; лимит 2 МиБ относится к со
## 15. Солнце и Луна: фон, оконные лучи и луна
Фон и оконные лучи — две независимые функции; луна живёт на фоне. Фон **Следует за Солнцем** работает без компаса:
Фон, оконные лучи и луна — независимые функции; луна стоит за планом при любом фоне. Фон **Следует за Солнцем** работает без компаса:
он использует корректные данные `sun.sun`, а при их отсутствии автоматически
переходит на локальные часы браузера. Направление севера и `sun.sun` обязательны
только для оконных лучей. Общие значения можно переопределить в конкретном
@@ -1857,19 +1857,28 @@ WebP и безопасный SVG; лимит 2 МиБ относится к со
### Луна
При фоне **Следует за Солнцем** в сумерках (утренних и вечерних) и ночью в левом
верхнем углу сцены видна луна в текущей фазе — тонкий серп, половина, полная.
Включается одним переключателем в общих настройках, раздел **Солнце и Луна** →
**Луна на плане в сумерках и ночью**. У новых установок переключатель включён,
у обновлённых старых — выключен, пока его не включат: обновление не меняет вид
плана.
При любом фоне — **Следует за Солнцем**, фоне темы или своём цвете — в сумерках
(утренних и вечерних) и ночью в левом верхнем углу сцены видна луна в текущей
фазе — тонкий серп, половина, полная. Включается одним переключателем в общих
настройках, раздел **Солнце и Луна** → **Луна на плане в сумерках и ночью**. У
новых установок переключатель включён, у обновлённых старых — выключен, пока его
не включат: обновление не меняет вид плана. Если переключатель уже был включён,
а фон статичный, после обновления луна появится и на нём; убрать её — тем же
переключателем.
Сумерки и ночь определяются так же, как у фона «Следует за Солнцем»: при
корректном `sun.sun` — по высоте Солнца (ниже 6°), без него — по часам
браузера (день 08:00–18:00). Статичный фон при этом не меняется: вокруг плана
остаётся выбранный цвет, градиентов и свечения контура нет, добавляется только
луна.
| Условие | Результат |
|---|---|
| День, луна ниже 3° над горизонтом или новолуние (освещено меньше 3 %) | Луны нет |
| Луна поднимается выше 3° или уходит ниже | Плавно появляется/исчезает за 2 секунды; при системном уменьшении движения — сразу |
| Фаза | Меняется непрерывно, каждый день; освещённая сторона всегда слева, убывание идёт теми же состояниями в обратном порядке |
| Пространство с собственным статическим фоном | Луны нет — она часть фона «Следует за Солнцем» |
| Пространство с собственным статическим фоном | Луна та же: то же место, размер и фаза, фон — выбранный цвет |
| Смена фона или переход на вкладку с другим фоном | Луна не мигает и остаётся на месте |
| План закрывает угол сцены | Луна за планом: видна та часть диска, что в полях; план, устройства и подписи всегда поверх неё |
| Редакторы | Луны нет |
| Полная карточка, киоск, статическая карточка пространства | Одинаково |
@@ -1881,6 +1890,27 @@ WebP и безопасный SVG; лимит 2 МиБ относится к со
появления. Точность положения около 1°: момент пересечения 3° может разойтись с
эфемеридами на несколько минут. Наклон терминатора и пепельный свет не
воспроизводятся, тёмная сторона — едва заметный силуэт для читаемости серпа.
Картинка одна для всех фонов: на среднем сером своём цвете луна контрастнее не
станет.
Под переключателем — строка «Сейчас: …»: видна ли луна в эту минуту, а если нет,
то почему — первая по порядку причина:
| Строка | Когда |
|---|---|
| Сейчас: показывается (на 24° над горизонтом, освещено 79 %). | Луна видна; высота и освещённость округлены до целых |
| Сейчас: не показывается (в Home Assistant не заданы координаты дома). | Нет широты и долготы дома — даже днём |
| Сейчас: не показывается (Солнце на 25° над горизонтом, луна видна, когда оно ниже 6°). | День по `sun.sun` |
| Сейчас: не показывается (день по часам, 08:00–18:00: в Home Assistant нет данных sun.sun). | День по часам браузера |
| Сейчас: не показывается (луна на высоте 2°, видна от 3° над горизонтом). | Луна ниже 3° |
| Сейчас: не показывается (новолуние, освещено 2 %, видна от 3 %). | Освещено меньше 3 % |
Строка считается один раз при открытии диалога и говорит, что было бы при
включённом переключателе: положение тумблера, выбор фона в диалоге и
настройки пространств на неё не влияют. Она относится к браузеру, в котором
открыт диалог: если у настенного планшета другие часы или часовой пояс, его
луна может появиться или исчезнуть в другой момент. Пока данные луны
загружаются или если загрузка не удалась, строки нет — остаётся общее пояснение.
## 16. Роботы-пылесосы
@@ -16,8 +16,8 @@ to prove that the Node witness actually kills it.
| Paint, cascade and layer composition | 26 | The invariant depends on computed CSS, SVG paint, clipping, stacking or pixels produced by Chromium. |
| Pointer geometry and trusted interaction | 45 | The invariant depends on hit testing, pointer capture, touch/keyboard dispatch or live DOM geometry. |
| Responsive DOM layout | 36 | The invariant depends on measured element boxes, responsive breakpoints, native/HA dialog shells or focusable target size. |
| Custom-element and HA browser lifecycle | 85 | The invariant crosses Lit/custom-element lifecycle, browser storage/events, lazy loading or a complete HA-card state transition. |
| **Total** | **200 / 200** | Growth above the cap fails `mutation-gate --check`. |
| Custom-element and HA browser lifecycle | 86 | The invariant crosses Lit/custom-element lifecycle, browser storage/events, lazy loading or a complete HA-card state transition. |
| **Total** | **201 / 200** | Growth above the cap fails `mutation-gate --check`. |
## Measured effect
@@ -242,6 +242,7 @@ The invariant crosses Lit/custom-element lifecycle, browser storage/events, lazy
- `locale-failure-toast-dropped`
- `marker-reject-keeps-optimistic-candidate`
- `marker-rollback-keeps-enqueue-time-revision`
- `moon-static-clock-tick-off`
- `namespace-loader-returns-english`
- `near-axis-authoring-snap-bypassed`
- `onboarding-loader-skips-namespace-ensure`
+5
View File
@@ -741,6 +741,11 @@ export function assertBundleBudget(
if (manifest.initialViewFiles.some((path) => manifest.lazyMoonFiles.includes(path))) {
throw new Error('initial View graph overlaps lazy moon graph');
}
// #718 K9: General settings reach the moon's status through the gate's
// loader only; a static import would pull its astronomy into the editor.
if (manifest.lazyEditorFiles.some((path) => manifest.lazyMoonFiles.includes(path))) {
throw new Error('lazy editor graph overlaps lazy moon graph');
}
// #474: designer furniture artwork is lazy; a static import anywhere in the
// View graph would pull ~10 KB gzip back into the initial graph silently.
if (!manifest.lazyFurnitureArtFiles?.length) {
+54 -2
View File
@@ -6302,8 +6302,8 @@ const MUTANT_DEFINITIONS = [
because: '#661 C7: the moon chunk is not loaded by day — the initial View graph pays only the gate',
patches: [{
file: 'src/moon-gate.ts',
find: " if (!runtime && state.phase !== 'day' && Date.now() >= nextAttempt) {",
replace: ' if (!runtime && Date.now() >= nextAttempt) {',
find: " if (!runtime && state.phase !== 'day') withMoon(() => host.requestUpdate());",
replace: ' if (!runtime) withMoon(() => host.requestUpdate());',
}],
},
{
@@ -6322,6 +6322,58 @@ const MUTANT_DEFINITIONS = [
replace: " + '.hp-moon{--hp-moon-box:min(200px,25cqmin);position:absolute;z-index:2;'",
}],
},
{
id: 'moon-static-sky-off',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test --test-name-pattern="#718" test/moon.test.mjs',
because: '#718 AC15/AC1: a static background — global or the space\'s own — shows the moon in its '
+ 'own sky layer; back to «no environment, no moon» it would give nothing again',
patches: [{
file: 'src/moon-gate.ts',
find: ' return !daynight && viewWeight > 0 && moonOn(settings) ? resolveDayCycle(hass, now) : null;',
replace: ' return daynight && viewWeight > 0 && moonOn(settings) ? resolveDayCycle(hass, now) : null;',
}],
},
{
id: 'moon-static-clock-tick-off',
guard: 'node demo/smoke_moon_static.mjs',
because: '#718 AC3/K5: without sun.sun a static background has only the card\'s 30 s clock ticker '
+ 'to reach 18:00 and 08:00; not armed, the moon never rises by the clock in the real card '
+ '(page clock, UTC context, no state update), which only the browser run observes',
patches: [{
file: 'src/houseplan-card.ts',
find: ' const [state, key] = dayCycleClock(this._dayCycleState(), this._moonSkyState());\n'
+ ' this._dayCycleClockKey = key;\n const needsTimer',
replace: ' const [state, key] = dayCycleClock(this._dayCycleState(), null);\n'
+ ' this._dayCycleClockKey = key;\n const needsTimer',
}],
},
{
id: 'moon-status-order',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test --test-name-pattern="#718" test/moon.test.mjs',
because: '#718 AC9/K7: without home coordinates the status says so even by day — the day reason '
+ 'checked first would name the sun of a home the card cannot place',
patches: [{
file: 'src/moon.ts',
find: " if (!sky.home) return { reason: 'no_home' };\n"
+ " if (sky.phase === 'day') return sky.source === 'sun' ? { reason: 'day_sun', sun: Math.round(sky.sun ?? 0) } : { reason: 'day_clock' };\n",
replace: " if (sky.phase === 'day') return sky.source === 'sun' ? { reason: 'day_sun', sun: Math.round(sky.sun ?? 0) } : { reason: 'day_clock' };\n"
+ " if (!sky.home) return { reason: 'no_home' };\n",
}],
},
{
id: 'moon-status-clamp-off',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test --test-name-pattern="#718" test/moon.test.mjs',
because: '#718 AC9/K7: a hidden reason never rounds up to its threshold — 2.6° or 2.6 % read «2», '
+ 'not «at 3°, shows from 3°»',
patches: [{
file: 'src/moon.ts',
find: " return sky.altitude < MOON_ELEVATION_MIN ? { reason: 'low', alt: Math.min(alt, 2) } : { reason: 'new', pct: Math.min(pct, 2) };",
replace: " return sky.altitude < MOON_ELEVATION_MIN ? { reason: 'low', alt } : { reason: 'new', pct };",
}],
},
{
id: 'moon-validation-accepts-anything',
guard: 'python3 -m pytest tests_backend/test_settings_moon.py -q -p no:cacheprovider',
+6 -1
View File
@@ -42,11 +42,16 @@ export const SMOKE_LINKS = [
symbols: [
'moonLayer', 'renderMoon', 'moonTick', 'moonView', 'moonShownAt', 'moonPhasePath',
'moonPosition', 'moonIllumination', 'moonFingerprint', 'renderDayCycleEnvironment',
'withMoon', 'moonSkyState', 'dayCycleClock', 'renderMoonSky', '_moonSkyState', '_dayCycleClock',
'moonStatus', 'moonStatusOf', 'openMoonStatus', 'moonStatusText',
],
smokes: ['smoke_moon.mjs', 'smoke_daycycle_layer_budget.mjs'],
smokes: ['smoke_moon.mjs', 'smoke_moon_static.mjs', 'smoke_moon_status.mjs', 'smoke_daycycle_layer_budget.mjs'],
because: '#661: the moon is observed only as `.hp-moon` in the production bundle — the lazy '
+ 'chunk arriving at night, the 30 s ticker and state updates carrying it through 3° with the '
+ '2 s fade, the plan painting over it pixel for pixel, and no extra composited layer (CDP); '
+ '#718: the same over a static background as `.hp-moon-sky` (the clock ticker at 08:00 and '
+ '18:00, tab and background switches, the #101 View weight, houseplan-space-card) and the '
+ 'General settings line as `[data-moon-status]` (delayed, refused and preloaded chunk); '
+ 'no smoke names the functions',
},
{
+7 -1
View File
@@ -15,6 +15,7 @@ import {
formCard, segmented, subsection, textLink, toggleRow, unitInput,
} from './form-kit';
import { forgetGeneralBaseline, generalDirty, generalProblems, type GeneralSettingsDraft } from './general-form-state';
import { moonStatusOf, moonStatusText } from './moon-status';
import { langOf, type I18nKey } from '../i18n';
import { settingsT, type SettingsI18nKey } from '../i18n/settings';
import { supportT } from '../i18n/support';
@@ -76,6 +77,8 @@ export function renderGeneralSettingsDialog(this: HouseplanEditorRuntime): Templ
};
const glowProblem = problemFor('gs-glow-radius');
const northProblem = problemFor('gs-north');
// #718 K7: the second caption line, once the moon chunk judged this opening.
const moonStatus = moonStatusOf(host);
const zigbeeHelp = hasTopologyTranslation(lang, 'help') && hasTopologyTranslation(lang, 'help_aria')
? html`<hp-help data-help-key="topology.help" .text=${topologyT(lang, 'help')} .ariaLabel=${topologyT(lang, 'help_aria')}></hp-help>`
: nothing;
@@ -227,7 +230,10 @@ export function renderGeneralSettingsDialog(this: HouseplanEditorRuntime): Templ
${renderSunRayOriginSegment(d.sunRayOrigin, (key) => t(key), (sunRayOrigin) => set({ sunRayOrigin }))}
${toggleRow({
id: 'gs-moon', icon: 'mdi:moon-waning-crescent',
title: st('gs.moon'), caption: st('gs.moon_hint'),
title: st('gs.moon'),
caption: moonStatus
? html`${st('gs.moon_hint')}<span style="display:block" data-moon-status=${moonStatus.reason}>${moonStatusText(moonStatus, st)}</span>`
: st('gs.moon_hint'),
checked: d.moon, onChange: (v) => set({ moon: v }),
})}`,
})}
+68
View File
@@ -0,0 +1,68 @@
/**
* #718 K7: the «Now: …» line under the moon switch in General settings.
*
* One snapshot per opening — `now`, `hass.config`, `sun.sun` — judged by the
* lazy moon chunk as if the switch were on: the moon no longer depends on the
* background, so one status serves the whole installation, whatever the
* switch, the background segment or the spaces say. It lives beside the
* draft, never in it (`generalDraftKey` does not see it), so the line arriving
* does not make the dialog dirty. The result of an opening that was closed in
* the meantime is dropped; until the chunk is here, or when it failed, there
* is no line and the hint alone stays.
*/
import { withMoon } from '../moon-gate';
import { resolveDayCycle, sunStateOf } from '../sun';
import type { MoonStatus, MoonStatusReason } from '../moon';
import type { SettingsI18nKey } from '../i18n/settings';
interface MoonStatusHost {
hass?: unknown;
_settingsDialog: unknown;
requestUpdate(): void;
}
interface Opening { status?: MoonStatus }
const openings = new WeakMap<object, Opening>();
/** Called by `_openSettingsDialog` once the draft exists: asks for this opening's status. */
export function openMoonStatus(host: MoonStatusHost, now: Date = new Date()): void {
const opening: Opening = {};
openings.set(host, opening);
const hass = host.hass as { config?: unknown } | undefined;
const state = resolveDayCycle(hass, now);
// With `source === 'sun'` this is the very elevation `dayCycleSunOf` read.
const sun = sunStateOf(hass)?.elevation ?? null;
withMoon((moon) => {
if (openings.get(host) !== opening || !host._settingsDialog) return;
opening.status = moon.moonStatus(hass?.config, state, sun, now);
host.requestUpdate();
});
}
/** The status of the current opening, once the chunk has judged it. */
export function moonStatusOf(host: object): MoonStatus | undefined {
return openings.get(host)?.status;
}
/** Literal keys (#502): the dead-key gate does not read a key glued from the reason. */
const MOON_STATUS_KEYS: Readonly<Record<MoonStatusReason, SettingsI18nKey>> = {
shown: 'gs.moon_status_shown',
no_home: 'gs.moon_status_no_home',
day_sun: 'gs.moon_status_day_sun',
day_clock: 'gs.moon_status_day_clock',
low: 'gs.moon_status_low',
new: 'gs.moon_status_new',
};
/** A signed whole number: the minus is U+2212, no plus; −0 reads as 0. */
const signed = (value = 0): string => (value < 0 ? `−${-value}` : String(Math.abs(value)));
/** The line's text; `st` is the dialog's `settingsT` for its language. */
export function moonStatusText(
status: MoonStatus, st: (key: SettingsI18nKey, vars: Record<string, string>) => string,
): string {
return st(MOON_STATUS_KEYS[status.reason], {
alt: signed(status.alt), pct: signed(status.pct), sun: signed(status.sun),
});
}
+12 -6
View File
@@ -44,11 +44,11 @@ import { type ResizeAreaPlacement } from './resize-labels';
import {
computeSunRays, dayPhase, northDegOf, bgModeOf, sunRaysOn, sunRayOriginOf,
sunStateOf, rayPeakAlpha, raysVisible, rayColor, RAY_FADE_MS, type SunRay,
rayStops, resolveDayCycle, dayCycleFingerprint, type DayCycleState,
rayStops, resolveDayCycle, type DayCycleState,
rayRimEdges, rimStops, rimPeakAlpha, RIM_COLOR, type SunRayOrigin,
} from './sun';
import { dayCycleStageVars, renderDayCycleEnvironment } from './day-cycle-render';
import { moonLayer } from './moon-gate';
import { dayCycleClock, moonLayer, moonSkyState } from './moon-gate';
import { renderPaperShapes, type PaperShape } from './render/paper-scene';
import {
furnitureGraphic, furnitureArtIsLazy,
@@ -9796,15 +9796,20 @@ export class HouseplanCard extends LitElement {
return resolveDayCycle(this._renderPlanHass, now);
}
/** #718 K1/K5: the moon's phase without an environment (static background, moon on, View). */
private _moonSkyState(): DayCycleState | null {
return moonSkyState(this._sunGlobal(), this._effBgMode() === 'daynight',
this._modeTransitionVisual?.viewWeight ?? (this._mode === 'view' ? 1 : 0), this._renderPlanHass);
}
private _dayCycleTick = (): void => {
if (!this.isConnected || this.ownerDocument.visibilityState === 'hidden') return;
const state = this._dayCycleState();
const [state, key] = dayCycleClock(this._dayCycleState(), this._moonSkyState());
if (!state) {
if (this._dayCycleTimer) { clearInterval(this._dayCycleTimer); this._dayCycleTimer = 0; }
this._dayCycleClockKey = '';
return;
}
const key = dayCycleFingerprint(state);
if (key === this._dayCycleClockKey) return;
this._dayCycleClockKey = key;
this.requestUpdate();
@@ -9812,8 +9817,8 @@ export class HouseplanCard extends LitElement {
/** Arm a 30 s timer only for the browser-clock fallback while visible. */
private _syncDayCycleClock(): void {
const state = this._dayCycleState();
this._dayCycleClockKey = state ? dayCycleFingerprint(state) : '';
const [state, key] = dayCycleClock(this._dayCycleState(), this._moonSkyState());
this._dayCycleClockKey = key;
const needsTimer = state?.source === 'clock'
&& this.ownerDocument.visibilityState !== 'hidden' && this.isConnected;
if (needsTimer && !this._dayCycleTimer) {
@@ -10868,6 +10873,7 @@ export class HouseplanCard extends LitElement {
@pointerup=${(e: PointerEvent) => this._stagePointerUp(e)}
@pointercancel=${(e: PointerEvent) => this._stagePointerCancel(e)}>
${renderDayCycleEnvironment(dayCycle, dayCycleWeight, moonLayer(this, this._sunGlobal(), dayCycle))}
${moonLayer(this, this._sunGlobal(), this._moonSkyState(), dayCycleWeight)}
${this._editorRuntime ? this._renderEditorSecondary() : nothing}
<div class="zoomwrap ${this._slide ? 'slide-' + this._slide : ''}"
?inert=${this._continuity.overlayBlocksInteraction || this._modeTransitionBusy}
+2
View File
@@ -111,6 +111,7 @@ import {
} from './space-dialog';
import { rememberSpaceDialogBaseline, spaceDialogProblems } from './editors/space-form-state';
import { generalProblems, moonDraftOf, rememberGeneralBaseline, writeMoonSetting } from './editors/general-form-state';
import { openMoonStatus } from './editors/moon-status';
import { rememberRoomBaseline } from './editors/room-form-state';
import { forgetMarkerBaseline, rememberMarkerBaseline } from './editors/marker-form-state';
import { commitPlanOptimization } from './plan-optimize-write';
@@ -8464,6 +8465,7 @@ public _openSettingsDialog = (): void => {
};
// #600 К10: снимок на момент открытия — от него считается «есть изменения».
rememberGeneralBaseline(this.host, this.host._settingsDialog);
openMoonStatus(this.host); // #718 K7: the status line, outside the draft
};
public _openSupportDialog = (): void => {
+7 -1
View File
@@ -56,7 +56,13 @@
"gs.bg_theme_hint": "Hintergrund des Themes wird verwendet.",
"gs.sun_rays_hint": "Sonnenstrahlen durch Fensteröffnungen zeichnen.",
"gs.moon": "Mond über dem Plan in Dämmerung und Nacht",
"gs.moon_hint": "Erscheint im Hintergrund hinter dem Plan ab 3° über dem Horizont, außer um Neumond; berechnet aus den Koordinaten des Zuhauses in Home Assistant.",
"gs.moon_hint": "Erscheint hinter dem Plan bei jedem Hintergrund, ab 3° über dem Horizont, außer um Neumond; berechnet aus den Koordinaten des Zuhauses in Home Assistant.",
"gs.moon_status_shown": "Jetzt: sichtbar ({alt}° über dem Horizont, {pct} % beleuchtet).",
"gs.moon_status_no_home": "Jetzt: nicht sichtbar (in Home Assistant ist der Standort des Zuhauses nicht festgelegt).",
"gs.moon_status_day_sun": "Jetzt: nicht sichtbar (die Sonne steht {sun}° über dem Horizont; der Mond erscheint, sobald sie unter 6° steht).",
"gs.moon_status_day_clock": "Jetzt: nicht sichtbar (Tag laut Uhr, 08:00–18:00: Home Assistant liefert keine sun.sun-Daten).",
"gs.moon_status_low": "Jetzt: nicht sichtbar (der Mond steht in {alt}° Höhe; er erscheint ab 3° über dem Horizont).",
"gs.moon_status_new": "Jetzt: nicht sichtbar (Neumond, {pct} % beleuchtet; sichtbar ab 3 %).",
"gs.error_glow_radius": "Geben Sie einen Radius größer als null ein.",
"gs.error_north": "Geben Sie eine ganze Zahl zwischen 0° und 359° ein.",
"gs.opacity_of": "Deckkraft: {name}",
+7 -1
View File
@@ -56,7 +56,13 @@
"gs.bg_theme_hint": "Using the theme background.",
"gs.sun_rays_hint": "Draw sun rays through window openings.",
"gs.moon": "Moon over the plan at dusk and night",
"gs.moon_hint": "Shown on the background behind the plan from 3° above the horizon, except around new moon; computed from the home coordinates in Home Assistant.",
"gs.moon_hint": "Shown behind the plan with any background, from 3° above the horizon, except around new moon; computed from the home coordinates in Home Assistant.",
"gs.moon_status_shown": "Now: shown ({alt}° above the horizon, {pct}% lit).",
"gs.moon_status_no_home": "Now: not shown (the home location is not set in Home Assistant).",
"gs.moon_status_day_sun": "Now: not shown (the sun is {sun}° above the horizon; the moon shows once it is below 6°).",
"gs.moon_status_day_clock": "Now: not shown (daytime by the clock, 08:00–18:00: Home Assistant has no sun.sun data).",
"gs.moon_status_low": "Now: not shown (the moon is at {alt}°; it shows from 3° above the horizon).",
"gs.moon_status_new": "Now: not shown (new moon, {pct}% lit; it shows from 3%).",
"gs.error_glow_radius": "Enter a radius greater than zero.",
"gs.error_north": "Enter a whole number between 0° and 359°.",
"gs.opacity_of": "{name} opacity",
+7 -1
View File
@@ -56,7 +56,13 @@
"gs.bg_theme_hint": "Fond du thème utilisé.",
"gs.sun_rays_hint": "Tracer les rayons du soleil à travers les ouvertures des fenêtres.",
"gs.moon": "Lune sur le plan au crépuscule et la nuit",
"gs.moon_hint": "Affichée sur le fond derrière le plan à partir de 3° au-dessus de l’horizon, sauf autour de la nouvelle lune ; calculée à partir des coordonnées du domicile dans Home Assistant.",
"gs.moon_hint": "Affichée derrière le plan quel que soit le fond, à partir de 3° au-dessus de l’horizon, sauf autour de la nouvelle lune ; calculée à partir des coordonnées du domicile dans Home Assistant.",
"gs.moon_status_shown": "Maintenant : affichée ({alt}° au-dessus de l’horizon, éclairée à {pct} %).",
"gs.moon_status_no_home": "Maintenant : non affichée (la position du domicile n’est pas définie dans Home Assistant).",
"gs.moon_status_day_sun": "Maintenant : non affichée (le soleil est à {sun}° au-dessus de l’horizon ; la lune apparaît dès qu’il passe sous 6°).",
"gs.moon_status_day_clock": "Maintenant : non affichée (jour selon l’horloge, 08:00–18:00 : Home Assistant n’a pas de données sun.sun).",
"gs.moon_status_low": "Maintenant : non affichée (la lune est à {alt}° ; elle apparaît à partir de 3° au-dessus de l’horizon).",
"gs.moon_status_new": "Maintenant : non affichée (nouvelle lune, éclairée à {pct} % ; visible à partir de 3 %).",
"gs.error_glow_radius": "Saisissez un rayon supérieur à zéro.",
"gs.error_north": "Saisissez un nombre entier entre 0° et 359°.",
"gs.opacity_of": "Opacité : {name}",
+7 -1
View File
@@ -56,7 +56,13 @@
"gs.bg_theme_hint": "Используется фон темы.",
"gs.sun_rays_hint": "Рисовать солнечные лучи через оконные проёмы.",
"gs.moon": "Луна на плане в сумерках и ночью",
"gs.moon_hint": "Видна на фоне за планом при высоте от 3° над горизонтом, кроме новолуния; положение считается по координатам дома из Home Assistant.",
"gs.moon_hint": "Видна за планом при любом фоне, от 3° над горизонтом, кроме новолуния; положение считается по координатам дома из Home Assistant.",
"gs.moon_status_shown": "Сейчас: показывается (на {alt}° над горизонтом, освещено {pct} %).",
"gs.moon_status_no_home": "Сейчас: не показывается (в Home Assistant не заданы координаты дома).",
"gs.moon_status_day_sun": "Сейчас: не показывается (Солнце на {sun}° над горизонтом, луна видна, когда оно ниже 6°).",
"gs.moon_status_day_clock": "Сейчас: не показывается (день по часам, 08:00–18:00: в Home Assistant нет данных sun.sun).",
"gs.moon_status_low": "Сейчас: не показывается (луна на высоте {alt}°, видна от 3° над горизонтом).",
"gs.moon_status_new": "Сейчас: не показывается (новолуние, освещено {pct} %, видна от 3 %).",
"gs.error_glow_radius": "Введите радиус больше нуля.",
"gs.error_north": "Введите целое число от 0° до 359°.",
"gs.opacity_of": "Прозрачность: {name}",
+65 -21
View File
@@ -1,50 +1,94 @@
/**
* The moon's only foothold in the initial View graph (#661, C7): whether the
* lazy `moon-runtime` chunk is wanted, and one load per page for every card on
* it. It is wanted when the moon is switched on, the environment exists (the
* effective background follows the sun, on a View surface) and it is not
* daytime. Everything else — astronomy, art, element, its 30 s ticker — lives
* in the chunk.
* it. It is wanted when the moon is switched on on a View surface and it is
* not daytime — with any background (#718 K1): over "Follow the Sun" the moon
* lives in the environment, over a static one in its own sky layer — and when
* General settings open, for the status line (#718 K6). Everything else —
* astronomy, art, element, layer, its 30 s ticker, the status — lives in the
* chunk.
*
* The contract of the other lazy runtimes, in the fewest bytes (the initial
* graph sits at its budget): a chunk from another build is never installed and
* never asked for again; a failed load leaves no moon and is retried at most
* every 30 s, on the next render, from the content-hashed URL with a fresh
* every 30 s, on the next request, from the content-hashed URL with a fresh
* query so the browser's cached failure is not replayed.
*/
import { nothing, type TemplateResult } from 'lit';
import { ENTRY_BUILD_FINGERPRINT } from './editor-runtime-loader';
import type { DayCycleState } from './sun';
import { dayCycleFingerprint, resolveDayCycle, type DayCycleState } from './sun';
type MoonRuntime = typeof import('./moon-runtime');
export type MoonHost = import('./moon-runtime').MoonHost;
const MOON_RETRY_ASSET = '__HOUSEPLAN_MOON_RETRY_ASSET__';
let runtime: MoonRuntime | null = null;
/** The load in flight: every caller meanwhile waits for the same one. */
let loading: Promise<void> | null = null;
/** Earliest next attempt; 0 before the first, Infinity after a foreign build. */
let nextAttempt = 0;
const moonOn = (settings: unknown): boolean => (settings as { moon?: unknown } | null | undefined)?.moon === true;
/**
* The moon element for `renderDayCycleEnvironment`. `state` is the
* environment (null: no environment, so no moon); `settings` the global
* settings, where only an explicit `moon: true` counts (C1). Nothing until the
* chunk is here; the card that asked re-renders when it arrives.
* `use` gets the chunk: at once when it is here, else when the page-wide load
* brings it. A failure, a load still barred by the 30 s retry or a chunk of
* another build never call it.
*/
export function moonLayer(
host: MoonHost | undefined, settings: unknown, state: DayCycleState | null,
): TemplateResult | typeof nothing {
if (!host || !state || (settings as { moon?: unknown } | null | undefined)?.moon !== true) return nothing;
if (!runtime && state.phase !== 'day' && Date.now() >= nextAttempt) {
export function withMoon(use: (moon: MoonRuntime) => void): void {
if (runtime) { use(runtime); return; }
if (!loading && Date.now() >= nextAttempt) {
const retry = nextAttempt > 0;
nextAttempt = Date.now() + 30_000;
(retry
loading = (retry
? import(/* @vite-ignore */ new URL(`${MOON_RETRY_ASSET}?${nextAttempt}`, import.meta.url).href) as Promise<MoonRuntime>
: import('./moon-runtime')
).then((module) => {
if (module.MOON_RUNTIME_FINGERPRINT !== ENTRY_BUILD_FINGERPRINT) { nextAttempt = Infinity; return; }
runtime = module;
host.requestUpdate();
}, () => undefined);
loading = null;
if (module.MOON_RUNTIME_FINGERPRINT === ENTRY_BUILD_FINGERPRINT) runtime = module;
else nextAttempt = Infinity;
}, () => { loading = null; });
}
return runtime?.renderMoon(host, settings, state.phase) ?? nothing;
void loading?.then(() => { if (runtime) use(runtime); });
}
/**
* #718 K1/K5: the phase the moon uses where no environment is drawn — a static
* background (global or the space's own), the moon switched on, a View
* surface (`viewWeight` of #101 above 0). Anything else computes nothing.
*/
export function moonSkyState(
settings: unknown, daynight: boolean, viewWeight: number, hass: unknown, now: Date | number = new Date(),
): DayCycleState | null {
return !daynight && viewWeight > 0 && moonOn(settings) ? resolveDayCycle(hass, now) : null;
}
/**
* #718 K5: what the 30 s clock ticker compares — the whole environment (its
* sun moves every minute), but only the phase of the moon's sky, so a static
* background re-renders at 08:00 and 18:00, not every minute.
*/
export function dayCycleClock(
env: DayCycleState | null, sky: DayCycleState | null,
): [DayCycleState | null, string] {
return [env ?? sky, env ? dayCycleFingerprint(env) : sky ? sky.phase : ''];
}
/**
* The moon element. `state` is the day-cycle sample (null: no moon);
* `settings` the global settings, where only an explicit `moon: true` counts
* (C1). Without `sky` the element goes into `renderDayCycleEnvironment`; with
* it (the View weight) it comes in its own layer for a static background.
* Nothing until the chunk is here; the card that asked re-renders when it
* arrives.
*/
export function moonLayer(
host: MoonHost | undefined, settings: unknown, state: DayCycleState | null, sky?: number,
): TemplateResult | typeof nothing {
if (!host || !state || !moonOn(settings)) return nothing;
if (!runtime && state.phase !== 'day') withMoon(() => host.requestUpdate());
if (!runtime) return nothing;
return sky === undefined
? runtime.renderMoon(host, settings, state.phase)
: runtime.renderMoonSky(host, settings, state.phase, sky);
}
+37 -14
View File
@@ -1,13 +1,17 @@
/**
* The lazy moon chunk (#661, C7): astronomy, the designer's art, the element
* template and the moon's own 30 s ticker. `moon-gate.ts` imports it only
* while the moon is switched on, the background follows the sun and it is not
* daytime; until then the initial View graph carries none of this.
* template, the moon's own 30 s ticker and the status of General settings
* (#718). `moon-gate.ts` imports it while the moon is switched on on a View
* surface and it is not daytime, or when General settings open; until then
* the initial View graph carries none of this.
*
* One element inside `.hp-day-cycle-env`, after the four phase layers: above
* the gradients and the sun glow, below the plan paper and everything else
* (owner decision 7). Its styles travel with it, so the View card, kiosk and
* `houseplan-space-card` get the same moon without growing their own CSS.
* One element behind the plan. Over "Follow the Sun" it is the last child of
* `.hp-day-cycle-env`, after the four phase layers: above the gradients and
* the sun glow, below the plan paper and everything else (owner decision 7).
* With a static background there is no environment (#718 K3): the same element
* stands in its own layer `.hp-moon-sky`, the first child of the scene, under
* the plan by DOM order. Its styles travel with it, so the View card, kiosk
* and `houseplan-space-card` get the same moon without growing their own CSS.
*/
import { html, nothing, type TemplateResult } from 'lit';
import { MOON_ART } from './moon-art.generated';
@@ -16,6 +20,9 @@ import {
} from './moon';
import type { DayCyclePhase } from './sun';
/** #718 K7: the status of General settings, judged where the moon is decided. */
export { moonStatus } from './moon';
export const MOON_RUNTIME_FINGERPRINT = '__HOUSEPLAN_SOURCE_FINGERPRINT__';
/** The card that shows the moon: the View card or `houseplan-space-card`. */
@@ -30,12 +37,16 @@ export interface MoonHost {
export const MOON_TICK_MS = 30_000;
/**
* C6: box `min(200px, 25cqmin)` measured on the scene (the environment is the
* size container), 5 % inset, never moved by pan or zoom, never hit-tested.
* C6: box `min(200px, 25cqmin)` measured on the scene (the environment or the
* sky layer is the size container, both the whole scene), 5 % inset, never
* moved by pan or zoom, never hit-tested.
* C4: opacity only, 2 s on the background curve; reduced motion — instant.
* C8: no CSS filter and no will-change on the element.
* C8: no CSS filter and no will-change on the element. #718 K3: the sky layer
* has no z-index either — it lies under the plan by DOM order, as the
* environment does.
*/
const MOON_CSS = '.hp-day-cycle-env{container-type:size}'
const MOON_CSS = '.hp-day-cycle-env,.hp-moon-sky{container-type:size}'
+ '.hp-moon-sky{position:absolute;inset:0;overflow:hidden;pointer-events:none}'
+ '.hp-moon{--hp-moon-box:min(200px,25cqmin);position:absolute;'
+ 'left:calc(var(--hp-moon-box)*.05);top:calc(var(--hp-moon-box)*.05);'
+ 'width:var(--hp-moon-box);height:var(--hp-moon-box);opacity:0;pointer-events:none;'
@@ -63,8 +74,8 @@ function forget(host: MoonHost, watch: MoonWatch): void {
/**
* C3: between renders only the clock moves the moon. Every 30 s the tick
* recomputes it from the last rendered inputs; an equal fingerprint costs no
* render. A host that re-rendered without a moon (switched off, editor mode,
* static background) or left the page drops its ticker; a hidden page skips.
* render. A host that re-rendered without a moon (switched off, editor mode)
* or left the page drops its ticker; a hidden page skips.
*/
export function moonTick(host: MoonHost, now: Date = new Date()): void {
const watch = watches.get(host);
@@ -93,7 +104,7 @@ function remember(host: MoonHost, settings: unknown, phase: DayCyclePhase, view:
}
/**
* The element stays in the environment while the moon is switched on, at
* The element stays in its parent while the moon is switched on, at
* opacity 0 when hidden, so rising, setting, the new-moon threshold and the
* day phase all fade (C4). A freshly created element takes its final state at
* once: the first appearance after a page or chunk load is not animated.
@@ -120,3 +131,15 @@ export function renderMoon(
<use href="#hp-moon-art" mask="url(#hp-moon-phase)"></use>
</svg>`;
}
/**
* #718 K3/K4: the moon without an environment — a scene-sized layer, first
* child of the scene, fading with the View weight of the #101 transition as
* the environment does. Inside, the very element of the environment.
*/
export function renderMoonSky(
host: MoonHost, settings: unknown, phase: DayCyclePhase, viewWeight: number, now: Date = new Date(),
): TemplateResult {
return html`<div class="hp-moon-sky" aria-hidden="true"
style="opacity:${Math.min(1, Math.max(0, viewWeight)).toFixed(4)}">${renderMoon(host, settings, phase, now)}</div>`;
}
+66 -8
View File
@@ -1,7 +1,8 @@
/**
* The moon over the "Follow the Sun" background (#661): pure astronomy,
* visibility rules and the phase mask. No DOM, no Lit — the lazy
* `moon-runtime` chunk renders from these, unit tests call them directly.
* The moon behind the plan (#661; with any background since #718): pure
* astronomy, visibility rules, the status line and the phase mask. No DOM, no
* Lit — the lazy `moon-runtime` chunk renders from these, unit tests call them
* directly.
*
* Home Assistant publishes no moon altitude, so the card computes it from
* `hass.config.latitude/longitude` and the browser clock: the short Meeus
@@ -9,7 +10,7 @@
* Checked against JPL Horizons (airless) on the twelve points of the issue:
* altitude within 1.5°, illumination within 2.5 percentage points.
*/
import type { DayCyclePhase } from './sun';
import type { DayCyclePhase, DayCycleSource } from './sun';
/**
* C1: the same 3° as the window rays (`RAY_ELEVATION_MIN`), so both lights
@@ -112,10 +113,9 @@ export interface MoonView {
const finite = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value);
/**
* C1 for an existing environment. The environment itself is the gate for the
* rest: it exists only on a View surface whose effective background follows
* the sun (`bg_mode` per space, then global). `settings` are the global
* settings, `config` is `hass.config`.
* C1 (#718 K1): the card is the gate for the rest — a View surface, any
* background; `phase` is `resolveDayCycle` whether or not an environment is
* drawn. `settings` are the global settings, `config` is `hass.config`.
*/
export function moonView(settings: unknown, phase: DayCyclePhase, config: unknown, now: Date): MoonView {
const { fraction } = moonIllumination(now);
@@ -126,6 +126,64 @@ export function moonView(settings: unknown, phase: DayCyclePhase, config: unknow
return { visible: moonShownAt(phase, moonPosition(now, latitude, longitude).altitude, fraction), k };
}
/** #718 K7: why the moon is or is not shown — the first reason that holds, in this order. */
export type MoonStatusReason = 'shown' | 'no_home' | 'day_sun' | 'day_clock' | 'low' | 'new';
/** The reason with its numbers already rounded: the dialog only puts them into the text. */
export interface MoonStatus {
reason: MoonStatusReason;
/** Moon altitude, whole degrees (`shown`, `low`). */
alt?: number;
/** Illuminated share, whole per cent (`shown`, `new`). */
pct?: number;
/** The `sun.sun` elevation, whole degrees (`day_sun`). */
sun?: number;
}
/** The sky the status is judged on, as ready numbers (AC9). */
export interface MoonSky {
/** Finite `hass.config.latitude/longitude`. */
home: boolean;
phase: DayCyclePhase;
source: DayCycleSource;
/** `sun.sun` elevation; null without it. */
sun: number | null;
altitude: number;
fraction: number;
}
/**
* #718 K7/K8: no home, day, below 3°, under 3 % — the first that holds; else
* shown, decided by the same `moonShownAt` as the element. A hidden reason
* never shows the threshold it missed: «at 3°, shows from 3°» reads as a bug,
* so its number stops at 2.
*/
export function moonStatusOf(sky: MoonSky): MoonStatus {
if (!sky.home) return { reason: 'no_home' };
if (sky.phase === 'day') return sky.source === 'sun' ? { reason: 'day_sun', sun: Math.round(sky.sun ?? 0) } : { reason: 'day_clock' };
const alt = Math.round(sky.altitude);
const pct = Math.round(sky.fraction * 100);
if (moonShownAt(sky.phase, sky.altitude, sky.fraction)) return { reason: 'shown', alt, pct };
return sky.altitude < MOON_ELEVATION_MIN ? { reason: 'low', alt: Math.min(alt, 2) } : { reason: 'new', pct: Math.min(pct, 2) };
}
/**
* #718 K7: the status for `hass.config` and a day-cycle sample taken at `now`,
* as if the switch were on — the moon no longer depends on the background, so
* one status serves the whole installation. `sun` is the `sun.sun` elevation.
*/
export function moonStatus(
config: unknown, state: { phase: DayCyclePhase; source: DayCycleSource }, sun: number | null, now: Date,
): MoonStatus {
const { latitude, longitude } = (config ?? {}) as { latitude?: unknown; longitude?: unknown };
const home = finite(latitude) && finite(longitude);
return moonStatusOf({
home, phase: state.phase, source: state.source, sun,
altitude: home ? moonPosition(now, latitude, longitude).altitude : 0,
fraction: moonIllumination(now).fraction,
});
}
/** C3: equal fingerprint → no re-render. The lit side is fixed, so k is all the shape. */
export function moonFingerprint(view: MoonView): string {
return `${view.visible ? 1 : 0}|${view.k.toFixed(2)}`;
+14 -9
View File
@@ -37,8 +37,9 @@ import {
type PageVisibilitySignal,
} from './visual-continuity';
import {
bgModeOf, resolveDayCycle, dayCycleFingerprint, type DayCycleState,
bgModeOf, resolveDayCycle, type DayCycleState,
} from './sun';
import { dayCycleClock, moonSkyState } from './moon-gate';
import { PointerModalityController } from './pointer-modality';
import { resolvedSvgScreenBlend, svgScreenBlendSupported } from './glow-blend';
import {
@@ -167,31 +168,35 @@ class HouseplanSpaceCard extends LitElement {
});
}
private _dayCycleState(now: Date | number = new Date()): DayCycleState | null {
/**
* The clock ticker's sample: the environment over "Follow the Sun", else the
* moon's sky over a static background — compared by its phase only (#718 K5).
*/
private _dayCycleClock(now: Date | number = new Date()): [DayCycleState | null, string] {
const cfg = this._snap?.config;
if (!cfg || !this._config) return null;
if (!cfg || !this._config) return [null, ''];
const spaceSettings = cfg.spaces?.find((space: any) => space.id === this._config?.space)?.settings || {};
if (bgModeOf(cfg.settings, spaceSettings) !== 'daynight') return null;
return resolveDayCycle(this._renderDeviceSnapshot?.hass || this.hass, now);
const daynight = bgModeOf(cfg.settings, spaceSettings) === 'daynight';
const hass = this._renderDeviceSnapshot?.hass || this.hass;
return dayCycleClock(daynight ? resolveDayCycle(hass, now) : null, moonSkyState(cfg.settings, daynight, 1, hass, now));
}
private _dayCycleTick = (): void => {
if (!this.isConnected || this.ownerDocument.visibilityState === 'hidden') return;
const state = this._dayCycleState();
const [state, key] = this._dayCycleClock();
if (!state) {
if (this._dayCycleTimer) { window.clearInterval(this._dayCycleTimer); this._dayCycleTimer = 0; }
this._dayCycleClockKey = '';
return;
}
const key = dayCycleFingerprint(state);
if (key === this._dayCycleClockKey) return;
this._dayCycleClockKey = key;
this.requestUpdate();
};
private _syncDayCycleClock(): void {
const state = this._dayCycleState();
this._dayCycleClockKey = state ? dayCycleFingerprint(state) : '';
const [state, key] = this._dayCycleClock();
this._dayCycleClockKey = key;
const needsTimer = state?.source === 'clock'
&& this.ownerDocument.visibilityState !== 'hidden' && this.isConnected;
if (needsTimer && !this._dayCycleTimer) {
+7 -3
View File
@@ -25,7 +25,7 @@ import {
import { DEFAULT_ICON_RULES, compileIconRules } from './rules';
import { t, type Lang } from './i18n';
import { bgModeOf, resolveDayCycle } from './sun';
import { moonLayer, type MoonHost } from './moon-gate';
import { moonLayer, moonSkyState, type MoonHost } from './moon-gate';
import { dayCycleStageVars, renderDayCycleEnvironment } from './day-cycle-render';
import type { DevItem, OpeningCfg, ServerConfig } from './types';
import { floorMinusBodies, physicalBodyParts, polyclipPathD } from './physical-geometry';
@@ -670,9 +670,12 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
});
// The static card paints the same four-phase environment as full View.
// Wedges stay full-card-only; the decorative background is independent.
// #718: a static background gets the moon in its own sky layer instead.
const spaceSettings = (o.cfg.spaces.find((sp: any) => sp.id === o.spaceId) as any)?.settings || {};
const dayCycle = bgModeOf(o.cfg?.settings, spaceSettings) === 'daynight'
? resolveDayCycle(planHass, o.dayCycleNow ?? new Date()) : null;
const daynight = bgModeOf(o.cfg?.settings, spaceSettings) === 'daynight';
const dayCycleNow = o.dayCycleNow ?? new Date();
const dayCycle = daynight ? resolveDayCycle(planHass, dayCycleNow) : null;
const moonSky = moonSkyState(o.cfg?.settings, daynight, 1, planHass, dayCycleNow);
const stageBg = stageBgOf(o.cfg?.settings, disp);
// Opaque plan paper, same contract as the full card (docs/DECOR-EDITOR.md §3.3):
@@ -931,6 +934,7 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
?inert=${!!o.inert}
style="aspect-ratio:${vb[2]}/${vb[3]}${stageBg ? ';background:' + stageBg : ''};--hp-cell-visual-scale:${gridVisualScale(cellCm)};--wall-fill:${colors.wall_fill.c};--wall-fill-op:${colors.wall_fill.a}${dayCycle ? `;${dayCycleStageVars(dayCycle)}` : ''}">
${renderDayCycleEnvironment(dayCycle, 1, moonLayer(o.moonHost, o.cfg?.settings, dayCycle))}
${moonLayer(o.moonHost, o.cfg?.settings, moonSky, 1)}
${dayCycle && paperShapes.length ? svg`<svg class="hp-paper-outline-svg"
viewBox="${vb[0]} ${vb[1]} ${vb[2]} ${vb[3]}"
preserveAspectRatio="xMidYMid meet" aria-hidden="true" pointer-events="none">
+6
View File
@@ -304,6 +304,12 @@ test('bundle manifest separates static initial graph from dynamic editor graph',
// lazy moon graph — the builder subtracts initial) is refused by name.
assert.throws(() => assertBundleBudget({ ...manifest, lazyMoonFiles: [] }, 1_000_000, undefined, ...lazyCeilings),
/bundle has no lazy moon graph/);
// #718 AC13: the status line reaches the chunk through the gate; the editor
// graph never holds the moon's own files.
assert.equal(manifest.lazyEditorFiles.some((path) => manifest.lazyMoonFiles.includes(path)), false);
assert.throws(() => assertBundleBudget({
...manifest, lazyEditorFiles: [...manifest.lazyEditorFiles, ...manifest.lazyMoonFiles],
}, 1_000_000, undefined, ...lazyCeilings), /lazy editor graph overlaps lazy moon graph/);
});
test('#486 Rollup names both stable entries explicitly', async () => {
+30 -2
View File
@@ -446,7 +446,7 @@ test('sun-ray golden requires browser-painted light from a state-only sun entity
assert.ok(scenario);
const fixture = prepareGoldenFixture(scenario);
const space = fixture.config.spaces.find((item) => item.id === scenario.space);
assert.equal(GOLDEN_MATRIX_VERSION, 69);
assert.equal(GOLDEN_MATRIX_VERSION, 70);
assert.equal(space.settings.sun_rays, true);
assert.equal(scenario.northDeg, 90,
'the sign-sensitive golden must keep a non-zero north direction');
@@ -940,7 +940,7 @@ test('issue 570 Stage 4 reuses the historical iso goldens for visual handoff cov
test('#673 Stage 6 designer acceptance scenes are canonical golden entries', () => {
const ids = STAGE6_ACCEPTANCE_SCENARIOS.map((scenario) => scenario.id);
assert.equal(GOLDEN_MATRIX_VERSION, 69);
assert.equal(GOLDEN_MATRIX_VERSION, 70);
assert.deepEqual(ids, [
'isometric-stage6-light-lightfloor',
'isometric-stage6-light-darkfloor',
@@ -1244,3 +1244,31 @@ test('#661 AC7 moon scenes: fixed home and clock, moon on, the old day-cycle fra
assert.equal(prepareGoldenFixture(scenario).config.settings?.moon, undefined, id);
}
});
test('#718 AC8 static-background moon scenes: the chosen colour, the sky layer, the dialog waits for the line', () => {
const byId = (id) => GOLDEN_SCENARIOS.find((scenario) => scenario.id === id);
const white = byId('static-bg-moon-gibbous-white-light');
const dark = byId('static-bg-moon-crescent-south-dark');
assert.deepEqual(white.moon, { clock: '2026-10-21T18:00:00Z', latitude: 55.75, longitude: 37.62, k: '0.79' });
assert.deepEqual(dark.moon, { clock: '2026-10-14T09:00:00Z', latitude: -33.87, longitude: 151.21, k: '0.14' });
for (const [scenario, theme, color] of [[white, 'light', '#ffffff'], [dark, 'dark', '#202126']]) {
assert.equal(scenario.capture, 'stage');
assert.equal(scenario.theme, theme);
assert.equal(scenario.bgMode, 'static');
const fixture = prepareGoldenFixture(scenario);
assert.equal(fixture.config.settings.moon, true);
const space = fixture.config.spaces.find((item) => item.id === scenario.space);
assert.equal(space.settings.bg_mode, 'static');
assert.equal(space.settings.bg_color, color);
}
// The harness checks the parent by background and waits for the status line
// in both General settings frames before the screenshot.
const harness = readFileSync(new URL('../demo/golden/harness.mjs', import.meta.url), 'utf8');
assert.match(harness, /scenario\.bgMode === 'daynight'\s+\? card\.renderRoot\.querySelector\('\.hp-day-cycle-env'\)\s+: card\.renderRoot\.querySelector\('\.hp-moon-sky'\)/);
for (const dialog of ['general-color', 'general-help']) {
const branch = harness.slice(harness.indexOf(`scenario.dialog === '${dialog}'`));
assert.match(branch.slice(0, 200), /await moonStatusSettled\(card\);/, dialog);
}
assert.match(harness, /hp-dialog \[data-moon-status\]/);
assert.match(harness, /innerWidth <= 390/);
});
+108 -4
View File
@@ -1,11 +1,13 @@
// #661 AC10: the moon switch in General settings — «Sun and Moon» section,
// reset to on, save writes `true` or removes the key; its strings exist in all
// four dictionaries. The dialog is a lazy Lit module (its layout is read as a
// source contract); reading and writing the key are executed.
// four dictionaries. #718: the status line under it (AC14, K7). The dialog is
// a lazy Lit module (its layout is read as a source contract); reading and
// writing the key and the status of an opening are executed.
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { moonDraftOf, writeMoonSetting } from '../test-build/editors/general-form-state.js';
import { generalDraftKey, moonDraftOf, writeMoonSetting } from '../test-build/editors/general-form-state.js';
import { moonStatusOf, openMoonStatus } from '../test-build/editors/moon-status.js';
const read = (path) => readFileSync(new URL(`../${path}`, import.meta.url), 'utf8');
const json = (path) => JSON.parse(read(path));
@@ -19,7 +21,10 @@ test('#661 AC10: the switch sits in the «Sun and Moon» card under «Sunlight t
assert.ok(rays > 0 && moon > rays, 'moon row follows the sun-rays row');
const row = sun.slice(moon, sun.indexOf('})}', moon));
assert.match(row, /icon: 'mdi:moon-waning-crescent'/);
assert.match(row, /title: st\('gs\.moon'\), caption: st\('gs\.moon_hint'\)/);
assert.match(row, /title: st\('gs\.moon'\),/);
// #718 K7: the hint alone until the chunk judged this opening, then the status as its second line.
assert.match(row, /caption: moonStatus\s+\? html`\$\{st\('gs\.moon_hint'\)\}<span style="display:block" data-moon-status=\$\{moonStatus\.reason\}>\$\{moonStatusText\(moonStatus, st\)\}<\/span>`\s+: st\('gs\.moon_hint'\)/);
assert.doesNotMatch(row, /aria-live/);
assert.match(row, /checked: d\.moon, onChange: \(v\) => set\(\{ moon: v \}\)/);
const reset = dialog.slice(dialog.indexOf("${t('gs.reset')}") - 600, dialog.indexOf("${t('gs.reset')}"));
assert.match(reset, /moon: true/, 'reset to defaults switches the moon on (new installations)');
@@ -51,3 +56,102 @@ test('#661 AC10: strings in all four dictionaries, section renamed', () => {
assert.equal(json('src/i18n/settings/ru.json')['gs.moon'], 'Луна на плане в сумерках и ночью');
assert.equal(json('src/i18n/settings/en.json')['gs.moon'], 'Moon over the plan at dusk and night');
});
/** #718: the seven keys, RU and EN verbatim from the specification. */
const MOON_KEYS = {
'gs.moon_hint': {
ru: 'Видна за планом при любом фоне, от 3° над горизонтом, кроме новолуния; положение считается по координатам дома из Home Assistant.',
en: 'Shown behind the plan with any background, from 3° above the horizon, except around new moon; computed from the home coordinates in Home Assistant.',
},
'gs.moon_status_shown': {
ru: 'Сейчас: показывается (на {alt}° над горизонтом, освещено {pct}\u00a0%).',
en: 'Now: shown ({alt}° above the horizon, {pct}% lit).',
},
'gs.moon_status_no_home': {
ru: 'Сейчас: не показывается (в Home Assistant не заданы координаты дома).',
en: 'Now: not shown (the home location is not set in Home Assistant).',
},
'gs.moon_status_day_sun': {
ru: 'Сейчас: не показывается (Солнце на {sun}° над горизонтом, луна видна, когда оно ниже 6°).',
en: 'Now: not shown (the sun is {sun}° above the horizon; the moon shows once it is below 6°).',
},
'gs.moon_status_day_clock': {
ru: 'Сейчас: не показывается (день по часам, 08:00\u201318:00: в Home Assistant нет данных sun.sun).',
en: 'Now: not shown (daytime by the clock, 08:00\u201318:00: Home Assistant has no sun.sun data).',
},
'gs.moon_status_low': {
ru: 'Сейчас: не показывается (луна на высоте {alt}°, видна от 3° над горизонтом).',
en: 'Now: not shown (the moon is at {alt}°; it shows from 3° above the horizon).',
},
'gs.moon_status_new': {
ru: 'Сейчас: не показывается (новолуние, освещено {pct}\u00a0%, видна от 3\u00a0%).',
en: 'Now: not shown (new moon, {pct}% lit; it shows from 3%).',
},
};
test('#718 AC14: seven keys in all four dictionaries, one placeholder set, RU and EN as specified', () => {
const placeholders = (text) => [...text.matchAll(/\{(\w+)\}/g)].map((match) => match[1]).sort();
for (const [key, expected] of Object.entries(MOON_KEYS)) {
const reference = placeholders(expected.en);
for (const lang of ['en', 'ru', 'de', 'fr']) {
const text = json(`src/i18n/settings/${lang}.json`)[key];
assert.ok(typeof text === 'string' && text.trim(), `${lang} ${key}`);
assert.deepEqual(placeholders(text), reference, `${lang} ${key}: placeholders`);
}
assert.equal(json('src/i18n/settings/ru.json')[key], expected.ru, `ru ${key}`);
assert.equal(json('src/i18n/settings/en.json')[key], expected.en, `en ${key}`);
}
// #502: called by literal, never as `gs.moon_status_${reason}`.
const source = read('src/editors/moon-status.ts');
for (const reason of ['shown', 'no_home', 'day_sun', 'day_clock', 'low', 'new']) {
assert.match(source, new RegExp(`'gs\\.moon_status_${reason}'`));
}
assert.doesNotMatch(source, /gs\.moon_status_\$\{/);
});
function dialogHost(hass) {
return {
hass,
updates: 0,
_settingsDialog: { moon: true, bgMode: 'static', busy: false, glowRadiusInput: '3', northDegInput: '' },
requestUpdate() { this.updates++; },
};
}
const NIGHT = { azimuth: 0, elevation: -12, rising: false };
const hassWith = (sun) => ({
config: { latitude: 55.75, longitude: 37.62 },
states: sun ? { 'sun.sun': { attributes: sun } } : {},
});
const settle = () => new Promise((done) => setTimeout(done, 300));
test('#718 K7: one snapshot per opening, outside the draft; a closed opening’s result is dropped', async () => {
const at = new Date('2026-10-21T18:00:00Z');
const host = dialogHost(hassWith(NIGHT));
const draftKey = generalDraftKey(host._settingsDialog);
// First opening: the chunk is not here yet, and the dialog closes before it arrives.
openMoonStatus(host, at);
assert.equal(moonStatusOf(host), undefined, 'no line while the chunk loads');
host._settingsDialog = null;
await settle();
assert.equal(moonStatusOf(host), undefined, 'the closed opening’s result is dropped');
assert.equal(host.updates, 0);
// Second opening after the sun rose: its own snapshot, at once (the chunk is here now).
host._settingsDialog = dialogHost()._settingsDialog;
host.hass = hassWith({ azimuth: 180, elevation: 40, rising: false });
openMoonStatus(host, at);
assert.deepEqual(moonStatusOf(host), { reason: 'day_sun', sun: 40 });
assert.equal(host.updates, 1);
// The snapshot is the opening's: a later state update does not change it.
host.hass = hassWith(NIGHT);
assert.deepEqual(moonStatusOf(host), { reason: 'day_sun', sun: 40 });
// The line is not part of the draft: its key is unchanged, the dialog is not dirty.
assert.equal(generalDraftKey(host._settingsDialog), draftKey);
// Night and the switch off in the draft: the status is still what «on» would show.
host._settingsDialog = { ...host._settingsDialog, moon: false };
openMoonStatus(host, at);
assert.deepEqual(moonStatusOf(host), { reason: 'shown', alt: 24, pct: 79 });
// No sun.sun: the clock decides — midday is day by the clock.
host.hass = hassWith(null);
openMoonStatus(host, new Date(2026, 9, 21, 12, 0));
assert.deepEqual(moonStatusOf(host), { reason: 'day_clock' });
});
+174 -11
View File
@@ -1,6 +1,8 @@
// #661: the moon over the "Follow the Sun" background — astronomy (AC1),
// visibility rules (AC2), phase mask (AC3), fingerprint and ticker (AC4) and
// the designer pack gate. Pure: test-build modules, no browser.
// the designer pack gate. #718: the moon with any background — the status
// function (AC9), its equivalence with the element (AC10) and the gate of the
// static background's sky layer (AC15). Pure: test-build modules, no browser.
import test from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
@@ -8,11 +10,16 @@ import { readFileSync } from 'node:fs';
import { nothing } from 'lit';
import {
MOON_ELEVATION_MIN, MOON_MIN_ILLUMINATION, MOON_R, MOON_SHARP_FROM,
moonFingerprint, moonIllumination, moonPhasePath, moonPosition, moonShownAt, moonView,
moonFingerprint, moonIllumination, moonPhasePath, moonPosition, moonShownAt, moonStatus, moonStatusOf,
moonView,
} from '../test-build/moon.js';
import { MOON_TICK_MS, moonTick, renderMoon } from '../test-build/moon-runtime.js';
import { moonLayer } from '../test-build/moon-gate.js';
import { RAY_ELEVATION_MIN, RAY_FADE_MS, bgModeOf, resolveDayCycle } from '../test-build/sun.js';
import { MOON_TICK_MS, moonTick, renderMoon, renderMoonSky } from '../test-build/moon-runtime.js';
import { dayCycleClock, moonLayer, moonSkyState } from '../test-build/moon-gate.js';
import { moonStatusText } from '../test-build/editors/moon-status.js';
import { subst } from '../test-build/logic.js';
import {
RAY_ELEVATION_MIN, RAY_FADE_MS, bgModeOf, dayCycleFingerprint, dayCyclePhaseFromSun, resolveDayCycle, sunStateOf,
} from '../test-build/sun.js';
import { renderDayCycleEnvironment } from '../test-build/day-cycle-render.js';
import { moonArtFromSvg, validateMoonPack } from '../scripts/generate-moon-assets.mjs';
@@ -76,7 +83,7 @@ test('#661 AC2: visibility thresholds on both sides', () => {
assert.equal(moonShownAt('day', 45, 1), false);
});
test('#661 AC2: only an explicit true, finite home coordinates and an environment show the moon', () => {
test('#661 AC2: only an explicit true and finite home coordinates show the moon', () => {
const at = new Date('2026-10-21T18:00:00Z');
assert.equal(moonView(ON, 'night', MOSCOW, at).visible, true);
for (const settings of [undefined, null, {}, { moon: false }, { moon: 'true' }, { moon: 1 }]) {
@@ -87,13 +94,11 @@ test('#661 AC2: only an explicit true, finite home coordinates and an environmen
assert.equal(moonView(ON, 'night', config, at).visible, false, JSON.stringify(config));
}
assert.equal(moonView(ON, 'day', MOSCOW, at).visible, false);
// bg_mode static — global or per space — leaves no environment, so no moon.
// #718 AC15: a static background no longer means «no moon» — its own sky
// layer shows it (inverted in the #718 AC15 tests below); here only the
// switch and the host still refuse it.
const host = fakeHost(MOSCOW);
const night = { 'sun.sun': { attributes: { azimuth: 0, elevation: -12, rising: false } } };
for (const [global, space] of [[{ ...ON, bg_mode: 'static' }, {}], [ON, { bg_mode: 'static' }]]) {
const state = bgModeOf(global, space) === 'daynight' ? resolveDayCycle({ states: night }, at) : null;
assert.equal(moonLayer(host, global, state), nothing);
}
assert.equal(moonLayer(host, { moon: false }, resolveDayCycle({ states: night }, at)), nothing);
assert.equal(moonLayer(undefined, ON, resolveDayCycle({ states: night }, at)), nothing);
});
@@ -237,6 +242,127 @@ test('#661 pack: generated art is fresh, and the gate refuses what the brief for
assert.throws(() => moonArtFromSvg(`${root}<path d="${'M0 0'.repeat(9000)}"/></svg>`), /larger/);
});
/** The UX table of #718, written out here: the oracle is the specification, not the dictionary. */
const UX = {
en: {
shown: ({ alt, pct }) => `Now: shown (${alt}° above the horizon, ${pct}% lit).`,
day_sun: ({ sun }) => `Now: not shown (the sun is ${sun}° above the horizon; the moon shows once it is below 6°).`,
low: ({ alt }) => `Now: not shown (the moon is at ${alt}°; it shows from 3° above the horizon).`,
new: ({ pct }) => `Now: not shown (new moon, ${pct}% lit; it shows from 3%).`,
},
ru: {
shown: ({ alt, pct }) => `Сейчас: показывается (на ${alt}° над горизонтом, освещено ${pct}\u00a0%).`,
day_sun: ({ sun }) => `Сейчас: не показывается (Солнце на ${sun}° над горизонтом, луна видна, когда оно ниже 6°).`,
low: ({ alt }) => `Сейчас: не показывается (луна на высоте ${alt}°, видна от 3° над горизонтом).`,
new: ({ pct }) => `Сейчас: не показывается (новолуние, освещено ${pct}\u00a0%, видна от 3\u00a0%).`,
},
};
const dictionaries = Object.fromEntries(['en', 'ru'].map((lang) => [lang,
JSON.parse(readFileSync(new URL(`../src/i18n/settings/${lang}.json`, import.meta.url), 'utf8'))]));
const stFor = (lang) => (key, vars) => subst(dictionaries[lang][key], vars);
test('#718 AC9: the status on ready numbers — the first reason, rounded and clamped', () => {
const sunPhase = (elevation) => dayCyclePhaseFromSun({ elevation, rising: false });
const rows = [
// home, phase, source, sun, altitude, fraction → reason, numbers, texts (minus U+2212)
[false, 'night', 'clock', null, 30, 0.5, { reason: 'no_home' }],
[false, 'day', 'sun', 40, 30, 0.5, { reason: 'no_home' }],
[true, sunPhase(25.4), 'sun', 25.4, 30, 0.5, { reason: 'day_sun', sun: 25 }, { sun: '25' }],
[true, sunPhase(6.0), 'sun', 6.0, 30, 0.5, { reason: 'day_sun', sun: 6 }, { sun: '6' }],
[true, sunPhase(5.9), 'sun', 5.9, 30, 0.5, { reason: 'shown', alt: 30, pct: 50 }, { alt: '30', pct: '50' }],
[true, 'day', 'clock', null, 30, 0.5, { reason: 'day_clock' }],
[true, 'night', 'clock', null, -12.3, 0.5, { reason: 'low', alt: -12 }, { alt: '\u221212' }],
[true, 'night', 'clock', null, 2.6, 0.5, { reason: 'low', alt: 2 }, { alt: '2' }],
[true, 'night', 'clock', null, -0.4, 0.5, { reason: 'low', alt: -0 }, { alt: '0' }],
[true, 'night', 'clock', null, -3, 0.01, { reason: 'low', alt: -3 }, { alt: '\u22123' }],
[true, 'night', 'clock', null, 30.6, 0.0163, { reason: 'new', pct: 2 }, { pct: '2' }],
[true, 'night', 'clock', null, 30, 0.026, { reason: 'new', pct: 2 }, { pct: '2' }],
[true, 'night', 'clock', null, 3.0, 0.03, { reason: 'shown', alt: 3, pct: 3 }, { alt: '3', pct: '3' }],
[true, 'night', 'clock', null, 34.4, 0.618, { reason: 'shown', alt: 34, pct: 62 }, { alt: '34', pct: '62' }],
];
assert.equal(sunPhase(25.4), 'day');
assert.equal(sunPhase(6.0), 'day', 'exactly 6° is day');
assert.equal(sunPhase(5.9), 'dusk');
for (const [home, phase, source, sun, altitude, fraction, expected, shown] of rows) {
const label = JSON.stringify({ home, phase, source, sun, altitude, fraction });
const status = moonStatusOf({ home, phase, source, sun, altitude, fraction });
assert.deepEqual(status, expected, label);
if (shown) {
for (const lang of ['en', 'ru']) {
assert.equal(moonStatusText(status, stFor(lang)), UX[lang][status.reason](shown), `${lang} ${label}`);
}
}
}
// The two literal reasons, exactly as the table has them.
assert.equal(moonStatusText({ reason: 'no_home' }, stFor('ru')),
'Сейчас: не показывается (в Home Assistant не заданы координаты дома).');
assert.equal(moonStatusText({ reason: 'day_clock' }, stFor('en')),
'Now: not shown (daytime by the clock, 08:00\u201318:00: Home Assistant has no sun.sun data).');
});
test('#718 AC10: «shown» exactly when the element is visible — every hour of October, two homes', () => {
const suns = [-12, 0, 5.9, 6, 40]
.map((elevation) => ({ 'sun.sun': { attributes: { azimuth: 180, elevation, rising: false } } }))
.concat([{}]);
let visibleCount = 0;
let total = 0;
for (const place of [MOSCOW, SYDNEY]) {
for (let hour = 0; hour < 31 * 24; hour++) {
const now = new Date(Date.UTC(2026, 9, 1) + hour * 3_600_000);
for (const states of suns) {
const hass = { config: place, states };
const state = resolveDayCycle(hass, now);
const status = moonStatus(place, state, sunStateOf(hass)?.elevation ?? null, now);
const view = moonView({ moon: true }, state.phase, place, now);
const label = `${now.toISOString()} ${place.latitude} ${JSON.stringify(states)}`;
assert.equal(status.reason === 'shown', view.visible, label);
// K8: the shown per cent is the element's data-moon-k × 100.
if (view.visible) assert.equal(status.pct, Math.round(view.k * 100), label);
visibleCount += view.visible ? 1 : 0;
total++;
}
}
}
assert.ok(visibleCount > 500 && visibleCount < total - 500, `${visibleCount} of ${total}: both answers occur`);
// No home: never shown, whatever the sky.
assert.equal(moonStatus({}, { phase: 'night', source: 'clock' }, null, new Date('2026-10-21T18:00:00Z')).reason, 'no_home');
});
test('#718 K5: the clock ticker compares the environment whole, the moon’s sky by its phase', () => {
const at = (hour, minute) => resolveDayCycle({}, hour * 60 + minute);
assert.equal(dayCycleClock(at(18, 5), null)[1], dayCycleFingerprint(at(18, 5)));
assert.notEqual(dayCycleClock(at(18, 5), null)[1], dayCycleClock(at(18, 6), null)[1], 'the environment’s sun moves');
assert.equal(dayCycleClock(null, at(18, 5))[1], dayCycleClock(null, at(20, 59))[1], 'a phase is one key');
assert.notEqual(dayCycleClock(null, at(17, 59))[1], dayCycleClock(null, at(18, 0))[1], '18:00 is a new key');
assert.equal(dayCycleClock(null, at(18, 5))[0].phase, 'dusk');
assert.deepEqual(dayCycleClock(null, null), [null, '']);
});
const NIGHT_HASS = { config: MOSCOW, states: { 'sun.sun': { attributes: { azimuth: 0, elevation: -12, rising: false } } } };
const DAY_HASS = { config: MOSCOW, states: { 'sun.sun': { attributes: { azimuth: 180, elevation: 40, rising: false } } } };
const STATIC_ON = { moon: true, bg_mode: 'static' };
// Runs before the #661 C7 test loads the chunk: here nothing may ask for it.
test('#718 AC15: the static sky computes and asks for nothing in the editor, with the moon off or by day', async () => {
const at = new Date('2026-10-21T18:00:00Z');
const host = fakeHost(MOSCOW);
// The editor (#101 View weight 0) and a moon that is not explicitly on compute no phase.
assert.equal(moonSkyState(STATIC_ON, false, 0, NIGHT_HASS, at), null);
for (const settings of [{ bg_mode: 'static' }, { bg_mode: 'static', moon: false }, { bg_mode: 'static', moon: 'true' }]) {
assert.equal(moonSkyState(settings, false, 1, NIGHT_HASS, at), null, JSON.stringify(settings));
assert.equal(moonLayer(host, settings, resolveDayCycle(NIGHT_HASS, at), 1), nothing);
}
// Over "Follow the Sun" the environment carries the moon; the sky computes nothing.
assert.equal(moonSkyState({ moon: true, bg_mode: 'daynight' }, true, 1, NIGHT_HASS, at), null);
assert.equal(moonLayer(host, STATIC_ON, moonSkyState(STATIC_ON, false, 0, NIGHT_HASS, at), 0), nothing);
// By day the chunk is not asked for (#661 C7).
const day = moonSkyState(STATIC_ON, false, 1, DAY_HASS, at);
assert.equal(day?.phase, 'day');
assert.equal(moonLayer(host, STATIC_ON, day, 1), nothing);
await new Promise((done) => setTimeout(done, 50));
assert.equal(host.updates, 0, 'nothing was loaded');
});
test('#661 C7: the chunk is asked for at night only; until it arrives there is no moon', async () => {
const at = new Date('2026-10-21T18:00:00Z');
const sunAt = (elevation) => resolveDayCycle({ states: { 'sun.sun': { attributes: { azimuth: 0, elevation, rising: false } } } }, at);
@@ -251,3 +377,40 @@ test('#661 C7: the chunk is asked for at night only; until it arrives there is n
assert.notEqual(element, nothing);
forgetHost(host);
});
test('#718 AC15: a static background — global or the space’s own — shows the moon in its sky layer', () => {
const at = new Date('2026-10-21T18:00:00Z');
const host = fakeHost(MOSCOW);
for (const [global, space] of [[STATIC_ON, {}], [{ moon: true, bg_mode: 'daynight' }, { bg_mode: 'static' }]]) {
const daynight = bgModeOf(global, space) === 'daynight';
assert.equal(daynight, false);
const sky = moonSkyState(global, daynight, 1, NIGHT_HASS, at);
assert.equal(sky?.phase, 'night');
const layer = moonLayer(host, global, sky, 1);
assert.notEqual(layer, nothing, JSON.stringify({ global, space }));
assert.match(layer.strings[0], /^<div class="hp-moon-sky" aria-hidden="true"/);
}
// Without `sky` the element goes into the environment, as in #661.
const env = moonLayer(host, { moon: true, bg_mode: 'daynight' }, resolveDayCycle(NIGHT_HASS, at));
assert.match(env.strings[0], /^<style>/);
forgetHost(host);
});
test('#718 K3/K4: the sky layer is scene-sized, unstacked, and fades with the View weight', () => {
const at = new Date('2026-10-21T18:00:00Z');
const host = fakeHost(MOSCOW);
const sky = renderMoonSky(host, ON, 'night', 0.37, at);
const inner = renderMoon(host, ON, 'night', at);
const clamped = renderMoonSky(host, ON, 'night', 3, at);
forgetHost(host);
assert.match(sky.strings[0], /^<div class="hp-moon-sky" aria-hidden="true"\s+style="opacity:$/);
assert.equal(sky.values[0], '0.3700');
assert.equal(clamped.values[0], '1.0000');
// Inside: the very element of the environment, and nothing else.
assert.deepEqual(sky.values[1].strings, inner.strings);
assert.equal(sky.strings.length, 3);
assert.match(sky.strings[2], /^<\/div>$/);
const css = inner.values[0];
assert.match(css, /\.hp-day-cycle-env,\.hp-moon-sky\{container-type:size\}/);
assert.match(css, /\.hp-moon-sky\{position:absolute;inset:0;overflow:hidden;pointer-events:none\}/);
});
+1 -1
View File
@@ -23,7 +23,7 @@
"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/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/space-form-state.ts", "src/editors/marker-form-state.ts", "src/editors/room-form-state.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",
"src/signing.ts", "src/initial-load.ts", "src/space-model-selection.ts", "src/space-dialog.ts",