The day/night sky catches up instead of crawling after the sun

Owner, 2026-08-04: «цвет фона не меняется сам с течением времени суток, только
после обновления страницы».

WHAT IS NOT THE BUG. The model layer was already live: `_stageBg` and the
`planDim` filter are read straight out of `hass.states['sun.sun']` on every
render, `hass` is a plain reactive property, and a bare `card.hass = {...}` in
the demo rig does move the style attribute — smoke_sun.mjs has asserted exactly
that since v1.56.0 and it has always passed.

WHAT IS. The sky is DELIVERED by a 45 s CSS transition, and a CSS transition
only advances while the element is being painted. Every second of a background
tab, another dashboard view, an editor session or a sleeping wall tablet is a
second the sun keeps moving and the sky does not; when the card comes back, the
transition restarts from the stale colour and crawls, 45 s at a time, toward a
target that has meanwhile moved again. A page reload, by contrast, paints the
right colour outright — a freshly mounted element has nothing to transition
FROM. That is the owner's sentence, word for word.

THE FIX. Measure the gap and decide. HA refreshes `sun.sun` every ~4 minutes by
day (verified on the home instance: 08:58:56, 09:02:56, 09:06:56, …), i.e. ≤1°
of elevation per update, so anything from SKY_SNAP_DEG = 3° up can only mean
"we were not watching". Such a step is painted with `transition: none` for a
single frame (`.stage.daynight.skysnap`, released on the next
requestAnimationFrame, so the very next change glides again); everything
smaller keeps the 45 s breathing untouched. `visibilitychange → visible` clears
the marker outright, so a tab that comes back is right immediately.

The elevation the sky is computed from is now rounded to 0.1° (`skyElevation`,
shared by the stage background and the plan dimming) — invisible across a 45 s
glide and it keeps lit from re-committing the style attribute on every hass
tick. The ray GEOMETRY memo is deliberately untouched and keeps its own,
coarser key: the sky is cheap, polygon clipping is not.

Tests: unit — skyNeedsSnap (null/NaN, a real 4-minute step glides, 3° in either
direction jumps), skyElevation. Smoke — demo/smoke_sun_live_bg.mjs, which
asserts the COMPUTED background of the stage (not the style attribute) after a
plain `hass` assignment with no reload and no requestUpdate, plus planDim and
the 3° ray threshold both ways. It fails on the previous tip with
dayComputedWhite, nightComputedDark, backToDayComputed, smallStepMovesSky and
returnFromHiddenSnaps, and it also pins that a REAL sun step still glides
rather than jumps.
This commit is contained in:
Matysh
2026-08-04 09:49:30 +03:00
parent 22b588e116
commit 285d569102
9 changed files with 902 additions and 682 deletions
File diff suppressed because one or more lines are too long
+71
View File
@@ -0,0 +1,71 @@
// Owner 2026-08-04: «цвет фона не меняется сам с течением времени суток, только
// после обновления страницы». The day/night sky must follow sun.sun on a plain
// hass tick — no reload, no requestUpdate() from the outside — and the COMPUTED
// background of the stage (what the eye sees, not the style attribute) must
// actually arrive at the sky colour (docs/SUN.md).
import { launch, check, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 900, height: 900 }, 1);
const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const cfg = c._serverCfg;
cfg.settings = { ...(cfg.settings || {}), north_deg: 0, bg_mode: 'daynight', sun_rays: true };
cfg.spaces.find((s) => s.id === 'f1').openings = [
{ id: 'wS', type: 'window', x: 0.30, y: 0.86, angle: 0, length: 0.08 },
];
c._cfgEpoch++;
// NO requestUpdate anywhere below: production only ever assigns `hass`.
const setSun = async (az, el) => {
c.hass = { ...c.hass, states: { ...c.hass.states, 'sun.sun': {
entity_id: 'sun.sun', state: el > 0 ? 'above_horizon' : 'below_horizon',
attributes: { azimuth: az, elevation: el },
} } };
await c.updateComplete;
// one paint so the sky lands on screen, not only in the attribute
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
};
const stage = () => sr().querySelector('.stage');
const comp = () => getComputedStyle(stage()).backgroundColor;
const dim = () => (sr().querySelector('.zoomwrap').getAttribute('style') || '');
const polys = () => sr().querySelectorAll('.sunlayer polygon').length;
const hexToRgb = (h) => 'rgb(' + [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16)).join(', ') + ')';
await setSun(180, 40);
out.dayComputedWhite = comp() === 'rgb(255, 255, 255)';
out.dayNoDim = dim().includes('brightness(1.000)');
out.dayRays = polys() > 0;
// the whole point: a NEW hass, nothing else. No reload, no requestUpdate.
await setSun(180, -10);
out.nightComputedDark = comp() === 'rgb(10, 16, 25)';
out.nightDimmed = dim().includes('brightness(0.900');
out.nightNoRays = polys() === 0;
// ...and back, again on a plain tick
await setSun(180, 40);
out.backToDayComputed = comp() === 'rgb(255, 255, 255)';
out.backToDayRays = polys() > 0;
// a small step still moves the sky (0.1° granularity of the model)...
await setSun(180, 20);
const twenty = comp();
await setSun(180, 12);
out.smallStepMovesSky = comp() !== twenty;
// ...but a step the size of a REAL sun update (~1° per 4 minutes) must still
// GLIDE: the catch-up jump is for a card that was not watching, never for the
// day/night breathing itself (docs/SUN.md).
await setSun(180, 12.6);
const target = stage().getAttribute('style').split('background:')[1];
out.realStepNoSnapClass = !sr().querySelector('.stage.skysnap');
out.realStepStillGlides = comp() !== hexToRgb(target); // 45 s transition, not a jump
// the tab came back from the background: the sky catches up at once
await setSun(180, 25); // 12° of sun happened while we were not painting
out.returnFromHiddenSnaps = comp() === hexToRgb(stage().getAttribute('style').split('background:')[1]);
return out;
});
await finish(browser, checkAll(res));
File diff suppressed because one or more lines are too long
+234 -226
View File
File diff suppressed because one or more lines are too long
+25
View File
@@ -67,6 +67,28 @@ only — no entities are created, no services are called.
daytime room fills stay readable. Transitions are a CSS
background/filter transition tens of seconds long;
`prefers-reduced-motion` gets the current colors statically.
- **Glide, but never lag behind reality** (owner 2026-08-04: «цвет фона
не меняется сам с течением времени суток, только после
обновления страницы»). The sky colour and the plan dimming are
delivered by a 45 s CSS transition, and a CSS transition only advances
while the card is being PAINTED. A card that was not painting — a
background tab, another dashboard view, a sleeping wall tablet, an
editor session — comes back holding a stale sky and then crawls toward
the truth 45 s at a time; a page reload, by contrast, paints the right
colour outright, because a freshly mounted element has nothing to
transition FROM. So the card measures the gap: HA refreshes `sun.sun`
every ~4 minutes by day, i.e. ≤1° per update, and anything from
`SKY_SNAP_DEG` = 3° up therefore means "we were not watching". Such a
step is applied with `transition: none` for a single frame
(`.stage.daynight.skysnap`, released on the next
`requestAnimationFrame`); everything smaller keeps the 45 s breathing.
`visibilitychange → visible` arms the catch-up outright.
- The elevation the sky is computed from is rounded to 0.1°
(`skyElevation()`) — finer than the eye can tell across a 45 s glide,
and it keeps `dayPhase` (and the style attribute lit has to commit)
from churning on every `hass` tick. The wedge GEOMETRY keeps its own,
coarser memo: the two have deliberately different granularity — the
sky is cheap, the polygon clipping is not.
- The UI is a two-option selector; the color picker shows only for
`'static'`.
- Backend validation: `In(['static', 'daynight'])` at both levels.
@@ -231,5 +253,8 @@ Backend validation: string or null.
- `demo/smoke_sun_soft.mjs` — the −30 % reach and the "always
dissolves" contract (the gradient spans the wedge and dies at 85 %,
the feather filter, the room clip).
- `demo/smoke_sun_live_bg.mjs` — the sky follows `sun.sun` on a plain
`hass` tick with no reload, asserted on the COMPUTED background of the
stage; small steps still glide, big ones catch up at once.
- `demo/shot_sun_short.mjs` — before/after stills at a low and a high
sun.
+48 -4
View File
@@ -32,7 +32,7 @@ import {
import {
computeSunRays, dayPhase, northDegOf, bgModeOf, sunRaysOn, weatherEntityOf,
sunStateOf, cloudFactor, rayPeakAlpha, raysVisible, rayColor, RAY_FADE_MS, type SunRay,
rayStops, raySoftness,
rayStops, raySoftness, skyElevation, skyNeedsSnap,
} from './sun';
import { ContentSigner } from './signing';
import { mdiHomeCityOutline } from '@mdi/js';
@@ -390,6 +390,11 @@ class HouseplanCard extends LitElement {
} | null = null;
/** Wedge memo: recomputed only when (azimuth, elevation, north, cfg rev) change (docs/SUN.md). */
private _sunRaysCache: { key: string; rays: SunRay[] } | null = null;
/** Sun elevation (0.1°) the day/night sky is currently PAINTED with, and
* whether the next paint must jump to it instead of gliding (docs/SUN.md). */
private _skyElev: number | null = null;
private _skySnap = false;
private _skySnapRaf = 0;
private _compassDrag = false;
private _importDialog: { floors: (FloorInfo & { checked: boolean })[] } | null = null;
private _importQueue: string[] = []; // floor titles still to create
@@ -505,6 +510,10 @@ class HouseplanCard extends LitElement {
private _vacVisHandler = () => {
if (document.visibilityState === 'visible') {
this._vacJumpOnce = true;
// A hidden tab paints nothing, so the 45 s sky transition stood still
// while the sun kept moving: come back on the RIGHT colour, then breathe
// again (docs/SUN.md, owner 2026-08-04).
this._skyElev = null;
this.requestUpdate();
}
};
@@ -628,6 +637,7 @@ class HouseplanCard extends LitElement {
public disconnectedCallback(): void {
document.removeEventListener('visibilitychange', this._vacVisHandler);
if (this._vacRaf) { cancelAnimationFrame(this._vacRaf); this._vacRaf = 0; }
if (this._skySnapRaf) { cancelAnimationFrame(this._skySnapRaf); this._skySnapRaf = 0; }
for (const rt of this._senseRt.values()) clearTimeout(rt.timer); // pending flash-window repaints
window.removeEventListener('keydown', this._keyHandler);
clearInterval(this._cycleTimer);
@@ -1030,6 +1040,7 @@ class HouseplanCard extends LitElement {
protected willUpdate(changed: PropertyValues): void {
if (changed?.has?.('hass')) { this._vacTick(); this._senseTick(); }
this._skyPlan();
if (changed.has('hass') && this.hass) {
this._hookConnection();
if (!this._loadOk && !this._loading && this._loadTries < 8) {
@@ -1040,6 +1051,7 @@ class HouseplanCard extends LitElement {
}
protected updated(): void {
this._skyRelease();
const stage = this._stageEl;
if (stage && !this._roViewport) {
this._roViewport = new ResizeObserver(() => this._refitView());
@@ -4969,6 +4981,38 @@ class HouseplanCard extends LitElement {
private _sunOut = false;
private _sunOutTimer = 0;
/**
* Day/night sky bookkeeping, once per update (docs/SUN.md).
*
* The sky colour and the plan dimming are delivered by a 45 s CSS transition
* — and a transition only advances while the card is being PAINTED. Whenever
* it was not (a background tab, another dashboard view, an editor session, a
* fresh mount), the sun moved on without it, and the transition then crawls
* toward the truth instead of showing it: the owner's «фон не меняется сам,
* только после обновления страницы» (2026-08-04). So: glide while we are
* keeping up (the sun moves ≲1° between two `sun.sun` updates), JUMP once
* when the gap says we were not watching.
*/
private _skyPlan(): void {
const sun = !this._editing && this._effBgMode() === 'daynight' ? this._sunNow() : null;
if (!sun) { this._skyElev = null; this._skySnap = false; return; }
const e = skyElevation(sun.elevation);
if (skyNeedsSnap(this._skyElev, e)) this._skySnap = true;
this._skyElev = e;
}
/** Hand the 45 s transition back once the jumped-to colour is on screen. */
private _skyRelease(): void {
if (!this._skySnap || this._skySnapRaf) return;
this._skySnapRaf = requestAnimationFrame(() => {
this._skySnapRaf = requestAnimationFrame(() => {
this._skySnapRaf = 0;
this._skySnap = false;
this.requestUpdate();
});
});
}
/** Drop the layer at once: used by every gate that is NOT the 3° threshold. */
private _sunFadeReset(): void {
if (this._sunOutTimer) { clearTimeout(this._sunOutTimer); this._sunOutTimer = 0; }
@@ -5025,7 +5069,7 @@ class HouseplanCard extends LitElement {
private _stageBg(disp: SpaceDisplay): string {
if (this._effBgMode() === 'daynight') {
const sun = this._sunNow();
if (sun) return dayPhase(sun.elevation).bg;
if (sun) return dayPhase(skyElevation(sun.elevation)).bg;
}
const gd = this._settingsDialog;
const sd = this._spaceDialog;
@@ -5553,7 +5597,7 @@ class HouseplanCard extends LitElement {
const stageBg = this._editing ? '' : this._stageBg(disp);
// day/night breathing: armed only with a compass AND sun.sun (docs/SUN.md)
const dayNight = !this._editing && this._effBgMode() === 'daynight' ? this._sunNow() : null;
const planDim = dayNight ? dayPhase(dayNight.elevation).planDim : 0;
const planDim = dayNight ? dayPhase(skyElevation(dayNight.elevation)).planDim : 0;
// opening rulers: the drag of an existing one OR the placement preview
const opMeasure = this._opMeasureView;
@@ -5631,7 +5675,7 @@ class HouseplanCard extends LitElement {
${this._markup ? this._renderMarkupBar() : this._mode === 'devices' ? this._renderDevicesBar() : this._mode === 'decor' ? this._renderDecorBar() : nothing}
</div>
<div class="stage ${this._markup ? 'markup tool-' + this._tool + (this._tool === 'split' && !this._splitSel ? ' pickstage' : '') + (this._tool === 'openwall' && this._openWallHover ? ' wallhot' : '') : ''} ${this._mode === 'decor' ? 'dtool-' + this._decorTool : ''} ${space.bg ? '' : 'noplan'} mode-${this._mode}${dayNight ? ' daynight' : ''}${this._booting ? ' hpboot' : ''}${this._bootSoft ? ' hpsettle' : ''}"
<div class="stage ${this._markup ? 'markup tool-' + this._tool + (this._tool === 'split' && !this._splitSel ? ' pickstage' : '') + (this._tool === 'openwall' && this._openWallHover ? ' wallhot' : '') : ''} ${this._mode === 'decor' ? 'dtool-' + this._decorTool : ''} ${space.bg ? '' : 'noplan'} mode-${this._mode}${dayNight ? ' daynight' : ''}${dayNight && this._skySnap ? ' skysnap' : ''}${this._booting ? ' hpboot' : ''}${this._bootSoft ? ' hpsettle' : ''}"
style="height:${this._kiosk ? '100dvh' : `calc(100dvh - ${this._hdrH}px)`}${stageBg ? `;background:${stageBg}` : ''}"
@click=${(e: MouseEvent) => this._markupClick(e)}
@wheel=${(e: WheelEvent) => this._onWheel(e)}
+8
View File
@@ -184,6 +184,14 @@ export const cardStyles = css`
.stage.daynight.hpsettle {
transition: height 0.25s ease, background-color 45s linear;
}
/* Catch-up frame (docs/SUN.md): the sky is out of date because the card
was not painting — show the truth at once, the glide comes back on the
very next frame. */
.stage.daynight.skysnap,
.stage.daynight.skysnap .zoomwrap,
.stage.daynight.skysnap.hpsettle {
transition: none;
}
@media (prefers-reduced-motion: reduce) {
.stage.daynight,
.stage.daynight .zoomwrap,
+33
View File
@@ -310,6 +310,39 @@ export function raySoftness(len: number): number {
return Math.max(3, Math.min(18, len * 0.07));
}
/**
* Day/night sky: how far the painted sky may drift from the real sun before
* the card stops gliding and simply JUMPS to the right colour.
*
* The stage colour is delivered by a 45 s CSS transition, and a transition only
* advances while the card is actually painting. A card that was not painting —
* a background tab, another dashboard view, a sleeping wall tablet — comes back
* with a stale sky and then crawls toward the truth 45 s at a time, which is
* exactly the owner's 2026-08-04 report («цвет фона не меняется сам с течением
* времени суток, только после обновления страницы»: a reload paints the right
* colour outright, because a freshly mounted element has nothing to transition
* FROM). The sun never moves more than ~1° between two `sun.sun` updates (HA
* refreshes the position every 4 minutes by day), so a gap this big can only
* mean "we were not watching" — catch up at once, then breathe again.
*/
export const SKY_SNAP_DEG = 3;
/** Should the sky jump rather than glide from `prev`° to `next`°? */
export function skyNeedsSnap(prev: number | null, next: number): boolean {
return prev === null || !Number.isFinite(prev)
|| Math.abs(next - prev) >= SKY_SNAP_DEG;
}
/**
* Sky granularity: the elevation the background is computed from, rounded to
* 0.1°. Finer than the eye can tell on a 45 s glide, and it keeps `dayPhase`
* (and therefore the style attribute lit has to commit) from churning on every
* hass tick while the ray GEOMETRY keeps its own, coarser memo.
*/
export function skyElevation(elevation: number): number {
return Math.round((Number(elevation) || 0) * 10) / 10;
}
// ---------------- cloud cover ----------------
/** weather.* state → wedge opacity multiplier (docs/SUN.md table). */
+15
View File
@@ -7,6 +7,7 @@ import {
rayAlpha, rayColor, cloudFactor, RAY_MAX_ALPHA,
raysVisible, rayPeakAlpha, RAY_ELEVATION_MIN, RAY_FADE_MS,
RAY_LENGTH_K, RAY_FADE_END, rayStops, raySoftness,
SKY_SNAP_DEG, skyNeedsSnap, skyElevation,
northDegOf, bgModeOf, sunRaysOn, weatherEntityOf, sunStateOf,
} from '../test-build/sun.js';
@@ -145,6 +146,20 @@ test('raySoftness: a feather proportional to the shaft, clamped both ends', () =
assert.ok(raySoftness(200) > raySoftness(100));
});
test('skyNeedsSnap / skyElevation: glide with the sun, jump when we were away', () => {
assert.equal(SKY_SNAP_DEG, 3);
assert.equal(skyNeedsSnap(null, 12), true); // nothing painted yet
assert.equal(skyNeedsSnap(NaN, 12), true);
assert.equal(skyNeedsSnap(12, 12), false);
assert.equal(skyNeedsSnap(12, 13), false); // a real 4-minute sun step
assert.equal(skyNeedsSnap(12, 14.9), false);
assert.equal(skyNeedsSnap(12, 15), true); // ~12 minutes unwatched
assert.equal(skyNeedsSnap(12, 9), true); // and in both directions
assert.equal(skyElevation(12.3456), 12.3);
assert.equal(skyElevation(-0.04), -0);
assert.equal(skyElevation('nonsense'), 0);
});
test('rayQuad + clipToRoom: the wedge is cut by the room outline', () => {
const quad = rayQuad([100, 270], [100, 330], [1, 0], 1000); // way past the wall
const clipped = clipToRoom(quad, ROOMS[0].poly);