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
+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' });
});