From b998b0b34af87f2ebc032a70ad803aa41f151e77 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 00:48:29 +0300 Subject: [PATCH] feat(moon): the moon with any background, and its status in General settings (#718) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd --- demo/golden/harness.mjs | 33 +- demo/golden/matrix.mjs | 16 +- demo/smoke_daycycle_layer_budget.mjs | 29 ++ demo/smoke_moon_static.mjs | 389 ++++++++++++++++++ demo/smoke_moon_status.mjs | 200 +++++++++ docs/CHANGELOG.md | 11 + docs/CHANGELOG.ru.md | 11 + docs/CONFIG-COMPATIBILITY.md | 21 +- docs/SUN.md | 116 ++++-- docs/USER-GUIDE.md | 45 +- docs/USER-GUIDE.ru.md | 46 ++- docs/testing-notes/mutation-browser-guards.md | 5 +- scripts/bundle-budget.mjs | 5 + scripts/mutation-registry.mjs | 56 ++- scripts/smoke-links.mjs | 7 +- src/editors/general-settings-dialog.ts | 8 +- src/editors/moon-status.ts | 68 +++ src/houseplan-card.ts | 18 +- src/houseplan-editor-runtime.ts | 2 + src/i18n/settings/de.json | 8 +- src/i18n/settings/en.json | 8 +- src/i18n/settings/fr.json | 8 +- src/i18n/settings/ru.json | 8 +- src/moon-gate.ts | 86 +++- src/moon-runtime.ts | 51 ++- src/moon.ts | 74 +++- src/space-card.ts | 23 +- src/space-render.ts | 10 +- test/bundle-assets.test.mjs | 6 + test/golden-matrix.test.mjs | 32 +- test/moon-settings.test.mjs | 112 ++++- test/moon.test.mjs | 185 ++++++++- tsconfig.test.json | 2 +- 33 files changed, 1553 insertions(+), 146 deletions(-) create mode 100644 demo/smoke_moon_static.mjs create mode 100644 demo/smoke_moon_status.mjs create mode 100644 src/editors/moon-status.ts diff --git a/demo/golden/harness.mjs b/demo/golden/harness.mjs index 00139dec..026825c9 100644 --- a/demo/golden/harness.mjs +++ b/demo/golden/harness.mjs @@ -686,7 +686,7 @@ export function prepareGoldenFixture(scenario) { throw new Error(`golden deviceName references missing device: ${scenario.deviceId || ''}`); 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(); diff --git a/demo/golden/matrix.mjs b/demo/golden/matrix.mjs index 36b48021..979e7523 100644 --- a/demo/golden/matrix.mjs +++ b/demo/golden/matrix.mjs @@ -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', diff --git a/demo/smoke_daycycle_layer_budget.mjs b/demo/smoke_daycycle_layer_budget.mjs index dd175ca3..cfef3af4 100644 --- a/demo/smoke_daycycle_layer_budget.mjs +++ b/demo/smoke_daycycle_layer_budget.mjs @@ -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'); diff --git a/demo/smoke_moon_static.mjs b/demo/smoke_moon_static.mjs new file mode 100644 index 00000000..ebd74b64 --- /dev/null +++ b/demo/smoke_moon_static.mjs @@ -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); +} diff --git a/demo/smoke_moon_status.mjs b/demo/smoke_moon_status.mjs new file mode 100644 index 00000000..e72ab085 --- /dev/null +++ b/demo/smoke_moon_status.mjs @@ -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); +} diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 5fdf6c43..7ea3840d 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -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 diff --git a/docs/CHANGELOG.ru.md b/docs/CHANGELOG.ru.md index 072aebc7..d8999e46 100755 --- a/docs/CHANGELOG.ru.md +++ b/docs/CHANGELOG.ru.md @@ -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-виде план больше не сдвигается: комнаты, мебель, прочий декор diff --git a/docs/CONFIG-COMPATIBILITY.md b/docs/CONFIG-COMPATIBILITY.md index 10b175c4..a232e9b8 100644 --- a/docs/CONFIG-COMPATIBILITY.md +++ b/docs/CONFIG-COMPATIBILITY.md @@ -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. diff --git a/docs/SUN.md b/docs/SUN.md index daa47a05..a400d4c0 100644 --- a/docs/SUN.md +++ b/docs/SUN.md @@ -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 `
`, 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 diff --git a/docs/USER-GUIDE.md b/docs/USER-GUIDE.md index 6ce1672d..bc45f0d2 100644 --- a/docs/USER-GUIDE.md +++ b/docs/USER-GUIDE.md @@ -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 diff --git a/docs/USER-GUIDE.ru.md b/docs/USER-GUIDE.ru.md index baa0a1b8..5a1a3bb5 100644 --- a/docs/USER-GUIDE.ru.md +++ b/docs/USER-GUIDE.ru.md @@ -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. Роботы-пылесосы diff --git a/docs/testing-notes/mutation-browser-guards.md b/docs/testing-notes/mutation-browser-guards.md index b99aeef4..0ad82b44 100644 --- a/docs/testing-notes/mutation-browser-guards.md +++ b/docs/testing-notes/mutation-browser-guards.md @@ -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` diff --git a/scripts/bundle-budget.mjs b/scripts/bundle-budget.mjs index 87f7e64f..15740c72 100644 --- a/scripts/bundle-budget.mjs +++ b/scripts/bundle-budget.mjs @@ -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) { diff --git a/scripts/mutation-registry.mjs b/scripts/mutation-registry.mjs index e5c8d273..efbd3792 100644 --- a/scripts/mutation-registry.mjs +++ b/scripts/mutation-registry.mjs @@ -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', diff --git a/scripts/smoke-links.mjs b/scripts/smoke-links.mjs index 7d75dbfc..92ec8a09 100644 --- a/scripts/smoke-links.mjs +++ b/scripts/smoke-links.mjs @@ -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', }, { diff --git a/src/editors/general-settings-dialog.ts b/src/editors/general-settings-dialog.ts index 09fc0218..9887e462 100644 --- a/src/editors/general-settings-dialog.ts +++ b/src/editors/general-settings-dialog.ts @@ -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`` : 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')}${moonStatusText(moonStatus, st)}` + : st('gs.moon_hint'), checked: d.moon, onChange: (v) => set({ moon: v }), })}`, })} diff --git a/src/editors/moon-status.ts b/src/editors/moon-status.ts new file mode 100644 index 00000000..0f3a9f8c --- /dev/null +++ b/src/editors/moon-status.ts @@ -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(); + +/** 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> = { + 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 { + return st(MOON_STATUS_KEYS[status.reason], { + alt: signed(status.alt), pct: signed(status.pct), sun: signed(status.sun), + }); +} diff --git a/src/houseplan-card.ts b/src/houseplan-card.ts index a54d60df..b1421819 100755 --- a/src/houseplan-card.ts +++ b/src/houseplan-card.ts @@ -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}
{ }; // #600 К10: снимок на момент открытия — от него считается «есть изменения». rememberGeneralBaseline(this.host, this.host._settingsDialog); + openMoonStatus(this.host); // #718 K7: the status line, outside the draft }; public _openSupportDialog = (): void => { diff --git a/src/i18n/settings/de.json b/src/i18n/settings/de.json index 1fd7e149..02e73c55 100644 --- a/src/i18n/settings/de.json +++ b/src/i18n/settings/de.json @@ -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}", diff --git a/src/i18n/settings/en.json b/src/i18n/settings/en.json index 4d390ec2..9ef81de5 100644 --- a/src/i18n/settings/en.json +++ b/src/i18n/settings/en.json @@ -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", diff --git a/src/i18n/settings/fr.json b/src/i18n/settings/fr.json index d5cdc479..6d084859 100644 --- a/src/i18n/settings/fr.json +++ b/src/i18n/settings/fr.json @@ -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}", diff --git a/src/i18n/settings/ru.json b/src/i18n/settings/ru.json index 10992e91..72b801b2 100644 --- a/src/i18n/settings/ru.json +++ b/src/i18n/settings/ru.json @@ -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}", diff --git a/src/moon-gate.ts b/src/moon-gate.ts index 6c96ff26..863760c3 100644 --- a/src/moon-gate.ts +++ b/src/moon-gate.ts @@ -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 | 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 : 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); } diff --git a/src/moon-runtime.ts b/src/moon-runtime.ts index ff5733b7..8ed791af 100644 --- a/src/moon-runtime.ts +++ b/src/moon-runtime.ts @@ -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( `; } + +/** + * #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``; +} diff --git a/src/moon.ts b/src/moon.ts index de7be0e9..a9f04f01 100644 --- a/src/moon.ts +++ b/src/moon.ts @@ -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)}`; diff --git a/src/space-card.ts b/src/space-card.ts index 3e17cbba..894bf09b 100644 --- a/src/space-card.ts +++ b/src/space-card.ts @@ -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) { diff --git a/src/space-render.ts b/src/space-render.ts index 25d4c712..907ec708 100644 --- a/src/space-render.ts +++ b/src/space-render.ts @@ -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`\$\{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' }); +}); diff --git a/test/moon.test.mjs b/test/moon.test.mjs index 4318fc53..617e2578 100644 --- a/test/moon.test.mjs +++ b/test/moon.test.mjs @@ -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}`), /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], /^