Files
houseplan-card/src/moon-runtime.ts
T
Claudeandclaude[bot] b998b0b34a 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
2026-10-01 05:25:21 +00:00

146 lines
6.3 KiB
TypeScript

/**
* The lazy moon chunk (#661, C7): astronomy, the designer's art, the element
* 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 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';
import {
MOON_SHARP_FROM, moonFingerprint, moonPhasePath, moonView, type MoonView,
} 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`. */
export interface MoonHost {
readonly isConnected: boolean;
readonly updateComplete: Promise<unknown>;
readonly hass?: { config?: unknown };
requestUpdate(): void;
}
/** C3: the environment ticker period; the moon rises at most 0.25°/min. */
export const MOON_TICK_MS = 30_000;
/**
* 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. #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,.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;'
+ 'transition:opacity 2000ms cubic-bezier(.22,.61,.36,1)}' // RAY_FADE_MS, pinned by the test
+ '.hp-moon.on{opacity:1}'
+ '@media (prefers-reduced-motion:reduce){.hp-moon{transition:none}}';
interface MoonWatch {
settings: unknown;
phase: DayCyclePhase;
/** Fingerprint of what is on screen (or was last asked for). */
key: string;
/** Set by every render that includes the moon; cleared when a tick asks for one. */
seen: boolean;
timer: ReturnType<typeof setInterval>;
}
const watches = new WeakMap<MoonHost, MoonWatch>();
function forget(host: MoonHost, watch: MoonWatch): void {
clearInterval(watch.timer);
if (watches.get(host) === watch) watches.delete(host);
}
/**
* 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)
* 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);
if (!watch) return;
if (!host.isConnected) { forget(host, watch); return; }
if (globalThis.document?.visibilityState === 'hidden') return;
const key = moonFingerprint(moonView(watch.settings, watch.phase, host.hass?.config, now));
if (key === watch.key) return;
watch.key = key;
watch.seen = false;
host.requestUpdate();
void host.updateComplete.then(() => { if (!watch.seen) forget(host, watch); });
}
function remember(host: MoonHost, settings: unknown, phase: DayCyclePhase, view: MoonView): void {
const key = moonFingerprint(view);
const watch = watches.get(host);
if (watch) {
Object.assign(watch, { settings, phase, key, seen: true });
return;
}
watches.set(host, {
settings, phase, key, seen: true,
timer: setInterval(() => moonTick(host), MOON_TICK_MS),
});
}
/**
* 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.
*/
export function renderMoon(
host: MoonHost, settings: unknown, phase: DayCyclePhase, now: Date = new Date(),
): TemplateResult {
const view = moonView(settings, phase, host.hass?.config, now);
remember(host, settings, phase, view);
return html`<style>${MOON_CSS}</style><svg class="hp-moon${view.visible ? ' on' : ''}"
viewBox="0 0 512 512" aria-hidden="true" focusable="false"
data-moon-k=${view.k.toFixed(2)} data-moon-visible=${view.visible ? 'true' : 'false'}>
<defs>
<g id="hp-moon-art">${MOON_ART}</g>
<filter id="hp-moon-soft" x="-5%" y="-5%" width="110%" height="110%">
<feGaussianBlur stdDeviation="5"></feGaussianBlur>
</filter>
<mask id="hp-moon-phase" maskUnits="userSpaceOnUse" x="0" y="0" width="512" height="512">
<path d=${moonPhasePath(view.k)} fill="#fff"
filter=${view.k >= MOON_SHARP_FROM ? nothing : 'url(#hp-moon-soft)'}></path>
</mask>
</defs>
<use href="#hp-moon-art" opacity="0.08"></use>
<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>`;
}