feat(view): the moon in its phase over the "Follow the Sun" background (#661)

At dawn, dusk and night the environment shows the moon in the top-left
corner of the scene, behind the plan: computed in the card from the home
coordinates and the browser clock (short Meeus series + topocentric
parallax, within 1.4° / 1.8 pp of JPL Horizons), one designer image under
a continuous phase mask with the lit side always on the left (owner
2026-09-29), a feathered terminator, 3°/3 % thresholds and the 2 s fade of
the window rays. Everything but a small gate lives in the lazy
moon-runtime chunk, with its own 30 s ticker. General settings: "Sun"
becomes "Sun and Moon" with one switch, on for new installations
(DEFAULT_CONFIG), off for existing ones.

The initial View graph sat 728 B under its budget: the gate is paid for by
moving fifteen dialog-only strings of General settings into the lazy
settings dictionary (#459) and by one build fingerprint literal instead of
three, so the graph ends 4 B above dev. Golden: two new moon scenes, and
the two General settings help frames show «Sun and Moon»; the WSL artifact
test fixture now models scenes whose first capture awaits acceptance.

Issue: #661
User-Visible: yes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
This commit is contained in:
Sergey Matyunin
2026-09-30 12:25:46 +03:00
co-authored by Claude Opus 5.5
parent e515bdaab8
commit c8c470ed57
58 changed files with 1682 additions and 126 deletions
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 JB (justbusiness)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+47
View File
@@ -0,0 +1,47 @@
# House Plan moon pack 1.0.0
Designer asset for [issue #661](https://github.com/Matysh/houseplan-card/issues/661)
(moon in its current phase on the "Follow the Sun" background), variant A:
one full-moon image, the phase is applied by the card as an SVG mask (C5).
- `moon.svg`: square `viewBox="0 0 512 512"`, disc centred at (256, 256),
diameter 480 (16 px margin for anti-aliasing). Full moon, front-lit: no
terminator, no shadow, no halo or glow outside the disc, no baked phase.
Near side, north up, Mare Crisium on the right; the art is never mirrored.
Owner 2026-09-29: the lit side is always the left one (as in the designer's
phase sequence), waning runs the same states backwards; only the mask flips.
Owner 2026-09-30: a distinct state for every day of the lunar month; the mask
is continuous, so the card simply uses the daily illumination k.
The dark side is not drawn: the card renders the same art at 8 % opacity.
- Edge: one radial gradient ring with no hard outer edge (as in the designer's
Figma export): bright rim 0.855-0.90 R, dark outline `#2b3138` at 50 % from
0.905 R to 0.92 R (owner 2026-09-29, for a forced white background), then a
1.5-unit fade to transparent by 0.935 R (r 239.4 in the 512 box, inside the 480
disc). The soft limb hides any missing anti-aliasing of the outer edge.
- The disc is masked (`<mask>`), not clipped: Chrome with GPU rasterisation draws
`clip-path` edges without anti-aliasing (seen on Windows), masks are anti-aliased.
- Art by JB (Figma, `House-plan` file). The Figma export (`source/figma-export/Full.svg`
in the source archive) was normalised for the pack by `source/convert_jb.py`:
viewBox 200 -> 512 (scale 2.56, disc clipped at r 240), the `mix-blend-mode`
screen ring and multiply groups removed (blend modes depend on the stacking
context they land in, so the file now composites normally and looks the same
everywhere), the three 5-7 % crater layers and the 484-path speckle texture
dropped (invisible at 200 px), and each mare "blend" of 22 nested rings
thinned to 4 rings (steps 0, 8, 16, 21). The export's screen-blended edge ring is rebuilt
as a normal-composited radial gradient (the art is masked at r 230), so the feathered edge is part
of the 480 disc and nothing is drawn outside it. Path coordinates rounded to 0.1, gradient offsets kept at 0.001 (`svgo.config.mjs`).
- Vector only: two `radialGradient`s (disc, edge), one `mask`, paths. No `filter`,
`feGaussianBlur`, `feImage`, embedded raster, fonts or external references.
25.6 KB raw, about 9 KB gzip.
- Colours: disc `#949493` (centre) -> `#D6D5D4` (rim), maria grey rings
`#404040 -> white` under a 0.2-0.6 group opacity. Checked on the night
(`#111a27 -> #1f2f3e`), dusk (`#48536c -> #9a7380`) and dawn
(`#aabdd1 -> #e8c8b7`) palettes at 200, 120 and 94 px.
- `pack.json`: pack id, version, licence, source link and the disc geometry
for `scripts/generate-moon-assets.mjs`.
JB (justbusiness) supplies this asset for House Plan to use, modify and
distribute under the repository MIT License without separate UI attribution
(`LICENSE.md`). The reviewed source archive with its SHA-256 is attached to
issue #661. Generated TypeScript (`src/moon-art.generated.ts`) must not be
edited by hand; regenerate it from this pack.
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 25 KiB

+38
View File
@@ -0,0 +1,38 @@
{
"schema_version": 1,
"pack_id": "houseplan",
"pack_version": "1.0.0",
"title_ru": "Луна: полный диск для маски фазы",
"title_en": "Moon: full disc for the phase mask",
"author": "JB (justbusiness)",
"license": "MIT",
"source_url": "https://www.figma.com/design/cpGN8MhJGydwVOUzZv8dia/House-plan",
"issue": 661,
"file": "moon.svg",
"view_box": [
0,
0,
512,
512
],
"disc": {
"cx": 256,
"cy": 256,
"r": 240
},
"lit": "front",
"north_up": true,
"east_right": true,
"notes_en": "Full moon, front-lit, no terminator, no shadow, no glow outside the disc; thin dark outline for light backgrounds. Art by JB (Figma export normalised: viewBox 512, blend modes removed, rings thinned). Phase mask and the 8 % dark side are applied by the card (C5). The art is never mirrored; the lit side is always the left one, waning runs the same states backwards (owner 2026-09-29).",
"notes_ru": "Полная луна, освещение в лоб, без терминатора, тени и ореола; тонкая тёмная обводка для светлого фона. Арт JB (экспорт из Figma нормализован: viewBox 512, без blend-режимов, кольца морей прорежены). Маску фазы и тёмную сторону 8 % накладывает карточка (C5). Арт не зеркалится; освещённая сторона всегда слева, убывание идёт теми же состояниями в обратном порядке (владелец 29.09).",
"mirror": false,
"outline": {
"color": "#2b3138",
"opacity": 0.5,
"from_r": 0.905,
"to_r": 0.92,
"fade_to_r": 0.935,
"note": "part of the edge gradient, no separate stroke"
},
"lit_side": "left"
}
+3 -1
View File
@@ -91,7 +91,9 @@ MAX_IMPORT_PREVIEWS_TOTAL = 3
DEFAULT_CONFIG: dict[str, object] = {
"spaces": [],
"markers": [],
"settings": {"bg_mode": "daynight", "sun_ray_origin": "inner"},
# #661: new installations get the moon; an existing config without the key
# keeps its look (docs/SUN.md: an update never changes a stored plan).
"settings": {"bg_mode": "daynight", "sun_ray_origin": "inner", "moon": True},
}
# #42: THE stable public error-code contract. Every code a user-facing
@@ -158,6 +158,9 @@ def _global_settings(value: object) -> dict[str, Any]:
volumetric_view = value.get("volumetric_view")
if isinstance(volumetric_view, bool):
out["volumetric_view"] = volumetric_view
moon = value.get("moon")
if isinstance(moon, bool):
out["moon"] = moon
fill_colors = value.get("fill_colors")
if isinstance(fill_colors, dict):
palette = {
@@ -2313,6 +2313,8 @@ CONFIG_SCHEMA = vol.All(
vol.Optional("show_room_tooltip"): bool,
# #649: installation-wide 2.5D View; absent/false keeps Flat.
vol.Optional("volumetric_view"): bool,
# #661: the moon on the "Follow the Sun" background; absent/false = off.
vol.Optional("moon"): bool,
vol.Optional("summary_panel"): SUMMARY_PANEL_WIRE_SCHEMA,
vol.Optional("radar"): vol.Schema({}, extra=vol.ALLOW_EXTRA),
# Removed from the UI/runtime in 2026-08-08. Keep accepting the
+32 -1
View File
@@ -83,7 +83,22 @@ async function stableEnvironment(page, scenario) {
});
await page.evaluate(({
variables, panelVariableNames, theme, panelHost, attemptedTarget, attemptKey, disableIsoFilters,
moonClock,
}) => {
// #661: a moon scene reads the browser clock. The scenario shifts it to
// its instant (time keeps flowing, so the card's timers behave); every
// other scenario gets the native clock back, like CSS.supports below.
window.__hpGoldenNativeDate ||= Date;
const NativeDate = window.__hpGoldenNativeDate;
if (moonClock) {
const offset = Date.parse(moonClock) - NativeDate.now();
window.Date = class GoldenDate extends NativeDate {
constructor(...args) { super(...(args.length ? args : [NativeDate.now() + offset])); }
static now() { return NativeDate.now() + offset; }
};
} else {
window.Date = NativeDate;
}
window.__hpGoldenNativeCssSupports ||= CSS.supports.bind(CSS);
CSS.supports = disableIsoFilters
? ((property, value) => property === 'filter'
@@ -138,6 +153,7 @@ async function stableEnvironment(page, scenario) {
? String(scenario.integrationVersion || '').trim() : null,
attemptKey: VERSION_RELOAD_ATTEMPT_KEY,
disableIsoFilters: scenario.disableIsoFilters === true,
moonClock: scenario.moon?.clock || null,
});
}
@@ -687,6 +703,9 @@ export function prepareGoldenFixture(scenario) {
...(scenario.customFill ? { custom_fill: scenario.customFill } : {}),
};
}
// #661: the moon is an installation-wide switch; the scene's home and clock
// arrive through hass.config and the page clock (stableEnvironment).
if (scenario.moon) fixture.config.settings = { ...(fixture.config.settings || {}), moon: true };
// #577 is deliberately global-only. Keeping this outside the per-space
// scenario overrides makes the golden exercise the same read path as a real
// General settings save instead of silently falling back to legacy inner.
@@ -988,7 +1007,10 @@ export async function prepareGoldenScenario(page, scenario) {
connection: { subscribeEvents: async () => () => undefined, subscribeMessage: async () => () => undefined },
localize: () => null,
formatEntityState: (state) => state.state,
config: { unit_system: { length: 'km' } },
config: {
unit_system: { length: 'km' },
...(scenario.moon ? { latitude: scenario.moon.latitude, longitude: scenario.moon.longitude } : {}),
},
});
const mount = async () => {
let card;
@@ -2181,6 +2203,15 @@ export async function prepareGoldenScenario(page, scenario) {
if (!marker) throw new Error(`golden focus device missing: ${scenario.focusDevice}`);
marker.focus({ focusVisible: true });
}
if (scenario.moon) {
// 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')) {
throw new Error(`golden moon contract did not render: ${scenario.id} (k ${moon.dataset.moonK})`);
}
await frame();
}
if (scenario.dayCycle) {
const environment = card.renderRoot.querySelector('.hp-day-cycle-env');
const active = card.renderRoot.querySelector('.hp-day-cycle-bg.active');
+14 -1
View File
@@ -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 = 67;
export const GOLDEN_MATRIX_VERSION = 68;
const stage = { capture: 'stage', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0005 } };
const page = { capture: 'page', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0008 } };
@@ -549,6 +549,19 @@ export const GOLDEN_SCENARIOS = Object.freeze([
bgMode: 'daynight', dayCycle: { phase: 'night', top: '#111a27' },
stateOverrides: { 'sun.sun': { attributes: { azimuth: 0, elevation: -12, rising: false } } },
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
// #661 AC7: the moon in its phase at the top-left of the scene, behind the
// plan — the plan deliberately covers part of the disc (owner decision 7).
// The lit side is the left one in both hemispheres (owner 2026-09-29).
{ id: 'day-cycle-night-moon-gibbous-dark', fixture: 'visual', space: 'golden-geometry', mode: 'view',
bgMode: 'daynight', dayCycle: { phase: 'night', top: '#111a27' },
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: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'day-cycle-dusk-moon-crescent-south-dark', fixture: 'visual', space: 'golden-geometry', mode: 'view',
bgMode: 'daynight', dayCycle: { phase: 'dusk', top: '#48536c' },
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',
+38
View File
@@ -199,6 +199,40 @@ const frameMetrics = await page.evaluate(async ({ encoded, stage }) => {
}),
});
// #661 AC6: at night, switching the moon on adds no composited layer — one
// element inside the environment, no CSS filter, no will-change (C8). The
// comparison is night without the moon against night with it, both settled.
await page.clock.setFixedTime(new Date('2026-10-21T18:00:00Z'));
await page.evaluate(async () => {
const card = window.__card;
card.hass = {
...card.hass,
config: { ...(card.hass.config || {}), latitude: 55.75, longitude: 37.62 },
states: { ...card.hass.states, 'sun.sun': {
entity_id: 'sun.sun', state: 'below_horizon',
attributes: { azimuth: 0, elevation: -12, rising: false },
} },
};
await card.updateComplete;
});
await settle();
// Transitions promote layers while they run: compare only settled frames.
const transitionsDone = () => page.waitForFunction(
() => !window.__card.renderRoot.getAnimations().some((animation) => animation instanceof CSSTransition),
null, { timeout: 10000 },
);
await transitionsDone();
const nightLayers = (await layerSnapshot()).layers;
await page.evaluate(async () => {
const card = window.__card;
card._serverCfg = { ...card._serverCfg, settings: { ...card._serverCfg.settings, moon: true } }; // 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;
});
await page.waitForFunction(() => !!window.__card.renderRoot.querySelector('.hp-moon.on'), null, { timeout: 8000 });
await transitionsDone();
const moonLayers = (await layerSnapshot()).layers;
// 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.
@@ -311,6 +345,8 @@ checks.staticCardOutlineLayerIsStageBounded = !!staticOutlineLayer
&& staticOutlineLayer.width <= staticStage.width * 1.5 + 64
&& staticOutlineLayer.height <= staticStage.height * 1.5 + 64;
checks.staticCardHasNo4096ContentLayer = staticOversized.length === 0;
checks.moonAddsNoCompositedLayer = moonLayers.length === nightLayers.length
&& !moonLayers.some((layer) => layer.className.includes('hp-moon'));
console.log(JSON.stringify({
stage: active.stage,
@@ -324,6 +360,8 @@ console.log(JSON.stringify({
staticStage,
staticLayers,
staticOversized,
nightLayers: nightLayers.length,
moonLayers: moonLayers.length,
}, null, 2));
checkAll(checks);
await cdp.send('LayerTree.disable');
+1 -1
View File
@@ -83,7 +83,7 @@ const res = await page.evaluate(async () => {
checkAll(res, {
"rows": 15, // 10 плиток + плашка стены + радиус свечения + фон + грань окна #577
// + «Оптимизировать планы» по действию, а не удалённому #605 классу alignall
"cards": ["Display", "Zigbee links", "Room fill colors", "Light-source glow", "Plan", "Sun", "Data"],
"cards": ["Display", "Zigbee links", "Room fill colors", "Light-source glow", "Plan", "Sun and Moon", "Data"],
"groups": ["Lights", "Temperature", "Zigbee signal", "Backup and transfer", "Plan maintenance"],
"everyCardHasContent": true,
"sunMissingStaysVisible": true,
+146
View File
@@ -0,0 +1,146 @@
// #661 AC4/AC5: the moon over the "Follow the Sun" background in a real
// browser. The page clock is Playwright's (`page.clock`), the home is Moscow
// through `hass.config`, the environment phase comes from `sun.sun`; the moon
// is switched on the way another client's save arrives (`__hpTest`).
//
// Moscow 2026-10-21 (waxing, 77 %): the moon sets through 3° between 22:37Z
// (3.03°) and 22:38Z (2.90°) — 22:30Z is above, 22:45Z below.
import { launch, checkAll, finish } from './serve.mjs';
const ABOVE = new Date('2026-10-21T22:30:00Z');
const BELOW = new Date('2026-10-21T22:45:00Z');
const { page, browser } = await launch({ width: 1000, height: 760 });
await page.clock.setFixedTime(ABOVE);
/** A state update, as Home Assistant pushes one: new hass object, same card. */
const pushHass = (patch) => page.evaluate(async ({ sun, config }) => {
const card = window.__card;
card.hass = {
...card.hass,
...(config ? { config: { ...(card.hass.config || {}), ...config } } : {}),
states: sun ? { ...card.hass.states, 'sun.sun': { entity_id: 'sun.sun', state: 'below_horizon', attributes: sun } } : card.hass.states,
};
await card.updateComplete;
}, patch);
const moonState = () => page.evaluate(() => {
const root = window.__card.renderRoot;
const moon = root.querySelector('.hp-moon');
if (!moon) return null;
const style = getComputedStyle(moon);
return {
on: moon.classList.contains('on'),
opacity: Number(style.opacity),
duration: style.transitionDuration,
parent: moon.parentElement?.className || '',
phase: root.querySelector('.hp-day-cycle-env')?.dataset.dayCyclePhase || null,
count: root.querySelectorAll('.hp-moon').length,
};
});
const waitMoon = (predicate, what) => page.waitForFunction(predicate, null, { timeout: 8000 })
.catch(() => { throw new Error(`moon did not ${what}`); });
await pushHass({ config: { latitude: 55.75, longitude: 37.62 }, sun: { azimuth: 0, elevation: -12, rising: false } });
await page.evaluate(() => window.__hpTest.setServerConfig((cfg) => {
cfg.settings = { ...(cfg.settings || {}), bg_mode: 'daynight', moon: true };
for (const space of cfg.spaces) if (space.settings) delete space.settings.bg_mode;
return cfg;
}));
await waitMoon(() => !!window.__card.renderRoot.querySelector('.hp-moon'), 'appear after the chunk loaded');
const first = await moonState();
const checks = {};
// C4: the first appearance after the chunk load is not animated.
checks.firstAppearanceIsImmediate = first?.on === true && first.opacity === 1;
checks.oneMoonInsideTheEnvironment = first?.count === 1 && first.parent === 'hp-day-cycle-env';
checks.twoSecondFade = first?.duration === '2s';
// AC4 in the browser: three 30 s ticks over an unchanged sky touch nothing.
await page.evaluate(() => {
const moon = window.__card.renderRoot.querySelector('.hp-moon');
window.__hpMoonMutations = 0;
window.__hpMoonObserver = new MutationObserver((records) => { window.__hpMoonMutations += records.length; });
window.__hpMoonObserver.observe(moon, { attributes: true, childList: true, subtree: true, characterData: true });
});
for (let tick = 0; tick < 3; tick++) await page.clock.runFor(30_000);
checks.threeQuietTicksNoRender = await page.evaluate(() => window.__hpMoonMutations) === 0;
// AC5: the ticker alone carries the moon through 3° — it fades out over 2 s.
await page.clock.setFixedTime(BELOW);
await page.clock.runFor(30_000);
await waitMoon(() => !window.__card.renderRoot.querySelector('.hp-moon')?.classList.contains('on'), 'set by the ticker');
await page.waitForTimeout(250);
const fading = await moonState();
checks.setsWithAFade = fading.on === false && fading.opacity > 0 && fading.opacity < 1;
await page.waitForTimeout(2200);
checks.setsToZero = (await moonState()).opacity === 0;
await page.evaluate(() => window.__hpMoonObserver.disconnect());
// Back above 3° on the next render (a state update): fades in to 1.
await page.clock.setFixedTime(ABOVE);
await pushHass({ sun: { azimuth: 2, elevation: -12.1, rising: false } });
await page.waitForTimeout(250);
const rising = await moonState();
checks.risesWithAFade = rising.on === true && rising.opacity > 0 && rising.opacity < 1;
await page.waitForTimeout(2200);
checks.risesToOne = (await moonState()).opacity === 1;
// C6 / owner decision 7: the plan paints over the moon. Along the disc's
// diameter, find one point where plan content is hit and one where only the
// empty scene is; then compare the painted pixel with the moon shown and
// hidden. Under the plan it must not change, in the open it must.
const probe = await page.evaluate(() => {
const root = window.__card.renderRoot;
const moon = root.querySelector('.hp-moon');
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, pointerEvents: getComputedStyle(moon).pointerEvents };
});
/** One painted pixel, as PNG bytes (the encoder is deterministic). */
const pixel = async (x, y) => (await page.screenshot({ clip: { x, y, width: 1, height: 1 } })).toString('base64');
const withMoonHidden = async (fn) => {
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);
});
try { return await fn(); } finally {
await page.evaluate(() => window.__card.renderRoot.querySelector('#hp-moon-probe')?.remove());
}
};
const shown = {
covered: probe.covered === null ? null : await pixel(probe.covered, probe.cy),
open: probe.open === null ? null : await pixel(probe.open, probe.cy),
};
const hidden = await withMoonHidden(async () => ({
covered: probe.covered === null ? null : await pixel(probe.covered, probe.cy),
open: probe.open === null ? null : await pixel(probe.open, probe.cy),
}));
checks.planCoversPartOfTheDisc = probe.covered !== null && probe.open !== null;
checks.moonChangesNoPixelUnderThePlan = shown.covered !== null && shown.covered === hidden.covered;
checks.moonIsPaintedWhereThePlanIsNot = shown.open !== null && shown.open !== hidden.open;
checks.moonIgnoresThePointer = probe.pointerEvents === 'none';
// Daytime: the environment turns to day and the moon fades out.
await pushHass({ sun: { azimuth: 180, elevation: 40, rising: false } });
await page.waitForTimeout(2300);
const day = await moonState();
checks.dayHidesTheMoon = day?.phase === 'day' && day.on === false && day.opacity === 0;
// Reduced motion: no transition at all.
await page.emulateMedia({ reducedMotion: 'reduce' });
checks.reducedMotionNoTransition = (await moonState()).duration === '0s';
console.log(JSON.stringify({ first, fading, rising, day, probe }, null, 2));
checkAll(checks);
await finish(browser, checks);
+7
View File
@@ -2,6 +2,13 @@
## Unreleased
- On the "Follow the Sun" background, the moon in its current phase now appears
in the top-left corner of the scene at dawn, dusk and night, behind the plan.
It follows the real moon over your home (Home Assistant home location), fades
in and out as it crosses 3° above the horizon, and is hidden by day and around
new moon. General settings: the "Sun" section is now "Sun and Moon", with a
new switch that is on for new installations
([#661](https://github.com/Matysh/houseplan-card/issues/661)).
- In 2.5D View, device icons no longer shift when a device changes state (for
example when a light turns on and shows its brightness); a Home Assistant
update in a dense plan also no longer re-lays out every icon
+7
View File
@@ -8,6 +8,13 @@
## Не выпущено
- На фоне «Следует за Солнцем» в сумерках и ночью в левом верхнем углу сцены,
за планом, теперь видна луна в текущей фазе. Она следует настоящей луне над
вашим домом (по координатам дома из Home Assistant), плавно появляется и
исчезает при пересечении 3° над горизонтом, днём и в новолуние её нет. В общих
настройках раздел «Солнце» стал «Солнце и Луна», в нём новый переключатель,
включённый у новых установок
([#661](https://github.com/Matysh/houseplan-card/issues/661)).
- В объёмном 2.5D-виде значки устройств больше не сдвигаются, когда меняется
состояние устройства (например, лампа включилась и показала яркость);
обновление Home Assistant в плотном плане больше не переразмещает все значки
+18
View File
@@ -134,6 +134,24 @@ 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)
`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
(`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
`settings` whole, so the value survives; a one-space export does not carry
global settings.
## Stairs (#663)
`spaces[].stairs[]` is an optional bounded (250 records per space)
+92
View File
@@ -376,6 +376,92 @@ 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)
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.
**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 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);
- `hass.config.latitude/longitude` are finite numbers.
**Where the numbers come from.** Home Assistant publishes no moon altitude
(`sun.sun` is the sun; the optional Moon integration gives eight phase names
without altitude, illumination or hemisphere). The card computes both from the
home coordinates and the browser clock (`src/moon.ts`): the short Meeus series
as in SunCalc, the topocentric parallax `h − π·cos h` with
`π = asin(6378.14 / distance)`, no refraction; the illuminated fraction
`k = (1 + cos i) / 2` from the Sun–Moon elongation. Against JPL Horizons
(airless) on twelve points in Moscow and Sydney, October 2026: altitude within
1.39°, illumination within 1.77 percentage points (`test/moon.test.mjs`, the
table and the query parameters are in the test).
**Phase.** One designer image of the full moon (`assets/moon/houseplan-1.0.0`,
art by JB) under an SVG mask. The lit side is **always the left one**, in both
hemispheres; waning runs the waxing states backwards (owner 2026-09-29). The
lit region is the left half of the box plus or minus the terminator
half-ellipse `R × R·|2k − 1|` (bulging into the dark side when `k > 0.5`); the
half's outer arc runs along the box, never along the limb, so the disc keeps
the art's own anti-aliased edge. The mask is a `<mask>`, not a `clipPath`
(Chrome with GPU rasterisation draws clip edges jagged). The terminator is
feathered by a Gaussian blur inside the mask (5 units of 512, about 2 px at
200 px), except for a full disc (`k ≥ 0.995`). The phase is continuous in `k`,
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).
**Movement.** Opacity only, 2 s on the background curve (`RAY_FADE_MS`),
none under `prefers-reduced-motion`: rising through 3°, setting through it,
the new-moon threshold and the day phase all fade; the first appearance after
a page or chunk load does not. The element is recomputed on every render of the
environment and by its own 30 s ticker (the moon rises at most 0.25°/min);
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`).
**Limits (documented, not bugs).**
- Position accuracy ≈ 1°: the moment of crossing 3° may differ from ephemerides
by a few minutes.
- The terminator is not tilted by the parallactic angle, and the lit side does
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.
- 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
home location gets someone else's moon, as it gets someone else's `sun.sun`.
- A wrong tablet clock gives a wrong phase and moment — the same class as the
background's clock fallback.
## Weather independence and legacy `weather_entity`
Weather never changes the window rays. Once the feature, compass,
@@ -417,6 +503,12 @@ saving General settings removes it.
inheritance); unit-tested in `test/sun.test.mjs`.
- `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/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
+29 -2
View File
@@ -37,7 +37,7 @@ relay, and exact plan geometry is attached only after you opt in and preview it.
12. [Device visual states](#12-device-visual-states)
13. [Room fills and light](#13-room-fills-and-light)
14. [Background editor](#14-background-editor)
15. [Sun background and window rays](#15-sun-background-and-window-rays)
15. [Sun and Moon: background, window rays and the moon](#15-sun-and-moon-background-window-rays-and-the-moon)
16. [Robot vacuums](#16-robot-vacuums)
17. [Kiosk](#17-kiosk)
18. [Static space card](#18-static-space-card)
@@ -1603,7 +1603,7 @@ keeps the same repairable placeholder instead of rejecting the whole plan.
![Selected line in the Background editor](images/07-background-editor.png)
## 15. Sun background and window rays
## 15. Sun and Moon: background, window rays and the moon
These are independent features. **Follow the sun** needs no compass: it uses
valid `sun.sun` data and otherwise falls back to the browser clock. North and
@@ -1651,6 +1651,33 @@ static card share the phase; editors keep their normal background. New installs
and spaces default to Follow the sun; upgrades/imports preserve their existing
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.
| 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 |
| 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 |
Position and phase are computed in the card from the Home Assistant home
location (Settings → System → General) and the device clock, with no
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.
## 16. Robot vacuums
Live position needs finite `vacuum_position` or `robot_position` coordinates.
+30 -3
View File
@@ -38,7 +38,7 @@ Assistant. Эта полноэкранная панель — основной
12. [Визуальные состояния устройств](#12-визуальные-состояния-устройств)
13. [Заливки комнат и свет](#13-заливки-комнат-и-свет)
14. [Редактор подложки](#14-редактор-подложки)
15. [Солнце: фон и оконные лучи](#15-солнце-фон-и-оконные-лучи)
15. [Солнце и Луна: фон, оконные лучи и луна](#15-солнце-и-луна-фон-оконные-лучи-и-луна)
16. [Роботы-пылесосы](#16-роботы-пылесосы)
17. [Киоск-режим](#17-киоск-режим)
18. [Статическая карточка пространства](#18-статическая-карточка-пространства)
@@ -1790,9 +1790,9 @@ WebP и безопасный SVG; лимит 2 МиБ относится к со
недостающего содержимого и после согласия оставляет ту же восстанавливаемую
рамку, а не отклоняет весь план.
## 15. Солнце: фон и оконные лучи
## 15. Солнце и Луна: фон, оконные лучи и луна
Это две независимые функции. Фон **Следует за Солнцем** работает без компаса:
Фон и оконные лучи — две независимые функции; луна живёт на фоне. Фон **Следует за Солнцем** работает без компаса:
он использует корректные данные `sun.sun`, а при их отсутствии автоматически
переходит на локальные часы браузера. Направление севера и `sun.sun` обязательны
только для оконных лучей. Общие значения можно переопределить в конкретном
@@ -1850,6 +1850,33 @@ WebP и безопасный SVG; лимит 2 МиБ относится к со
Тени от других крыльев здания и внешних объектов не моделируются.
### Луна
При фоне **Следует за Солнцем** в сумерках (утренних и вечерних) и ночью в левом
верхнем углу сцены видна луна в текущей фазе — тонкий серп, половина, полная.
Включается одним переключателем в общих настройках, раздел **Солнце и Луна** →
**Луна на плане в сумерках и ночью**. У новых установок переключатель включён,
у обновлённых старых — выключен, пока его не включат: обновление не меняет вид
плана.
| Условие | Результат |
|---|---|
| День, луна ниже 3° над горизонтом или новолуние (освещено меньше 3 %) | Луны нет |
| Луна поднимается выше 3° или уходит ниже | Плавно появляется/исчезает за 2 секунды; при системном уменьшении движения — сразу |
| Фаза | Меняется непрерывно, каждый день; освещённая сторона всегда слева, убывание идёт теми же состояниями в обратном порядке |
| Пространство с собственным статическим фоном | Луны нет — она часть фона «Следует за Солнцем» |
| План закрывает угол сцены | Луна за планом: видна та часть диска, что в полях; план, устройства и подписи всегда поверх неё |
| Редакторы | Луны нет |
| Полная карточка, киоск, статическая карточка пространства | Одинаково |
Положение и фаза считаются в карточке по координатам дома из Home Assistant
(Настройки → Система → Общие) и часам устройства, без интеграций и внешних
данных. Если координаты дома в Home Assistant не заданы, луна будет «чужой» —
как и данные `sun.sun`; с неверными часами планшета неверны фаза и момент
появления. Точность положения около 1°: момент пересечения 3° может разойтись с
эфемеридами на несколько минут. Наклон терминатора и пепельный свет не
воспроизводятся, тёмная сторона — едва заметный силуэт для читаемости серпа.
## 16. Роботы-пылесосы
Живая позиция доступна для маркера пылесоса, если источник отдаёт конечные
+2
View File
@@ -50,6 +50,8 @@
"benchmark:zigbee-topology": "tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs && node demo/benchmark_zigbee_topology.mjs",
"furniture:generate": "node scripts/generate-furniture-assets.mjs",
"furniture:check": "node scripts/generate-furniture-assets.mjs --check",
"moon:generate": "node scripts/generate-moon-assets.mjs",
"moon:check": "node scripts/generate-moon-assets.mjs --check",
"pdf-font:generate": "node scripts/generate-pdf-font.mjs",
"pdf-font:check": "node scripts/generate-pdf-font.mjs --check"
},
+9
View File
@@ -734,6 +734,13 @@ export function assertBundleBudget(
if (!manifest.lazyPdfFiles?.length) {
throw new Error('bundle has no lazy PDF graph');
}
// #661 C7/AC8: the moon's math, art and template load only at night.
if (!manifest.lazyMoonFiles?.length) {
throw new Error('bundle has no lazy moon graph');
}
if (manifest.initialViewFiles.some((path) => manifest.lazyMoonFiles.includes(path))) {
throw new Error('initial View 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) {
@@ -806,6 +813,7 @@ export function assertBundleBudget(
lazyIsometricGzipBytes: manifest.lazyIsometricGzipBytes,
lazyFurnitureArtGzipBytes: manifest.lazyFurnitureArtGzipBytes,
lazyPdfGzipBytes: manifest.lazyPdfGzipBytes,
lazyMoonGzipBytes: manifest.lazyMoonGzipBytes,
};
}
@@ -840,6 +848,7 @@ if (import.meta.url === pathToFileURL(process.argv[1] || '').href) {
`lazy locale: ${result.lazyLocaleGzipBytes} B gzip`,
`lazy isometric: ${result.lazyIsometricGzipBytes} B gzip`,
`lazy PDF: ${result.lazyPdfGzipBytes} B gzip`,
`lazy moon: ${result.lazyMoonGzipBytes} B gzip`,
];
for (const line of lines) console.log(line);
const warning = lowHeadroomWarning(headroom);
+21 -2
View File
@@ -11,6 +11,7 @@ const DE_RETRY_ASSET_TOKEN = '__HOUSEPLAN_DE_RETRY_ASSET__';
const FR_RETRY_ASSET_TOKEN = '__HOUSEPLAN_FR_RETRY_ASSET__';
const FURNITURE_ART_RETRY_ASSET_TOKEN = '__HOUSEPLAN_FURNITURE_ART_RETRY_ASSET__';
const PDF_RETRY_ASSET_TOKEN = '__HOUSEPLAN_PDF_RETRY_ASSET__';
const MOON_RETRY_ASSET_TOKEN = '__HOUSEPLAN_MOON_RETRY_ASSET__';
/**
* #627: ru/de/fr of the three lazy dictionary namespaces are one chunk per
@@ -110,6 +111,9 @@ export function buildBundleManifest(bundle, fingerprint) {
? 'isometric'
: modules.some((id) => id.endsWith('/src/pdf/pdf-export.ts'))
? 'pdf'
// #661: the moon's math, art and template — loaded only at night.
: modules.some((id) => id.endsWith('/src/moon-runtime.ts'))
? 'moon'
// #474: designer furniture artwork — its own lazy chunk, shared by
// the editor (static import) and the View runtime (dynamic).
: modules.some((id) => id.endsWith('/src/furniture-plan-art.generated.ts'))
@@ -153,6 +157,8 @@ export function buildBundleManifest(bundle, fingerprint) {
const furnitureArtRoots = dynamicRoots.filter((path) => byPath.get(path)?._role === 'furniture-art');
const pdfRoots = dynamicRoots.filter((path) => byPath.get(path)?._role === 'pdf'
|| path.includes('pdf-export-'));
const moonRoots = dynamicRoots.filter((path) => byPath.get(path)?._role === 'moon'
|| path.includes('moon-runtime-'));
// #627: namespace dictionaries are dynamic imports of LAZY chunks, never of
// the initial graph, so they are found by their module, not as a root.
const namespaceLocaleRoots = files.filter((file) => file._role === 'namespace-locale')
@@ -170,6 +176,7 @@ export function buildBundleManifest(bundle, fingerprint) {
const lazyIsometric = graphFrom(isometricRoots);
const lazyFurnitureArt = graphFrom(furnitureArtRoots);
const lazyPdf = graphFrom(pdfRoots);
const lazyMoon = graphFrom(moonRoots);
const lazyNamespaceLocale = graphFrom(namespaceLocaleRoots);
const sum = (paths) => [...paths]
.reduce((total, path) => total + (byPath.get(path)?.gzipBytes || 0), 0);
@@ -198,6 +205,8 @@ export function buildBundleManifest(bundle, fingerprint) {
lazyFurnitureArtGzipBytes: sum(lazyFurnitureArt),
lazyPdfFiles: [...lazyPdf].sort(),
lazyPdfGzipBytes: sum(lazyPdf),
lazyMoonFiles: [...lazyMoon].sort(),
lazyMoonGzipBytes: sum(lazyMoon),
lazyNamespaceLocaleFiles: [...lazyNamespaceLocale].sort(),
lazyNamespaceLocaleGzipBytes: sum(lazyNamespaceLocale),
files: files.map(({ _role, ...file }) => file),
@@ -257,6 +266,8 @@ export function editorRuntimeRetryUrlPlugin() {
.some((id) => id.replaceAll('\\', '/').endsWith('/src/furniture-plan-art.generated.ts')));
const pdf = chunks.find((chunk) => Object.keys(chunk.modules)
.some((id) => id.replaceAll('\\', '/').endsWith('/src/pdf/pdf-export.ts')));
const moon = chunks.find((chunk) => Object.keys(chunk.modules)
.some((id) => id.replaceAll('\\', '/').endsWith('/src/moon-runtime.ts')));
if (!editor) throw new Error('editor runtime chunk was not emitted');
if (!onboarding) throw new Error('onboarding runtime chunk was not emitted');
if (!isometric) throw new Error('isometric runtime chunk was not emitted');
@@ -264,6 +275,7 @@ export function editorRuntimeRetryUrlPlugin() {
if (!french) throw new Error('French locale chunk was not emitted');
if (!furnitureArt) throw new Error('furniture artwork chunk was not emitted');
if (!pdf) throw new Error('PDF export runtime chunk was not emitted');
if (!moon) throw new Error('moon runtime chunk was not emitted');
const namespaceLocales = NAMESPACE_LOCALE_CHUNKS.map((entry) => {
const chunk = chunks.find((candidate) => Object.keys(candidate.modules)
.some((id) => id.replaceAll('\\', '/').endsWith(entry.module)));
@@ -279,6 +291,7 @@ export function editorRuntimeRetryUrlPlugin() {
let germanReplacements = 0;
let frenchReplacements = 0;
let pdfReplacements = 0;
let moonReplacements = 0;
for (const chunk of chunks) {
if (chunk.code.includes(EDITOR_RETRY_ASSET_TOKEN)) {
let asset = posix.relative(posix.dirname(chunk.fileName), editor.fileName);
@@ -322,6 +335,12 @@ export function editorRuntimeRetryUrlPlugin() {
pdfReplacements += chunk.code.split(PDF_RETRY_ASSET_TOKEN).length - 1;
chunk.code = chunk.code.replaceAll(PDF_RETRY_ASSET_TOKEN, asset);
}
if (chunk.code.includes(MOON_RETRY_ASSET_TOKEN)) {
let asset = posix.relative(posix.dirname(chunk.fileName), moon.fileName);
if (!asset.startsWith('.')) asset = `./${asset}`;
moonReplacements += chunk.code.split(MOON_RETRY_ASSET_TOKEN).length - 1;
chunk.code = chunk.code.replaceAll(MOON_RETRY_ASSET_TOKEN, asset);
}
for (const entry of namespaceLocales) {
if (!chunk.code.includes(entry.token)) continue;
let asset = posix.relative(posix.dirname(chunk.fileName), entry.chunk.fileName);
@@ -332,9 +351,9 @@ export function editorRuntimeRetryUrlPlugin() {
}
if (editorReplacements !== 1 || onboardingReplacements !== 1 || isometricReplacements !== 1
|| germanReplacements !== 1 || frenchReplacements !== 1 || furnitureArtReplacements !== 1
|| pdfReplacements !== 1) {
|| pdfReplacements !== 1 || moonReplacements !== 1) {
throw new Error('lazy retry URL placeholder counts are '
+ `${editorReplacements}/${onboardingReplacements}/${isometricReplacements}/${germanReplacements}/${frenchReplacements}/${furnitureArtReplacements}/${pdfReplacements}, expected 1/1/1/1/1/1/1`);
+ `${editorReplacements}/${onboardingReplacements}/${isometricReplacements}/${germanReplacements}/${frenchReplacements}/${furnitureArtReplacements}/${pdfReplacements}/${moonReplacements}, expected 1/1/1/1/1/1/1/1`);
}
// #627: the same strict rule for every namespace × language token —
// exactly one second-attempt URL each, never zero and never two.
+1 -1
View File
@@ -342,7 +342,7 @@ export const CHECKS = {
entries: ['test/*.test.mjs', 'scripts/no-new-any.mjs', 'scripts/no-new-private-writes.mjs', 'scripts/render-layout-read.mjs', 'scripts/bundle-budget.mjs',
'scripts/fix-test-build.mjs', 'scripts/unused-locals-gate.mjs'],
// demo/helpers/** — область no-new-private-writes (#629): гейт читает их текст.
roots: [...BUILD_INPUTS, 'assets/furniture/**', 'test/**', 'tsconfig*.json', 'scripts/monolith-baseline.json', 'demo/smoke_*.mjs', 'demo/benchmark_*.mjs', 'demo/guard/*.mjs', 'demo/helpers/**', ...WORKFLOW],
roots: [...BUILD_INPUTS, 'assets/furniture/**', 'assets/moon/**', 'test/**', 'tsconfig*.json', 'scripts/monolith-baseline.json', 'demo/smoke_*.mjs', 'demo/benchmark_*.mjs', 'demo/guard/*.mjs', 'demo/helpers/**', ...WORKFLOW],
},
changed_mutants: {
entries: ['scripts/mutation-*.mjs', 'scripts/*-guard.mjs', 'test/*.test.mjs', 'demo/smoke_*.mjs', 'tests_backend/**/*.py'],
+4
View File
@@ -413,6 +413,10 @@
"required": true,
"type": "str"
},
"config.settings.moon": {
"required": false,
"type": "bool"
},
"config.settings.new_device_ids": {
"required": false
},
+99
View File
@@ -0,0 +1,99 @@
// Designer moon pack → `src/moon-art.generated.ts` (#661, variant A).
//
// The pack is one full-moon SVG; the card applies the phase as a mask (C5).
// The generator is the gate for what the designer's file may contain: a square
// 512 viewBox with the documented disc, vector shapes and gradients only — no
// filters, raster, fonts, scripts or external references — and at most 30 KB.
// Ids are prefixed so the art can live in the card's shadow root next to
// everything else. `--check` fails when the committed module has drifted.
import { createHash } from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import process from 'node:process';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
export const MOON_PACK_DIR = path.join(ROOT, 'assets', 'moon', 'houseplan-1.0.0');
const OUT = path.join(ROOT, 'src', 'moon-art.generated.ts');
/** Brief to the designer: ≤ 30 KB before compression. */
export const MOON_SVG_MAX_BYTES = 30 * 1024;
const ALLOWED_TAGS = new Set([
'svg', 'title', 'defs', 'g', 'path', 'circle', 'ellipse', 'mask', 'radialGradient', 'linearGradient', 'stop',
]);
const ID_PREFIX = 'hp-moon-';
const fail = (message) => { throw new Error(`Moon pack: ${message}`); };
/** pack.json: identity, licence and the disc geometry the mask is built for. */
export function validateMoonPack(pack) {
if (!pack || typeof pack !== 'object' || Array.isArray(pack)) fail('pack.json must be an object');
if (pack.schema_version !== 1) fail('schema_version must be 1');
if (pack.pack_id !== 'houseplan') fail('pack_id must be "houseplan"');
if (typeof pack.pack_version !== 'string' || !/^\d+\.\d+\.\d+$/.test(pack.pack_version)) fail('pack_version must be semver');
if (pack.license !== 'MIT') fail('license must be MIT');
if (typeof pack.source_url !== 'string' || !pack.source_url.startsWith('https://')) fail('source_url must be https');
if (pack.file !== 'moon.svg') fail('file must be moon.svg');
if (JSON.stringify(pack.view_box) !== '[0,0,512,512]') fail('view_box must be [0, 0, 512, 512]');
if (JSON.stringify(pack.disc) !== '{"cx":256,"cy":256,"r":240}') fail('disc must be centred at 256 with r 240');
if (pack.mirror !== false) fail('mirror must be false (owner 2026-09-29)');
if (pack.lit_side !== 'left') fail('lit_side must be "left" (owner 2026-09-29)');
return pack;
}
/** The SVG body the card inlines: validated, title dropped, ids prefixed. */
export function moonArtFromSvg(source) {
const text = String(source).replace(/^/, '').trim();
if (Buffer.byteLength(text, 'utf8') > MOON_SVG_MAX_BYTES) fail(`moon.svg is larger than ${MOON_SVG_MAX_BYTES} bytes`);
if (/<!DOCTYPE|<!ENTITY|<\?|<!--|<!\[CDATA/i.test(text)) fail('moon.svg contains active XML');
if (/href\s*=|url\((?!#)|@import|base64|font-family|style\s*=/i.test(text)) fail('moon.svg references outside itself or carries styles');
for (const [, name] of text.matchAll(/<\/?([A-Za-z][\w:-]*)/g)) {
if (!ALLOWED_TAGS.has(name)) fail(`moon.svg contains <${name}>`);
}
const root = text.match(/^<svg\b([^>]*)>([\s\S]*)<\/svg>$/);
if (!root) fail('moon.svg is not one SVG root');
if (!/\bviewBox="0 0 512 512"/.test(root[1])) fail('moon.svg must have viewBox="0 0 512 512"');
let body = root[2].replace(/<title>[\s\S]*?<\/title>/g, '');
const ids = [...body.matchAll(/\bid="([^"]+)"/g)].map((match) => match[1]);
if (new Set(ids).size !== ids.length) fail('moon.svg repeats an id');
for (const [, ref] of body.matchAll(/url\(#([^)]+)\)/g)) {
if (!ids.includes(ref)) fail(`moon.svg references a missing id: ${ref}`);
}
if (/[`\\]|\$\{/.test(body)) fail('moon.svg contains template-literal syntax');
body = body
.replace(/\bid="([^"]+)"/g, (_, id) => `id="${ID_PREFIX}${id}"`)
.replace(/url\(#([^)]+)\)/g, (_, id) => `url(#${ID_PREFIX}${id})`);
return body;
}
export function generateMoonModule(packDir = MOON_PACK_DIR) {
const pack = validateMoonPack(JSON.parse(fs.readFileSync(path.join(packDir, 'pack.json'), 'utf8')));
const svg = fs.readFileSync(path.join(packDir, pack.file), 'utf8');
const art = moonArtFromSvg(svg);
const sha = createHash('sha256').update(svg).digest('hex');
return [
'/* Generated by scripts/generate-moon-assets.mjs. DO NOT EDIT. */',
`// Designer pack assets/moon/${pack.pack_id}-${pack.pack_version} (#661). Lazy: imported only by`,
'// moon-runtime.ts, never by the initial View graph (C7).',
'// A static Lit template: no directive, no binding, nothing parsed at runtime but the art.',
"import { svg } from 'lit';",
`export const MOON_PACK = ${JSON.stringify(`${pack.pack_id}-${pack.pack_version}`)};`,
`export const MOON_ART_SHA256 = ${JSON.stringify(sha)};`,
`export const MOON_ART = svg\`${art}\`;`,
'',
].join('\n');
}
if (fileURLToPath(import.meta.url) === path.resolve(process.argv[1] || '')) {
const next = generateMoonModule();
if (process.argv.includes('--check')) {
const current = fs.existsSync(OUT) ? fs.readFileSync(OUT, 'utf8') : '';
if (current !== next) {
console.error('src/moon-art.generated.ts is stale: run node scripts/generate-moon-assets.mjs');
process.exit(1);
}
console.log('moon art is up to date');
} else {
fs.writeFileSync(OUT, next);
console.log(`wrote ${path.relative(ROOT, OUT)}`);
}
}
+1 -1
View File
@@ -4,5 +4,5 @@
"hostRefs": 4880,
"portPrivates": 94,
"harnessPrivates": 100,
"bundleBytes": 2583806
"bundleBytes": 2616267
}
+106 -2
View File
@@ -5432,8 +5432,8 @@ const MUTANT_DEFINITIONS = [
+ 'правка пакета не выбирает frontend и не запускает сверку generated-модулей (#671)',
patches: [{
file: 'scripts/check-inputs.mjs',
find: " roots: [...BUILD_INPUTS, 'assets/furniture/**', 'test/**',",
replace: " roots: [...BUILD_INPUTS, 'test/**', // mutant: furniture package is not a frontend input",
find: " roots: [...BUILD_INPUTS, 'assets/furniture/**', 'assets/moon/**', 'test/**',",
replace: " roots: [...BUILD_INPUTS, 'assets/moon/**', 'test/**', // mutant: furniture package is not a frontend input",
}],
},
{
@@ -6214,6 +6214,110 @@ const MUTANT_DEFINITIONS = [
replace: ' kind: \'device\', core, presentation,',
}],
},
{
id: 'moon-threshold-off',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test --test-name-pattern="#661" test/moon.test.mjs',
because: '#661 AC2: the moon keeps the window rays\' 3° horizon; at 2.9° it is not shown',
patches: [{
file: 'src/moon.ts',
find: " return phase !== 'day' && altitude >= MOON_ELEVATION_MIN && fraction >= MOON_MIN_ILLUMINATION;",
replace: " return phase !== 'day' && altitude >= 0 && fraction >= MOON_MIN_ILLUMINATION;",
}],
},
{
id: 'moon-new-moon-shown',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test --test-name-pattern="#661" test/moon.test.mjs',
because: '#661 AC2: below 3 % illumination (around new moon) there is no moon',
patches: [{
file: 'src/moon.ts',
find: " return phase !== 'day' && altitude >= MOON_ELEVATION_MIN && fraction >= MOON_MIN_ILLUMINATION;",
replace: " return phase !== 'day' && altitude >= MOON_ELEVATION_MIN;",
}],
},
{
id: 'moon-day-visible',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test --test-name-pattern="#661" test/moon.test.mjs',
because: '#661 AC2: the day phase shows no moon, however high it stands',
patches: [{
file: 'src/moon.ts',
find: " return phase !== 'day' && altitude >= MOON_ELEVATION_MIN && fraction >= MOON_MIN_ILLUMINATION;",
replace: " return altitude >= MOON_ELEVATION_MIN && fraction >= MOON_MIN_ILLUMINATION;",
}],
},
{
id: 'moon-parallax-off',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test --test-name-pattern="#661" test/moon.test.mjs',
because: '#661 AC1: without the topocentric parallax the altitude by the horizon is off by '
+ 'up to 2° — the 2026-10-31 Moscow point leaves the 1.5° tolerance',
patches: [{
file: 'src/moon.ts',
find: ' const altitude = geocentric - parallax * Math.cos(geocentric);',
replace: ' const altitude = geocentric;',
}],
},
{
id: 'moon-lit-side-flips',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test --test-name-pattern="#661" test/moon.test.mjs',
because: '#661 AC3 (owner 2026-09-29): the lit side is always the left one, in both hemispheres',
patches: [{
file: 'src/moon.ts',
find: ' return `M${c} 0A${c} ${c} 0 0 0 ${c} ${MOON_BOX}L${c} ${bottom}${terminator}L${c} 0Z`;',
replace: ' return `M${c} 0A${c} ${c} 0 0 1 ${c} ${MOON_BOX}L${c} ${bottom}${terminator}L${c} 0Z`;',
}],
},
{
id: 'moon-tick-renders-every-time',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test --test-name-pattern="#661" test/moon.test.mjs',
because: '#661 AC4: a 30 s tick over an unchanged moon must not cost a render',
patches: [{
file: 'src/moon-runtime.ts',
find: ' if (key === watch.key) return;\n',
replace: '',
}],
},
{
id: 'moon-lazy-gate-off',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test --test-name-pattern="#661" test/moon.test.mjs',
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) {',
}],
},
{
id: 'moon-over-paper',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test --test-name-pattern="#661" test/moon.test.mjs',
because: '#661 C6 / owner decision 7: the moon covers only the background — it is the last child '
+ 'of the environment and never stacks by itself; taken out and raised, it would paint over the plan',
patches: [{
file: 'src/day-cycle-render.ts',
find: ' })}${moon}\n </div>`;',
replace: ' })}\n </div>${moon}`;',
}, {
file: 'src/moon-runtime.ts',
find: " + '.hp-moon{--hp-moon-box:min(200px,25cqmin);position:absolute;'",
replace: " + '.hp-moon{--hp-moon-box:min(200px,25cqmin);position:absolute;z-index:2;'",
}],
},
{
id: 'moon-validation-accepts-anything',
guard: 'python3 -m pytest tests_backend/test_settings_moon.py -q -p no:cacheprovider',
because: '#661 AC9: the backend accepts only a boolean moon',
patches: [{
file: 'custom_components/houseplan/validation.py',
find: ' vol.Optional("moon"): bool,\n',
replace: ' vol.Optional("moon"): object,\n',
}],
},
{
id: 'wall-isolated-extra-discarded',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+11
View File
@@ -27,6 +27,17 @@
/** @type {SmokeLink[]} */
export const SMOKE_LINKS = [
{
symbols: [
'moonLayer', 'renderMoon', 'moonTick', 'moonView', 'moonShownAt', 'moonPhasePath',
'moonPosition', 'moonIllumination', 'moonFingerprint', 'renderDayCycleEnvironment',
],
smokes: ['smoke_moon.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); '
+ 'no smoke names the functions',
},
{
symbols: ['memoIsoLightFloorRooms', 'IsoFirstFrameState', 'isoPaperContext'],
smokes: ['smoke_iso_first_frame.mjs'],
+7 -2
View File
@@ -17,10 +17,15 @@ export function dayCycleStageVars(state: DayCycleState | null): string {
].join(';');
}
/** Four constant layers make gradient changes genuinely cross-fade in CSS. */
/**
* Four constant layers make gradient changes genuinely cross-fade in CSS. The
* moon (#661) comes last: above the gradients and the sun glow, still inside
* the environment and therefore below the plan paper and everything else.
*/
export function renderDayCycleEnvironment(
state: DayCycleState | null,
viewWeight = 1,
moon: TemplateResult | typeof nothing = nothing,
): TemplateResult | typeof nothing {
if (!state) return nothing;
const position = [
@@ -39,6 +44,6 @@ export function renderDayCycleEnvironment(
data-day-cycle-layer=${phase} style=${background}>
<div class="hp-day-cycle-sun" style="background:radial-gradient(circle, ${palette.sun} 0%, transparent 67%)"></div>
</div>`;
})}
})}${moon}
</div>`;
}
+3
View File
@@ -1,5 +1,8 @@
export type EditorRuntimeLoaderState = 'idle' | 'loading' | 'ready' | 'failed';
/** This build's id, embedded at build time; every lazy runtime must carry the same. */
export const ENTRY_BUILD_FINGERPRINT = '__HOUSEPLAN_SOURCE_FINGERPRINT__';
export interface EditorRuntimeModule<Runtime> {
readonly fingerprint: string;
create(): Runtime;
+11
View File
@@ -43,3 +43,14 @@ export function generalProblems(d: GeneralSettingsDraft): GeneralProblem[] {
}
return problems;
}
/** #661: the moon switch reads only an explicit `true` (absent, `false` or garbage — off). */
export function moonDraftOf(settings: { moon?: unknown } | null | undefined): boolean {
return settings?.moon === true;
}
/** #661: saving writes `true` or removes the key — `false` is never stored (as volumetric_view). */
export function writeMoonSetting(settings: { moon?: unknown }, on: boolean): void {
if (on) settings.moon = true;
else delete settings.moon;
}
+24 -19
View File
@@ -81,7 +81,7 @@ export function renderGeneralSettingsDialog(this: HouseplanEditorRuntime): Templ
: nothing;
return html`<hp-dialog .hass=${host.hass} data-kind="settings" form-shell wide
.title=${t('gs.title')} icon="mdi:cog-outline" @hp-close=${requestClose}>
.title=${st('gs.title')} icon="mdi:cog-outline" @hp-close=${requestClose}>
<div class="body hpf-form">
${formCard({
id: 'display',
@@ -94,7 +94,7 @@ export function renderGeneralSettingsDialog(this: HouseplanEditorRuntime): Templ
})}
${toggleRow({
id: 'gs-radar-live', icon: 'mdi:radar',
title: t('gs.radar_show_live'), caption: st('gs.radar_show_live_hint'),
title: st('gs.radar_show_live'), caption: st('gs.radar_show_live_hint'),
checked: d.radarShowLive, onChange: (v) => set({ radarShowLive: v }),
})}
${toggleRow({
@@ -116,11 +116,11 @@ export function renderGeneralSettingsDialog(this: HouseplanEditorRuntime): Templ
title: t('gs.card_fills'),
help: this._help('gs.card_fills.help'),
body: html`
${subsection({ title: t('gs.light_group') })}
${subsection({ title: st('gs.light_group') })}
${colorTiles([tile('light_on', 'gs.light_on'), tile('light_off', 'gs.light_off'), tile('light_none', 'gs.light_none')])}
${subsection({ title: t('gs.temp_group') })}
${subsection({ title: st('gs.temp_group') })}
${colorTiles([tile('temp_cold', 'gs.temp_cold'), tile('temp_ok', 'gs.temp_ok'), tile('temp_hot', 'gs.temp_hot')])}
${subsection({ title: t('gs.lqi_group') })}
${subsection({ title: st('gs.lqi_group') })}
${colorTiles([tile('lqi_low', 'gs.lqi_low'), tile('lqi_high', 'gs.lqi_high')])}`,
})}
${formCard({
@@ -129,7 +129,7 @@ export function renderGeneralSettingsDialog(this: HouseplanEditorRuntime): Templ
body: html`
${colorTiles([tile('glow_base', 'gs.glow_base'), tile('glow_light', 'gs.glow_light')])}
${field({
label: t('gs.glow_radius'), htmlFor: 'gs-glow-radius', help: this._help('gs.glow_radius.help'),
label: st('gs.glow_radius'), htmlFor: 'gs-glow-radius', help: this._help('gs.glow_radius.help'),
error: glowProblem ? st(glowProblem.message) : undefined,
control: unitInput({
id: 'gs-glow-radius', value: d.glowRadiusInput, min: 0.5, step: 0.5, invalid: !!glowProblem,
@@ -146,13 +146,13 @@ export function renderGeneralSettingsDialog(this: HouseplanEditorRuntime): Templ
title: t('gs.card_plan'),
body: html`
${colorRow({
label: t('gs.wall_fill'),
label: st('gs.wall_fill'),
picker: colorField({
hex: d.colors.wall_fill.c, opacity: d.colors.wall_fill.a, opacityLabel: t('space.opacity'),
onOpacity: (a) => this._setFillColor('wall_fill', { c: d.colors.wall_fill.c, a }),
resetLabel: t('btn.reset'),
onReset: () => this._setFillColor('wall_fill', { ...DEFAULT_FILL_COLORS.wall_fill }),
picker: html`<hp-color-opacity .label=${t('gs.wall_fill')} hide-label flat-swatch
picker: html`<hp-color-opacity .label=${st('gs.wall_fill')} hide-label flat-swatch
.opacityLabel=${t('space.opacity')} .pickerLabels=${host._colorPickerLabels}
.color=${d.colors.wall_fill.c} .opacity=${d.colors.wall_fill.a} .showOpacity=${true}
@hp-color-opacity-change=${(e: CustomEvent<{ color: string; opacity: number }>) => {
@@ -161,11 +161,11 @@ export function renderGeneralSettingsDialog(this: HouseplanEditorRuntime): Templ
}),
})}
${field({
label: t('gs.bg_mode'), help: this._help('gs.bg_mode.help'),
label: st('gs.bg_mode'), help: this._help('gs.bg_mode.help'),
control: segmented({
name: 'gs-bg-mode',
value: d.bgMode === 'daynight' ? 'daynight' : 'static',
ariaLabel: t('gs.bg_mode'),
ariaLabel: st('gs.bg_mode'),
options: [
{ value: 'static', label: t('gs.bg_static') },
{ value: 'daynight', label: t('gs.bg_daynight') },
@@ -201,7 +201,7 @@ export function renderGeneralSettingsDialog(this: HouseplanEditorRuntime): Templ
<div class="hpf-inline hpf-wrap">
${unitInput({
id: 'gs-north', value: d.northDegInput, unit: '°',
min: 0, max: 359, step: 1, placeholder: t('gs.north_ph'), invalid: !!northProblem,
min: 0, max: 359, step: 1, placeholder: st('gs.north_ph'), invalid: !!northProblem,
onInput: (raw) => {
const trimmed = raw.trim();
const n = trimmed === '' ? null : strictNumber(trimmed);
@@ -221,10 +221,15 @@ export function renderGeneralSettingsDialog(this: HouseplanEditorRuntime): Templ
${northProblem ? html`<p class="hpf-error" role="alert">${st(northProblem.message)}</p>` : nothing}
${toggleRow({
id: 'gs-sun-rays', icon: 'mdi:weather-sunny',
title: t('gs.sun_rays'), caption: st('gs.sun_rays_hint'),
title: st('gs.sun_rays'), caption: st('gs.sun_rays_hint'),
checked: d.sunRays, onChange: (v) => set({ sunRays: v }),
})}
${renderSunRayOriginSegment(d.sunRayOrigin, (key) => t(key), (sunRayOrigin) => set({ sunRayOrigin }))}`,
${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'),
checked: d.moon, onChange: (v) => set({ moon: v }),
})}`,
})}
${formCard({
id: 'data',
@@ -234,28 +239,28 @@ export function renderGeneralSettingsDialog(this: HouseplanEditorRuntime): Templ
${subsection({ title: t('gs.backup_group'), help: this._help('gs.backup_group.help') })}
<div class="hpf-actions">
<button class="btn" @click=${() => this._openBackupExport()}>
<ha-icon icon="mdi:download"></ha-icon>${t('backup.export_open')}
<ha-icon icon="mdi:download"></ha-icon>${st('backup.export_open')}
</button>
<span class="backupupload">
<button class="btn" type="button" @click=${(e: Event) =>
((e.currentTarget as HTMLElement).nextElementSibling as HTMLInputElement | null)?.click()}>
<ha-icon icon="mdi:upload"></ha-icon>${t('backup.import_open')}
<ha-icon icon="mdi:upload"></ha-icon>${st('backup.import_open')}
</button>
<input type="file" accept="application/json,.json" @change=${(event: Event) => this._pickBackupImport(event)} />
</span>
${host._canOptimizeUndo && host._undoKind === 'import' ? html`
<button class="btn" @click=${() => this._undoPlanOptimization()} ?disabled=${host._optimizeUndoBusy}>
<ha-icon icon="mdi:undo-variant"></ha-icon>${t('backup.undo_import')}
<ha-icon icon="mdi:undo-variant"></ha-icon>${st('backup.undo_import')}
</button>` : nothing}
</div>` : nothing}
${subsection({ title: t('gs.grid_group'), help: this._help('gs.grid_group.help') })}
${subsection({ title: st('gs.grid_group'), help: this._help('gs.grid_group.help') })}
<div class="hpf-actions">
<button class="btn" @click=${() => this.optimizePlans.open()}>
<ha-icon icon="mdi:broom"></ha-icon>${t('gs.align_all')}
</button>
${host._canOptimizeUndo && host._undoKind !== 'import' ? html`
<button class="btn" @click=${() => this._undoPlanOptimization()} ?disabled=${host._optimizeUndoBusy}>
<ha-icon icon="mdi:undo-variant"></ha-icon>${t('gs.optimize_undo')}
<ha-icon icon="mdi:undo-variant"></ha-icon>${st('gs.optimize_undo')}
</button>` : nothing}
</div>`,
})}
@@ -266,7 +271,7 @@ export function renderGeneralSettingsDialog(this: HouseplanEditorRuntime): Templ
colors: JSON.parse(JSON.stringify(DEFAULT_FILL_COLORS)), glowRadius: host._imperial ? 9.8 : 3,
glowRadiusInput: String(host._imperial ? 9.8 : 3),
bgColor: null, northDeg: null, northDegInput: '', bgMode: 'daynight', sunRays: false, sunRayOrigin: 'inner',
showRoomTooltip: true, radarShowLive: true, volumetricView: false, zigbeeTopology: { enabled: false, z2mBaseTopics: [] },
showRoomTooltip: true, radarShowLive: true, volumetricView: false, moon: true, zigbeeTopology: { enabled: false, z2mBaseTopics: [] },
})}>${t('gs.reset')}</button>
</div>
${footerStatus(problems.length
+1 -1
View File
@@ -28,6 +28,7 @@
* get one `requestUpdate()` when the runtime settles.
*/
import type { FurnitureGraphic } from './furniture';
import { ENTRY_BUILD_FINGERPRINT } from './editor-runtime-loader';
export type FurnitureArtState = 'ready' | 'pending' | 'fallback';
@@ -211,7 +212,6 @@ export function ensureFurnitureArtFor(
/* Page-scoped runtime wired to the real chunk. The retry token is replaced
* at build time with the exact content-hashed asset (bundle-manifest.mjs). */
const ENTRY_BUILD_FINGERPRINT = '__HOUSEPLAN_SOURCE_FINGERPRINT__';
const FURNITURE_ART_RETRY_ASSET = '__HOUSEPLAN_FURNITURE_ART_RETRY_ASSET__';
let retrySeq = 0;
+5 -4
View File
@@ -48,6 +48,7 @@ import {
rayRimEdges, rimStops, rimPeakAlpha, RIM_COLOR, type SunRayOrigin,
} from './sun';
import { dayCycleStageVars, renderDayCycleEnvironment } from './day-cycle-render';
import { moonLayer } from './moon-gate';
import { renderPaperShapes, type PaperShape } from './render/paper-scene';
import {
furnitureGraphic, furnitureArtIsLazy,
@@ -231,7 +232,7 @@ import {
roomFitGeometryBounds, roomFitOwnerFromPath, STAGE_TAP_DISTANCE_PX,
type RoomFitGestureCandidate,
} from './room-fit';
import { EditorRuntimeLoader, lazyLoadFailureMessage, safeRuntimeDiagnostic, type EditorRuntimeLoaderState } from './editor-runtime-loader';
import { ENTRY_BUILD_FINGERPRINT, EditorRuntimeLoader, lazyLoadFailureMessage, safeRuntimeDiagnostic, type EditorRuntimeLoaderState } from './editor-runtime-loader';
import { FURNITURE_ART_RUNTIME, composeUnsub, ensureFurnitureArtFor, furnitureArtBootPending, subscribeFurnitureArtLoadFailures } from './furniture-art-runtime';
import type { BackdropGuardState } from './backdrop-pick';
import {
@@ -302,7 +303,6 @@ import { isoWallMaterialVars, parseCssColor } from './iso-materials'; import { I
import { renderIsoTileShadow } from './iso-tiles';
import { displayVersion } from './card-version';
const CARD_VERSION = '1.78.0';
const ENTRY_BUILD_FINGERPRINT = '__HOUSEPLAN_SOURCE_FINGERPRINT__';
const EDITOR_RETRY_ASSET = '__HOUSEPLAN_EDITOR_RETRY_ASSET__';
const ISO_RETRY_ASSET = '__HOUSEPLAN_ISO_RETRY_ASSET__';
const PDF_RETRY_ASSET = '__HOUSEPLAN_PDF_RETRY_ASSET__';
@@ -2050,7 +2050,7 @@ export class HouseplanCard extends LitElement {
colors: FillColors; glowRadius: number; glowRadiusInput: string; bgColor: string | null;
/** sun on the plan (docs/SUN.md) */
northDeg: number | null; northDegInput: string; bgMode: 'static' | 'daynight'; sunRays: boolean; sunRayOrigin: SunRayOrigin;
showRoomTooltip: boolean; zigbeeTopology: import('./zigbee-topology-settings').ZigbeeTopologySettings; radarShowLive: boolean; volumetricView: boolean; busy: boolean;
showRoomTooltip: boolean; zigbeeTopology: import('./zigbee-topology-settings').ZigbeeTopologySettings; radarShowLive: boolean; volumetricView: boolean; moon: boolean; busy: boolean;
} | null = null;
private _pdfDialog = false;
private _supportDialog: SupportDialogState | null = null;
@@ -9772,6 +9772,7 @@ export class HouseplanCard extends LitElement {
bg_mode: gd.bgMode,
sun_rays: gd.sunRays,
sun_ray_origin: gd.sunRayOrigin,
moon: gd.moon,
};
}
@@ -10873,7 +10874,7 @@ export class HouseplanCard extends LitElement {
@pointerleave=${(e: PointerEvent) => this._stagePointerLeave(e)}
@pointerup=${(e: PointerEvent) => this._stagePointerUp(e)}
@pointercancel=${(e: PointerEvent) => this._stagePointerCancel(e)}>
${renderDayCycleEnvironment(dayCycle, dayCycleWeight)}
${renderDayCycleEnvironment(dayCycle, dayCycleWeight, moonLayer(this, this._sunGlobal(), dayCycle))}
${this._editorRuntime ? this._renderEditorSecondary() : nothing}
<div class="zoomwrap ${this._slide ? 'slide-' + this._slide : ''}"
?inert=${this._continuity.overlayBlocksInteraction || this._modeTransitionBusy}
+5 -4
View File
@@ -110,7 +110,7 @@ import {
roomTempThresholdDraft, roomTempThresholdInputValues, strictNumber, type SpaceDialogState,
} from './space-dialog';
import { rememberSpaceDialogBaseline, spaceDialogProblems } from './editors/space-form-state';
import { generalProblems, rememberGeneralBaseline } from './editors/general-form-state';
import { generalProblems, moonDraftOf, rememberGeneralBaseline, writeMoonSetting } from './editors/general-form-state';
import { rememberRoomBaseline } from './editors/room-form-state';
import { forgetMarkerBaseline, rememberMarkerBaseline } from './editors/marker-form-state';
import { commitPlanOptimization } from './plan-optimize-write';
@@ -713,9 +713,9 @@ export interface HouseplanEditorHostPort {
_sentPos: Map<string, DeviceLayout[string] | null>;
_serverCfg: ServerConfig | null;
_serverStorage: boolean;
_settings: { exclude_integrations?: string[]; group_lights?: boolean; show_all?: boolean; filter_seeded?: boolean; icon_rules?: { pattern: string; icon: string; }[]; show_room_tooltip?: boolean; sun_ray_origin?: SunRayOrigin; zigbee_topology?: { enabled?: boolean; z2m_base_topics?: string[] }; radar?: { version?: 1; show_live?: boolean; [key: string]: unknown }; };
_settings: { exclude_integrations?: string[]; group_lights?: boolean; show_all?: boolean; filter_seeded?: boolean; icon_rules?: { pattern: string; icon: string; }[]; show_room_tooltip?: boolean; moon?: boolean; sun_ray_origin?: SunRayOrigin; zigbee_topology?: { enabled?: boolean; z2m_base_topics?: string[] }; radar?: { version?: 1; show_live?: boolean; [key: string]: unknown }; };
_terminalFrame: 0 | 1 | 2;
_settingsDialog: { colors: FillColors; glowRadius: number; glowRadiusInput: string; bgColor: string | null; northDeg: number | null; northDegInput: string; bgMode: "static" | "daynight"; sunRays: boolean; sunRayOrigin: SunRayOrigin; showRoomTooltip: boolean; zigbeeTopology: ZigbeeTopologySettings; radarShowLive: boolean; volumetricView: boolean; busy: boolean; } | null;
_settingsDialog: { colors: FillColors; glowRadius: number; glowRadiusInput: string; bgColor: string | null; northDeg: number | null; northDegInput: string; bgMode: "static" | "daynight"; sunRays: boolean; sunRayOrigin: SunRayOrigin; showRoomTooltip: boolean; zigbeeTopology: ZigbeeTopologySettings; radarShowLive: boolean; volumetricView: boolean; moon: boolean; busy: boolean; } | null;
_supportDialog: SupportDialogState | null;
_showAll: boolean;
_showHidden: boolean;
@@ -8454,7 +8454,7 @@ public _openSettingsDialog = (): void => {
bgMode: bgModeOf(this.host._settings, {}),
sunRays: sunRaysOn(this.host._settings, {}),
sunRayOrigin: sunRayOriginOf(this.host._settings),
radarShowLive: this.host._settings.radar?.show_live !== false, volumetricView: volumetricViewOf(this.host._settings),
radarShowLive: this.host._settings.radar?.show_live !== false, volumetricView: volumetricViewOf(this.host._settings), moon: moonDraftOf(this.host._settings),
showRoomTooltip: showRoomTooltipOf(this.host._settings), zigbeeTopology: zigbeeTopologySettingsOf(this.host._settings), busy: false,
};
// #600 К10: снимок на момент открытия — от него считается «есть изменения».
@@ -9512,6 +9512,7 @@ public _updateDecorStyle(next: DecorStyle): void {
else settings.show_room_tooltip = false;
if (d.volumetricView) settings.volumetric_view = true;
else delete settings.volumetric_view;
writeMoonSetting(settings, d.moon);
const radarSettings = settings.radar;
if (!radarSettings || radarSettings.version == null || radarSettings.version === 1) {
settings.radar = { ...(radarSettings || {}), version: 1 };
+1 -16
View File
@@ -481,20 +481,15 @@
"pdf.asset_failed": "Ein Hintergrund- oder Dekorbild konnte nicht geladen werden.",
"pdf.scale": "Maßstab 1:{n}",
"pdf.north": "Nord",
"gs.title": "Allgemeine Einstellungen",
"gs.light_group": "Beleuchtung",
"gs.light_on": "Lichter an",
"gs.light_off": "Alle Lichter aus",
"gs.temp_group": "Temperatur",
"gs.temp_cold": "Kalt",
"gs.temp_ok": "Angenehm",
"gs.temp_hot": "Heiß",
"gs.lqi_group": "Zigbee-Signal",
"gs.lqi_low": "Schwaches Signal",
"gs.lqi_high": "Starkes Signal",
"gs.reset": "Auf Standard zurücksetzen",
"gs.saved": "Allgemeine Einstellungen gespeichert",
"gs.radar_show_live": "Live-Präsenz auf dem Plan anzeigen",
"gs.volumetric_view": "Plan in 2.5D anzeigen",
"radar.title": "Präsenz auf dem Plan",
"radar.help": "Verbinde diese Markierung mit exakten Home-Assistant-Radarentitäten und wähle Position sowie Koordinatenprofil. Live-Positionen werden auf den ausgewählten Raum begrenzt.",
@@ -730,8 +725,6 @@
"gs.glow_group": "Glow von Lichtquellen",
"gs.glow_base": "Grunddunkelheit des Hauses",
"gs.glow_light": "Standardlichtfarbe / Intensität",
"gs.wall_fill": "Wandfüllung",
"gs.glow_radius": "Glow-Radius",
"gs.glow_radius.help": "Legt den allgemeinen Radius des Lichtscheins in Metern oder Fuß fest; ein gerätespezifischer Radius überschreibt diesen Wert.",
"gs.glow_radius.help.aria": "Hilfe: allgemeiner Glow-Radius",
"gs.unit_m": "m",
@@ -1014,20 +1007,17 @@
"vac.trail_always": "Immer",
"gs.bg_color": "Hintergrund um den Plan",
"gs.bg_default": "Standardthema",
"gs.bg_mode": "Planhintergrund",
"gs.bg_mode.help": "„Folgt der Sonne“ verwendet sun.sun und ersatzweise die lokale Browserzeit; „Statische Farbe“ zeigt immer die ausgewählte Farbe.",
"gs.bg_mode.help.aria": "Hilfe: allgemeiner Planhintergrund",
"gs.bg_static": "Statische Farbe",
"gs.bg_daynight": "Folgt der Sonne",
"gs.sun_group": "Sonne",
"gs.sun_group": "Sonne und Mond",
"gs.sun_missing": "Die sun.sun-Entität wurde nicht gefunden — der Hintergrund folgt Ihrer lokalen Uhr; Fensterstrahlen sind nicht verfügbar.",
"gs.north": "Norden auf dem Plan",
"gs.north.help": "Der Winkel wird im Uhrzeigersinn von der oberen Vertikalen des Plans gemessen; Norden wird für Fensterstrahlen benötigt, nicht für den Hintergrund „Folgt der Sonne“.",
"gs.north.help.aria": "Hilfe: allgemeine Nordrichtung",
"gs.north_ph": "nicht gesetzt",
"gs.north_clear": "Löschen",
"gs.north_letter": "N",
"gs.sun_rays": "Sonnenlicht durch Fenster",
"gs.sun_ray_origin": "Sonnenstrahlen",
"gs.sun_ray_origin.inner": "Von den inneren Fensterecken",
"gs.sun_ray_origin.outer": "Von den äußeren Fensterecken",
@@ -1050,7 +1040,6 @@
"canvas.far_objects": "{n} Objekt(e) weit vom Plan entfernt",
"canvas.show_far": "Anzeigen",
"canvas.home_tip": "Der Plan ist dort drüben — klicken, um ihn anzupassen",
"gs.grid_group": "Planwartung",
"gs.align_all": "Pläne optimieren",
"gs.align_title": "Pläne optimieren",
"gs.align_none": "Alle Pläne verwenden bereits das aktuelle optimierte Datenmodell.",
@@ -1109,7 +1098,6 @@
"gs.align_warn": "Absichtlich zwischen Rasterknoten platzierte Elemente werden sich bewegen. Nach der Operation ist nur eine Rückgängig-Funktion verfügbar, und zwar nur bis zur nächsten Planbearbeitung.",
"gs.align_run": "Optimieren",
"gs.align_done": "Pläne optimiert: {n} Elemente bewegt, {m} Datensätze beibehalten, {r} Verweise repariert",
"gs.optimize_undo": "Letzte Optimierung rückgängig machen",
"gs.optimize_undone": "Die letzte Optimierung wurde rückgängig gemacht",
"decor.furniture": "Möbel",
"decor.image": "Bild",
@@ -1273,8 +1261,6 @@
"vac.residual_message": "Die zugeordneten Räume weichen um bis zu {error} voneinander ab. Diese ungefähre Kalibrierung anwenden, manuell verfeinern oder ohne Änderung der gespeicherten Einrichtung abbrechen.",
"vac.apply_proposal": "Anwenden",
"gs.backup_group": "Sichern und übertragen",
"backup.export_open": "Exportieren",
"backup.import_open": "Importieren",
"backup.export_title": "House Plan exportieren",
"backup.import_title": "House Plan importieren",
"backup.export_hint": "Wählen Sie, ob das Backup die gesamte House Plan-Konfiguration oder nur den aktuellen Bereich enthalten soll.",
@@ -1391,7 +1377,6 @@
"backup.add": "Bereich hinzufügen",
"backup.space_done": "Bereich importiert: {rooms} Räume, {markers} Geräte, {refs} bestehende Referenzen wiederhergestellt",
"backup.full_done": "Backup wiederhergestellt: {spaces} Bereiche, {rooms} Räume, {markers} Geräte",
"backup.undo_import": "Letzten vollständigen Import rückgängig machen",
"backup.import_undone": "Vollständiger Import rückgängig gemacht",
"backdrop.large_title": "Großes Bild",
"backdrop.large_body": "Dieses Bild ist {w}×{h} ({fileMb} MB Datei) und benötigt etwa {decodedMb} MB Speicher zur Anzeige. Auf Tablets kann das die Seite zum Absturz bringen.",
+1 -16
View File
@@ -481,20 +481,15 @@
"pdf.asset_failed": "A backdrop or decor image could not be loaded.",
"pdf.scale": "Scale 1:{n}",
"pdf.north": "North",
"gs.title": "General settings",
"gs.light_group": "Lights",
"gs.light_on": "Lights on",
"gs.light_off": "All lights off",
"gs.temp_group": "Temperature",
"gs.temp_cold": "Cold",
"gs.temp_ok": "Comfortable",
"gs.temp_hot": "Hot",
"gs.lqi_group": "Zigbee signal",
"gs.lqi_low": "Weak signal",
"gs.lqi_high": "Strong signal",
"gs.reset": "Reset to defaults",
"gs.saved": "General settings saved",
"gs.radar_show_live": "Show live presence on the plan",
"gs.volumetric_view": "Show the plan in 2.5D",
"radar.title": "Presence on the plan",
"radar.help": "Connect this marker to exact Home Assistant radar entities, place the sensor and choose its coordinate profile. Live positions are clipped to the selected room.",
@@ -730,8 +725,6 @@
"gs.glow_group": "Light-source glow",
"gs.glow_base": "House darkness",
"gs.glow_light": "Default light color / intensity",
"gs.wall_fill": "Wall fill",
"gs.glow_radius": "Glow radius",
"gs.glow_radius.help": "Sets the default glow radius in metres or feet; a device-specific radius overrides it.",
"gs.glow_radius.help.aria": "Help: default glow radius",
"gs.unit_m": "m",
@@ -1014,20 +1007,17 @@
"vac.trail_always": "Always",
"gs.bg_color": "Background around the plan",
"gs.bg_default": "Theme default",
"gs.bg_mode": "Plan background",
"gs.bg_mode.help": "“Follows the Sun” uses sun.sun, falling back to the browser's local clock; Static always shows the selected colour.",
"gs.bg_mode.help.aria": "Help: general plan background",
"gs.bg_static": "Static color",
"gs.bg_daynight": "Follows the Sun",
"gs.sun_group": "Sun",
"gs.sun_group": "Sun and Moon",
"gs.sun_missing": "The sun.sun entity was not found — the background follows your local clock; window rays are unavailable.",
"gs.north": "North on the plan",
"gs.north.help": "The angle is measured clockwise from the plan's upward vertical; north is required for window rays, not for the “Follows the Sun” background.",
"gs.north.help.aria": "Help: general north direction",
"gs.north_ph": "not set",
"gs.north_clear": "Clear",
"gs.north_letter": "N",
"gs.sun_rays": "Sunlight through windows",
"gs.sun_ray_origin": "Sun rays",
"gs.sun_ray_origin.inner": "From the inner window corners",
"gs.sun_ray_origin.outer": "From the outer window corners",
@@ -1050,7 +1040,6 @@
"canvas.far_objects": "{n} object(s) far from the plan",
"canvas.show_far": "Show",
"canvas.home_tip": "The plan is over there — click to fit it",
"gs.grid_group": "Plan maintenance",
"gs.align_all": "Optimize plans",
"gs.align_title": "Optimize plans",
"gs.align_none": "All plans already use the current optimized data model.",
@@ -1109,7 +1098,6 @@
"gs.align_warn": "Elements deliberately placed between grid nodes will move. One undo is available after the operation, only until the next plan edit.",
"gs.align_run": "Optimize",
"gs.align_done": "Plans optimized: {n} elements moved, {m} records maintained, {r} references repaired",
"gs.optimize_undo": "Undo last optimization",
"gs.optimize_undone": "The last optimization was undone",
"decor.furniture": "Furniture",
"decor.image": "Image",
@@ -1273,8 +1261,6 @@
"vac.residual_message": "The matched rooms disagree by up to {error}. Apply this approximate calibration, refine it manually, or cancel without changing the saved setup.",
"vac.apply_proposal": "Apply",
"gs.backup_group": "Backup and transfer",
"backup.export_open": "Export",
"backup.import_open": "Import",
"backup.export_title": "Export House Plan",
"backup.import_title": "Import House Plan",
"backup.export_hint": "Choose whether the backup should contain the whole House Plan configuration or only the current space.",
@@ -1391,7 +1377,6 @@
"backup.add": "Add space",
"backup.space_done": "Space imported: {rooms} rooms, {markers} devices, {refs} existing references restored",
"backup.full_done": "Backup restored: {spaces} spaces, {rooms} rooms, {markers} devices",
"backup.undo_import": "Undo last full import",
"backup.import_undone": "Full import undone",
"backdrop.large_title": "Large image",
"backdrop.large_body": "This image is {w}×{h} ({fileMb} MB file) and needs about {decodedMb} MB of memory to display. On tablets this can crash the page.",
+1 -16
View File
@@ -481,20 +481,15 @@
"pdf.asset_failed": "Impossible de charger une image de fond ou de décoration.",
"pdf.scale": "Échelle 1:{n}",
"pdf.north": "Nord",
"gs.title": "Paramètres généraux",
"gs.light_group": "Éclairage",
"gs.light_on": "Lumières allumées",
"gs.light_off": "Toutes les lumières éteintes",
"gs.temp_group": "Température",
"gs.temp_cold": "Froid",
"gs.temp_ok": "Confortable",
"gs.temp_hot": "Chaud",
"gs.lqi_group": "Signal Zigbee",
"gs.lqi_low": "Signal faible",
"gs.lqi_high": "Signal fort",
"gs.reset": "Réinitialiser par défaut",
"gs.saved": "Paramètres généraux enregistrés",
"gs.radar_show_live": "Afficher la présence en direct sur le plan",
"gs.volumetric_view": "Afficher le plan en 2.5D",
"radar.title": "Présence sur le plan",
"radar.help": "Reliez ce marqueur aux entités radar exactes de Home Assistant, placez le capteur et choisissez son profil de coordonnées. Les positions en direct sont limitées à la pièce sélectionnée.",
@@ -730,8 +725,6 @@
"gs.glow_group": "Halo des sources lumineuses",
"gs.glow_base": "Obscurité de la maison",
"gs.glow_light": "Couleur / intensité de lumière par défaut",
"gs.wall_fill": "Remplissage des murs",
"gs.glow_radius": "Rayon du halo",
"gs.glow_radius.help": "Définit le rayon général du halo en mètres ou en pieds ; le rayon propre à un appareil remplace cette valeur.",
"gs.glow_radius.help.aria": "Aide : rayon général du halo",
"gs.unit_m": "m",
@@ -1014,20 +1007,17 @@
"vac.trail_always": "Toujours",
"gs.bg_color": "Arrière-plan autour du plan",
"gs.bg_default": "Valeur par défaut du thème",
"gs.bg_mode": "Arrière-plan du plan",
"gs.bg_mode.help": "« Suit le soleil » utilise sun.sun ou, à défaut, l’heure locale du navigateur ; « Couleur statique » affiche toujours la couleur choisie.",
"gs.bg_mode.help.aria": "Aide : arrière-plan général du plan",
"gs.bg_static": "Couleur statique",
"gs.bg_daynight": "Suit le soleil",
"gs.sun_group": "Soleil",
"gs.sun_group": "Soleil et Lune",
"gs.sun_missing": "L’entité sun.sun est introuvable — l’arrière-plan suit votre heure locale ; les rayons de soleil par les fenêtres sont indisponibles.",
"gs.north": "Nord sur le plan",
"gs.north.help": "L’angle se mesure dans le sens horaire depuis la verticale supérieure du plan ; le nord sert aux rayons des fenêtres, pas à l’arrière-plan « Suit le soleil ».",
"gs.north.help.aria": "Aide : direction générale du nord",
"gs.north_ph": "non défini",
"gs.north_clear": "Effacer",
"gs.north_letter": "N",
"gs.sun_rays": "Lumière du soleil à travers les fenêtres",
"gs.sun_ray_origin": "Rayons du soleil",
"gs.sun_ray_origin.inner": "Depuis les angles intérieurs des fenêtres",
"gs.sun_ray_origin.outer": "Depuis les angles extérieurs des fenêtres",
@@ -1050,7 +1040,6 @@
"canvas.far_objects": "{n} objet(s) éloigné(s) du plan",
"canvas.show_far": "Afficher",
"canvas.home_tip": "Le plan est là-bas — cliquez pour l’ajuster à l’écran",
"gs.grid_group": "Maintenance du plan",
"gs.align_all": "Optimiser les plans",
"gs.align_title": "Optimiser les plans",
"gs.align_none": "Tous les plans utilisent déjà le modèle de données optimisé actuel.",
@@ -1109,7 +1098,6 @@
"gs.align_warn": "Les éléments volontairement placés entre les nœuds de la grille seront déplacés. Une seule annulation est disponible après l’opération, uniquement jusqu’à la prochaine modification du plan.",
"gs.align_run": "Optimiser",
"gs.align_done": "Plans optimisés : {n} élément(s) déplacé(s), {m} enregistrement(s) maintenu(s), {r} référence(s) réparée(s)",
"gs.optimize_undo": "Annuler la dernière optimisation",
"gs.optimize_undone": "La dernière optimisation a été annulée",
"decor.furniture": "Mobilier",
"decor.image": "Image",
@@ -1273,8 +1261,6 @@
"vac.residual_message": "Les pièces correspondantes présentent un écart maximal de {error}. Appliquez cette calibration approximative, affinez-la manuellement ou annulez sans modifier la configuration enregistrée.",
"vac.apply_proposal": "Appliquer",
"gs.backup_group": "Sauvegarde et transfert",
"backup.export_open": "Exporter",
"backup.import_open": "Importer",
"backup.export_title": "Exporter House Plan",
"backup.import_title": "Importer House Plan",
"backup.export_hint": "Choisissez si la sauvegarde doit contenir toute la configuration House Plan ou uniquement l’espace actuel.",
@@ -1391,7 +1377,6 @@
"backup.add": "Ajouter un espace",
"backup.space_done": "Espace importé : {rooms} pièces, {markers} appareils, {refs} références existantes restaurées",
"backup.full_done": "Sauvegarde restaurée : {spaces} espaces, {rooms} pièces, {markers} appareils",
"backup.undo_import": "Annuler la dernière importation complète",
"backup.import_undone": "Importation complète annulée",
"backdrop.large_title": "Grande image",
"backdrop.large_body": "Cette image fait {w}×{h} (fichier de {fileMb} Mo) et nécessite environ {decodedMb} Mo de mémoire pour s'afficher. Sur tablette, cela peut faire planter la page.",
+1 -1
View File
@@ -6,8 +6,8 @@ import {
type LazyLanguageModule,
type LocaleDictionary,
} from './language-runtime';
import { ENTRY_BUILD_FINGERPRINT as BUILD_FINGERPRINT } from '../editor-runtime-loader';
const BUILD_FINGERPRINT = '__HOUSEPLAN_SOURCE_FINGERPRINT__';
const GERMAN_RETRY_ASSET = '__HOUSEPLAN_DE_RETRY_ASSET__';
const FRENCH_RETRY_ASSET = '__HOUSEPLAN_FR_RETRY_ASSET__';
+1 -16
View File
@@ -481,20 +481,15 @@
"pdf.asset_failed": "Не удалось загрузить подложку или изображение декора.",
"pdf.scale": "Масштаб 1:{n}",
"pdf.north": "Север",
"gs.title": "Общие настройки",
"gs.light_group": "Освещение",
"gs.light_on": "Свет включён",
"gs.light_off": "Весь свет выключен",
"gs.temp_group": "Температура",
"gs.temp_cold": "Холодно",
"gs.temp_ok": "Комфорт",
"gs.temp_hot": "Жарко",
"gs.lqi_group": "Сигнал Zigbee",
"gs.lqi_low": "Слабый сигнал",
"gs.lqi_high": "Сильный сигнал",
"gs.reset": "Сбросить к умолчаниям",
"gs.saved": "Общие настройки сохранены",
"gs.radar_show_live": "Показывать текущее присутствие на плане",
"gs.volumetric_view": "Объёмный вид плана (2.5D)",
"radar.title": "Присутствие на плане",
"radar.help": "Свяжите маркер с точными сущностями радара Home Assistant, укажите положение датчика и профиль координат. Живые позиции обрезаются по выбранной комнате.",
@@ -730,8 +725,6 @@
"gs.glow_group": "Свечение источников света",
"gs.glow_base": "Темнота дома",
"gs.glow_light": "Цвет света по умолчанию / интенсивность",
"gs.wall_fill": "Цвет заливки стен",
"gs.glow_radius": "Радиус свечения",
"gs.glow_radius.help": "Задаёт общий радиус светового пятна в метрах или футах; персональный радиус устройства заменяет это значение.",
"gs.glow_radius.help.aria": "Подсказка: общий радиус свечения",
"gs.unit_m": "м",
@@ -1014,20 +1007,17 @@
"vac.trail_always": "Показывать всегда",
"gs.bg_color": "Цвет фона вокруг плана",
"gs.bg_default": "Как в теме",
"gs.bg_mode": "Фон плана",
"gs.bg_mode.help": "«Следует за Солнцем» использует sun.sun, а при недоступности — локальные часы браузера; статичный режим всегда показывает выбранный цвет.",
"gs.bg_mode.help.aria": "Подсказка: общий фон плана",
"gs.bg_static": "Статичный цвет",
"gs.bg_daynight": "Следует за Солнцем",
"gs.sun_group": "Солнце",
"gs.sun_group": "Солнце и Луна",
"gs.sun_missing": "Сущность sun.sun не найдена — фон следует локальным часам, а оконные лучи недоступны.",
"gs.north": "Север на плане",
"gs.north.help": "Угол отсчитывается по часовой стрелке от верхней вертикали плана; север нужен для оконных лучей, а не для фона «Следует за Солнцем».",
"gs.north.help.aria": "Подсказка: общее направление севера",
"gs.north_ph": "не задан",
"gs.north_clear": "Сбросить",
"gs.north_letter": "С",
"gs.sun_rays": "Солнце в окнах",
"gs.sun_ray_origin": "Лучи солнца",
"gs.sun_ray_origin.inner": "От внутренних углов окон",
"gs.sun_ray_origin.outer": "От внешних углов окон",
@@ -1050,7 +1040,6 @@
"canvas.far_objects": "Объектов далеко от плана: {n}",
"canvas.show_far": "Показать",
"canvas.home_tip": "План там — нажмите, чтобы вписать",
"gs.grid_group": "Обслуживание планов",
"gs.align_all": "Оптимизировать планы",
"gs.align_title": "Оптимизировать планы",
"gs.align_none": "Все планы уже используют актуальную и оптимальную модель данных.",
@@ -1109,7 +1098,6 @@
"gs.align_warn": "Элементы, намеренно поставленные между узлами, будут сдвинуты. После операции доступна одна отмена — только до следующего изменения плана.",
"gs.align_run": "Оптимизировать",
"gs.align_done": "Планы оптимизированы: сдвинуто элементов — {n}, обслужено записей — {m}, исправлено ссылок — {r}",
"gs.optimize_undo": "Отменить последнюю оптимизацию",
"gs.optimize_undone": "Последняя оптимизация отменена",
"decor.furniture": "Мебель",
"decor.image": "Изображение",
@@ -1273,8 +1261,6 @@
"vac.residual_message": "Совпавшие комнаты расходятся максимум на {error}. Примените приблизительную калибровку, подгоните её вручную либо отмените без изменения сохранённых настроек.",
"vac.apply_proposal": "Применить",
"gs.backup_group": "Резервная копия и перенос",
"backup.export_open": "Экспорт",
"backup.import_open": "Импорт",
"backup.export_title": "Экспорт House Plan",
"backup.import_title": "Импорт House Plan",
"backup.export_hint": "Выберите, должна ли копия содержать всю конфигурацию House Plan или только текущее пространство.",
@@ -1391,7 +1377,6 @@
"backup.add": "Добавить пространство",
"backup.space_done": "Пространство импортировано: комнат — {rooms}, устройств — {markers}, существующих ссылок восстановлено — {refs}",
"backup.full_done": "Копия восстановлена: пространств — {spaces}, комнат — {rooms}, устройств — {markers}",
"backup.undo_import": "Отменить последний полный импорт",
"backup.import_undone": "Полный импорт отменён",
"backdrop.large_title": "Большое изображение",
"backdrop.large_body": "Изображение {w}×{h} ({fileMb} МБ), для показа потребуется около {decodedMb} МБ памяти. На планшетах это может привести к падению страницы.",
+18 -1
View File
@@ -55,6 +55,8 @@
"gs.bg_custom_hint": "Eigene Hintergrundfarbe.",
"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.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}",
@@ -91,5 +93,20 @@
"fill.seg_lqi": "Zigbee",
"fill.seg_light": "Licht",
"fill.seg_temp": "Temperatur",
"fill.seg_custom": "Eigene"
"fill.seg_custom": "Eigene",
"backup.export_open": "Exportieren",
"backup.import_open": "Importieren",
"backup.undo_import": "Letzten vollständigen Import rückgängig machen",
"gs.bg_mode": "Planhintergrund",
"gs.glow_radius": "Glow-Radius",
"gs.grid_group": "Planwartung",
"gs.light_group": "Beleuchtung",
"gs.lqi_group": "Zigbee-Signal",
"gs.north_ph": "nicht gesetzt",
"gs.optimize_undo": "Letzte Optimierung rückgängig machen",
"gs.radar_show_live": "Live-Präsenz auf dem Plan anzeigen",
"gs.sun_rays": "Sonnenlicht durch Fenster",
"gs.temp_group": "Temperatur",
"gs.title": "Allgemeine Einstellungen",
"gs.wall_fill": "Wandfüllung"
}
+18 -1
View File
@@ -55,6 +55,8 @@
"gs.bg_custom_hint": "Custom background color.",
"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.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",
@@ -91,5 +93,20 @@
"fill.seg_lqi": "Zigbee",
"fill.seg_light": "Lights",
"fill.seg_temp": "Temperature",
"fill.seg_custom": "Custom"
"fill.seg_custom": "Custom",
"backup.export_open": "Export",
"backup.import_open": "Import",
"backup.undo_import": "Undo last full import",
"gs.bg_mode": "Plan background",
"gs.glow_radius": "Glow radius",
"gs.grid_group": "Plan maintenance",
"gs.light_group": "Lights",
"gs.lqi_group": "Zigbee signal",
"gs.north_ph": "not set",
"gs.optimize_undo": "Undo last optimization",
"gs.radar_show_live": "Show live presence on the plan",
"gs.sun_rays": "Sunlight through windows",
"gs.temp_group": "Temperature",
"gs.title": "General settings",
"gs.wall_fill": "Wall fill"
}
+18 -1
View File
@@ -55,6 +55,8 @@
"gs.bg_custom_hint": "Couleur de fond personnalisée.",
"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.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}",
@@ -91,5 +93,20 @@
"fill.seg_lqi": "Zigbee",
"fill.seg_light": "Lumières",
"fill.seg_temp": "Température",
"fill.seg_custom": "Perso"
"fill.seg_custom": "Perso",
"backup.export_open": "Exporter",
"backup.import_open": "Importer",
"backup.undo_import": "Annuler la dernière importation complète",
"gs.bg_mode": "Arrière-plan du plan",
"gs.glow_radius": "Rayon du halo",
"gs.grid_group": "Maintenance du plan",
"gs.light_group": "Éclairage",
"gs.lqi_group": "Signal Zigbee",
"gs.north_ph": "non défini",
"gs.optimize_undo": "Annuler la dernière optimisation",
"gs.radar_show_live": "Afficher la présence en direct sur le plan",
"gs.sun_rays": "Lumière du soleil à travers les fenêtres",
"gs.temp_group": "Température",
"gs.title": "Paramètres généraux",
"gs.wall_fill": "Remplissage des murs"
}
+18 -1
View File
@@ -55,6 +55,8 @@
"gs.bg_custom_hint": "Свой цвет фона.",
"gs.bg_theme_hint": "Используется фон темы.",
"gs.sun_rays_hint": "Рисовать солнечные лучи через оконные проёмы.",
"gs.moon": "Луна на плане в сумерках и ночью",
"gs.moon_hint": "Видна на фоне за планом при высоте от 3° над горизонтом, кроме новолуния; положение считается по координатам дома из Home Assistant.",
"gs.error_glow_radius": "Введите радиус больше нуля.",
"gs.error_north": "Введите целое число от 0° до 359°.",
"gs.opacity_of": "Прозрачность: {name}",
@@ -91,5 +93,20 @@
"fill.seg_lqi": "Zigbee",
"fill.seg_light": "Свет",
"fill.seg_temp": "Температура",
"fill.seg_custom": "Свой цвет"
"fill.seg_custom": "Свой цвет",
"backup.export_open": "Экспорт",
"backup.import_open": "Импорт",
"backup.undo_import": "Отменить последний полный импорт",
"gs.bg_mode": "Фон плана",
"gs.glow_radius": "Радиус свечения",
"gs.grid_group": "Обслуживание планов",
"gs.light_group": "Освещение",
"gs.lqi_group": "Сигнал Zigbee",
"gs.north_ph": "не задан",
"gs.optimize_undo": "Отменить последнюю оптимизацию",
"gs.radar_show_live": "Показывать текущее присутствие на плане",
"gs.sun_rays": "Солнце в окнах",
"gs.temp_group": "Температура",
"gs.title": "Общие настройки",
"gs.wall_fill": "Цвет заливки стен"
}
File diff suppressed because one or more lines are too long
+50
View File
@@ -0,0 +1,50 @@
/**
* 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.
*
* 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
* 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';
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;
/** Earliest next attempt; 0 before the first, Infinity after a foreign build. */
let nextAttempt = 0;
/**
* 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.
*/
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) {
const retry = nextAttempt > 0;
nextAttempt = Date.now() + 30_000;
(retry
? import(/* @vite-ignore */ new URL(`${MOON_RETRY_ASSET}?${nextAttempt}`, import.meta.url).href) as Promise<MoonRuntime>
: import('./moon-runtime')
).then((module) => {
if (module.MOON_RUNTIME_FINGERPRINT !== ENTRY_BUILD_FINGERPRINT) { nextAttempt = Infinity; return; }
runtime = module;
host.requestUpdate();
}, () => undefined);
}
return runtime?.renderMoon(host, settings, state.phase) ?? nothing;
}
+122
View File
@@ -0,0 +1,122 @@
/**
* 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.
*
* 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.
*/
import { html, nothing, type TemplateResult } from 'lit';
import { MOON_ART } from './moon-art.generated';
import {
MOON_SHARP_FROM, moonFingerprint, moonPhasePath, moonView, type MoonView,
} from './moon';
import type { DayCyclePhase } from './sun';
export const MOON_RUNTIME_FINGERPRINT = '__HOUSEPLAN_SOURCE_FINGERPRINT__';
/** The card that shows the moon: the View card or `houseplan-space-card`. */
export interface MoonHost {
readonly isConnected: boolean;
readonly updateComplete: Promise<unknown>;
readonly hass?: { config?: unknown };
requestUpdate(): void;
}
/** C3: the environment ticker period; the moon rises at most 0.25°/min. */
export const MOON_TICK_MS = 30_000;
/**
* C6: box `min(200px, 25cqmin)` measured on the scene (the environment is the
* size container), 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.
*/
const MOON_CSS = '.hp-day-cycle-env{container-type:size}'
+ '.hp-moon{--hp-moon-box:min(200px,25cqmin);position:absolute;'
+ 'left:calc(var(--hp-moon-box)*.05);top:calc(var(--hp-moon-box)*.05);'
+ 'width:var(--hp-moon-box);height:var(--hp-moon-box);opacity:0;pointer-events:none;'
+ 'transition:opacity 2000ms cubic-bezier(.22,.61,.36,1)}' // RAY_FADE_MS, pinned by the test
+ '.hp-moon.on{opacity:1}'
+ '@media (prefers-reduced-motion:reduce){.hp-moon{transition:none}}';
interface MoonWatch {
settings: unknown;
phase: DayCyclePhase;
/** Fingerprint of what is on screen (or was last asked for). */
key: string;
/** Set by every render that includes the moon; cleared when a tick asks for one. */
seen: boolean;
timer: ReturnType<typeof setInterval>;
}
const watches = new WeakMap<MoonHost, MoonWatch>();
function forget(host: MoonHost, watch: MoonWatch): void {
clearInterval(watch.timer);
if (watches.get(host) === watch) watches.delete(host);
}
/**
* C3: between renders only the clock moves the moon. Every 30 s the tick
* recomputes it from the last rendered inputs; an equal fingerprint costs no
* render. A host that re-rendered without a moon (switched off, editor mode,
* static background) or left the page drops its ticker; a hidden page skips.
*/
export function moonTick(host: MoonHost, now: Date = new Date()): void {
const watch = watches.get(host);
if (!watch) return;
if (!host.isConnected) { forget(host, watch); return; }
if (globalThis.document?.visibilityState === 'hidden') return;
const key = moonFingerprint(moonView(watch.settings, watch.phase, host.hass?.config, now));
if (key === watch.key) return;
watch.key = key;
watch.seen = false;
host.requestUpdate();
void host.updateComplete.then(() => { if (!watch.seen) forget(host, watch); });
}
function remember(host: MoonHost, settings: unknown, phase: DayCyclePhase, view: MoonView): void {
const key = moonFingerprint(view);
const watch = watches.get(host);
if (watch) {
Object.assign(watch, { settings, phase, key, seen: true });
return;
}
watches.set(host, {
settings, phase, key, seen: true,
timer: setInterval(() => moonTick(host), MOON_TICK_MS),
});
}
/**
* The element stays in the environment while the moon is switched on, at
* opacity 0 when hidden, so rising, setting, the new-moon threshold and the
* day phase all fade (C4). A freshly created element takes its final state at
* once: the first appearance after a page or chunk load is not animated.
*/
export function renderMoon(
host: MoonHost, settings: unknown, phase: DayCyclePhase, now: Date = new Date(),
): TemplateResult {
const view = moonView(settings, phase, host.hass?.config, now);
remember(host, settings, phase, view);
return html`<style>${MOON_CSS}</style><svg class="hp-moon${view.visible ? ' on' : ''}"
viewBox="0 0 512 512" aria-hidden="true" focusable="false"
data-moon-k=${view.k.toFixed(2)} data-moon-visible=${view.visible ? 'true' : 'false'}>
<defs>
<g id="hp-moon-art">${MOON_ART}</g>
<filter id="hp-moon-soft" x="-5%" y="-5%" width="110%" height="110%">
<feGaussianBlur stdDeviation="5"></feGaussianBlur>
</filter>
<mask id="hp-moon-phase" maskUnits="userSpaceOnUse" x="0" y="0" width="512" height="512">
<path d=${moonPhasePath(view.k)} fill="#fff"
filter=${view.k >= MOON_SHARP_FROM ? nothing : 'url(#hp-moon-soft)'}></path>
</mask>
</defs>
<use href="#hp-moon-art" opacity="0.08"></use>
<use href="#hp-moon-art" mask="url(#hp-moon-phase)"></use>
</svg>`;
}
+156
View File
@@ -0,0 +1,156 @@
/**
* 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.
*
* Home Assistant publishes no moon altitude, so the card computes it from
* `hass.config.latitude/longitude` and the browser clock: the short Meeus
* series (as in SunCalc) plus the topocentric parallax, without refraction.
* 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';
/**
* C1: the same 3° as the window rays (`RAY_ELEVATION_MIN`), so both lights
* obey one horizon. A literal, not an import: every name the lazy chunk takes
* from the initial graph is one more export there (test/moon.test.mjs pins it).
*/
export const MOON_ELEVATION_MIN = 3;
/** C1: a thinner crescent reads as a scratch on a wall tablet. */
export const MOON_MIN_ILLUMINATION = 0.03;
const RAD = Math.PI / 180;
const DAY_MS = 86_400_000;
const J1970 = 2_440_588;
const J2000 = 2_451_545;
const OBLIQUITY = RAD * 23.4397;
const EARTH_RADIUS_KM = 6378.14;
const SUN_DISTANCE_KM = 149_598_000;
const daysSinceJ2000 = (date: Date): number => date.valueOf() / DAY_MS - 0.5 + J1970 - J2000;
const rightAscension = (l: number, b: number): number =>
Math.atan2(Math.sin(l) * Math.cos(OBLIQUITY) - Math.tan(b) * Math.sin(OBLIQUITY), Math.cos(l));
const declination = (l: number, b: number): number =>
Math.asin(Math.sin(b) * Math.cos(OBLIQUITY) + Math.cos(b) * Math.sin(OBLIQUITY) * Math.sin(l));
interface Equatorial { ra: number; dec: number; dist: number }
/** Geocentric moon: the short Meeus series, degrees per day as in SunCalc. */
function moonCoords(d: number): Equatorial {
const L = RAD * (218.316 + 13.176396 * d);
const M = RAD * (134.963 + 13.064993 * d);
const F = RAD * (93.272 + 13.22935 * d);
const l = L + RAD * 6.289 * Math.sin(M);
const b = RAD * 5.128 * Math.sin(F);
return { ra: rightAscension(l, b), dec: declination(l, b), dist: 385_001 - 20_905 * Math.cos(M) };
}
function sunCoords(d: number): Equatorial {
const M = RAD * (357.5291 + 0.98560028 * d);
const C = RAD * (1.9148 * Math.sin(M) + 0.02 * Math.sin(2 * M) + 0.0003 * Math.sin(3 * M));
const L = M + C + RAD * 102.9372 + Math.PI;
return { ra: rightAscension(L, 0), dec: declination(L, 0), dist: SUN_DISTANCE_KM };
}
export interface MoonPosition {
/** Topocentric altitude, degrees, airless (no refraction). */
altitude: number;
/** Degrees clockwise from north. */
azimuth: number;
}
/** C2: topocentric altitude/azimuth; the parallax `h − π·cos h` matters by the horizon. */
export function moonPosition(date: Date, latitude: number, longitude: number): MoonPosition {
const d = daysSinceJ2000(date);
const phi = RAD * latitude;
const c = moonCoords(d);
const H = RAD * (280.16 + 360.9856235 * d) + RAD * longitude - c.ra;
const geocentric = Math.asin(Math.sin(phi) * Math.sin(c.dec) + Math.cos(phi) * Math.cos(c.dec) * Math.cos(H));
const parallax = Math.asin(EARTH_RADIUS_KM / c.dist);
const altitude = geocentric - parallax * Math.cos(geocentric);
const south = Math.atan2(Math.sin(H), Math.cos(H) * Math.sin(phi) - Math.tan(c.dec) * Math.cos(phi));
return {
altitude: altitude / RAD,
azimuth: (((south / RAD + 180) % 360) + 360) % 360,
};
}
export interface MoonIllumination {
/** Illuminated fraction k, 0…1, from the Sun–Moon elongation. */
fraction: number;
/** Growing towards full; by the sign of the bright limb's position angle. */
waxing: boolean;
}
/** C2: `k = (1 + cos i) / 2`; independent of where on Earth the plan is. */
export function moonIllumination(date: Date): MoonIllumination {
const d = daysSinceJ2000(date);
const s = sunCoords(d);
const m = moonCoords(d);
const elongation = Math.acos(Math.sin(s.dec) * Math.sin(m.dec)
+ Math.cos(s.dec) * Math.cos(m.dec) * Math.cos(s.ra - m.ra));
const inc = Math.atan2(s.dist * Math.sin(elongation), m.dist - s.dist * Math.cos(elongation));
const angle = Math.atan2(Math.cos(s.dec) * Math.sin(s.ra - m.ra),
Math.sin(s.dec) * Math.cos(m.dec) - Math.cos(s.dec) * Math.sin(m.dec) * Math.cos(s.ra - m.ra));
return { fraction: (1 + Math.cos(inc)) / 2, waxing: angle < 0 };
}
/** C1 thresholds on an already computed sky: the environment phase, 3° and 3 %. */
export function moonShownAt(phase: DayCyclePhase, altitude: number, fraction: number): boolean {
return phase !== 'day' && altitude >= MOON_ELEVATION_MIN && fraction >= MOON_MIN_ILLUMINATION;
}
export interface MoonView {
visible: boolean;
/** Illuminated fraction quantised to 0.01: the render changes only with the fingerprint (C3). */
k: number;
}
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`.
*/
export function moonView(settings: unknown, phase: DayCyclePhase, config: unknown, now: Date): MoonView {
const { fraction } = moonIllumination(now);
const k = Math.round(fraction * 100) / 100;
const { latitude, longitude } = (config ?? {}) as { latitude?: unknown; longitude?: unknown };
if ((settings as { moon?: unknown } | null | undefined)?.moon !== true
|| !finite(latitude) || !finite(longitude)) return { visible: false, k };
return { visible: moonShownAt(phase, moonPosition(now, latitude, longitude).altitude, fraction), k };
}
/** 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)}`;
}
/** Disc of the designer's pack: `viewBox 0 0 512 512`, centre 256, radius 240. */
export const MOON_BOX = 512;
export const MOON_R = 240;
/** Below this the terminator is feathered in the mask; a full disc keeps the art's own edge. */
export const MOON_SHARP_FROM = 0.995;
/**
* C5, owner 2026-09-29: the lit side is always the LEFT one, in both
* hemispheres; waning runs the waxing states backwards. The lit region is the
* left half of the box (its outer arc runs along the box, r 256, never along
* the limb, so the art keeps its anti-aliased edge) plus or minus the
* terminator half-ellipse `R × R·|2k−1|`: bulging right when k > 0.5.
*/
export function moonPhasePath(k: number): string {
const c = MOON_BOX / 2;
const top = c - MOON_R;
const bottom = c + MOON_R;
const rx = MOON_R * Math.abs(2 * Math.min(1, Math.max(0, k)) - 1);
const terminator = rx < 0.5
? `L${c} ${top}`
: `A${rx.toFixed(2)} ${MOON_R} 0 0 ${k > 0.5 ? 0 : 1} ${c} ${top}`;
return `M${c} 0A${c} ${c} 0 0 0 ${c} ${MOON_BOX}L${c} ${bottom}${terminator}L${c} 0Z`;
}
+1
View File
@@ -892,6 +892,7 @@ class HouseplanSpaceCard extends LitElement {
showTemperature: this._config.show_temperature !== false,
showSignal: this._config.show_signal !== false,
inert: this._continuity.overlayBlocksInteraction,
moonHost: this,
});
if (!stage) {
return this._errorCard(t(this._lang, 'space_card.not_found', { id: spaceId }));
+4 -1
View File
@@ -25,6 +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 { dayCycleStageVars, renderDayCycleEnvironment } from './day-cycle-render';
import type { DevItem, OpeningCfg, ServerConfig } from './types';
import { floorMinusBodies, physicalBodyParts, polyclipPathD } from './physical-geometry';
@@ -219,6 +220,8 @@ export interface StaticRenderOpts {
glowRuntime?: StaticGlowRuntime;
/** Deterministic clock injection for unit/smoke fixtures; production omits it. */
dayCycleNow?: Date | number;
/** #661: the card that shows the moon (re-rendered when its runtime arrives or it moves). */
moonHost?: MoonHost;
virtualLights?: VirtualLightSnapshot | null;
/**
* Resolve a stored content url to what the DOM may actually request — the
@@ -927,7 +930,7 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
<div class="hp-static-stage${dayCycle ? ` daycycle phase-${dayCycle.phase}` : ''}"
?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)}
${renderDayCycleEnvironment(dayCycle, 1, moonLayer(o.moonHost, o.cfg?.settings, dayCycle))}
${dayCycle && paperShapes.length ? svg`<svg class="hp-paper-outline-svg"
viewBox="${vb[0]} ${vb[1]} ${vb[2]} ${vb[3]}"
preserveAspectRatio="xMidYMid meet" aria-hidden="true" pointer-events="none">
+2
View File
@@ -305,6 +305,8 @@ export interface ServerConfig {
show_room_tooltip?: boolean;
/** #649: the whole installation views plans in 2.5D; absent or false keeps Flat. */
volumetric_view?: boolean;
/** #661: the moon on the "Follow the Sun" background; only `true` switches it on. */
moon?: boolean;
north_deg?: number;
bg_mode?: 'static' | 'daynight';
sun_rays?: boolean;
+27 -5
View File
@@ -191,7 +191,7 @@ test('bundle manifest separates static initial graph from dynamic editor graph',
'houseplan-assets/editor.js', 'houseplan-assets/houseplan-onboarding-runtime-HASH.js',
'houseplan-assets/de-HASH.js', 'houseplan-assets/iso-scene-render-HASH.js',
'houseplan-assets/furniture-plan-art.generated-HASH.js',
'houseplan-assets/pdf-export-HASH.js',
'houseplan-assets/pdf-export-HASH.js', 'houseplan-assets/moon-runtime-HASH.js',
],
},
'shared.js': {
@@ -232,6 +232,11 @@ test('bundle manifest separates static initial graph from dynamic editor graph',
code: 'PDF export runtime', isEntry: false, imports: ['shared.js'], dynamicImports: [],
modules: { '/repo/src/pdf/pdf-export.ts': {} },
},
'houseplan-assets/moon-runtime-HASH.js': {
type: 'chunk', fileName: 'houseplan-assets/moon-runtime-HASH.js',
code: 'moon runtime', isEntry: false, imports: ['shared.js'], dynamicImports: [],
modules: { '/repo/src/moon-runtime.ts': {}, '/repo/src/moon-art.generated.ts': {} },
},
}, 'fingerprint');
assert.equal(manifest.entry, 'houseplan-card.js');
assert.equal(manifest.panelEntry, 'houseplan-panel.js');
@@ -252,6 +257,9 @@ test('bundle manifest separates static initial graph from dynamic editor graph',
assert.deepEqual(manifest.lazyIsometricFiles, ['houseplan-assets/iso-scene-render-HASH.js']);
assert.deepEqual(manifest.lazyFurnitureArtFiles, ['houseplan-assets/furniture-plan-art.generated-HASH.js']);
assert.deepEqual(manifest.lazyPdfFiles, ['houseplan-assets/pdf-export-HASH.js']);
// #661 AC8: the moon (math, art, template) is its own lazy graph, never initial.
assert.deepEqual(manifest.lazyMoonFiles, ['houseplan-assets/moon-runtime-HASH.js']);
assert.equal(manifest.initialViewFiles.includes('houseplan-assets/moon-runtime-HASH.js'), false);
assert.deepEqual(manifest.lazyEditorFiles, ['houseplan-assets/editor.js', 'houseplan-assets/furniture-plan-art.generated-HASH.js']);
// #627: found by module, not as a root of the initial graph; own graph.
assert.deepEqual(manifest.lazyNamespaceLocaleFiles, NAMESPACE_LOCALE_CHUNKS.map(namespaceLocaleChunkPath).sort());
@@ -263,6 +271,7 @@ test('bundle manifest separates static initial graph from dynamic editor graph',
'houseplan-assets/furniture-plan-art.generated-HASH.js',
'houseplan-assets/houseplan-onboarding-runtime-HASH.js',
'houseplan-assets/iso-scene-render-HASH.js',
'houseplan-assets/moon-runtime-HASH.js',
'houseplan-assets/pdf-export-HASH.js',
]);
// Этот тест про РАЗДЕЛЕНИЕ графов, а не про их размеры: потолки ленивых
@@ -291,6 +300,10 @@ test('bundle manifest separates static initial graph from dynamic editor graph',
assert.throws(() => assertBundleBudget({
...manifest, lazyNamespaceLocaleFiles: manifest.lazyNamespaceLocaleFiles.slice(1),
}, 1_000_000, undefined, ...lazyCeilings), /lazy namespace locale graph has 8 files, expected 9/);
// #661 AC8: a moon pulled into the first frame (a static import leaves no
// lazy moon graph — the builder subtracts initial) is refused by name.
assert.throws(() => assertBundleBudget({ ...manifest, lazyMoonFiles: [] }, 1_000_000, undefined, ...lazyCeilings),
/bundle has no lazy moon graph/);
});
test('#486 Rollup names both stable entries explicitly', async () => {
@@ -518,7 +531,8 @@ test('retry URL points at the content-hashed runtime chunk after naming', () =>
+ 'new URL("__HOUSEPLAN_DE_RETRY_ASSET__", import.meta.url);'
+ 'new URL("__HOUSEPLAN_FR_RETRY_ASSET__", import.meta.url);'
+ 'new URL("__HOUSEPLAN_FURNITURE_ART_RETRY_ASSET__", import.meta.url);'
+ 'new URL("__HOUSEPLAN_PDF_RETRY_ASSET__", import.meta.url)', modules: {},
+ 'new URL("__HOUSEPLAN_PDF_RETRY_ASSET__", import.meta.url);'
+ 'new URL("__HOUSEPLAN_MOON_RETRY_ASSET__", import.meta.url)', modules: {},
},
// #627: the lazy namespace chunk that owns the nine second-attempt tokens.
'houseplan-assets/backdrop-pick-HASH.js': {
@@ -554,6 +568,10 @@ test('retry URL points at the content-hashed runtime chunk after naming', () =>
type: 'chunk', fileName: 'houseplan-assets/pdf-export-HASH.js', code: '',
modules: { '/repo/src/pdf/pdf-export.ts': {} },
},
'houseplan-assets/moon-runtime-HASH.js': {
type: 'chunk', fileName: 'houseplan-assets/moon-runtime-HASH.js', code: '',
modules: { '/repo/src/moon-runtime.ts': {} },
},
};
plugin.generateBundle({}, bundle);
assert.equal(
@@ -564,7 +582,8 @@ test('retry URL points at the content-hashed runtime chunk after naming', () =>
+ 'new URL("./de-HASH.js", import.meta.url);'
+ 'new URL("./fr-HASH.js", import.meta.url);'
+ 'new URL("./furniture-plan-art.generated-HASH.js", import.meta.url);'
+ 'new URL("./pdf-export-HASH.js", import.meta.url)',
+ 'new URL("./pdf-export-HASH.js", import.meta.url);'
+ 'new URL("./moon-runtime-HASH.js", import.meta.url)',
);
assert.equal(
bundle['houseplan-assets/backdrop-pick-HASH.js'].code,
@@ -577,7 +596,7 @@ test('#627 namespace retry tokens stay strict: exactly one each, every chunk emi
const bundle = {
'houseplan-assets/houseplan-card.js': {
type: 'chunk', fileName: 'houseplan-assets/houseplan-card.js', modules: {},
code: ['EDITOR', 'ONBOARDING', 'ISO', 'DE', 'FR', 'FURNITURE_ART', 'PDF']
code: ['EDITOR', 'ONBOARDING', 'ISO', 'DE', 'FR', 'FURNITURE_ART', 'PDF', 'MOON']
.map((name) => `"__HOUSEPLAN_${name}_RETRY_ASSET__"`).join(';'),
},
...Object.fromEntries([
@@ -585,7 +604,7 @@ test('#627 namespace retry tokens stay strict: exactly one each, every chunk emi
['houseplan-onboarding-runtime', '/src/houseplan-onboarding-runtime.ts'],
['iso-scene-render', '/src/iso-scene-render.ts'], ['de', '/src/i18n/de.ts'],
['fr', '/src/i18n/fr.ts'], ['furniture-plan-art.generated', '/src/furniture-plan-art.generated.ts'],
['pdf-export', '/src/pdf/pdf-export.ts'],
['pdf-export', '/src/pdf/pdf-export.ts'], ['moon-runtime', '/src/moon-runtime.ts'],
].map(([name, module]) => [`houseplan-assets/${name}-HASH.js`, {
type: 'chunk', fileName: `houseplan-assets/${name}-HASH.js`, code: '', modules: { [`/repo${module}`]: {} },
}])),
@@ -979,6 +998,7 @@ const runBudgetCli = (initialViewGzipBytes) => {
writeFileSync(join(dir, 'dist/isometric.js'), 'lazy isometric runtime');
writeFileSync(join(dir, 'dist/furniture-art.js'), 'lazy furniture artwork');
writeFileSync(join(dir, 'dist/pdf.js'), 'lazy pdf writer');
writeFileSync(join(dir, 'dist/moon.js'), 'lazy moon runtime');
writeFileSync(join(dir, 'dist/houseplan-assets.json'), JSON.stringify({
schema: 1,
fingerprint: 'f'.repeat(64),
@@ -1008,6 +1028,8 @@ const runBudgetCli = (initialViewGzipBytes) => {
lazyFurnitureArtGzipBytes: LAZY_FURNITURE_ART_GZIP_CEILING - 1_000,
lazyPdfFiles: ['pdf.js'],
lazyPdfGzipBytes: 100,
lazyMoonFiles: ['moon.js'],
lazyMoonGzipBytes: 100,
lazyOnboardingFiles: ['onboarding.js'],
lazyOnboardingGzipBytes: LAZY_ONBOARDING_GZIP_CEILING - 1_000,
lazyNamespaceLocaleFiles: NAMESPACE_LOCALE_CHUNKS
+20 -2
View File
@@ -446,7 +446,7 @@ test('sun-ray golden requires browser-painted light from a state-only sun entity
assert.ok(scenario);
const fixture = prepareGoldenFixture(scenario);
const space = fixture.config.spaces.find((item) => item.id === scenario.space);
assert.equal(GOLDEN_MATRIX_VERSION, 67);
assert.equal(GOLDEN_MATRIX_VERSION, 68);
assert.equal(space.settings.sun_rays, true);
assert.equal(scenario.northDeg, 90,
'the sign-sensitive golden must keep a non-zero north direction');
@@ -940,7 +940,7 @@ test('issue 570 Stage 4 reuses the historical iso goldens for visual handoff cov
test('#673 Stage 6 designer acceptance scenes are canonical golden entries', () => {
const ids = STAGE6_ACCEPTANCE_SCENARIOS.map((scenario) => scenario.id);
assert.equal(GOLDEN_MATRIX_VERSION, 67);
assert.equal(GOLDEN_MATRIX_VERSION, 68);
assert.deepEqual(ids, [
'isometric-stage6-light-lightfloor',
'isometric-stage6-light-darkfloor',
@@ -1226,3 +1226,21 @@ test('issue 43 support dialog has the six reviewed responsive states', () => {
assert.equal(support.filter((scenario) => scenario.viewport.width >= 900).length, 3);
for (const scenario of support) assert.equal(scenario.capture, 'page', scenario.id);
});
test('#661 AC7 moon scenes: fixed home and clock, moon on, the old day-cycle frames untouched', () => {
const byId = (id) => GOLDEN_SCENARIOS.find((scenario) => scenario.id === id);
const gibbous = byId('day-cycle-night-moon-gibbous-dark');
const crescent = byId('day-cycle-dusk-moon-crescent-south-dark');
assert.deepEqual(gibbous.moon, { clock: '2026-10-21T18:00:00Z', latitude: 55.75, longitude: 37.62, k: '0.79' });
assert.deepEqual(crescent.moon, { clock: '2026-10-14T09:00:00Z', latitude: -33.87, longitude: 151.21, k: '0.14' });
for (const scenario of [gibbous, crescent]) {
assert.equal(scenario.capture, 'stage');
assert.equal(scenario.bgMode, 'daynight');
assert.equal(prepareGoldenFixture(scenario).config.settings.moon, true);
}
for (const id of ['day-cycle-dawn-dark', 'day-cycle-day-dark', 'day-cycle-dusk-dark', 'day-cycle-night-dark']) {
const scenario = byId(id);
assert.equal(scenario.moon, undefined, id);
assert.equal(prepareGoldenFixture(scenario).config.settings?.moon, undefined, id);
}
});
+13 -3
View File
@@ -42,15 +42,25 @@ const toolchain = () => {
};
};
/** Matrix scenarios whose first capture still awaits acceptance (declared as new). */
const PENDING_NEW = Object.freeze(GOLDEN_SCENARIOS.map((scenario) => scenario.id)
.filter((id) => !existsSync(resolve(BASELINES, `${id}.png`))));
const ACCEPTED = GOLDEN_SCENARIOS.map((scenario) => scenario.id).filter((id) => !PENDING_NEW.includes(id));
function artifact() {
const dir = mkdtempSync(resolve(tmpdir(), 'hp-golden-wsl-'));
const actualRoot = resolve(dir, 'actual');
mkdirSync(actualRoot, { recursive: true });
const index = JSON.parse(readFileSync(resolve(BASELINES, 'baselines-index.json'), 'utf8'));
const results = GOLDEN_SCENARIOS.map((scenario) => {
const baseline = resolve(BASELINES, `${scenario.id}.png`);
assert.equal(existsSync(baseline), true, `test fixture needs reviewed baseline ${scenario.id}`);
const actual = resolve(actualRoot, `${scenario.id}.png`);
// #661: a scenario added to the matrix has no reviewed baseline until its
// first Linux capture is accepted — exactly what a real run reports then.
if (PENDING_NEW.includes(scenario.id)) {
copyFileSync(resolve(BASELINES, `${ACCEPTED[0]}.png`), actual);
return { id: scenario.id, status: 'missing-baseline', actualSha256: digest(readFileSync(actual)) };
}
const baseline = resolve(BASELINES, `${scenario.id}.png`);
copyFileSync(baseline, actual);
const sha = digest(readFileSync(actual));
return { id: scenario.id, status: 'passed', actualSha256: sha, baselineSha256: sha };
@@ -72,7 +82,7 @@ function artifact() {
return dir;
}
const intent = Object.freeze({ expectChange: [], expectNew: [], noWitnesses: false, reason: '' });
const intent = Object.freeze({ expectChange: [], expectNew: PENDING_NEW, noWitnesses: false, reason: '' });
test('#641: repository and WSL/ext4 preconditions fail closed', () => {
assert.equal(repositoryRefusal(source), null);
+53
View File
@@ -0,0 +1,53 @@
// #661 AC10: the moon switch in General settings — «Sun and Moon» section,
// reset to on, save writes `true` or removes the key; its strings exist in all
// four dictionaries. The dialog is a lazy Lit module (its layout is read as a
// source contract); reading and writing the key are executed.
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { moonDraftOf, writeMoonSetting } from '../test-build/editors/general-form-state.js';
const read = (path) => readFileSync(new URL(`../${path}`, import.meta.url), 'utf8');
const json = (path) => JSON.parse(read(path));
test('#661 AC10: the switch sits in the «Sun and Moon» card under «Sunlight through windows»', () => {
const dialog = read('src/editors/general-settings-dialog.ts');
const sun = dialog.slice(dialog.indexOf("id: 'sun',"), dialog.indexOf("id: 'data',"));
assert.match(sun, /title: t\('gs\.sun_group'\)/);
const rays = sun.indexOf("id: 'gs-sun-rays'");
const moon = sun.indexOf("id: 'gs-moon'");
assert.ok(rays > 0 && moon > rays, 'moon row follows the sun-rays row');
const row = sun.slice(moon, sun.indexOf('})}', moon));
assert.match(row, /icon: 'mdi:moon-waning-crescent'/);
assert.match(row, /title: st\('gs\.moon'\), caption: st\('gs\.moon_hint'\)/);
assert.match(row, /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)');
});
test('#661 AC10: opening reads only an explicit true; saving writes true or removes the key', () => {
assert.equal(moonDraftOf({ moon: true }), true);
for (const settings of [undefined, null, {}, { moon: false }, { moon: 'true' }, { moon: 1 }]) {
assert.equal(moonDraftOf(settings), false, JSON.stringify(settings));
}
const on = { bg_mode: 'daynight' };
writeMoonSetting(on, true);
assert.deepEqual(on, { bg_mode: 'daynight', moon: true });
const off = { bg_mode: 'daynight', moon: true };
writeMoonSetting(off, false);
assert.deepEqual(off, { bg_mode: 'daynight' });
assert.equal('moon' in off, false, 'false is stored as absence');
});
test('#661 AC10: strings in all four dictionaries, section renamed', () => {
const group = { en: 'Sun and Moon', ru: 'Солнце и Луна', de: 'Sonne und Mond', fr: 'Soleil et Lune' };
for (const lang of ['en', 'ru', 'de', 'fr']) {
assert.equal(json(`src/i18n/${lang}.json`)['gs.sun_group'], group[lang], lang);
const settings = json(`src/i18n/settings/${lang}.json`);
for (const key of ['gs.moon', 'gs.moon_hint']) {
assert.ok(typeof settings[key] === 'string' && settings[key].trim(), `${lang} ${key}`);
}
}
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');
});
+253
View File
@@ -0,0 +1,253 @@
// #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.
import test from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
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,
} 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 { renderDayCycleEnvironment } from '../test-build/day-cycle-render.js';
import { moonArtFromSvg, validateMoonPack } from '../scripts/generate-moon-assets.mjs';
const MOSCOW = { latitude: 55.75, longitude: 37.62 };
const SYDNEY = { latitude: -33.87, longitude: 151.21 };
const ON = { moon: true, bg_mode: 'daynight' };
/**
* AC1 reference: JPL Horizons, observer table, `CENTER='coord@399'` with
* `SITE_COORD='<lon>,<lat>,0'`, `QUANTITIES='4,10'` (apparent az/el and
* illuminated fraction), `APPARENT='AIRLESS'`, `TIME_TYPE=UT`, one row per
* epoch below. Moscow 55.75 N 37.62 E, Sydney 33.87 S 151.21 E, October 2026.
*/
const HORIZONS = [
['2026-10-01T18:00:00Z', MOSCOW, 9.052, 70.03],
['2026-10-02T18:00:00Z', MOSCOW, 3.018, 59.01],
['2026-10-03T18:00:00Z', MOSCOW, -3.347, 47.57],
['2026-10-10T18:00:00Z', MOSCOW, -32.212, 0.176],
['2026-10-18T18:00:00Z', MOSCOW, 4.050, 50.44],
['2026-10-21T18:00:00Z', MOSCOW, 23.279, 78.16],
['2026-10-25T18:00:00Z', MOSCOW, 37.066, 99.67],
['2026-10-26T20:00:00Z', MOSCOW, 48.978, 99.27],
['2026-10-31T18:00:00Z', MOSCOW, -0.096, 62.05],
['2026-10-12T09:00:00Z', SYDNEY, 8.150, 3.00],
['2026-10-14T09:00:00Z', SYDNEY, 30.692, 13.20],
['2026-10-20T09:00:00Z', SYDNEY, 72.716, 66.31],
];
test('#661 AC1: altitude within 1.5° and illumination within 2.5 pp of JPL Horizons', () => {
for (const [utc, place, altitude, percent] of HORIZONS) {
const date = new Date(utc);
const position = moonPosition(date, place.latitude, place.longitude);
const { fraction } = moonIllumination(date);
assert.ok(Math.abs(position.altitude - altitude) <= 1.5,
`${utc} ${place.latitude}: altitude ${position.altitude.toFixed(3)} vs ${altitude}`);
assert.ok(Math.abs(fraction * 100 - percent) <= 2.5,
`${utc} ${place.latitude}: illumination ${(fraction * 100).toFixed(2)} vs ${percent}`);
assert.ok(position.azimuth >= 0 && position.azimuth < 360);
}
});
test('#661 AC1: waxing follows the lunation (first quarter grows, last quarter shrinks)', () => {
assert.equal(moonIllumination(new Date('2026-10-14T09:00:00Z')).waxing, true);
assert.equal(moonIllumination(new Date('2026-10-21T18:00:00Z')).waxing, true);
assert.equal(moonIllumination(new Date('2026-10-31T18:00:00Z')).waxing, false);
});
test('#661 C1: the moon keeps the rays’ horizon and a 3 % new-moon threshold', () => {
assert.equal(MOON_ELEVATION_MIN, RAY_ELEVATION_MIN);
assert.equal(MOON_ELEVATION_MIN, 3);
assert.equal(MOON_MIN_ILLUMINATION, 0.03);
});
test('#661 AC2: visibility thresholds on both sides', () => {
for (const phase of ['dawn', 'dusk', 'night']) {
assert.equal(moonShownAt(phase, 2.9, 0.5), false, `${phase} 2.9°`);
assert.equal(moonShownAt(phase, 3.0, 0.5), true, `${phase} 3.0°`);
assert.equal(moonShownAt(phase, 30, 0.029), false, `${phase} k 0.029`);
assert.equal(moonShownAt(phase, 30, 0.03), true, `${phase} k 0.03`);
}
assert.equal(moonShownAt('day', 45, 1), false);
});
test('#661 AC2: only an explicit true, finite home coordinates and an environment 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 }]) {
assert.equal(moonView(settings, 'night', MOSCOW, at).visible, false, JSON.stringify(settings));
}
for (const config of [undefined, null, {}, { latitude: '55.75', longitude: 37.62 },
{ latitude: Number.NaN, longitude: 37.62 }, { latitude: 55.75, longitude: null }]) {
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.
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);
});
/** Decode `A rx ry rot large sweep x y` of the terminator from the mask path. */
function terminatorOf(path) {
const arcs = [...path.matchAll(/A([\d.]+) ([\d.]+) 0 0 ([01]) ([\d.]+) ([\d.]+)/g)]
.map((match) => match.slice(1).map(Number));
return { outer: arcs[0], terminator: arcs[1] ?? null };
}
test('#661 AC3: the mask is the left half plus or minus the terminator half-ellipse', () => {
for (const k of [0.03, 0.25, 0.5, 0.75, 0.97]) {
const path = moonPhasePath(k);
const { outer, terminator } = terminatorOf(path);
// The lit half's outer arc runs along the box (r 256), counter-clockwise
// from the top: through the LEFT edge, never along the limb.
assert.deepEqual(outer, [256, 256, 0, 256, 512], `${k}: outer arc`);
const rx = MOON_R * Math.abs(2 * k - 1);
if (rx < 0.5) {
assert.equal(terminator, null, `${k}: a half moon has a straight terminator`);
assert.match(path, /L256 496L256 16L256 0Z$/);
continue;
}
const [trx, try_, sweep, x, y] = terminator;
assert.ok(Math.abs(trx - rx) < 0.01, `${k}: rx ${trx} vs ${rx}`);
assert.equal(try_, MOON_R);
assert.deepEqual([x, y], [256, 16], `${k}: the terminator ends at the north pole`);
// sweep 0 bulges right, into the dark side: the lit area grows past half.
const litShare = 0.5 + (sweep === 0 ? 1 : -1) * (trx / MOON_R) / 2;
assert.ok(Math.abs(litShare - k) < 1e-3, `${k}: lit share ${litShare}`);
}
});
test('#661 AC3 (owner 2026-09-29): one lit side in both hemispheres, waxing or waning', () => {
// Sydney 2026-10-14 09:00Z: waxing crescent, 14 % — lit on the LEFT, like Moscow.
const at = new Date('2026-10-14T09:00:00Z');
const hosts = [fakeHost(SYDNEY), fakeHost(MOSCOW)];
const sydney = renderMoon(hosts[0], ON, 'dusk', at);
const moscow = renderMoon(hosts[1], ON, 'night', at);
hosts.forEach(forgetHost);
const pathOf = (result) => result.values.find((value) => typeof value === 'string' && value.startsWith('M256 0A256'));
assert.equal(pathOf(sydney), pathOf(moscow));
assert.equal(pathOf(sydney), moonPhasePath(0.14));
// Waxing and waning at the same k draw the same state backwards.
const waxing = new Date('2026-10-21T18:00:00Z');
const waning = new Date('2026-10-29T12:00:00Z');
assert.equal(moonIllumination(waxing).waxing, true);
assert.equal(moonIllumination(waning).waxing, false);
assert.equal(moonPhasePath(0.78), moonPhasePath(Math.round(0.78 * 100) / 100));
});
test('#661 C5: the feathered terminator stops only for a full disc', () => {
assert.equal(MOON_SHARP_FROM, 0.995);
const host = fakeHost(MOSCOW);
const filterOf = (at) => renderMoon(host, ON, 'night', new Date(at)).values
.find((value) => value === nothing || value === 'url(#hp-moon-soft)');
assert.equal(filterOf('2026-10-21T18:00:00Z'), 'url(#hp-moon-soft)');
forgetHost(host);
});
function fakeHost(config) {
return {
isConnected: true,
updates: 0,
updateComplete: Promise.resolve(true),
hass: { config },
requestUpdate() { this.updates++; },
};
}
function forgetHost(host) {
host.isConnected = false;
moonTick(host);
}
test('#661 AC4: three ticks over an unchanged moon cost no render', () => {
const host = fakeHost(MOSCOW);
const start = new Date('2026-10-21T18:00:00Z');
renderMoon(host, ON, 'night', start);
for (let tick = 1; tick <= 3; tick++) moonTick(host, new Date(start.getTime() + tick * MOON_TICK_MS));
assert.equal(host.updates, 0);
assert.equal(
moonFingerprint(moonView(ON, 'night', MOSCOW, start)),
moonFingerprint(moonView(ON, 'night', MOSCOW, new Date(start.getTime() + 3 * MOON_TICK_MS))),
);
forgetHost(host);
});
test('#661 AC4: the tick asks for exactly one render when the moon sets below 3°', async () => {
// Moscow 2026-10-21: 3.026° at 22:37Z, 2.902° at 22:38Z (the setting side).
const host = fakeHost(MOSCOW);
renderMoon(host, ON, 'night', new Date('2026-10-21T22:30:00Z'));
moonTick(host, new Date('2026-10-21T22:45:00Z'));
moonTick(host, new Date('2026-10-21T22:45:30Z'));
assert.equal(host.updates, 1);
// The host re-rendered without a moon (switched off): its ticker is dropped.
await host.updateComplete;
await Promise.resolve();
moonTick(host, new Date('2026-10-22T12:00:00Z'));
assert.equal(host.updates, 1);
});
test('#661 C4: the element fades on the background curve for two seconds', () => {
const host = fakeHost(MOSCOW);
const result = renderMoon(host, ON, 'night', new Date('2026-10-21T18:00:00Z'));
forgetHost(host);
const css = result.values[0];
assert.match(css, new RegExp(`transition:opacity ${RAY_FADE_MS}ms cubic-bezier\\(\\.22,\\.61,\\.36,1\\)`));
assert.match(css, /prefers-reduced-motion:reduce\)\{\.hp-moon\{transition:none\}/);
assert.match(css, /--hp-moon-box:min\(200px,25cqmin\)/);
assert.match(css, /pointer-events:none/);
assert.doesNotMatch(css, /filter|will-change|z-index/);
});
test('#661 C6: the moon is the environment’s last child — above the phases, below the plan', () => {
const state = resolveDayCycle({ states: { 'sun.sun': { attributes: { azimuth: 0, elevation: -12, rising: false } } } });
const moon = { moon: 'marker' };
const env = renderDayCycleEnvironment(state, 1, moon);
const at = env.values.indexOf(moon);
assert.ok(at > 0, 'the environment carries the moon');
assert.equal(env.strings[at + 1].trim(), '</div>', 'the environment closes right after the moon');
assert.match(env.strings[0], /class="hp-day-cycle-env"/);
});
test('#661 pack: generated art is fresh, and the gate refuses what the brief forbids', () => {
const run = spawnSync(process.execPath, ['scripts/generate-moon-assets.mjs', '--check'], { encoding: 'utf8' });
assert.equal(run.status, 0, run.stderr);
const pack = JSON.parse(readFileSync(new URL('../assets/moon/houseplan-1.0.0/pack.json', import.meta.url), 'utf8'));
assert.doesNotThrow(() => validateMoonPack(pack));
assert.throws(() => validateMoonPack({ ...pack, mirror: true }), /mirror/);
assert.throws(() => validateMoonPack({ ...pack, disc: { cx: 256, cy: 256, r: 256 } }), /disc/);
const svg = readFileSync(new URL('../assets/moon/houseplan-1.0.0/moon.svg', import.meta.url), 'utf8');
const art = moonArtFromSvg(svg);
assert.doesNotMatch(art, /<title|id="[abc]"/);
assert.match(art, /url\(#hp-moon-a\)/);
const root = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">';
assert.throws(() => moonArtFromSvg(`${root}<filter id="f"><feGaussianBlur/></filter></svg>`), /<filter>/);
assert.throws(() => moonArtFromSvg(`${root}<image href="x.png"/></svg>`), /outside itself|<image>/);
assert.throws(() => moonArtFromSvg(`${root.replace('512 512', '512 400')}</svg>`), /viewBox/);
assert.throws(() => moonArtFromSvg(`${root}<path d="${'M0 0'.repeat(9000)}"/></svg>`), /larger/);
});
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);
const host = fakeHost(MOSCOW);
assert.equal(moonLayer(host, ON, sunAt(40)), nothing, 'daytime: nothing and no load');
await new Promise((done) => setTimeout(done, 50));
assert.equal(host.updates, 0, 'nothing was loaded by day');
assert.equal(moonLayer(host, ON, sunAt(-12)), nothing, 'the first night render has no moon yet');
await new Promise((done) => setTimeout(done, 200));
assert.equal(host.updates, 1, 'the host that asked re-renders when the chunk arrives');
const element = moonLayer(host, ON, sunAt(-12));
assert.notEqual(element, nothing);
forgetHost(host);
});
+15
View File
@@ -603,6 +603,21 @@ def test_issue_577_full_export_preserves_sun_ray_origin(tmp_path: Path) -> None:
assert parsed["payload"]["config"]["settings"]["sun_ray_origin"] == "outer"
def test_issue_661_full_export_and_import_preserve_the_moon(tmp_path: Path) -> None:
"""#661 AC9: `settings.moon` travels with a full backup both ways."""
runtime = SimpleNamespace(instance_id="instance-a")
for value in (True, False):
config = _config()
config["settings"]["moon"] = value
document, _ = create_export(
runtime, {"config": config}, {"layout": {}},
kind="full", space_id=None, card_version="1.79.0", config_root=tmp_path,
)
assert document["payload"]["config"]["settings"]["moon"] is value
parsed = parse_document(json.dumps(document).encode())
assert parsed["payload"]["config"]["settings"]["moon"] is value
@pytest.mark.parametrize(
("kind", "plan_only"),
[("full", False), ("space", False), ("space", True)],
+41
View File
@@ -0,0 +1,41 @@
"""#661 AC9: `settings.moon`, the moon on the "Follow the Sun" background.
Pure subset (no Home Assistant, runs on Windows): the schema takes a strict
boolean, the support package copies only a boolean, new installations get the
moon switched on. The export/import round trip needs the integration package
and lives in test_ha_import_export.py.
"""
import pytest
import voluptuous as vol
from pure_imports import HOUSEPLAN_ROOT, load_pure
v = load_pure("hp_validation_moon", HOUSEPLAN_ROOT / "validation.py")
const = load_pure("custom_components.houseplan.const", HOUSEPLAN_ROOT / "const.py")
support = load_pure(
"custom_components.houseplan.support_package", HOUSEPLAN_ROOT / "support_package.py",
)
@pytest.mark.parametrize("value", [True, False])
def test_moon_accepts_a_boolean_and_keeps_it(value):
out = v.CONFIG_SCHEMA({"spaces": [], "settings": {"moon": value}})
assert out["settings"]["moon"] is value
@pytest.mark.parametrize("bad", ["yes", 1, 0, None, "true", [], {}])
def test_moon_rejects_everything_but_a_boolean(bad):
with pytest.raises(vol.Invalid):
v.CONFIG_SCHEMA({"spaces": [], "settings": {"moon": bad}})
def test_support_package_copies_only_a_boolean_moon():
assert support._global_settings({"moon": True}) == {"moon": True}
assert support._global_settings({"moon": False}) == {"moon": False}
for bad in ("true", 1, None):
assert "moon" not in support._global_settings({"moon": bad})
def test_new_installations_get_the_moon():
assert const.DEFAULT_CONFIG["settings"]["moon"] is True
assert v.CONFIG_SCHEMA(const.DEFAULT_CONFIG)["settings"]["moon"] is True
+1 -1
View File
@@ -16,7 +16,7 @@
"src/zigbee-topology-settings.ts", "src/zigbee-topology.ts", "src/zigbee-topology-geometry.ts", "src/zigbee-topology-runtime.ts",
"src/radar-model.ts", "src/radar-editor.ts", "src/radar-geometry.ts", "src/radar-setup.ts", "src/radar-render.ts",
"src/integration-provider.ts", "src/vacuum.ts", "src/vacuum-routes.ts", "src/vacuum-route-edit.ts",
"src/sun.ts",
"src/sun.ts", "src/moon.ts", "src/moon-gate.ts", "src/moon-runtime.ts", "src/moon-art.generated.ts",
"src/light-visibility.ts", "src/glow-scene.ts", "src/space-render.ts",
"src/resize.ts", "src/resize-labels.ts", "src/resize-controller.ts", "src/wall-record-preservation.ts",
"src/rules.ts",