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