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
+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">