The HACS submission check does not read hacs.json to find the integration: it
globs `*manifest.json` over the whole clone of the default branch and exits 1
unless there is exactly one (hacs/default, scripts/helpers/integration_path.py).
Three files matched — the two stand-only integrations added on 2026-07-31 and
the golden baseline index added on 2026-08-11 — so the Hassfest job of PR #9004
went red five weeks into the review queue, with a log that named no file.
The stand manifests ship as manifest.template.json and demo/stand/install.sh
renames them at install time; the golden index becomes baselines-index.json
(the exported constant keeps its name, so no consumer changes).
test/repo-hygiene.test.mjs fails if a second manifest ever appears, and the
existing golden-policy assertion — which compared against 'manifest.json' and
happily passed on 'baseline-manifest.json' — now checks the suffix.
Plan-editor wall thickness (docs/WALL-THICKNESS.md) and keep the white drawing sheet under the grid in editors even when a backdrop image is loaded.
Co-authored-by: Cursor <cursoragent@cursor.com>
Ship the unreleased 1.59 batch on dev: top-view furniture in the decor
layer, space toggles to hide decor/openings, stable card-mod data-*
hooks, HA entity value formatting, and the approved wall-thickness
spec (docs only — not implemented yet).
Co-authored-by: Cursor <cursoragent@cursor.com>
Two owner reports after v1.57.0, both about coordinates.
=== DEV-B58-01: nothing stops at the old canvas border any more ===
The infinite canvas freed the FRAME and the DRAWING; it did not free the
drag handlers, and both the owner and a user hit that within a day:
"названия комнат и устройства не перетаскиваются дальше старых границ
холста".
Two clamps survived v1.57.0, and the second is the worse one:
* `_pointerMove` (device marker) clamped into `_baseVb()` — the CONTENT
FRAME, with a 0.8 % inset. A marker could never be dragged past the
outline of what was already drawn, so a plan could not be extended by
putting a device where the next room was going to be.
* `_labelMove` (room label) clamped into `_spaceModel().vb` — the
space's STORED `view_box`, which is `[0,0,1,1]` for every plan the
card has ever written. Literally the old square: a room drawn at 2.5
had a name that could not reach its own room.
And one asymmetry: `_decorCommitDraft` and the decor text anchor had no
guard at all, while `_decorMoveUpdate` did — a draft could be born
outside the range the mover then refused to leave.
The rule now is one line: an editor gesture has exactly ONE bound,
`+/-CANVAS_LIMIT`, the same number `validation.py` enforces, and it is a
garbage limit rather than a frame. `clampCanvasR` / `clampCanvasN` in
space-geometry.ts are the only two functions allowed to impose it, and
`_snap()` applies it on the way out, so every gesture that goes through
the snap is bounded by construction.
demo/smoke_drag_bounds.mjs starts from an ORDINARY plan (rooms inside
0..1, so the old clamps really were in the way), drags a marker, a room
name, a decor shape and an opening far past the old square, checks each
arrives, is stored, survives a rebuild and takes the frame with it — and
that a wild drag still parks at exactly 5000 rather than 1e12. Seven of
its eleven facts fail by name on 85263d5.
=== DEV-B58-02: everything strictly on the grid ===
The owner's suspicion first, answered honestly in docs/CANVAS.md §9.2:
THE GRID STEP DID NOT CHANGE. `_gridPitch = NORM_W / GRID_N = 1000/240`,
both constants, independent of the frame, the view, the zoom, `view_box`
and `cell_cm`; `git log -S` shows neither touched since v1.4.0. So the
move to the infinite canvas did not put any existing element between the
nodes. `gridLevels()` changes what is DRAWN, never what is SNAPPED TO.
What WAS off the grid, and is now fixed:
* auto placements. `defaultPositions`, the `spaceCenter` fallback and
an undragged room label used centroids, which are not nodes for an
odd-sized or polygonal room. This is the likeliest thing the owner
was actually looking at.
* `_decorMoveUpdate` snapped the DELTA, which preserves whatever
off-grid offset a shape already had for ever, one step at a time. It
snaps the resulting anchor now, so one drag is enough.
* `snapToGrid`/`snapR` returned 500.00000000000006 for an exact 500 —
the round trip through a non-dyadic pitch. They are bit-identical on
a node now, so "is this on the grid?" stops answering no.
Openings and split points on a wall are deliberately NOT rounded to a
node — a door on a node but off its diagonal wall is broken geometry.
They are WALL-bound: projected onto the wall, then the offset ALONG it
quantised to the same step (`snapToWall({step,length})`,
`snapPointAlongPoly`). On the axis-aligned, grid-drawn walls the editor
itself makes, the two rules give the same point. The centre magnet is
consulted FIRST, so a wall whose middle is not a node can still hold a
centred window (this is what smoke_opening_measure caught).
Shift now means one thing everywhere: suspend the snap for this gesture.
It keeps its two older meanings (no centre magnet, coarse 15° compass).
=== And why an ACTION rather than a silent migration ===
Old plans may hold coordinates between the nodes. The card does not
round them on update. General settings grow a Grid group with
«Выровнять всё по сетке», which first states how many elements will
move and by how much at most, warns that there is no undo, and only then
writes — one config/set plus the layout updates, in one go.
1. A migration moves the user's data without asking. A house plan is a
drawing; the card has no mandate to redraw it on a version bump.
2. Some elements are off-grid ON PURPOSE — a small decor label nudged
next to an icon, a window on a diagonal wall, a plan traced over a
photo whose scale was never a whole number of cells.
3. A silent migration is unattributable: when a room looks 3 cm wrong
the owner cannot tell whether the card did it or they did.
4. An update that rewrites stored geometry cannot be undone by
downgrading the card. A button can simply not be pressed.
`alignAllToGrid()` (src/align-grid.ts) is pure — it copies its input and
returns the new spaces, the new layout and the report — so the dialog
measures and commits the SAME object and cannot promise one thing and do
another. test/align-grid.test.mjs pins what moves, what does not (a
stray opening with no wall in reach stays put), that a rect's FAR corner
lands on a node too, and idempotency: a second run reports moved 0,
changed false, and deep-equals the first. demo/smoke_grid_snap.mjs does
the same through the DOM plus every by-hand placement.
docs/CANVAS.md §9 carries the whole contract; docs/TESTING.md gains
three manual items. i18n en/ru. The backend is untouched — same
coordinates, same schema.
Audit finding P2. At a grazing sun the wedge lost the two invariants it
was supposed to keep: one end of the GLASS started at opacity 0, and the
two sides of one shaft came out 5.41 and 84.19 long — the long one 31 %
LONGER than the pre-cut 64, not 30 % shorter.
The cause was the axis. The gradient ran along `dir` from the middle of
the window span, so the geometry had to be skewed (each end extruded by
a different amount) to make both far corners land on the same offset.
That buys the iso-alpha far edge with the other two requirements.
The light is a bundle of PARALLEL rays: the distance a point has
travelled from the glass is depth/cos, an affine function of the point,
whose level sets are lines PARALLEL TO THE WALL. So the correct linear
gradient runs along the wall's INWARD NORMAL, starts on the window line
and is `len·cos(incidence)` long — SunRay.normal / SunRay.depth. A point
`source + dir·u` then lands on offset u/len, whichever ray it rode in
on. All three invariants hold at once:
* the whole pane of glass is at depth 0 → peak alpha end to end;
* alpha depends only on how far that point's own ray has run;
* rayQuad() is an honest parallelogram again (both ends extruded by the
same `len`), and its far edge — parallel to the wall — IS the
gradient's last iso-alpha line, so a bright kerb is impossible by
construction and the −30 % holds for every side of every wedge.
windowLit() gets a real threshold instead of the 1e-9 epsilon:
RAY_MIN_COS = 0.05, i.e. the sun must clear the plane of the wall by
~2.9°. Below it glass reflects nearly everything and the shaft would be
a sliver thinner than the wall it came through — nothing is drawn, and
the gradient axis can never degenerate to a point.
Tests: rayQuad now asserts equal, full-length sides and a wall-parallel
far edge; new unit tests replay the auditor's repro with his numbers
(both sides 44.8, offsets 0 at both ends of the glass, offset = travel /
len for arbitrary rays) and the RAY_MIN_COS cut-off. smoke_sun_soft
measures the same facts off the DOM gradient end to end and fails by
name on the old bundle (9 named failures). docs/SUN.md carries the new
contract and the finding.
Owner, 2026-08-04, on yesterday's attempt: «с лучами солнца ты сделал фигню —
не надо размывать их боковые грани».
They are right. 22b588e answered "the shafts run into something invisible" with
a Gaussian blur of the WHOLE wedge (`raySoftness`, filter `hp-sunsoft`), which
feathered the sides as well as the tip. A shaft of light through a window has
crisp sides; only its reach fades. The blur turned every wedge into a smudge.
GONE. `raySoftness()`, the `<filter>`/`feGaussianBlur` in <defs>, the `<g
filter clip-path>` wrapper, and with it the `hp-sunclip` clipPath — that clip
existed only so the blur could not bleed through a wall. The polygons come out
of `computeSunRays()` already intersected with the room, so a wall still stops
the light by geometry (demo/smoke_sun.mjs, wedgeClippedToRoom). The sun layer
is plain `<polygon fill="url(#hp-sun-i)">` again.
THE KERB DID NOT COME BACK, and not by luck. The old bright edge floating in
mid-floor was never about softness: the gradient's iso-alpha lines are square
to the SUN, while a parallelogram's far edge is parallel to the WALL. Head-on
they coincide; at any other angle one far corner sits at offset `1 − 0.5/k` —
0.71 of the way at a low sun, 0.11 at a high one — i.e. still lit when the
polygon ends. So `rayQuad()` no longer builds a parallelogram: each side is
extruded until it reaches the same distance `len` ALONG `dir`, which puts the
far edge on one iso-alpha line of the gradient. Combined with the untouched
`RAY_FADE_END` = 85 %, the last 15 % of every wedge is empty and its outline
has nothing left to draw. The sides stay razor-sharp on purpose.
Length (×0.7) and the live sky catch-up are untouched.
Tests: unit — `rayQuad` at six sun angles (sides exactly parallel to the ray,
both far corners at offset 1, far edge ⊥ ray, nothing past the gradient) plus
the head-on parallelogram pinned; the `raySoftness` test is gone with the
function. Smoke — demo/smoke_sun_soft.mjs keeps the reach and the "dead at
85 %" checks and flips the feather assert into its opposite: no filter on any
wedge, no `feGaussianBlur` in the tree, and at an OBLIQUE sun (230°/8° and
225°/55°) no vertex is drawn past the end of the gradient. Verified to fail on
the previous bundle on exactly those four. All 247 unit tests and all 97 smokes
green. Stills: sun_sharp_low / sun_sharp_high (demo/shot_sun_short.mjs now
takes a file prefix).
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.