mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-05 06:08:59 +00:00
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
146 lines
6.3 KiB
TypeScript
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>`;
|
|
}
|