mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-01 20:29:00 +00:00
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:
+31
-2
@@ -686,7 +686,7 @@ export function prepareGoldenFixture(scenario) {
|
||||
throw new Error(`golden deviceName references missing device: ${scenario.deviceId || '<empty>'}`);
|
||||
fixture.devices[scenario.deviceId].name = scenario.deviceName;
|
||||
}
|
||||
if (scenario.fillMode || scenario.bgMode || typeof scenario.glowEnabled === 'boolean'
|
||||
if (scenario.fillMode || scenario.bgMode || scenario.bgColor || typeof scenario.glowEnabled === 'boolean'
|
||||
|| typeof scenario.sunRays === 'boolean' || typeof scenario.showBorders === 'boolean'
|
||||
|| typeof scenario.showNames === 'boolean'
|
||||
|| typeof scenario.northDeg === 'number') {
|
||||
@@ -695,6 +695,8 @@ export function prepareGoldenFixture(scenario) {
|
||||
...(space.settings || {}),
|
||||
...(scenario.fillMode ? { fill_mode: scenario.fillMode } : {}),
|
||||
...(scenario.bgMode ? { bg_mode: scenario.bgMode } : {}),
|
||||
// #718: the scene colour of a static background, deterministic in both themes.
|
||||
...(scenario.bgColor ? { bg_color: scenario.bgColor } : {}),
|
||||
...(typeof scenario.glowEnabled === 'boolean' ? { glow_enabled: scenario.glowEnabled } : {}),
|
||||
...(typeof scenario.sunRays === 'boolean' ? { sun_rays: scenario.sunRays } : {}),
|
||||
...(typeof scenario.showBorders === 'boolean' ? { show_borders: scenario.showBorders } : {}),
|
||||
@@ -933,6 +935,23 @@ export async function prepareGoldenScenario(page, scenario) {
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
};
|
||||
/**
|
||||
* #718 AC8: General settings grow a status line under the moon switch once
|
||||
* the lazy moon chunk judged the opening. Wait for it, so the frame does
|
||||
* not depend on the chunk's timing; at phone width it wraps, it never
|
||||
* scrolls the dialog sideways.
|
||||
*/
|
||||
const moonStatusSettled = async (card) => {
|
||||
await until(() => !!card.renderRoot.querySelector('hp-dialog [data-moon-status]'));
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
const dialog = card.renderRoot.querySelector('hp-dialog');
|
||||
const body = dialog?.querySelector('.body');
|
||||
if (innerWidth <= 390 && (!dialog || dialog.scrollWidth > dialog.clientWidth + 1
|
||||
|| (body && body.scrollWidth > body.clientWidth + 1))) {
|
||||
throw new Error(`golden general-settings dialog overflows horizontally: ${scenario.id}`);
|
||||
}
|
||||
};
|
||||
const settleCamera = async (card) => {
|
||||
await until(() => !card._cameraTransition?.active, 1500);
|
||||
await card.updateComplete;
|
||||
@@ -2022,6 +2041,7 @@ export async function prepareGoldenScenario(page, scenario) {
|
||||
} else if (scenario.dialog === 'general-color') {
|
||||
card._openSettingsDialog();
|
||||
await card.updateComplete;
|
||||
await moonStatusSettled(card);
|
||||
const dialog = card.renderRoot.querySelector('hp-dialog');
|
||||
const picker = [...(dialog?.querySelectorAll('hp-color-opacity') || [])]
|
||||
.find((item) => item.label === card._t('gs.light_on'));
|
||||
@@ -2035,6 +2055,7 @@ export async function prepareGoldenScenario(page, scenario) {
|
||||
} else if (scenario.dialog === 'general-help') {
|
||||
card._openSettingsDialog();
|
||||
await card.updateComplete;
|
||||
await moonStatusSettled(card);
|
||||
const dialog = card.renderRoot.querySelector('hp-dialog');
|
||||
const help = dialog?.querySelector(`hp-help[data-help-key="${scenario.openHelp}"]`);
|
||||
await help?.updateComplete;
|
||||
@@ -2216,7 +2237,15 @@ export async function prepareGoldenScenario(page, scenario) {
|
||||
// The moon chunk is lazy: wait for it, then for the expected phase.
|
||||
await until(() => card.renderRoot.querySelector('.hp-moon')?.dataset.moonVisible === 'true');
|
||||
const moon = card.renderRoot.querySelector('.hp-moon');
|
||||
if (moon.dataset.moonK !== scenario.moon.k || moon.parentElement !== card.renderRoot.querySelector('.hp-day-cycle-env')) {
|
||||
// #718: over "Follow the Sun" the moon is in the environment; over a
|
||||
// static background in its own sky layer, the first child of the scene.
|
||||
const stage = card.renderRoot.querySelector('.stage');
|
||||
const parent = scenario.bgMode === 'daynight'
|
||||
? card.renderRoot.querySelector('.hp-day-cycle-env')
|
||||
: card.renderRoot.querySelector('.hp-moon-sky');
|
||||
const staticSky = scenario.bgMode === 'daynight' || (stage?.firstElementChild === parent
|
||||
&& !card.renderRoot.querySelector('.hp-day-cycle-env'));
|
||||
if (moon.dataset.moonK !== scenario.moon.k || !parent || moon.parentElement !== parent || !staticSky) {
|
||||
throw new Error(`golden moon contract did not render: ${scenario.id} (k ${moon.dataset.moonK})`);
|
||||
}
|
||||
await frame();
|
||||
|
||||
+15
-1
@@ -1,7 +1,7 @@
|
||||
import { fixtureWallKey } from '../fixtures/visual-matrix.mjs';
|
||||
|
||||
/** Data-only HP-QA-01 capture matrix. Bump when framing or scenarios change. */
|
||||
export const GOLDEN_MATRIX_VERSION = 69;
|
||||
export const GOLDEN_MATRIX_VERSION = 70;
|
||||
|
||||
const stage = { capture: 'stage', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0005 } };
|
||||
const page = { capture: 'page', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0008 } };
|
||||
@@ -562,6 +562,20 @@ export const GOLDEN_SCENARIOS = Object.freeze([
|
||||
moon: { clock: '2026-10-14T09:00:00Z', latitude: -33.87, longitude: 151.21, k: '0.14' },
|
||||
stateOverrides: { 'sun.sun': { attributes: { azimuth: 265, elevation: -2, rising: false } } },
|
||||
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
// #718 AC8: the moon with a static background — its own sky layer at the
|
||||
// top-left of the scene, the plan over part of the disc, the chosen scene
|
||||
// colour around the plan unchanged. White in the light theme, dark grey in
|
||||
// the dark one; the same art and lit side as over "Follow the Sun".
|
||||
{ id: 'static-bg-moon-gibbous-white-light', fixture: 'visual', space: 'golden-geometry', mode: 'view',
|
||||
bgMode: 'static', bgColor: '#ffffff',
|
||||
moon: { clock: '2026-10-21T18:00:00Z', latitude: 55.75, longitude: 37.62, k: '0.79' },
|
||||
stateOverrides: { 'sun.sun': { attributes: { azimuth: 0, elevation: -12, rising: false } } },
|
||||
theme: 'light', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'static-bg-moon-crescent-south-dark', fixture: 'visual', space: 'golden-geometry', mode: 'view',
|
||||
bgMode: 'static', bgColor: '#202126',
|
||||
moon: { clock: '2026-10-14T09:00:00Z', latitude: -33.87, longitude: 151.21, k: '0.14' },
|
||||
stateOverrides: { 'sun.sun': { attributes: { azimuth: 265, elevation: -2, rising: false } } },
|
||||
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'geometry-plan-editor-dark', fixture: 'visual', space: 'golden-geometry', mode: 'plan',
|
||||
theme: 'dark', viewport: { width: 1180, height: 900 }, ...page },
|
||||
{ id: 'safe-resize-handles-clamp-light', fixture: 'visual', space: 'golden-safe-resize',
|
||||
|
||||
@@ -242,6 +242,31 @@ await page.waitForFunction(() => !!window.__card.renderRoot.querySelector('.hp-m
|
||||
await transitionsDone();
|
||||
const moonLayers = (await layerSnapshot()).layers;
|
||||
|
||||
// #718 AC7: a static background at night — the moon stands in its own sky
|
||||
// layer (no z-index, filter or will-change) and adds no composited layer
|
||||
// either. Night without the moon against night with it, both settled.
|
||||
const setStaticMoon = (moon) => page.evaluate(async (moon) => {
|
||||
const card = window.__card;
|
||||
card._serverCfg = { ...card._serverCfg, settings: { ...card._serverCfg.settings, bg_mode: 'static', moon } }; // private-ok: the synthetic 1 cm/point space exists only in the card config, a server push would reload the demo plan
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
}, moon);
|
||||
await setStaticMoon(false);
|
||||
await settle();
|
||||
await transitionsDone();
|
||||
const staticNightLayers = (await layerSnapshot()).layers;
|
||||
await setStaticMoon(true);
|
||||
await page.waitForFunction(() => !!window.__card.renderRoot.querySelector('.hp-moon-sky > .hp-moon.on'), null, { timeout: 8000 });
|
||||
await transitionsDone();
|
||||
const staticMoonLayers = (await layerSnapshot()).layers;
|
||||
await page.evaluate(async () => {
|
||||
const card = window.__card;
|
||||
card._serverCfg = { ...card._serverCfg, settings: { ...card._serverCfg.settings, bg_mode: 'daynight', moon: true } }; // private-ok: back to the #661 night for the static card below
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
});
|
||||
await settle();
|
||||
|
||||
// Contract item 6: the non-interactive houseplan-space-card never needs a
|
||||
// gesture to become safe. Its day-cycle silhouette is stage-bounded from its
|
||||
// first frame, while the visible paper group owns no filter layer.
|
||||
@@ -370,6 +395,8 @@ checks.staticCardOutlineLayerIsStageBounded = !!staticOutlineLayer
|
||||
checks.staticCardHasNo4096ContentLayer = staticOversized.length === 0;
|
||||
checks.moonAddsNoCompositedLayer = moonLayers.length === nightLayers.length
|
||||
&& !moonLayers.some((layer) => layer.className.includes('hp-moon'));
|
||||
checks.staticMoonAddsNoCompositedLayer = staticMoonLayers.length === staticNightLayers.length
|
||||
&& !staticMoonLayers.some((layer) => layer.className.includes('hp-moon'));
|
||||
|
||||
console.log(JSON.stringify({
|
||||
stage: active.stage,
|
||||
@@ -389,6 +416,8 @@ console.log(JSON.stringify({
|
||||
staticOversized,
|
||||
nightLayers: nightLayers.length,
|
||||
moonLayers: moonLayers.length,
|
||||
staticNightLayers: staticNightLayers.length,
|
||||
staticMoonLayers: staticMoonLayers.length,
|
||||
}, null, 2));
|
||||
checkAll(checks);
|
||||
await cdp.send('LayerTree.disable');
|
||||
|
||||
@@ -0,0 +1,389 @@
|
||||
// #718 AC1–AC6: the moon with any background, in the production bundle. With
|
||||
// a static background there is no environment: the moon stands in its own
|
||||
// layer `.hp-moon-sky`, the first child of the scene, under the plan by DOM
|
||||
// order. The home is Moscow through `hass.config`; the clock is Playwright's
|
||||
// (`page.clock`), the time zone the context's (`timezoneId`).
|
||||
//
|
||||
// Moscow 2026-10-21 18:00Z: the moon at 23.9°, 79 % lit; 2026-10-03 07:59Z:
|
||||
// 31.5°, 53 %. AC1 and AC3 are the witnesses: red on the #661 code, where a
|
||||
// static background leaves no moon at all.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
|
||||
const MOSCOW = { latitude: 55.75, longitude: 37.62 };
|
||||
const NIGHT_SUN = { azimuth: 0, elevation: -12, rising: false };
|
||||
const DAY_SUN = { azimuth: 180, elevation: 40, rising: false };
|
||||
const checks = {};
|
||||
const report = {};
|
||||
const isMoonAsset = (url) => /\/moon-runtime-[^/?]*\.js(?:\?|$)/.test(url);
|
||||
|
||||
async function open(contextOptions = {}) {
|
||||
const run = await launch({ width: 1000, height: 760 }, 1, [], contextOptions);
|
||||
run.moonRequests = [];
|
||||
run.page.on('request', (request) => { if (isMoonAsset(request.url())) run.moonRequests.push(request.url()); });
|
||||
return run;
|
||||
}
|
||||
|
||||
/** A state update, as Home Assistant pushes one; `sun: null` removes `sun.sun`. */
|
||||
const pushHass = (page, { config, sun } = {}) => page.evaluate(async ({ config, sun }) => {
|
||||
const card = window.__card;
|
||||
const states = { ...card.hass.states };
|
||||
if (sun === null) delete states['sun.sun'];
|
||||
else if (sun) {
|
||||
states['sun.sun'] = {
|
||||
entity_id: 'sun.sun', state: sun.elevation > 0 ? 'above_horizon' : 'below_horizon', attributes: sun,
|
||||
};
|
||||
}
|
||||
card.hass = {
|
||||
...card.hass,
|
||||
...(config ? { config: { ...(card.hass.config || {}), ...config } } : {}),
|
||||
states,
|
||||
};
|
||||
await card.updateComplete;
|
||||
}, { config, sun });
|
||||
|
||||
/**
|
||||
* Another client's save: global settings merged (`null` removes a key), every
|
||||
* space's own `bg_mode` dropped, then `spaces[id]` applied to that space.
|
||||
*/
|
||||
const setConfig = (page, settings, spaces = {}) => page.evaluate(({ settings, spaces }) => (
|
||||
window.__hpTest.setServerConfig((cfg) => {
|
||||
cfg.settings = { ...(cfg.settings || {}), ...settings };
|
||||
for (const [key, value] of Object.entries(settings)) if (value === null) delete cfg.settings[key];
|
||||
for (const space of cfg.spaces) {
|
||||
space.settings = { ...(space.settings || {}) };
|
||||
delete space.settings.bg_mode;
|
||||
Object.assign(space.settings, spaces[space.id] || {});
|
||||
}
|
||||
return cfg;
|
||||
})), { settings, spaces });
|
||||
|
||||
const moonState = (page) => page.evaluate(() => {
|
||||
const root = window.__card.renderRoot;
|
||||
const stage = root.querySelector('.stage');
|
||||
const moons = root.querySelectorAll('.hp-moon');
|
||||
const moon = moons[0] || null;
|
||||
const style = moon ? getComputedStyle(moon) : null;
|
||||
const box = moon?.getBoundingClientRect();
|
||||
return {
|
||||
count: moons.length,
|
||||
on: !!moon?.classList.contains('on'),
|
||||
opacity: style ? Number(style.opacity) : null,
|
||||
k: moon?.dataset.moonK ?? null,
|
||||
parent: moon?.parentElement?.className ?? null,
|
||||
parentIsFirstChild: !!moon && moon.parentElement === stage.firstElementChild,
|
||||
sky: root.querySelectorAll('.hp-moon-sky').length,
|
||||
env: !!root.querySelector('.hp-day-cycle-env'),
|
||||
outline: !!root.querySelector('.hp-paper-outline-svg'),
|
||||
stageClasses: [...stage.classList].filter((name) => name === 'daycycle' || name.startsWith('phase-')),
|
||||
stageBg: getComputedStyle(stage).backgroundColor,
|
||||
box: box ? [box.left, box.top, box.width, box.height] : null,
|
||||
running: moon ? moon.getAnimations()
|
||||
.filter((animation) => animation instanceof CSSTransition && animation.playState === 'running').length : 0,
|
||||
pointerEvents: style?.pointerEvents ?? null,
|
||||
};
|
||||
});
|
||||
const settle = (page) => page.evaluate(async () => {
|
||||
await window.__card.updateComplete;
|
||||
await new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
|
||||
});
|
||||
/** Soft wait: a missing moon is a named red check, not an aborted run. */
|
||||
const until = (page, predicate, arg, timeout = 8000) => page.waitForFunction(predicate, arg, { timeout })
|
||||
.then(() => true, () => false);
|
||||
const moonOn = () => !!window.__card.renderRoot.querySelector('.hp-moon.on');
|
||||
const sameBox = (a, b) => !!a && !!b && a.every((value, index) => Math.abs(value - b[index]) <= 1);
|
||||
|
||||
// ─── Part A: full card, `sun.sun` from Home Assistant (AC1, AC2, AC4, AC5) ───
|
||||
{
|
||||
const { page, browser, moonRequests } = await open();
|
||||
await page.clock.setFixedTime(new Date('2026-10-21T18:00:00Z'));
|
||||
await pushHass(page, { config: MOSCOW, sun: NIGHT_SUN });
|
||||
await setConfig(page, { bg_mode: 'static', moon: null });
|
||||
await settle(page);
|
||||
await page.waitForTimeout(400);
|
||||
const moonOff = await moonState(page);
|
||||
// AC2: night with the moon switched off asks for nothing.
|
||||
checks.ac2_nightMoonOffNoChunk = moonOff.count === 0 && moonRequests.length === 0;
|
||||
|
||||
// AC2: switched on by day — no moon and still no request.
|
||||
await pushHass(page, { sun: DAY_SUN });
|
||||
await setConfig(page, { bg_mode: 'static', moon: true });
|
||||
await settle(page);
|
||||
await page.waitForTimeout(400);
|
||||
const day = await moonState(page);
|
||||
checks.ac2_dayNoMoonNoChunk = !day.on && moonRequests.length === 0;
|
||||
|
||||
// AC2: the state update at night brings the moon.
|
||||
await pushHass(page, { sun: NIGHT_SUN });
|
||||
checks.ac2_nightPushShowsTheMoon = await until(page, moonOn);
|
||||
const first = await moonState(page);
|
||||
report.ac1 = { moonOff, first, requests: moonRequests.length };
|
||||
|
||||
// AC1 (witness): one moon in its own layer, the first child of the scene.
|
||||
checks.ac1_oneMoonOnAtFullOpacity = first.count === 1 && first.on && first.opacity === 1;
|
||||
checks.ac1_parentIsTheSkyLayer = first.sky === 1 && first.parent === 'hp-moon-sky' && first.parentIsFirstChild;
|
||||
checks.ac1_noEnvironment = !first.env && !first.outline && first.stageClasses.length === 0;
|
||||
checks.ac1_backgroundUnchanged = first.stageBg === moonOff.stageBg;
|
||||
checks.ac1_moonIgnoresThePointer = first.pointerEvents === 'none';
|
||||
|
||||
// AC1: the #661 probe along the disc's diameter — under the plan the moon
|
||||
// changes no pixel, in the open it does.
|
||||
const probe = await page.evaluate(() => {
|
||||
const root = window.__card.renderRoot;
|
||||
const moon = root.querySelector('.hp-moon');
|
||||
if (!moon) return { covered: null, open: null, cy: 0 };
|
||||
const box = moon.getBoundingClientRect();
|
||||
const cy = Math.round(box.top + box.height / 2);
|
||||
const disc = box.width * 0.47;
|
||||
let covered = null;
|
||||
let open = null;
|
||||
for (let x = box.left + box.width / 2 - disc * 0.9; x <= box.left + box.width / 2 + disc * 0.9; x += 2) {
|
||||
const hit = root.elementFromPoint(x, cy);
|
||||
const svg = hit?.closest('svg.plan-svg');
|
||||
if (svg && hit !== svg) covered ??= Math.round(x);
|
||||
else if (!svg || hit === svg) open ??= Math.round(x);
|
||||
}
|
||||
return { covered, open, cy };
|
||||
});
|
||||
const pixel = async (x, y) => (await page.screenshot({ clip: { x, y, width: 1, height: 1 } })).toString('base64');
|
||||
const pixels = async () => ({
|
||||
covered: probe.covered === null ? null : await pixel(probe.covered, probe.cy),
|
||||
open: probe.open === null ? null : await pixel(probe.open, probe.cy),
|
||||
});
|
||||
const shown = await pixels();
|
||||
await page.evaluate(() => {
|
||||
const style = document.createElement('style');
|
||||
style.id = 'hp-moon-probe';
|
||||
style.textContent = '.hp-moon{visibility:hidden!important}';
|
||||
window.__card.renderRoot.appendChild(style);
|
||||
});
|
||||
const hidden = await pixels();
|
||||
await page.evaluate(() => window.__card.renderRoot.querySelector('#hp-moon-probe')?.remove());
|
||||
report.ac1.probe = probe;
|
||||
checks.ac1_planCoversPartOfTheDisc = probe.covered !== null && probe.open !== null;
|
||||
checks.ac1_noPixelChangesUnderThePlan = shown.covered !== null && shown.covered === hidden.covered;
|
||||
checks.ac1_moonPaintedWhereThePlanIsNot = shown.open !== null && shown.open !== hidden.open;
|
||||
|
||||
// AC2: daytime again — the moon fades out over 2 s.
|
||||
await pushHass(page, { sun: DAY_SUN });
|
||||
await page.waitForTimeout(250);
|
||||
const fading = await moonState(page);
|
||||
await page.waitForTimeout(2050);
|
||||
const gone = await moonState(page);
|
||||
report.ac2 = { day, fading, gone };
|
||||
checks.ac2_dayFades = fading.count === 1 && !fading.on && fading.opacity > 0 && fading.opacity < 1;
|
||||
checks.ac2_dayFadesToZero = gone.count === 1 && gone.opacity === 0;
|
||||
|
||||
// AC4: global "Follow the Sun", the garden has its own static background.
|
||||
await pushHass(page, { sun: NIGHT_SUN });
|
||||
await setConfig(page, { bg_mode: 'daynight', moon: true }, { garden: { bg_mode: 'static' } });
|
||||
await page.evaluate(() => window.__hpTest.switchSpace('f1'));
|
||||
await until(page, moonOn);
|
||||
await page.waitForTimeout(2200);
|
||||
const inEnv = await moonState(page);
|
||||
const tabs = [];
|
||||
for (const id of ['garden', 'f1', 'garden', 'f1']) {
|
||||
await page.evaluate((space) => window.__hpTest.switchSpace(space), id);
|
||||
await settle(page);
|
||||
tabs.push({ id, ...(await moonState(page)) });
|
||||
}
|
||||
report.ac4 = { inEnv, tabs };
|
||||
checks.ac4_startsInsideTheEnvironment = inEnv.count === 1 && inEnv.on && inEnv.parent === 'hp-day-cycle-env';
|
||||
checks.ac4_tabsKeepOneMoonInItsPlace = tabs.every((tab) => tab.count === 1 && tab.on && tab.opacity === 1
|
||||
&& tab.parent === (tab.id === 'garden' ? 'hp-moon-sky' : 'hp-day-cycle-env')
|
||||
&& sameBox(tab.box, inEnv.box) && tab.running === 0);
|
||||
|
||||
// AC4: the background segment previewed in the open General settings.
|
||||
await page.evaluate(async () => { window.__card._openSettingsDialog(); await window.__card.updateComplete; });
|
||||
const pick = (value) => page.evaluate(async (value) => {
|
||||
const card = window.__card;
|
||||
const input = card.renderRoot.querySelector(`hp-dialog input[name="gs-bg-mode"][value="${value}"]`);
|
||||
input?.click();
|
||||
await card.updateComplete;
|
||||
return !!input;
|
||||
}, value);
|
||||
const segmentFound = await pick('static');
|
||||
await settle(page);
|
||||
const previewed = await moonState(page);
|
||||
await pick('daynight');
|
||||
await settle(page);
|
||||
const restored = await moonState(page);
|
||||
await page.evaluate(async () => {
|
||||
const dialog = window.__card.renderRoot.querySelector('hp-dialog[data-kind="settings"]');
|
||||
if (dialog) await window.__hpTest.close(dialog);
|
||||
});
|
||||
report.ac4.previewed = previewed;
|
||||
checks.ac4_segmentPreviewKeepsTheMoon = segmentFound && previewed.count === 1 && previewed.on
|
||||
&& previewed.opacity === 1 && previewed.parent === 'hp-moon-sky' && sameBox(previewed.box, inEnv.box)
|
||||
&& previewed.running === 0;
|
||||
checks.ac4_segmentBackKeepsTheMoon = restored.count === 1 && restored.on
|
||||
&& restored.parent === 'hp-day-cycle-env' && sameBox(restored.box, inEnv.box);
|
||||
|
||||
// AC5: static background, View → plan editor and back (#101 transition).
|
||||
await setConfig(page, { bg_mode: 'static', moon: true });
|
||||
await settle(page);
|
||||
checks.ac5_staticMoonBeforeTheEditor = await until(page,
|
||||
() => window.__card.renderRoot.querySelector('.hp-moon.on')?.parentElement?.className === 'hp-moon-sky');
|
||||
const sample = () => page.evaluate(() => {
|
||||
const card = window.__card;
|
||||
window.__hpSkySamples = [];
|
||||
window.__hpSkyStop = false;
|
||||
const loop = () => {
|
||||
const root = card.renderRoot;
|
||||
const sky = root.querySelector('.hp-moon-sky');
|
||||
const moon = root.querySelector('.hp-moon');
|
||||
const stage = root.querySelector('.stage');
|
||||
window.__hpSkySamples.push({
|
||||
busy: !!card._modeTransitionBusy,
|
||||
weight: Number(getComputedStyle(stage).getPropertyValue('--hp-mode-view-weight')),
|
||||
sky: sky ? Number(getComputedStyle(sky).opacity) : null,
|
||||
moon: moon ? { on: moon.classList.contains('on'), opacity: Number(getComputedStyle(moon).opacity) } : null,
|
||||
});
|
||||
if (!window.__hpSkyStop) requestAnimationFrame(loop);
|
||||
};
|
||||
requestAnimationFrame(loop);
|
||||
});
|
||||
const stopSampling = () => page.evaluate(() => { window.__hpSkyStop = true; return window.__hpSkySamples; });
|
||||
await sample();
|
||||
await page.evaluate(() => window.__hpTest.setMode('plan'));
|
||||
await settle(page);
|
||||
const toEditor = await stopSampling();
|
||||
const inEditor = await moonState(page);
|
||||
await sample();
|
||||
await page.evaluate(() => window.__hpTest.setMode('view'));
|
||||
await settle(page);
|
||||
const toView = await stopSampling();
|
||||
const backInView = await moonState(page);
|
||||
const between = toEditor.filter((frame) => frame.sky !== null && frame.weight > 0 && frame.weight < 1);
|
||||
const firstBack = toView.find((frame) => frame.moon);
|
||||
report.ac5 = { between: between.length, toEditor: toEditor.length, inEditor, firstBack, backInView };
|
||||
checks.ac5_skyFollowsTheViewWeight = between.length > 0
|
||||
&& toEditor.every((frame) => frame.sky === null || Math.abs(frame.sky - frame.weight) <= 0.02);
|
||||
checks.ac5_noMoonInTheEditor = inEditor.count === 0 && inEditor.sky === 0;
|
||||
checks.ac5_backInViewAtOnce = !!firstBack && firstBack.moon.on && firstBack.moon.opacity === 1
|
||||
&& backInView.on && backInView.opacity === 1 && backInView.parent === 'hp-moon-sky';
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
// ─── Part B: the browser clock, no `sun.sun`, UTC (AC3) ───
|
||||
{
|
||||
const { page, browser, moonRequests } = await open({ timezoneId: 'UTC' });
|
||||
await page.clock.install({ time: new Date('2026-10-21T17:59:00Z') });
|
||||
await pushHass(page, { config: MOSCOW, sun: null });
|
||||
await setConfig(page, { bg_mode: 'static', moon: true });
|
||||
await settle(page);
|
||||
await page.waitForTimeout(400);
|
||||
const before = await moonState(page);
|
||||
checks.ac3_dayByTheClockNoMoonNoChunk = !before.on && moonRequests.length === 0;
|
||||
// Witness: only the card's own clock ticker can bring the moon at 18:00.
|
||||
await page.clock.runFor(90_000);
|
||||
const dusk = await until(page, moonOn);
|
||||
const evening = await moonState(page);
|
||||
checks.ac3_duskByTheClockShowsTheMoon = dusk && evening.count === 1 && evening.k === '0.79'
|
||||
&& evening.parent === 'hp-moon-sky';
|
||||
await page.clock.setSystemTime(new Date('2026-10-03T07:59:00Z'));
|
||||
await page.clock.runFor(30_000);
|
||||
const dawnShown = await until(page,
|
||||
() => window.__card.renderRoot.querySelector('.hp-moon.on')?.dataset.moonK === '0.53');
|
||||
const dawn = await moonState(page);
|
||||
checks.ac3_dawnByTheClockShowsTheMoon = dawnShown && dawn.on && dawn.k === '0.53';
|
||||
await page.clock.runFor(60_000);
|
||||
const dayHides = await until(page, () => {
|
||||
const moon = window.__card.renderRoot.querySelector('.hp-moon');
|
||||
return !!moon && !moon.classList.contains('on');
|
||||
});
|
||||
checks.ac3_dayByTheClockHidesTheMoon = dayHides;
|
||||
report.ac3 = { before, evening, dawn, requests: moonRequests.length };
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
// ─── Part C: houseplan-space-card with its own static background (AC6) ───
|
||||
{
|
||||
const { page, browser } = await open({ timezoneId: 'UTC' });
|
||||
await page.clock.install({ time: new Date('2026-10-21T18:00:00Z') });
|
||||
await setConfig(page, { bg_mode: 'daynight', moon: true }, { f1: { bg_mode: 'static' } });
|
||||
await page.evaluate(async ({ config, sun }) => {
|
||||
await customElements.whenDefined('houseplan-space-card');
|
||||
const full = window.__card;
|
||||
full.style.display = 'none';
|
||||
const host = document.createElement('div');
|
||||
host.style.cssText = 'position:fixed;left:0;top:0;width:820px';
|
||||
document.body.appendChild(host);
|
||||
const card = document.createElement('houseplan-space-card');
|
||||
card.setConfig({ type: 'custom:houseplan-space-card', space: 'f1', title: '', show_button: false });
|
||||
card.hass = {
|
||||
...full.hass,
|
||||
config: { ...(full.hass.config || {}), ...config },
|
||||
states: { ...full.hass.states, 'sun.sun': { entity_id: 'sun.sun', state: 'below_horizon', attributes: sun } },
|
||||
};
|
||||
host.appendChild(card);
|
||||
window.__hpSpaceCard = card;
|
||||
}, { config: MOSCOW, sun: NIGHT_SUN });
|
||||
const spaceMoon = () => page.evaluate(() => {
|
||||
const card = window.__hpSpaceCard;
|
||||
const moon = card.renderRoot?.querySelector('.hp-static-stage > .hp-moon-sky > .hp-moon');
|
||||
return {
|
||||
count: card.renderRoot?.querySelectorAll('.hp-moon').length ?? 0,
|
||||
nested: !!moon,
|
||||
firstChild: !!moon && moon.parentElement === moon.parentElement.parentElement.firstElementChild,
|
||||
on: !!moon?.classList.contains('on'),
|
||||
k: moon?.dataset.moonK ?? null,
|
||||
env: !!card.renderRoot?.querySelector('.hp-day-cycle-env'),
|
||||
};
|
||||
});
|
||||
const spaceOn = () => !!window.__hpSpaceCard?.renderRoot?.querySelector('.hp-static-stage > .hp-moon-sky > .hp-moon.on');
|
||||
const nightShown = await until(page, spaceOn);
|
||||
const night = await spaceMoon();
|
||||
checks.ac6_spaceCardMoonInTheSkyLayer = nightShown && night.count === 1 && night.nested && night.firstChild
|
||||
&& !night.env;
|
||||
// Stacking: with both hit-testable, the plan wins over the part of the disc it covers.
|
||||
const stacking = await page.evaluate(() => {
|
||||
const root = window.__hpSpaceCard.renderRoot;
|
||||
if (!root?.querySelector('.hp-moon')) return { plan: 0, disc: 0 };
|
||||
const style = document.createElement('style');
|
||||
style.id = 'hp-moon-hit';
|
||||
style.textContent = '.hp-static-plan-svg *,.hp-moon,.hp-moon *{pointer-events:auto!important}';
|
||||
root.appendChild(style);
|
||||
const moon = root.querySelector('.hp-moon');
|
||||
const box = moon.getBoundingClientRect();
|
||||
const r = box.width * 0.47 * 0.9;
|
||||
const cx = box.left + box.width / 2;
|
||||
const cy = box.top + box.height / 2;
|
||||
let plan = 0;
|
||||
let disc = 0;
|
||||
for (let y = cy - r; y <= cy + r; y += 3) {
|
||||
for (let x = cx - r; x <= cx + r; x += 3) {
|
||||
if ((x - cx) ** 2 + (y - cy) ** 2 > r * r) continue;
|
||||
const hit = root.elementFromPoint(x, y);
|
||||
if (hit?.closest('.hp-static-plan-svg')) plan++;
|
||||
else if (hit?.closest('.hp-moon')) disc++;
|
||||
}
|
||||
}
|
||||
style.remove();
|
||||
return { plan, disc };
|
||||
});
|
||||
report.ac6 = { night, stacking };
|
||||
checks.ac6_planCoversPartOfTheDisc = stacking.plan > 0 && stacking.disc > 0;
|
||||
const pushSpace = (sun) => page.evaluate(async (sun) => {
|
||||
const card = window.__hpSpaceCard;
|
||||
const states = { ...card.hass.states };
|
||||
if (sun) states['sun.sun'] = { entity_id: 'sun.sun', state: 'above_horizon', attributes: sun };
|
||||
else delete states['sun.sun'];
|
||||
card.hass = { ...card.hass, states };
|
||||
await card.updateComplete;
|
||||
}, sun);
|
||||
await pushSpace(DAY_SUN);
|
||||
checks.ac6_dayHidesTheMoon = await until(page,
|
||||
() => window.__hpSpaceCard.renderRoot.querySelector('.hp-moon')?.classList.contains('on') === false);
|
||||
// Without `sun.sun`: 17:59 is day by the clock; 18:00 comes without a state update.
|
||||
await page.clock.setSystemTime(new Date('2026-10-21T17:59:00Z'));
|
||||
await pushSpace(null);
|
||||
await page.waitForTimeout(300);
|
||||
const clockDay = await spaceMoon();
|
||||
await page.clock.runFor(90_000);
|
||||
const clockDusk = await until(page, spaceOn);
|
||||
report.ac6.clockDay = clockDay;
|
||||
checks.ac6_clockDuskShowsTheMoon = !clockDay.on && clockDusk;
|
||||
console.log(JSON.stringify(report, null, 1));
|
||||
checkAll(checks);
|
||||
await finish(browser, checks);
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
// #718 AC11/AC12: the «Now: …» line under the moon switch in General
|
||||
// settings, in the production bundle. One snapshot per opening, judged by the
|
||||
// lazy moon chunk as if the switch were on; kept outside the draft. Moscow
|
||||
// through `hass.config`, the page clock at 2026-10-21 18:00Z (the moon at
|
||||
// 23.9°, 79 % lit), the demo in English.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
|
||||
const MOSCOW = { latitude: 55.75, longitude: 37.62 };
|
||||
const NIGHT_SUN = { azimuth: 0, elevation: -12, rising: false };
|
||||
const 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.';
|
||||
const SHOWN = 'Now: shown (24° above the horizon, 79% lit).';
|
||||
const DAY_SUN = 'Now: not shown (the sun is 25° above the horizon; the moon shows once it is below 6°).';
|
||||
const checks = {};
|
||||
const report = {};
|
||||
const isMoonAsset = (url) => /\/moon-runtime-[^/?]*\.js(?:\?|$)/.test(url);
|
||||
|
||||
/**
|
||||
* A fresh page (the chunk is loaded once per page). `route` intercepts the
|
||||
* moon chunk on top of the harness's `route('**\/*')`.
|
||||
*/
|
||||
async function open({ moon = false, route = null } = {}) {
|
||||
const run = await launch({ width: 1000, height: 760 });
|
||||
run.moonRequests = [];
|
||||
run.page.on('request', (request) => { if (isMoonAsset(request.url())) run.moonRequests.push(request.url()); });
|
||||
if (route) await run.page.route('**/moon-runtime-*.js', route);
|
||||
await run.page.clock.setFixedTime(new Date('2026-10-21T18:00:00Z'));
|
||||
await pushHass(run.page, { config: MOSCOW, sun: NIGHT_SUN });
|
||||
await run.page.evaluate((moon) => window.__hpTest.setServerConfig((cfg) => {
|
||||
cfg.settings = { ...(cfg.settings || {}), bg_mode: 'static' };
|
||||
if (moon) cfg.settings.moon = true; else delete cfg.settings.moon;
|
||||
return cfg;
|
||||
}), moon);
|
||||
return run;
|
||||
}
|
||||
|
||||
const pushHass = (page, { config, sun }) => page.evaluate(async ({ config, sun }) => {
|
||||
const card = window.__card;
|
||||
card.hass = {
|
||||
...card.hass,
|
||||
...(config ? { config } : {}),
|
||||
states: sun
|
||||
? { ...card.hass.states, 'sun.sun': { entity_id: 'sun.sun', state: sun.elevation > 0 ? 'above_horizon' : 'below_horizon', attributes: sun } }
|
||||
: card.hass.states,
|
||||
};
|
||||
await card.updateComplete;
|
||||
}, { config, sun });
|
||||
|
||||
/** Open General settings; what the first rendered frame shows. */
|
||||
const openDialog = (page) => page.evaluate(async () => {
|
||||
const card = window.__card;
|
||||
card._openSettingsDialog();
|
||||
await card.updateComplete;
|
||||
const lines = card.renderRoot.querySelectorAll('hp-dialog [data-moon-status]');
|
||||
return {
|
||||
lines: lines.length,
|
||||
caption: card.renderRoot.querySelector('hp-dialog #gs-moon-caption')?.textContent.trim() ?? null,
|
||||
};
|
||||
});
|
||||
const line = (page) => page.evaluate(() => {
|
||||
const root = window.__card.renderRoot;
|
||||
const nodes = root.querySelectorAll('hp-dialog [data-moon-status]');
|
||||
const node = nodes[0] || null;
|
||||
return {
|
||||
count: nodes.length,
|
||||
reason: node?.dataset.moonStatus ?? null,
|
||||
text: node?.textContent.trim() ?? null,
|
||||
describedBy: root.querySelector('hp-dialog #gs-moon')?.getAttribute('aria-describedby') ?? null,
|
||||
inCaption: !!node?.closest('#gs-moon-caption'),
|
||||
live: !!node?.closest('[aria-live]'),
|
||||
saveDisabled: !!root.querySelector('hp-dialog [data-hp="dialog-confirm"]')?.disabled,
|
||||
};
|
||||
});
|
||||
const waitLine = (page, timeout = 2000) => page.waitForFunction(
|
||||
() => !!window.__card.renderRoot.querySelector('hp-dialog [data-moon-status]'), null, { timeout },
|
||||
).then(() => true, () => false);
|
||||
const closeDialog = (page) => page.evaluate(async () => {
|
||||
const dialog = window.__card.renderRoot.querySelector('hp-dialog[data-kind="settings"]');
|
||||
if (!dialog) return { closed: false, confirm: false };
|
||||
const result = await window.__hpTest.close(dialog);
|
||||
return { closed: result.closed, confirm: !!result.confirm };
|
||||
});
|
||||
|
||||
// ─── AC11 and AC12 (chunk already loaded by the View at night) ───
|
||||
{
|
||||
const { page, browser } = await open({ moon: true });
|
||||
const planMoon = await page.waitForFunction(
|
||||
() => window.__card.renderRoot.querySelector('.hp-moon.on')?.dataset.moonK, null, { timeout: 8000 },
|
||||
).then((handle) => handle.jsonValue(), () => null);
|
||||
const first = await openDialog(page);
|
||||
// AC12: the View loaded the chunk — the line is in the dialog's first frame.
|
||||
checks.ac12_preloadedLineInTheFirstFrame = first.lines === 1;
|
||||
const shown = await line(page);
|
||||
report.ac11 = { planMoon, first, shown };
|
||||
checks.ac11_shownText = shown.text === SHOWN && shown.reason === 'shown';
|
||||
checks.ac11_percentIsThePlanMoon = planMoon !== null && Math.round(Number(planMoon) * 100) === 79;
|
||||
checks.ac11_lineIsTheSecondCaptionLine = shown.inCaption && shown.describedBy === 'gs-moon-caption'
|
||||
&& !shown.live && first.caption === `${HINT}${SHOWN}`;
|
||||
checks.ac11_lineLeavesTheDraftClean = shown.saveDisabled;
|
||||
const toggled = [];
|
||||
for (let i = 0; i < 2; i++) {
|
||||
await page.evaluate(async () => {
|
||||
window.__card.renderRoot.querySelector('hp-dialog #gs-moon')?.click();
|
||||
await window.__card.updateComplete;
|
||||
});
|
||||
toggled.push(await line(page));
|
||||
}
|
||||
report.ac11.toggled = toggled;
|
||||
checks.ac11_switchDoesNotChangeTheText = toggled.every((state) => state.text === SHOWN && state.count === 1);
|
||||
const closed = await closeDialog(page);
|
||||
checks.ac11_closesWithoutAsking = closed.closed && !closed.confirm;
|
||||
|
||||
await pushHass(page, { sun: { azimuth: 200, elevation: 25.4, rising: false } });
|
||||
await openDialog(page);
|
||||
const day = await line(page);
|
||||
checks.ac11_daySunText = day.count === 1 && day.reason === 'day_sun' && day.text === DAY_SUN;
|
||||
await closeDialog(page);
|
||||
|
||||
await pushHass(page, { config: { unit_system: { length: 'km' } } });
|
||||
await openDialog(page);
|
||||
const noHome = await line(page);
|
||||
report.ac11.day = day;
|
||||
report.ac11.noHome = noHome;
|
||||
checks.ac11_noHomeReason = noHome.count === 1 && noHome.reason === 'no_home';
|
||||
await closeDialog(page);
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
// ─── AC12: the chunk arrives a second late ───
|
||||
{
|
||||
const delay = async (route) => { await new Promise((done) => setTimeout(done, 1000)); await route.fallback(); };
|
||||
const { page, browser, moonRequests } = await open({ route: delay });
|
||||
const first = await openDialog(page);
|
||||
checks.ac12_noLineWhileLoading = first.lines === 0 && first.caption === HINT;
|
||||
checks.ac12_lineArrives = await waitLine(page, 2000);
|
||||
const late = await line(page);
|
||||
report.ac12 = { first, late, requests: moonRequests.length };
|
||||
// The switch is off here: the dialog alone asked for the chunk, and the line
|
||||
// still says what the switch would show when on.
|
||||
checks.ac12_dialogLoadsTheChunkWithTheMoonOff = moonRequests.length === 1 && late.reason === 'shown'
|
||||
&& late.saveDisabled;
|
||||
await closeDialog(page);
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
// ─── AC12: opened while loading, closed, sun changed, opened again ───
|
||||
{
|
||||
const delay = async (route) => { await new Promise((done) => setTimeout(done, 1000)); await route.fallback(); };
|
||||
const { page, browser } = await open({ route: delay });
|
||||
await openDialog(page);
|
||||
const closed = await closeDialog(page);
|
||||
await pushHass(page, { sun: { azimuth: 180, elevation: 40, rising: false } });
|
||||
await openDialog(page);
|
||||
const arrived = await waitLine(page, 3000);
|
||||
await page.waitForTimeout(300);
|
||||
const again = await line(page);
|
||||
report.ac12.reopened = { closed, again };
|
||||
checks.ac12_reopenShowsOnlyTheSecondSnapshot = closed.closed && arrived && again.count === 1
|
||||
&& again.reason === 'day_sun';
|
||||
await closeDialog(page);
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
// ─── AC12: the chunk request is refused ───
|
||||
{
|
||||
const { page, browser } = await open({ route: (route) => route.abort() });
|
||||
const first = await openDialog(page);
|
||||
await page.waitForTimeout(800);
|
||||
const refused = await line(page);
|
||||
report.ac12.refused = { first, refused };
|
||||
checks.ac12_refusedLeavesNoLine = first.lines === 0 && refused.count === 0;
|
||||
// The dialog keeps working: a change enables Save and saves; Cancel closes.
|
||||
const saved = await page.evaluate(async () => {
|
||||
const card = window.__card;
|
||||
const root = card.renderRoot;
|
||||
root.querySelector('hp-dialog #gs-moon')?.click();
|
||||
await card.updateComplete;
|
||||
const save = root.querySelector('hp-dialog [data-hp="dialog-confirm"]');
|
||||
const enabled = !!save && !save.disabled;
|
||||
save?.click();
|
||||
const deadline = Date.now() + 4000;
|
||||
while (root.querySelector('hp-dialog[data-kind="settings"]') && Date.now() < deadline) {
|
||||
await new Promise((done) => setTimeout(done, 50));
|
||||
}
|
||||
return { enabled, closed: !root.querySelector('hp-dialog[data-kind="settings"]'), moon: card._serverCfg.settings.moon === true };
|
||||
});
|
||||
await openDialog(page);
|
||||
const cancelled = await page.evaluate(async () => {
|
||||
const dialog = window.__card.renderRoot.querySelector('hp-dialog[data-kind="settings"]');
|
||||
const result = await window.__hpTest.close(dialog, { via: 'cancel' });
|
||||
return { closed: result.closed, confirm: !!result.confirm };
|
||||
});
|
||||
report.ac12.saved = saved;
|
||||
report.ac12.cancelled = cancelled;
|
||||
checks.ac12_saveStillWorks = saved.enabled && saved.closed && saved.moon;
|
||||
checks.ac12_cancelStillWorks = cancelled.closed && !cancelled.confirm;
|
||||
console.log(JSON.stringify(report, null, 1));
|
||||
checkAll(checks);
|
||||
await finish(browser, checks);
|
||||
}
|
||||
@@ -2,6 +2,17 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
- The moon now appears with any background, not only "Follow the Sun": with a
|
||||
static background — the theme's or a colour of your own, for the whole
|
||||
installation or a single space — it stands in the same top-left corner behind
|
||||
the plan at dawn, dusk and night, in View, kiosk, the panel and the space
|
||||
card, while the background keeps its colour. If "Moon over the plan at dusk
|
||||
and night" was already on, the moon appears on static backgrounds after this
|
||||
update; switch it off in General settings › Sun and Moon to hide it. Under
|
||||
that switch a new line tells whether the moon is shown right now and, if not,
|
||||
why — daytime, the moon below 3°, new moon or no home location in Home
|
||||
Assistant ([#718](https://github.com/Matysh/houseplan-card/issues/718)).
|
||||
|
||||
## v1.79.0-beta.1 — 2026-09-30
|
||||
|
||||
- In 2.5D View the plan no longer moves: rooms, furniture, other decor and room
|
||||
|
||||
@@ -8,6 +8,17 @@
|
||||
|
||||
## Не выпущено
|
||||
|
||||
- Луна теперь видна при любом фоне, а не только на «Следует за Солнцем»: при
|
||||
статичном фоне — фоне темы или своём цвете, для всей установки или одного
|
||||
пространства — она стоит в том же левом верхнем углу за планом в сумерках и
|
||||
ночью, в просмотре, киоске, панели и карточке пространства, а фон сохраняет
|
||||
свой цвет. Если переключатель «Луна на плане в сумерках и ночью» уже был
|
||||
включён, после обновления луна появится и на статичном фоне; чтобы её убрать,
|
||||
выключите его в общих настройках, раздел «Солнце и Луна». Под переключателем
|
||||
появилась строка: видна ли луна сейчас, а если нет — почему (день, луна ниже
|
||||
3°, новолуние или в Home Assistant не заданы координаты дома)
|
||||
([#718](https://github.com/Matysh/houseplan-card/issues/718)).
|
||||
|
||||
## v1.79.0-beta.1 — 2026-09-30
|
||||
|
||||
- В объёмном 2.5D-виде план больше не сдвигается: комнаты, мебель, прочий декор
|
||||
|
||||
@@ -134,21 +134,26 @@ An older frontend ignores the field and shows Flat. An older backend preserves
|
||||
it through the unknown-settings policy. Full backup/import carries `settings`
|
||||
whole, so the value survives.
|
||||
|
||||
## Moon (#661)
|
||||
## Moon (#661, #718)
|
||||
|
||||
`settings.moon` is an optional global boolean: the moon on the "Follow the Sun"
|
||||
background for every space, user, device and kiosk. Only exact `true` switches
|
||||
it on; absence, `false` or anything else read as off. Saving `false` removes
|
||||
the key. There is no per-space moon: a space shows it only when its effective
|
||||
`bg_mode` is `daynight`. The backend accepts only a boolean
|
||||
`settings.moon` is an optional global boolean: the moon behind the plan at
|
||||
dawn, dusk and night for every space, user, device and kiosk, with any
|
||||
background (#718 — before it, only on "Follow the Sun"). Only exact `true`
|
||||
switches it on; absence, `false` or anything else read as off. Saving `false`
|
||||
removes the key. There is no per-space moon switch, and the effective
|
||||
`bg_mode` of a space is no longer a condition: a space with a static
|
||||
background, its own or the global one, shows the moon too. The backend accepts only a boolean
|
||||
(`vol.Optional("moon"): bool`), and the privacy-safe support projection copies
|
||||
only a validated boolean. New installations get `moon: true` in
|
||||
`DEFAULT_CONFIG`; an existing config has no key and keeps its look after the
|
||||
update (docs/SUN.md). "Reset to defaults" in General settings sets `true`. The
|
||||
store and model versions do not change; there is no migration.
|
||||
|
||||
An older frontend ignores the field and shows no moon. An older backend
|
||||
preserves it through the unknown-settings policy. Full backup/import carries
|
||||
An older frontend ignores the field and shows no moon; a #661 frontend with a
|
||||
#718 config shows it only on `daynight`, as before. #718 adds no field and no
|
||||
migration: an installation that already has `moon: true` with a static
|
||||
background sees the moon there after the update, and the same switch turns it
|
||||
off. An older backend preserves it through the unknown-settings policy. Full backup/import carries
|
||||
`settings` whole, so the value survives; a one-space export does not carry
|
||||
global settings.
|
||||
|
||||
|
||||
+86
-30
@@ -376,26 +376,26 @@ The wash lives where the Flat `.sunlayer` lives (floor group, above room fills
|
||||
and Glow) as `.sunlayer.iso-sunwash`, one `.iso-sunbeam[data-opening]` per lit
|
||||
window. Witness: `demo/smoke_iso_sun.mjs`.
|
||||
|
||||
## Moon — `settings.moon` (#661)
|
||||
## Moon — `settings.moon` (#661, any background since #718)
|
||||
|
||||
At dawn, dusk and night on the "Follow the Sun" background, the moon in its
|
||||
current phase stands in the top-left corner of the scene: a thin crescent, a
|
||||
half, a full disc. General settings › Sun and Moon › «Moon over the plan at
|
||||
dusk and night» switches it for the whole installation.
|
||||
At dawn, dusk and night, with any background, the moon in its current phase
|
||||
stands in the top-left corner of the scene: a thin crescent, a half, a full
|
||||
disc. General settings › Sun and Moon › «Moon over the plan at dusk and night»
|
||||
switches it for the whole installation; there is no per-space moon switch.
|
||||
|
||||
**When it is shown** — all at once, otherwise there is no moon:
|
||||
|
||||
- `settings.moon === true` (global; absent, `false` or anything else is off);
|
||||
- the effective `bg_mode` of the space is `daynight` — the moon lives in the
|
||||
four-phase environment, so a space with its own `static` has no moon, and
|
||||
there is no per-space moon switch;
|
||||
- the environment phase is `dawn`, `dusk` or `night` (the same
|
||||
`resolveDayCycle`, browser-clock fallback included);
|
||||
- the day-cycle phase is `dawn`, `dusk` or `night`: `resolveDayCycle(hass,
|
||||
now)`, whatever the background — with a valid `sun.sun` (`dayCycleSunOf`:
|
||||
finite azimuth and elevation, boolean `rising`) day is elevation ≥ 6°
|
||||
(exactly 6° is day), otherwise the browser-local clock, day 08:00–18:00. The
|
||||
effective `bg_mode` of the space is not a condition (#718 K1);
|
||||
- the topocentric altitude is ≥ 3° (`MOON_ELEVATION_MIN = RAY_ELEVATION_MIN`);
|
||||
- the illuminated fraction is ≥ 3 % (`MOON_MIN_ILLUMINATION`, about ±1.5 days
|
||||
around new moon);
|
||||
- a View surface: View, kiosk or `houseplan-space-card` (editors have no
|
||||
environment);
|
||||
- a View surface: View, kiosk, panel or `houseplan-space-card` (editors, the
|
||||
PDF export and the space dialog preview have no moon);
|
||||
- `hass.config.latitude/longitude` are finite numbers.
|
||||
|
||||
**Where the numbers come from.** Home Assistant publishes no moon altitude
|
||||
@@ -423,12 +423,29 @@ quantised to 0.01 only so the element changes when the fingerprint does. The
|
||||
dark side is the same art at 8 % opacity.
|
||||
|
||||
**Place, size, layer.** Fixed top-left corner, box `min(200px, 25cqmin)` of the
|
||||
scene (the environment is the size container), inset 5 % of the box; it does
|
||||
not move with pan or zoom and never takes the pointer. It is the last child of
|
||||
`.hp-day-cycle-env`: above the phase gradients and the sun glow, below the plan
|
||||
paper, rooms, devices, labels and UI. A plan that fills the scene covers the
|
||||
moon partly or entirely — that is the environment's norm, like the sun glow
|
||||
(owner decision 7).
|
||||
scene, inset 5 % of the box; it does not move with pan or zoom and never takes
|
||||
the pointer. Over "Follow the Sun" it is the last child of `.hp-day-cycle-env`:
|
||||
above the phase gradients and the sun glow, below the plan paper, rooms,
|
||||
devices, labels and UI. A plan that fills the scene covers the moon partly or
|
||||
entirely — that is the environment's norm, like the sun glow (owner
|
||||
decision 7).
|
||||
|
||||
**Static background (#718 K3).** No environment is created — no
|
||||
`.hp-day-cycle-env`, no `daycycle`/`phase-*` classes, no
|
||||
`.hp-paper-outline-svg`; the scene keeps the chosen colour or the theme's. The
|
||||
same `.hp-moon` element stands in its own layer `<div class="hp-moon-sky"
|
||||
aria-hidden="true">`, the first child of `.stage` (full card) or
|
||||
`.hp-static-stage` (space card) — where the environment would be. The layer is
|
||||
the whole scene (`position:absolute; inset:0; overflow:hidden;
|
||||
pointer-events:none; container-type:size`), so the box and place are the same;
|
||||
it has no `z-index`, `filter` or `will-change` and lies under the plan by DOM
|
||||
order (`.zoomwrap` and the space card's plan are `z-index:1`). The layer's
|
||||
`opacity` is the View weight of the #101 transition, as the environment's;
|
||||
editors have neither. Switching between `daynight` and `static` (a space tab,
|
||||
the background segment previewed in the open dialog, a config push) moves the
|
||||
element to its new parent in the same render, in its final state, on the same
|
||||
box — no flicker. The chunk renders and styles the layer; until it is here
|
||||
there is no layer.
|
||||
|
||||
**Movement.** Opacity only, 2 s on the background curve (`RAY_FADE_MS`),
|
||||
none under `prefers-reduced-motion`: rising through 3°, setting through it,
|
||||
@@ -439,13 +456,47 @@ the ticker keeps the last rendered inputs, and an equal fingerprint
|
||||
(`visible | k`) costs no render. A card that renders without a moon, leaves the
|
||||
page or is hidden drops or pauses its ticker.
|
||||
|
||||
**Weight.** Astronomy, art (≈ 9 KB gzip) and template are one lazy chunk,
|
||||
`moon-runtime-*`, loaded when the moon is switched on, the environment exists
|
||||
and it is not daytime; until it arrives there is no moon, a failed load is
|
||||
retried at most every 30 s through the exact-build loader of the isometric
|
||||
runtime. The initial View graph carries only the gate (`src/moon-gate.ts`).
|
||||
One element, no CSS filter and no `will-change`: the composited layer count
|
||||
does not change (`demo/smoke_daycycle_layer_budget.mjs`).
|
||||
With a static background the phase for the moon comes from the same
|
||||
`resolveDayCycle` (`moonSkyState` in `src/moon-gate.ts`), computed only while
|
||||
the moon is on and the surface is View. With `sun.sun` it follows the Home
|
||||
Assistant state updates the cards already re-render on. Without it the card
|
||||
keeps its 30 s clock ticker (`_syncDayCycleClock`, both cards) and re-renders
|
||||
only when the phase changes (`dayCycleClock`: the environment is compared by
|
||||
its whole fingerprint, the moon's sky by its phase), so the moon leaves at
|
||||
08:00 and comes at 18:00 without a state update. The moon switched off or an
|
||||
editor: no phase, no ticker.
|
||||
|
||||
**Weight.** Astronomy, art (≈ 9 KB gzip), template, the static-background
|
||||
layer and the status function are one lazy chunk, `moon-runtime-*`, loaded
|
||||
when the moon is switched on on a View surface and it is not daytime — with any
|
||||
background — and when General settings open (for the status line, by day and
|
||||
with the moon off too). One load per page through the gate's loader
|
||||
(`withMoon`): a chunk of another build is never installed, a failed load is
|
||||
retried at most every 30 s, and every caller meanwhile waits for the same load.
|
||||
The initial View graph carries only the gate (`src/moon-gate.ts`); the editor
|
||||
graph only the status line and its strings (`src/editors/moon-status.ts`) —
|
||||
`src/moon.ts` is in neither (`scripts/bundle-budget.mjs` refuses an overlap of
|
||||
the moon graph with the initial or the editor graph). One element, no CSS
|
||||
filter and no `will-change`: the composited layer count does not change, with
|
||||
either background (`demo/smoke_daycycle_layer_budget.mjs`).
|
||||
|
||||
**Status line (#718 K7).** Under the switch in General settings a second
|
||||
caption line, inside `aria-describedby`, no `aria-live`, anchored
|
||||
`data-moon-status="shown|no_home|day_sun|day_clock|low|new"`: «Now: shown (24°
|
||||
above the horizon, 79% lit).» or «Now: not shown (reason).», the first reason
|
||||
that holds — no home coordinates; day (by `sun.sun` with its elevation, or by
|
||||
the clock); the moon below 3°; under 3 % lit. It is judged once per opening on
|
||||
a snapshot taken when the dialog opens (`now`, `hass.config`, `sun.sun`), as
|
||||
if the switch were on — the moon no longer depends on the background, so one
|
||||
status serves the installation; the switch, «Reset» and the background segment
|
||||
do not change it. `moonStatus` in `src/moon.ts` decides «shown» with the same
|
||||
`moonShownAt` as the element (AC10 checks the equivalence every hour of a
|
||||
month), rounds to whole numbers and keeps a hidden reason's number below its
|
||||
threshold (2.6° reads «2°»). The status lives beside the draft, never in it:
|
||||
the line arriving leaves «Save» disabled. While the chunk loads, or when it
|
||||
failed, there is no line; a closed opening's result is dropped. The line
|
||||
belongs to the browser the dialog is open in — a wall tablet with another
|
||||
clock or time zone may differ.
|
||||
|
||||
**Limits (documented, not bugs).**
|
||||
|
||||
@@ -455,6 +506,8 @@ does not change (`demo/smoke_daycycle_layer_budget.mjs`).
|
||||
not follow the hemisphere (owner decision): the crescent is "vertical".
|
||||
- Earthshine is not modelled; the dark side is an 8 % silhouette for legibility.
|
||||
- The moon does not follow its azimuth — fixed corner of the scene.
|
||||
- One art for every background (owner decision, #718): on a mid-grey custom
|
||||
colour the disc has less contrast than on white or at night.
|
||||
- A dense layout (plan filling the screen) shows only the part of the disc in
|
||||
the margins.
|
||||
- Coordinates come from Home Assistant: an installation that kept the default
|
||||
@@ -504,11 +557,14 @@ saving General settings removes it.
|
||||
- `src/day-cycle-render.ts` — the shared constant environment layers and
|
||||
plan-outline variables for full and static cards.
|
||||
- `src/moon.ts`, `src/moon-runtime.ts`, `src/moon-gate.ts`,
|
||||
`src/moon-art.generated.ts` — the moon (#661): pure astronomy and mask, the
|
||||
lazy chunk with the element and its ticker, the initial-graph gate, the
|
||||
generated art (`node scripts/generate-moon-assets.mjs` from
|
||||
`assets/moon/houseplan-1.0.0`); unit-tested in `test/moon.test.mjs`,
|
||||
end-to-end in `demo/smoke_moon.mjs`.
|
||||
`src/moon-art.generated.ts` — the moon (#661, #718): pure astronomy, mask and
|
||||
status, the lazy chunk with the element, the static-background layer and the
|
||||
ticker, the initial-graph gate with the page-wide loader, the generated art
|
||||
(`node scripts/generate-moon-assets.mjs` from `assets/moon/houseplan-1.0.0`);
|
||||
`src/editors/moon-status.ts` — the General settings line. Unit-tested in
|
||||
`test/moon.test.mjs` and `test/moon-settings.test.mjs`, end-to-end in
|
||||
`demo/smoke_moon.mjs`, `demo/smoke_moon_static.mjs` and
|
||||
`demo/smoke_moon_status.mjs`.
|
||||
- `src/houseplan-card.ts` — the memoised wedge layer, four-phase background
|
||||
lifecycle, and both settings dialogs (compass dial included).
|
||||
- `src/space-render.ts` / `src/space-card.ts` — static-card environment and
|
||||
|
||||
+37
-8
@@ -1657,19 +1657,27 @@ choice. Shadows from trees, awnings or other building wings are not modelled.
|
||||
|
||||
### Moon
|
||||
|
||||
On the **Follow the sun** background, at dawn, dusk and night, the moon in its
|
||||
current phase stands in the top-left corner of the scene — a thin crescent, a
|
||||
half, a full disc. One switch in General settings turns it on: **Sun and Moon**
|
||||
→ **Moon over the plan at dusk and night**. It is on for new installations and
|
||||
off for upgraded ones until switched on: an update never changes how a plan
|
||||
looks.
|
||||
With any background — **Follow the sun**, the theme background or a colour of
|
||||
your own — at dawn, dusk and night the moon in its current phase stands in the
|
||||
top-left corner of the scene — a thin crescent, a half, a full disc. One switch
|
||||
in General settings turns it on: **Sun and Moon** → **Moon over the plan at dusk
|
||||
and night**. It is on for new installations and off for upgraded ones until
|
||||
switched on: an update never changes how a plan looks. If the switch was
|
||||
already on with a static background, the moon appears on that background after
|
||||
the update; the same switch removes it.
|
||||
|
||||
Dusk and night are decided as for the Follow the sun background: with a valid
|
||||
`sun.sun` by the sun's elevation (below 6°), without it by the browser clock
|
||||
(day is 08:00–18:00). A static background stays as it is: the chosen colour
|
||||
around the plan, no gradients and no outline glow — only the moon is added.
|
||||
|
||||
| Condition | Result |
|
||||
|---|---|
|
||||
| Daytime, moon below 3° above the horizon, or new moon (under 3 % lit) | No moon |
|
||||
| The moon rises above 3° or sets below it | Fades in/out over 2 seconds; immediately with reduced motion |
|
||||
| Phase | Changes continuously, every day; the lit side is always on the left, waning runs the same states backwards |
|
||||
| Space with its own static background | No moon — it belongs to the Follow the sun background |
|
||||
| Space with its own static background | The same moon: same place, size and phase; the background keeps its colour |
|
||||
| Changing the background, or a tab with another background | The moon does not flicker and stays in place |
|
||||
| The plan covers the corner of the scene | The moon is behind the plan: the part of the disc in the margins shows; the plan, devices and labels are always on top |
|
||||
| Editors | No moon |
|
||||
| Full card, kiosk, static space card | The same |
|
||||
@@ -1680,7 +1688,28 @@ integration or external data. A Home Assistant that kept the default location
|
||||
shows someone else's moon — as with `sun.sun`; a wrong tablet clock gives a
|
||||
wrong phase and moment. Position accuracy is about 1°, so crossing 3° may
|
||||
differ from ephemerides by a few minutes. The terminator tilt and earthshine
|
||||
are not modelled; the dark side is a faint silhouette so a crescent reads.
|
||||
are not modelled; the dark side is a faint silhouette so a crescent reads. One
|
||||
image serves every background: on a mid-grey colour of your own the moon has
|
||||
less contrast.
|
||||
|
||||
Under the switch a «Now: …» line says whether the moon is shown this minute
|
||||
and, if not, why — the first reason that holds:
|
||||
|
||||
| Line | When |
|
||||
|---|---|
|
||||
| Now: shown (24° above the horizon, 79% lit). | The moon is shown; altitude and illumination in whole numbers |
|
||||
| Now: not shown (the home location is not set in Home Assistant). | No home latitude and longitude — even by day |
|
||||
| Now: not shown (the sun is 25° above the horizon; the moon shows once it is below 6°). | Daytime by `sun.sun` |
|
||||
| Now: not shown (daytime by the clock, 08:00–18:00: Home Assistant has no sun.sun data). | Daytime by the browser clock |
|
||||
| Now: not shown (the moon is at 2°; it shows from 3° above the horizon). | The moon is below 3° |
|
||||
| Now: not shown (new moon, 2% lit; it shows from 3%). | Under 3 % lit |
|
||||
|
||||
The line is computed once when the dialog opens and tells what the switch would
|
||||
show when on: the switch itself, the background chosen in the dialog and the
|
||||
space settings do not change it. It belongs to the browser the dialog is open
|
||||
in: a wall tablet with another clock or time zone may show or hide its moon at
|
||||
another moment. While the moon's data loads, or if loading failed, there is no
|
||||
line — the general hint stays.
|
||||
|
||||
## 16. Robot vacuums
|
||||
|
||||
|
||||
+38
-8
@@ -1797,7 +1797,7 @@ WebP и безопасный SVG; лимит 2 МиБ относится к со
|
||||
|
||||
## 15. Солнце и Луна: фон, оконные лучи и луна
|
||||
|
||||
Фон и оконные лучи — две независимые функции; луна живёт на фоне. Фон **Следует за Солнцем** работает без компаса:
|
||||
Фон, оконные лучи и луна — независимые функции; луна стоит за планом при любом фоне. Фон **Следует за Солнцем** работает без компаса:
|
||||
он использует корректные данные `sun.sun`, а при их отсутствии автоматически
|
||||
переходит на локальные часы браузера. Направление севера и `sun.sun` обязательны
|
||||
только для оконных лучей. Общие значения можно переопределить в конкретном
|
||||
@@ -1857,19 +1857,28 @@ WebP и безопасный SVG; лимит 2 МиБ относится к со
|
||||
|
||||
### Луна
|
||||
|
||||
При фоне **Следует за Солнцем** в сумерках (утренних и вечерних) и ночью в левом
|
||||
верхнем углу сцены видна луна в текущей фазе — тонкий серп, половина, полная.
|
||||
Включается одним переключателем в общих настройках, раздел **Солнце и Луна** →
|
||||
**Луна на плане в сумерках и ночью**. У новых установок переключатель включён,
|
||||
у обновлённых старых — выключен, пока его не включат: обновление не меняет вид
|
||||
плана.
|
||||
При любом фоне — **Следует за Солнцем**, фоне темы или своём цвете — в сумерках
|
||||
(утренних и вечерних) и ночью в левом верхнем углу сцены видна луна в текущей
|
||||
фазе — тонкий серп, половина, полная. Включается одним переключателем в общих
|
||||
настройках, раздел **Солнце и Луна** → **Луна на плане в сумерках и ночью**. У
|
||||
новых установок переключатель включён, у обновлённых старых — выключен, пока его
|
||||
не включат: обновление не меняет вид плана. Если переключатель уже был включён,
|
||||
а фон статичный, после обновления луна появится и на нём; убрать её — тем же
|
||||
переключателем.
|
||||
|
||||
Сумерки и ночь определяются так же, как у фона «Следует за Солнцем»: при
|
||||
корректном `sun.sun` — по высоте Солнца (ниже 6°), без него — по часам
|
||||
браузера (день 08:00–18:00). Статичный фон при этом не меняется: вокруг плана
|
||||
остаётся выбранный цвет, градиентов и свечения контура нет, добавляется только
|
||||
луна.
|
||||
|
||||
| Условие | Результат |
|
||||
|---|---|
|
||||
| День, луна ниже 3° над горизонтом или новолуние (освещено меньше 3 %) | Луны нет |
|
||||
| Луна поднимается выше 3° или уходит ниже | Плавно появляется/исчезает за 2 секунды; при системном уменьшении движения — сразу |
|
||||
| Фаза | Меняется непрерывно, каждый день; освещённая сторона всегда слева, убывание идёт теми же состояниями в обратном порядке |
|
||||
| Пространство с собственным статическим фоном | Луны нет — она часть фона «Следует за Солнцем» |
|
||||
| Пространство с собственным статическим фоном | Луна та же: то же место, размер и фаза, фон — выбранный цвет |
|
||||
| Смена фона или переход на вкладку с другим фоном | Луна не мигает и остаётся на месте |
|
||||
| План закрывает угол сцены | Луна за планом: видна та часть диска, что в полях; план, устройства и подписи всегда поверх неё |
|
||||
| Редакторы | Луны нет |
|
||||
| Полная карточка, киоск, статическая карточка пространства | Одинаково |
|
||||
@@ -1881,6 +1890,27 @@ WebP и безопасный SVG; лимит 2 МиБ относится к со
|
||||
появления. Точность положения около 1°: момент пересечения 3° может разойтись с
|
||||
эфемеридами на несколько минут. Наклон терминатора и пепельный свет не
|
||||
воспроизводятся, тёмная сторона — едва заметный силуэт для читаемости серпа.
|
||||
Картинка одна для всех фонов: на среднем сером своём цвете луна контрастнее не
|
||||
станет.
|
||||
|
||||
Под переключателем — строка «Сейчас: …»: видна ли луна в эту минуту, а если нет,
|
||||
то почему — первая по порядку причина:
|
||||
|
||||
| Строка | Когда |
|
||||
|---|---|
|
||||
| Сейчас: показывается (на 24° над горизонтом, освещено 79 %). | Луна видна; высота и освещённость округлены до целых |
|
||||
| Сейчас: не показывается (в Home Assistant не заданы координаты дома). | Нет широты и долготы дома — даже днём |
|
||||
| Сейчас: не показывается (Солнце на 25° над горизонтом, луна видна, когда оно ниже 6°). | День по `sun.sun` |
|
||||
| Сейчас: не показывается (день по часам, 08:00–18:00: в Home Assistant нет данных sun.sun). | День по часам браузера |
|
||||
| Сейчас: не показывается (луна на высоте 2°, видна от 3° над горизонтом). | Луна ниже 3° |
|
||||
| Сейчас: не показывается (новолуние, освещено 2 %, видна от 3 %). | Освещено меньше 3 % |
|
||||
|
||||
Строка считается один раз при открытии диалога и говорит, что было бы при
|
||||
включённом переключателе: положение тумблера, выбор фона в диалоге и
|
||||
настройки пространств на неё не влияют. Она относится к браузеру, в котором
|
||||
открыт диалог: если у настенного планшета другие часы или часовой пояс, его
|
||||
луна может появиться или исчезнуть в другой момент. Пока данные луны
|
||||
загружаются или если загрузка не удалась, строки нет — остаётся общее пояснение.
|
||||
|
||||
## 16. Роботы-пылесосы
|
||||
|
||||
|
||||
@@ -16,8 +16,8 @@ to prove that the Node witness actually kills it.
|
||||
| Paint, cascade and layer composition | 26 | The invariant depends on computed CSS, SVG paint, clipping, stacking or pixels produced by Chromium. |
|
||||
| Pointer geometry and trusted interaction | 45 | The invariant depends on hit testing, pointer capture, touch/keyboard dispatch or live DOM geometry. |
|
||||
| Responsive DOM layout | 36 | The invariant depends on measured element boxes, responsive breakpoints, native/HA dialog shells or focusable target size. |
|
||||
| Custom-element and HA browser lifecycle | 85 | The invariant crosses Lit/custom-element lifecycle, browser storage/events, lazy loading or a complete HA-card state transition. |
|
||||
| **Total** | **200 / 200** | Growth above the cap fails `mutation-gate --check`. |
|
||||
| Custom-element and HA browser lifecycle | 86 | The invariant crosses Lit/custom-element lifecycle, browser storage/events, lazy loading or a complete HA-card state transition. |
|
||||
| **Total** | **201 / 200** | Growth above the cap fails `mutation-gate --check`. |
|
||||
|
||||
## Measured effect
|
||||
|
||||
@@ -242,6 +242,7 @@ The invariant crosses Lit/custom-element lifecycle, browser storage/events, lazy
|
||||
- `locale-failure-toast-dropped`
|
||||
- `marker-reject-keeps-optimistic-candidate`
|
||||
- `marker-rollback-keeps-enqueue-time-revision`
|
||||
- `moon-static-clock-tick-off`
|
||||
- `namespace-loader-returns-english`
|
||||
- `near-axis-authoring-snap-bypassed`
|
||||
- `onboarding-loader-skips-namespace-ensure`
|
||||
|
||||
@@ -741,6 +741,11 @@ export function assertBundleBudget(
|
||||
if (manifest.initialViewFiles.some((path) => manifest.lazyMoonFiles.includes(path))) {
|
||||
throw new Error('initial View graph overlaps lazy moon graph');
|
||||
}
|
||||
// #718 K9: General settings reach the moon's status through the gate's
|
||||
// loader only; a static import would pull its astronomy into the editor.
|
||||
if (manifest.lazyEditorFiles.some((path) => manifest.lazyMoonFiles.includes(path))) {
|
||||
throw new Error('lazy editor graph overlaps lazy moon graph');
|
||||
}
|
||||
// #474: designer furniture artwork is lazy; a static import anywhere in the
|
||||
// View graph would pull ~10 KB gzip back into the initial graph silently.
|
||||
if (!manifest.lazyFurnitureArtFiles?.length) {
|
||||
|
||||
@@ -6302,8 +6302,8 @@ const MUTANT_DEFINITIONS = [
|
||||
because: '#661 C7: the moon chunk is not loaded by day — the initial View graph pays only the gate',
|
||||
patches: [{
|
||||
file: 'src/moon-gate.ts',
|
||||
find: " if (!runtime && state.phase !== 'day' && Date.now() >= nextAttempt) {",
|
||||
replace: ' if (!runtime && Date.now() >= nextAttempt) {',
|
||||
find: " if (!runtime && state.phase !== 'day') withMoon(() => host.requestUpdate());",
|
||||
replace: ' if (!runtime) withMoon(() => host.requestUpdate());',
|
||||
}],
|
||||
},
|
||||
{
|
||||
@@ -6322,6 +6322,58 @@ const MUTANT_DEFINITIONS = [
|
||||
replace: " + '.hp-moon{--hp-moon-box:min(200px,25cqmin);position:absolute;z-index:2;'",
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'moon-static-sky-off',
|
||||
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
|
||||
+ '&& node --test --test-name-pattern="#718" test/moon.test.mjs',
|
||||
because: '#718 AC15/AC1: a static background — global or the space\'s own — shows the moon in its '
|
||||
+ 'own sky layer; back to «no environment, no moon» it would give nothing again',
|
||||
patches: [{
|
||||
file: 'src/moon-gate.ts',
|
||||
find: ' return !daynight && viewWeight > 0 && moonOn(settings) ? resolveDayCycle(hass, now) : null;',
|
||||
replace: ' return daynight && viewWeight > 0 && moonOn(settings) ? resolveDayCycle(hass, now) : null;',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'moon-static-clock-tick-off',
|
||||
guard: 'node demo/smoke_moon_static.mjs',
|
||||
because: '#718 AC3/K5: without sun.sun a static background has only the card\'s 30 s clock ticker '
|
||||
+ 'to reach 18:00 and 08:00; not armed, the moon never rises by the clock in the real card '
|
||||
+ '(page clock, UTC context, no state update), which only the browser run observes',
|
||||
patches: [{
|
||||
file: 'src/houseplan-card.ts',
|
||||
find: ' const [state, key] = dayCycleClock(this._dayCycleState(), this._moonSkyState());\n'
|
||||
+ ' this._dayCycleClockKey = key;\n const needsTimer',
|
||||
replace: ' const [state, key] = dayCycleClock(this._dayCycleState(), null);\n'
|
||||
+ ' this._dayCycleClockKey = key;\n const needsTimer',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'moon-status-order',
|
||||
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
|
||||
+ '&& node --test --test-name-pattern="#718" test/moon.test.mjs',
|
||||
because: '#718 AC9/K7: without home coordinates the status says so even by day — the day reason '
|
||||
+ 'checked first would name the sun of a home the card cannot place',
|
||||
patches: [{
|
||||
file: 'src/moon.ts',
|
||||
find: " if (!sky.home) return { reason: 'no_home' };\n"
|
||||
+ " if (sky.phase === 'day') return sky.source === 'sun' ? { reason: 'day_sun', sun: Math.round(sky.sun ?? 0) } : { reason: 'day_clock' };\n",
|
||||
replace: " if (sky.phase === 'day') return sky.source === 'sun' ? { reason: 'day_sun', sun: Math.round(sky.sun ?? 0) } : { reason: 'day_clock' };\n"
|
||||
+ " if (!sky.home) return { reason: 'no_home' };\n",
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'moon-status-clamp-off',
|
||||
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
|
||||
+ '&& node --test --test-name-pattern="#718" test/moon.test.mjs',
|
||||
because: '#718 AC9/K7: a hidden reason never rounds up to its threshold — 2.6° or 2.6 % read «2», '
|
||||
+ 'not «at 3°, shows from 3°»',
|
||||
patches: [{
|
||||
file: 'src/moon.ts',
|
||||
find: " return sky.altitude < MOON_ELEVATION_MIN ? { reason: 'low', alt: Math.min(alt, 2) } : { reason: 'new', pct: Math.min(pct, 2) };",
|
||||
replace: " return sky.altitude < MOON_ELEVATION_MIN ? { reason: 'low', alt } : { reason: 'new', pct };",
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'moon-validation-accepts-anything',
|
||||
guard: 'python3 -m pytest tests_backend/test_settings_moon.py -q -p no:cacheprovider',
|
||||
|
||||
@@ -42,11 +42,16 @@ export const SMOKE_LINKS = [
|
||||
symbols: [
|
||||
'moonLayer', 'renderMoon', 'moonTick', 'moonView', 'moonShownAt', 'moonPhasePath',
|
||||
'moonPosition', 'moonIllumination', 'moonFingerprint', 'renderDayCycleEnvironment',
|
||||
'withMoon', 'moonSkyState', 'dayCycleClock', 'renderMoonSky', '_moonSkyState', '_dayCycleClock',
|
||||
'moonStatus', 'moonStatusOf', 'openMoonStatus', 'moonStatusText',
|
||||
],
|
||||
smokes: ['smoke_moon.mjs', 'smoke_daycycle_layer_budget.mjs'],
|
||||
smokes: ['smoke_moon.mjs', 'smoke_moon_static.mjs', 'smoke_moon_status.mjs', 'smoke_daycycle_layer_budget.mjs'],
|
||||
because: '#661: the moon is observed only as `.hp-moon` in the production bundle — the lazy '
|
||||
+ 'chunk arriving at night, the 30 s ticker and state updates carrying it through 3° with the '
|
||||
+ '2 s fade, the plan painting over it pixel for pixel, and no extra composited layer (CDP); '
|
||||
+ '#718: the same over a static background as `.hp-moon-sky` (the clock ticker at 08:00 and '
|
||||
+ '18:00, tab and background switches, the #101 View weight, houseplan-space-card) and the '
|
||||
+ 'General settings line as `[data-moon-status]` (delayed, refused and preloaded chunk); '
|
||||
+ 'no smoke names the functions',
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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 }),
|
||||
})}`,
|
||||
})}
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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">
|
||||
|
||||
@@ -304,6 +304,12 @@ test('bundle manifest separates static initial graph from dynamic editor graph',
|
||||
// lazy moon graph — the builder subtracts initial) is refused by name.
|
||||
assert.throws(() => assertBundleBudget({ ...manifest, lazyMoonFiles: [] }, 1_000_000, undefined, ...lazyCeilings),
|
||||
/bundle has no lazy moon graph/);
|
||||
// #718 AC13: the status line reaches the chunk through the gate; the editor
|
||||
// graph never holds the moon's own files.
|
||||
assert.equal(manifest.lazyEditorFiles.some((path) => manifest.lazyMoonFiles.includes(path)), false);
|
||||
assert.throws(() => assertBundleBudget({
|
||||
...manifest, lazyEditorFiles: [...manifest.lazyEditorFiles, ...manifest.lazyMoonFiles],
|
||||
}, 1_000_000, undefined, ...lazyCeilings), /lazy editor graph overlaps lazy moon graph/);
|
||||
});
|
||||
|
||||
test('#486 Rollup names both stable entries explicitly', async () => {
|
||||
|
||||
@@ -446,7 +446,7 @@ test('sun-ray golden requires browser-painted light from a state-only sun entity
|
||||
assert.ok(scenario);
|
||||
const fixture = prepareGoldenFixture(scenario);
|
||||
const space = fixture.config.spaces.find((item) => item.id === scenario.space);
|
||||
assert.equal(GOLDEN_MATRIX_VERSION, 69);
|
||||
assert.equal(GOLDEN_MATRIX_VERSION, 70);
|
||||
assert.equal(space.settings.sun_rays, true);
|
||||
assert.equal(scenario.northDeg, 90,
|
||||
'the sign-sensitive golden must keep a non-zero north direction');
|
||||
@@ -940,7 +940,7 @@ test('issue 570 Stage 4 reuses the historical iso goldens for visual handoff cov
|
||||
|
||||
test('#673 Stage 6 designer acceptance scenes are canonical golden entries', () => {
|
||||
const ids = STAGE6_ACCEPTANCE_SCENARIOS.map((scenario) => scenario.id);
|
||||
assert.equal(GOLDEN_MATRIX_VERSION, 69);
|
||||
assert.equal(GOLDEN_MATRIX_VERSION, 70);
|
||||
assert.deepEqual(ids, [
|
||||
'isometric-stage6-light-lightfloor',
|
||||
'isometric-stage6-light-darkfloor',
|
||||
@@ -1244,3 +1244,31 @@ test('#661 AC7 moon scenes: fixed home and clock, moon on, the old day-cycle fra
|
||||
assert.equal(prepareGoldenFixture(scenario).config.settings?.moon, undefined, id);
|
||||
}
|
||||
});
|
||||
|
||||
test('#718 AC8 static-background moon scenes: the chosen colour, the sky layer, the dialog waits for the line', () => {
|
||||
const byId = (id) => GOLDEN_SCENARIOS.find((scenario) => scenario.id === id);
|
||||
const white = byId('static-bg-moon-gibbous-white-light');
|
||||
const dark = byId('static-bg-moon-crescent-south-dark');
|
||||
assert.deepEqual(white.moon, { clock: '2026-10-21T18:00:00Z', latitude: 55.75, longitude: 37.62, k: '0.79' });
|
||||
assert.deepEqual(dark.moon, { clock: '2026-10-14T09:00:00Z', latitude: -33.87, longitude: 151.21, k: '0.14' });
|
||||
for (const [scenario, theme, color] of [[white, 'light', '#ffffff'], [dark, 'dark', '#202126']]) {
|
||||
assert.equal(scenario.capture, 'stage');
|
||||
assert.equal(scenario.theme, theme);
|
||||
assert.equal(scenario.bgMode, 'static');
|
||||
const fixture = prepareGoldenFixture(scenario);
|
||||
assert.equal(fixture.config.settings.moon, true);
|
||||
const space = fixture.config.spaces.find((item) => item.id === scenario.space);
|
||||
assert.equal(space.settings.bg_mode, 'static');
|
||||
assert.equal(space.settings.bg_color, color);
|
||||
}
|
||||
// The harness checks the parent by background and waits for the status line
|
||||
// in both General settings frames before the screenshot.
|
||||
const harness = readFileSync(new URL('../demo/golden/harness.mjs', import.meta.url), 'utf8');
|
||||
assert.match(harness, /scenario\.bgMode === 'daynight'\s+\? card\.renderRoot\.querySelector\('\.hp-day-cycle-env'\)\s+: card\.renderRoot\.querySelector\('\.hp-moon-sky'\)/);
|
||||
for (const dialog of ['general-color', 'general-help']) {
|
||||
const branch = harness.slice(harness.indexOf(`scenario.dialog === '${dialog}'`));
|
||||
assert.match(branch.slice(0, 200), /await moonStatusSettled\(card\);/, dialog);
|
||||
}
|
||||
assert.match(harness, /hp-dialog \[data-moon-status\]/);
|
||||
assert.match(harness, /innerWidth <= 390/);
|
||||
});
|
||||
|
||||
+108
-4
@@ -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' });
|
||||
});
|
||||
|
||||
+174
-11
@@ -1,6 +1,8 @@
|
||||
// #661: the moon over the "Follow the Sun" background — astronomy (AC1),
|
||||
// visibility rules (AC2), phase mask (AC3), fingerprint and ticker (AC4) and
|
||||
// the designer pack gate. Pure: test-build modules, no browser.
|
||||
// the designer pack gate. #718: the moon with any background — the status
|
||||
// function (AC9), its equivalence with the element (AC10) and the gate of the
|
||||
// static background's sky layer (AC15). Pure: test-build modules, no browser.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
@@ -8,11 +10,16 @@ import { readFileSync } from 'node:fs';
|
||||
import { nothing } from 'lit';
|
||||
import {
|
||||
MOON_ELEVATION_MIN, MOON_MIN_ILLUMINATION, MOON_R, MOON_SHARP_FROM,
|
||||
moonFingerprint, moonIllumination, moonPhasePath, moonPosition, moonShownAt, moonView,
|
||||
moonFingerprint, moonIllumination, moonPhasePath, moonPosition, moonShownAt, moonStatus, moonStatusOf,
|
||||
moonView,
|
||||
} from '../test-build/moon.js';
|
||||
import { MOON_TICK_MS, moonTick, renderMoon } from '../test-build/moon-runtime.js';
|
||||
import { moonLayer } from '../test-build/moon-gate.js';
|
||||
import { RAY_ELEVATION_MIN, RAY_FADE_MS, bgModeOf, resolveDayCycle } from '../test-build/sun.js';
|
||||
import { MOON_TICK_MS, moonTick, renderMoon, renderMoonSky } from '../test-build/moon-runtime.js';
|
||||
import { dayCycleClock, moonLayer, moonSkyState } from '../test-build/moon-gate.js';
|
||||
import { moonStatusText } from '../test-build/editors/moon-status.js';
|
||||
import { subst } from '../test-build/logic.js';
|
||||
import {
|
||||
RAY_ELEVATION_MIN, RAY_FADE_MS, bgModeOf, dayCycleFingerprint, dayCyclePhaseFromSun, resolveDayCycle, sunStateOf,
|
||||
} from '../test-build/sun.js';
|
||||
import { renderDayCycleEnvironment } from '../test-build/day-cycle-render.js';
|
||||
import { moonArtFromSvg, validateMoonPack } from '../scripts/generate-moon-assets.mjs';
|
||||
|
||||
@@ -76,7 +83,7 @@ test('#661 AC2: visibility thresholds on both sides', () => {
|
||||
assert.equal(moonShownAt('day', 45, 1), false);
|
||||
});
|
||||
|
||||
test('#661 AC2: only an explicit true, finite home coordinates and an environment show the moon', () => {
|
||||
test('#661 AC2: only an explicit true and finite home coordinates show the moon', () => {
|
||||
const at = new Date('2026-10-21T18:00:00Z');
|
||||
assert.equal(moonView(ON, 'night', MOSCOW, at).visible, true);
|
||||
for (const settings of [undefined, null, {}, { moon: false }, { moon: 'true' }, { moon: 1 }]) {
|
||||
@@ -87,13 +94,11 @@ test('#661 AC2: only an explicit true, finite home coordinates and an environmen
|
||||
assert.equal(moonView(ON, 'night', config, at).visible, false, JSON.stringify(config));
|
||||
}
|
||||
assert.equal(moonView(ON, 'day', MOSCOW, at).visible, false);
|
||||
// bg_mode static — global or per space — leaves no environment, so no moon.
|
||||
// #718 AC15: a static background no longer means «no moon» — its own sky
|
||||
// layer shows it (inverted in the #718 AC15 tests below); here only the
|
||||
// switch and the host still refuse it.
|
||||
const host = fakeHost(MOSCOW);
|
||||
const night = { 'sun.sun': { attributes: { azimuth: 0, elevation: -12, rising: false } } };
|
||||
for (const [global, space] of [[{ ...ON, bg_mode: 'static' }, {}], [ON, { bg_mode: 'static' }]]) {
|
||||
const state = bgModeOf(global, space) === 'daynight' ? resolveDayCycle({ states: night }, at) : null;
|
||||
assert.equal(moonLayer(host, global, state), nothing);
|
||||
}
|
||||
assert.equal(moonLayer(host, { moon: false }, resolveDayCycle({ states: night }, at)), nothing);
|
||||
assert.equal(moonLayer(undefined, ON, resolveDayCycle({ states: night }, at)), nothing);
|
||||
});
|
||||
@@ -237,6 +242,127 @@ test('#661 pack: generated art is fresh, and the gate refuses what the brief for
|
||||
assert.throws(() => moonArtFromSvg(`${root}<path d="${'M0 0'.repeat(9000)}"/></svg>`), /larger/);
|
||||
});
|
||||
|
||||
/** The UX table of #718, written out here: the oracle is the specification, not the dictionary. */
|
||||
const UX = {
|
||||
en: {
|
||||
shown: ({ alt, pct }) => `Now: shown (${alt}° above the horizon, ${pct}% lit).`,
|
||||
day_sun: ({ sun }) => `Now: not shown (the sun is ${sun}° above the horizon; the moon shows once it is below 6°).`,
|
||||
low: ({ alt }) => `Now: not shown (the moon is at ${alt}°; it shows from 3° above the horizon).`,
|
||||
new: ({ pct }) => `Now: not shown (new moon, ${pct}% lit; it shows from 3%).`,
|
||||
},
|
||||
ru: {
|
||||
shown: ({ alt, pct }) => `Сейчас: показывается (на ${alt}° над горизонтом, освещено ${pct}\u00a0%).`,
|
||||
day_sun: ({ sun }) => `Сейчас: не показывается (Солнце на ${sun}° над горизонтом, луна видна, когда оно ниже 6°).`,
|
||||
low: ({ alt }) => `Сейчас: не показывается (луна на высоте ${alt}°, видна от 3° над горизонтом).`,
|
||||
new: ({ pct }) => `Сейчас: не показывается (новолуние, освещено ${pct}\u00a0%, видна от 3\u00a0%).`,
|
||||
},
|
||||
};
|
||||
const dictionaries = Object.fromEntries(['en', 'ru'].map((lang) => [lang,
|
||||
JSON.parse(readFileSync(new URL(`../src/i18n/settings/${lang}.json`, import.meta.url), 'utf8'))]));
|
||||
const stFor = (lang) => (key, vars) => subst(dictionaries[lang][key], vars);
|
||||
|
||||
test('#718 AC9: the status on ready numbers — the first reason, rounded and clamped', () => {
|
||||
const sunPhase = (elevation) => dayCyclePhaseFromSun({ elevation, rising: false });
|
||||
const rows = [
|
||||
// home, phase, source, sun, altitude, fraction → reason, numbers, texts (minus U+2212)
|
||||
[false, 'night', 'clock', null, 30, 0.5, { reason: 'no_home' }],
|
||||
[false, 'day', 'sun', 40, 30, 0.5, { reason: 'no_home' }],
|
||||
[true, sunPhase(25.4), 'sun', 25.4, 30, 0.5, { reason: 'day_sun', sun: 25 }, { sun: '25' }],
|
||||
[true, sunPhase(6.0), 'sun', 6.0, 30, 0.5, { reason: 'day_sun', sun: 6 }, { sun: '6' }],
|
||||
[true, sunPhase(5.9), 'sun', 5.9, 30, 0.5, { reason: 'shown', alt: 30, pct: 50 }, { alt: '30', pct: '50' }],
|
||||
[true, 'day', 'clock', null, 30, 0.5, { reason: 'day_clock' }],
|
||||
[true, 'night', 'clock', null, -12.3, 0.5, { reason: 'low', alt: -12 }, { alt: '\u221212' }],
|
||||
[true, 'night', 'clock', null, 2.6, 0.5, { reason: 'low', alt: 2 }, { alt: '2' }],
|
||||
[true, 'night', 'clock', null, -0.4, 0.5, { reason: 'low', alt: -0 }, { alt: '0' }],
|
||||
[true, 'night', 'clock', null, -3, 0.01, { reason: 'low', alt: -3 }, { alt: '\u22123' }],
|
||||
[true, 'night', 'clock', null, 30.6, 0.0163, { reason: 'new', pct: 2 }, { pct: '2' }],
|
||||
[true, 'night', 'clock', null, 30, 0.026, { reason: 'new', pct: 2 }, { pct: '2' }],
|
||||
[true, 'night', 'clock', null, 3.0, 0.03, { reason: 'shown', alt: 3, pct: 3 }, { alt: '3', pct: '3' }],
|
||||
[true, 'night', 'clock', null, 34.4, 0.618, { reason: 'shown', alt: 34, pct: 62 }, { alt: '34', pct: '62' }],
|
||||
];
|
||||
assert.equal(sunPhase(25.4), 'day');
|
||||
assert.equal(sunPhase(6.0), 'day', 'exactly 6° is day');
|
||||
assert.equal(sunPhase(5.9), 'dusk');
|
||||
for (const [home, phase, source, sun, altitude, fraction, expected, shown] of rows) {
|
||||
const label = JSON.stringify({ home, phase, source, sun, altitude, fraction });
|
||||
const status = moonStatusOf({ home, phase, source, sun, altitude, fraction });
|
||||
assert.deepEqual(status, expected, label);
|
||||
if (shown) {
|
||||
for (const lang of ['en', 'ru']) {
|
||||
assert.equal(moonStatusText(status, stFor(lang)), UX[lang][status.reason](shown), `${lang} ${label}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
// The two literal reasons, exactly as the table has them.
|
||||
assert.equal(moonStatusText({ reason: 'no_home' }, stFor('ru')),
|
||||
'Сейчас: не показывается (в Home Assistant не заданы координаты дома).');
|
||||
assert.equal(moonStatusText({ reason: 'day_clock' }, stFor('en')),
|
||||
'Now: not shown (daytime by the clock, 08:00\u201318:00: Home Assistant has no sun.sun data).');
|
||||
});
|
||||
|
||||
test('#718 AC10: «shown» exactly when the element is visible — every hour of October, two homes', () => {
|
||||
const suns = [-12, 0, 5.9, 6, 40]
|
||||
.map((elevation) => ({ 'sun.sun': { attributes: { azimuth: 180, elevation, rising: false } } }))
|
||||
.concat([{}]);
|
||||
let visibleCount = 0;
|
||||
let total = 0;
|
||||
for (const place of [MOSCOW, SYDNEY]) {
|
||||
for (let hour = 0; hour < 31 * 24; hour++) {
|
||||
const now = new Date(Date.UTC(2026, 9, 1) + hour * 3_600_000);
|
||||
for (const states of suns) {
|
||||
const hass = { config: place, states };
|
||||
const state = resolveDayCycle(hass, now);
|
||||
const status = moonStatus(place, state, sunStateOf(hass)?.elevation ?? null, now);
|
||||
const view = moonView({ moon: true }, state.phase, place, now);
|
||||
const label = `${now.toISOString()} ${place.latitude} ${JSON.stringify(states)}`;
|
||||
assert.equal(status.reason === 'shown', view.visible, label);
|
||||
// K8: the shown per cent is the element's data-moon-k × 100.
|
||||
if (view.visible) assert.equal(status.pct, Math.round(view.k * 100), label);
|
||||
visibleCount += view.visible ? 1 : 0;
|
||||
total++;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.ok(visibleCount > 500 && visibleCount < total - 500, `${visibleCount} of ${total}: both answers occur`);
|
||||
// No home: never shown, whatever the sky.
|
||||
assert.equal(moonStatus({}, { phase: 'night', source: 'clock' }, null, new Date('2026-10-21T18:00:00Z')).reason, 'no_home');
|
||||
});
|
||||
|
||||
test('#718 K5: the clock ticker compares the environment whole, the moon’s sky by its phase', () => {
|
||||
const at = (hour, minute) => resolveDayCycle({}, hour * 60 + minute);
|
||||
assert.equal(dayCycleClock(at(18, 5), null)[1], dayCycleFingerprint(at(18, 5)));
|
||||
assert.notEqual(dayCycleClock(at(18, 5), null)[1], dayCycleClock(at(18, 6), null)[1], 'the environment’s sun moves');
|
||||
assert.equal(dayCycleClock(null, at(18, 5))[1], dayCycleClock(null, at(20, 59))[1], 'a phase is one key');
|
||||
assert.notEqual(dayCycleClock(null, at(17, 59))[1], dayCycleClock(null, at(18, 0))[1], '18:00 is a new key');
|
||||
assert.equal(dayCycleClock(null, at(18, 5))[0].phase, 'dusk');
|
||||
assert.deepEqual(dayCycleClock(null, null), [null, '']);
|
||||
});
|
||||
|
||||
const NIGHT_HASS = { config: MOSCOW, states: { 'sun.sun': { attributes: { azimuth: 0, elevation: -12, rising: false } } } };
|
||||
const DAY_HASS = { config: MOSCOW, states: { 'sun.sun': { attributes: { azimuth: 180, elevation: 40, rising: false } } } };
|
||||
const STATIC_ON = { moon: true, bg_mode: 'static' };
|
||||
|
||||
// Runs before the #661 C7 test loads the chunk: here nothing may ask for it.
|
||||
test('#718 AC15: the static sky computes and asks for nothing in the editor, with the moon off or by day', async () => {
|
||||
const at = new Date('2026-10-21T18:00:00Z');
|
||||
const host = fakeHost(MOSCOW);
|
||||
// The editor (#101 View weight 0) and a moon that is not explicitly on compute no phase.
|
||||
assert.equal(moonSkyState(STATIC_ON, false, 0, NIGHT_HASS, at), null);
|
||||
for (const settings of [{ bg_mode: 'static' }, { bg_mode: 'static', moon: false }, { bg_mode: 'static', moon: 'true' }]) {
|
||||
assert.equal(moonSkyState(settings, false, 1, NIGHT_HASS, at), null, JSON.stringify(settings));
|
||||
assert.equal(moonLayer(host, settings, resolveDayCycle(NIGHT_HASS, at), 1), nothing);
|
||||
}
|
||||
// Over "Follow the Sun" the environment carries the moon; the sky computes nothing.
|
||||
assert.equal(moonSkyState({ moon: true, bg_mode: 'daynight' }, true, 1, NIGHT_HASS, at), null);
|
||||
assert.equal(moonLayer(host, STATIC_ON, moonSkyState(STATIC_ON, false, 0, NIGHT_HASS, at), 0), nothing);
|
||||
// By day the chunk is not asked for (#661 C7).
|
||||
const day = moonSkyState(STATIC_ON, false, 1, DAY_HASS, at);
|
||||
assert.equal(day?.phase, 'day');
|
||||
assert.equal(moonLayer(host, STATIC_ON, day, 1), nothing);
|
||||
await new Promise((done) => setTimeout(done, 50));
|
||||
assert.equal(host.updates, 0, 'nothing was loaded');
|
||||
});
|
||||
|
||||
test('#661 C7: the chunk is asked for at night only; until it arrives there is no moon', async () => {
|
||||
const at = new Date('2026-10-21T18:00:00Z');
|
||||
const sunAt = (elevation) => resolveDayCycle({ states: { 'sun.sun': { attributes: { azimuth: 0, elevation, rising: false } } } }, at);
|
||||
@@ -251,3 +377,40 @@ test('#661 C7: the chunk is asked for at night only; until it arrives there is n
|
||||
assert.notEqual(element, nothing);
|
||||
forgetHost(host);
|
||||
});
|
||||
|
||||
test('#718 AC15: a static background — global or the space’s own — shows the moon in its sky layer', () => {
|
||||
const at = new Date('2026-10-21T18:00:00Z');
|
||||
const host = fakeHost(MOSCOW);
|
||||
for (const [global, space] of [[STATIC_ON, {}], [{ moon: true, bg_mode: 'daynight' }, { bg_mode: 'static' }]]) {
|
||||
const daynight = bgModeOf(global, space) === 'daynight';
|
||||
assert.equal(daynight, false);
|
||||
const sky = moonSkyState(global, daynight, 1, NIGHT_HASS, at);
|
||||
assert.equal(sky?.phase, 'night');
|
||||
const layer = moonLayer(host, global, sky, 1);
|
||||
assert.notEqual(layer, nothing, JSON.stringify({ global, space }));
|
||||
assert.match(layer.strings[0], /^<div class="hp-moon-sky" aria-hidden="true"/);
|
||||
}
|
||||
// Without `sky` the element goes into the environment, as in #661.
|
||||
const env = moonLayer(host, { moon: true, bg_mode: 'daynight' }, resolveDayCycle(NIGHT_HASS, at));
|
||||
assert.match(env.strings[0], /^<style>/);
|
||||
forgetHost(host);
|
||||
});
|
||||
|
||||
test('#718 K3/K4: the sky layer is scene-sized, unstacked, and fades with the View weight', () => {
|
||||
const at = new Date('2026-10-21T18:00:00Z');
|
||||
const host = fakeHost(MOSCOW);
|
||||
const sky = renderMoonSky(host, ON, 'night', 0.37, at);
|
||||
const inner = renderMoon(host, ON, 'night', at);
|
||||
const clamped = renderMoonSky(host, ON, 'night', 3, at);
|
||||
forgetHost(host);
|
||||
assert.match(sky.strings[0], /^<div class="hp-moon-sky" aria-hidden="true"\s+style="opacity:$/);
|
||||
assert.equal(sky.values[0], '0.3700');
|
||||
assert.equal(clamped.values[0], '1.0000');
|
||||
// Inside: the very element of the environment, and nothing else.
|
||||
assert.deepEqual(sky.values[1].strings, inner.strings);
|
||||
assert.equal(sky.strings.length, 3);
|
||||
assert.match(sky.strings[2], /^<\/div>$/);
|
||||
const css = inner.values[0];
|
||||
assert.match(css, /\.hp-day-cycle-env,\.hp-moon-sky\{container-type:size\}/);
|
||||
assert.match(css, /\.hp-moon-sky\{position:absolute;inset:0;overflow:hidden;pointer-events:none\}/);
|
||||
});
|
||||
|
||||
+1
-1
@@ -23,7 +23,7 @@
|
||||
"src/devices.ts", "src/device-inbox.ts",
|
||||
"src/virtual-light-state.ts", "src/config-store.ts", "src/config-reload-authority.ts", "src/config-write-conflict.ts", "src/summary-panel.ts", "src/summary-panel-metrics.ts", "src/summary-panel-i18n.ts", "src/summary-panel-identity.ts", "src/summary-panel-picker.ts", "src/summary-panel-runtime-loaded.ts", "src/header-menu.ts", "src/iso-materials.ts", "src/iso-first-frame.ts", "src/iso-tiles.ts", "src/iso-sun.ts",
|
||||
"src/backdrop-probe.ts",
|
||||
"src/types.ts", "src/canvas-constants.ts", "src/editors/dialog-baseline.ts", "src/editors/color-tile-ink.ts", "src/editors/general-form-state.ts", "src/editors/space-form-state.ts", "src/editors/marker-form-state.ts", "src/editors/room-form-state.ts",
|
||||
"src/types.ts", "src/canvas-constants.ts", "src/editors/dialog-baseline.ts", "src/editors/color-tile-ink.ts", "src/editors/general-form-state.ts", "src/editors/moon-status.ts", "src/editors/space-form-state.ts", "src/editors/marker-form-state.ts", "src/editors/room-form-state.ts",
|
||||
"src/space-geometry.ts", "src/stairs.ts", "src/clean-floor.ts", "src/stairs-editor-model.ts", "src/stairs-box.ts", "src/stairs-view.ts", "src/junction-limits.ts", "src/room-gear-drag.ts",
|
||||
"src/space-order.ts", "src/card-editor-validation.ts",
|
||||
"src/signing.ts", "src/initial-load.ts", "src/space-model-selection.ts", "src/space-dialog.ts",
|
||||
|
||||
Reference in New Issue
Block a user