Compare commits

..
Author SHA1 Message Date
Cursor AgentandMatysh f4e5ce6822 chore: add Cursor Cloud dev setup notes and ignore backend venv
- Add AGENTS.md documenting the card + integration + demo harness,
  and non-obvious cloud caveats (Python 3.13 backend venv for HA-harness
  tests, fresh-bundle copy before smoke, demo render nudge, known
  pixel-precision smoke).
- Ignore .venv-backend/ (Python 3.13 venv provisioned by the update script).

Co-authored-by: Matysh <Matysh@users.noreply.github.com>
2026-08-05 04:45:11 +00:00
Matysh 46f20fe4e1 v1.58.0
Validate / hacs (push) Failing after 12s
Validate / hassfest (push) Failing after 13s
Validate / frontend (push) Successful in 2m50s
Validate / backend (push) Failing after 8m29s
Validate / smoke (push) Failing after 21m33s
2026-08-04 15:10:46 +03:00
Matysh 1108b2bc14 v1.58.0: backdrop transform, paper by rooms, align-to-grid fixes 2026-08-04 15:04:55 +03:00
Matysh 2fd46493db v1.58.0-beta.1: backdrop transform, paper by rooms, decor tool fix 2026-08-04 14:13:38 +03:00
Matysh df233905c5 DEV-B58: one bound, one grid — the canvas border and the snap contract
Validate / backend (push) Failing after 8m25s
Validate / smoke (push) Failing after 12m2s
Validate / hassfest (push) Failing after 7s
Validate / hacs (push) Failing after 7s
Validate / frontend (push) Successful in 2m48s
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.
2026-08-04 12:42:16 +03:00
Matysh 85263d520f Rebuild the tracked bundles for the 2026-08-04 batch
dist, demo/srv/assets and custom_components/houseplan/frontend are the
same bytes as a fresh production build of this tree: round room border
joins, the decor draft's live size badge and the normal-axis sun fade.
2026-08-04 12:05:59 +03:00
Matysh 02ae7f9588 DEV-EB173-01: a shaft of light fades along the wall's normal
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.
2026-08-04 11:59:11 +03:00
Matysh 1dc03e1fb1 The background editor measures the line you are drawing
Owner 2026-08-04: «в редакторе подложки у линий писать длину, как при
рисовании комнат в редакторе плана».

The decor draft now feeds the SAME badge a wall gets while a plan is
drawn — _fmtLen (segmentCm over the space's cell_cm), the HA unit
system, the green .on45 highlight, the .measurelabel chrome. The only
difference is where it sits: a wall badge follows the cursor because the
cursor is the wall's free end, while a decor line is pulled out by both
ends at once, so its badge rides the MIDDLE of the segment (owner:
«плашка на середине линии»).

Rectangles and ovals have no length but they do have a size, and the
same two calls answer it: «W × H» of the bounding box. A draft that has
not moved yet shows nothing — a «0» badge is noise, not a measurement.

smoke_decor now draws a line and asserts the badge's exact text against
the geometry (12 cells x cell_cm 5 = 0.60 m, 0°), its position at the
midpoint, the 45° highlight, the oblique 3-4-5 case, the W x H box and
that it is gone after the release.
2026-08-04 11:52:25 +03:00
Matysh d21d532f10 No room border ends in a tooth: walls join round
Owner 2026-08-04: «углы границ комнат всё ещё с зубцами (фиксили для
декоративных линий, они теперь заканчиваются полукружьями, надо сделать
так же для границ комнат)».

A default miter join on a sharp room corner shoots a spike far past the
two walls that meet there, and flips to a flat bevel once the miter
limit clips it — both read as a tooth. Room borders now join ROUND:

* .room (polygon / evenodd path / rect) — one rule, so the plan view,
  the Plan editor and the static space-card all get it;
* .room-outline — a room with open boundaries draws its walls as
  separate M..L subpaths, so its corners are stroke ENDS: round caps
  close them the same way a round join closes a contour;
* .seg — the contour being drawn in the editor already had round caps,
  now it states the join too.

smoke_render_parity checks both renderers and the trimmed outline;
demo/shot_room_joins.mjs is the before/after still (a 45° apex).
2026-08-04 11:47:50 +03:00
Matysh 50ba443492 v1.57.0
Validate / backend (push) Failing after 8m25s
Validate / smoke (push) Failing after 1m2s
Validate / hacs (push) Failing after 10s
Validate / hassfest (push) Failing after 9s
Validate / frontend (push) Successful in 2m46s
2026-08-04 11:27:09 +03:00
Matysh aa01eaef01 v1.57.0 2026-08-04 11:21:30 +03:00
Sergey 93b60c5b8e Editor grid dots are a hint: mute both levels (0.35 / 0.5)
The adaptive grid drew at full strength — on the white paper of a drawn
plan the dots argued with the walls instead of guiding them. Both levels
are dimmed, the CAD hierarchy kept: fine dots 0.75 -> 0.35, coarse nodes
1 -> 0.5, so the accents still carry the scale reference on the dark
scene background. Editors only; View draws no grid (smoke_grid_fade).
2026-08-04 11:01:39 +03:00
Matysh eb17396006 Sunlight has hard sides again and fades only along the ray
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).
2026-08-04 10:30:22 +03:00
Sergey 0af50a74ae A kiosk pan stays a pan all the way to the release
The gesture is classified once, on the first movement past 8 px
(`_panLock`), but `_stagePointerUp` ignored that decision and asked
`swipeTarget()` again from the raw start→end vector — audit DEV-1DA1-02.

So a CURVED gesture could be both: a small vertical lead-in locked
`pan`, the plan started following the finger, the trajectory then swept
far sideways, and lifting the finger landed the user on another storey.
On a wall tablet that is the worst kind of surprise — you watch the plan
drag along and end up on a different floor.

The lock is now final: with `_panLock === 'pan'` the floor never
changes, whatever the overall vector looks like, and only a gesture
locked as `swipe` may reach `swipeTarget()`. A motionless tap locks
nothing, so the double-tap zoom reset is untouched.

Regression: demo/smoke_kiosk_pan_lock.mjs — the auditor's curved pan
(both directions and a long diagonal), the mirror case of a swipe that
bends vertically (it never pans, and if it stops qualifying it simply
does nothing), plus the straight swipe / straight pan / double tap /
zoomed-in cases. docs/CANVAS.md §5 and docs/TESTING.md updated.
2026-08-04 10:11:57 +03:00
Sergey 232c4807fd Nothing paints over a marker that says it is a curtain
An explicit «Открыть/закрыть» marker is the strongest statement the card
has about what a marker IS, so its cover now decides the plate BEFORE the
bound `controls` and before a lit light of the same device — audit
DEV-1DA1-01.

Until now the cover came third, and the owner's contract «у штор не
должно быть жёлтой подложки НИКОГДА» had two holes: a mixed device (a
lamp that also ships a blind) told «Открыть/закрыть» went yellow off its
own lit light, and a curtain marker with a bound wall switch went yellow
off `controls`. The early `return 'on'` never reached the cover branch,
so the travelling curtain lost its breathing ring as well — and in glow
fill, where the renderer strips `on` from a shining source, it was left
with no indicator at all, while the tap still drove the cover.

Everything else keeps the old precedence: the same mixed device WITHOUT
the explicit action is yellow again, a wall switch still mirrors its
controls, and a «cover» marker whose device carries no cover.* at all
falls back to its primary.

docs/FILTERING.md «What a marker SHOWS» is renumbered accordingly.
Regression: demo/smoke_cover_plate_precedence.mjs (the auditor's two
markers, every cover state, class AND resolved plate colour).
2026-08-04 10:11:32 +03:00
Matysh 285d569102 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.
2026-08-04 09:49:30 +03:00
Matysh 22b588e116 Sunlight is 30% shorter and always dissolves into nothing
Owner, 2026-08-04: «лучи от солнца сделать короче на 30%, проверить, чтобы они
всегда плавно рассеивались (сейчас есть ощущение, что они упираются во что-то
невидимое)».

SHORTER. `rayLength` is now the v1.56 curve times RAY_LENGTH_K = 0.7 — 1.75
window lengths at sunrise, 0.56 at the zenith. Scaling the whole curve instead
of re-picking the constants keeps the shape the owner approved: a low sun still
reaches three times further than a high one.

WHAT THEY WERE BUMPING INTO. Nothing invisible — the wedge's own outline, in
three places at once.

1. The gradient runs ALONG the sun, so its iso-alpha lines are perpendicular to
   the sun, while the wedge's far edge is parallel to the WALL. The two
   coincide only for a sun hitting the glass dead-on; at any other angle one
   half of that far edge was cut while it still carried colour — a straight
   bright kerb hanging in the middle of the floor. The single `100% → alpha 0`
   stop hid this from the reader of the code and from nobody else.
2. The two SIDES of the wedge had no falloff at all: two razor lines from the
   window into the room, brightest exactly where they are most visible.
3. Where the room outline clips the wedge — the opposite wall, the inner corner
   of an L, and above all an OPEN (virtual) boundary, which has no wall drawn
   at all — the shaft was chopped at whatever alpha it still had.

WHAT IT IS NOW. The gradient still spans the FULL wedge (geometry and gradient
must describe the same shaft), but `rayStops()` eases it to a hard zero at
RAY_FADE_END = 85% of the length, so the last 15% of every wedge is guaranteed
empty and a shaft ending in mid-air has nothing left to draw an edge with. Each
wedge is then drawn inside `<g filter clip-path>`: SVG applies the filter FIRST
and the clip SECOND, so a Gaussian blur of `raySoftness(len)` (7% of the shaft,
clamped 3…18 render units) feathers the sides and the tip and the room outline
cuts that feather off. Light still never crosses a wall — but where it reaches
one, the kerb is a soft ramp that reads as light landing ON the wall.

Clipping by the room is untouched; only its visible edge changed.

Tests: unit — rayLength pinned at exactly 70% of the old curve at ten
elevations, rayStops (monotone, dead at/after 85%, bright at the glass),
raySoftness clamps. Smoke — demo/smoke_sun_soft.mjs, which fails on the
previous tip (lowSunIs70Percent, highSunIs70Percent, gradientSpansWholeWedge,
deadWellBeforeTheEnd, everyWedgeFeathered). Stills: demo/shot_sun_short.mjs.
2026-08-04 09:47:34 +03:00
Matysh 1da1aba625 Curtains never wear a coloured plate
Validate / hacs (push) Failing after 1m8s
Validate / hassfest (push) Failing after 1m6s
Validate / frontend (push) Successful in 2m26s
Validate / backend (push) Failing after 7m59s
Validate / smoke (push) Failing after 9m32s
Owner's contract, 2026-08-04, verbatim: «у штор не должно быть жёлтой подложки
никогда, индикация открыто/закрыто за счёт морфинга иконки».

WHAT 'open' WAS. `.dev.open` is not a border — it is the badge FILLED with
--hp-open (#ff9f43), border and glyph colour included: a solid orange plate,
one step down from the yellow «включено» one. Covers shared a branch with
`valve` and took it in `open` AND `opening`, so a travelling curtain wore the
orange plate UNDER the breathing ring the owner approved a day earlier — the
plate he had just said should stay neutral while it moves, kept for the state
it stopped in. Since de53d53 an «Открыть/закрыть» marker reads its cover
wherever that entity sits, so the paint had just reached every curtain that
had the action set, his own included.

WHAT IT IS NOW. `_stateClass` returns no plate class for the `cover` domain in
any state: closed, open, ajar (HA reports a positioned cover as plain 'open'),
opening and closing all keep the neutral badge, and motion is the `.covermove`
ring alone. Open/closed is told by the ICON — which makes the morph the only
signal there is, so it had to stop having holes:

- `awning` mapped BOTH states to `mdi:awning-outline` — one glyph for open and
  closed, i.e. no indication at all for that class. Now outline (retracted) ->
  `mdi:awning` (extended).
- a cover with NO device_class (z2m ships plenty) only morphed if its icon
  happened to be in a device_class pair — and the icons the card itself hands
  out are not: the name rule «штор|curtain|blind|shade» gives `mdi:roller-shade`,
  «ворота|garage|gate» gives `mdi:garage-variant`. Those, plus
  `mdi:blinds-horizontal` and `mdi:door`, are now recognised as pairs on the
  base icon (COVER_ICON_ALIASES — base-icon matching only, never picked by
  device_class, so nothing is swapped for a guess).
- a hand-picked icon still wins outright everywhere, with ONE exception: a
  cover whose custom icon IS one of those pair members morphs inside THAT pair
  (`mdi:curtains` <-> `mdi:curtains-closed`) — never traded for another family.
  Without it, choosing an icon would silently switch the marker's only
  indicator off.

WHAT KEEPS THE FRAME, deliberately: door / window / garage_door / opening
binary sensors, an unlocked lock — and `valve`, which parts ways with `cover`
here. No icon pair morphs for a valve, so the frame is the only thing it has
to say «открыт» with; sweeping it along would have left those markers mute for
a rule that names the curtains. If the two domains should ever read alike, a
valve needs an icon pair first (docs/FILTERING.md).

smoke_cover_no_plate.mjs walks one curtain through closed / open / ajar /
opening / closing and reads the COMPUTED plate colour against probes of
--hp-bg, --hp-on and --hp-open: neutral every time, never yellow, never
orange, no 'on'/'open' class, the breathing ring in the two travelling states
and nowhere else. It also checks the morph for all ten classes both ways, the
no-device_class and custom-icon paths, and — the point of the whole bottom
half — that an unlocked lock and an open window sensor STILL come out orange
(and a locked lock neutral again, so the frame still means something). 13
checks are red on the parent commit. The unit suite gains a loop that fails
any class mapping both states to one glyph. smoke_cover_tap and
smoke_cover_not_primary flip their «open frame» assertions to the new
contract; docs/FILTERING.md gets the state table and the valve reasoning,
docs/TESTING.md the checklist item. shot_cover_states.mjs captures the four
states side by side.
2026-08-04 04:38:23 +03:00
Matysh de53d530fa A curtain marker shows the cover it opens
Owner, 2026-08-04, on his own curtains: «нет ни дышащего кольца во время хода,
ни рамки "открыто", ни морфинга иконки». Same device and the same cause as the
tap fix two commits before this branch: his Aqara «Roller shade driver E1»
ships the `cover.*` hidden by the integration and a visible
`switch.*_reverse_direction`, so `primaryEntity` picks the service switch —
and `_stateClass`, the state-morphed icon and the ripple all read `d.primary`.
The plan reported the state of the reverse-direction option: a yellow
«включено» plate whenever it was on, and nothing at all while the curtain
actually travelled.

`coverEntityOf` already knew where the cover was; the indication now asks it
through one helper, `_coverIndicator` — the device's cover when the marker's
tap action is explicitly «Открыть/закрыть», null otherwise — and `_actEntity`
(`_coverIndicator || primary`) is what the tap path and the marker
presentation now share. Same entity offered in the dialog, driven by the tap
and shown on the plan.

THE RULE, and why it is the least surprising one (docs/FILTERING.md «What a
marker SHOWS»): picking «Открыть/закрыть» is the only statement the card has
that means «this marker IS the curtain», and the dialog offers it exactly for
the devices that own a cover. Hanging the indication on «the device has a
cover somewhere» would have re-decided, silently, what a mixed marker is — a
lamp that also owns a blind would stop showing the lamp. The precedence in
`_stateClass` is unchanged above it: bound controls first, then a lit light
(the glow spot and the badge may never disagree), then the cover, then the
primary — so even with the action chosen a shining lamp keeps its yellow. The
price is that a curtain left on «Инфо-карточка» still speaks for its primary;
that is one click in the dialog, and it is the honest reading of what the
marker has been told it is.

smoke_cover_not_primary.mjs grows an indication section on the owner's device:
closed / open / opening / closing give no class, `open`, `covermove`,
`covermove`, the icon morphs `mdi:curtains-closed` <-> `mdi:curtains`, and
reverse-direction ON never lights the marker again. The rule's boundary is
asserted from both sides (take the action away — the primary speaks again;
give it back — the cover does), a lit lamp with a travelling cover keeps its
yellow and its own icon, and the auditor's own DEV-2C947-04 shape (both
entities VISIBLE) is pinned for the tap as well. Eight checks are red on the
parent commit.
2026-08-04 01:45:05 +03:00
Matysh ade8daab16 Icons are measured by the same plan the frame is
Audit dev@2c947f4, DEV-2C947-03 (P2). Three rooms in the core plus one dragged
90 canvases out: the frame rejected the stray exactly as §4.1 promises, and
then a perfectly ordinary marker on the main plan came out 90.89x too big and
covered the house. `contentFrame` voted; `iconUnit` did not — it took
`boxOf(every room)`, so the distance to the stray the frame had just thrown
away lived on in the numerator of `iconCqw`.

`iconUnit` now takes `contentFrame(roomItems, { pad: 0 }).core`: the same
main-mass vote, over the same rooms it always used (rooms only is what keeps
the full card and the static card bit-identical), with no padding, because
this is a UNIT and not a viewport. Below MIN_VOTERS nothing is declared an
outlier, so every ordinary plan — and every genuinely wide one, where the
majority veto applies — keeps exactly the unit it had. `defaultPositions`
takes its declump distance from the same call, so the auto-placement spacing
follows without a second rule.

Unit (test/canvas.test.mjs): a far room leaves both the frame and the icon
unit alone, `iconCqw` on the strayed plan equals `iconCqw` on the same plan
without the stray, and a plan that is honestly two canvases wide still scales.
smoke_canvas_frame.mjs measures the rendered badge in px with and without the
far room. Both are red on the parent commit.
2026-08-04 01:44:08 +03:00
Matysh 05a2a838d6 The editor's grown frame stays in the editor
Audit dev@2c947f4, DEV-2C947-02 (P2). Move the only room from 0.1..0.9 to
5.1..5.9 inside the Plan editor and go back to View: the frame stayed 5880
units wide instead of the room's 880, and only a manual `_frame = null` put it
right. Anything that moves, deletes or heavily resizes geometry in an editor
left View looking at ground the plan no longer occupies — until some unrelated
model/layout/device change happened to invalidate the memo.

The growth itself is deliberate and stays (docs/CANVAS.md §4.3): inside an
editor the frame bounds pan and defines what zoom 1 means, and one that shrank
the instant a room was deleted would move the ground under a live gesture. The
bug was that the growth was invisible to the memo — `_frame`'s key carried the
space, the model, the layout, the devices and the show-far flag, but not the
mode, so the accumulated union was handed straight back in View.

`grow` (`_mode !== 'view'`) is now part of the key, and the union is only ever
taken against a frame the same editor session produced. Leaving an editor
recomputes from the content; entering one starts from the current geometry
instead of resurrecting the union of a previous session.

smoke_canvas_frame.mjs grows the auditor's scenario: the frame before, the
union inside the editor (asserted, so the growth cannot be "fixed" by deleting
it), the frame after exit — 5060..5940 — and re-entry. Two checks are red on
the parent commit.
2026-08-04 01:43:28 +03:00
Matysh f4ad843619 A hidden device no longer stretches the plan's frame
Audit dev@2c947f4, DEV-2C947-01 (P2). One visible room and one marker with a
saved position 90 canvases out, then the marker is hidden: the auditor's probe
measured a frame 112.375x wider than the room it drew — the house opened as a
dot in the corner of empty canvas. The same on `houseplan-space-card`.

Both cards filtered the devices for RENDERING and framed the unfiltered list.
The full card's `_contentItems` walked `_devices` without looking at `hidden`,
while the renderer a few lines later drew `!d.hidden`; `space-render.ts` said
it out loud — `devs = spaceDevs.filter(d => !d.hidden)` for the markers,
`spaceDevs` for the frame.

The frame is PRESENTATION (docs/CANVAS.md §4), so it follows what is drawn.
Hidden devices keep everything the filtering contract gives them: they are
still built, still counted by room LQI, still hold their cell in the auto-grid
roster (so hiding one does not move a visible neighbour) — they are simply not
content items. The device editor's ghosts are not items either: reaching a
ghost is what the §5 pan slack is for, and making the frame follow a local,
ephemeral editor toggle would have made the opening view depend on which tab
had it switched on.

demo/smoke_canvas_frame.mjs is the auditor's probe, both cards: with the
marker visible the frame holds it (2 items is below MIN_VOTERS, so the outlier
vote cannot quietly rescue the test); hidden, the marker is gone from the DOM,
the frame is exactly the room's 60..940 and the room fills the stage. Three of
its checks are red on the parent commit.
2026-08-04 01:42:44 +03:00
houseplan-dev a7d58f0552 Open/close finds the cover even when it is not the primary entity
Owner's report 2026-08-04: «в настройках "открыть\закрыть", а по нажатию
по-прежнему инфо-карточка».

Diagnosed on his own config, not guessed. The two curtain markers in the
office (`.storage/houseplan.config`) carry `tap_action: "cover"` exactly
as the dialog wrote it — so saving was never the problem. The devices
are Aqara «Roller shade driver E1», and their entity registry reads:

  cover.shtory_v_kabinete_sprava            hidden_by: integration
  switch.shtory_..._reverse_direction       visible
  sensor.shtory_..._motor_state             visible
  binary_sensor.shtory_..._running          visible
  + battery / temperature / linkquality     diagnostic

`primaryEntity` ranks visible above hidden (that tier loop is deliberate
— a TRV's anti-scaling switch must not outrank the head that heats), and
inside a tier `switch` outranks `cover`. So the marker's primary was
`switch.*_reverse_direction`, `_clickDevice` handed the domain `switch`
to `resolveTapAction`, and `want === 'cover'` with `domain !== 'cover'`
degrades to 'info' — the info card the owner kept getting. The dialog
meanwhile went on offering the action, because `_bindingCoverTap` had
always looked at EVERY entity of the device. The two checks disagreed
about what the device is.

Fixed the way the climate temperature already does it: what a device
DOES is not always what its primary entity is. `coverEntityOf(entIds)`
(logic.ts) returns the first `cover.*` among all of the marker's
entities; `_clickDevice` uses it as the entity the tap acts on whenever
the explicit action is 'cover', and reads the domain, the device_class
and the current state off it, then calls the service on it. So the
guarded classes still degrade: a garage door's `cover.*` is found the
same way and `resolveTapAction` still answers 'info'. `_bindingCoverTap`
now goes through the same helper, so the option offered and the action
taken can no longer disagree about WHICH cover. No cover at all on the
device: `coverEid` is null, nothing changes, still the info card.

demo/smoke_cover_not_primary.mjs builds the owner's device entity for
entity (hidden cover + visible reverse-direction switch + diagnostics),
asserts the premise (the primary IS the switch), then goes end to end:
open the marker dialog, pick «Open/close», save through _saveMarker, let
the card rebuild the marker from that config, tap — cover.open_cover on
cover.office_curtain, then close_cover, then stop_cover while
travelling, and the service switch is never called. A garage
device_class on the same cover calls nothing, shows the info card and is
not offered in the dialog. Before the fix four of its checks are red.

Unit: coverEntityOf over the same entity list, empty/null input, two
covers (first wins) and a `sensor.cover_position` decoy.
2026-08-04 00:44:01 +03:00
houseplan-dev fd72330549 Drag the plan at any zoom, not only above 100%
Owner's report 2026-08-04: «добавь возможность таскать план при любом
масштабе, а не только при более 100%, как сейчас (и в редакторах, и в
просмотре)».

_stagePointerMove moved the view only while `_zoom > 1`. That gate is
older than the infinite canvas and made sense under the old rule — the
content had to cover the scene, so at 100% or below there was literally
nowhere to go and a drag could only jitter. The infinite canvas removed
the edge and gave panning a slack of one screen past the content in
every direction (CANVAS.md §5), and from that moment the gate was not a
guard but a missing feature: at 100% you could see the arrow «home is
that way» light up from a wheel-zoom, and still not drag the plan an
inch. The zoom no longer takes part in the decision — `_clampView`
alone says how far you may walk, at 400% and at 33% alike.

The drag also stopped depending on `_view` being materialised: it reads
`_viewOr(baseVb)`, so the very first drag on a freshly opened space
pans instead of doing nothing.

Gesture ownership is unchanged, and that is the point of most of the
new smoke: `_stagePointerDown` still bails out on the room-resize
handles, device badges, openings, room labels and decor shapes, and on
a decor drawing tool that consumes the press; two fingers are still a
pinch. The one place where a drag had a rival is the kiosk, where a
horizontal swipe changes floors. It is now classified once per gesture,
on the first movement past 8px (`_panLock`): horizontal in the swipe
zone (kiosk, zoom <= 1, more than one space) = swipe and no pan,
everything else = pan. So the plan never slides out from under a swipe,
a vertical drag on a wall tablet pans as it does everywhere else, and
zoomed in — where swipeTarget already refuses — a horizontal drag pans.

demo/smoke_pan_any_zoom.mjs: a drag on empty scene moves the view at
100%, 50% and 1/3 in View and in every editor (all seven plan tools,
Devices, Background), and at 400% as before; the walk stops at the
PAN_SLACK limit and the home arrow appears; a resize handle resizes, a
device badge moves the device and an opening slides along its wall,
none of them panning a pixel; two fingers still zoom; the kiosk still
swipes floors through a gesture that has real pointermove events in it
(smoke_kiosk only ever sent down+up), a vertical drag there pans, and a
zoomed-in horizontal drag pans without changing the floor.

Before the fix 25 of its checks are red, including every editor at
every zoom.
2026-08-04 00:43:05 +03:00
houseplan-dev 2c947f4f7a Icons scale with the plan again, and a 5 degree angle step
Two owner corrections after the infinite canvas.

- --icon-size goes back to being a percentage of the PLAN: a marker
  grows and shrinks with the zoom, like everything else drawn on the
  plan. The infinite canvas had made it a percentage of the viewport
  (fixed pixel size) — the owner looked at it and asked for the
  original contract back.
  What survives from the canvas work is the NUMERATOR. The old
  expression divided by `vb.w`, the stored view_box, which is not a
  frame any more; a fixed NORM_W in its place would have shrunk every
  marker on a plan drawn past the old square by exactly the factor the
  plan is outsized (an invisible dot 50 canvases out). So it is now
  `iconCqw() = iconPct * iconUnit(space) * kioskScale / view.w`, one
  pure helper both renderers call. `iconUnit` is exactly NORM_W for
  any plan that fits the old square — and the editor has never written
  anything but `view_box: [0,0,1,1]` — so the rendered size is
  bit-identical to the pre-canvas card: measured against the v1.56.0
  bundle at a fixed view, both give 3.400 / 3.091 / 6.182 / 12.364 cqw
  = 28.52 / 26.11 / 50.22 / 98.44 px. On a plan drawn at 1.5..3.8 the
  marker is 26.1 px, the same as on an ordinary plan, instead of the
  ~11 px a fixed numerator would have given.
  The static space-card uses the same helper: it has no zoom, but its
  frame is the content now, so a bare iconPct shrank its markers as
  the frame tightened. marker.size, the kiosk scales and every
  satellite still ride on --dev-size, untouched.
- the icon angle in the device dialog steps by 5 degrees, not 10
  (0..355): a marker often has to line up with a wall that is not on a
  10-degree grid.

Tests: three unit tests on iconCqw (the legacy expression reproduced
digit for digit, the runaway plan, the no-view fallback); the infinite
canvas smoke's "same pixel size at zoom 1/4/1/3" assert is turned back
into "scales 4x / 1/3 with the zoom" plus a new one that the marker on
the far plan measures the same as on an ordinary one; the angle step
is pinned in smoke_size_angle_parity. docs/CANVAS.md §6 rewritten.
2026-08-04 00:06:21 +03:00
houseplan-dev 693601a8e0 Infinite canvas smoke: pan slack and the "home is that way" arrow
Panning a screen past the content is allowed (there is no edge), the
arrow shows up only when the plan is entirely off screen, one click
fits it back and the arrow leaves.
2026-08-03 23:20:38 +03:00
houseplan-dev c7fa9542ba Infinite canvas: smoke, and the three smokes that pinned the square
demo/smoke_infinite_canvas.mjs: a plan at 1.5..3.0 renders whole with
every room and marker on screen, a device placed at 3.4/2.9 and a room
at 3.8 survive the WS write (and the payload is fed to the REAL
voluptuous schema when it is installed), one stray at 90/90 neither
commands the view nor hides itself, «Показать» fits it, zoom-out stops
at exactly 3x, a marker keeps its pixel size at zoom 1/4/1-3, and an
old small plan frames to the same rectangle as before.

Adjusted, each with the reason in the smoke:
- smoke_audit_1490: "editors see the whole canvas" rewritten into the
  intent HP-1490-03 actually had — there is room to draw outwards;
- smoke_zoom_out: the zoom-out floor is 1/3 of the content, not 0.4;
- smoke_hidden_flag: the static card frames content, so the auto-grid
  parity check reads its viewBox instead of assuming 0..1000.

docs/TESTING.md: a manual checklist section for the feature.
2026-08-03 23:12:49 +03:00
houseplan-dev 478d2042b2 Infinite canvas: render, editors, toolbar and the icon-size change
- the frame is now the content on EVERY path — view mode, all three
  editors and the static space-card. The editor special case ("give
  them the whole square, there is nowhere to draw otherwise",
  HP-1490-03) is replaced by what it actually needed: pan slack of one
  screen in each direction plus zoom-out to 3x the content.
- _clampView no longer pins the content over the scene: there is no
  edge to be stopped at. Zoom-out floor 0.4 -> 1/3 of the content.
- --icon-size is a percentage of the VISIBLE viewport instead of the
  canvas (docs/CANVAS.md §6). Icons no longer grow with the zoom —
  the one deliberate visual change, owner is aware. The per-device
  multiplier and the kiosk scales still feed --dev-size, so every
  satellite scales exactly as before, and the full card and the static
  card now use the identical expression.
- adaptive grid: the dot pattern follows the VIEW (it is a property of
  the plane, not of a box) and thins out by decades as you zoom away,
  with every 5th/10th node kept bigger — the CAD convention.
- the middle zoom button is «Вписать всё» / «Fit all» (the old "reset
  zoom" renamed, not duplicated) and is never disabled.
- an inline chip reports objects an order of magnitude away with one
  «Показать» action that takes them into the frame; a small arrow
  points home when the plan is entirely off screen. No modals.
- the decor drag clamp (-0.25..1.25) becomes the sane-range clamp; the
  fallback position for an unplaced marker is the middle of the
  content, not the middle of a canvas that has no edges.
2026-08-03 23:12:36 +03:00
houseplan-dev 47ab60cddd Infinite canvas: the spec, the pure geometry and the ±5000 limits
docs/CANVAS.md is the source of truth (owner-approved 2026-08-03): the
normalised square was never a sheet of paper, only a coordinate system,
and users who drew past its edge could not place devices there.

Storage does not change and there is no migration. What changes is what
the renderers DERIVE from it:

- space-geometry.ts gains contentFrame() — one item per drawn object,
  a rank-based outlier vote (median centre, 75th-percentile spread,
  10x threshold, majority veto) and a fit-everything box beside the
  opening view. contentBounds() is now a thin wrapper over it; the old
  -25%..125% envelope is gone — it WAS the bug that made a plan drawn
  at 1.5..3.0 frame empty canvas.
- spaceFrame()/spaceCenter() make view_box an optional first-frame hint
  used only when there is nothing to frame; iconUnit() keeps auto
  placement spacing in proportion (NORM_W for anything inside the old
  square, so no layout moves); gridLevels() picks a legible grid step.
- validation.py: coordinates ±4 -> ±5000, sizes 0.001..5000, decor
  -1..2 -> ±5000, opening length <= 5000. Garbage insurance, not a
  frame — a stored 1e100 is still refused.

Units: test/canvas.test.mjs covers the plan past the square, the
outlier (and the three ways NOT to declare one), corruption, empty
space, a lone marker, image plans and the adaptive grid.
Backend: the limits, and that a config from any released version
validates untouched.
2026-08-03 23:12:22 +03:00
houseplan dev 79142d9334 Sun rays: brighter, and a hard 3 degree threshold with a 2 s fade
Owner 2026-08-03: «лучи поярче, иногда плохо видны. Убрать плавное
затухание — появляться и исчезать анимацией в 2 секунды при переходе
через 3 градуса над горизонтом».

RAY_MAX_ALPHA 0.18 -> 0.30: checked against both hard cases, a daylight
sun on white paper and a low sun over the dark glow canvas
(demo/shot_sun_bright.mjs writes the pair).

The gradual ramp-in over the first ~2 degrees is gone. rayAlpha() is now a
threshold: 0 below RAY_ELEVATION_MIN (3), rayPeakAlpha(cloud) at or above
it — cloud cover stays the only multiplier. Crossing it animates the
LAYER, never the geometry: <g class='sunlayer'> fades in/out over exactly
RAY_FADE_MS = 2 s (hp-sunfade-in / hp-sunfade-out), and the card keeps the
group mounted with .out for those two seconds so the dissolve can play at
all. prefers-reduced-motion skips it. Every other reason to drop the
wedges — editor, feature off, night, rain — stays instant.

Units: rayAlpha rewritten (ramp tests dropped), raysVisible/rayPeakAlpha/
RAY_MAX_ALPHA covered. Smoke: smoke_sun gains a threshold section (8 of
its checks fail on the previous build). docs/SUN.md + TESTING.md updated.
2026-08-03 22:25:03 +03:00
houseplan dev bb4d4e1f6e Opening rulers also while PLACING a new opening
The shoulder badges, the centre tick and the soft magnet used to live only
in the drag of an EXISTING opening. Placing a new one — the gesture where
you actually choose the spot — showed a bare dashed ghost.

One implementation now serves both: _opRuler() takes a wall snap, the
opening length and the Shift flag, returns the magnetised point plus the
badges/tick, and is called from _opPointerMove (drag), _openingPreview
(hover) and _openingClick (placement). The click therefore creates the
opening exactly where the preview showed it, and clearing _cursorPt makes
ghost, badges and tick disappear together the moment it lands.

Smoke: smoke_opening_measure gains a «PLACING a new opening» section (13 of
its checks fail on the previous build). TESTING.md: checklist row.
Shot: demo/shot_opening_place.mjs.
2026-08-03 22:14:54 +03:00
houseplan dev b6675dc3a4 Covers: 'Open/close' tap action + travelling indication
The tap-action list gains 'cover' (i18n en/ru), offered only for a binding
that HAS a cover entity and never for the guarded classes garage/door/gate;
a value saved there anyway degrades to 'info', like a card-wide toggle does.

The service follows the CURRENT state: closed -> open_cover, open (incl.
ajar) -> close_cover, opening/closing -> stop_cover (a tap during travel is
a stop; the next one simply reverses), no readable state -> cover.toggle.
The existing 'ask for confirmation' checkbox guards it too.

Indication: a travelling cover breathes a soft yellow ring around the icon
(.covermove, the vacuum puck's 2.2s period, static under
prefers-reduced-motion) and its plate stays NEUTRAL — yellow means
'включено'. Static states morph the icon by state + device_class
(blinds/shutter/curtain/…); an unknown state morphs nothing and pulses
nothing. No position percentages.

Backend: validation.py accepts tap_action='cover' (+ test).
Smoke: demo/smoke_cover_tap.mjs. TESTING.md: checklist row.
2026-08-03 22:08:16 +03:00
Matysh 53e1c7163d General settings: About block — card version, GitHub and Telegram links
Validate / hacs (push) Failing after 9s
Validate / hassfest (push) Failing after 8s
Validate / frontend (push) Successful in 2m22s
Validate / backend (push) Failing after 7m51s
Validate / smoke (push) Failing after 1m3s
- new i18n group gs.about_* (en/ru), rendered after the Sun group
- version line reuses CARD_VERSION (integration version is not exposed
  to the frontend by any backend response, so no second line)
- links open in a new tab (rel=noopener), mdi:github / mdi:send icons
- demo icons.js: added the two mdi paths for the ha-icon shim
- smoke_general_settings: pins the About group, the rendered version
  (must equal CARD_VERSION extracted from the built bundle) and both
  link href/target/rel; verified red on the pre-feature bundle
2026-08-03 17:04:53 +03:00
Matysh ca66791440 Default room border/name colour: dark grey #55606c (was accent #3ea6ff)
Owner call 2026-08-03: the resolved default for spaces without an explicit
room_color is now a dark slate grey — reads on the white paper of drawn
plans and on the glow-dark theme. Spaces where the colour was ever chosen
keep their stored room_color; editor accents, resize handles, marker
ripple default and the compass needle stay on the accent colour.
Pinned defaults updated in test/logic.test.mjs and smoke_space_settings.
2026-08-03 16:38:53 +03:00
Matysh db19753bbf DEV-B703: warm re-mount without the veil + WS outages never blank the plan
Owner's report: «план перезагружается при возврате на вкладку, хотя страница
жива». Diagnosis confirmed: Lovelace re-creates the card element when the
websocket reconnects after a long-backgrounded tab, and the fresh instance ran
the FULL first-open boot — veil + BOOT_MIN_MS + quiescence — reading as a plan
reload. On top of that, _loadFromServer's catch nulled _serverCfg after 8
failed tries, so a slow reconnect could genuinely blank an already-shown plan.

DEV-B703-01 warm re-mount: module-scoped memo (lives with the PAGE, not the
instance) of the settled header height, keyed by viewport size × card config.
A repeat instance adopts the settled geometry in setConfig and skips the veil
entirely — synchronous reveal at the saved zoom (HP-1551); a window resize
between instances changes the key and brings the full protective boot back.
The memo follows the live geometry (updated()'s measure) and is written on
every _bootSettled. Test hook: static _warmBootReset().

DEV-B703-02 stale-while-revalidate: an instance that already renders a valid
config (LS snapshot or a successful load) NEVER clears it on WS failures —
the local-only fallback is reserved for a card that never had a backend. A
self-driven retry (backoff, cap 8 s) keeps revalidating after willUpdate's
8-try budget is spent, and a connection 'ready' hook resets the budget and
quietly re-reads the config the moment the socket is back (the event
subscriptions re-subscribe on their own inside home-assistant-js-websocket).

Smokes: smoke_warm_remount (fails pre-fix: veil + hidden plan on re-mount;
resize invalidation stays cold), smoke_ws_resilience (fails pre-fix:
_serverCfg cleared after 8 tries, no revalidation after recovery); the
preloader smokes now reset the warm memo — they simulate a COLD first open.
2026-08-03 15:36:32 +03:00
76 changed files with 10819 additions and 1127 deletions
+1
View File
@@ -4,3 +4,4 @@ test-build/
*.log
__pycache__/
.pytest_cache/
.venv-backend/
+19
View File
@@ -0,0 +1,19 @@
# AGENTS.md
House Plan is one HACS package with two parts plus a demo harness:
- **Lovelace card** (`src/`, TypeScript + Lit) — the primary product, bundled to `dist/houseplan-card.js`.
- **Storage integration** (`custom_components/houseplan/`, Python) — the Home Assistant backend.
- **Demo harness** (`demo/`) — a self-contained Playwright page (`demo/srv/demo.html`) that renders the card against a fake `hass`, used for screenshots and the `smoke_*.mjs` end-to-end suite.
Standard commands live in `package.json` scripts, `CONTRIBUTING.md`, and `docs/DEVELOPMENT.md`. Read `docs/ARCHITECTURE.md` and `docs/STATUS.md` before non-trivial changes.
## Cursor Cloud specific instructions
The startup update script already runs `npm ci`, provisions a Python 3.13 backend venv at `.venv-backend`, and installs Playwright Chromium. You do not need to reinstall dependencies.
- **Frontend** (from repo root): `npm run typecheck`, `npm test` (node:test, ~270 tests), `npm run build`. After building, keep the integration copy in sync — `cp dist/houseplan-card.js custom_components/houseplan/frontend/`. CI enforces `cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js` byte-for-byte.
- **Backend HA-harness tests need Python 3.13, not the system 3.12.** Run them with the venv: `.venv-backend/bin/python -m pytest tests_backend/ -q` (126 tests). Running `python3 -m pytest tests_backend` on the system 3.12 silently **skips** the `test_ha_*.py` harness tests (`conftest.py` ignores them when `homeassistant` is not importable) and runs only the ~83 pure tests.
- **Running the app / smoke suite**: build a fresh bundle and copy it into the demo assets first — `npm run build && cp dist/houseplan-card.js demo/srv/assets/houseplan-card.js` — then run `node demo/smoke_*.mjs`. The committed `demo/srv/assets/houseplan-card.js` is a stale snapshot; testing it reports green about code that no longer exists. No real Home Assistant server is required: `demo/srv/demo.html` stubs `hass`, registries and `callService`.
- **Demo harness render quirk**: the fake `hass` in `demo.html` is set once, so opening the page directly in a browser renders the floor plan but **device icons only appear after a re-render** (an F5 refresh, or nudging `card.hass = {...card.hass}`). The smoke launcher `demo/serve.mjs` already does this nudge; a plain browser session does not. This is a harness limitation, not a card bug.
- **Known environment-sensitive smoke**: `demo/smoke_opening_measure.mjs` fails two sub-checks (`place_dialog_x_magnetised`, `place_committed_x_center`) under the pinned Chromium — a `1e-6`-tolerance magnet-snap on the opening-*placement* path. It reproduces against the pristine committed bundle, so treat it as pre-existing/pixel-precision, not a regression you introduced.
+10 -1
View File
@@ -27,10 +27,16 @@ right on your Lovelace dashboard.
**Feature highlights**
- ♾️ **An infinite canvas** — there is no "plan size" and no edge to run
past: draw and place devices anywhere, pan at any zoom, zoom out to see
everything, and let one tap fit the whole plan back on screen.
- 🖱 **GUI-first floorplan editor** — rooms, doors & windows, island rooms,
virtual walls and a visual decor layer, all drawn with clicks; room resize
by dragging walls, with live lengths and areas as you drag; smart
alignment guides and a live ruler in real meters/feet.
- 🖼 **A backdrop you can move and scale** — drag the floor-plan picture into
place and pull a corner to size it, with its real size in metres shown as
you drag, so the drawing and the photo of your plan finally line up.
- 💡 **Lights toggle on click** out of the box; wall-switch markers can control
whole groups of lights (works for dumb switches and stateless remotes too).
- 🌒 **“Light sources” fill** — a dark house where every lit lamp casts a pool
@@ -39,6 +45,9 @@ right on your Lovelace dashboard.
the day (white noon → golden hour → deep night), while windows on exterior
walls cast real wedges of sunlight into the rooms; optional cloud cover
from a weather entity.
- 🪟 **Curtains and blinds open on a tap** — one action opens, closes or
stops a cover, and the icon itself morphs between open and closed while a
soft ring pulses as it travels.
- 🌡 **Room cards** with temperature, humidity, Zigbee LQI and light count;
comfort-range temperature fills, per-room signal heatmap.
- 🚪 **Doors, windows and locks** with contact sensors — unlocking is always an
@@ -179,7 +188,7 @@ for each floor (names prefilled, a plan image is asked for one by one; any floor
![Empty plan — prompt to add a space](docs/images/02-onboarding-empty.png)
In the dialog, set a **name** (for example, "1st floor") and pick the background: **upload** a floor-plan image (SVG, PNG, JPG, WebP), **choose one already uploaded** to the server earlier, or select **"no background, I'll draw the rooms"** for a hand-drawn space. The canvas is always square; an image keeps its own proportions and is centred inside it.
In the dialog, set a **name** (for example, "1st floor") and pick the background: **upload** a floor-plan image (SVG, PNG, JPG, WebP), **choose one already uploaded** to the server earlier, or select **"no background, I'll draw the rooms"** for a hand-drawn space. The canvas is infinite; an image keeps its own proportions and can be moved and resized at any time in the Background editor.
![Space creation dialog](docs/images/03-space-dialog.png)
+9 -1
View File
@@ -25,6 +25,10 @@
**Главное**
- ♾️ **Бесконечный холст** — нет «размера плана» и нет края, за который
нельзя выйти: рисуйте и ставьте устройства где угодно, тащите план на
любом зуме, отдаляйтесь, чтобы увидеть всё, и одной кнопкой вписывайте
план обратно в экран.
- 🖱 **Редакторы прямо в карточке** — комнаты, двери и окна, комнаты-острова,
виртуальные стены и декор-слой рисуются кликами; размеры комнат меняются
перетаскиванием стен с живыми длинами и площадями; помощник выравнивания и
@@ -37,6 +41,9 @@
(белый полдень → золотой час → глубокая ночь), а окна внешних стен пускают
в комнаты настоящие клинья солнечного света; облачность — опционально, от
weather-сущности.
- 🪟 **Шторы открываются тапом** — одно действие открывает, закрывает или
останавливает штору, а сам значок морфится между открытым и закрытым
видом и мягко пульсирует кольцом, пока штора едет.
- 🌡 **Карточки комнат**: температура, влажность, Zigbee-сигнал, свет «1 из 3»;
температурная заливка по комфортным границам.
- 🚪 **Двери, окна и замки** с датчиками — отпирание только явной кнопкой,
@@ -97,6 +104,7 @@ House Plan показывает ваш умный дом так, как он в
кольцо = авария (протечка, дым, газ). Цвет RGB-лампы живёт в её пятне света (режим glow),
где само пятно — индикатор включения, а подложка значка остаётся стандартной.
Полупрозрачный значок = недоступно. Тёмный = покой.
- **Подложку можно двигать и масштабировать.** Картинка плана не прибита к холсту: в редакторе подложки её тянут за тело и за угол, а плашка показывает реальный размер в метрах — чертёж и фотография плана наконец совмещаются.
- **Чёткий зум.** Приближение не «мылит» картинку: план, подписи и иконки остаются векторно-чёткими на любом масштабе.
---
@@ -180,7 +188,7 @@ title: План дома
![Пустой план — предложение добавить пространство](docs/images/02-onboarding-empty.png)
В диалоге задайте **название** (например, «1 этаж») и выберите подложку: **загрузите** картинку плана (SVG, PNG, JPG, WebP), **возьмите уже загруженную** на сервер ранее или отметьте **«без подложки, нарисую комнаты сам»**. Холст всегда квадратный; картинка сохраняет свои пропорции и центрируется внутри него.
В диалоге задайте **название** (например, «1 этаж») и выберите подложку: **загрузите** картинку плана (SVG, PNG, JPG, WebP), **возьмите уже загруженную** на сервер ранее или отметьте **«без подложки, нарисую комнаты сам»**. Холст бесконечный; картинка сохраняет свои пропорции, а подвинуть и отмасштабировать её можно в любой момент в редакторе подложки.
![Диалог создания пространства](docs/images/03-space-dialog.png)
+1 -1
View File
@@ -45,7 +45,7 @@ PLAN_ORPHAN_TTL_S = 3600
SCHEDULED_GRACE_S = 30 * 24 * 3600
FILES_DIR = "houseplan/files"
CONF_ADMIN_ONLY = "admin_only"
VERSION = "1.56.0"
VERSION = "1.58.0"
DEFAULT_CONFIG: dict = {
"spaces": [],
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -16,5 +16,5 @@
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
"requirements": [],
"single_config_entry": true,
"version": "1.56.0"
"version": "1.58.0"
}
+42 -16
View File
@@ -95,11 +95,15 @@ _TEXT = vol.All(str, vol.Length(max=MAX_TEXT))
_TEXT_OR_NONE = vol.Any(None, _TEXT)
_URL = vol.All(str, vol.Length(max=MAX_URL))
# Positions are normalised to the canvas (0..1). Allow generous slack for an
# icon dragged past an edge, but not arbitrary magnitudes: any finite float
# used to pass, and a single stored 1e100 stretched every client's view of the
# space until the plan was invisible (HP-1500-03).
_COORD = vol.All(_finite, vol.Range(min=-4.0, max=4.0))
# The canvas is UNBOUNDED (docs/CANVAS.md). Coordinates are still normalised —
# 1.0 is still one canvas width — but there is no frame any more, so a plan may
# legitimately live at 2.7 or -1.4. The range below is GARBAGE INSURANCE, not a
# boundary: at the product's own scale (cell_cm=5, 240 cells across the unit
# width) 5000 is about 60 km of plan, unreachable in a home, while a stored
# 1e100 still cannot stretch every client's view until the plan is invisible
# (HP-1500-03 / HP-1501-01). Widened from +/-4 on 2026-08-03.
CANVAS_LIMIT = 5000.0
_COORD = vol.All(_finite, vol.Range(min=-CANVAS_LIMIT, max=CANVAS_LIMIT))
POS_SCHEMA = vol.Schema(
{vol.Required("x"): _COORD, vol.Required("y"): _COORD},
@@ -107,19 +111,26 @@ POS_SCHEMA = vol.Schema(
)
LAYOUT_SCHEMA = vol.All(vol.Schema({str: POS_SCHEMA}), vol.Length(max=MAX_LAYOUT))
# Geometry is normalised to the canvas (0..1). ±4 is generous slack for a
# vertex nudged past an edge, not an envelope for arbitrary magnitudes: any
# finite float used to pass here, and a single 1e100 room vertex stretched the
# frame until the whole space was unviewable for every client (HP-1501-01) —
# the exact failure HP-1500-03 closed for layout positions, one schema over.
_GEOM = vol.All(_finite, vol.Range(min=-4.0, max=4.0))
# Room/opening geometry: same story, same range (docs/CANVAS.md). A vertex at
# 2.5 is a plan that grew past the old square, not corruption; 1e100 is
# corruption (HP-1501-01, the room-geometry twin of HP-1500-03).
_GEOM = vol.All(_finite, vol.Range(min=-CANVAS_LIMIT, max=CANVAS_LIMIT))
# A SIZE is not a coordinate (HP-1502-01): SVG requires positive width/height,
# and the clients divide by these. `view_box: [0,0,0,0]` passed the shared
# validator and serialised into viewBox="0 0 0 0" — a blank plan on every
# client. The floor is one thousandth of the canvas (1 render unit): far below
# any real room, but keeps the maths finite.
_EXTENT = vol.All(_finite, vol.Range(min=0.001, max=4.0))
# any real room, but keeps the maths finite. The CEILING follows the canvas
# (docs/CANVAS.md) — a room on an unbounded plane may legitimately be wider
# than the old unit square — while staying strictly positive.
_EXTENT = vol.All(_finite, vol.Range(min=0.001, max=CANVAS_LIMIT))
# The backdrop's uniform scale (docs/BACKDROP.md). A MULTIPLIER, not a
# coordinate: strictly positive, and bounded by what a person could mean —
# a hundredth of the canvas is already a thumbnail, a hundred canvases is
# already absurd. Mirrored by PLAN_SCALE_MIN/MAX in src/space-geometry.ts.
PLAN_SCALE_MIN = 0.01
PLAN_SCALE_MAX = 100.0
def _view_box(value):
@@ -214,7 +225,9 @@ _DECOR_COMMON = {
vol.Optional("color"): vol.Match(r"^#[0-9a-fA-F]{6}$"),
vol.Optional("width"): vol.All(vol.Coerce(float), vol.Range(min=0.1, max=30)),
}
_NORM = vol.All(vol.Coerce(float), vol.Range(min=-1, max=2))
# Decor lives on the same unbounded canvas as everything else (docs/CANVAS.md):
# it used to be pinned to -1..2, i.e. "one canvas of slack around the square".
_NORM = vol.All(_finite, vol.Range(min=-CANVAS_LIMIT, max=CANVAS_LIMIT))
DECOR_SCHEMA = vol.Any(
vol.Schema({**_DECOR_COMMON, vol.Required("kind"): "line",
vol.Required("x1"): _NORM, vol.Required("y1"): _NORM,
@@ -247,6 +260,17 @@ SPACE_SCHEMA = vol.Schema(
vol.Optional("plan_aspect"): vol.Any(
None, vol.All(vol.Coerce(float), vol.Range(min=0.05, max=20))
),
# Backdrop placement (docs/BACKDROP.md): the picture may be moved and
# scaled UNIFORMLY on the canvas. All three are optional and their
# absence is the pre-v1.58.0 behaviour exactly — there is no migration,
# and an old config validates unchanged. The offset is a normalised
# coordinate like every other one (the ±CANVAS_LIMIT garbage guard);
# the scale is a positive multiplier in a range a human could mean.
vol.Optional("plan_x"): vol.Any(None, _COORD),
vol.Optional("plan_y"): vol.Any(None, _COORD),
vol.Optional("plan_scale"): vol.Any(
None, vol.All(_finite, vol.Range(min=PLAN_SCALE_MIN, max=PLAN_SCALE_MAX))
),
vol.Required("view_box"): _view_box,
vol.Required("rooms"): vol.All([ROOM_SCHEMA], vol.Length(max=MAX_ROOMS)),
vol.Optional("decor"): vol.All([DECOR_SCHEMA], vol.Length(max=MAX_DECOR)),
@@ -258,7 +282,9 @@ SPACE_SCHEMA = vol.Schema(
vol.Required("x"): _GEOM,
vol.Required("y"): _GEOM,
vol.Required("angle"): vol.All(_finite, vol.Range(min=-360.0, max=360.0)),
vol.Required("length"): vol.All(vol.Coerce(float), vol.Range(min=0.001, max=1)),
# a SIZE: strictly positive, capped by the canvas insurance
# limit rather than by the old unit square (docs/CANVAS.md)
vol.Required("length"): vol.All(_finite, vol.Range(min=0.001, max=CANVAS_LIMIT)),
vol.Optional("contact"): vol.Any(str, None),
vol.Optional("lock"): vol.Any(str, None),
vol.Optional("invert"): bool,
@@ -291,7 +317,7 @@ MARKER_SCHEMA = vol.Schema(
vol.Optional("model"): _TEXT_OR_NONE,
vol.Optional("link"): vol.Any(None, _URL),
vol.Optional("description"): vol.Any(None, vol.All(str, vol.Length(max=MAX_DESCRIPTION))),
vol.Optional("tap_action"): vol.Any("info", "more-info", "toggle", "run", None),
vol.Optional("tap_action"): vol.Any("info", "more-info", "toggle", "run", "cover", None),
# the 'run' target: only the runnable domains, nothing else is callable
vol.Optional("tap_target"): vol.Any(
None, vol.All(str, vol.Length(max=MAX_TEXT), vol.Match(r"^(automation|script|scene)\.[A-Za-z0-9_]+$"))
+67
View File
@@ -0,0 +1,67 @@
// Backdrop image (docs/BACKDROP.md) — two shots for the owner:
// backdrop_frame — the transform frame mid-gesture: dashed outline, four
// finger-sized corner handles, and the live "W × H" badge
// stating the picture's real size through cell_cm;
// backdrop_paper — the new paper rule on an image plan: the opaque sheet is
// the ROOM CONTOURS, so the scene colour is visible around
// them and under the parts of the picture nobody has drawn
// over. The picture is scaled down here so both are obvious.
// Usage: node demo/shot_backdrop.mjs <outdir>
import { launch } from './serve.mjs';
const outDir = process.argv[2] || '/tmp';
const { page, browser } = await launch({ width: 900, height: 820 }, 2);
await page.emulateMedia({ reducedMotion: 'reduce' });
const settle = () => page.waitForTimeout(260);
// ---- 1) the frame, mid-drag ---------------------------------------------
await page.evaluate(async () => {
const c = window.__card;
c._setMode('decor');
c._decorTool = 'backdrop';
c.requestUpdate();
await c.updateComplete;
});
await settle();
const screenPt = (x, y) => page.evaluate(([x, y]) => {
const stage = window.__card.renderRoot.querySelector('.stage');
const r = stage.getBoundingClientRect();
const [vx, vy, vw, vh] = stage.querySelector('svg').getAttribute('viewBox').split(' ').map(Number);
return [r.left + ((x - vx) / vw) * r.width, r.top + ((y - vy) / vh) * r.height];
}, [x, y]);
{
// grab the bottom-right handle and hold it mid-gesture, so the shot carries
// the frame AND the live badge at once
const [ax, ay] = await screenPt(1000, 900);
const [bx, by] = await screenPt(820, 760);
await page.mouse.move(ax, ay);
await page.mouse.down();
await page.mouse.move((ax + bx) / 2, (ay + by) / 2, { steps: 4 });
await settle();
await page.screenshot({ path: `${outDir}/backdrop_frame.png` });
await page.mouse.up();
}
await settle();
// ---- 2) the paper is the rooms ------------------------------------------
await page.evaluate(async () => {
const c = window.__card;
const sp = c._serverCfg.spaces.find((s) => s.id === 'f1');
// a picture pulled well inside the plan and an acid scene colour: what shows
// through around the rooms is the SCENE, which is the whole new rule
sp.plan_x = 0.16; sp.plan_y = 0.10; sp.plan_scale = 0.62;
c._serverCfg.settings = { ...(c._serverCfg.settings || {}), bg_color: '#7bd389' };
c._cfgEpoch++; c._frame = null;
c._setMode('view');
c.requestUpdate();
await c.updateComplete;
c._fitAll();
c.requestUpdate();
await c.updateComplete;
});
await settle();
await settle();
await page.screenshot({ path: `${outDir}/backdrop_paper.png` });
await browser.close();
console.log('shots written to ' + outDir + '/backdrop_frame.png, ' + outDir + '/backdrop_paper.png');
+30
View File
@@ -0,0 +1,30 @@
// Capture: a curtain on the move — the breathing ring (.covermove) around a
// NEUTRAL plate (owner 2026-08-03). The pulse is frozen at a visible frame so
// the shot is deterministic.
import { launch } from './serve.mjs';
const { page, browser } = await launch({ width: 820, height: 760 }, 2);
await page.evaluate(async () => {
const c = window.__card;
c._serverCfg = { ...c._serverCfg, markers: [{ id: 'm_mower', binding: 'device:d_mower', hidden: true }] };
c._cfgEpoch++; c._regSignature = ''; c._maybeRebuildDevices();
c.hass = { ...c.hass, states: { ...c.hass.states,
'cover.gate': { entity_id: 'cover.gate', state: 'opening',
attributes: { friendly_name: 'Curtain', device_class: 'curtain' } } } };
c._setMode('view'); c._space = 'garden';
c.requestUpdate();
await c.updateComplete;
const st = document.createElement('style');
st.textContent = '.dev.covermove::after{animation-delay:-1.1s;animation-play-state:paused;}';
(c.shadowRoot || c.renderRoot).appendChild(st);
});
await page.waitForTimeout(400);
const box = await page.evaluate(() => {
const c = window.__card;
const r = (c.shadowRoot || c.renderRoot).querySelector('.dev.covermove').getBoundingClientRect();
return { x: r.x, y: r.y, w: r.width, h: r.height };
});
const pad = 70;
await page.screenshot({ path: process.argv[2] || '/tmp/cover_move.png',
clip: { x: Math.max(0, box.x - pad), y: Math.max(0, box.y - pad), width: box.w + pad * 2, height: box.h + pad * 2 } });
await browser.close();
console.log('shot ok');
+69
View File
@@ -0,0 +1,69 @@
// Capture: one curtain in the four states an owner sees — closed, open,
// opening, closing (owner's contract 2026-08-04). The plate is the plain
// neutral badge in ALL of them; open/closed is told by the icon morph alone,
// and the two travelling ones add the breathing .covermove ring (frozen at a
// visible frame so the shot is deterministic).
import { launch } from './serve.mjs';
const { page, browser } = await launch({ width: 900, height: 520 }, 2);
const STATES = ['closed', 'open', 'opening', 'closing'];
await page.evaluate(async (STATES) => {
const c = window.__card;
const devices = {}; const entities = {}; const states = {};
STATES.forEach((s, i) => {
const id = 'd_cur' + i;
devices[id] = { id, name: 'Curtain ' + s, model: 'Roller shade driver E1',
area_id: 'garden', identifiers: [['demo', id]], entry_type: null, via_device_id: null };
entities['cover.cur' + i] = { entity_id: 'cover.cur' + i, device_id: id, platform: 'demo' };
states['cover.cur' + i] = { entity_id: 'cover.cur' + i, state: s,
attributes: { friendly_name: 'Curtain ' + s, device_class: 'curtain' } };
});
c.hass = { ...c.hass,
devices: { ...c.hass.devices, ...devices },
entities: { ...c.hass.entities, ...entities },
states: { ...c.hass.states, ...states } };
c._serverCfg = { ...c._serverCfg, markers: [
...STATES.map((s, i) => ({ id: 'm_cur' + i, binding: 'device:d_cur' + i, tap_action: 'cover' })),
{ id: 'm_mower', binding: 'device:d_mower', hidden: true },
{ id: 'm_gate', binding: 'device:d_gate', hidden: true },
] };
c._cfgEpoch++; c._regSignature = ''; c._maybeRebuildDevices();
const layout = { ...c._layout };
STATES.forEach((s, i) => {
const d = c._devices.find((x) => x.bindingRef === 'd_cur' + i);
if (d) layout[d.id] = { s: 'garden', x: 0.18 + i * 0.215, y: 0.5 };
});
c._layout = layout;
c._setMode('view'); c._space = 'garden';
c.requestUpdate();
await c.updateComplete;
const st = document.createElement('style');
st.textContent = '.dev.covermove::after{animation-delay:-1.1s;animation-play-state:paused;}';
(c.shadowRoot || c.renderRoot).appendChild(st);
}, STATES);
await page.waitForTimeout(500);
// captions under each badge, so the four states are readable in the file
const box = await page.evaluate((STATES) => {
const c = window.__card;
const els = [...(c.shadowRoot || c.renderRoot).querySelectorAll('.devlayer .dev')]
.sort((a, b) => a.getBoundingClientRect().x - b.getBoundingClientRect().x);
let minX = 1e9, maxX = -1e9, minY = 1e9, maxY = -1e9;
els.forEach((el, i) => {
const r = el.getBoundingClientRect();
const cap = document.createElement('div');
cap.textContent = STATES[i];
cap.style.cssText = `position:fixed;left:${r.x + r.width / 2}px;top:${r.y + r.height + 10}px;`
+ 'transform:translateX(-50%);font:600 13px system-ui,sans-serif;color:#1c2530;'
+ 'letter-spacing:.02em;z-index:9999;padding:2px 7px;border-radius:6px;'
+ 'background:rgba(255,255,255,0.88);white-space:nowrap;';
document.body.appendChild(cap);
minX = Math.min(minX, r.x); maxX = Math.max(maxX, r.x + r.width);
minY = Math.min(minY, r.y); maxY = Math.max(maxY, r.y + r.height);
});
return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
}, STATES);
const pad = 55;
await page.screenshot({ path: process.argv[2] || '/tmp/cover_states.png',
clip: { x: Math.max(0, box.x - pad), y: Math.max(0, box.y - pad),
width: box.w + pad * 2, height: box.h + pad * 2 + 22 } });
await browser.close();
console.log('shot ok');
+27
View File
@@ -0,0 +1,27 @@
// Capture: the shoulder rulers + centre tick while PLACING a new opening
// (Opening tool, cursor hovering the wall centre) — owner 2026-08-03.
import { launch } from './serve.mjs';
const { page, browser } = await launch({ width: 900, height: 820 }, 2);
await page.evaluate(() => {
const c = window.__card;
const sp = c._serverCfg.spaces.find((s) => s.id === 'f1');
sp.openings = [];
c._setMode('plan'); c._tool = 'opening';
c._cfgEpoch++; c.requestUpdate();
return c.updateComplete && true;
});
await page.waitForTimeout(300);
const pt = await page.evaluate(() => {
const c = window.__card;
const stage = c.renderRoot.querySelector('.stage');
const r = stage.getBoundingClientRect();
const [vx, vy, vw, vh] = stage.querySelector('svg').getAttribute('viewBox').split(' ').map(Number);
return { x: r.left + ((293.7 - vx) / vw) * r.width, y: r.top + ((141 - vy) / vh) * r.height,
sx: r.left, sy: r.top, sw: r.width, sh: r.height };
});
await page.mouse.move(pt.x, pt.y, { steps: 4 });
await page.waitForTimeout(300);
await page.screenshot({ path: process.argv[2] || '/tmp/opening_place.png',
clip: { x: pt.sx, y: pt.sy, width: pt.sw, height: Math.min(pt.sh, 420) } });
await browser.close();
console.log('shot ok');
+45
View File
@@ -0,0 +1,45 @@
// Owner 2026-08-04: «углы границ комнат всё ещё с зубцами». A miter join on a
// sharp corner shoots a spike far past the walls that meet there; past the
// miter limit it flips to a flat bevel. Room borders now join round, like the
// decor lines already did. Two rooms make the defect obvious: an L (a concave
// 90° corner plus the convex ones) and a wedge with a ~45° apex — the angle
// where the miter spike is longest before the miter limit clips it to a bevel.
// Usage: node demo/shot_room_joins.mjs <outdir> <name>
import { launch } from './serve.mjs';
const outDir = process.argv[2] || '/tmp';
const name = process.argv[3] || 'room_joins';
const { page, browser } = await launch({ width: 900, height: 820 }, 6);
await page.emulateMedia({ reducedMotion: 'reduce' });
await page.evaluate(async () => {
const c = window.__card;
const sp = c._serverCfg.spaces.find((s) => s.id === 'f1');
delete sp.plan_url;
sp.rooms = [
{ id: 'rL', name: '', area: 'living_room',
poly: [[0.06, 0.04], [0.52, 0.04], [0.52, 0.14], [0.94, 0.14], [0.94, 0.26], [0.06, 0.26]] },
{ id: 'rW', name: '', area: 'kitchen',
poly: [[0.20, 0.318], [0.90, 0.55], [0.20, 0.782]] },
];
sp.settings = { ...(sp.settings || {}), show_borders: true, show_names: false,
fill_mode: 'none', room_color: '#3ea6ff', room_opacity: 1 };
c._cfgEpoch = (c._cfgEpoch || 0) + 1;
c.requestUpdate();
await c.updateComplete;
});
await page.waitForTimeout(300);
// crop tight around the wedge's ~28° apex: at plan scale the whole stage hides
// a 5-unit spike, and the spike is the entire point of the shot
const box = await page.evaluate(() => {
const c = window.__card;
const svg = c.shadowRoot.querySelector('.stage svg');
const poly = [...svg.querySelectorAll('polygon.room')].pop();
const pts = poly.getAttribute('points').split(' ').map((s) => s.split(',').map(Number));
const apex = pts.reduce((m, p) => (p[0] > m[0] ? p : m), pts[0]);
const m = svg.getScreenCTM();
return { x: m.a * apex[0] + m.c * apex[1] + m.e, y: m.b * apex[0] + m.d * apex[1] + m.f };
});
await page.screenshot({ path: `${outDir}/${name}.png`,
clip: { x: box.x - 62, y: box.y - 30, width: 72, height: 60 } });
await browser.close();
console.log('shot written to ' + outDir + '/' + name + '.png');
+46
View File
@@ -0,0 +1,46 @@
// Brightness check for the wedges (owner 2026-08-03: «поярче, иногда плохо
// видны»). Two hardest cases side by side: a DAYLIGHT sun on white paper (the
// wedge colour is nearly neutral there) and a low sun over the dark glow
// canvas. Usage: node demo/shot_sun_bright.mjs <outdir> [suffix]
import { launch } from './serve.mjs';
const outDir = process.argv[2] || '/tmp';
const tag = process.argv[3] ? '_' + process.argv[3] : '';
const { page, browser } = await launch({ width: 900, height: 860 }, 2);
// the fade is a 2 s animation; reduced motion pins the layer at full opacity
await page.emulateMedia({ reducedMotion: 'reduce' });
const setup = (fill, azimuth, elevation, ceiling) => page.evaluate(async ([fill, azimuth, elevation, ceiling]) => {
const c = window.__card;
const sp = c._serverCfg.spaces.find((s) => s.id === 'f1');
sp.openings = [
{ id: 'wN', type: 'window', x: 0.30, y: 0.14, angle: 0, length: 0.08 },
{ id: 'wE', type: 'window', x: 0.96, y: 0.60, angle: 90, length: 0.08 },
{ id: 'wS', type: 'window', x: 0.30, y: 0.86, angle: 0, length: 0.08 },
{ id: 'wW', type: 'window', x: 0.04, y: 0.30, angle: 90, length: 0.08 },
{ id: 'wW2', type: 'window', x: 0.04, y: 0.72, angle: 90, length: 0.08 },
];
sp.settings = { ...(sp.settings || {}), show_borders: true, fill_mode: fill };
c._serverCfg.settings = { ...(c._serverCfg.settings || {}),
north_deg: 0, sun_rays: true, bg_mode: 'daynight' };
c._cfgEpoch++;
c.hass = { ...c.hass, states: { ...c.hass.states,
'light.ceiling': { ...c.hass.states['light.ceiling'], state: ceiling },
'sun.sun': { entity_id: 'sun.sun', state: 'above_horizon', attributes: { azimuth, elevation } } } };
c.requestUpdate();
await c.updateComplete;
}, [fill, azimuth, elevation, ceiling]);
const shoot = async (name) => {
await page.waitForTimeout(400);
const stage = await page.evaluateHandle(() => window.__card.shadowRoot.querySelector('.stage'));
await stage.asElement().screenshot({ path: `${outDir}/${name}${tag}.png` });
};
// 1) plain daylight on white paper: neutral wedge colour, brightest canvas
await setup('none', 265, 24, 'off');
await shoot('sun_bright_paper');
// 2) a low warm sun over the dark glow canvas
await setup('glow', 265, 4, 'off');
await shoot('sun_bright_glow');
await browser.close();
console.log('shots written to ' + outDir);
+48
View File
@@ -0,0 +1,48 @@
// Stills for the 2026-08-04 ray report: 30 % shorter shafts with SHARP sides
// that dissolve only along the ray (docs/SUN.md). The low-sun frame is the
// interesting one — the north and south windows get a nearly wall-grazing sun,
// the case that used to hang a bright kerb across the floor. Usage:
// node demo/shot_sun_short.mjs <outdir> <prefix>
import { launch } from './serve.mjs';
const outDir = process.argv[2] || '/tmp';
const tag = process.argv[3] || 'sun_rays_short';
const { page, browser } = await launch({ width: 900, height: 860 }, 2);
await page.emulateMedia({ reducedMotion: 'reduce' }); // still shots, no 45 s sky glide
const setup = async (az, el) => {
await page.evaluate(async ([azimuth, elevation]) => {
const c = window.__card;
const sp = c._serverCfg.spaces.find((s) => s.id === 'f1');
sp.openings = [
{ id: 'wN', type: 'window', x: 0.30, y: 0.14, angle: 0, length: 0.08 },
{ id: 'wE', type: 'window', x: 0.96, y: 0.60, angle: 90, length: 0.08 },
{ id: 'wS', type: 'window', x: 0.30, y: 0.86, angle: 0, length: 0.08 },
{ id: 'wW', type: 'window', x: 0.04, y: 0.30, angle: 90, length: 0.08 },
{ id: 'wW2', type: 'window', x: 0.04, y: 0.72, angle: 90, length: 0.08 },
];
sp.settings = { ...(sp.settings || {}), show_borders: true, fill_mode: 'glow' };
c._serverCfg.settings = { ...(c._serverCfg.settings || {}),
north_deg: 0, sun_rays: true, bg_mode: 'daynight' };
c._cfgEpoch = (c._cfgEpoch || 0) + 1;
c.hass = { ...c.hass, states: { ...c.hass.states,
'light.ceiling': { ...c.hass.states['light.ceiling'], state: 'off' },
'sun.sun': { entity_id: 'sun.sun', state: 'above_horizon',
attributes: { azimuth, elevation } } } };
c.requestUpdate();
await c.updateComplete;
}, [az, el]);
await page.waitForTimeout(350);
};
// low sun in the west — the long shafts, the case that hit the far walls
await setup(265, 5);
let stage = await page.evaluateHandle(() => window.__card.shadowRoot.querySelector('.stage'));
await stage.asElement().screenshot({ path: `${outDir}/${tag}_low.png` });
// high southern sun — the short noon pools
await setup(180, 55);
stage = await page.evaluateHandle(() => window.__card.shadowRoot.querySelector('.stage'));
await stage.asElement().screenshot({ path: `${outDir}/${tag}_high.png` });
await browser.close();
console.log('shots written to ' + outDir);
+16 -3
View File
@@ -21,14 +21,27 @@ Object.assign(out, await page.evaluate(async () => {
c._setMode('plan'); await c.updateComplete;
await new Promise((r) => requestAnimationFrame(r));
const inPlan = c._baseVb();
o.editorSeesWholeCanvas = inPlan[2] === 1000 && inPlan[3] === 1000;
// docs/CANVAS.md: холста-квадрата больше нет, поэтому «редактор видит весь
// холст» переписано в исходное НАМЕРЕНИЕ HP-1490-03 — «в редакторе есть куда
// рисовать наружу». Рамка теперь по содержимому и в редакторе тоже, а место
// даёт запас панорамирования (§5) и зум-аут до 3x.
o.editorFrameIsContent = inPlan[2] < 999 && inPlan[2] > 0;
o.editorCanPanPastContent = (() => {
const fit = c._viewOr(c._baseVb());
const moved = c._clampView({ x: fit.x + fit.w * 0.9, y: fit.y, w: fit.w, h: fit.h }, fit);
return moved.x > fit.x + fit.w * 0.5; // ушли почти на экран вправо, зажима нет
})();
o.editorZoomOutGivesRoom = (() => {
c._resetZoom(); c._applyView(1 / 3);
const wide = c._view.w; c._resetZoom();
return wide > inPlan[2] * 2.5; // видно втрое больше, чем нарисовано
})();
const v = c._viewOr(c._baseVb());
o.editorViewCoversCanvas = v.w >= 999; // старый cropped view не пережил смену
// в редакторе можно ткнуть в дальний угол холста
o.canReachFarCorner = (() => {
const stage = (c.shadowRoot || c.renderRoot).querySelector('.stage');
const pt = c._screenToVb(stage.clientWidth - 1, stage.clientHeight - 1);
return pt[0] > 900 || pt[1] > 900;
return pt[0] > inPlan[0] + inPlan[2] * 0.9 || pt[1] > inPlan[1] + inPlan[3] * 0.9;
})();
c._setMode('view'); await c.updateComplete;
await new Promise((r) => requestAnimationFrame(r));
+329
View File
@@ -0,0 +1,329 @@
/**
* Backdrop image: move, uniform scale, and the new paper rule (docs/BACKDROP.md).
*
* The whole contract, on the demo's f1 — which IS an image plan, so every
* assertion here lands on exactly the case that used to behave differently:
* • the transform frame exists in the backdrop editor and nowhere else;
* • dragging the body moves the picture and writes plan_x/plan_y;
* • a corner handle scales UNIFORMLY about the opposite corner;
* • live badges state the real size in metres, through cell_cm;
* • position and size land on the grid, Shift steps off it;
* • the paper follows the ROOMS even here, and the picture is drawn above
* the paper and below the walls;
* • «Вписать всё» still finds a picture that has been dragged away;
* • the static card reads the same three fields.
*/
import { launch, check, finish } from './serve.mjs';
const { page, browser } = await launch();
const snap = await page.evaluate(() => JSON.stringify(window.__card._serverCfg));
const restore = () => page.evaluate((s) => {
const c = window.__card;
c._serverCfg = JSON.parse(s);
c._bdDrag = null;
c._cfgEpoch++; c.requestUpdate();
return c.updateComplete && true;
}, snap);
const mode = (m) => page.evaluate((m) => {
const c = window.__card;
c._setMode(m); c.requestUpdate();
return c.updateComplete && true;
}, m);
const settle = () => page.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))));
const q = (sel) => page.evaluate((s) => window.__card.renderRoot.querySelectorAll(s).length, sel);
const spaceCfg = () => page.evaluate(() => {
const sp = window.__card._serverCfg.spaces.find((s) => s.id === 'f1');
return { x: sp.plan_x ?? null, y: sp.plan_y ?? null, k: sp.plan_scale ?? null };
});
/** The image rect AS RENDERED (attributes of the <image> in the stage svg). */
const imageRect = () => page.evaluate(() => {
const im = window.__card.renderRoot.querySelector('.stage svg image');
if (!im) return null;
const a = (n) => Number(im.getAttribute(n));
return { x: a('x'), y: a('y'), w: a('width'), h: a('height') };
});
const frameRect = () => page.evaluate(() => {
const b = window.__card.renderRoot.querySelector('.bdframe .bdbox');
if (!b) return null;
const a = (n) => Number(b.getAttribute(n));
return { x: a('x'), y: a('y'), w: a('width'), h: a('height') };
});
const badges = () => page.evaluate(() =>
[...window.__card.renderRoot.querySelectorAll('.measurelabel.bdmeasure')].map((e) => e.textContent.trim()));
/** Screen position of a render-unit point (the svg viewBox fills the stage). */
const screenPt = (x, y) => page.evaluate(([x, y]) => {
const stage = window.__card.renderRoot.querySelector('.stage');
const r = stage.getBoundingClientRect();
const [vx, vy, vw, vh] = stage.querySelector('svg').getAttribute('viewBox').split(' ').map(Number);
return [r.left + ((x - vx) / vw) * r.width, r.top + ((y - vy) / vh) * r.height];
}, [x, y]);
const near = (a, b, tol = 1e-4) => Math.abs(a - b) <= tol;
// f1: plan_aspect 1.25 → the centred default is 1000×800 at (0,100)
const BASE = { x: 0, y: 100, w: 1000, h: 800 };
const PITCH = 1000 / 240;
// "on a node", allowing for the 6 decimals the normalised config keeps
// (5e-7 normalised ≈ 5e-4 render units ≈ 1e-4 of a step)
const onGrid = (v, tol = 1e-3) => Math.abs(v / PITCH - Math.round(v / PITCH)) < tol;
/** Metres a render-unit length is worth on the demo plan (cell_cm = 5). */
const metres = (u) => (((u / PITCH) * 5) / 100).toFixed(2) + ' m';
// ---------- 1) the frame lives in the backdrop editor and nowhere else -----
await mode('view');
await settle();
check('no_frame_in_view', await q('.bdframe'), 0);
await mode('plan');
await settle();
check('no_frame_in_plan', await q('.bdframe'), 0);
const tool = (t) => page.evaluate((t) => {
const c = window.__card;
c._decorTool = t; c._decorDraft = null; c.requestUpdate();
return c.updateComplete && true;
}, t);
await mode('decor');
await settle();
// the frame is on screen the moment the backdrop editor opens…
check('frame_in_backdrop_editor', await q('.bdframe'), 1);
check('four_finger_handles', await q('.bdframe .bdhandle'), 4);
check('frame_hugs_the_picture', await frameRect(), BASE);
check('untouched_picture_is_where_it_always_was', await imageRect(), BASE);
// …and the picture has a tool of its own, offered because f1 HAS a picture
check('the_picture_has_a_tool', await q('.decorbar .btn.dtool'), 7);
check('no_grab_cursor_until_that_tool', await page.evaluate(() =>
window.__card.renderRoot.querySelector('.stage').classList.contains('bdgrab')), false);
await tool('backdrop');
await settle();
check('grab_cursor', await page.evaluate(() =>
window.__card.renderRoot.querySelector('.stage').classList.contains('bdgrab')), true);
check('frame_still_there_under_its_own_tool', await q('.bdframe'), 1);
// a drawing tool owns the drag instead — no frame competing with it
await tool('rect');
await settle();
check('no_frame_under_a_drawing_tool', await q('.bdframe'), 0);
await tool('backdrop');
await settle();
// ---------- 2) drag the body: the picture moves, the plan does not --------
const roomsBefore = await page.evaluate(() =>
JSON.stringify(window.__card._serverCfg.spaces.find((s) => s.id === 'f1').rooms));
{
const [ax, ay] = await screenPt(500, 500);
// +102 / +52 render units: neither is a whole number of grid steps, so the
// snap has something to do (24 steps = 100, 12 steps = 50)
const [bx, by] = await screenPt(602, 552);
await page.mouse.move(ax, ay);
await page.mouse.down();
await page.mouse.move((ax + bx) / 2, (ay + by) / 2, { steps: 3 });
await settle();
const live = await badges();
check('live_badge_while_dragging', live.length, 1);
// the demo's cell_cm is 5 → the untouched picture is 240 cells wide, i.e.
// 12.00 m by 9.60 m. Moving does not change the size, and the badge says so.
check('live_badge_is_the_real_size', live[0], `${metres(BASE.w)} × ${metres(BASE.h)}`);
check('live_badge_is_metres', live[0], '12.00 m × 9.60 m');
await page.mouse.move(bx, by, { steps: 3 });
await settle();
await page.mouse.up();
await settle();
}
check('badge_gone_after_release', (await badges()).length, 0);
{
const cfg = await spaceCfg();
const im = await imageRect();
// the gesture asked for +102/+52 render units; the pointer arrives in whole
// screen pixels, so the ASSERTION is the contract, not the arithmetic: the
// corner lands on a lattice node within one step of where the finger let go
check('plan_x_saved', near(cfg.x, im.x / 1000, 1e-6), true);
check('plan_y_saved', near(cfg.y, (im.y - BASE.y) / 1000, 1e-6), true);
check('scale_untouched_by_a_move', cfg.k, 1);
check('size_untouched_by_a_move', [im.w, im.h], [BASE.w, BASE.h]);
check('corner_lands_on_a_grid_node', onGrid(im.x) && onGrid(im.y), true);
// …at the node NEAREST the finger: 102 → 100 (24 steps), 152 → 150 (36).
// Exact, and it is meant to be — nothing may rescale the stage mid-gesture
// (the frame is frozen while _bdDrag is live; the toolbar does not reflow),
// or the picture would run away from the finger.
check('landed_on_the_node_nearest_the_finger', [im.x, im.y], [100, 150]);
check('frame_follows_the_picture', await frameRect(), im);
}
check('the_plan_geometry_never_moved', await page.evaluate(() =>
JSON.stringify(window.__card._serverCfg.spaces.find((s) => s.id === 'f1').rooms)), roomsBefore);
// ---------- 3) Shift steps off the grid ----------------------------------
await restore();
await settle();
{
const [ax, ay] = await screenPt(500, 500);
const [bx, by] = await screenPt(602, 552);
await page.keyboard.down('Shift');
await page.mouse.move(ax, ay);
await page.mouse.down();
await page.mouse.move(bx, by, { steps: 3 });
await settle();
await page.mouse.up();
await page.keyboard.up('Shift');
await settle();
const im = await imageRect();
// Shift is not "a different snap", it is NO snap: the corner is wherever the
// finger left it, to the pixel — and a pixel is a fraction of a grid step,
// so at least one axis is bound to sit between the nodes.
check('shift_lands_where_the_finger_did',
Math.abs(im.x - 102) <= 1 && Math.abs(im.y - 152) <= 1, true);
check('shift_is_off_the_lattice', onGrid(im.x) && onGrid(im.y), false);
}
// ---------- 4) a corner handle scales UNIFORMLY about the opposite one ----
await restore();
await settle();
{
// the bottom-right corner of the default rect is (1000, 900); pull it in to
// (700, ...) — the top-left corner (0,100) must not budge and the ratio must
// stay 1.25, whatever the vertical component of the gesture says
const [ax, ay] = await screenPt(BASE.x + BASE.w, BASE.y + BASE.h);
const [bx, by] = await screenPt(700, 600);
await page.mouse.move(ax, ay);
await page.mouse.down();
await settle();
await page.mouse.move((ax + bx) / 2, (ay + by) / 2, { steps: 3 });
await settle();
check('live_badge_while_scaling', (await badges()).length, 1);
await page.mouse.move(bx, by, { steps: 3 });
await settle();
await page.mouse.up();
await settle();
const im = await imageRect();
check('opposite_corner_stayed_put', near(im.x, BASE.x, 1e-6) && near(im.y, BASE.y, 1e-6), true);
check('scaled_down', im.w < BASE.w, true);
check('uniform_no_stretch', near(im.w / im.h, BASE.w / BASE.h, 1e-9), true);
const cfg = await spaceCfg();
check('plan_scale_saved', near(cfg.k, im.w / BASE.w, 1e-5), true);
check('scaled_width_is_on_the_grid',
Math.abs((im.x + im.w) / PITCH - Math.round((im.x + im.w) / PITCH)) < 1e-6, true);
// …and the badge states the picture's REAL size through cell_cm
check('badge_matches_the_new_size', await page.evaluate(() => {
const c = window.__card;
c._bdDrag = { kind: 'move', pid: 1, sx: 0, sy: 0, base: { x: 0, y: 100, w: 1000, h: 800 },
p0: { dx: 0, dy: 0, k: 1 }, fx: 0, fy: 0, sgx: 0, sgy: 0, moved: false };
c.requestUpdate();
return c.updateComplete.then(() =>
c.renderRoot.querySelector('.measurelabel.bdmeasure')?.textContent.trim() ?? null);
}), `${metres(im.w)} × ${metres(im.h)}`);
await page.evaluate(() => { const c = window.__card; c._bdDrag = null; c.requestUpdate(); return c.updateComplete; });
}
// ---------- 5) the reset button ------------------------------------------
check('reset_button_offered_once_moved', await q('.decorbar .bdreset'), 1);
await page.evaluate(() => { const c = window.__card; c._bdReset?.(); c.requestUpdate(); return c.updateComplete; });
await settle();
check('reset_puts_the_picture_back', await imageRect(), BASE);
check('reset_clears_the_fields', await spaceCfg(), { x: null, y: null, k: null });
check('reset_button_gone_again', await q('.decorbar .bdreset'), 0);
// ---------- 5b) the SELECT tool still pans right over the picture ---------
// The body of the picture is most of the screen; claiming it outside its own
// tool would take away the one-finger pan (docs/BACKDROP.md §2), which is why
// moving is a tool. smoke_pan_any_zoom guards the same thing from the outside.
await tool('select');
await settle();
check('select_tool_still_pans_over_the_picture', await page.evaluate(async () => {
const c = window.__card;
const before = { ...c._viewOr(c._baseVb()) };
c._stagePointerDown({ pointerId: 91, clientX: 300, clientY: 300, target: c._stageEl, preventDefault() {} });
c._stagePointerMove({ pointerId: 91, clientX: 360, clientY: 350 });
const after = { ...c._viewOr(c._baseVb()) };
c._stagePointerUp({ pointerId: 91, clientX: 360, clientY: 350 });
await c.updateComplete;
const sp = c._serverCfg.spaces.find((s) => s.id === 'f1');
return Math.abs(after.x - before.x) > 1 && sp.plan_x == null;
}), true);
// ---------- 6) the paper is the ROOMS, and the picture sits on it ---------
await mode('view');
await settle();
const layers = await page.evaluate(() => {
const c = window.__card;
const m = c._spaceModel();
const svgEl = c.renderRoot.querySelector('.stage svg');
const kids = [...svgEl.children];
const idx = (sel) => kids.findIndex((n) => n.matches(sel) || n.querySelector?.(sel));
const papers = [...svgEl.querySelectorAll('.hp-paper')];
return {
paperShapes: papers.length,
rooms: m.rooms.length,
// not one paper shape the size of the picture any more
imageSizedPaper: papers.some((p) => p.tagName === 'rect'
&& Math.abs(Number(p.getAttribute('width')) - m.bg.w) < 1e-6
&& Math.abs(Number(p.getAttribute('height')) - m.bg.h) < 1e-6),
iPaper: idx('.hp-paper'), iImage: idx('image'), iRoom: idx('.room-outline, .room'),
};
});
check('paper_is_one_shape_per_room', layers.paperShapes, layers.rooms);
check('no_paper_the_size_of_the_picture', layers.imageSizedPaper, false);
check('picture_is_above_the_paper', layers.iImage > layers.iPaper && layers.iPaper >= 0, true);
check('picture_is_below_the_walls', layers.iImage < layers.iRoom, true);
// ---------- 7) «Вписать всё» keeps a picture that was dragged away --------
await page.evaluate(() => {
const c = window.__card;
const sp = c._serverCfg.spaces.find((s) => s.id === 'f1');
sp.plan_x = 1.5; sp.plan_y = 1.5; sp.plan_scale = 0.5;
c._cfgEpoch++; c._frame = null; c.requestUpdate();
return c.updateComplete;
});
await settle();
const fit = await page.evaluate(() => {
const c = window.__card;
c._fitAll();
const b = c._baseVb();
const bg = c._spaceModel().bg;
return { b, bg };
});
check('the_frame_reaches_the_moved_picture',
fit.b[0] <= fit.bg.x && fit.b[1] <= fit.bg.y
&& fit.b[0] + fit.b[2] >= fit.bg.x + fit.bg.w
&& fit.b[1] + fit.b[3] >= fit.bg.y + fit.bg.h, true);
check('and_the_moved_picture_is_where_the_transform_says',
near(fit.bg.x, 1500) && near(fit.bg.y, 1600) && near(fit.bg.w, 500) && near(fit.bg.h, 400), true);
// ---------- 8) the static card reads the same three fields ---------------
const stat = await page.evaluate(async () => {
await customElements.whenDefined('houseplan-space-card');
const main = window.__card;
const cfg = JSON.parse(JSON.stringify(main._serverCfg));
const hass = { ...main.hass, callWS: async (m) => {
if (m.type === 'houseplan/config/get') return { config: cfg, rev: 1 };
if (m.type === 'houseplan/layout/get') return { layout: {} };
return { ok: true };
} };
const host = document.createElement('div');
document.body.appendChild(host);
const card = document.createElement('houseplan-space-card');
card.setConfig({ type: 'custom:houseplan-space-card', space: 'f1' });
card.hass = hass;
host.appendChild(card);
const t0 = Date.now();
while (!card.renderRoot?.querySelector('.hp-static-stage svg image') && Date.now() - t0 < 6000) {
await new Promise((r) => setTimeout(r, 60));
}
await card.updateComplete;
const root = card.renderRoot.querySelector('.hp-static-stage svg');
const im = root.querySelector('image');
const a = (n) => Number(im.getAttribute(n));
const kids = [...root.children];
const idx = (sel) => kids.findIndex((n) => n.matches(sel) || n.querySelector?.(sel));
return {
rect: { x: a('x'), y: a('y'), w: a('width'), h: a('height') },
papers: root.querySelectorAll('.hp-paper').length,
imageSizedPaper: [...root.querySelectorAll('.hp-paper')].some((p) => p.tagName === 'rect'
&& Math.abs(Number(p.getAttribute('width')) - a('width')) < 1e-6),
iPaper: idx('.hp-paper'), iImage: idx('image'),
};
});
check('static_card_applies_the_transform', stat.rect, { x: 1500, y: 1600, w: 500, h: 400 });
check('static_card_papers_the_rooms', stat.papers, 4);
check('static_card_has_no_picture_paper', stat.imageSizedPaper, false);
check('static_card_draws_the_picture_on_the_paper', stat.iImage > stat.iPaper, true);
await restore();
await finish(browser);
+19 -9
View File
@@ -144,9 +144,11 @@ checkAll(res);
// ---- 11) opaque plan paper (owner 2026-08-03) --------------------------
// The scene bg_color must never bleed through the plan itself: opaque
// .hp-paper shapes sit under everything the plan draws. An IMAGE plan papers
// the backdrop image rect (the canvas is the paper); a DRAWN plan papers the
// ROOM CONTOURS — one shape per room, never their bounding box (section 12).
// .hp-paper shapes sit under everything the plan draws. Since v1.58.0 the
// paper is the ROOM CONTOURS and ONLY them — one shape per room, never their
// bounding box (section 12), and never the backdrop image rect either
// (docs/BACKDROP.md §3). The demo's f1 IS an image plan, so this section now
// asserts the new rule on exactly the case that used to be the exception.
// The scene colour is visible ONLY around the paper; the 'daynight' night
// dims the paper via brightness only.
const paper = await page.evaluate(async () => {
@@ -158,20 +160,28 @@ const paper = await page.evaluate(async () => {
c._cfgRev = (c._cfgRev || 0) + 1;
await upd();
const m = c._spaceModel();
const p = sr().querySelector('.stage svg rect.hp-paper');
const shapes = () => [...sr().querySelectorAll('.stage svg .hp-paper')];
const p = shapes()[0];
out.paperExists = !!p;
out.imagePlanPapersTheRooms = shapes().length === m.rooms.length;
// …and NOT the image: no paper rect the size of the backdrop any more
out.noImageSizedPaper = ![...sr().querySelectorAll('.stage svg rect.hp-paper')].some((r) =>
Math.abs(Number(r.getAttribute('width')) - m.bg.w) < 1e-6
&& Math.abs(Number(r.getAttribute('height')) - m.bg.h) < 1e-6);
if (p) {
const a = (n) => Number(p.getAttribute(n));
out.paperHugsImage = Math.abs(a('x') - m.bg.x) < 1e-6 && Math.abs(a('y') - m.bg.y) < 1e-6
&& Math.abs(a('width') - m.bg.w) < 1e-6 && Math.abs(a('height') - m.bg.h) < 1e-6;
const cs = getComputedStyle(p);
out.paperOpaque = cs.fillOpacity === '1' && cs.opacity === '1'
&& cs.fill !== 'none' && !/rgba\(.*,\s*0\)/.test(cs.fill);
}
// …and the picture is drawn ON that paper: image after paper, before walls
const kids = [...sr().querySelector('.stage svg').children];
const idxOf = (sel) => kids.findIndex((n) => n.matches(sel) || n.querySelector?.(sel));
out.imageAbovePaper = idxOf('image') > idxOf('.hp-paper') && idxOf('image') >= 0;
out.imageBelowRooms = idxOf('image') < idxOf('.room, .room-outline');
// editors keep the paper too (their canvas ignores bg_color anyway)
c._setMode('decor');
await upd();
out.editorKeepsPaper = !!sr().querySelector('.stage svg rect.hp-paper');
out.editorKeepsPaper = !!sr().querySelector('.stage svg .hp-paper');
c._setMode('view');
await upd();
// daynight night: the paper dims via the brightness filter ONLY, never alpha
@@ -184,7 +194,7 @@ const paper = await page.evaluate(async () => {
} } };
await upd();
out.nightDimsByBrightness = (sr().querySelector('.zoomwrap').getAttribute('style') || '').includes('brightness(0.900');
const pn = sr().querySelector('.stage svg rect.hp-paper');
const pn = sr().querySelector('.stage svg .hp-paper');
out.nightPaperStaysOpaque = !!pn && getComputedStyle(pn).fillOpacity === '1' && getComputedStyle(pn).opacity === '1';
delete c._serverCfg.settings.bg_mode;
delete c._serverCfg.settings.north_deg;
+212
View File
@@ -0,0 +1,212 @@
// The content frame is PRESENTATION (docs/CANVAS.md §4) — audit dev@2c947f4.
//
// DEV-2C947-01: a hidden device is not drawn, but it still stretched the
// frame — hiding a marker that had wandered into the yard left the visible
// house a dot in the corner (the full card AND the static
// houseplan-space-card, which drew `devs` but framed `spaceDevs`).
//
// DEV-2C947-02: inside an editor the frame only grows (a frame that shrank
// mid-drag would move the ground under the pointer); that union was memoised
// without the mode, so it survived the way back into View.
//
// DEV-2C947-03: a room-outlier is rejected from the frame, but iconUnit()
// measured ALL rooms — so the stray it had just rejected still blew every
// marker of the main plan up ~91x.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 900, height: 820 }, 1);
const out = {};
const raf = () => page.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))));
// Load a config/layout into the card AND into the demo's WS stub, so the
// static card (which fetches its own copy) sees exactly the same house.
const load = (cfg, layout, space) => page.evaluate(async ([c2, l2, sp]) => {
const c = window.__card;
const srvCfg = (await c.hass.callWS({ type: 'houseplan/config/get' })).config;
const srvLay = (await c.hass.callWS({ type: 'houseplan/layout/get' })).layout;
for (const k of Object.keys(srvCfg)) delete srvCfg[k];
Object.assign(srvCfg, JSON.parse(JSON.stringify(c2)));
for (const k of Object.keys(srvLay)) delete srvLay[k];
Object.assign(srvLay, JSON.parse(JSON.stringify(l2)));
c._serverCfg = srvCfg;
c._layout = JSON.parse(JSON.stringify(l2));
c._modelCache = null;
c._frame = null;
c._showFar = false;
c._space = sp;
c._cfgEpoch++;
c._regSignature = '';
c._maybeRebuildDevices?.();
c._defPos = c._defaultPositions();
c._view = null;
c._zoom = 1;
c.requestUpdate();
await c.updateComplete;
c._resetZoom();
await c.updateComplete;
}, [cfg, layout, space]);
// ---------------------------------------------------------------- 01: hidden
// The auditor's probe, verbatim: ONE visible room and ONE marker parked 90
// canvases away. Two content items is below MIN_VOTERS, so the outlier vote
// never runs and the far position is guaranteed to stay in the core — the
// frame either sees the marker or it does not.
const oneRoom = (hidden) => ({
spaces: [{
id: 'h1', title: 'Hidden', view_box: [0, 0, 1, 1], plan_url: null, plan_aspect: null,
rooms: [{ id: 'r1', name: 'Room', area: 'zx_room',
poly: [[0.1, 0.1], [0.9, 0.1], [0.9, 0.9], [0.1, 0.9]] }],
}],
// a made-up area: no auto device of the demo house lands in this space, so
// the only content is the room and the one marker below
markers: [{ id: 'm_far', binding: 'device:d_motion', area: 'zx_room', hidden }],
settings: { filter_seeded: true },
});
const FAR_POS = { m_far: { s: 'h1', x: 90, y: 0.5 } };
await load(oneRoom(false), FAR_POS, 'h1');
await raf();
Object.assign(out, await page.evaluate(() => {
const c = window.__card;
const sr = c.shadowRoot || c.renderRoot;
const b = c._baseVb();
return {
// premise: while it IS drawn, the marker is content and the frame holds it
visibleMarkerIsDrawn: sr.querySelectorAll('.devlayer .dev').length === 1,
visibleMarkerWidensTheFrame: b[2] > 50000,
};
}));
await load(oneRoom(true), FAR_POS, 'h1');
await raf();
Object.assign(out, await page.evaluate(() => {
const c = window.__card;
const sr = c.shadowRoot || c.renderRoot;
const o = {};
const b = c._baseVb();
o.hiddenMarkerNotDrawn = sr.querySelectorAll('.devlayer .dev').length === 0;
o.hiddenMarkerStillBuilt = !!c._devices.find((d) => d.id === 'm_far' && d.hidden === true);
// the room is 100..900 render units, padded by 5 % of the longer side
o.frameIsTheVisibleRoom = Math.round(b[0]) === 60 && Math.round(b[2]) === 880
&& Math.round(b[1]) === 60 && Math.round(b[3]) === 880;
// and the room fills the screen instead of being a dot 112x too small
const st = sr.querySelector('.stage').getBoundingClientRect();
const poly = sr.querySelector('polygon.room').getBoundingClientRect();
o.roomFillsTheStage = poly.width > st.width * 0.7;
return o;
}));
// the same house on the STATIC card (space-render.ts drew `devs` but framed
// `spaceDevs` — the identical split)
Object.assign(out, await page.evaluate(async () => {
await customElements.whenDefined('houseplan-space-card');
const o = {};
const host = document.createElement('div');
host.style.cssText = 'width:400px;height:400px';
document.body.appendChild(host);
const el = document.createElement('houseplan-space-card');
el.setConfig({ type: 'custom:houseplan-space-card', space: 'h1' });
el.hass = window.__card.hass;
host.appendChild(el);
const t0 = Date.now();
while (!el.renderRoot?.querySelector('.hp-static-stage') && Date.now() - t0 < 6000) {
await new Promise((r) => setTimeout(r, 80));
}
await el.updateComplete;
const svg = el.renderRoot.querySelector('.hp-static-stage svg');
const vb = (svg?.getAttribute('viewBox') || '').split(/\s+/).map(Number);
o.staticCardHidesTheMarker = el.renderRoot.querySelectorAll('.devlayer .dev').length === 0;
o.staticFrameIsTheVisibleRoom = vb.length === 4
&& Math.round(vb[0]) === 60 && Math.round(vb[2]) === 880;
host.remove();
return o;
}));
// ------------------------------------------------------- 02: editor -> view
const oneRoomAt = (x0) => ({
spaces: [{
id: 'e1', title: 'Editor', view_box: [0, 0, 1, 1], plan_url: null, plan_aspect: null,
rooms: [{ id: 'r1', name: 'Room', area: 'zx_room',
poly: [[x0, 0.1], [x0 + 0.8, 0.1], [x0 + 0.8, 0.9], [x0, 0.9]] }],
}],
markers: [], settings: { filter_seeded: true },
});
await load(oneRoomAt(0.1), {}, 'e1');
await raf();
Object.assign(out, await page.evaluate(async () => {
const c = window.__card;
const o = {};
const round = () => c._baseVb().map((n) => Math.round(n));
o.viewFrameBefore = JSON.stringify(round()) === JSON.stringify([60, 60, 880, 880]);
// move the only room 5 canvases to the right, inside the Plan editor
c._setMode('plan');
await c.updateComplete;
const cfg = JSON.parse(JSON.stringify(c._serverCfg));
cfg.spaces[0].rooms[0].poly = [[5.1, 0.1], [5.9, 0.1], [5.9, 0.9], [5.1, 0.9]];
c._serverCfg = cfg;
c._modelCache = null;
c._cfgEpoch++;
c.requestUpdate();
await c.updateComplete;
const inEditor = round();
// inside the editor the frame is the UNION — that is deliberate: it bounds
// pan and defines zoom 1, and it must not shrink under a live gesture
o.editorFrameOnlyGrows = inEditor[2] > 5000;
c._setMode('view');
await c.updateComplete;
const back = round();
o.viewFrameFollowsTheRoomAgain = JSON.stringify(back) === JSON.stringify([5060, 60, 880, 880]);
if (!o.viewFrameFollowsTheRoomAgain) console.log('frame after exit', back, 'editor', inEditor);
// and back into an editor it starts from the CURRENT geometry, not the old union
c._setMode('plan');
await c.updateComplete;
o.reenteringTheEditorDoesNotResurrectTheUnion =
JSON.stringify(round()) === JSON.stringify([5060, 60, 880, 880]);
c._setMode('view');
await c.updateComplete;
return o;
}));
// ---------------------------------------------------- 03: icons and outliers
// Three rooms in the core plus one dragged 90 canvases out. The frame rejects
// the stray (that part always worked); the ICON UNIT used to measure all four
// rooms, so every marker of the plan the user is looking at came out ~91x too
// big and covered the house.
const withFarRoom = (far) => ({
spaces: [{
id: 'i1', title: 'Icons', view_box: [0, 0, 1, 1], plan_url: null, plan_aspect: null,
rooms: [
{ id: 'r1', name: 'A', area: 'zx_a', poly: [[0.10, 0.10], [0.30, 0.10], [0.30, 0.30], [0.10, 0.30]] },
{ id: 'r2', name: 'B', area: 'zx_b', poly: [[0.30, 0.10], [0.55, 0.10], [0.55, 0.35], [0.30, 0.35]] },
{ id: 'r3', name: 'C', area: 'zx_c', poly: [[0.10, 0.30], [0.35, 0.30], [0.35, 0.55], [0.10, 0.55]] },
...(far ? [{ id: 'r4', name: 'Far', area: 'zx_d', poly: [[90, 90], [91, 90], [91, 91], [90, 91]] }] : []),
],
}],
markers: [{ id: 'm_one', binding: 'device:d_light1', area: 'zx_a' }],
settings: { filter_seeded: true },
});
const ICON_POS = { m_one: { s: 'i1', x: 0.2, y: 0.2 } };
const iconPx = () => page.evaluate(() => {
const c = window.__card;
const sr = c.shadowRoot || c.renderRoot;
const el = sr.querySelector('.devlayer .dev');
// the badge is --dev-size plus a 1 px border on each side (smoke_icon_scale)
return { px: el ? Math.round((el.getBoundingClientRect().width - 2) * 100) / 100 : null,
outliers: c._outliers, frameW: Math.round(c._baseVb()[2]) };
});
await load(withFarRoom(false), ICON_POS, 'i1');
await raf();
const plain = await iconPx();
await load(withFarRoom(true), ICON_POS, 'i1');
await raf();
const strayed = await iconPx();
out.strayRoomIsRejectedFromTheFrame = strayed.outliers === 1 && strayed.frameW < 1000;
out.strayRoomDoesNotInflateTheIcons =
plain.px > 4 && Math.abs(strayed.px - plain.px) < 0.5;
if (!out.strayRoomDoesNotInflateTheIcons) console.log('icon px', plain, strayed);
checkAll(out);
await finish(browser, out);
+197
View File
@@ -0,0 +1,197 @@
// A cover NEVER wears a coloured plate (owner's contract 2026-08-04):
// «у штор не должно быть жёлтой подложки никогда, индикация открыто/закрыто
// за счёт морфинга иконки».
//
// Before this the domain shared one branch with `valve`: 'open'/'opening' put
// the orange «открыто» frame on the badge (.dev.open — a FILLED orange plate,
// --hp-open #ff9f43, not just a border), and the travelling states carried it
// underneath the breathing ring. Now a cover returns no plate class at all:
//
// closed / open / ajar -> neutral plate, the ICON tells the state
// opening / closing -> neutral plate + the .covermove ring (kept, the
// owner approved it 2026-08-03)
//
// The frame itself is NOT gone: door/window binary sensors and locks still
// wear it, and a valve keeps it too (nothing morphs its icon). Those are
// re-checked at the bottom, or «no yellow for curtains» would quietly cost
// the whole house its «открыто» indication.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const out = await page.evaluate(async () => {
const o = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
// --- colour probes: resolve the tokens the same way the badge does ------
const probe = (css) => {
const d = document.createElement('div');
d.style.cssText = `position:absolute;left:-999px;background:${css}`;
sr().appendChild(d);
const v = getComputedStyle(d).backgroundColor;
d.remove();
return v;
};
const YELLOW = probe('var(--hp-on)'); // «включено»
const ORANGE = probe('var(--hp-open)'); // «открыто» frame
const NEUTRAL = probe('var(--hp-bg)'); // the plain badge
o.tokensDiffer = new Set([YELLOW, ORANGE, NEUTRAL]).size === 3;
o.yellowIsTheYellow = YELLOW === 'rgb(255, 212, 92)';
o.orangeIsTheOpenFrame = ORANGE === 'rgb(255, 159, 67)';
// --- the gate marker, alone in the Garden, told it IS the curtain -------
const setMarker = async () => {
c._serverCfg = {
...c._serverCfg,
markers: [
{ id: 'm_gate', binding: 'device:d_gate', tap_action: 'cover' },
{ id: 'm_mower', binding: 'device:d_mower', hidden: true },
],
};
c._cfgEpoch++;
c._regSignature = '';
c._maybeRebuildDevices();
c.requestUpdate();
await c.updateComplete;
};
const setCover = async (state, attrs = {}) => {
c.hass = { ...c.hass, states: { ...c.hass.states,
'cover.gate': { entity_id: 'cover.gate', state, attributes: { friendly_name: 'Gate', ...attrs } } } };
c.requestUpdate();
await c.updateComplete;
// .dev animates `background 0.15s` — a colour read in the same frame is
// still mid-fade and would pass on a plate that ends up orange
await new Promise((r) => setTimeout(r, 300));
};
c._setMode('view');
c._space = 'garden';
c.requestUpdate();
await c.updateComplete;
await setMarker();
await setCover('closed', { device_class: 'curtain' });
const devEl = () => sr().querySelector('.devlayer .dev');
const cls = () => [...(devEl()?.classList || [])];
const plate = () => getComputedStyle(devEl()).backgroundColor;
const icon = () => devEl()?.querySelector('ha-icon')?.getAttribute('icon') || '';
o.onlyTheCoverOnThePlan = sr().querySelectorAll('.devlayer .dev').length === 1;
/** One state: the plate must be the neutral one, and nothing else. */
const plateIsNeutral = (tag) => {
o[tag + 'PlateNeutral'] = plate() === NEUTRAL;
o[tag + 'NotYellow'] = plate() !== YELLOW;
o[tag + 'NotOrange'] = plate() !== ORANGE;
o[tag + 'NoPlateClass'] = !cls().includes('on') && !cls().includes('open');
};
plateIsNeutral('closed');
o.closedNoRing = !cls().includes('covermove');
o.closedIconClosed = icon() === 'mdi:curtains-closed';
await setCover('open', { device_class: 'curtain' });
plateIsNeutral('open');
o.openNoRing = !cls().includes('covermove');
o.openIconOpen = icon() === 'mdi:curtains';
// ajar: HA reports a positioned curtain as plain 'open' — it reads as OPEN
await setCover('open', { device_class: 'curtain', current_position: 40 });
plateIsNeutral('ajar');
o.ajarIconOpen = icon() === 'mdi:curtains';
await setCover('opening', { device_class: 'curtain' });
plateIsNeutral('opening');
o.openingRings = cls().includes('covermove');
o.openingRingIsTheBreathingOne = (() => {
const cs = getComputedStyle(devEl(), '::after');
return cs.animationName === 'hp-covermove' && cs.animationDuration === '2.2s';
})();
await setCover('closing', { device_class: 'curtain' });
plateIsNeutral('closing');
o.closingRings = cls().includes('covermove');
// --- the morph covers every class, both ways ---------------------------
const morph = async (dc, closedIcon, openIcon) => {
await setCover('closed', dc ? { device_class: dc } : {});
const a = icon();
await setCover('open', dc ? { device_class: dc } : {});
const b = icon();
return a === closedIcon && b === openIcon && a !== b;
};
o.morphBlind = await morph('blind', 'mdi:blinds', 'mdi:blinds-open');
o.morphShade = await morph('shade', 'mdi:blinds', 'mdi:blinds-open');
o.morphShutter = await morph('shutter', 'mdi:window-shutter', 'mdi:window-shutter-open');
o.morphCurtain = await morph('curtain', 'mdi:curtains-closed', 'mdi:curtains');
o.morphWindow = await morph('window', 'mdi:window-closed', 'mdi:window-open');
o.morphAwning = await morph('awning', 'mdi:awning-outline', 'mdi:awning');
o.morphDoor = await morph('door', 'mdi:door-closed', 'mdi:door-open');
o.morphGarage = await morph('garage', 'mdi:garage', 'mdi:garage-open');
o.morphGate = await morph('gate', 'mdi:gate', 'mdi:gate-open');
o.morphDamper = await morph('damper', 'mdi:circle-slice-8', 'mdi:circle-outline');
// no device_class at all (z2m ships plenty): the gate's auto icon is the
// one the name rule «ворота|garage|gate» hands out, and it morphs too
o.gateAutoIcon = c._devices.find((x) => x.bindingRef === 'd_gate')?.icon === 'mdi:garage-variant';
o.morphNoClass = await morph('', 'mdi:garage-variant', 'mdi:garage-open-variant');
// …and an unreadable state morphs nothing, plate still neutral
await setCover('unknown', { device_class: 'curtain' });
plateIsNeutral('unknown');
o.unknownNoMorph = icon() === 'mdi:garage-variant';
o.unknownNoRing = !cls().includes('covermove');
// --- NOT touched: the «открыто» frame elsewhere ------------------------
// the demo house has a lock and a window sensor on the ground floor
c._space = 'f1';
c.requestUpdate();
await c.updateComplete;
const elOf = (dev) => {
const v = c._viewOr(c._baseVb());
const p = c._pos(dev);
const left = ((p.x - v.x) / v.w) * 100;
const top = ((p.y - v.y) / v.h) * 100;
return [...sr().querySelectorAll('.devlayer .dev')].find((e) =>
Math.abs(parseFloat(e.style.left) - left) < 0.4 && Math.abs(parseFloat(e.style.top) - top) < 0.4);
};
const dev = (ref) => c._devices.find((x) => x.bindingRef === ref);
c.hass = { ...c.hass, states: { ...c.hass.states,
'lock.front_door': { entity_id: 'lock.front_door', state: 'unlocked', attributes: { friendly_name: 'Front door' } },
'binary_sensor.window': { entity_id: 'binary_sensor.window', state: 'on', attributes: { friendly_name: 'Window', device_class: 'window' } } } };
c.requestUpdate();
await c.updateComplete;
await new Promise((r) => setTimeout(r, 300));
const lockEl = elOf(dev('d_lock'));
const winEl = elOf(dev('d_window'));
o.lockMarkerFound = !!lockEl;
o.windowMarkerFound = !!winEl;
o.unlockedLockKeepsTheFrame = !!lockEl && lockEl.classList.contains('open')
&& getComputedStyle(lockEl).backgroundColor === ORANGE;
o.openWindowKeepsTheFrame = !!winEl && winEl.classList.contains('open')
&& getComputedStyle(winEl).backgroundColor === ORANGE;
// …and a locked lock is neutral again, so the frame still MEANS something
c.hass = { ...c.hass, states: { ...c.hass.states,
'lock.front_door': { entity_id: 'lock.front_door', state: 'locked', attributes: { friendly_name: 'Front door' } } } };
c.requestUpdate();
await c.updateComplete;
await new Promise((r) => setTimeout(r, 300));
o.lockedLockIsNeutral = getComputedStyle(elOf(dev('d_lock'))).backgroundColor === NEUTRAL;
// a VALVE keeps the frame too: nothing morphs its icon, so the frame is the
// only thing it has to say «открыт» with (deliberately left alone)
const valve = { id: 'v', primary: 'valve.water', entities: ['valve.water'], marker: null };
const clsOfState = (d, states) => {
const saved = c.hass;
c.hass = { ...c.hass, states: { ...c.hass.states, ...states } };
const r = c._stateClass(d);
c.hass = saved;
return r;
};
o.valveOpenKeepsTheFrame = clsOfState(valve, { 'valve.water': { state: 'open', attributes: {} } }) === 'open';
o.valveClosedIsNeutral = clsOfState(valve, { 'valve.water': { state: 'closed', attributes: {} } }) === '';
// and the cover's own class string, straight from the source
const cover = { id: 'k', primary: 'cover.blind', entities: ['cover.blind'], marker: null };
o.coverOpenClassIsEmpty = clsOfState(cover, { 'cover.blind': { state: 'open', attributes: { device_class: 'blind' } } }) === '';
o.coverOpeningClassIsRingOnly = clsOfState(cover, { 'cover.blind': { state: 'opening', attributes: { device_class: 'blind' } } }) === 'covermove';
o.coverClosingClassIsRingOnly = clsOfState(cover, { 'cover.blind': { state: 'closing', attributes: { device_class: 'blind' } } }) === 'covermove';
o.coverUnavailableStillFades = clsOfState(cover, { 'cover.blind': { state: 'unavailable', attributes: {} } }) === 'unavail';
return o;
});
checkAll(out);
await finish(browser, out);
+234
View File
@@ -0,0 +1,234 @@
// «Открыть/закрыть» on a curtain whose cover entity is NOT the primary one
// (owner's report 2026-08-04: «в настройках "открыть\закрыть", а по нажатию
// по-прежнему инфо-карточка»).
//
// The device below is his, entity for entity: an Aqara «Roller shade driver
// E1» ships the `cover.*` HIDDEN by the integration and a perfectly visible
// `switch.*_reverse_direction` beside it. `primaryEntity` ranks visible above
// hidden and `switch` above `cover`, so the marker's primary was the service
// switch — the tap then resolved on the domain `switch` and the action the
// user had explicitly chosen degraded to the info card, while the dialog kept
// OFFERING the action, because that check already looked at every entity of
// the device. The tap now finds the cover the same way the climate
// temperature finds the thermostat: among all of them.
//
// End to end: the action is chosen in the dialog and SAVED through the normal
// path, and the click is done on the marker the card rebuilt from that config.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const out = await page.evaluate(async () => {
const o = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const calls = [];
// ---- the owner's curtain driver, joined to the demo house --------------
const ENTS = {
// hidden by the integration — exactly as z2m ships it
'cover.office_curtain': { entity_id: 'cover.office_curtain', device_id: 'd_curtain', platform: 'demo', hidden: true },
'sensor.office_curtain_battery': { entity_id: 'sensor.office_curtain_battery', device_id: 'd_curtain', platform: 'demo', entity_category: 'diagnostic' },
'switch.office_curtain_reverse': { entity_id: 'switch.office_curtain_reverse', device_id: 'd_curtain', platform: 'demo' },
'sensor.office_curtain_motor_state': { entity_id: 'sensor.office_curtain_motor_state', device_id: 'd_curtain', platform: 'demo' },
// a MIXED device: a lamp that also happens to ship a cover. Its primary is
// meaningfully the light, and nothing below may take that away from it.
'light.mixed': { entity_id: 'light.mixed', device_id: 'd_mixed', platform: 'demo' },
'cover.mixed': { entity_id: 'cover.mixed', device_id: 'd_mixed', platform: 'demo' },
};
const coverState = (state, attrs = {}) => ({
entity_id: 'cover.office_curtain', state,
attributes: { friendly_name: 'Office curtain', ...attrs },
});
const push = async (state, attrs = {}) => {
c.hass = {
...c.hass,
devices: { ...c.hass.devices,
d_curtain: { id: 'd_curtain', name: 'Office curtain', model: 'Roller shade driver E1',
area_id: 'bedroom', identifiers: [['demo', 'd_curtain']], entry_type: null, via_device_id: null },
d_mixed: { id: 'd_mixed', name: 'Bedside lamp', model: 'Lamp with a blind',
area_id: 'bedroom', identifiers: [['demo', 'd_mixed']], entry_type: null, via_device_id: null } },
entities: { ...c.hass.entities, ...ENTS },
states: { ...c.hass.states,
'cover.office_curtain': coverState(state, attrs),
'sensor.office_curtain_battery': { entity_id: 'sensor.office_curtain_battery', state: '84', attributes: { device_class: 'battery', unit_of_measurement: '%' } },
'switch.office_curtain_reverse': { entity_id: 'switch.office_curtain_reverse', state: 'off', attributes: { friendly_name: 'Reverse direction' } },
'sensor.office_curtain_motor_state': { entity_id: 'sensor.office_curtain_motor_state', state: 'stopped', attributes: {} },
'light.mixed': { entity_id: 'light.mixed', state: 'on', attributes: { friendly_name: 'Bedside lamp' } },
'cover.mixed': { entity_id: 'cover.mixed', state: 'opening', attributes: { friendly_name: 'Bedside blind', device_class: 'curtain' } },
},
callService: async (dom, svc, data) => { calls.push([dom, svc, data]); return {}; },
};
c._regSignature = '';
c._maybeRebuildDevices();
// park both extra markers in their own corners: the DOM assertions below
// find a marker by its position, and the auto grid would sit them on top
// of the demo house's own bedroom devices
const cu = c._devices.find((x) => x.bindingRef === 'd_curtain');
const mx = c._devices.find((x) => x.bindingRef === 'd_mixed');
if (cu && mx) {
c._layout = { ...c._layout,
[cu.id]: { s: cu.space, x: 0.06, y: 0.94 },
[mx.id]: { s: mx.space, x: 0.94, y: 0.06 } };
}
c.requestUpdate();
await c.updateComplete;
};
await push('closed', { device_class: 'curtain' });
const curtain = () => c._devices.find((x) => x.bindingRef === 'd_curtain');
const mixed = () => c._devices.find((x) => x.bindingRef === 'd_mixed');
o.deviceIsOnThePlan = !!curtain();
// the premise of the whole report: the cover is NOT the primary entity
o.primaryIsNotTheCover = curtain()?.primary === 'switch.office_curtain_reverse';
o.coverIsAmongTheEntities = (curtain()?.entities || []).includes('cover.office_curtain');
// ---- the dialog offers the action and SAVES it -------------------------
c._setMode('devices'); await c.updateComplete;
c._openMarkerDialog(curtain()); await c.updateComplete;
const optionValues = () => [...sr().querySelectorAll('.dialog option')].map((e) => e.value);
o.dialogOffersCover = optionValues().includes('cover');
c._markerDialog = { ...c._markerDialog, tapAction: 'cover' };
await c.updateComplete;
await c._saveMarker();
await c.updateComplete;
const saved = (c._serverCfg.markers || []).find((m) => m.binding === 'device:d_curtain');
o.markerSaved = saved?.tap_action === 'cover';
// ---- and the tap on THAT marker opens the curtain -----------------------
c._setMode('view'); c._regSignature = ''; c._maybeRebuildDevices();
c.requestUpdate(); await c.updateComplete;
const tap = async () => {
c._clickDevice({ stopPropagation() {} }, curtain());
await c.updateComplete;
await new Promise((r) => setTimeout(r, 10));
};
o.tapActionSurvivedTheRebuild = curtain()?.tapAction === 'cover';
calls.length = 0;
await tap();
o.closedOpensTheCover = JSON.stringify(calls[calls.length - 1] || [])
=== JSON.stringify(['cover', 'open_cover', { entity_id: 'cover.office_curtain' }]);
o.noInfoCard = !c._infoCard;
c._infoCard = null;
await push('open', { device_class: 'curtain' });
calls.length = 0;
await tap();
o.openClosesTheCover = JSON.stringify(calls[calls.length - 1] || [])
=== JSON.stringify(['cover', 'close_cover', { entity_id: 'cover.office_curtain' }]);
await push('opening', { device_class: 'curtain' });
calls.length = 0;
await tap();
o.travellingStops = (calls[calls.length - 1] || [])[1] === 'stop_cover';
// the service switch, which used to be the whole story, is never touched
o.serviceSwitchNeverCalled = !calls.some((x) => String(x[2]?.entity_id).startsWith('switch.'));
// ---- the guarded classes still degrade, read off the COVER --------------
await push('closed', { device_class: 'garage' });
calls.length = 0;
await tap();
o.garageCallsNothing = calls.length === 0;
o.garageShowsInfo = !!c._infoCard;
c._infoCard = null; await c.updateComplete;
c._setMode('devices'); await c.updateComplete;
c._openMarkerDialog(curtain()); await c.updateComplete;
o.dialogHidesCoverForGarage = !optionValues().includes('cover');
c._markerDialog = null;
c._setMode('view'); await c.updateComplete;
// ---- and the marker SHOWS that cover, not the service switch -----------
// Owner, 2026-08-04: «нет ни дышащего кольца во время хода, ни рамки
// "открыто", ни морфинга иконки». (The «открыто» frame itself was retired
// for covers later the same day — the marker still had to start speaking for
// the cover, which is what this section pins.) Same cause, same helper:
// _stateClass and
// the icon morph read d.primary, so the plan reported the state of
// `switch.*_reverse_direction`. The rule (docs/FILTERING.md «What a marker
// SHOWS»): the marker indicates the entity its tap ACTS ON — the cover
// exactly when the owner has explicitly chosen «Открыть/закрыть».
const devEl = (dev) => {
const v = c._viewOr(c._baseVb());
const p = c._pos(dev);
const left = ((p.x - v.x) / v.w) * 100;
const top = ((p.y - v.y) / v.h) * 100;
return [...sr().querySelectorAll('.devlayer .dev')].find((e) =>
Math.abs(parseFloat(e.style.left) - left) < 0.4 && Math.abs(parseFloat(e.style.top) - top) < 0.4);
};
const clsOf = (dev) => [...(devEl(dev)?.classList || [])];
const iconOf = (dev) => devEl(dev)?.querySelector('ha-icon')?.getAttribute('icon') || '';
const setSwitch = async (state) => {
c.hass = { ...c.hass, states: { ...c.hass.states,
'switch.office_curtain_reverse': { entity_id: 'switch.office_curtain_reverse', state, attributes: {} } } };
c.requestUpdate(); await c.updateComplete;
};
await push('closed', { device_class: 'curtain' });
o.markerIsOnScreen = !!devEl(curtain());
o.closedIsNeitherOpenNorMoving =
!clsOf(curtain()).includes('open') && !clsOf(curtain()).includes('covermove');
o.closedIconIsClosedCurtains = iconOf(curtain()) === 'mdi:curtains-closed';
await push('open', { device_class: 'curtain' });
// the plate is neutral in every cover state since 2026-08-04 — the morph is
// the whole open/closed story (smoke_cover_no_plate.mjs)
o.openWearsNoColouredPlate =
!clsOf(curtain()).includes('open') && !clsOf(curtain()).includes('on');
o.openIconIsOpenCurtains = iconOf(curtain()) === 'mdi:curtains';
await push('opening', { device_class: 'curtain' });
o.travellingBreathes = clsOf(curtain()).includes('covermove');
await push('closing', { device_class: 'curtain' });
o.closingBreathesToo = clsOf(curtain()).includes('covermove');
o.stateClassIsTheSameStory = c._stateClass(curtain()).includes('covermove');
// the service switch never speaks for the marker again: reverse-direction
// ON used to paint the curtain yellow («включено») while it stood still
await push('closed', { device_class: 'curtain' });
await setSwitch('on');
o.reverseSwitchNeverLightsTheMarker = !clsOf(curtain()).includes('on');
// THE BOUNDARY of the rule: take the explicit action away and the marker
// goes back to its primary entity — no cover indication is invented
const savedMarker = (c._serverCfg.markers || []).find((m) => m.binding === 'device:d_curtain');
savedMarker.tap_action = 'info';
await push('opening', { device_class: 'curtain' });
await setSwitch('on');
o.withoutTheActionThePrimarySpeaks = clsOf(curtain()).includes('on')
&& !clsOf(curtain()).includes('covermove');
savedMarker.tap_action = 'cover';
await push('closed', { device_class: 'curtain' });
await setSwitch('off');
o.actionBackMeansCoverBack = iconOf(curtain()) === 'mdi:curtains-closed';
// ---- the auditor's own probe: BOTH entities visible ---------------------
// DEV-2C947-04 was written up with a plain visible `cover.*` beside a
// visible `switch.*`: no hidden tier involved, `switch` simply outranks
// `cover` inside DOMAIN_PRIORITY. Same premise, same cure — pinned here so
// the finding cannot come back through the other door.
ENTS['cover.office_curtain'] = { ...ENTS['cover.office_curtain'], hidden: false };
await push('closed', { device_class: 'curtain' });
await setSwitch('off');
o.visibleCoverIsStillNotThePrimary = curtain()?.primary === 'switch.office_curtain_reverse';
calls.length = 0;
await tap();
o.visibleCoverOpensOnTap = JSON.stringify(calls[calls.length - 1] || [])
=== JSON.stringify(['cover', 'open_cover', { entity_id: 'cover.office_curtain' }]);
o.visibleCoverAlsoIndicates = iconOf(curtain()) === 'mdi:curtains-closed';
o.noInfoCardForTheVisibleCover = !c._infoCard;
// ---- a MIXED device is not hijacked ------------------------------------
// A lit lamp that also owns a cover keeps the lamp: no explicit «Open/close»
// on this marker, so nothing changes for it — and even if there were one,
// a shining light still wins the yellow plate (the glow and the badge may
// never disagree, owner's principle 2026-07-29).
o.mixedPrimaryIsTheLight = mixed()?.primary === 'light.mixed';
o.mixedCoverIsTravelling = c.hass.states['cover.mixed'].state === 'opening';
o.mixedStaysALitLamp = clsOf(mixed()).includes('on') && !clsOf(mixed()).includes('covermove');
// its icon is the device's own auto icon, NOT the cover's open/closed pair
o.mixedIconIsNotMorphedByTheCover =
iconOf(mixed()) === mixed()?.icon && !iconOf(mixed()).includes('curtains');
return o;
});
checkAll(out);
await finish(browser, out);
+207
View File
@@ -0,0 +1,207 @@
// An EXPLICIT «Открыть/закрыть» marker is a curtain, and a curtain is never
// painted — audit DEV-1DA1-01 (P2).
//
// `_stateClass` used to answer in this order: bound `controls` first, then any
// lit `light.*` of the device, and only then the entity the tap acts on
// (`_actEntity` → `_coverIndicator`). So the two combinations below still made
// a curtain yellow, in flat contradiction with the owner's contract («у штор
// не должно быть жёлтой подложки НИКОГДА») and with the promise that a marker
// indicates the entity its tap drives:
//
// 1. a MIXED device (a lamp that also ships a blind) told `tap_action:
// 'cover'` — the lit `light.mixed` returned 'on' before the cover branch
// was ever reached, so the travelling blind lost its breathing ring too;
// 2. a curtain marker with a bound `controls` target that happened to be on
// — `controls` returned 'on' first, same result.
//
// The order now starts with the explicit cover: saying «this thing is a
// curtain» in the dialog is the strongest statement the card has about what a
// marker IS, so nothing may paint over it. Everything else keeps the old
// precedence — a marker that was NOT told it is a curtain is untouched, and
// that is re-checked at the bottom.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const out = await page.evaluate(async () => {
const o = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
// ---- the plate colours, resolved the way the badge resolves them -------
const probe = (css) => {
const d = document.createElement('div');
d.style.cssText = `position:absolute;left:-999px;background:${css}`;
sr().appendChild(d);
const v = getComputedStyle(d).backgroundColor;
d.remove();
return v;
};
const YELLOW = probe('var(--hp-on)');
const ORANGE = probe('var(--hp-open)');
const NEUTRAL = probe('var(--hp-bg)');
o.tokensDiffer = new Set([YELLOW, ORANGE, NEUTRAL]).size === 3;
// =======================================================================
// Part 1 — the class itself, straight from _stateClass, over every state
// =======================================================================
const withStates = (states, fn) => {
const saved = c.hass;
c.hass = { ...c.hass, states: { ...c.hass.states, ...states } };
const r = fn();
c.hass = saved;
return r;
};
const st = (state, attrs = {}) => ({ state, attributes: attrs });
/** The auditor's marker #1: primary is a LIT light, the cover is explicit. */
const litLamp = {
id: 'p_lamp', primary: 'light.mixed', entities: ['light.mixed', 'cover.mixed'],
tapAction: 'cover', marker: null,
};
/** The auditor's marker #2: a cover marker whose bound control is on. */
const controlled = {
id: 'p_ctrl', primary: 'cover.mixed', entities: ['cover.mixed'],
tapAction: 'cover', marker: { id: 'p_ctrl', binding: 'device:x', controls: ['switch.control'] },
};
const CV = { closed: '', open: '', opening: 'covermove', closing: 'covermove' };
for (const [state, want] of Object.entries(CV)) {
const states = {
'light.mixed': st('on', { friendly_name: 'Bedside lamp' }),
'switch.control': st('on', {}),
'cover.mixed': st(state, { device_class: 'curtain' }),
};
o['litLamp_' + state] = withStates(states, () => c._stateClass(litLamp)) === want;
o['controlled_' + state] = withStates(states, () => c._stateClass(controlled)) === want;
}
// an unreachable cover still only fades — never yellow, never orange
o.litLampUnavailFades = withStates({
'light.mixed': st('on', {}), 'cover.mixed': st('unavailable', {}),
}, () => c._stateClass(litLamp)) === 'unavail';
o.controlledUnavailFades = withStates({
'switch.control': st('on', {}), 'cover.mixed': st('unavailable', {}),
}, () => c._stateClass(controlled)) === 'unavail';
// …and a cover entity with no state at all is simply neutral, not yellow
o.litLampNoCoverStateIsNeutral = withStates({
'light.mixed': st('on', {}),
}, () => c._stateClass(litLamp)) === '';
// ---- the OTHER side of the rule: nothing changed for the rest ----------
// the very same mixed device WITHOUT the explicit action keeps its primary
const plainLamp = { ...litLamp, id: 'p_plain', tapAction: null };
o.mixedWithoutTheOptionStaysYellow = withStates({
'light.mixed': st('on', {}), 'cover.mixed': st('opening', { device_class: 'curtain' }),
}, () => c._stateClass(plainLamp)) === 'on';
o.mixedWithoutTheOptionOffIsNeutral = withStates({
'light.mixed': st('off', {}), 'cover.mixed': st('opening', { device_class: 'curtain' }),
}, () => c._stateClass(plainLamp)) === '';
// a wall switch with controls and NO cover in sight still mirrors them
const remote = {
id: 'p_remote', primary: 'sensor.remote', entities: ['sensor.remote'], tapAction: null,
marker: { id: 'p_remote', binding: 'device:y', controls: ['light.hall'] },
};
o.remoteMirrorsItsControls = withStates({ 'light.hall': st('on', {}) },
() => c._stateClass(remote)) === 'on';
o.remoteOffIsNeutral = withStates({ 'light.hall': st('off', {}) },
() => c._stateClass(remote)) === '';
// a curtain marker with controls that are OFF is still just a curtain
o.controlledOffStillRings = withStates({
'switch.control': st('off', {}), 'cover.mixed': st('opening', { device_class: 'curtain' }),
}, () => c._stateClass(controlled)) === 'covermove';
// an explicit «cover» marker with NO cover among its entities falls back to
// the old order — the statement is only as strong as the entity behind it
const noCover = {
id: 'p_nocover', primary: 'light.mixed', entities: ['light.mixed'], tapAction: 'cover', marker: null,
};
o.explicitCoverWithoutACoverKeepsThePrimary = withStates({ 'light.mixed': st('on', {}) },
() => c._stateClass(noCover)) === 'on';
// =======================================================================
// Part 2 — the real markers on the real plan: the PLATE, not just a class
// =======================================================================
const ENTS = {
'light.mixed': { entity_id: 'light.mixed', device_id: 'd_mixed', platform: 'demo' },
'cover.mixed': { entity_id: 'cover.mixed', device_id: 'd_mixed', platform: 'demo' },
'cover.ctrl': { entity_id: 'cover.ctrl', device_id: 'd_ctrl', platform: 'demo' },
'switch.control': { entity_id: 'switch.control', device_id: 'd_wall', platform: 'demo' },
};
const push = async (coverState) => {
c.hass = {
...c.hass,
devices: { ...c.hass.devices,
d_mixed: { id: 'd_mixed', name: 'Bedside lamp', model: 'Lamp with a blind',
area_id: 'bedroom', identifiers: [['demo', 'd_mixed']], entry_type: null, via_device_id: null },
d_ctrl: { id: 'd_ctrl', name: 'Office curtain', model: 'Curtain motor',
area_id: 'bedroom', identifiers: [['demo', 'd_ctrl']], entry_type: null, via_device_id: null },
d_wall: { id: 'd_wall', name: 'Wall switch', model: 'Wall switch',
area_id: 'bedroom', identifiers: [['demo', 'd_wall']], entry_type: null, via_device_id: null } },
entities: { ...c.hass.entities, ...ENTS },
states: { ...c.hass.states,
'light.mixed': { entity_id: 'light.mixed', state: 'on', attributes: { friendly_name: 'Bedside lamp' } },
'cover.mixed': { entity_id: 'cover.mixed', state: coverState, attributes: { friendly_name: 'Bedside blind', device_class: 'curtain' } },
'cover.ctrl': { entity_id: 'cover.ctrl', state: coverState, attributes: { friendly_name: 'Office curtain', device_class: 'curtain' } },
'switch.control': { entity_id: 'switch.control', state: 'on', attributes: { friendly_name: 'Wall switch' } },
},
};
c._serverCfg = {
...c._serverCfg,
markers: [
{ id: 'm_mixed', binding: 'device:d_mixed', tap_action: 'cover' },
{ id: 'm_ctrl', binding: 'device:d_ctrl', tap_action: 'cover', controls: ['switch.control'] },
],
};
c._cfgEpoch++;
c._regSignature = '';
c._maybeRebuildDevices();
const mx = c._devices.find((x) => x.bindingRef === 'd_mixed');
const ct = c._devices.find((x) => x.bindingRef === 'd_ctrl');
if (mx && ct) {
c._layout = { ...c._layout,
[mx.id]: { s: mx.space, x: 0.94, y: 0.06 },
[ct.id]: { s: ct.space, x: 0.06, y: 0.94 } };
}
c.requestUpdate();
await c.updateComplete;
// .dev animates `background 0.15s` — read a settled colour, not a fade
await new Promise((r) => setTimeout(r, 300));
};
c._setMode('view');
c._space = 'f1';
c.requestUpdate();
await c.updateComplete;
await push('opening');
const dev = (ref) => c._devices.find((x) => x.bindingRef === ref);
const elOf = (d) => {
const v = c._viewOr(c._baseVb());
const p = c._pos(d);
const left = ((p.x - v.x) / v.w) * 100;
const top = ((p.y - v.y) / v.h) * 100;
return [...sr().querySelectorAll('.devlayer .dev')].find((e) =>
Math.abs(parseFloat(e.style.left) - left) < 0.4 && Math.abs(parseFloat(e.style.top) - top) < 0.4);
};
const report = (tag, ref) => {
const d = dev(ref);
const el = d && elOf(d);
o[tag + 'MarkerFound'] = !!el;
if (!el) return;
const bg = getComputedStyle(el).backgroundColor;
o[tag + 'PlateNeutral'] = bg === NEUTRAL;
o[tag + 'NotYellow'] = bg !== YELLOW;
o[tag + 'NotOrange'] = bg !== ORANGE;
o[tag + 'NoOnClass'] = !el.classList.contains('on') && !el.classList.contains('open');
o[tag + 'Rings'] = el.classList.contains('covermove');
o[tag + 'IconIsTheCover'] = el.querySelector('ha-icon')?.getAttribute('icon') === 'mdi:curtains';
};
o.lampIsTrulyLit = c.hass.states['light.mixed'].state === 'on';
o.controlIsTrulyOn = c.hass.states['switch.control'].state === 'on';
report('litLampDom', 'd_mixed');
report('controlledDom', 'd_ctrl');
// …and at rest (closed) they are plain, with no ring
await push('closed');
const closedBg = (ref) => getComputedStyle(elOf(dev(ref))).backgroundColor;
o.litLampClosedNeutral = closedBg('d_mixed') === NEUTRAL;
o.controlledClosedNeutral = closedBg('d_ctrl') === NEUTRAL;
o.litLampClosedNoRing = !elOf(dev('d_mixed')).classList.contains('covermove');
return o;
});
checkAll(out);
await finish(browser, out);
+196
View File
@@ -0,0 +1,196 @@
// Covers: the «Open/close» tap action + the travelling indication
// (owner's contract 2026-08-03).
//
// closed -> cover.open_cover
// open / ajar -> cover.close_cover
// opening / closing -> cover.stop_cover (a tap during travel is a STOP;
// the next tap simply travels the other way)
// unknown -> cover.toggle, no morphing, no pulse
//
// The option is offered ONLY for a binding that HAS a cover entity, and never
// for the guarded classes (garage/door/gate) — for those a value smuggled into
// the config degrades to 'info', exactly like a card-wide toggle does.
// While travelling the icon breathes a soft ring (.covermove, the vacuum
// puck's 2.2s period) and the plate stays NEUTRAL — yellow means «включено».
// Since 2026-08-04 the plate is neutral in EVERY cover state, the «открыто»
// frame included: open/closed is told by the icon morph alone (the full
// contract lives in smoke_cover_no_plate.mjs).
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const out = await page.evaluate(async () => {
const o = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const calls = [];
const YELLOW = 'rgb(255, 212, 92)'; // --hp-on, the «включено» plate
const gateEntity = (state, attrs) => ({
entity_id: 'cover.gate', state,
attributes: { friendly_name: 'Gate', ...attrs },
});
/** Push a cover state into hass keeping the service recorder in place. */
const setCover = async (state, attrs = {}) => {
c.hass = {
...c.hass,
states: { ...c.hass.states, 'cover.gate': gateEntity(state, attrs) },
callService: async (dom, svc, data) => { calls.push([dom, svc, data]); return {}; },
};
c.requestUpdate();
await c.updateComplete;
};
/** Marker for the gate device: tap action + optional confirmation.
* `solo` also hides the mower, leaving exactly one badge in the Garden —
* the indication checks then read the cover's own plate, not a neighbour's. */
const setMarker = async (tapAction, confirm = false, solo = false) => {
c._serverCfg = {
...c._serverCfg,
markers: [
{ id: 'm_gate', binding: 'device:d_gate', tap_action: tapAction, tap_confirm: confirm || null },
...(solo ? [{ id: 'm_mower', binding: 'device:d_mower', hidden: true }] : []),
],
};
c._cfgEpoch++;
c._regSignature = '';
c._maybeRebuildDevices();
c.requestUpdate();
await c.updateComplete;
};
const gate = () => c._devices.find((x) => x.bindingRef === 'd_gate');
const tap = async () => { c._clickDevice({ stopPropagation() {} }, gate()); await c.updateComplete; };
// never throw when the feature is missing: the smoke must REPORT the gap
const lastCall = () => calls[calls.length - 1] || ['-', '-', {}];
// the gate is the only cover in the Garden space; the plan draws no names
// inside the badge, so the icon set of the space is the readable handle
const devEl = () => sr().querySelector('.dev');
const icons = () => [...sr().querySelectorAll('.dev ha-icon')].map((e) => e.getAttribute('icon'));
// the gate lives in the Garden space — look at it, not at the ground floor
c._setMode('view');
c._space = 'garden';
c.requestUpdate();
await c.updateComplete;
// ---- the dialog offers the option only where it makes sense ------------
await setCover('closed', { device_class: 'curtain' });
c._setMode('devices'); await c.updateComplete;
const optionValues = () => [...sr().querySelectorAll('.dialog option')].map((e) => e.value);
c._openMarkerDialog(gate()); await c.updateComplete;
o.dialogOffersCoverForCurtain = optionValues().includes('cover');
// …and the confirmation checkbox follows the choice, like it does for run/toggle
const rowsBefore = sr().querySelectorAll('.dialog .srcrow').length;
c._markerDialog = { ...c._markerDialog, tapAction: 'cover' }; await c.updateComplete;
o.confirmCheckboxShownForCover = sr().querySelectorAll('.dialog .srcrow').length > rowsBefore;
c._markerDialog = null; await c.updateComplete;
// a GARAGE door never gets the option (owner: «нет, только шторы/жалюзи»)
await setCover('closed', { device_class: 'garage' });
c._openMarkerDialog(gate()); await c.updateComplete;
o.dialogHidesCoverForGarage = !optionValues().includes('cover');
c._markerDialog = null; await c.updateComplete;
// …and neither does a device without a cover entity at all
c._openMarkerDialog(c._devices.find((x) => x.bindingRef === 'd_mower')); await c.updateComplete;
o.dialogHidesCoverWithoutCover = !optionValues().includes('cover');
c._markerDialog = null; await c.updateComplete;
c._setMode('view'); c._space = 'garden'; c.requestUpdate(); await c.updateComplete;
// ---- the tap itself ----------------------------------------------------
await setMarker('cover');
await setCover('closed', { device_class: 'curtain' });
calls.length = 0;
await tap();
await new Promise((r) => setTimeout(r, 10));
o.closedOpens = JSON.stringify(lastCall()) === JSON.stringify(['cover', 'open_cover', { entity_id: 'cover.gate' }]);
await setCover('open', { device_class: 'curtain' });
await tap(); await new Promise((r) => setTimeout(r, 10));
o.openCloses = lastCall()[0] === 'cover' && lastCall()[1] === 'close_cover';
// a half-drawn curtain is still 'open' for HA — it closes
await setCover('open', { device_class: 'curtain', current_position: 40 });
await tap(); await new Promise((r) => setTimeout(r, 10));
o.ajarCloses = lastCall()[1] === 'close_cover';
await setCover('opening', { device_class: 'curtain' });
await tap(); await new Promise((r) => setTimeout(r, 10));
o.openingStops = lastCall()[1] === 'stop_cover';
await setCover('closing', { device_class: 'curtain' });
await tap(); await new Promise((r) => setTimeout(r, 10));
o.closingStops = lastCall()[1] === 'stop_cover';
// no readable state — a plain cover.toggle, nothing clever
await setCover('unknown', { device_class: 'curtain' });
await tap(); await new Promise((r) => setTimeout(r, 10));
o.unknownToggles = lastCall()[0] === 'cover' && lastCall()[1] === 'toggle';
// ---- the confirmation checkbox works for this action too ---------------
await setMarker('cover', true);
await setCover('closed', { device_class: 'curtain' });
calls.length = 0;
await tap();
o.confirmAsks = !!c._tapConfirm && c._tapConfirm.text.includes('Gate');
c._tapConfirm = null; await c.updateComplete;
await new Promise((r) => setTimeout(r, 10));
o.confirmCancelCallsNothing = calls.length === 0;
await tap();
const conf = c._tapConfirm; c._tapConfirm = null; conf?.exec?.();
await new Promise((r) => setTimeout(r, 10));
o.confirmOkOpens = calls.length === 1 && calls[0][1] === 'open_cover';
// ---- a saved 'cover' on a GARAGE degrades to the info card -------------
await setMarker('cover');
await setCover('closed', { device_class: 'garage' });
calls.length = 0;
await tap();
await new Promise((r) => setTimeout(r, 10));
o.garageSavedCoverCallsNothing = calls.length === 0;
o.garageSavedCoverShowsInfo = !!c._infoCard;
c._infoCard = null; await c.updateComplete;
// ---- the indication ----------------------------------------------------
const afterAnim = () => {
const el = devEl();
if (!el) return null;
const cs = getComputedStyle(el, '::after');
return { name: cs.animationName, dur: cs.animationDuration };
};
await setMarker('cover', false, true); // the gate alone on the plan
await setCover('opening', { device_class: 'curtain' });
// .dev animates `background 0.15s`: lit recycles the badge node the hidden
// mower used, so a plate colour read in the same frame is still mid-fade
await new Promise((r) => setTimeout(r, 300));
o.onlyTheCoverOnThePlan = sr().querySelectorAll('.dev').length === 1;
o.pulseWhileOpening = sr().querySelectorAll('.dev.covermove').length === 1;
const a1 = afterAnim();
o.pulseIsBreathingRing = a1?.name === 'hp-covermove' && a1?.dur === '2.2s';
// the plate stays NEUTRAL while travelling: yellow is reserved for «включено»
o.movingPlateNotYellow = getComputedStyle(devEl()).backgroundColor !== YELLOW;
await setCover('closing', { device_class: 'curtain' });
await new Promise((r) => setTimeout(r, 300));
o.pulseWhileClosing = sr().querySelectorAll('.dev.covermove').length === 1;
o.closingPlateNotYellow = getComputedStyle(devEl()).backgroundColor !== YELLOW;
await setCover('open', { device_class: 'curtain' });
o.noPulseWhenOpen = sr().querySelectorAll('.dev.covermove').length === 0;
o.iconMorphOpen = icons().includes('mdi:curtains') && !icons().includes('mdi:curtains-closed');
// owner 2026-08-04: never a coloured plate on a cover — not even «открыто»
o.openWearsNoFrame = sr().querySelectorAll('.dev.open').length === 0;
o.openPlateNotYellow = getComputedStyle(devEl()).backgroundColor !== YELLOW;
await setCover('closed', { device_class: 'curtain' });
o.noPulseWhenClosed = sr().querySelectorAll('.dev.covermove').length === 0;
o.iconMorphClosed = icons().includes('mdi:curtains-closed') && !icons().includes('mdi:curtains');
await setCover('open', { device_class: 'shutter' });
o.iconMorphShutterOpen = icons().includes('mdi:window-shutter-open');
await setCover('unknown', { device_class: 'curtain' });
o.noPulseWhenUnknown = sr().querySelectorAll('.dev.covermove').length === 0;
o.noMorphWhenUnknown = icons().includes(gate().icon)
&& !icons().some((i) => i.startsWith('mdi:curtains'));
return o;
});
checkAll(out);
await finish(browser, out);
+110 -2
View File
@@ -11,7 +11,9 @@ const res = await page.evaluate(async () => {
tabs[2].click(); await c.updateComplete;
out.decorMode = c._mode === 'decor';
out.decorBar = !!sr().querySelector('.editbar.decorbar');
out.toolBtns = sr().querySelectorAll('.decorbar .btn:not(.barclose)').length === 6;
// six drawing tools + «Картинка-подложка», which f1 offers because it HAS a
// picture (docs/BACKDROP.md §2); a hand-drawn space still shows six
out.toolBtns = sr().querySelectorAll('.decorbar .btn.dtool').length === 7;
// 2) нарисовать прямоугольник drag-ом (через прямые вызовы)
c._decorTool = 'rect'; c._decorStyle = { color: '#ff0000', width: 3, fill: true }; await c.updateComplete;
const g = c._gridPitch;
@@ -45,6 +47,45 @@ const res = await page.evaluate(async () => {
c._decorDraft = { kind: 'line', a: [g, g], b: [g, g], pid: 1 };
c._decorCommitDraft();
out.degenerateSkipped = c._decorList.length === 3;
// 3b) живая плашка размера, пока фигуру ещё тянут (владелец 2026-08-04:
// «в редакторе подложки у линий писать длину, как при рисовании комнат в
// редакторе плана»). Та же .measurelabel, тот же formatLength/cell_cm.
const label = () => sr().querySelector('.measurelabel.dmeasure');
const txt = () => (label() ? label().textContent.trim() : null);
const cellCm = 5; // cell_cm по умолчанию
const fmt = (px) => (((px / g) * cellCm) / 100).toFixed(2) + ' m';
const vw = () => c._viewOr(c._baseVb());
c._decorTool = 'line';
c._decorDraft = { kind: 'line', a: [g * 4, g * 20], b: [g * 4, g * 20], pid: 3 };
await c.updateComplete;
out.noBadgeBeforeTheDrag = !label(); // нулевая длина — молчим
c._decorDraft = { ...c._decorDraft, b: [g * 16, g * 20] }; // 12 клеток вправо
await c.updateComplete;
out.lineBadgeShown = !!label();
// 12 клеток × 5 см = 0.60 m, угол 0° — ровно то, что пишет плашка стены
out.lineBadgeText = txt();
out.lineBadgeMatchesGeometry = txt() === fmt(g * 12) + ' · 0°';
// ...ровно на середине отрезка (владелец: «плашка на середине линии»)
out.lineBadgeAtTheMiddle = !!label() && Math.abs(
parseFloat(label().style.left) - ((g * 10 - vw().x) / vw().w) * 100) < 0.02;
// 0° кратен 45° — плашка зелёная, как у стены на 45°
out.badgeGreenOnAxis = !!label() && label().classList.contains('on45');
// косой отрезок 3-4-5: длина 5 клеток, угол 53.1° — не кратен 45°
c._decorDraft = { kind: 'line', a: [g * 4, g * 20], b: [g * 7, g * 24], pid: 3 };
await c.updateComplete;
out.obliqueBadge = txt() === fmt(g * 5) + ' · 53.1°';
out.obliqueNotGreen = !!label() && !label().classList.contains('on45');
// прямоугольник и овал: длины у них нет, есть габарит «Ш × В»
c._decorDraft = { kind: 'rect', a: [g * 4, g * 20], b: [g * 12, g * 26], pid: 3 };
await c.updateComplete;
out.rectBadgeIsSize = txt() === fmt(g * 8) + ' × ' + fmt(g * 6);
// отпустили — плашки нет
c._decorCommitDraft(); await c.updateComplete;
out.badgeGoneAfterRelease = !label();
c._curSpaceCfg.decor = c._decorList.slice(0, 3); // назад к трём фигурам
c._decorTool = 'select';
await c.updateComplete;
out.stillThreeShapes = c._decorList.length === 3;
// 4) надпись через диалог
c._decorTextDialog = { x: 0.5, y: 0.5, text: 'Сауна', size: 'l', color: '#0000ff' };
c._decorSaveText(); await c.updateComplete;
@@ -62,6 +103,72 @@ const res = await page.evaluate(async () => {
c._curSpaceCfg.decor = c._decorList.map((x) => x.id === m.id ? { ...x, x: m.orig.x + dx5 } : x);
out.moveKeepsSize = Math.abs(c._decorList.find((x) => x.id === rect.id).w - before.w) < 1e-9;
c._decorMove = null;
// 5b) инструмент рисования ВЛАДЕЕТ холстом: клик по существующей фигуре
// начинает новую фигуру в этой точке, а не выделяет старую (владелец
// 2026-08-04: «нельзя поставить начало линии на конец другой»).
const stageEl = () => sr().querySelector('.stage');
// экранные координаты точки холста (обратное _screenToVb)
const toScreen = (x, y) => {
const r = stageEl().getBoundingClientRect();
const v = c._viewOr(c._baseVb());
return { clientX: r.left + ((x - v.x) / v.w) * r.width,
clientY: r.top + ((y - v.y) / v.h) * r.height };
};
const press = (el, x, y, pid) => {
const { clientX, clientY } = toScreen(x, y);
el.dispatchEvent(new PointerEvent('pointerdown', {
bubbles: true, composed: true, cancelable: true,
pointerId: pid, clientX, clientY, button: 0, isPrimary: true }));
};
// подопытная линия с известными концами
c._decorTool = 'select'; c._decorSel = null; c._decorDraft = null; c._decorMove = null;
const decorBefore = c._decorList.slice();
c._curSpaceCfg.decor = [{ id: 'dcprobe', kind: 'line', x1: (g * 6) / 1000, y1: (g * 30) / 1000,
x2: (g * 18) / 1000, y2: (g * 30) / 1000, color: '#000000', width: 3 }];
c._cfgEpoch = (c._cfgEpoch || 0) + 1;
c.requestUpdate(); await c.updateComplete;
const probe = () => [...sr().querySelectorAll('.decorlayer line.dshape')]
.find((l) => Math.abs(+l.getAttribute('x1') - g * 6) < 0.01);
out.probeRendered = !!probe();
// — инструмент «линия»: жмём ровно на КОНЕЦ линии
c._decorTool = 'line'; await c.updateComplete;
// фигура вообще перестаёт быть мишенью для указателя
out.shapeInertUnderLineTool = getComputedStyle(probe()).pointerEvents === 'none';
press(probe(), g * 18, g * 30, 41);
await c.updateComplete;
out.lineToolStartsDraft = !!c._decorDraft && c._decorDraft.kind === 'line';
// ...и именно из этой точки (снап к концу старой линии)
out.draftStartsAtTheEnd = !!c._decorDraft
&& Math.abs(c._decorDraft.a[0] - g * 18) < 0.51 && Math.abs(c._decorDraft.a[1] - g * 30) < 0.51;
out.lineToolNoSelect = c._decorSel === null;
out.lineToolNoMove = !c._decorMove;
c._decorDraft = null; await c.updateComplete;
// — прямоугольник и овал ведут себя так же
for (const [tool, key] of [['rect', 'rectToolStartsDraft'], ['ellipse', 'ellipseToolStartsDraft']]) {
c._decorTool = tool; c._decorSel = null; await c.updateComplete;
press(probe(), g * 12, g * 30, 42);
await c.updateComplete;
out[key] = !!c._decorDraft && c._decorDraft.kind === tool && c._decorSel === null;
c._decorDraft = null;
}
// — «текст» по фигуре открывает диалог новой надписи, а не выделяет
c._decorTool = 'text'; c._decorSel = null; c._decorTextDialog = null; await c.updateComplete;
press(probe(), g * 12, g * 30, 43);
await c.updateComplete;
out.textToolOpensDialog = !!c._decorTextDialog && !c._decorTextDialog.id && c._decorSel === null;
c._decorTextDialog = null;
// — а вот в «выборе» всё по-прежнему: клик выделяет и берёт на перетаскивание
c._decorTool = 'select'; c._decorSel = null; c._decorDraft = null; await c.updateComplete;
out.shapeLiveUnderSelect = getComputedStyle(probe()).pointerEvents !== 'none';
press(probe(), g * 12, g * 30, 44);
await c.updateComplete;
out.selectStillSelects = c._decorSel === 'dcprobe';
out.selectStillGrabs = !!c._decorMove && c._decorMove.id === 'dcprobe';
out.selectMakesNoDraft = !c._decorDraft;
c._decorMove = null; c._decorSel = null;
c._curSpaceCfg.decor = decorBefore; // сцена как была до пробы
c._decorTool = 'select'; await c.updateComplete;
out.decorRestored = c._decorList.length === decorBefore.length;
// 6) erase удаляет
const n0 = c._decorList.length;
c._decorTool = 'erase';
@@ -82,5 +189,6 @@ const res = await page.evaluate(async () => {
out.deleteKey = c._decorList.length === n1 - 1;
return out;
});
checkAll(res);
// значение плашки зафиксировано числом: 12 клеток × cell_cm 5 = 0.60 m, 0°
checkAll(res, { lineBadgeText: '0.60 m · 0°' });
await finish(browser, res);
+126
View File
@@ -0,0 +1,126 @@
// DEV-B58-01 — the infinite canvas reached the FRAME and the drawing, but not
// the drag handlers. A device marker was clamped into the CONTENT FRAME (with
// a 0.8 % inset), a room label into the space's stored view_box — which, for
// every plan the card has ever written, is the old unit square. The owner and a
// user hit the same wall: "названия комнат и устройства не перетаскиваются
// дальше старых границ холста".
//
// Every interactive coordinate now has ONE bound: the ±5000 the backend
// enforces (docs/CANVAS.md §9). This smoke starts from an ORDINARY plan — one
// that lives inside 0..1, so the old clamps really were in the way — and drags
// each kind of thing out to 2.5 / 2.2 and beyond.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 900, height: 820 }, 1);
const out = await page.evaluate(async () => {
const o = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const NORM_W = 1000;
const near = (a, b, eps = 1 / 240) => Math.abs(a - b) <= eps + 1e-9;
const onGrid = (v) => Math.abs(v * 240 - Math.round(v * 240)) < 1e-9;
// the demo plan is an ordinary one: rooms inside the old unit square
const b0 = c._baseVb();
o.startsFromAnOrdinaryPlan = b0[0] + b0[2] < 1.3 * NORM_W && b0[1] + b0[3] < 1.3 * NORM_W;
/** Turn a wanted RENDER-unit delta into the clientX/clientY a handler wants. */
const mk = (dxu, dyu, sx = 400, sy = 400, extra = {}) => {
const rect = sr().querySelector('.stage').getBoundingClientRect();
const v = c._viewOr(c._baseVb());
return new PointerEvent('pointermove', {
clientX: sx + (dxu / v.w) * rect.width,
clientY: sy + (dyu / v.h) * rect.height,
bubbles: true, ...extra,
});
};
// ---- (a) a DEVICE MARKER goes to 2.5 / 2.2 ---------------------------
c._setMode('devices'); await c.updateComplete;
const dev = c._devices.find((d) => !d.virtual);
const p0 = c._pos(dev);
c._drag = { id: dev.id, sx: 400, sy: 400, ox: p0.x, oy: p0.y, moved: false };
c._pointerMove(mk(2.5 * NORM_W - p0.x, 2.2 * NORM_W - p0.y), dev);
c._pointerUp(new PointerEvent('pointerup'), dev);
await c.updateComplete;
const saved = c._layout[dev.id];
o.markerLeftTheOldCanvas = near(saved.x, 2.5) && near(saved.y, 2.2);
o.markerIsOnTheGrid = onGrid(saved.x) && onGrid(saved.y);
// ---- (b) it SURVIVES a rebuild ---------------------------------------
c._drag = null;
c._modelCache = null; c._frame = null; c._defPos = c._defaultPositions();
c.requestUpdate(); await c.updateComplete;
const back = c._pos(c._devices.find((d) => d.id === dev.id));
o.markerSurvivesRebuild = near(back.x / NORM_W, 2.5) && near(back.y / NORM_W, 2.2);
// …and the frame followed it out there instead of pretending it is elsewhere
const b1 = c._baseVb();
o.frameFollowedTheMarker = b1[0] + b1[2] > 2.4 * NORM_W;
// ---- (c) a ROOM LABEL, the worse of the two (it clamped to view_box) --
c._setMode('plan'); await c.updateComplete;
const room = c._spaceModel(c._space).rooms.find((r) => r.name);
const lp = c._labelPos(room, c._space);
c._drag = { id: 'rl_' + room.id, sx: 400, sy: 400, ox: lp.x, oy: lp.y, moved: false };
c._labelMove(mk(2.7 * NORM_W - lp.x, 2.4 * NORM_W - lp.y), room, c._space);
c._labelUp(room);
await c.updateComplete;
const sl = c._layout['rl_' + room.id];
o.labelLeftTheOldCanvas = near(sl.x, 2.7) && near(sl.y, 2.4);
o.labelIsOnTheGrid = onGrid(sl.x) && onGrid(sl.y);
c._drag = null;
c.requestUpdate(); await c.updateComplete;
const lp2 = c._labelPos(room, c._space);
o.labelSurvivesRebuild = near(lp2.x / NORM_W, 2.7) && near(lp2.y / NORM_W, 2.4);
// ---- (d) DECOR born far out, and moved further still -------------------
c._setMode('decor'); await c.updateComplete;
c._decorTool = 'rect';
c._decorDraft = { kind: 'rect', a: [2.6 * NORM_W, 2.6 * NORM_W], b: [2.9 * NORM_W, 2.8 * NORM_W], pid: 1 };
c._decorCommitDraft(); await c.updateComplete;
const sh = c._decorList[c._decorList.length - 1];
o.decorBornFarOut = near(sh.x, 2.6) && near(sh.y, 2.6);
// the decor mover works in ABSOLUTE svg points, so the gesture is built the
// way _decorShapeDown builds it: grab at the shape, release 0.6/0.5 away
const atUnits = (X, Y) => {
const rect = sr().querySelector('.stage').getBoundingClientRect();
const v = c._viewOr(c._baseVb());
return new PointerEvent('pointermove', {
clientX: rect.left + ((X - v.x) / v.w) * rect.width,
clientY: rect.top + ((Y - v.y) / v.h) * rect.height, bubbles: true,
});
};
const grab = c._svgPoint(atUnits(2.6 * NORM_W, 2.6 * NORM_W));
c._decorMove = { id: sh.id, start: grab,
orig: JSON.parse(JSON.stringify(sh)), pid: 2, moved: false };
c._decorMoveUpdate(atUnits(3.2 * NORM_W, 3.1 * NORM_W));
await c.updateComplete;
const sh2 = c._decorList.find((x) => x.id === sh.id);
o.decorMovedFurtherOut = near(sh2.x, 3.2) && near(sh2.y, 3.1);
c._decorMove = null;
// ---- (e) the ONE bound that is left is the backend's ±5000 ------------
c._setMode('devices'); await c.updateComplete;
const dev2 = c._devices.find((d) => d.id !== dev.id && !d.virtual);
const q0 = c._pos(dev2);
c._drag = { id: dev2.id, sx: 400, sy: 400, ox: q0.x, oy: q0.y, moved: false };
c._pointerMove(mk(9e9, 9e9), dev2);
c._pointerUp(new PointerEvent('pointerup'), dev2);
c._drag = null;
o.garbageStillClamped = c._layout[dev2.id].x === 5000 && c._layout[dev2.id].y === 5000;
// ---- (f) Shift is the only way to land BETWEEN the nodes --------------
const dev3 = c._devices.find((d) => d.id !== dev.id && d.id !== dev2.id && !d.virtual);
const r0 = c._pos(dev3);
const halfStep = NORM_W / 240 / 2;
c._drag = { id: dev3.id, sx: 400, sy: 400, ox: r0.x, oy: r0.y, moved: false };
c._pointerMove(mk(2.5 * NORM_W + halfStep - r0.x, 2.5 * NORM_W - r0.y, 400, 400, { shiftKey: true }), dev3);
c._drag = null;
o.shiftPlacesBetweenTheNodes = !onGrid(c._layout[dev3.id].x);
c._setMode('view');
return o;
});
await finish(browser, checkAll(out));
+23 -3
View File
@@ -1,4 +1,5 @@
import { launch, checkAll, finish } from './serve.mjs';
import { launch, check, checkAll, finish } from './serve.mjs';
import { readFileSync } from 'node:fs';
const { page, browser } = await launch();
const res = await page.evaluate(async () => {
const out = {};
@@ -8,6 +9,10 @@ const res = await page.evaluate(async () => {
c._openSettingsDialog(); await c.updateComplete;
out.rows = sr().querySelectorAll('.gsrow').length;
out.groups = [...sr().querySelectorAll('.dialog .dispsection')].map((l) => l.textContent.trim());
// 1b) блок About: версия + две внешние ссылки (target=_blank rel=noopener)
out.aboutVersion = sr().querySelector('.dialog .aboutver')?.textContent.trim() ?? null;
out.aboutLinks = [...sr().querySelectorAll('.dialog a.aboutlink')].map((a) => ({
href: a.getAttribute('href'), target: a.getAttribute('target'), rel: a.getAttribute('rel') }));
// 2) сменить цвет light_on и сохранить
c._setFillColor('light_on', { c: '#ff00ff', a: 0.5 });
await c._saveSettingsDialog(); await c.updateComplete;
@@ -26,10 +31,25 @@ const res = await page.evaluate(async () => {
out.lqiAfter = sr().querySelectorAll('.dev .lqi').length;
return out;
});
// CARD_VERSION из собранного бандла (тот же текст уходит в console-баннер).
// terser либо инлайнит строку (v1.56.0), либо оставляет переменную (v${xx}) —
// во втором случае доразрешаем её по присваиванию xx="1.56.0".
// Версия — SemVer, у пре-релиза есть суффикс (1.58.0-beta.1), он тоже часть строки.
const bundle = readFileSync(new URL('./srv/assets/houseplan-card.js', import.meta.url), 'utf8');
const SEMVER = '\\d+\\.\\d+\\.\\d+(?:-[0-9A-Za-z.-]+)?';
const m = bundle.match(new RegExp(`HOUSEPLAN-CARD %c v(?:(${SEMVER})|\\$\\{(\\w+)\\})`));
const BUNDLE_VERSION = m?.[1] ?? (m?.[2] && bundle.match(new RegExp(`[^\\w$]${m[2]}="(${SEMVER})"`))?.[1]);
check('bundleVersionFound', typeof BUNDLE_VERSION === 'string' && BUNDLE_VERSION.length > 0);
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
checkAll(res, {
"rows": 14, // 10 цветов + радиус свечения + режим фона + цвет фона + погода (docs/SUN.md)
"groups": ["Fill: lights", "Fill: temperature", "Fill: zigbee signal", "Light-sources fill", "Stage background", "Sun"],
"rows": 15, // 10 цветов + радиус свечения + режим фона + цвет фона + погода (docs/SUN.md)
// + «Выровнять всё по сетке» (docs/CANVAS.md §9)
"groups": ["Fill: lights", "Fill: temperature", "Fill: zigbee signal", "Light-sources fill", "Stage background", "Sun", "Grid", "About"],
"aboutVersion": `Houseplan Card v${BUNDLE_VERSION}`, // та же константа, что в баннере
"aboutLinks": [
{ "href": "https://github.com/Matysh/houseplan-card", "target": "_blank", "rel": "noopener" },
{ "href": "https://t.me/ha_houseplan", "target": "_blank", "rel": "noopener" },
],
"saved": {"c": "#ff00ff", "a": 0.5},
"lqiBefore": 7,
"lqiAfter": 0,
+257
View File
@@ -0,0 +1,257 @@
// «Всё строго по сетке» (docs/CANVAS.md §9). Two halves:
// 1. every element placed or dragged by hand lands on a node — devices, room
// labels, decor, room vertices, resize handles; an opening lands ON ITS
// WALL at a whole number of steps along it; Shift is the only way out;
// 2. the explicit «Выровнять всё по сетке» action moves a deliberately
// detuned plan onto the grid, tells the truth about how much it moves,
// and does nothing at all the second time.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 900, height: 820 }, 1);
const out = await page.evaluate(async () => {
const o = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const NORM_W = 1000, GRID_N = 240;
const PITCH = NORM_W / GRID_N;
const onGridN = (v) => Math.abs(v * GRID_N - Math.round(v * GRID_N)) < 1e-9;
const onGridR = (v) => Math.abs(v / PITCH - Math.round(v / PITCH)) < 1e-7;
/** A click/pointer event at a given RENDER-unit point of the current view. */
const at = (X, Y, type = 'pointermove', extra = {}) => {
const rect = sr().querySelector('.stage').getBoundingClientRect();
const v = c._viewOr(c._baseVb());
return new PointerEvent(type, {
clientX: rect.left + ((X - v.x) / v.w) * rect.width,
clientY: rect.top + ((Y - v.y) / v.h) * rect.height, bubbles: true, ...extra,
});
};
// an off-grid target: a third of a step past a node, in both axes
const OFF = PITCH / 3;
// ---- 1a) the snap itself, the one gate every gesture goes through -----
const s1 = c._snap([100 + OFF, 200 + OFF]);
o.snapRoundsToTheNode = onGridR(s1[0]) && onGridR(s1[1]);
const s2 = c._snap([100 + OFF, 200 + OFF], { shiftKey: true });
o.shiftSuspendsTheSnap = !onGridR(s2[0]);
o.snapAlsoClampsTheGarbage = c._snap([1e12, -1e12])[0] === 5000 * NORM_W;
// the step is NORM_W/GRID_N and nothing else — it did NOT change with the
// infinite canvas, so no plan's nodes ever moved out from under it
o.pitchIsTheCanvasFreeConstant = Math.abs(c._gridPitch - PITCH) < 1e-12;
// ---- 1b) a DEVICE dropped with the mouse lands on a node --------------
c._setMode('devices'); await c.updateComplete;
const dev = c._devices.find((d) => !d.virtual);
const p0 = c._pos(dev);
const mkDelta = (dxu, dyu, extra = {}) => {
const rect = sr().querySelector('.stage').getBoundingClientRect();
const v = c._viewOr(c._baseVb());
return new PointerEvent('pointermove', {
clientX: 400 + (dxu / v.w) * rect.width, clientY: 400 + (dyu / v.h) * rect.height,
bubbles: true, ...extra,
});
};
c._drag = { id: dev.id, sx: 400, sy: 400, ox: p0.x, oy: p0.y, moved: false };
c._pointerMove(mkDelta(137.7 - p0.x + OFF, 251.3 - p0.y + OFF), dev);
c._drag = null;
o.deviceLandsOnANode = onGridN(c._layout[dev.id].x) && onGridN(c._layout[dev.id].y);
// ---- 1c) a ROOM LABEL too ---------------------------------------------
c._setMode('plan'); await c.updateComplete;
const room = c._spaceModel(c._space).rooms.find((r) => r.name);
const lp = c._labelPos(room, c._space);
c._drag = { id: 'rl_' + room.id, sx: 400, sy: 400, ox: lp.x, oy: lp.y, moved: false };
c._labelMove(mkDelta(311.4 - lp.x + OFF, 402.9 - lp.y + OFF), room, c._space);
c._drag = null;
o.roomLabelLandsOnANode = onGridN(c._layout['rl_' + room.id].x)
&& onGridN(c._layout['rl_' + room.id].y);
// …and one that has never been dragged is on a node as well
const other = c._spaceModel(c._space).rooms.find((r) => r.name && r.id !== room.id);
const op = c._labelPos(other, c._space);
o.untouchedLabelIsOnANode = onGridR(op.x) && onGridR(op.y);
// ---- 1d) DECOR: draft, text anchor, and a move ------------------------
c._setMode('decor'); await c.updateComplete;
c._decorTool = 'rect';
// dispatched for real, so the handler sees a target (it looks for .dshape)
sr().querySelector('.stage').dispatchEvent(at(300 + OFF, 300 + OFF, 'pointerdown', { pointerId: 21 }));
o.decorDraftStartsOnANode = onGridR(c._decorDraft.a[0]) && onGridR(c._decorDraft.a[1]);
c._decorDraft = { ...c._decorDraft, b: c._snap([420 + OFF, 380 + OFF]) };
c._decorCommitDraft(); await c.updateComplete;
const dsh = c._decorList[c._decorList.length - 1];
o.decorShapeIsOnNodes = onGridN(dsh.x) && onGridN(dsh.y)
&& onGridN(dsh.x + dsh.w) && onGridN(dsh.y + dsh.h);
c._decorTool = 'text';
sr().querySelector('.stage').dispatchEvent(at(500 + OFF, 500 + OFF, 'pointerdown', { pointerId: 22 }));
o.decorTextAnchorIsOnANode = onGridN(c._decorTextDialog.x) && onGridN(c._decorTextDialog.y);
c._decorTextDialog = null;
// a shape that is ALREADY off the grid is put on it by one drag: the mover
// snaps the resulting anchor, not the delta (which used to preserve the drift)
const sp = c._curSpaceCfg;
sp.decor = [...c._decorList, { id: 'dcOff', kind: 'rect',
x: 0.3013, y: 0.4017, w: 0.1, h: 0.05, color: '#889', width: 2 }];
c.requestUpdate(); await c.updateComplete;
const off = c._decorList.find((x) => x.id === 'dcOff');
c._decorMove = { id: 'dcOff', start: c._svgPoint(at(301.3, 401.7)),
orig: JSON.parse(JSON.stringify(off)), pid: 9, moved: false };
c._decorMoveUpdate(at(351.3, 451.7));
await c.updateComplete;
const off2 = c._decorList.find((x) => x.id === 'dcOff');
o.oneDragPutsADriftedShapeOnTheGrid = onGridN(off2.x) && onGridN(off2.y);
c._decorMove = null;
sp.decor = sp.decor.filter((x) => x.id !== 'dcOff' && x.id !== dsh.id);
// ---- 1e) an OPENING is WALL-bound: on its wall, whole steps along it ---
c._setMode('plan'); await c.updateComplete;
c._tool = 'opening';
const spm = c._spaceModel(c._space);
const wall = (() => {
for (const r of spm.rooms) {
const poly = r.poly || null;
if (!poly) continue;
for (let i = 0; i < poly.length; i++) {
const a = poly[i], b = poly[(i + 1) % poly.length];
if (Math.hypot(b[0] - a[0], b[1] - a[1]) > PITCH * 20) return [a, b];
}
}
return null;
})();
o.foundAWallToTestWith = !!wall;
if (wall) {
const [a, b] = wall;
const mid = [(a[0] + b[0]) / 2 + 0.37 * PITCH, (a[1] + b[1]) / 2 + 0.4];
c._openingClick(mid);
const d = c._openingDialog;
o.openingPlaced = !!d;
if (d) {
// exactly on the wall segment…
const t = ((d.x - a[0]) * (b[0] - a[0]) + (d.y - a[1]) * (b[1] - a[1]))
/ ((b[0] - a[0]) ** 2 + (b[1] - a[1]) ** 2);
const px = a[0] + t * (b[0] - a[0]), py = a[1] + t * (b[1] - a[1]);
o.openingSitsOnItsWall = Math.hypot(d.x - px, d.y - py) < 1e-6;
// …and at a whole number of steps along it (or at the wall's own centre,
// which is what the magnet is for and what the ruler shows)
const len = Math.hypot(b[0] - a[0], b[1] - a[1]);
const along = t * len;
o.openingIsQuantisedAlongTheWall =
Math.abs(along / PITCH - Math.round(along / PITCH)) < 1e-6
|| Math.abs(along - len / 2) < 1e-6;
}
c._openingDialog = null;
}
// ---- 1f) a room VERTEX drawn by hand ----------------------------------
c._tool = 'draw'; c._path = [];
c._markupClick(at(220 + OFF, 220 + OFF, 'click'));
o.roomVertexOnANode = c._path.length === 1 && onGridR(c._path[0][0]) && onGridR(c._path[0][1]);
c._path = []; c._tool = 'draw';
// ---- 2) «Выровнять всё по сетке» --------------------------------------
c._setMode('view'); await c.updateComplete;
// a deliberately detuned fixture: a third of a step off, everywhere
const D = 1 / GRID_N / 3;
const FIX = { spaces: [{
id: 'g1', title: 'Grid test', view_box: [0, 0, 1, 1],
rooms: [
{ id: 'r1', name: 'A', area: 'living_room',
poly: [[0.2 + D, 0.2], [0.5, 0.2 - D], [0.5 + D, 0.5], [0.2, 0.5]] },
{ id: 'r2', name: 'B', area: 'kitchen', x: 0.5, y: 0.2, w: 0.3 + D, h: 0.3 },
],
openings: [{ id: 'o1', type: 'window', x: 0.3 + D, y: 0.2 + D / 2, angle: 0, length: 0.06 }],
decor: [{ id: 'd1', kind: 'line', x1: 0.1 + D, y1: 0.7, x2: 0.9, y2: 0.7 - D }],
}], markers: [], settings: {} };
const FIXLAY = { d_light1: { s: 'g1', x: 0.3 + D, y: 0.3 }, rl_r1: { s: 'g1', x: 0.25 + D, y: 0.25 } };
c._serverCfg = JSON.parse(JSON.stringify(FIX));
c._layout = JSON.parse(JSON.stringify(FIXLAY));
c._modelCache = null; c._frame = null; c._space = 'g1';
c.requestUpdate(); await c.updateComplete;
c._openAlignDialog();
await c.updateComplete;
const dlg = c._alignDialog;
o.alignDialogOpens = !!dlg && !!sr().querySelector('.dialogwrap .alignmsg');
o.alignCountsWhatMoves = !!dlg && dlg.report.moved > 0 && dlg.report.moved <= dlg.report.total;
// half a step of 5 cm cells ≈ 2.5 cm; a third of a step is well under that
o.alignPromisesASmallShift = !!dlg && dlg.cm > 0 && dlg.cm < 3;
o.alignWarnsAboutNoUndo = (sr().querySelector('.dialogwrap .rhint')?.textContent || '').length > 20;
// capture what the network is told, then run it
const sent = [];
const base = c.hass.callWS;
c.hass = { ...c.hass, callWS: async (m) => { sent.push(m.type); return base(m); } };
await c._runAlignToGrid();
await c.updateComplete;
o.alignDialogClosed = c._alignDialog === null;
o.alignWroteConfigOnce = sent.filter((t) => t === 'houseplan/config/set').length === 1;
o.alignWroteLayout = sent.some((t) => t === 'houseplan/layout/update');
const g = c._serverCfg.spaces[0];
o.alignedRoomPoly = g.rooms[0].poly.every((p) => onGridN(p[0]) && onGridN(p[1]));
o.alignedRoomRect = onGridN(g.rooms[1].x) && onGridN(g.rooms[1].x + g.rooms[1].w);
o.alignedDecor = onGridN(g.decor[0].x1) && onGridN(g.decor[0].y1)
&& onGridN(g.decor[0].x2) && onGridN(g.decor[0].y2);
o.alignedLayout = onGridN(c._layout.d_light1.x) && onGridN(c._layout.rl_r1.x);
// the opening stayed ON the top wall of r1 rather than being rounded off it
const w0 = g.rooms[0].poly[0], w1 = g.rooms[0].poly[1];
const tt = ((g.openings[0].x - w0[0]) * (w1[0] - w0[0]) + (g.openings[0].y - w0[1]) * (w1[1] - w0[1]))
/ ((w1[0] - w0[0]) ** 2 + (w1[1] - w0[1]) ** 2);
o.alignedOpeningStaysOnItsWall = Math.hypot(
g.openings[0].x - (w0[0] + tt * (w1[0] - w0[0])),
g.openings[0].y - (w0[1] + tt * (w1[1] - w0[1]))) < 1e-9;
// ---- 2b) …and the second run has nothing to do ------------------------
c._openAlignDialog();
await c.updateComplete;
o.secondRunMovesNothing = c._alignDialog.report.moved === 0;
o.secondRunOffersNoButton = !sr().querySelector('.dialogwrap .btn.on');
c._alignDialog = null;
await c.updateComplete;
// ---- 2c) the promise is an UPPER BOUND, in EACH space's own scale -----
// AUD-158B1-01: one normalised maximum converted through the first space's
// cell size promised 2.5 cm for a vertex that moved 50 cm on the floor above.
const MULTI = { spaces: [
{ id: 'm1', title: 'Ground', cell_cm: 5, view_box: [0, 0, 1, 1],
rooms: [{ id: 'ma', name: 'A', area: 'living_room',
poly: [[0.1, 0.1], [0.3, 0.1], [0.3, 0.3], [0.1, 0.3]] }] },
{ id: 'm2', title: 'Attic', cell_cm: 100, view_box: [0, 0, 1, 1],
// the ONLY off-grid vertex on the plan, and it is on the 100 cm floor
rooms: [{ id: 'mb', name: 'B', area: 'kitchen',
poly: [[0.2 + 1 / GRID_N / 2, 0.2], [0.4, 0.2], [0.4, 0.4], [0.2, 0.4]] }] },
], markers: [], settings: {} };
c._serverCfg = JSON.parse(JSON.stringify(MULTI));
c._layout = {};
c._modelCache = null; c._frame = null; c._space = 'm1';
c.requestUpdate(); await c.updateComplete;
c._openAlignDialog(); await c.updateComplete;
const md = c._alignDialog;
o.alignPromiseUsesTheOwnScaleOfEachSpace = !!md && md.cm >= 50 && md.cm < 51;
o.alignPromiseNamesTheSpaceItBelongsTo = !!md && md.where === 'Attic'
&& (sr().querySelector('.dialogwrap .body')?.textContent || '').includes('Attic');
c._alignDialog = null; await c.updateComplete;
// ---- 2d) an angle-only opening fix is offerable (AUD-158B1-02) --------
// its centre is already on the wall; only the stored angle is wrong, and the
// batch used to report "nothing to move" while returning a different plan
const TURN = { spaces: [{ id: 'a1', title: 'Flat', cell_cm: 5, view_box: [0, 0, 1, 1],
rooms: [{ id: 'r1', name: 'A', area: 'living_room',
poly: [[0.2, 0.2], [0.5, 0.2], [0.5, 0.5], [0.2, 0.5]] }],
openings: [{ id: 'oa', type: 'window', x: 0.35, y: 0.2, angle: 90, length: 0.1 }],
}], markers: [], settings: {} };
c._serverCfg = JSON.parse(JSON.stringify(TURN));
c._layout = {};
c._modelCache = null; c._frame = null; c._space = 'a1';
c.requestUpdate(); await c.updateComplete;
c._openAlignDialog(); await c.updateComplete;
const ad = c._alignDialog;
o.angleOnlyOpeningCounts = !!ad && ad.report.moved === 1 && ad.report.rotated === 1;
o.angleOnlyOpeningOffersTheButton = !!sr().querySelector('.dialogwrap .btn.on');
await c._runAlignToGrid();
await c.updateComplete;
o.angleOnlyOpeningIsActuallyFixed = c._serverCfg.spaces[0].openings[0].angle === 0;
return o;
});
await finish(browser, checkAll(out));
+8 -2
View File
@@ -175,8 +175,14 @@ Object.assign(out, await page.evaluate(async () => {
await card.updateComplete;
const st = [...card.renderRoot.querySelectorAll('[style*="left"]')]
.find((el) => /%$/.test(el.style.left || '') && el.style.top);
// статичная карточка рендерит от полного квадрата 0..1000
const stVb = st && [parseFloat(st.style.left) * 10, parseFloat(st.style.top) * 10];
// docs/CANVAS.md §4: статичная карточка теперь кадрирует СОДЕРЖИМОЕ, как и
// полная, а не квадрат 0..1000 — координаты берём из её же viewBox
const svb = (card.renderRoot.querySelector('svg')?.getAttribute('viewBox') || '0 0 1000 1000')
.trim().split(/\s+/).map(Number);
const stVb = st && [
svb[0] + (parseFloat(st.style.left) / 100) * svb[2],
svb[1] + (parseFloat(st.style.top) / 100) * svb[3],
];
o.autoGridParity = !!fullVb && !!stVb
&& Math.abs(fullVb[0] - stVb[0]) < 6 && Math.abs(fullVb[1] - stVb[1]) < 6;
if (!o.autoGridParity) console.log('full', fullVb, 'static', stVb);
+359
View File
@@ -0,0 +1,359 @@
// Infinite canvas (docs/CANVAS.md): a plan drawn far past the old unit square
// renders whole, devices can be placed and saved out there, the opening view
// follows the content, one far stray neither breaks the view nor hides itself,
// zoom-out stops at 3x the content and the icons keep their proportion to it.
import { writeFileSync, mkdirSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
import { launch, check, checkAll, finish } from './serve.mjs';
const SHOTS = process.env.HP_SHOTS || '';
const { page, browser } = await launch({ width: 900, height: 820 }, 1);
const out = {};
const raf = () => page.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))));
// A hand-drawn plan whose rooms live at 1.5..3.0 — entirely outside the old
// canvas. Before the infinite canvas the -25%..125% envelope threw every one
// of these vertices away and the card framed empty space.
const FAR_CFG = {
spaces: [{
id: 'f1', title: 'Ground floor', view_box: [0, 0, 1, 1], plan_url: null, plan_aspect: null,
rooms: [
{ id: 'r1', name: 'Living room', area: 'living_room',
poly: [[1.5, 1.5], [2.2, 1.5], [2.2, 2.2], [1.5, 2.2]] },
{ id: 'r2', name: 'Kitchen', area: 'kitchen',
poly: [[2.2, 1.5], [3.0, 1.5], [3.0, 2.0], [2.2, 2.0]] },
{ id: 'r3', name: 'Bedroom', area: 'bedroom',
poly: [[2.2, 2.0], [3.0, 2.0], [3.0, 2.6], [2.2, 2.6]] },
{ id: 'r4', name: 'Hallway', area: 'hallway',
poly: [[1.5, 2.2], [2.2, 2.2], [2.2, 2.6], [1.5, 2.6]] },
],
openings: [{ id: 'o1', type: 'window', x: 1.8, y: 1.5, angle: 0, length: 0.15 }],
decor: [{ id: 'd1', kind: 'line', x1: 1.4, y1: 2.7, x2: 3.1, y2: 2.7, color: '#8899aa' }],
}, {
id: 'garden', title: 'Garden', view_box: [0, 0, 1, 1],
rooms: [{ id: 'g1', name: 'Garden', area: 'garden',
poly: [[0.03, 0.178], [0.97, 0.178], [0.97, 0.822], [0.03, 0.822]] }],
}],
markers: [], settings: {},
};
const FAR_LAYOUT = {
d_light1: { s: 'f1', x: 1.7, y: 1.7 }, d_lamp: { s: 'f1', x: 2.0, y: 1.9 },
d_tv: { s: 'f1', x: 1.6, y: 2.1 }, d_temp: { s: 'f1', x: 1.9, y: 1.6 },
d_kettle: { s: 'f1', x: 2.5, y: 1.7 }, d_leak: { s: 'f1', x: 2.9, y: 1.8 },
d_bedlight: { s: 'f1', x: 2.6, y: 2.3 }, d_window: { s: 'f1', x: 2.9, y: 2.5 },
d_lock: { s: 'f1', x: 1.6, y: 2.5 }, d_motion: { s: 'f1', x: 2.0, y: 2.4 },
};
const loadFar = (cfg, layout) => page.evaluate(async ([c2, l2]) => {
const c = window.__card;
c._serverCfg = JSON.parse(JSON.stringify(c2));
c._layout = JSON.parse(JSON.stringify(l2));
c._modelCache = null;
c._frame = null;
c._showFar = false;
c._space = 'f1';
c._maybeRebuildDevices?.();
c._defPos = c._defaultPositions();
c._view = null;
c._zoom = 1;
c.requestUpdate();
await c.updateComplete;
c._resetZoom();
await c.updateComplete;
}, [cfg, layout]);
await loadFar(FAR_CFG, FAR_LAYOUT);
await raf();
// ---- (b) the plan past the old square renders WHOLE ----------------------
Object.assign(out, await page.evaluate(() => {
const c = window.__card;
const o = {};
const b = c._baseVb();
// the content is 1500..3000 x 1500..2700 render units, plus decor/openings
o.frameHoldsFarPlan = b[0] < 1450 && b[0] + b[2] > 3050 && b[1] < 1450 && b[1] + b[3] > 2750;
o.frameIsNotTheOldSquare = b[0] > 1000; // nothing is framed at the origin any more
const v = c._viewOr(b);
o.viewShowsFarPlan = v.x <= 1500 && v.x + v.w >= 3000;
// every room polygon is inside the painted viewBox
const sr = c.shadowRoot || c.renderRoot;
const polys = [...sr.querySelectorAll('polygon.room')];
o.allRoomsDrawn = polys.length === 4;
const st = sr.querySelector('.stage').getBoundingClientRect();
o.roomsOnScreen = polys.every((p) => {
const r = p.getBoundingClientRect();
return r.width > 4 && r.height > 4 && r.left >= st.left - 2 && r.right <= st.right + 2
&& r.top >= st.top - 2 && r.bottom <= st.bottom + 2;
});
// and the markers landed on the plan, not in a corner of a phantom canvas
const devs = [...sr.querySelectorAll('.devlayer .dev')];
o.markersOnPlan = devs.length >= 10 && devs.every((d) => {
const r = d.getBoundingClientRect();
return r.left >= st.left - 2 && r.right <= st.right + 2 && r.top >= st.top - 2 && r.bottom <= st.bottom + 2;
});
return o;
}));
// ---- a device can be PLACED past the old square, and it saves -------------
const ws = await page.evaluate(async () => {
const c = window.__card;
const sent = [];
const base = c.hass.callWS;
c.hass = { ...c.hass, callWS: async (m) => { sent.push(m); return base(m); } };
// drop a virtual marker at 3.4 / 2.9 — a long way outside the old canvas
const far = { s: 'f1', x: 3.4, y: 2.9 };
c._layout = { ...c._layout, d_gate: far };
c._dirtyPos.add('d_gate');
await c._persistLayout?.();
// and a room extended even further out, saved through the config writer
const cfg = JSON.parse(JSON.stringify(c._serverCfg));
cfg.spaces[0].rooms.push({ id: 'r5', name: 'Workshop',
poly: [[3.1, 2.0], [3.8, 2.0], [3.8, 2.6], [3.1, 2.6]] });
c._serverCfg = cfg; c._modelCache = null; c._frame = null;
await c._saveConfig?.();
await new Promise((r) => setTimeout(r, 700));
return { sent: JSON.parse(JSON.stringify(sent)), layout: JSON.parse(JSON.stringify(c._layout)) };
});
out.farDeviceSaved = ws.layout.d_gate.x === 3.4 && ws.layout.d_gate.y === 2.9;
const cfgMsg = ws.sent.filter((m) => m.type === 'houseplan/config/set').pop();
const layMsg = ws.sent.filter((m) => m.type === 'houseplan/layout/update').pop();
out.wsCarriedFarGeometry = !!cfgMsg
&& JSON.stringify(cfgMsg.config).includes('3.8');
out.wsCarriedFarPosition = !!layMsg && JSON.stringify(layMsg).includes('3.4');
// the payload the card sends must pass the REAL backend schema (docs/CANVAS.md
// §3: ±5000). Skipped, not failed, where voluptuous is not installed.
out.backendAcceptsFarPayload = (() => {
if (!cfgMsg) return false;
const f = '/tmp/hp_infinite_payload.json';
writeFileSync(f, JSON.stringify({ config: cfgMsg.config, layout: (layMsg || {}).layout || ws.layout }));
try {
execFileSync('python3', ['-c', 'import voluptuous'], { stdio: 'ignore' });
} catch { console.log('(voluptuous absent — backend schema check skipped)'); return true; }
const code = [
'import json,sys',
'sys.path.insert(0, "custom_components/houseplan")',
'import validation as v',
`d=json.load(open(${JSON.stringify(f)}))`,
'v.CONFIG_SCHEMA(d["config"]); v.LAYOUT_SCHEMA(d["layout"])',
'print("VALID")',
].join('\n');
try {
return execFileSync('python3', ['-c', code], { encoding: 'utf8' }).includes('VALID');
} catch (e) { console.log('backend refused the payload:', String(e.stdout || e.message).slice(-400)); return false; }
})();
// ---- an OUTLIER: the view ignores it, the hint offers it ------------------
await loadFar({
...FAR_CFG,
spaces: FAR_CFG.spaces.map((s) => (s.id === 'f1' ? { ...s } : s)),
}, { ...FAR_LAYOUT, d_motion: { s: 'f1', x: 90, y: 90 } });
await raf();
Object.assign(out, await page.evaluate(() => {
const c = window.__card;
const o = {};
o.outlierReported = c._outliers === 1;
const b = c._baseVb();
o.outlierDoesNotCommandView = b[2] < 4000 && b[0] + b[2] < 5000; // still the house, not 90 canvases
o.outlierViewStillHoldsPlan = b[0] < 1500 && b[0] + b[2] > 3000;
const sr = c.shadowRoot || c.renderRoot;
const hint = sr.querySelector('.farhint');
o.hintShown = !!hint && !!hint.querySelector('button');
o.hintIsNotAModal = !sr.querySelector('.dialogwrap');
return o;
}));
// «Показать» / «Вписать всё» takes the stray in
Object.assign(out, await page.evaluate(async () => {
const c = window.__card;
const sr = c.shadowRoot || c.renderRoot;
sr.querySelector('.farhint button').click();
await c.updateComplete;
const o = {};
const v = c._viewOr(c._baseVb());
o.fitAllReachesTheStray = v.x + v.w > 90000 && v.y + v.h > 90000;
o.fitAllStillHoldsPlan = v.x <= 1500 && v.y <= 1500;
await c.updateComplete;
o.hintGoneAfterShow = !sr.querySelector('.farhint');
return o;
}));
// ---- zoom-out stops at 3x, zoom-in unchanged -----------------------------
await loadFar(FAR_CFG, FAR_LAYOUT);
await raf();
Object.assign(out, await page.evaluate(() => {
const c = window.__card;
const o = {};
c._resetZoom();
const fit = { ...c._viewOr(c._baseVb()) };
c._applyView(0.001);
o.zoomOutStopsAtThree = Math.abs(c._zoom - 1 / 3) < 1e-9
&& Math.abs(c._view.w - fit.w * 3) < fit.w * 0.02;
c._applyView(50);
o.zoomInUnchanged = c._zoom === 8;
c._resetZoom();
return o;
}));
// ---- pan has slack, and "home is that way" brings you back ---------------
Object.assign(out, await page.evaluate(async () => {
const c = window.__card;
const sr = c.shadowRoot || c.renderRoot;
const o = {};
c._resetZoom(); await c.updateComplete;
const fit = { ...c._view };
// pan most of a screen to the right: nothing pins the content over the scene
c._view = c._clampView({ ...fit, x: fit.x + fit.w * 0.9 }, fit);
o.panPastContentAllowed = c._view.x > fit.x + fit.w * 0.5;
// walk right off the plan: the frame is entirely outside the view now
c._view = { ...fit, x: fit.x + fit.w * 1.6 };
c.requestUpdate(); await c.updateComplete;
const arrow = sr.querySelector('.homearrow');
o.homeArrowAppears = !!arrow;
if (arrow) {
arrow.click();
await c.updateComplete;
o.homeArrowFitsBack = Math.abs(c._view.x - fit.x) < 2 && Math.abs(c._view.w - fit.w) < 2;
await c.updateComplete;
o.homeArrowGone = !sr.querySelector('.homearrow');
}
// and it is NOT there while the plan is on screen
c._resetZoom(); c.requestUpdate(); await c.updateComplete;
o.noHomeArrowWhenVisible = !sr.querySelector('.homearrow');
return o;
}));
// ---- icons scale WITH THE PLAN across zooms (docs/CANVAS.md §6) ----------
// The infinite canvas briefly made --icon-size a percentage of the viewport,
// so a marker kept its pixel size at every zoom. The owner looked at it and
// asked for the original contract back (2026-08-03): an icon is a percentage
// of the PLAN, it grows when you zoom in and shrinks when you zoom out, like
// everything else drawn on the plan. What the infinite canvas contributes is
// only the base unit the percentage is taken OF: `iconUnit` instead of the
// stored `vb.w`, checked right below on this very plan — which is drawn
// entirely outside the old square.
const farIcon = await page.evaluate(async () => {
const c = window.__card;
const sr = c.shadowRoot || c.renderRoot;
// the badge is --dev-size PLUS a 1px border on each side, and the border
// is not part of the scaled geometry (same correction as smoke_icon_scale)
const size = () => {
const el = sr.querySelector('.devlayer .dev');
return Math.round((el.getBoundingClientRect().width - 2) * 100) / 100;
};
const o = {};
c._resetZoom(); await c.updateComplete;
const at1 = size();
c._applyView(4); await c.updateComplete;
const at4 = size();
c._applyView(1 / 3); await c.updateComplete;
const atOut = size();
c._resetZoom(); await c.updateComplete;
o.iconGrowsWithZoomIn = at1 > 4 && Math.abs(at4 / at1 - 4) < 0.05;
o.iconShrinksWithZoomOut = Math.abs(atOut / at1 - 1 / 3) < 0.02;
if (!o.iconGrowsWithZoomIn || !o.iconShrinksWithZoomOut) console.log('icon px', at1, at4, atOut);
return { o, at1 };
});
Object.assign(out, farIcon.o);
// ---- an OLD plan (small, with a view_box) opens exactly as before ---------
Object.assign(out, await page.evaluate(async () => {
const c = window.__card;
const o = {};
const cfg = {
spaces: [{ id: 'f1', title: 'Old', view_box: [0, 0, 1, 1], plan_url: null, plan_aspect: null,
rooms: [{ id: 'r1', name: 'Living', area: 'living_room',
poly: [[0.04, 0.14], [0.55, 0.14], [0.55, 0.58], [0.04, 0.58]] },
{ id: 'r2', name: 'Kitchen', area: 'kitchen',
poly: [[0.55, 0.14], [0.96, 0.14], [0.96, 0.46], [0.55, 0.46]] }] }],
markers: [], settings: {},
};
c._serverCfg = cfg; c._layout = { d_light1: { s: 'f1', x: 0.22, y: 0.22 } };
c._modelCache = null; c._frame = null; c._showFar = false; c._view = null;
c._maybeRebuildDevices?.();
c._defPos = c._defaultPositions(); // the auto grid follows the new rooms
c.requestUpdate(); await c.updateComplete;
c._resetZoom(); await c.updateComplete;
const b = c._baseVb();
// identical to what contentBounds produced before: the content bbox
// (40..960 x 140..580) plus 5% of the longer side (920 -> 46)
o.legacyFrameUnchanged = Math.abs(b[0] - -6) < 1 && Math.abs(b[1] - 94) < 1
&& Math.abs(b[2] - 1012) < 1 && Math.abs(b[3] - 532) < 1;
o.legacyNoHint = c._outliers === 0;
// an EMPTY space falls back to the stored view_box hint (§4)
c._serverCfg = { spaces: [{ id: 'f1', title: 'E', view_box: [0.1, 0.2, 0.5, 0.4], rooms: [] }],
markers: [], settings: {} };
// a space with NOTHING in it at all — not even a marker parked in it
c._layout = {}; c._modelCache = null; c._frame = null;
c._devices = [];
c.requestUpdate(); await c.updateComplete;
const e = c._baseVb();
o.emptySpaceUsesViewBoxHint = JSON.stringify(e.map(Math.round)) === '[100,200,500,400]';
return o;
}));
// ---- and the icon does not DEGENERATE on the runaway plan ----------------
// The catch of "a percentage of the plan" on an unbounded canvas: keep the
// old fixed numerator (vb.w = NORM_W) and a plan drawn 2.3 canvases wide
// divides it by a 2.5-canvas-wide frame — the marker shrinks by that factor
// and keeps shrinking the further out the plan is drawn. `iconUnit` grows
// with the plan, so the marker on the far plan measured above is the same
// number of pixels as on this ordinary 0..1 one.
out.farIconNotDegenerate = await page.evaluate(async (farPx) => {
const c = window.__card;
const sr = c.shadowRoot || c.renderRoot;
// the small plan from the block above, with its devices back
c._serverCfg = { spaces: [{ id: 'f1', title: 'Old', view_box: [0, 0, 1, 1], plan_url: null, plan_aspect: null,
rooms: [{ id: 'r1', name: 'Living', area: 'living_room',
poly: [[0.04, 0.14], [0.55, 0.14], [0.55, 0.58], [0.04, 0.58]] },
{ id: 'r2', name: 'Kitchen', area: 'kitchen',
poly: [[0.55, 0.14], [0.96, 0.14], [0.96, 0.46], [0.55, 0.46]] }] }],
markers: [], settings: {} };
c._layout = { d_light1: { s: 'f1', x: 0.22, y: 0.22 } };
c._modelCache = null; c._frame = null; c._showFar = false; c._view = null;
c._maybeRebuildDevices?.();
c._defPos = c._defaultPositions();
c.requestUpdate(); await c.updateComplete;
c._resetZoom(); await c.updateComplete;
const el = sr.querySelector('.devlayer .dev');
const px = el.getBoundingClientRect().width - 2;
if (!(px > 4 && Math.abs(px / farPx - 1) < 0.3)) console.log('icon px near/far', px, farPx);
return px > 4 && Math.abs(px / farPx - 1) < 0.3;
}, farIcon.at1);
// ---- screenshots ---------------------------------------------------------
if (SHOTS) {
mkdirSync(SHOTS, { recursive: true });
await loadFar(FAR_CFG, FAR_LAYOUT);
await raf();
await page.screenshot({ path: `${SHOTS}/canvas_far_plan.png` });
// fit-all with a stray far away
await loadFar(FAR_CFG, { ...FAR_LAYOUT, d_motion: { s: 'f1', x: 90, y: 90 } });
await raf();
await page.screenshot({ path: `${SHOTS}/canvas_far_hint.png` });
await page.evaluate(async () => {
const c = window.__card;
(c.shadowRoot || c.renderRoot).querySelector('.farhint button').click();
await c.updateComplete;
});
await raf();
await page.screenshot({ path: `${SHOTS}/canvas_fit_all.png` });
// adaptive grid, zoomed far out in the plan editor
await loadFar(FAR_CFG, FAR_LAYOUT);
await page.evaluate(async () => {
const c = window.__card;
c._setMode('plan'); await c.updateComplete;
c._resetZoom(); c._applyView(1 / 3); await c.updateComplete;
});
await raf();
await page.screenshot({ path: `${SHOTS}/canvas_grid_far.png` });
await page.evaluate(async () => {
const c = window.__card;
c._resetZoom(); c._applyView(4); await c.updateComplete;
});
await raf();
await page.screenshot({ path: `${SHOTS}/canvas_grid_near.png` });
await page.evaluate(async () => { window.__card._setMode('view'); });
}
await finish(browser, checkAll(out));
+138
View File
@@ -0,0 +1,138 @@
// A kiosk gesture is classified ONCE, and the release obeys that decision —
// audit DEV-1DA1-02 (P2).
//
// `_stagePointerMove` locks the gesture on the first movement past 8 px
// (`_panLock`: 'swipe' if it is horizontal enough inside the swipe zone, 'pan'
// otherwise) and the plan then follows the finger. But `_stagePointerUp` used
// to ignore that lock and ask `swipeTarget()` again, from the raw start→end
// vector alone. A CURVED gesture — a small vertical lead-in that locks 'pan',
// then a long horizontal sweep — therefore panned under the finger and still
// switched the floor on release. On a wall tablet that is the worst kind of
// surprise: you watch the plan drag along and land on another storey.
//
// The lock is now final: with `_panLock === 'pan'` the floor never changes, no
// matter what the overall vector looks like. The two straight gestures keep
// their old behaviour, and so does the motionless double tap (no movement,
// no lock — the swipe path is never even reached by it).
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const out = await page.evaluate(async () => {
const o = {};
const c0 = window.__card;
const k = document.createElement('houseplan-card');
k.setConfig({ type: 'custom:houseplan-card', kiosk: true, cycle: 0 });
k.hass = c0.hass;
document.body.appendChild(k);
k.style.cssText = 'position:fixed;left:0;top:0;width:900px;height:700px;z-index:99';
await new Promise((r) => setTimeout(r, 500));
k.hass = { ...c0.hass };
await k.updateComplete;
const sr = k.shadowRoot || k.renderRoot;
const stage = sr.querySelector('.stage');
const fire = (type, id, x, y) => stage.dispatchEvent(new PointerEvent(type, {
bubbles: true, composed: true, cancelable: true, pointerId: id, clientX: x, clientY: y,
}));
o.kioskHasSeveralSpaces = k._model.length > 1;
const home = async () => {
const vb = k._baseVb();
k._applyView(1, vb[0] + vb[2] / 2, vb[1] + vb[3] / 2);
await k.updateComplete;
};
/**
* Play one trajectory and report what the gesture decided and what it did.
* `pts` are the moves; the finger lifts at the last one.
*/
const play = async (id, from, pts) => {
await home();
const s0 = k._space;
const v0 = { ...k._viewOr(k._baseVb()) };
fire('pointerdown', id, from[0], from[1]);
let lockAfterLeadIn = null;
let moved = false;
pts.forEach((p, i) => {
fire('pointermove', id, p[0], p[1]);
if (i === 0) lockAfterLeadIn = k._panLock;
if (Math.abs(k._viewOr(k._baseVb()).x - v0.x) > 1
|| Math.abs(k._viewOr(k._baseVb()).y - v0.y) > 1) moved = true;
});
const lockBeforeRelease = k._panLock;
const last = pts[pts.length - 1];
fire('pointerup', id, last[0], last[1]);
await k.updateComplete;
return { s0, s1: k._space, lockAfterLeadIn, lockBeforeRelease, moved };
};
// ---- 1. the auditor's curved pan: vertical lead-in, horizontal ending --
// (500,300) → (502,312) locks 'pan' → curve left to (350,304) → release.
// dx = -150, dy = +4: swipeTarget() would happily call that a swipe.
const curvedPan = await play(51, [500, 300], [[502, 312], [460, 310], [400, 306], [350, 304]]);
o.curvedPanLocksPan = curvedPan.lockAfterLeadIn === 'pan';
o.curvedPanKeepsTheLock = curvedPan.lockBeforeRelease === 'pan';
o.curvedPanActuallyPans = curvedPan.moved === true;
o.curvedPanKeepsTheFloor = curvedPan.s1 === curvedPan.s0;
// the same trajectory the other way round — a swipe to the right would have
// been the previous floor, so the bug is symmetric and so is the fix
const curvedPanRight = await play(52, [400, 300], [[402, 312], [460, 308], [520, 305], [560, 304]]);
o.curvedPanRightLocksPan = curvedPanRight.lockAfterLeadIn === 'pan';
o.curvedPanRightKeepsTheFloor = curvedPanRight.s1 === curvedPanRight.s0;
// a long diagonal that ends up dominated by x, but started as a pan
const diagonalPan = await play(53, [500, 300], [[496, 316], [420, 340], [330, 350], [260, 352]]);
o.diagonalPanLocksPan = diagonalPan.lockAfterLeadIn === 'pan';
o.diagonalPanKeepsTheFloor = diagonalPan.s1 === diagonalPan.s0;
// ---- 2. a gesture locked as a SWIPE keeps its own semantics ------------
// horizontal lead-in locks 'swipe'; the plan must not slide under it, even
// when the trajectory then bends vertically and the final vector no longer
// qualifies — the floor simply stays, and nothing pans
const curvedSwipe = await play(54, [600, 300], [[540, 302], [536, 380], [545, 500]]);
o.curvedSwipeLocksSwipe = curvedSwipe.lockAfterLeadIn === 'swipe';
o.curvedSwipeNeverPans = curvedSwipe.moved === false;
o.curvedSwipeThatDiesChangesNothing = curvedSwipe.s1 === curvedSwipe.s0;
// a swipe that bends but still ends as a swipe does switch the floor
const bentSwipe = await play(55, [600, 300], [[540, 302], [470, 330], [420, 340]]);
o.bentSwipeLocksSwipe = bentSwipe.lockAfterLeadIn === 'swipe';
o.bentSwipeNeverPans = bentSwipe.moved === false;
o.bentSwipeStillSwitches = bentSwipe.s1 !== bentSwipe.s0;
// ---- 3. the straight gestures are exactly as they were -----------------
const straightSwipe = await play(56, [600, 300], [[540, 302], [480, 305], [450, 305]]);
o.straightSwipeSwitches = straightSwipe.s1 !== straightSwipe.s0;
o.straightSwipeDoesNotPan = straightSwipe.moved === false;
const straightPan = await play(57, [450, 200], [[452, 260], [454, 330]]);
o.straightPanPans = straightPan.moved === true;
o.straightPanKeepsTheFloor = straightPan.s1 === straightPan.s0;
// ---- 4. a motionless double tap still resets the zoom ------------------
// no movement means no lock at all, so nothing above can reach this path
await home();
k._applyView(2);
await k.updateComplete;
k._lastTap = Date.now() - 100;
fire('pointerdown', 58, 500, 300);
fire('pointerup', 58, 501, 300);
await k.updateComplete;
o.doubleTapStillResetsZoom = k._zoom === 1;
o.doubleTapLeavesNoLock = k._panLock === null;
// ---- 5. zoomed in there is no swipe zone, and the lock says so ---------
k._applyView(2);
await k.updateComplete;
const zoomed = await (async () => {
const s0 = k._space;
fire('pointerdown', 59, 600, 300);
fire('pointermove', 59, 540, 302);
const lock = k._panLock;
fire('pointermove', 59, 480, 305);
fire('pointerup', 59, 480, 305);
await k.updateComplete;
return { lock, same: k._space === s0 };
})();
o.zoomedHorizontalLocksPan = zoomed.lock === 'pan';
o.zoomedHorizontalKeepsTheFloor = zoomed.same;
k.remove();
return o;
});
checkAll(out);
await finish(browser, out);
+104
View File
@@ -169,4 +169,108 @@ check('shared_wall_badges_equal', await badges(), ['1.44 m', '1.44 m']);
await page.mouse.up();
await settle();
// ---------- PLACING a new opening: the same rulers (owner 2026-08-03) -------
// Moving along a wall with the Opening tool must show exactly what dragging an
// existing opening shows: a badge on each shoulder of the WOULD-BE opening, a
// perpendicular tick + magnet at the wall centre, Shift opting out — and the
// whole lot vanishing the moment the opening is placed. Default length is
// 90 cm = 75 units, so the shoulders are measured from the edges c +- 37.5.
await page.evaluate(() => {
const c = window.__card;
const sp = c._serverCfg.spaces.find((s) => s.id === 'f1');
sp.rooms = [
{ id: 'r1', name: 'Living room', poly: [[0.04, 0.14], [0.55, 0.14], [0.55, 0.58], [0.04, 0.58]] },
{ id: 'r2', name: 'Kitchen', poly: [[0.55, 0.14], [0.96, 0.14], [0.96, 0.46], [0.55, 0.46]] },
];
sp.openings = [];
c._setMode('plan'); c._tool = 'opening';
c._cfgEpoch++; c.requestUpdate();
return c.updateComplete && true;
});
await settle();
const ghosts = () => page.evaluate(() => window.__card.renderRoot.querySelectorAll('.opghost').length);
// hover off-centre on r1's top wall (own edge 40..550, y = 140)
{
const [hx, hy] = await screenPt(460, 141);
await page.mouse.move(hx, hy, { steps: 3 });
await settle();
check('place_ghost_shown', await ghosts(), 1);
check('place_two_badges', (await badges()).length, 2);
// edges 422.5 / 497.5 -> 382.5 u (4.59 m) left, 52.5 u (0.63 m) right
const [a, b] = await nums();
check('place_badge_right_0_63', near(a, 0.63, 0.05));
check('place_badge_left_4_59', near(b, 4.59, 0.05));
check('place_no_tick_off_center', await tick(), 0);
}
// live update on the way — the badges follow the cursor
{
const [hx, hy] = await screenPt(200, 141);
await page.mouse.move(hx, hy, { steps: 3 });
await settle();
// edges 162.5 / 237.5 -> 122.5 u (1.47 m) left, 312.5 u (3.75 m) right
const [a, b] = await nums();
check('place_badge_updates_1_47', near(a, 1.47, 0.05));
check('place_badge_updates_3_75', near(b, 3.75, 0.05));
}
// near the wall centre (40+550)/2 = 295: tick appears and the magnet bites
{
const [hx, hy] = await screenPt(293.7, 141);
await page.mouse.move(hx, hy, { steps: 3 });
await settle();
check('place_tick_at_center', await tick(), 1);
// equal shoulders after the magnet: (510 - 75) / 2 = 217.5 u -> 2.61 m
check('place_badges_equal_at_center', await badges(), ['2.61 m', '2.61 m']);
const g = await page.evaluate(() => {
const el = window.__card.renderRoot.querySelector('.opcentertick');
return el && { x1: +el.getAttribute('x1'), x2: +el.getAttribute('x2'),
y1: +el.getAttribute('y1'), y2: +el.getAttribute('y2') };
});
check('place_tick_perpendicular', !!g && near(g.x1, 295, 1e-6) && near(g.x2, 295, 1e-6)
&& near(g.y2 - g.y1, 30, 1e-6));
// the ghost itself already sits on the magnetised centre
const gh = await page.evaluate(() => {
const el = window.__card.renderRoot.querySelector('.opghost');
return el && (+el.getAttribute('x1') + +el.getAttribute('x2')) / 2;
});
check('place_ghost_magnetised', near(gh, 295, 1e-6));
}
// Shift opts out of the magnet, the badges stay
{
await page.keyboard.down('Shift');
const [hx, hy] = await screenPt(293.7, 141);
await page.mouse.move(hx, hy, { steps: 3 });
await settle();
check('place_no_tick_with_shift', await tick(), 0);
check('place_badges_with_shift', (await badges()).length, 2);
const gh = await page.evaluate(() => {
const el = window.__card.renderRoot.querySelector('.opghost');
return el && (+el.getAttribute('x1') + +el.getAttribute('x2')) / 2;
});
check('place_ghost_free_with_shift', !!gh && !near(gh, 295, 1e-6) && near(gh, 293.7, 1));
await page.keyboard.up('Shift');
}
// the click places it AT THE MAGNET and clears every hint
{
const [hx, hy] = await screenPt(293.7, 141);
await page.mouse.move(hx, hy, { steps: 3 });
await settle();
await page.mouse.click(hx, hy);
await settle();
check('place_dialog_opened', await page.evaluate(() => !!window.__card._openingDialog), true);
check('place_dialog_x_magnetised', near(await page.evaluate(() => window.__card._openingDialog.x), 295, 1e-6));
check('place_badges_gone', (await badges()).length, 0);
check('place_tick_gone', await tick(), 0);
check('place_ghost_gone', await ghosts(), 0);
// …and confirming the dialog writes the opening at the magnetised point
await page.evaluate(() => { const c = window.__card; c._saveOpening(); return c.updateComplete && true; });
await settle();
check('place_committed_x_center', near(await page.evaluate(() =>
window.__card._serverCfg.spaces.find((s) => s.id === 'f1').openings[0].x), 0.295, 1e-6));
}
await finish(browser, { done: true });
+253
View File
@@ -0,0 +1,253 @@
// Panning at ANY zoom (owner's report 2026-08-04: «добавь возможность таскать
// план при любом масштабе, а не только при более 100%»).
//
// Until this fix `_stagePointerMove` moved the view only while `_zoom > 1`:
// the rule was inherited from the old bounded canvas, where a plan smaller
// than the scene had nowhere to go. The infinite canvas removed that edge —
// there is always somewhere to go, and an editor extending a plan outwards
// needs exactly that. So a drag on empty scene now pans at 100 %, at 50 % and
// at 800 %, in view mode and in every editor; how far you may walk is decided
// by `_clampView` (PAN_SLACK: about a screen past the content) and not by the
// zoom. The tools keep the pointer they already owned — a resize handle, a
// device badge, an opening — and the kiosk keeps its floor swipe.
import { launch, check, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const out = {};
// ---- a drag on empty scene pans, at every zoom and in every mode ---------
Object.assign(out, await page.evaluate(async () => {
const c = window.__card;
const o = {};
const pd = (id, x, y) => c._stagePointerDown({ pointerId: id, clientX: x, clientY: y, target: c._stageEl, preventDefault() {} });
const pm = (id, x, y) => c._stagePointerMove({ pointerId: id, clientX: x, clientY: y });
const pu = (id, x, y) => c._stagePointerUp({ pointerId: id, clientX: x, clientY: y });
let pid = 100;
/** Drag 80x60 px across the middle of the scene; did the view follow? */
const drag = async (zoom) => {
// always start from the plan in the middle: the clamp is a real limit, and
// a dozen drags in a row would otherwise walk into it
const vb = c._baseVb();
c._applyView(zoom, vb[0] + vb[2] / 2, vb[1] + vb[3] / 2);
await c.updateComplete;
const v0 = { ...c._viewOr(c._baseVb()) };
const id = ++pid;
pd(id, 300, 300);
pm(id, 340, 330);
pm(id, 380, 360);
const v1 = { ...c._viewOr(c._baseVb()) };
pu(id, 380, 360);
// dragging down-right pulls the viewport up-left, and the zoom is untouched
return Math.abs(v1.x - v0.x) > 1 && Math.abs(v1.y - v0.y) > 1
&& v1.x < v0.x && v1.y < v0.y && Math.abs(c._zoom - zoom) < 1e-9;
};
const inMode = async (name, setup) => {
await setup();
await c.updateComplete;
o['pan_' + name + '_at100'] = await drag(1);
o['pan_' + name + '_at50'] = await drag(0.5);
o['pan_' + name + '_at33'] = await drag(1 / 3);
};
await inMode('view', async () => { c._setMode('view'); });
for (const tool of ['draw', 'merge', 'split', 'resize', 'opening', 'openwall', 'delroom']) {
await inMode('plan_' + tool, async () => { c._setMode('plan'); c._tool = tool; });
}
await inMode('devices', async () => { c._setMode('devices'); });
await inMode('decor', async () => { c._setMode('decor'); c._decorTool = 'select'; });
c._setMode('view');
c._applyView(1);
await c.updateComplete;
return o;
}));
// ---- 400 % still pans (the behaviour that already worked) ----------------
out.panStillWorksZoomedIn = await page.evaluate(async () => {
const c = window.__card;
c._setMode('view'); c._applyView(4); await c.updateComplete;
const v0 = { ...c._view };
c._stagePointerDown({ pointerId: 7, clientX: 300, clientY: 300, target: c._stageEl, preventDefault() {} });
c._stagePointerMove({ pointerId: 7, clientX: 380, clientY: 360 });
const moved = Math.abs(c._view.x - v0.x) > 1;
c._stagePointerUp({ pointerId: 7, clientX: 380, clientY: 360 });
c._applyView(1);
return moved;
});
// ---- the clamp: you may walk about a screen off, never further ------------
Object.assign(out, await page.evaluate(async () => {
const c = window.__card;
const sr = c.shadowRoot || c.renderRoot;
const o = {};
c._setMode('view');
const vb = c._baseVb();
c._applyView(1, vb[0] + vb[2] / 2, vb[1] + vb[3] / 2); // at 100 % the view IS fitView
await c.updateComplete;
const fit = { ...c._view };
c._stagePointerDown({ pointerId: 8, clientX: 700, clientY: 400, target: c._stageEl, preventDefault() {} });
c._stagePointerMove({ pointerId: 8, clientX: -40000, clientY: 400 }); // shove it into the void
const far = { ...c._view };
c._stagePointerUp({ pointerId: 8, clientX: -40000, clientY: 400 });
o.panLeavesTheContent = far.x > fit.x + fit.w * 0.5;
o.panStopsAtTheSlack = far.x <= fit.x + fit.w + 1;
c.requestUpdate(); await c.updateComplete;
o.homeArrowOffersTheWayBack = !!sr.querySelector('.homearrow');
c._applyView(1, vb[0] + vb[2] / 2, vb[1] + vb[3] / 2);
c.requestUpdate(); await c.updateComplete;
// the same at 50 %: the screen holds more, the walk is still finite
const fit2 = { ...c._view };
c._applyView(0.5, vb[0] + vb[2] / 2, vb[1] + vb[3] / 2); await c.updateComplete;
c._stagePointerDown({ pointerId: 9, clientX: 700, clientY: 400, target: c._stageEl, preventDefault() {} });
c._stagePointerMove({ pointerId: 9, clientX: -40000, clientY: 400 });
const far2 = { ...c._view };
c._stagePointerUp({ pointerId: 9, clientX: -40000, clientY: 400 });
o.panStopsAtTheSlackZoomedOut = far2.x <= fit2.x + fit2.w + 1 && far2.x > fit2.x;
c._applyView(1, vb[0] + vb[2] / 2, vb[1] + vb[3] / 2); await c.updateComplete;
return o;
}));
// ---- the tools still own their pointer ------------------------------------
const ev = (type, id, x, y) => ({ type, id, x, y });
Object.assign(out, await page.evaluate(async () => {
const c = window.__card;
const sr = c.shadowRoot || c.renderRoot;
const o = {};
const fire = (el, type, id, x, y) => el.dispatchEvent(new PointerEvent(type, {
bubbles: true, composed: true, cancelable: true, pointerId: id, clientX: x, clientY: y,
}));
// a) resize handle: the wall moves, the view does not
c._setMode('plan'); c._tool = 'resize'; c._applyView(1); await c.updateComplete;
const h = sr.querySelector('.rszhandle');
o.resizeHandlePresent = !!h;
if (h) {
const v0 = { ...c._viewOr(c._baseVb()) };
const r = h.getBoundingClientRect();
fire(h, 'pointerdown', 21, r.left + r.width / 2, r.top + r.height / 2);
o.resizeHandleTakesThePointer = !!c._rszDrag && c._panStart === null;
fire(h, 'pointermove', 21, r.left + r.width / 2 + 40, r.top + r.height / 2 + 40);
const v1 = { ...c._viewOr(c._baseVb()) };
o.resizeHandleDoesNotPan = Math.abs(v1.x - v0.x) < 0.5 && Math.abs(v1.y - v0.y) < 0.5;
o.resizeHandleResizes = !!c._rszDrag && c._rszDrag.moved === true;
c._rszCancelDrag?.();
fire(h, 'pointerup', 21, r.left + r.width / 2 + 40, r.top + r.height / 2 + 40);
}
c._rszSel = null;
// b) a device badge in the devices editor drags the DEVICE
c._setMode('devices'); c._applyView(1); await c.updateComplete;
const dev = sr.querySelector('.devlayer .dev');
o.devicePresent = !!dev;
if (dev) {
const v0 = { ...c._viewOr(c._baseVb()) };
const r = dev.getBoundingClientRect();
fire(dev, 'pointerdown', 22, r.left + r.width / 2, r.top + r.height / 2);
o.deviceTakesThePointer = !!c._drag && c._panStart === null;
fire(dev, 'pointermove', 22, r.left + r.width / 2 + 40, r.top + r.height / 2 + 30);
c._stagePointerMove({ pointerId: 22, clientX: r.left + 60, clientY: r.top + 50 });
const v1 = { ...c._viewOr(c._baseVb()) };
o.deviceDragDoesNotPan = Math.abs(v1.x - v0.x) < 0.5 && Math.abs(v1.y - v0.y) < 0.5;
o.deviceActuallyMoved = !!c._drag && c._drag.moved === true;
fire(dev, 'pointerup', 22, r.left + r.width / 2 + 40, r.top + r.height / 2 + 30);
// _pointerUp releases _drag on the next macrotask, and _stagePointerDown
// refuses to start a gesture while a device drag is live
await new Promise((r2) => setTimeout(r2, 20));
o.deviceDragReleased = c._drag === null;
await c.updateComplete;
}
// c) an opening drags along its wall, the view stays put
const cfg = JSON.parse(JSON.stringify(c._serverCfg));
cfg.spaces[0].openings = [{ id: 'o_pan', type: 'window', x: 0.3, y: 0.14, angle: 0, length: 0.1 }];
c._serverCfg = cfg; c._modelCache = null; c._frame = null;
c._setMode('plan'); c._tool = 'draw'; c._applyView(1);
c.requestUpdate(); await c.updateComplete;
const op = sr.querySelector('.op-hit');
o.openingPresent = !!op;
if (op) {
const v0 = { ...c._viewOr(c._baseVb()) };
const r = op.getBoundingClientRect();
fire(op, 'pointerdown', 23, r.left + r.width / 2, r.top + r.height / 2);
o.openingTakesThePointer = !!c._opDrag && c._panStart === null;
fire(op, 'pointermove', 23, r.left + r.width / 2 + 50, r.top + r.height / 2);
const v1 = { ...c._viewOr(c._baseVb()) };
o.openingDragDoesNotPan = Math.abs(v1.x - v0.x) < 0.5 && Math.abs(v1.y - v0.y) < 0.5;
fire(op, 'pointerup', 23, r.left + r.width / 2 + 50, r.top + r.height / 2);
await c.updateComplete;
}
// d) a drawing tool is still a drawing tool: two fingers pinch, they do not pan
c._setMode('plan'); c._tool = 'draw'; c._applyView(1); await c.updateComplete;
const z0 = c._zoom;
c._stagePointerDown({ pointerId: 31, clientX: 300, clientY: 300, target: c._stageEl, preventDefault() {} });
c._stagePointerDown({ pointerId: 32, clientX: 400, clientY: 300, target: c._stageEl, preventDefault() {} });
c._stagePointerMove({ pointerId: 31, clientX: 250, clientY: 300 });
c._stagePointerMove({ pointerId: 32, clientX: 450, clientY: 300 });
o.pinchStillZooms = c._zoom > z0 * 1.5;
c._stagePointerUp({ pointerId: 31, clientX: 250, clientY: 300 });
c._stagePointerUp({ pointerId: 32, clientX: 450, clientY: 300 });
c._path = [];
c._setMode('view'); c._applyView(1); await c.updateComplete;
return o;
}));
// ---- the kiosk keeps its floor swipe ---------------------------------------
Object.assign(out, await page.evaluate(async () => {
const c0 = window.__card;
const o = {};
const k = document.createElement('houseplan-card');
k.setConfig({ type: 'custom:houseplan-card', kiosk: true, cycle: 0 });
k.hass = c0.hass;
document.body.appendChild(k);
k.style.cssText = 'position:fixed;left:0;top:0;width:900px;height:700px;z-index:99';
await new Promise((r) => setTimeout(r, 500));
k.hass = { ...c0.hass };
await k.updateComplete;
const sr = k.shadowRoot || k.renderRoot;
const stage = sr.querySelector('.stage');
const fire = (type, id, x, y) => stage.dispatchEvent(new PointerEvent(type, {
bubbles: true, composed: true, cancelable: true, pointerId: id, clientX: x, clientY: y,
}));
k._applyView(1); await k.updateComplete;
const s0 = k._space;
const v0 = { ...k._viewOr(k._baseVb()) };
// a REAL swipe: down, several moves, up — the moves are what used to be
// impossible to add to smoke_kiosk without hijacking the gesture
fire('pointerdown', 41, 600, 300);
fire('pointermove', 41, 540, 302);
fire('pointermove', 41, 480, 305);
fire('pointermove', 41, 450, 305);
const midPan = Math.abs(k._viewOr(k._baseVb()).x - v0.x);
fire('pointerup', 41, 450, 305);
await k.updateComplete;
o.kioskSwipeStillSwitchesFloors = k._space !== s0;
o.kioskHorizontalDragIsNotAPan = midPan < 1;
// …and the plan is not left hanging half-panned after the switch
o.kioskViewCenteredAfterSwipe = Math.abs(k._viewOr(k._baseVb()).x - v0.x) < 2000;
// a VERTICAL drag is nobody's swipe, so it pans like everywhere else
const s1 = k._space;
const v1 = { ...k._viewOr(k._baseVb()) };
fire('pointerdown', 42, 450, 200);
fire('pointermove', 42, 452, 260);
fire('pointermove', 42, 454, 330);
const panned = Math.abs(k._viewOr(k._baseVb()).y - v1.y) > 1;
fire('pointerup', 42, 454, 330);
await k.updateComplete;
o.kioskVerticalDragPans = panned;
o.kioskVerticalDragKeepsFloor = k._space === s1;
// zoomed in, a horizontal drag pans (no swipe up there — unchanged rule)
k._applyView(2); await k.updateComplete;
const v2 = { ...k._view };
const s2 = k._space;
fire('pointerdown', 43, 600, 300);
fire('pointermove', 43, 540, 302);
fire('pointermove', 43, 480, 305);
const pannedZoomed = Math.abs(k._view.x - v2.x) > 1;
fire('pointerup', 43, 480, 305);
await k.updateComplete;
o.kioskZoomedHorizontalPans = pannedZoomed;
o.kioskZoomedDoesNotSwipe = k._space === s2;
k.remove();
return o;
}));
await finish(browser, checkAll(out));
+2
View File
@@ -13,6 +13,7 @@ const { page, browser } = await launch({ width: 820, height: 760 });
// ---------- phase 1: normal mode, HA panel lands late ----------
const res = await page.evaluate(async () => {
const out = {};
customElements.get('houseplan-card')?._warmBootReset?.(); // DEV-B703: this scenario simulates a COLD first open — forget the page's warm re-mount memo
const spacer = document.createElement('div');
spacer.style.cssText = 'height:0px';
const c = document.createElement('houseplan-card');
@@ -119,6 +120,7 @@ check('kioskPlanNeverHidden', kiosk.kioskPlanNeverHidden);
// ---------- phase 3: prefers-reduced-motion → the house does not pulse ----------
await page.emulateMedia({ reducedMotion: 'reduce' });
const reduced = await page.evaluate(async () => {
customElements.get('houseplan-card')?._warmBootReset?.(); // DEV-B703: this scenario simulates a COLD first open — forget the page's warm re-mount memo
const c = document.createElement('houseplan-card');
c.setConfig({ type: 'custom:houseplan-card' });
c.hass = window.__card.hass;
+4
View File
@@ -22,6 +22,7 @@ const { page, browser } = await launch({ width: 820, height: 760 });
const rec = await page.evaluate(async () => {
const out = {};
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
customElements.get('houseplan-card')?._warmBootReset?.(); // DEV-B703: this scenario simulates a COLD first open — forget the page's warm re-mount memo
const c = document.createElement('houseplan-card');
c.setConfig({ type: 'custom:houseplan-card' });
c.hass = window.__card.hass;
@@ -48,6 +49,7 @@ const rec = await page.evaluate(async () => {
out.veilGone = !sr().querySelector('.bootveil');
// A2: a second detach DURING the fade must not leave a zombie veil either
customElements.get('houseplan-card')?._warmBootReset?.(); // DEV-B703: this scenario simulates a COLD first open — forget the page's warm re-mount memo
const c2 = document.createElement('houseplan-card');
c2.setConfig({ type: 'custom:houseplan-card' });
c2.hass = window.__card.hass;
@@ -72,6 +74,7 @@ const outB = {};
for (const shiftMs of shifts) {
const res = await page.evaluate(async (shiftAt) => {
const out = {};
customElements.get('houseplan-card')?._warmBootReset?.(); // DEV-B703: this scenario simulates a COLD first open — forget the page's warm re-mount memo
const spacer = document.createElement('div');
spacer.style.cssText = 'height:0px';
const c = document.createElement('houseplan-card');
@@ -133,6 +136,7 @@ for (const shiftMs of shifts) {
const late = await page.evaluate(async () => {
const out = {};
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
customElements.get('houseplan-card')?._warmBootReset?.(); // DEV-B703: this scenario simulates a COLD first open — forget the page's warm re-mount memo
const spacer = document.createElement('div');
spacer.style.cssText = 'height:0px';
const c = document.createElement('houseplan-card');
+23
View File
@@ -47,6 +47,29 @@ const res = await page.evaluate(async () => {
out.parity = out.fullCardRoom0 === out.staticCardRoom0;
out.overrideRespected = out.staticCardRoom0 === 'transparent';
// Owner 2026-08-04: «углы границ комнат всё ещё с зубцами» — a miter join
// spikes far past a sharp corner (and bevels it flat once past the miter
// limit). Room borders join ROUND now, and BOTH renderers share the rule:
// the styles live in one cardStyles, and this is the smoke that keeps it so.
const joins = (root) => [...root.querySelectorAll('.room')]
.map((el) => getComputedStyle(el).strokeLinejoin);
const fullJoins = joins(main.shadowRoot || main.renderRoot);
const staticJoins = joins(card.renderRoot);
out.fullCardBordersRound = fullJoins.length > 0 && fullJoins.every((j) => j === 'round');
out.staticCardBordersRound = staticJoins.length > 0 && staticJoins.every((j) => j === 'round');
// the trimmed outline of a room with open boundaries is a set of separate
// subpaths: its CAPS are the corners, so they must be round as well
const f1cfg = cfg.spaces.find((s) => s.id === 'f1');
f1cfg.rooms[0].open_to = [f1cfg.rooms[1].id];
main._cfgEpoch++;
main.requestUpdate(); await main.updateComplete;
const outlines = [...(main.shadowRoot || main.renderRoot).querySelectorAll('.room-outline')];
out.trimmedOutlineDrawn = outlines.length > 0;
out.trimmedOutlineRound = outlines.every((el) => {
const cs = getComputedStyle(el);
return cs.strokeLinejoin === 'round' && cs.strokeLinecap === 'round';
});
return out;
});
// зафиксировано прогоном на v1.46.0 и сверено с кодом
+30 -1
View File
@@ -58,4 +58,33 @@ const out = await page.evaluate(async () => {
c._cfgEpoch++; c._regSignature = ''; c._maybeRebuildDevices();
return o;
});
await finish(browser, checkAll(out));
checkAll(out);
// Шаг угла в диалоге устройства — 5°, а не 10° (владелец, 2026-08-03):
// значок часто надо выровнять по стене, которая не лежит на сетке в 10°.
const out3 = await page.evaluate(async () => {
const o = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
c._setMode('devices'); await c.updateComplete;
const dev = c._devices.find((d) => d.space === 'f1');
c._openMarkerDialog(dev); await c.updateComplete;
// строка «размер · угол»: два ползунка, второй — угол
const rows = [...sr().querySelectorAll('.dialog .colorrow')];
const row = rows.find((r) => r.textContent.includes('°'));
const ctl = [...row.querySelectorAll('ha-slider, input[type=range]')];
const stepOf = (el) => Number(el.step ?? el.getAttribute('step'));
const maxOf = (el) => Number(el.max ?? el.getAttribute('max'));
o.angleStepIs5 = ctl.length === 2 && stepOf(ctl[1]) === 5;
o.angleReaches355 = maxOf(ctl[1]) === 355;
o.sizeStepUnchanged = stepOf(ctl[0]) === 0.1;
// и значение реально ложится на 5°
ctl[1].value = 35;
ctl[1].dispatchEvent(new Event('input', { bubbles: true, composed: true }));
await c.updateComplete;
o.angle35Applied = c._markerDialog.angle === 35;
c._markerDialog = null; await c.updateComplete;
c._setMode('view'); await c.updateComplete;
return o;
});
await finish(browser, checkAll(out3));
+1 -1
View File
@@ -58,6 +58,6 @@ checkAll(res, {
"atticSquare": true,
// fill_mode 'glow' — the default for NEW spaces since v1.54 (owner call).
// Existing spaces with an absent fill_mode still resolve to 'none'.
"atticSettings": {"show_borders": true, "show_names": true, "room_color": "#3ea6ff", "room_opacity": 0.55, "fill_mode": "glow", "temp_min": 20, "temp_max": 25, "show_lqi": true, "label_temp": false, "label_hum": false, "label_lqi": false, "label_light": false},
"atticSettings": {"show_borders": true, "show_names": true, "room_color": "#55606c", "room_opacity": 0.55, "fill_mode": "glow", "temp_min": 20, "temp_max": 25, "show_lqi": true, "label_temp": false, "label_hum": false, "label_lqi": false, "label_light": false},
});
await finish(browser, res);
+55 -2
View File
@@ -133,10 +133,10 @@ const res = await page.evaluate(async () => {
await setWeather('cloudy');
const stopCloudy = Number(sr().querySelector('.sunlayer ~ * stop, defs stop')?.getAttribute('stop-opacity')
|| sr().querySelector('stop')?.getAttribute('stop-opacity'));
out.cloudyFades = domPolys().length > 0 && Math.abs(stopCloudy - 0.18 * 0.4) < 1e-6;
out.cloudyFades = domPolys().length > 0 && Math.abs(stopCloudy - 0.3 * 0.4) < 1e-6;
await setWeather('sunny');
const stopClear = Number(sr().querySelector('stop')?.getAttribute('stop-opacity'));
out.clearFullAlpha = Math.abs(stopClear - 0.18) < 1e-6;
out.clearFullAlpha = Math.abs(stopClear - 0.3) < 1e-6;
delete cfg().settings.weather_entity;
touchCfg();
@@ -290,4 +290,57 @@ const final = await rayInfo();
check('b701_write_landed', final.rev !== base.rev, true);
check('b701_ray_survives_ack', Math.abs(final.midY - final.wEy * 1000) < 1, true);
// 15) the 3° threshold + the 2 s dissolve (owner 2026-08-03). Runs LAST and in
// its own evaluate: it deliberately waits out a two-second fade, and the b701
// regression above measures a debounced config write against wall-clock time.
const thr = await page.evaluate(async () => {
const out = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const upd = async () => { c.requestUpdate(); await c.updateComplete; };
const setSun = async (azimuth, elevation) => {
c.hass = { ...c.hass, states: { ...c.hass.states, 'sun.sun': {
entity_id: 'sun.sun', state: elevation > 0 ? 'above_horizon' : 'below_horizon',
attributes: { azimuth, elevation },
} } };
await upd();
};
const domPolys = () => [...sr().querySelectorAll('.sunlayer polygon')];
const layer = () => sr().querySelector('.sunlayer');
const stopAlpha = () => Number(sr().querySelector('stop')?.getAttribute('stop-opacity'));
// the old build ramped the alpha in over the first ~2°; now the layer is
// either absent or at FULL strength, and crossing 3° fades the whole LAYER
// (never the geometry) in or out over exactly two seconds
await setSun(90, 10);
out.aboveThresholdDrawn = domPolys().length > 0 && !layer().classList.contains('out');
{
const cs = getComputedStyle(layer());
out.fadeInTakesTwoSeconds = cs.animationName === 'hp-sunfade-in' && cs.animationDuration === '2s';
}
out.fullAlphaWellAboveThreshold = Math.abs(stopAlpha() - 0.3) < 1e-6;
await setSun(90, 3.2);
out.fullAlphaJustAboveThreshold = Math.abs(stopAlpha() - 0.3) < 1e-6;
// below 3°: the layer stays for exactly the dissolve, then leaves the DOM
await setSun(90, 2);
{
const dying = layer();
const cs = dying && getComputedStyle(dying);
out.belowThresholdDissolves = !!dying && dying.classList.contains('out')
&& cs.animationName === 'hp-sunfade-out' && cs.animationDuration === '2s';
}
await new Promise((r) => setTimeout(r, 2300));
await upd();
out.belowThresholdGoneAfterFade = !layer() && domPolys().length === 0;
// a sun that never crossed the threshold draws nothing at all — no ghost
await setSun(90, 1);
out.coldStartBelowThresholdEmpty = domPolys().length === 0 && !layer();
await setSun(90, 5);
out.backAboveThreshold = domPolys().length > 0 && !layer().classList.contains('out');
return out;
});
Object.assign(res, thr);
await finish(browser, checkAll(res));
+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));
+216
View File
@@ -0,0 +1,216 @@
// Owner 2026-08-04, on the first attempt: «с лучами солнца ты сделал фигню —
// не надо размывать их боковые грани». A shaft of sunlight has HARD sides; it
// fades only with distance, along the ray, from the glass inward. So:
// * the reach is still 70 % of the v1.56 curve;
// * the gradient still spans the FULL wedge and is dead from RAY_FADE_END;
// * the sides are SHARP — no blur filter on the wedge, none defined at all;
// * and because the sides are sharp, the wedge must end ON an iso-alpha line
// of that gradient: nothing is ever drawn past its end, so the old bright
// kerb (a far edge parallel to the wall, cut while still lit) cannot come
// back at an oblique sun.
// DEV-EB173-01 turned the last of those into a contract of its own: the fade
// runs along the wall's INWARD NORMAL, not along the ray. For parallel rays
// the distance travelled from the glass is an affine function of the point, so
// its iso-alpha lines are parallel to the WALL — which is where an honest
// parallelogram puts its far edge. All three invariants then hold at once:
// peak alpha across the whole pane, the same fade distance along every ray,
// and a far edge exactly on the gradient's end. The auditor's own grazing
// repro is re-run below with his numbers.
// The "light never crosses a wall" clip is asserted in demo/smoke_sun.mjs
// (wedgeClippedToRoom) — the polygons arrive from computeSunRays() already
// intersected with the room, which is why no clip-path is needed here.
import { launch, 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;
const sp = cfg.spaces.find((s) => s.id === 'f1');
sp.openings = [
{ id: 'wW', type: 'window', x: 0.04, y: 0.30, angle: 90, length: 0.08 },
{ id: 'wS', type: 'window', x: 0.30, y: 0.86, angle: 0, length: 0.08 },
];
cfg.settings = { ...(cfg.settings || {}), north_deg: 0, bg_mode: 'static', sun_rays: true };
c._cfgEpoch++;
const setSun = async (az, el) => {
c.hass = { ...c.hass, states: { ...c.hass.states, 'sun.sun': {
entity_id: 'sun.sun', state: 'above_horizon', attributes: { azimuth: az, elevation: el } } } };
c.requestUpdate(); await c.updateComplete;
};
const grads = () => [...sr().querySelectorAll('linearGradient[id^=hp-sun-]')];
const stopsOf = (g) => [...g.querySelectorAll('stop')].map((s) => [
parseFloat(s.getAttribute('offset')), Number(s.getAttribute('stop-opacity'))]);
// everything below is measured off the DOM gradient, exactly like the audit
// probe: axis, the offset a point lands on, and the alpha there
const axisOf = (g) => {
const x1 = +g.getAttribute('x1'), y1 = +g.getAttribute('y1');
const x2 = +g.getAttribute('x2'), y2 = +g.getAttribute('y2');
const len = Math.hypot(x2 - x1, y2 - y1);
return { x1, y1, dx: x2 - x1, dy: y2 - y1, len, ux: (x2 - x1) / len, uy: (y2 - y1) / len };
};
const offsetOf = (g, p) => {
const a = axisOf(g);
return ((p[0] - a.x1) * a.dx + (p[1] - a.y1) * a.dy) / (a.len * a.len);
};
// a bundle without the normal-axis fade must FAIL these by name, not blow up
const nrm = (r) => r.normal || [NaN, NaN];
const dep = (r) => (r.depth === undefined ? NaN : r.depth);
const alphaAt = (g, off) => {
const st = stopsOf(g).map(([o, a]) => [o / 100, a]);
if (off <= st[0][0]) return st[0][1];
for (let i = 1; i < st.length; i++) {
if (off <= st[i][0]) {
const t = (off - st[i - 1][0]) / (st[i][0] - st[i - 1][0] || 1);
return st[i - 1][1] + t * (st[i][1] - st[i - 1][1]);
}
}
return st[st.length - 1][1];
};
// ---- 1) 30 % shorter: the wedge reach in window lengths ----------------
await setSun(270, 5); // low western sun into the west window
const oldK = (e) => 0.8 + 1.7 * Math.pow(1 - e / 90, 1.6);
const winLen = 0.08 * 1000;
const low = c._sunRaysCache.rays[0].len;
out.lowSunIs70Percent = Math.abs(low - oldK(5) * 0.7 * winLen) < 1e-6;
await setSun(180, 60);
const high = c._sunRaysCache.rays[0].len;
out.highSunIs70Percent = Math.abs(high - oldK(60) * 0.7 * winLen) < 1e-6;
out.lowStillReachesFurther = low > high;
// ---- 2) the shaft always dissolves BEFORE its own far edge -------------
await setSun(270, 5);
const gs = grads();
out.gradientsDrawn = gs.length > 0;
// the axis is the wall's inward normal, `len · cos(incidence)` long — the
// perpendicular depth a ray reaches after running the FULL wedge length
out.gradientRunsAlongTheWallNormal = gs.every((g, i) => {
const r = c._sunRaysCache.rays[i];
const a = axisOf(g);
return Math.abs(a.ux - nrm(r)[0]) < 1e-6 && Math.abs(a.uy - nrm(r)[1]) < 1e-6;
});
out.gradientSpansWholeWedge = gs.every((g, i) => {
const r = c._sunRaysCache.rays[i];
const cos = r.dir[0] * nrm(r)[0] + r.dir[1] * nrm(r)[1];
return Math.abs(axisOf(g).len - r.len * cos) < 1e-6
&& Math.abs(dep(r) - r.len * cos) < 1e-9
&& stopsOf(g).length > 2;
});
// the ONLY thing that matters about that axis: a point `source + dir·u`
// lands on offset `u / len`, whichever ray it rode in on
out.offsetIsDistanceAlongTheRay = gs.every((g, i) => {
const r = c._sunRaysCache.rays[i];
return [0, 0.3, 0.85, 1].every((u) => [r.a, r.b].every((src) => {
const p = [src[0] + r.dir[0] * r.len * u, src[1] + r.dir[1] * r.len * u];
return Math.abs(offsetOf(g, p) - u) < 1e-6;
}));
});
out.deadWellBeforeTheEnd = gs.every((g) => {
const st = stopsOf(g);
const firstZero = st.find(([, a]) => a === 0);
return !!firstZero && firstZero[0] <= 85.001; // %
});
out.lastStopIsZero = gs.every((g) => stopsOf(g).slice(-1)[0][1] === 0);
out.brightAtTheGlass = gs.every((g) => stopsOf(g)[0][1] > 0.2);
out.neverBrightensInward = gs.every((g) => {
const st = stopsOf(g);
return st.every(([, a], i) => i === 0 || a <= st[i - 1][1] + 1e-9);
});
// ---- 3) SHARP sides: no blur on the wedge, and none defined anywhere ----
const wedges = () => [...sr().querySelectorAll('.sunlayer polygon')];
// walk the polygon and its ancestors up to and including .sunlayer — the
// day/night `brightness` filter lives further up, on the zoomwrap, and is
// none of this test's business
const blurredChain = (el) => {
for (let n = el; n; n = n.parentElement) {
const attr = n.getAttribute && n.getAttribute('filter');
if (attr && attr !== 'none') return true;
const cs = getComputedStyle(n).filter;
if (cs && cs !== 'none' && cs !== '') return true;
if (n.classList && n.classList.contains('sunlayer')) break;
}
return false;
};
out.wedgesDrawn = wedges().length > 0;
out.everyWedgeHasSharpSides = wedges().length > 0 && wedges().every((p) => !blurredChain(p));
out.noSoftFilterDefined = sr().querySelectorAll('filter[id^=hp-sunsoft-]').length === 0;
out.noGaussianBlurAtAll = sr().querySelectorAll('feGaussianBlur').length === 0;
// ---- 4) an OBLIQUE sun: the shaft still dies of its gradient -----------
// The sides are hard again, so the only thing that may end the wedge is the
// gradient. That holds ONLY if the far edge is square to the RAY: with the
// old wall-parallel edge one far corner sat at offset ~0.7 (low sun) or
// ~0.11 (high sun) — i.e. still lit — which is exactly the bright kerb.
// offset ALONG THE GRADIENT, i.e. depth under the wall over `len · cos`
const tOf = (r, p) => {
const mx = (r.a[0] + r.b[0]) / 2, my = (r.a[1] + r.b[1]) / 2;
return ((p[0] - mx) * nrm(r)[0] + (p[1] - my) * nrm(r)[1]) / dep(r);
};
const skew = (r) => {
const mx = (r.a[0] + r.b[0]) / 2, my = (r.a[1] + r.b[1]) / 2;
return Math.abs((r.a[0] - mx) * r.dir[0] + (r.a[1] - my) * r.dir[1]) / r.len;
};
out.obliqueChecked = [];
out.sunIsReallyOblique = true;
out.nothingDrawnPastTheGradient = true;
for (const [az, el] of [[230, 8], [225, 55]]) {
await setSun(az, el);
const rays = c._sunRaysCache.rays;
out.obliqueChecked.push(rays.length);
if (!rays.length) { out.sunIsReallyOblique = false; continue; }
if (!rays.some((r) => skew(r) > 1e-3)) out.sunIsReallyOblique = false;
for (const r of rays) {
for (const poly of r.polys) {
for (const p of poly) if (tOf(r, p) > 1 + 1e-6) out.nothingDrawnPastTheGradient = false;
}
}
}
out.obliqueSunHasWedges = out.obliqueChecked.every((n) => n > 0);
delete out.obliqueChecked;
// ---- 5) DEV-EB173-01: the auditor's own grazing repro ------------------
// West window 80 render units long, elevation 90 (nominal reach 0.56 · 80 =
// 44.8, i.e. 70 % of the old 64), azimuth 190 — the light enters the glass
// and travels 10° off the wall's own direction. The probe on the broken
// build read sides 5.408 / 84.192 (ratio 15.57) and source offsets ±0.879
// with opacity 0 at one end of the pane.
sp.openings = [{ id: 'wW', type: 'window', x: 0.04, y: 0.30, angle: 90, length: 0.08 }];
c._cfgEpoch++;
await setSun(190, 90);
const gr = c._sunRaysCache.rays;
out.grazingRayDrawn = gr.length === 1;
if (gr.length === 1) {
const r = gr[0];
const g = grads()[0];
out.grazingIsReallyGrazing = Math.abs(r.dir[0] * nrm(r)[0] + r.dir[1] * nrm(r)[1] - 0.17365) < 1e-4;
out.grazingLengthIs70Percent = Math.abs(r.len - 0.7 * (0.8 * 80)) < 1e-6
&& Math.abs(r.len - 44.8) < 1e-6;
// both sides of the shaft, measured off the DRAWN polygon: the depth of a
// vertex divided by cos is how far its ray ran
const cos = r.dir[0] * nrm(r)[0] + r.dir[1] * nrm(r)[1];
const ran = (p) => ((p[0] - r.a[0]) * nrm(r)[0] + (p[1] - r.a[1]) * nrm(r)[1]) / cos;
const far = r.polys[0].map(ran).filter((u) => u > 1e-6);
out.grazingHasTwoFarCorners = far.length === 2;
out.grazingSidesEqualWithin1Percent = far.length === 2
&& Math.abs(far[0] - far[1]) <= 0.01 * r.len;
out.grazingBothSidesAreTheNominalLength = far.every((u) => Math.abs(u - r.len) <= 0.01 * r.len);
// the whole pane of glass at peak alpha (was 0 at one end)
const peak = stopsOf(g)[0][1];
out.grazingGlassAtOffsetZero = [r.a, r.b].every((p) => Math.abs(offsetOf(g, p)) < 1e-6);
out.grazingGlassAtPeakAlpha = [r.a, r.b].every((p) => Math.abs(alphaAt(g, offsetOf(g, p)) - peak) < 1e-9);
out.grazingPeakIsTheRealPeak = peak > 0.2;
// and nothing is drawn past the gradient
out.grazingInsideTheGradient = r.polys.every((poly) =>
poly.every((p) => offsetOf(g, p) >= -1e-6 && offsetOf(g, p) <= 1 + 1e-6));
}
// a sun 2° off the wall's plane (cos 0.035 < RAY_MIN_COS) casts nothing
await setSun(182, 90);
out.sunAlongTheWallCastsNothing = c._sunRaysCache.rays.length === 0;
await setSun(186, 90);
out.sunJustClearOfTheWallStillCasts = c._sunRaysCache.rays.length === 1;
return out;
});
await finish(browser, checkAll(res));
+119
View File
@@ -0,0 +1,119 @@
// DEV-B703-01: Lovelace re-creates card elements when the websocket reconnects
// after a long-backgrounded tab; the fresh instance used to run the whole
// first-open boot (veil + 700 ms + quiescence) — the owner's «план
// перезагружается при возврате на вкладку». Within one loaded page a repeat
// instance must open WARM: no veil, no wait, not a single frame at a stale
// stage height or a default zoom. A window resize between instances
// invalidates the memo → the full protective boot is back.
// FAILS on the build before DEV-B703-01 (every instance booted cold).
import { launch, check, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 820, height: 760 });
const res = await page.evaluate(async () => {
const out = {};
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
customElements.get('houseplan-card')?._warmBootReset?.(); // deterministic: the page starts cold
localStorage.setItem('houseplan_card_zoom_v1', JSON.stringify({ f1: 1.8 }));
localStorage.removeItem('houseplan_card_nav_v1');
const wrap = document.createElement('div');
wrap.style.cssText = 'position:fixed;left:0;top:0;width:800px;z-index:99;background:#000';
document.body.appendChild(wrap);
const mk = () => {
const c = document.createElement('houseplan-card');
c.setConfig({ type: 'custom:houseplan-card' });
c.hass = window.__mkHass();
wrap.appendChild(c);
return c;
};
// ---- cold boot: the first instance pays the full protective window ----
const c1 = mk();
out.coldBoots = c1._booting === true;
const t0 = performance.now();
let veilSeen = false;
while (c1._booting && performance.now() - t0 < 2500) {
if ((c1.shadowRoot || c1.renderRoot).querySelector('.bootveil')) veilSeen = true;
await sleep(30);
}
out.coldVeilSeen = veilSeen;
out.coldSettled = c1._booting === false;
await sleep(300); // fade out + soft grace start
const stage1 = (c1.shadowRoot || c1.renderRoot).querySelector('.stage');
const hFinal = stage1.clientHeight;
const zoomSaved = c1._zoom;
out.zoomArmed = Math.abs(zoomSaved - 1.8) < 0.01;
c1.remove(); // Lovelace throws the old element away…
await sleep(30);
// ---- …and creates a new one: same page, same viewport → WARM mount ----
const c2 = mk();
out.warmNoBootFlag = c2._booting === false; // decided synchronously in setConfig
const sr2 = () => c2.shadowRoot || c2.renderRoot;
const frames = [];
const t1 = performance.now();
await new Promise((done) => {
const sample = () => {
const stage = sr2().querySelector('.stage');
if (stage) {
const zw = stage.querySelector(':scope > .zoomwrap');
frames.push({
t: Math.round(performance.now() - t1),
veil: !!sr2().querySelector('.bootveil'),
planVisible: !!zw && getComputedStyle(zw).visibility === 'visible',
rooms: stage.querySelectorAll('.room').length,
stageH: stage.clientHeight,
zoom: c2._zoom,
});
}
if (performance.now() - t1 < 900) requestAnimationFrame(sample);
else done();
};
requestAnimationFrame(sample);
});
out.framesSampled = frames.length > 20;
out.warmNoVeilEver = !frames.some((f) => f.veil);
out.warmPlanVisibleEveryFrame = frames.length > 0 && frames.every((f) => f.planVisible)
? true : 'a frame hid the plan';
const badH = frames.filter((f) => Math.abs(f.stageH - hFinal) > 2);
out.warmNoStaleHeightFrame = badH.length === 0
? true : `stage h=${badH[0].stageH} vs final ${hFinal} at t=${badH[0].t}ms`;
out.warmZoomEveryFrame = frames.every((f) => Math.abs(f.zoom - zoomSaved) < 0.01)
? true : 'a frame at a non-saved zoom';
// the server config reload on a warm mount must not blank the plan
out.warmNeverEmptyPlan = frames.every((f) => f.rooms > 0) ? true : 'a frame rendered no rooms';
c2.remove();
wrap.remove();
return out;
});
for (const [k, v] of Object.entries(res)) check(k, v);
// ---- a window resize between instances → memo stale → the full boot ----
await page.setViewportSize({ width: 900, height: 680 });
const rez = await page.evaluate(async () => {
const out = {};
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const c = document.createElement('houseplan-card');
c.setConfig({ type: 'custom:houseplan-card' });
c.hass = window.__mkHass();
const wrap = document.createElement('div');
wrap.style.cssText = 'position:fixed;left:0;top:0;width:800px;z-index:99;background:#000';
wrap.appendChild(c);
document.body.appendChild(wrap);
out.resizedBootsCold = c._booting === true;
const sr = () => c.shadowRoot || c.renderRoot;
const t0 = performance.now();
let veil = false;
while (c._booting && performance.now() - t0 < 2500) {
if (sr().querySelector('.bootveil')) veil = true;
await sleep(30);
}
out.resizedVeilShown = veil;
out.resizedSettles = c._booting === false;
wrap.remove();
return out;
});
for (const [k, v] of Object.entries(rez)) check(k, v);
await finish(browser, { ...res, ...rez });
+80
View File
@@ -0,0 +1,80 @@
// DEV-B703-02: temporary WS failures must NEVER blank a plan that was already
// shown. On the build before the fix _loadFromServer's catch nulled
// _serverCfg after 8 failed tries — the plan disappeared even though a valid
// config (the LS snapshot) was on screen. Stale-while-revalidate: the last
// valid config lives until a successful reload replaces it, and once the
// socket recovers the card revalidates QUIETLY on its own retry clock
// (willUpdate stops driving loads after 8 tries).
// planSurvivesOutage + reloadedAfterRecovery FAIL on the pre-fix build.
import { launch, check, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 820, height: 760 });
const res = await page.evaluate(async () => {
const out = {};
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
customElements.get('houseplan-card')?._warmBootReset?.();
// the LS snapshot is in place (the page's main card cached it): the new
// instance renders instantly from it — a wall tablet rejoining wifi
out.lsCachePresent = !!localStorage.getItem('houseplan_card_cfg_v1');
let fail = true;
let failedCalls = 0;
const base = window.__mkHass();
const mkFlaky = () => ({
...base,
callWS: async (m) => {
if (fail) { failedCalls++; throw new Error('ws down'); }
return base.callWS(m);
},
});
const c = document.createElement('houseplan-card');
c.setConfig({ type: 'custom:houseplan-card' });
c.hass = mkFlaky();
const wrap = document.createElement('div');
wrap.style.cssText = 'position:fixed;left:0;top:0;width:800px;z-index:99;background:#000';
wrap.appendChild(c);
document.body.appendChild(wrap);
await sleep(50);
out.rendersFromCache = !!c._serverCfg;
// hass keeps ticking while the socket is down — drive past the 8-try budget
for (let i = 0; i < 12; i++) {
c.hass = mkFlaky();
await sleep(60);
}
out.wsFailedEnough = failedCalls >= 8 ? true : `only ${failedCalls} failed calls`;
out.planSurvivesOutage = c._serverCfg ? true : '_serverCfg was cleared by the outage';
out.stillServerMode = c._serverStorage === true;
const sr = () => c.shadowRoot || c.renderRoot;
const stage = sr().querySelector('.stage');
out.roomsStillRendered = !!stage && stage.querySelectorAll('.room').length > 0;
// ---- the socket comes back: quiet self-driven revalidation ----
fail = false;
const t0 = performance.now();
while (!c._loadOk && performance.now() - t0 < 12000) await sleep(100);
out.reloadedAfterRecovery = c._loadOk === true ? true : 'never revalidated after recovery';
out.cfgValidAfterRecovery = !!c._serverCfg && Array.isArray(c._serverCfg.spaces)
&& c._serverCfg.spaces.length > 0;
wrap.remove();
return out;
});
for (const [k, v] of Object.entries(res)) check(k, v);
// ---- a LIVE instance: a failing config reload keeps the last config ----
const live = await page.evaluate(async () => {
const out = {};
const c = window.__card;
const before = c._serverCfg;
const origHass = c.hass;
c.hass = { ...c.hass, callWS: async () => { throw new Error('ws down'); } };
await c._reloadConfigOnly(true);
out.liveReloadFailKeepsCfg = c._serverCfg === before
? true : 'a failed reload replaced/cleared the live config';
c.hass = origHass;
return out;
});
for (const [k, v] of Object.entries(live)) check(k, v);
await finish(browser, { ...res, ...live });
+5 -2
View File
@@ -44,8 +44,11 @@ out.zoomOut = await page.evaluate(() => {
out.zoomOutWorks = out.zoomOut.zoom === 0.5 && out.zoomOut.wider
&& out.zoomOut.centredX && out.zoomOut.centredY;
delete out.zoomOut;
out.floorIsHalf = await page.evaluate(() => { window.__card._resetZoom(); const c = window.__card;
c._applyView(0.1); return c._zoom; }) === 0.4; // clamped at the floor
// docs/CANVAS.md §5: the zoom-out floor moved from a fixed 0.4 of the old
// view_box to 1/3 of the CONTENT frame — three canvases of empty plane is as
// far as it is useful to go when the canvas has no edges.
out.floorIsThird = Math.abs(await page.evaluate(() => { window.__card._resetZoom(); const c = window.__card;
c._applyView(0.01); return c._zoom; }) - 1 / 3) < 1e-9; // clamped at the floor
await page.evaluate(() => window.__card._resetZoom());
// -- editor zoom is a working tool, not the viewing intent ----------------
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+536 -248
View File
File diff suppressed because one or more lines are too long
+6 -2
View File
@@ -109,7 +109,9 @@ references and duplicates by name|area. Manual files: transactional HTTP upload
`.storage/houseplan.config` (Store):
```json
{ "spaces": [{ "id","title","plan_url","plan_aspect","view_box":[4],
{ "spaces": [{ "id","title","plan_url","plan_aspect",
"plan_x","plan_y","plan_scale", // optional, docs/BACKDROP.md
"view_box":[4],
"rooms":[{"id","name","area","poly|x/y/w/h","open_to","settings"}],
"openings":[…], "decor":[…], "settings":{…} }],
"markers": [{ "id","binding":"device:<id>|entity:<eid>|virtual","hidden",
@@ -122,7 +124,9 @@ references and duplicates by name|area. Manual files: transactional HTTP upload
All coordinates are **normalized (0..1 of the canvas)**; the canvas is always
**square** (v1.48.0), render space `NORM_W × NORM_W` (1000×1000). A space has no
proportions of its own — `plan_aspect` is the IMAGE's ratio, used to letterbox
it centred on the square. The schema bounds geometry to ±4 with strictly
it centred on the square; the optional `plan_x`/`plan_y`/`plan_scale` then move
and uniformly scale that rectangle (`planRect`, docs/BACKDROP.md), and their
absence is the centred default exactly. The schema bounds geometry to ±4 with strictly
positive sizes (HP-1501/1502). `device_overrides`/`virtual_devices` are long
gone — markers carry everything, `marker.hidden` is the explicit
"hide from plan" flag seeded once by the old filter (docs/FILTERING.md).
+251
View File
@@ -0,0 +1,251 @@
# The backdrop picture — the spec (source of truth)
*Owner-approved 2026-08-04. Companion to docs/CANVAS.md (the plane the
picture lives on) and docs/RESIZE.md (the other transform frame).*
Until v1.58.0 an uploaded plan image was nailed to the canvas: centred, at
its own proportions, filling the square. If it did not line up with the
rooms you had drawn — or with the rooms you were about to draw — the only
recourse was to edit the file. And because it filled the canvas, it also
decided where the opaque **paper** was, which quietly made a picture plan
and a drawn plan two different kinds of thing.
This closes both. The picture is now **one of the objects on the plane**:
it can be moved and scaled like anything else, it counts as content for
the frame, and it no longer makes paper of its own.
---
## §1 Model — three optional numbers
A space gains three optional fields, next to `plan_url` / `plan_aspect`:
| Field | Meaning | Units | Range |
| --- | --- | --- | --- |
| `plan_x` | offset of the image's **top-left corner** from its default place | normalised (as rooms, decor, openings) | ±`CANVAS_LIMIT` (±5000) |
| `plan_y` | the same, vertically | normalised | ±`CANVAS_LIMIT` |
| `plan_scale` | **one** uniform multiplier for both sides, anchored at the (already offset) top-left corner | multiplier | `0.01 … 100` |
There is **no rotation** and no independent width/height: the picture keeps
its own aspect ratio for ever, which is what makes "scale" a single number.
**The absence of all three is exactly the pre-v1.58.0 behaviour.** No
migration runs, nothing is written into anybody's store, and a plan that has
never been through the backdrop editor renders bit-identically. Junk (a
string, `NaN`, a zero or negative scale) is treated as absence, not as an
error — the same reading rule the rest of the geometry uses.
One function owns the arithmetic, `planRect()` in `src/space-geometry.ts`:
```
base = fitInSquare(plan_aspect, 1000) // the centred default
rect.x = base.x + plan_x * 1000
rect.y = base.y + plan_y * 1000
rect.w = base.w * plan_scale
rect.h = base.h * plan_scale
```
`spaceModels()` calls it, so **every** consumer — the full card, the static
`houseplan-space-card`, the kiosk, the content frame — reads the same
rectangle. The card's model cache key (`_cfgFingerprint`) carries the three
fields, so a drag invalidates the memo.
`validation.py` mirrors the ranges (`PLAN_SCALE_MIN` / `PLAN_SCALE_MAX`
alongside `CANVAS_LIMIT`). Detaching the image (space dialog → «Нарисую
комнаты вручную») deletes all three: there is nothing left for them to
describe, and a stale transform must not silently apply to the next upload.
---
## §2 The transform frame
**Where.** The **backdrop editor** (`mode: 'decor'`, «Редактор подложки»).
The frame is on screen as soon as that editor opens (tools **select** and
**«Картинка-подложка»**); the drawing tools own the drag, so the frame
steps aside for them. Nowhere else: View, the Plan editor, the Device
editor and the kiosk never touch the picture. A space with no image never
grows a frame, and its toolbar is unchanged.
**What.** A dashed outline on the picture's rectangle plus four
finger-sized corner handles (radius = 2 % of the visible view, so they stay
grabbable at any zoom — the same rule the vacuum fit frame uses). The
outline never takes a pointer; the handles do, with `setPointerCapture` and
`nwse-resize` / `nesw-resize` cursors. Under the picture's own tool the
stage cursor is `grab`, and `grabbing` while a gesture is live.
**Move** — press anywhere inside the picture and drag, **under the
«Картинка-подложка» tool**. Why a tool and not just the select tool: the
picture's body is most of the screen, and claiming it would take away the
one-finger pan of the whole plane, which the owner asked for on 2026-08-04
(«таскать план при любом масштабе») and `smoke_pan_any_zoom` guards. The
corner handles are precise targets and need no such protection — they are
live whenever the frame is. The gesture is also only started INSIDE the
rectangle, so pressing beside the picture pans even under its own tool.
**Nothing may rescale the stage mid-gesture.** Two things wanted to, and
both moved the picture away from the finger before they were stopped:
* the picture is a content item, so dragging it GROWS the editor's content
frame, which rescales the view — `_frameOf()` therefore returns the
memoized frame untouched while `_bdDrag` is live, and catches up on
release;
* «Вернуть картинку» appears the moment the picture has moved, and the
toolbar sits above the stage — so it is withheld until the gesture ends.
**Scale** — pull a corner. Uniform, about the **opposite** corner, which
stays exactly where it was (the mechanic borrowed from the vacuum
calibration panel). `k = max(|dx| / base.w, |dy| / base.h)`, so the gesture
follows whichever axis you pulled harder, and both sides then follow `k`.
**Nothing else moves.** Rooms, openings, devices, decor and room labels are
untouched by both gestures — this is a picture-only transform, and the
smoke asserts the room geometry is byte-identical afterwards.
**Undo** is the «Вернуть картинку» button in the backdrop toolbar: it
appears only once the picture HAS been moved, and it deletes the three
fields (back to centred, own size).
---
## §3 THE PAPER IS THE ROOMS — and only the rooms
*This is a behaviour CHANGE, deliberately.*
The opaque "paper" exists so the scene background — `bg_color`, or the
`daynight` sky (docs/SUN.md) — never bleeds through the plan itself. Until
v1.58.0 it had two forms: the ROOM CONTOURS for a drawn plan, and **the
image rectangle** for a picture plan.
The second form is gone. The paper is now `paperRoomShapes(space.rooms)`
in every case:
* one opaque shape per room, in exactly the room's own geometry — an
L-shaped house or a pair of detached buildings never grows a bounding
rectangle;
* an empty space has **no paper at all**, image or no image;
* its colour is unchanged (`styles.ts .hp-paper`): white on a hand-drawn
plan (`.stage.noplan`), the theme card background where an image is
attached. `daynight` dims it via the `.zoomwrap` brightness filter only —
its alpha stays 1.
**Layer order**, top to bottom, in both renderers:
```
devices / labels
sun wedges and rays
walls, openings, room fills, decor
THE PICTURE ← above the paper, below the geometry
the paper (room contours)
the scene background (bg_color / the daynight sky)
```
**The consequence, and the owner knows it:** a picture with transparency
over a space with no rooms drawn shows the scene background through itself.
That is the honest reading of "the paper belongs to the rooms" — draw the
rooms and the paper appears under them.
---
## §4 The picture is content
`contentItems()` has always counted the image rectangle as one content item
alongside the rooms; it now counts the **transformed** one. So the frame,
«Вписать всё», the zoom floor and the pan slack (docs/CANVAS.md §4–§5) all
follow the picture wherever it is dragged, and a picture scaled down to a
quarter tightens the frame around it instead of framing empty plane.
`iconUnit()` deliberately does **not** count it: an icon is a percentage of
the drawn plan (docs/CANVAS.md §6), and scaling the backdrop must not
resize every marker on it.
---
## §5 Snap
Both gestures obey the canvas snap contract (docs/CANVAS.md §9):
| Gesture | What is snapped |
| --- | --- |
| move | the resulting **top-left corner** — the anchor, not the delta, so one drag is enough to put a legacy off-grid picture onto the lattice |
| scale | the dragged corner **along the picture's longer side**; the scale is then read back off it |
The fixed corner of a scale keeps its (on-grid) place and the long side
lands on a node. The short side follows from the aspect ratio and generally
does **not** — that is what "uniform, no stretch" costs, and it is the
right trade: a picture whose proportions drift is a broken picture.
**Shift** suspends the snap for the duration of the gesture, exactly as
everywhere else (§9.4). The `±CANVAS_LIMIT` clamp rides along regardless —
`_snap()` and `clampCanvasN` are the only bound either gesture has.
---
## §6 Live measurements
While the picture is dragged or scaled, one badge rides its centre with the
picture's **real size**, width × height, through the space's `cell_cm` and
the HA unit system (metres or feet). It is the same `_fmtLen`
(`segmentCm` → `formatLength`) and the same `.measurelabel` element the
wall ruler, the opening shoulders and the room resize use — the card states
a length exactly one way.
---
## §6b One tool at a time — a drawing tool owns the canvas
The backdrop editor has seven tools, and they are **exclusive**: only
**select** talks to an existing shape, only **erase** deletes one, only
**«Картинка-подложка»** grabs the picture's body, and the four drawing
tools (**line**, **rect**, **ellipse**, **text**) do nothing but create.
The rule exists because of a concrete failure (owner, 2026-08-04): *«нельзя
поставить начало линии на конец другой — при клике на конец выделяется
первая линия, а не начинает рисоваться новая»*. The shape's own
`pointerdown` handler ran first, stopped the event and did nothing under a
drawing tool, so the press never reached the stage and no draft was born —
exactly on the one spot where a new line most wants to start, since §5's
snap anchors include every line end.
So, under a drawing tool (and under the picture tool):
* `.dshape` is `pointer-events: none` — an existing figure is not a target
at all, and the cursor is the stage's;
* `_decorShapeDown` returns before `stopPropagation` for any tool that is
not select/erase, so a synthetic press cannot swallow the gesture either;
* `_decorPointerDown` only looks for a shape under the pointer for
select/erase, and otherwise starts the draft at the pressed point;
* the transform frame's corner handles stay off (§2) — they were the first
thing taught not to swallow the first point of a line.
Selecting, moving and the text dialog's double-click therefore live in the
select tool only. `demo/smoke_decor.mjs` asserts both halves: a press on an
existing line starts a new line **from that end** under the line tool, and
still selects and grabs it under select.
---
## §7 What is deliberately NOT done
* **No rotation.** A skewed scan is a scan to re-take, not a plan to skew
the whole editor's maths for.
* **No non-uniform scale.** See above: the picture keeps its ratio.
* **No numeric fields** in the space dialog. The frame is the interface;
the numbers exist for the config, not for typing.
* **No per-room clipping of the picture.** It is one rectangle.
* **No transform for the sun's paper/wedges.** Those follow the rooms, and
now so does the paper — one rule, no second geometry.
---
## §8 Where it lives
| Concern | File |
| --- | --- |
| the rectangle (`planRect`, `PLAN_SCALE_MIN/MAX`), content items | `src/space-geometry.ts` |
| the frame, the gestures, the live badge, the reset | `src/houseplan-card.ts` (`_bd*`, `_renderBackdropFrame`) |
| the frame's look, the paper, the cursors | `src/styles.ts` (`.bdframe`, `.hp-paper`) |
| the static card's copy of the paper + picture order | `src/space-render.ts` |
| the stored ranges | `custom_components/houseplan/validation.py` |
| unit coverage | `test/backdrop.test.mjs` |
| DOM coverage | `demo/smoke_backdrop.mjs`, `demo/smoke_bg_color.mjs` §11 |
| screenshots | `demo/shot_backdrop.mjs` |
+503
View File
@@ -0,0 +1,503 @@
# Infinite canvas — the spec (source of truth)
Status: approved by the owner 2026-08-03, **shipped in v1.57.0**.
Scope decisions final: there is no "plan size" any more, the canvas
is conceptually unbounded, storage does not change, and the
opening view is always derived from what is actually drawn.
## The problem it closes
Several users drew plans that ran past the edge of the grid and then
could not place devices outside it. The only workaround was to redraw
the whole plan. The card behaved as if the normalised unit square
(`0..1`, rendered as `NORM_W x NORM_W = 1000 x 1000` units) were a
sheet of paper with edges. It is not a sheet of paper — it is just the
coordinate system.
## Principle
1. **Coordinates keep their meaning.** Rooms, openings, decor and
device positions are still stored normalised. `1.0` is still the
same distance it always was; `cell_cm` still ties a grid cell to
real centimetres. **No data migration.** An existing plan opens as
before — including the size of the markers (§6).
2. **`0..1` is not a boundary, it is an origin.** Any finite
coordinate is legal. `2.7` simply means "2.7 canvas widths to the
right of the origin".
3. **Nothing in the product may say "you cannot go past the edge".**
No clamp on drawing, dragging, decor or device placement stops at
a rectangle.
4. **What is stored is only where something is drawn.** There is no
stored extent to keep in sync.
## Model
| Concept | Before | Now |
| --- | --- | --- |
| Canvas | square `0..1`, rendered `0..1000` | unbounded plane, same units |
| `space.view_box` | the frame; everything was clamped into it | an OPTIONAL hint for the very first frame; used only when there is nothing to frame |
| "fit" rectangle | `view_box` (or the content bbox in view mode) | always the **content frame** (§4) |
| Zoom out floor | `ZOOM_MIN = 0.4` (fraction of `view_box`) | 3x the content frame (`MIN_ZOOM = 1/3`) |
| Pan bounds | content must cover the scene, and only above 100% zoom | content frame + one screen of slack in each direction, at any zoom |
| Icon size | % of `view_box`, i.e. grew with zoom | % of `iconUnit` — still grows with zoom (§6) |
| Validation range | `+/-4` | `+/-5000` (§3) |
### Render frame vs. view
* **Frame** (`_baseVb()` / `spaceFrame()`) — the rectangle that "fit to
screen" fits and that zoom `1` means. It is recomputed from content,
never stored.
* **View** (`_view`) — the SVG `viewBox` actually painted. It is in
absolute render units, so recomputing the frame never teleports the
plan; it only changes what zoom `100 %` means and where panning
stops.
## §3 Validation limits
`custom_components/houseplan/validation.py`:
| Symbol | Before | Now | What it is |
| --- | --- | --- | --- |
| `_COORD` (layout x/y) | `-4 .. 4` | `-5000 .. 5000` | coordinate |
| `_GEOM` (room x/y, poly points, opening x/y, `view_box` origin) | `-4 .. 4` | `-5000 .. 5000` | coordinate |
| `_EXTENT` (room w/h, `view_box` w/h) | `0.001 .. 4` | `0.001 .. 5000` | size — strictly positive |
| `_NORM` (decor x/y/w/h) | `-1 .. 2` | `-5000 .. 5000` | coordinate |
| opening `length` | `0.001 .. 1` | `0.001 .. 5000` | size — strictly positive |
`+/-5000` is **garbage insurance, not a frame**. At the product's own
scale (`cell_cm` = 5 by default, 240 grid cells across the unit width)
one canvas width is ~12 m, so `5000` is ~60 km of plan — unreachable
in a home, while still stopping a stored `1e100` from making the plan
invisible for every client (the failure HP-1500-03 / HP-1501-01
closed). Sizes stay strictly positive because SVG divides by them and
`viewBox="0 0 0 0"` paints nothing (HP-1502-01).
## §4 The content frame
`contentFrame(items, opts)` in `src/space-geometry.ts` — pure, unit
tested. Input is a list of **items**, one per drawn/placed object:
* every room (its own bounding box — polygon or legacy rect);
* the backdrop image rectangle, when the space has one;
* every opening (door/window) end-to-end segment;
* every decor shape;
* every device the layout actually places in this space **and that the card
actually draws** — a HIDDEN device (docs/FILTERING.md) is not content: the
frame is presentation, and an object nobody can see must not decide what the
plan opens on. It keeps its auto-grid cell and every aggregation it feeds;
it simply is not an item here. Ghosts in the device editor are not items
either — reaching them is §5's job, not the opening view's.
Output:
```ts
{ core: Rect | null, all: Rect | null, outliers: number }
```
* `core` — bbox of the **main mass**, padded. This is the opening view.
* `all` — bbox of **everything**, padded. This is what "show the far
objects" fits.
* `outliers` — how many items were left out of `core`.
Both rectangles are padded by `pad` (default `0.05`) of the longer
side, and degenerate axes are inflated (see §4.2).
For a space with a **backdrop image** the image rectangle is one of the
items, so the image still sets the extent — cropping to the rooms would
hide the parts of the picture nobody has outlined yet (owner, point 2).
Fallback order when there are no items at all: the stored `view_box`
(the "hint"), then the legacy unit square. This is the only place
`view_box` is still read for framing.
### §4.1 Outlier rejection
An object standing an order of magnitude further away than the rest
must not decide the opening view, but must still be reachable. The
criterion is deliberately rank-based (medians/percentiles), so a single
absurd value cannot move it:
1. Items whose coordinates fall outside the sane range
(`+/-CANVAS_LIMIT`, i.e. the same `+/-5000` the backend accepts) are
dropped outright — that is corruption, not content.
2. With fewer than `MIN_VOTERS = 4` items no outlier is declared:
with two objects there is no majority to be far from.
3. `m` = component-wise **median** of the item centres.
4. `d_i` = Chebyshev distance `max(|x_i-m_x|, |y_i-m_y|)` from `m`.
5. `spread` = the **75th percentile** of `d`, floored at
`MIN_SPREAD = 0.05 * NORM_W` (50 render units, about a small room),
so a tightly clustered plan does not call its own neighbour an
outlier.
6. Item `i` is an outlier iff `d_i > OUTLIER_K * spread`, with
`OUTLIER_K = 10` — literally "an order of magnitude further than
the bulk".
7. **Majority veto**: if more than a third of the items came out as
outliers, this is not a plan with strays — it is a spread-out plan.
No outliers are declared and `core = all`.
When `outliers > 0` the card shows an unobtrusive inline hint (no
modal) — "there are objects far from the plan" with a **Show** action
that fits `all`.
Whatever the vote rejects is rejected **everywhere the plan is measured**,
not only in the viewBox: §6's `iconUnit` runs the same vote over the rooms.
One notion of "the plan", or a stray the frame had just thrown out came back
as icons ninety times too big (audit DEV-2C947-03).
### §4.3 The frame in an editor
Inside an editor the frame only ever **grows** (`unionRect` with the previous
one): it bounds pan and defines what zoom 1 means, and a frame that shrank the
instant a room was deleted would move the ground under a live gesture.
That union belongs to the editor session and to nothing else. The memo key
carries the growth flag, so leaving for View recomputes the frame from the
content instead of inheriting the union — otherwise a room dragged five
canvases away in the Plan editor kept View framing the empty ground it had
left behind, until some unrelated model change happened to invalidate the memo
(audit DEV-2C947-02).
### §4.2 Degenerate frames
An SVG `viewBox` with a zero axis paints nothing, so a frame still has
a floor:
* an axis shorter than `DEGENERATE = 0.03 * NORM_W` is grown to
`FLOOR = 0.2 * NORM_W`, centred on itself.
That covers "one lone marker" and "a collinear row of markers". A real
thin shape (a 100-unit corridor) is well above the threshold and keeps
its tight frame. This is the only survivor of the old safety props —
the `-25 % .. 125 %` envelope that used to reject far content is gone,
replaced by §4.1 (the envelope WAS the bug: content past the old square
was silently excluded from the frame).
## §5 Zoom and pan
* **Zoom in** — unchanged, `ZOOM_MAX = 8`.
* **Zoom out** — `MIN_ZOOM = 1/3`: you can see three times the content
frame and no further. Empty space beyond that is not information.
* **Pan** — available at **every zoom**, in view mode and in every
editor, and bounded by the content frame inflated by
`PAN_SLACK = 1.0` of `max(view, frame)` on each side. You can walk
off the plan (there is no edge), but not into infinity.
Until 2026-08-04 a drag moved the view only while `zoom > 1`: on the
old bounded canvas a plan smaller than the scene genuinely had
nowhere to go, so the gate was harmless. With no edge left it was
simply a missing feature, and the owner reported it as one
(«таскать план при любом масштабе»). The zoom no longer takes part
in the decision — `_clampView` alone says how far you may walk.
* **Who owns the pointer.** A drag pans only when it starts on empty
scene: the room-resize handles, device badges, openings, room labels
and the decor shapes take the pointer first (`_stagePointerDown`
bails out on them), and a drawing tool that consumes the press —
decor line/rect/ellipse/text — bails out too. Two fingers are always
a pinch, never a pan. On a **kiosk** screen at swipe zoom (`≤ 1`,
more than one space) a *horizontal* drag belongs to the floor swipe:
the gesture is classified once, on the first movement past 8 px, and
keeps that role until the finger lifts (`_panLock`), so the plan
never slides under a swipe and a vertical drag still pans.
* **The lock is final, at the release too** (audit DEV-1DA1-02). The
release used to ask `swipeTarget()` again from the raw start→end
vector, ignoring the lock — so a *curved* gesture (a short vertical
lead-in that locks `pan`, then a long horizontal sweep) dragged the
plan under the finger and still landed on another storey when it
lifted: the worst kind of surprise on a wall tablet. `_panLock ===
'pan'` now means no floor change, whatever the overall vector ends up
looking like; only a gesture locked as `swipe` may reach
`swipeTarget()`, and it never pans on the way. A motionless tap locks
nothing, so the double-tap zoom reset is untouched.
* **"Home is that way" arrow** — when the content frame is entirely
outside the current view, a small pointer appears at the view edge
in the frame's direction. Clicking it fits the content. Cheap
insurance against getting lost in the empty plane.
## §6 Icon size — a percentage of the plan
**Unchanged behaviour** — an icon scales with the plan, exactly as it
always did. (A first cut of the infinite canvas made it a fixed
percentage of the viewport; the owner looked at it on 2026-08-03 and
asked for the original back. The history is kept here because the
reasoning for the *numerator* below is the whole point.)
Before the infinite canvas:
```
--icon-size: iconPct * vb.w / view.w (cqw)
```
Now (`iconCqw()` in `src/space-geometry.ts`, pure and unit tested):
```
--icon-size: iconPct * iconUnit(space) * kioskScale / view.w (cqw)
```
Read it in render units: a marker always occupies
`iconPct/100 * iconUnit` **render units** of the plan, whatever the
frame and whatever the zoom. Dividing by the width of the visible view
turns that into the percentage of the container `cqw` means. Zoom in
2x and the marker is 2x bigger, together with the walls it sits on.
**Why the numerator changed.** `vb.w` was the stored `view_box`, and
`view_box` is not a frame any more (§4). Keeping a fixed `NORM_W`
there would have been worse than wrong: on a plan drawn 2 canvases
wide the frame is ~2.2 canvases, so every marker would come out 2.2x
smaller than on an ordinary plan — and 55x smaller on a plan 50
canvases out, i.e. an invisible dot. `iconUnit(space) =
max(NORM_W, mainMassOfTheRooms)` — the extent of the rooms **after the same
outlier vote §4.1 applies to the frame** (rooms only, so the full card and the
static card cannot drift apart), and it is:
* **exactly `NORM_W` for every plan that fits the old square**, and the
editor has only ever stored `view_box: [0,0,1,1]`, so `iconUnit ===
vb.w` there and the rendered pixel size is bit-identical to the
pre-canvas card (verified against the v1.56.0 bundle at a fixed view:
`3.400 / 3.091 / 6.182 / 12.364 cqw`, i.e. `28.52 / 26.11 / 50.22 /
98.44 px`, both bundles);
* **proportional to an outsized plan**, so a runaway plan gets markers
of the same apparent size as an ordinary one.
Everything else is untouched: the per-device multiplier `marker.size`
and the kiosk icon/font scales still feed `--dev-size`, and every
satellite (badges, LQI chips, presence rings, ripples) still derives
from `--dev-size`. The full card and the static
`houseplan-space-card` call the same `iconCqw()` — the static card has
no zoom, but its frame is the content now, so a bare `iconPct` would
have made its markers shrink as the frame tightened.
**Auto-placement spacing** (`defaultPositions` -> `declump`) is measured
in render units and uses the same `iconUnit`, so the icon's footprint
and the distance markers are pushed apart by can never drift apart — and
the outlier vote reaches the spacing through the very same call.
## §7 Adaptive grid
The drawing grid is a dot pattern at `pitch = NORM_W / GRID_N`. On a
plan several canvases wide, zoomed out, the dots merged into a grey
wash. `gridLevels(pitch, pxPerUnit, minPx)` (pure, unit tested) picks:
* `fine` — the smallest multiplier from `1, 2, 5, 10, 20, 50, 100,
200, 500, 1000` whose on-screen step is at least `minPx` (7 px);
finer dots are simply not drawn;
* `coarse` — the next multiplier that is at least `5 x fine`, drawn
bigger/darker, so the eye keeps a scale reference (the usual CAD
every-5th/10th-line convention);
* `null` when even the coarsest step would be sub-pixel — then there
is no grid at all rather than a grey fog.
The grid rectangle also follows the **view**, not the old `view_box`,
so it is there wherever you pan.
The dots are a **hint, not content** (owner 2026-08-04): at full strength
they argued with the plan on white paper. `.griddot` is `opacity: 0.35`,
`.griddot.major` `opacity: 0.5` (styles.ts) — both muted, the CAD
hierarchy kept, coarse nodes still denser than fine ones. The grid lives
in the editors only; View never draws it (`smoke_grid_fade`).
## §8 Toolbar
The middle button of the zoom control was "Reset zoom" (`_resetZoom`,
disabled at zoom 1). It **is** the fit-everything action, so it was
re-labelled rather than duplicated: `title.zoom_fit` — "Fit all" /
«Вписать всё», icon unchanged (`mdi:fit-to-page-outline`), and it is no
longer disabled at zoom 1 (at zoom 1 off-centre it still has work to
do). It fits `core` — the same rectangle the plan opens with. Far
objects are reached through the outlier hint's **Show** action, which
fits `all`.
## §9 Drag limits and the snap contract (dev, DEV-B58)
### 9.1 One bound, and it is the backend's
v1.57.0 freed the FRAME and the DRAWING, but not the drag handlers. Two
of them still clamped, and the owner and a user hit both:
| Handler | Old clamp | Effect |
| --- | --- | --- |
| `_pointerMove` (device marker) | `_baseVb()` ± a 0.8 % inset — the CONTENT FRAME | 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) | `_spaceModel().vb` — the space's STORED `view_box` | worse: that is `[0,0,1,1]` for every plan the card has ever written, i.e. literally the old square. A room drawn at 2.5 had a name that could not reach its own room |
| `_decorCommitDraft` / decor text anchor | *none at all* | asymmetric with `_decorMoveUpdate`, which did clamp — a draft could be born outside the range the mover then refused to leave |
The rule now: **an editor gesture has exactly one bound, `±CANVAS_LIMIT`
(±5000 normalised, ±`SANE_LIMIT` in render units), and it is the same
number `validation.py` enforces.** It is a garbage limit — insurance
against a stored `1e100` — and never a frame. `clampCanvasR` /
`clampCanvasN` (`space-geometry.ts`) are the only two functions that may
impose it, and `_snap()` applies `clampCanvasR` on the way out, so every
gesture that goes through the snap is bounded by construction.
Room drawing, split, resize and opening placement had no clamp before
and still have none of their own — they inherit it from `_snap()` /
`clampCanvasN` at the write.
### 9.2 The grid step never changed
`_gridPitch = NORM_W / GRID_N = 1000 / 240`. Both constants; neither
depends on the content frame, the view, the zoom, `view_box`, or
`cell_cm`. `git log -S` confirms neither has been touched since v1.4.0
(the one historical change, `GRID_N` 120 → 240, halved the step, so the
old nodes stayed a subset of the new ones and every position was
preserved).
**So the infinite canvas did NOT move any existing element off the
grid.** `gridLevels()` (§7) chooses which multiples of that pitch are
still legible at the current zoom — it changes what is DRAWN, never what
is SNAPPED TO. An element that looks off-grid is off-grid because it was
placed by something that never snapped, not because the lattice moved.
### 9.3 What snaps, and to what
Two kinds of element, because a door rounded to a grid node while its
wall runs diagonally is broken geometry, not a tidy plan:
**GRID-BOUND — rounded to the nearest node:**
| Element | Where |
| --- | --- |
| the backdrop picture: move (its top-left corner) and corner scale (the dragged corner, along the longer side) | `_bdMove` → `_snap` / `snapToGrid` (docs/BACKDROP.md §5) |
| room vertices (draw tool) | `_markupClick` → `_snap` |
| split tool's interior vertices | `_splitClick` → `_snap` |
| resize edge drag / corner scale | `_rszMove` → `_snap` |
| decor draft endpoints, text anchor | `_decorPointerDown` / `_stagePointerMove` → `_snap` |
| decor move | `_decorMoveUpdate` → `_snap` of the resulting ANCHOR |
| device markers | `_savePos` |
| room labels | `_labelMove` → `_savePos` |
| auto-placed markers (`defaultPositions`), the `spaceCenter` fallback, an undragged room label | `snapPt` |
**WALL-BOUND — projected onto the wall, then the offset ALONG the wall
quantised to the same step, measured from the wall's first corner:**
| Element | Where |
| --- | --- |
| openings, placed and dragged | `snapToWall(..., { step, length })` |
| split tool's points ON a wall | `snapPointAlongPoly` |
On an axis-aligned wall whose corners are on the grid — every wall the
editor itself draws — the two rules give the same point. An opening is
also kept inside its wall by half its own length.
Three things were fixed here besides the new coverage:
* `_decorMoveUpdate` used to snap the **delta**, which preserves any
off-grid offset the shape already had for ever, one step at a time.
It snaps the resulting anchor now, so one drag is enough.
* `defaultPositions` / `labelPos` / the `spaceCenter` fallback placed
auto elements at centroids, which are not nodes for an odd-sized or
polygonal room. These were the most likely source of "some elements
are between the points" on an untouched plan.
* `snapToGrid` and `snapR` now return a value that is already on a node
**bit-identical**. The round trip through a non-dyadic pitch
(1000/240) otherwise turns an exact `500` into `500.00000000000006`,
and "is this on the grid?" starts answering no.
### 9.4 Shift
`Shift` suspends the snap for the duration of the gesture, everywhere:
`_snap(p, ev)` reads `ev.shiftKey`, `_savePos(..., shift)` takes it, and
`snapToWall` is called without `step` when it is held. It keeps its two
older meanings too — it opts out of the opening's centre magnet, and it
turns the compass to coarse 15° steps.
### 9.5 «Выровнять всё по сетке» — an ACTION, not a migration
Existing plans may hold coordinates between the nodes. The card does
**not** round them on update. Instead, general settings grow a **Grid**
group with one button; it opens a confirmation that states how many
elements will move and by how much at most, warns that there is no undo,
and only then writes.
Why an action rather than a silent migration:
1. It 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 touches stored geometry cannot be rolled back by
downgrading the card. An action can simply not be pressed.
`alignAllToGrid(spaces, layout)` (`src/align-grid.ts`) is pure: it
copies its input, never mutates it, and returns the new spaces, the new
layout and the report. The dialog therefore measures and commits the
**same object** — the numbers it promises cannot differ from what it
does. The write is one `config/set` plus the layout updates, in one go.
Guarantees, all covered by `test/align-grid.test.mjs`:
* every grid-bound element ends on a node; a rect's FAR corner too (a
snapped *size* on an off-grid origin leaves the other side between
the nodes);
* an opening ends on its wall, at whole steps along it, inside it, and
**with the wall's own angle** — the angle is written, so it is part of
the diff (AUD-158B1-02: an opening already on its wall with a wrong
angle used to be returned changed inside `changed: false`, which made
it unfixable);
* a stray opening with no wall within 6 steps is left exactly where it
is rather than teleported;
* **idempotent**: a second run reports `moved: 0`, `changed: false`, and
returns objects deep-equal to the first run's;
* the report is an **upper bound**, not a sample (AUD-158B1-01).
### The report is a promise
The confirmation is the only gate in front of an action with no undo, so
`maxShift`/`maxShiftCm` must never be smaller than what the run does:
* displacement is measured on the geometry **actually written back** —
all FOUR corners of a rect, minimum-size correction included. The two
corners nobody used to measure are exactly the two that can be worst:
they carry the X error of one side together with the Y error of the
other, which is √2 of either;
* an opening is measured on its **ends**, flip-invariantly, so turning
it in place costs what it really costs and a 180° rewrite costs
nothing;
* the maximum is accumulated in **centimetres**, each space through its
own `cell_cm`, and the report names the space it belongs to. One
normalised maximum converted through the *first* space's cell size
promised 2.5 cm for a vertex that moved 50 cm on a 100 cm floor;
* the dialog rounds the last tenth **up** and, on a multi-space plan,
says which space the maximum is in; openings corrected in angle alone
are counted on a line of their own.
There is no undo, and the dialog says so: the card keeps no snapshot of
the previous geometry. Re-running the action does not undo it either —
it is a projection, and a projection is not invertible.
## Every place that assumed the unit square
| Place | Assumption | Decision |
| --- | --- | --- |
| `contentBounds` envelope `-25 %..125 %` | content outside the square does not count | **removed** — replaced by §4.1 outlier rejection |
| `_baseVb()` `if (mode !== 'view') return m.vb` | editors need the whole square to have room to draw | **removed** — the content frame plus §5 pan slack and 3x zoom-out gives more room than the square ever did |
| `_baseVb()` `if (m.bg) return m.vb` | image plans frame on the square | image rect is now just one content item (§4) |
| `--icon-size` scaled by `vb.w / view.w` | the canvas is what an icon is a fraction OF | numerator becomes `iconUnit()`; the icon still scales with the plan (§6) |
| `defaultPositions` `minDist` from `NORM_W` | one canvas = one plan | `iconUnit()` (§6) |
| `markerPos` / `_pos` fallback = `view_box` centre | a device with no position belongs in the middle of the square | `spaceCenter()` — the middle of the content |
| grid `<rect>` over `vb` | the grid ends with the square | rect follows the view (§7) |
| grid pitch fixed | fine at 1 canvas wide | `gridLevels()` (§7) |
| `_clampView` pinned content over the scene | you cannot pan past the edge | §5 pan slack |
| `_stagePointerMove` panned only while `zoom > 1` | below 100% the content already covered the scene, so a drag had nowhere to go | **removed** — §5, panning at every zoom |
| `ZOOM_MIN = 0.4` | fraction of the square | `MIN_ZOOM = 1/3` of the content frame (§5) |
| `_decorMoveUpdate` clamp `-0.25 .. 1.25` | decor may hang a quarter past the edge | clamp widened to the sane range (`+/-CANVAS_LIMIT`) — corruption insurance, not a frame |
| `_pointerMove` clamp to `_baseVb()`, `_labelMove` clamp to `view_box` | a marker/label belongs inside the canvas | **removed** — §9.1; missed in v1.57.0 and reported by the owner |
| static card `aspect-ratio` + `viewBox` from `space.vb` | the static card frames the square | `spaceFrame()` — same content frame as the full card |
| `validation.py` `+/-4`, `_EXTENT <= 4`, decor `-1..2`, opening `length <= 1` | the square plus slack | §3 |
| `safeViewBox` fallback `[0,0,1,1]` | a broken `view_box` means the square | kept — it is only the last-resort hint (§4) |
| `fitInSquare` (image placement) | image is centred in the square | **kept** — it defines the image's own rectangle in canvas units, which is exactly what §4 wants as a content item. Since v1.58.0 it is only the DEFAULT placement: `planRect()` adds the owner's `plan_x`/`plan_y`/`plan_scale` on top, and the moved rectangle is what §4 counts (docs/BACKDROP.md) |
| image plan papers the image rect | the picture IS the sheet | **removed** in v1.58.0 — the opaque paper is the room contours in every case, and the picture is drawn on top of it (docs/BACKDROP.md §3) |
| `_spaceH` / `_decorH` = `NORM_W` | the canvas is square | **kept** — this is the coordinate system's aspect, not a frame |
| `_gridPitch = NORM_W / GRID_N` | grid pitch is tied to the canvas unit | **kept** — the pitch is the real-world cell (`cell_cm`), it must not change with the plan's size |
| sun wedges / glow radii / resize maths | all in render units, relative to their own geometry | **unaffected** — verified: no `NORM_W`-relative constants |
## What is deliberately NOT done
* No new stored field. The frame is derived every time; there is
nothing to migrate, nothing to keep in sync, nothing to corrupt.
* `view_box` is still WRITTEN as `[0,0,1,1]` on space creation and is
still required by the schema — removing a required field is a
breaking storage change for old clients and buys nothing.
* The outlier hint has no "hide this object" action. Deciding what to
do with a stray marker is the device editor's job.
+169
View File
@@ -1,5 +1,174 @@
# Changelog
## v1.58.0 — 2026-08-04
Minor release: the backdrop picture becomes a movable, scalable object,
the opaque plan sheet is redefined as the room contours, the decor
drawing tools stop stealing clicks, and «Align everything to the grid»
tells the truth about what it is going to do.
### The backdrop picture can be moved and scaled (docs/BACKDROP.md)
- **The plan image is no longer nailed down.** In the Background
editor the picture gets a transform frame: drag it by its body to
move it, pull a corner to resize it evenly (proportions kept, the
opposite corner stays put). A live badge states the picture's real
size in metres (or feet) while you drag, through the space's
`cell_cm`. Position and size land on the grid; Shift steps off it.
Nothing else moves — rooms, doors, devices and decor stay put — and
there is no rotation.
- Three new optional space fields: `plan_x`, `plan_y`, `plan_scale`.
Their absence is exactly the old behaviour, so **existing plans
render bit-identically and nothing is migrated**. «Вернуть картинку»
in the toolbar clears them.
- **The opaque plan sheet is now always the room contours.** It used
to be the image rectangle whenever a picture was attached; now the
picture is drawn ON the sheet — above it, below the walls. The
scene colour (`bg_color`, the `daynight` sky) therefore reaches the
exterior walls on an image plan too. Consequence, deliberate: a
transparent picture over a space with no rooms drawn shows the
scene background through itself.
- «Вписать всё» counts the moved and scaled picture, so it can no
longer be left off screen.
### Drawing tools no longer grab the shape under the cursor
- **A drawing tool owns the canvas.** With Line, Rectangle, Oval or
Text picked in the decor editor, clicking on an existing figure now
starts a NEW figure instead of selecting the old one — so a line can
begin exactly on the end of another line, and a rectangle can be
drawn on top of a filled one. Double-clicking a text under a drawing
tool no longer opens it for editing either. The same inertness
applies in the «Картинка-подложка» tool, where a decor shape lying
over the plan must not block the picture's own drag.
- «Выбрать» and «Стереть» are unchanged: shapes stay clickable there,
which is where selecting and deleting belong.
### «Align everything to the grid» keeps its promise (AUD-158B1-01)
The action has no undo, so its confirmation dialog is the only safety
there is — and it was understating the damage.
- **The maximum shift is measured on the geometry that is actually
written**, not on an intermediate one. A rect was measured by its
origin and its far corner only — so the minimum-size correction that
widens a too-thin box afterwards went unmeasured, and the two corners
that carry the X error of one side together with the Y error of the
other were never looked at at all. An ordinary box could move √2
times further than promised; a box thinner than one grid step, much
further.
- **Each space is converted through its own `cell_cm`.** The dialog
used to take one normalised maximum and multiply it by the cell size
of the FIRST space: a plan whose ground floor is drawn at 5 cm per
cell and whose attic is at 100 cm promised 2.5 cm for a vertex that
moved 50 cm. The report now carries the maximum in centimetres and
the space it belongs to, and the dialog names that space when there
is more than one.
- The last tenth of a centimetre is rounded **up**: the promise can
never be smaller than the deed.
### An opening whose only error is its angle can now be fixed (AUD-158B1-02)
- A window sitting exactly on its wall but holding a wrong `angle` used
to come back as `changed: false` — the dialog said there was nothing
to move and offered no button, while the returned plan differed from
the one given. Such openings could never be aligned. The angle is
part of the diff now; the dialog counts them separately, and the
displacement is measured on the opening's ENDS, so turning it in
place is not free in the report either. Turning an opening end over
end (180°) is still counted as a correction but displaces nothing.
## v1.57.0 — 2026-08-04
Minor release: the canvas becomes infinite — no more "plan size", no
more edge to run past — plus a tap action for curtains and blinds,
a polish pass over the sun, live rulers while placing openings, and a
warm re-mount that no longer flashes.
### The infinite canvas (docs/CANVAS.md)
The idea of a "grid size" or "plan size" is gone. The canvas is
conceptually unbounded — any coordinate is legal, with a `+/-5000`
limit kept only as garbage insurance (about 60 km of plan at the
card's own scale).
- **If your plan ran off the edge, it now just works.** Some plans
grew past the old square and devices simply could not be placed
outside it; the only workaround was to redraw everything. That
limit no longer exists: draw, drag and place devices anywhere, and
nothing needs to be redrawn. Your stored plan is untouched — the
coordinates mean exactly what they always meant, and there is no
migration.
- **Existing plans open exactly as before.** Verified on a real
three-floor config: the opening frames come out bit-for-bit
identical to v1.56.0.
- **The opening view is derived from what is actually drawn.**
`view_box` is now an optional hint for the very first frame only;
after that the starting view is always computed from the real
content, with outliers rejected so one stray room cannot shrink the
whole plan.
- **Pan at any zoom, in every editor.** Dragging the plan used to work
only above 100% zoom; now it works at any zoom and in all editing
modes, with one screen of slack around the content.
- **Zoom out to 3x the content**, so you can see the whole plan and
the space around it.
- **"Fit everything" button** — one tap frames all the content again.
- **A "home is that way" arrow** appears when you have panned away
from the plan, pointing back to it.
- **An adaptive grid.** The grid picks its step from the zoom
(1 / 2 / 5 / 10 ... 1000) instead of a fixed cell, and the dots stay
a muted hint rather than a pattern that competes with the plan.
### Curtains and blinds
- **New "Open/close" tap action for covers.** Closed opens, open
closes, and a cover in motion stops. Offered only for the
unprotected device classes — garage doors, doors and gates stay on
the info dialog, on purpose.
- **The icon itself tells you the state**: it morphs between the open
and closed shape (a table of cover classes, plus aliases resolved
from the base icon), and pulses with a soft ring while the cover is
travelling.
- **A curtain never wears a coloured plate.** No state fill behind a
cover marker, ever.
- **The cover is found among all of the marker's entities**, not just
the primary one — which is the common case for Aqara and
zigbee2mqtt devices — and an explicit "Open/close" choice always
wins the indication.
### Sun polish
- **Brighter, shorter rays.** Opacity up to 0.30, length down 30%,
with crisp sides again.
- **Light fades only along the ray**, and is guaranteed to reach zero
before the wedge ends — the wedge is now clipped by distance along
the ray, so the "bright rim" that appeared with a low, oblique sun
is gone.
- **A hard 3 degree threshold.** The rays appear and disappear at 3
degrees of solar elevation over a two-second fade, instead of
creeping in.
- **The day/night sky catches up with the sun** when you come back to
the tab: a gap of more than 3 degrees is repainted at once, while
normal movement keeps breathing smoothly.
### Everything else
- **Live rulers and the centre magnet while PLACING an opening**, not
only while dragging one — with the distances measured along the
owning room's own edge.
- **A warm re-mount.** When Lovelace recreates the card on reconnect
there is no preloader and no flash of the plan any more, and a
dropped WebSocket never blanks a plan that is already on screen.
- **Room borders and names default to dark grey `#55606c`** instead of
the accent blue. If you picked a colour explicitly, nothing changes.
- **An "About" block at the end of the general settings** — card
version, GitHub and Telegram links.
- **The icon angle now steps by 5 degrees.**
- Audit fixes: the sun-ray cache survives local edits, hidden devices
no longer stretch the frame, the editor's grown frame does not leak
into view mode, an outlier room no longer inflates icon sizes, and a
kiosk pan stays a pan instead of flipping to the next floor.
## v1.56.0 — 2026-08-03
Minor release: the sun comes to the floor plan — a compass, a day/night
+169
View File
@@ -6,6 +6,175 @@
> **Правило проекта:** оба файла пополняются в одном коммите с самим
> изменением — как и остальная документация (см. docs/STATUS.md).
## v1.58.0 — 2026-08-04
Минорный релиз: картинка-подложка становится объектом, который можно
двигать и масштабировать, непрозрачный лист плана переопределён как
контуры комнат, рисующие инструменты декора перестают перехватывать
клик по чужой фигуре, а «Выровнять всё по сетке» честно говорит, что
собирается сделать.
### Картинку-подложку можно двигать и масштабировать (docs/BACKDROP.md)
- **Картинка плана больше не прибита гвоздями.** В редакторе подложки
у неё появляется рамка трансформации: тянешь за тело — двигается,
тянешь за угол — меняет размер равномерно (пропорции сохраняются,
противоположный угол стоит на месте). Пока тянешь, плашка пишет
реальный размер картинки в метрах (или футах) — через `cell_cm`
пространства. Положение и размер ложатся на сетку, Shift снимает
привязку. Больше ничего не двигается: комнаты, двери, устройства и
декор остаются на месте; поворота нет.
- Три новых необязательных поля пространства: `plan_x`, `plan_y`,
`plan_scale`. Их отсутствие — ровно прежнее поведение, поэтому
**старые планы рисуются один в один и ничего не мигрируется**.
Кнопка «Вернуть картинку» в панели их удаляет.
- **Непрозрачный лист-бумага теперь всегда строится по контурам
комнат.** Раньше при картинке бумагой был её прямоугольник; теперь
картинка рисуется НА бумаге — над ней и под стенами. Цвет сцены
(`bg_color`, небо `daynight`) поэтому доходит до наружных стен и на
плане с картинкой. Осознанное следствие: у прозрачной картинки без
нарисованных комнат сквозь неё виден фон сцены.
- «Вписать всё» учитывает сдвинутую и отмасштабированную картинку —
потерять её за экраном больше нельзя.
### Рисующие инструменты больше не хватают фигуру под курсором
- **Рисующий инструмент владеет холстом.** Когда в редакторе декора
выбрана Линия, Прямоугольник, Овал или Текст, клик по существующей
фигуре начинает НОВУЮ фигуру, а не выделяет старую — поэтому линию
можно начать ровно на конце другой линии, а прямоугольник нарисовать
поверх залитого. Двойной клик по тексту под рисующим инструментом
тоже больше не открывает его на правку. Та же инертность работает в
инструменте «Картинка-подложка»: лежащая поверх плана фигура декора
не должна мешать тащить саму картинку.
- «Выбрать» и «Стереть» не изменились: там фигуры по-прежнему
кликабельны — выделять и удалять положено именно ими.
### «Выровнять всё по сетке» держит обещание (AUD-158B1-01)
У действия нет отмены, поэтому диалог подтверждения — единственная
защита, а он занижал масштаб происходящего.
- **Максимальный сдвиг измеряется по той геометрии, которая
записывается**, а не по промежуточной. У прямоугольника мерились
только исходный угол и дальний — поэтому доводка до минимального
размера, которая потом раздвигает слишком узкую коробку, в замер не
попадала, а два угла, где ошибка по X одной стороны складывается с
ошибкой по Y другой, не смотрели вовсе. Обычная коробка могла
сдвинуться в √2 раза дальше обещанного, а коробка тоньше шага сетки —
намного дальше.
- **Каждое пространство переводится через свой `cell_cm`.** Диалог брал
один нормализованный максимум и умножал его на размер клетки ПЕРВОГО
пространства: на плане, где первый этаж нарисован по 5 см на клетку, а
мансарда по 100 см, обещали 2,5 см вершине, которая уезжала на 50 см.
Отчёт теперь несёт максимум в сантиметрах и пространство, которому он
принадлежит, а диалог называет это пространство, если их больше
одного.
- Последняя десятая сантиметра округляется **вверх**: обещание не может
оказаться меньше сделанного.
### Проём, у которого неверен только угол, теперь можно исправить (AUD-158B1-02)
- Окно, стоящее ровно на своей стене, но с неправильным `angle`,
возвращалось с `changed: false` — диалог писал «двигать нечего» и не
показывал кнопку, хотя возвращённый план отличался от поданного.
Такие проёмы нельзя было выровнять никогда. Угол теперь входит в
разницу; диалог считает такие проёмы отдельно, а смещение меряется по
КОНЦАМ проёма, так что поворот на месте в отчёте тоже не бесплатен.
Разворот проёма на 180° по-прежнему считается исправлением, но ничего
не смещает.
## v1.57.0 — 2026-08-04
Минорный релиз: холст становится бесконечным — больше нет «размера
плана» и края, за который нельзя выйти, — плюс тап-действие для штор и
жалюзи, полировка солнца, живые линейки при размещении проёмов и
тёплый ре-маунт без миганий.
### Бесконечный холст (docs/CANVAS.md)
Понятие «размер сетки» или «размер плана» упразднено. Холст условно
бесконечен — допустима любая координата, а предел `±5000` оставлен
только как страховка от мусора (около 60 км плана в масштабе самой
карточки).
- **Если ваш план «уехал» за границу — теперь он просто работает.**
Некоторые планы выросли за пределы старого квадрата, и устройства
снаружи было не поставить; единственным выходом было перерисовать
всё заново. Этого ограничения больше нет: рисуйте, двигайте и
ставьте устройства где угодно, перерисовывать ничего не нужно. Сам
сохранённый план не тронут — координаты значат ровно то же, что и
раньше, никакой миграции.
- **Существующие планы открываются в точности как прежде.** Проверено
на реальном конфиге с тремя этажами: стартовые рамки совпадают с
v1.56.0 бит-в-бит.
- **Стартовый вид строится по тому, что реально нарисовано.**
`view_box` стал необязательной подсказкой только для самого первого
кадра; дальше вид всегда считается по фактическому содержимому, с
отбраковкой выбросов — одна случайная комната больше не сжимает
весь план.
- **Панорамирование при любом зуме и во всех редакторах.** Раньше
план можно было тащить только выше 100%; теперь — на любом
масштабе и в любом режиме правки, с запасом в один экран вокруг
содержимого.
- **Зум-аут до 3× содержимого** — видно и весь план, и воздух вокруг
него.
- **Кнопка «Вписать всё»** — одно нажатие возвращает кадр по всему
содержимому.
- **Стрелка «дом там»** появляется, когда вы уехали от плана, и
показывает дорогу обратно.
- **Адаптивная сетка.** Шаг сетки выбирается по зуму
(1 / 2 / 5 / 10 … 1000) вместо фиксированной клетки, а точки
остаются приглушённой подсказкой и не спорят с планом.
### Шторы и жалюзи
- **Новое тап-действие «Открыть/закрыть» для штор.** Закрытая —
откроется, открытая — закроется, а штора в движении остановится.
Предлагается только для незащищённых классов: гаражные ворота,
двери и калитки намеренно остаются на информационном диалоге.
- **Состояние видно по самому значку**: он морфится между открытой и
закрытой формой (таблица классов штор плюс алиасы по базовой
иконке) и мягко пульсирует кольцом, пока штора едет.
- **У шторы никогда нет цветной подложки.** Заливки по состоянию за
маркером cover не будет.
- **Cover ищется среди всех сущностей маркера**, а не только среди
основной — частый случай для устройств Aqara и zigbee2mqtt, — а
явно выбранное «Открыть/закрыть» всегда имеет приоритет индикации.
### Полировка солнца
- **Лучи ярче и короче.** Непрозрачность поднята до 0.30, длина
уменьшена на 30%, боковые грани снова чёткие.
- **Свет затухает только вдоль луча** и гарантированно доходит до
нуля раньше конца клина — клин теперь обрезается по расстоянию
вдоль луча, и «светлый бортик», вылезавший на низком косом солнце,
исчез.
- **Жёсткий порог в 3°.** Лучи появляются и исчезают на трёх градусах
высоты солнца с двухсекундной анимацией, а не вкатываются плавно.
- **Фон «день/ночь» догоняет солнце** при возврате на вкладку: разрыв
больше 3° перекрашивается мгновенно, обычный ход по-прежнему дышит
плавно.
### Прочее
- **Живые линейки и магнит центра теперь и при РАЗМЕЩЕНИИ проёма**, а
не только при перетаскивании; расстояния считаются по ребру своей
комнаты.
- **Тёплый ре-маунт.** Когда Lovelace пересоздаёт карточку при
реконнекте, прелоадер больше не показывается и план не мигает, а
обрыв WebSocket никогда не очищает уже показанный план.
- **Цвет границ и названий комнат по умолчанию — тёмно-серый
`#55606c`** вместо синего акцента. Если цвет выбран явно, ничего не
меняется.
- **Блок «О карточке» в конце общих настроек** — версия, ссылки на
GitHub и Telegram.
- **Шаг угла значка — 5°.**
- Находки аудитов: кэш солнечных лучей переживает локальные правки,
скрытые устройства не растягивают кадр, кадр редактора не протекает
в режим просмотра, комната-выброс не раздувает размер иконок, а
киоск-пан остаётся паном и не перелистывает этаж.
## v1.56.0 — 2026-08-03
Минорный релиз: на план приходит солнце — компас, фон «день/ночь» и
+95
View File
@@ -51,6 +51,15 @@ the old behaviour until an editing client materialises it.
- "Show hidden devices" (rename of "Show all") is LOCAL, ephemeral state of
the current tab.
- Room LQI counts hidden devices (owner's decision).
- Hidden devices are NOT content for the CONTENT FRAME (docs/CANVAS.md §4,
audit DEV-2C947-01). The frame is presentation: an object the plan does not
draw may not decide what the plan opens on. Hiding a marker that had once
been dragged into the yard used to leave the visible house a dot in the
corner of a frame 112x too wide — on the full card and on
`houseplan-space-card` alike. They keep their place in the auto-grid roster
(so a visible neighbour does not move when one is hidden) and in every
aggregation listed here; only the frame stops seeing them, ghosts in the
device editor included — reaching a ghost is the pan slack's job (§5).
- Light fill and glow do NOT count hidden devices — an invisible device casts
no visible light (owner's decision). Room climate is registry-wide and
unaffected, as before.
@@ -59,3 +68,89 @@ the old behaviour until an editing client materialises it.
one way to hide); a virtual device's "Delete" remains a real deletion.
- Duplicate names are still numbered, light groups still fold — those are
aggregation, not hiding.
## What a marker SHOWS
A marker's live indication — the yellow «on» plate, the «open» frame (never
on a cover, see below), the breathing `covermove` ring, the state-morphed
icon, the ripple — speaks for ONE entity of the device, resolved in this
order (`_stateClass` / `_actEntity`):
1. the device's **cover**, when the marker's tap action is explicitly
«Открыть/закрыть» (`tap_action: 'cover'` — `coverEntityOf`, the same helper
and the same entity the tap drives). It wins over EVERYTHING below;
2. the marker's bound **controls**, if it has any (a stateless remote or a
virtual wall switch mirrors what it drives, not itself);
3. a **lit light** among its entities (owner's principle 2026-07-29: the glow
spot and the badge may never disagree);
4. otherwise the **primary** entity (`primaryEntity`).
Rule 1 was added 2026-08-04 on the owner's report: his Aqara «Roller shade
driver E1» curtains ship the `cover.*` hidden by the integration and a visible
`switch.*_reverse_direction`, so `primaryEntity` picked the service switch —
the plan showed no ring while a curtain travelled, no `curtains` /
`curtains-closed` morph, and a yellow «включено» plate whenever the
reverse-direction option happened to be on. The tap had already been
taught to find the cover among ALL the device's entities (2026-08-04, the
same `coverEntityOf`); the indication now follows it.
**Why the cover is FIRST and not third** (audit DEV-1DA1-01, fixed the same
day). It went in below `controls` and the lit light at first, and that left
the contract below («у штор не должно быть жёлтой подложки НИКОГДА») with two
holes big enough to walk through: a mixed device — a lamp that also ships a
blind — told «Открыть/закрыть» went yellow off its own lit `light.*`, and a
curtain marker with a bound wall switch went yellow off `controls`. In both
the early `return 'on'` never reached the cover branch, so the travelling
curtain also lost its breathing ring, and in glow fill (where the renderer
strips `on` from a shining source) it was left with no indicator at all —
while the tap still drove the cover. A rule that «шторы никогда не жёлтые»
cannot have exceptions decided by the neighbours in the entity list.
**Why it hangs on the explicit action and not on «the device has a cover».**
Choosing «Открыть/закрыть» in the marker dialog is the only statement the card
has that means *this marker IS the curtain* — and the dialog offers that option
for exactly the devices where a cover exists. Tying the indication to it keeps
one answer to «what is this marker»: the option offered, the entity tapped and
the state shown are the same entity, decided in one place. Nothing changes
behind the user's back for a mixed device (a lamp that also owns a blind, a TRV
with a service switch) that was NOT told it is a curtain: it keeps its primary
and the old 2–3–4 precedence, controls and lit light included, until its owner
says otherwise. Two edges follow from the wording: a marker set to
«Открыть/закрыть» whose device carries no `cover.*` at all falls through to
rules 2–4 (the statement is only as strong as the entity behind it), and a
curtain left on «Инфо-карточка» still indicates its primary — one click in the
dialog away, and the honest reading of what the marker was told it is.
### A cover is never painted (owner 2026-08-04)
«У штор не должно быть жёлтой подложки НИКОГДА, индикация открыто/закрыто за
счёт морфинга иконки.» For the `cover` domain — and for the cover an
«Открыть/закрыть» marker indicates, rule 3 above — `_stateClass` returns no
plate class in any state:
| cover state | plate | ring | icon |
|---|---|---|---|
| `closed` | neutral | — | closed glyph |
| `open`, ajar (`open` + position) | neutral | — | open glyph |
| `opening`, `closing` | neutral | `.covermove` breathes | open glyph |
| `unknown` / no state | neutral | — | base icon, no morph |
| `unavailable` | neutral, faded (`.unavail`) | — | base icon |
Until this the domain shared one branch with `valve` and wore `.dev.open` —
an orange FILLED badge (`--hp-open`), not a mere border — while open or
opening. The open/closed story is now told by the icon alone (`stateIcon` /
`COVER_ICONS`), so the morph has to be exhaustive: every device class maps
its two states to two DIFFERENT glyphs, and a cover with no `device_class` at
all (z2m ships plenty) morphs within the family of its own base icon —
`mdi:roller-shade` (what the name rule «штор|curtain|blind|shade» hands out),
`mdi:garage-variant`, `mdi:blinds-horizontal`, `mdi:door`. The one place a
hand-picked icon is not final: a cover whose custom icon IS one of those pair
members morphs inside that pair — never traded for another family — because
otherwise choosing an icon would silently switch the marker's only indicator
off.
WHAT KEEPS THE FRAME. `.dev.open` is untouched everywhere else: door / window
/ garage_door / opening binary sensors, an unlocked `lock`, and `valve`. A
valve is deliberately left out of the owner's rule — no icon pair morphs for
it, so the frame is the only thing it has to say «открыт» with. If the owner
ever wants the two domains to read alike, a valve needs an icon pair first.
+5 -4
View File
@@ -11,16 +11,17 @@
> (versions, publication, infrastructure), DEVELOPMENT.md for new gotchas,
> ARCHITECTURE.md for design changes, ROADMAP.md when plans move.
## Snapshot (2026-08-03)
## Snapshot (2026-08-04)
| Item | State |
|---|---|
| Version | **v1.56.0** everywhere (manifest, const.py, package.json, CARD_VERSION); deployed to the home instance. Minor release: the Sun feature (docs/SUN.md — north compass, day/night backdrop, window light wedges, optional weather cloud cover, opaque room-contour paper), opt-in room temperature from climate devices, design-token pass over styles.ts + native ha-switch/ha-slider, opening-drag shoulder rulers with a center magnet, and the DEV-B701-01 sun-ray cache fix |
| Workflow | Since 2026-07-22: minor changes go to branch **`dev`** (build + smokes → deploy home → commit → push, NO release); releases are batched on the owner's command (merge dev→main, one tag, one release with a summary changelog, CI checked on dev beforehand) |
| Version | **v1.58.0** everywhere (manifest, const.py, package.json, CARD_VERSION) — stable release, tag `v1.58.0` on `main`, GitHub Release with `prerelease=false`; **no direct deploy** — the home instance picks it up through HACS by tag. Contents: the backdrop picture becomes a movable/scalable object (docs/BACKDROP.md — transform frame in the «Картинка-подложка» tool, even corner scaling, live size in metres, grid snap with a Shift bypass, new optional fields `plan_x`/`plan_y`/`plan_scale`, counted by "fit everything"); **NEW PAPER RULE — the opaque plan sheet is always the room contours**, the picture is drawn on top of the sheet and under the geometry (the "paper = image rectangle" branch is gone); decor drawing tools (line/rect/oval/text) and the backdrop tool no longer select the shape under the cursor — existing figures are inert there, select/erase unchanged; the two P2 fixes from the beta.1 audit of «Выровнять всё по сетке» — the confirmation now promises a true upper bound measured per space in its own `cell_cm` (AUD-158B1-01), and an opening whose only error is its angle is a real change that can be applied (AUD-158B1-02). Shipped through v1.58.0-beta.1 (pre-release, 2026-08-04). Previous stable: v1.57.0 "the infinite canvas" |
| Workflow | Since 2026-07-22: minor changes go to branch **`dev`** (build + smokes → deploy home → commit → push, NO release); releases are batched on the owner's command. **Since 2026-08-04 there is also a pre-release track:** bump to `X.Y.Z-beta.N`, tag the `dev` commit, publish a GitHub Release with `prerelease=true` — `main` is not touched and nothing is copied to the home instance by hand; HACS delivers it on the beta channel |
| GitHub | https://github.com/Matysh/houseplan-card — **`main` carries every published release, the latest tag is the current version above**; `dev` is where work lands and is merged into `main` at release time (so `dev` is normally equal to or ahead of `main`, never behind). Push via SSH key `ha_jb` (remote git@github.com:…); API releases via the fine-grained PAT in `~/.git-credentials` (Contents R/W, issued 2026-07-23) |
| CI | validate.yml (hacs + hassfest + frontend + backend) green; release.yml attaches the bundle on release publish |
| HACS | Custom repository works. **Inclusion PR: hacs/default#9004** — open, valid, labeled, mergeable clean, never drafted. Queue: 1212 open, 835 older than ours. Merge rate COLLAPSED: 75 in July but almost all in the first decade, 0 in the last week (checked 2026-07-29) — maintainers process in rare bursts; ETA unknowable, months at best. Nothing actionable on our side |
| Home instance | ha.jbstudio.pro (SSH port **22222**, key `ha_jb`; HA config root is `/mnt/data/supervisor/homeassistant` — `/config` does NOT exist in this SSH environment), deployed **v1.56.0** via direct copy (HACS custom repo also installed) |
| Home instance | ha.jbstudio.pro (SSH port **22222**, key `ha_jb`; HA config root is `/mnt/data/supervisor/homeassistant` — `/config` does NOT exist in this SSH environment), last direct copy was **v1.57.0**; from v1.58.0 on it updates itself through HACS by tag (no scp) |
| Localization | UI en/ru (src/i18n/*.json), everything user-visible localized incl. kiosk popover |
| Tests | Four layers: frontend unit (`npm test`, node:test over `test-build/`), pure backend (`pytest tests_backend`, runs anywhere), HA-harness backend (same folder, CI only — needs py3.13 + pytest-homeassistant-custom-component), and browser smokes (`demo/smoke_*.mjs`, headless chromium). **Counts are not written down here** — they went stale within two releases while the version line beside them was kept current, which reads as less coverage than exists (review R5-2). Run `npm run inventory` for the current numbers, or read them off the last CI run |
| Vacuums | Live robot vacuums shipped (docs/VACUUM.md): puck, server-side trails with display modes, fit-panel calibration. Verified on a live Dreame X50 Master |
+113
View File
@@ -0,0 +1,113 @@
# Sunlight that reads on a light plan — the spec
Status: **approved in principle, not implemented.** Planned for the release
after v1.58.0. Owner asked for the spec first (2026-08-04); nothing in this
document is code yet. Builds on docs/SUN.md, which describes what ships today.
## The problem
Today a lit window casts a warm translucent wedge (docs/SUN.md). It reads
beautifully on a dark scene — glow fill, night, a dark theme — and almost
disappears on a light one, which is now the common case: `bg_mode: daynight`
makes a clear day **white**, and a hand-drawn plan's paper is white too.
The cause is not opacity, it is physics. Painting light means adding
luminance, and white has none left to give. Raising the alpha does not add
contrast, it only tints the paper towards beige until the whole room looks
dirty. Any fix has to take its contrast from something other than brightness.
## The model: paint the shade, not the light
On a light scene the wedges invert. The lit areas stay untouched paper; the
**rest of the room** gets a light, cool veil. Contrast now comes from the
unlit part, which has plenty of room to go darker, and the wedge becomes a
window of clean paper inside a softly shaded room.
This is how architects draw insolation on white sheets, and it stays
physically honest: a sunlit room really is brighter where the shaft lands.
It also rhymes with the mode we already have — glow paints a dark house with
pools of lamp light; this paints a light house with the shade between shafts.
Geometrically it is the same wedge polygon, used as a hole: a veil rectangle
over the room's polygon, minus the wedges (an even-odd path, or a mask). The
maths of `computeSunRays()` does not change at all — only what we do with the
result.
## Switching between the two
Automatic, by the luminance of what the wedge is drawn on (the paper under
the room, or the scene colour where a picture backdrop shows through):
- luminance below the threshold → **light model** (today's warm wedge);
- above it → **shade model** (veil with wedge-shaped holes).
The threshold sits around mid-grey; the exact value is a visual decision to be
made against real plans, dark and light themes, and the day/night palette.
Whether this is fully automatic or also offered as an explicit choice is an
open question (below).
## Two amplifiers, both models
**A rim on the wedge.** Light is invisible but its boundary is not: a hairline
warm stroke along the two side edges of the shaft, fading out with the shaft
itself. It costs one stroke, works on any background, and gives the shape a
"beam" reading even when the fill is nearly transparent. In the shade model
the same rim marks the edge of the veil hole.
**A mark on the window.** Independent of the wedges: a lit window gets a
thicker warm stroke and a short arrow along the sun direction. It answers the
question "which windows is the sun in right now" without reading the floor,
and it survives everything the wedge does not — furniture-heavy plans, room
fills, small kiosk scale, a wedge clipped to almost nothing by a narrow room.
## Colour
The veil is cool and desaturated (a blue-grey), never black: a black veil
reads as a printing defect and fights the room fills. Its opacity is small —
the target is a perceptible step, not a dimmed room; the value is to be tuned
visually, in the same order as the wedge alpha today.
The light model keeps its warm ramp near the horizon (docs/SUN.md), but should
lean on **hue rather than luminance** on medium backgrounds: an amber tint
reads as colour even where it cannot read as brightness.
## What must not break
- **Room fills.** Temperature and LQI fills carry meaning; the veil must not
be mistaken for them, and must not shift their perceived colour more than
marginally. The veil goes under the fills, not over.
- **The 3° threshold and the two-second fade** (docs/SUN.md) apply unchanged
to whichever model is active. Crossing the luminance threshold — a theme
switch, day turning to night — must cross-fade between models, never pop.
- **Cloudiness** keeps dimming the effect, in the shade model by thinning the
veil.
- **Editors** stay neutral, as today: no sun, no day/night.
- **The static card and kiosk** must pick the same model as the full card on
the same plan; the choice is a pure function of the background.
- **prefers-reduced-motion**: no cross-fade, switch instantly.
- **Performance**: one veil path per room per render at most, memoised on the
same key as the wedges.
## Open questions for the owner
1. Automatic switching only, or also an explicit setting «свет / тень / авто»
in the space dialog? (Automatic is one less knob; explicit lets someone on
a light theme keep the warm wedges if they like them.)
2. Does the veil cover the whole room, or only the part of the room the sun
could reach at all (the room's window-facing side)? Whole-room is simpler
and reads as "this room is in shade"; partial is subtler.
3. Should the window mark be always on, or follow the same «Solar rays»
toggle?
## Testing notes (for when this is built)
Unit: the model chooser is a pure function of a colour → assert the switch at
the threshold, both sides, plus junk input. The veil-with-holes geometry:
holes equal the wedges, an unlit room gets a plain veil, a room with no
exterior window gets nothing.
Browser: on a white paper plan a lit wedge is measurably lighter than its
surroundings (sample pixels inside and outside the wedge — the current
implementation would fail this, which is the point); on a dark scene nothing
changes versus today; crossing the threshold cross-fades; room fills keep
their identity; the window mark appears exactly on lit windows.
+150 -9
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.
@@ -102,18 +124,123 @@ when BOTH hold:
- the sun is above the horizon (`elevation > 0`), and
- the dot product of the wall's outward normal with the direction
toward the sun is positive (the sun actually faces this window).
toward the sun — the cosine of the angle of incidence — is above
`RAY_MIN_COS` = 0.05, i.e. the sun faces this window AND clears the
plane of its wall by ~2.9° (~87.1° of incidence). Below that there is
nothing to paint: glass reflects almost all of it, and the shaft's
perpendicular depth (`len · cos`, see «Dissolving») would be thinner
than the wall it came through.
The wedge is a quadrilateral cast from the window's span along the
direction AWAY from the sun (light falls inward), clipped by the
The wedge is a PARALLELOGRAM: the window's span extruded by the same
length along the direction AWAY from the sun (light falls inward), so
its far edge is parallel to the wall, clipped by the
room's polygon (`polyclip` intersection, the same dependency
`src/resize.ts` already uses). Its length is `k(elevation)` in window
lengths: ~2.5 at sunrise/sunset tapering to ~0.8 at the zenith
(`0.8 + 1.7·(1 − elevation/90)^1.6`). A linear gradient runs bright at
the window and dissolves inward; the color is warm orange while
`elevation < 10°` and neutral by day; peak opacity is modest (~0.18 —
two overlapping wedges never exceed a readable ceiling). Near the
horizon the opacity ramps in over the first ~2° so wedges never pop.
lengths: ~1.75 at sunrise/sunset tapering to ~0.56 at the zenith
(`0.56 + 1.19·(1 − elevation/90)^1.6` — the v1.56 curve
`0.8 + 1.7·(1 − elevation/90)^1.6` times `RAY_LENGTH_K` = 0.7, owner
2026-08-04: «лучи от солнца сделать короче на 30%»; scaling the whole
curve keeps the "a low sun reaches much further" shape intact). The
color is warm orange while `elevation < 10°` and neutral by day; peak
opacity is `RAY_MAX_ALPHA` = 0.30 (owner 2026-08-03: «лучи поярче,
иногда плохо видны» — raised from 0.18; two overlapping wedges
still stay under a readable ceiling on white paper and on the dark glow
canvas alike).
### Dissolving — along the ray only (owner 2026-08-04)
Two rounds with the owner on the same day:
1. «Проверить, чтобы они всегда плавно рассеивались (сейчас есть
ощущение, что они упираются во что-то невидимое)» — the wedge was
ending on a visible line;
2. «С лучами солнца ты сделал фигню — не надо размывать их боковые
грани» — the first answer to (1) was a Gaussian blur over the whole
wedge, which feathered the SIDES too. Wrong: a shaft of sunlight
through a window has crisp sides. Only its reach fades.
So the falloff is one-dimensional: **along the ray, from the glass
inward, and nothing else.** Three invariants have to hold at once:
1. the whole pane of glass is at peak alpha — light does not start out
half-dark at one end of the window;
2. every ray fades over the same distance, its own `len`;
3. the wedge's far edge lies exactly on an iso-alpha line, so the shaft
dies of its gradient and never of its own outline (that visible
straight «bright kerb» hanging in mid-floor).
**The axis of the fade is the wall's INWARD NORMAL, not the ray.**
The light is a bundle of PARALLEL rays, so the distance a point has
travelled from the glass is `depth / cos`, where `depth` is its
perpendicular distance from the wall and `cos = dir·normal` is fixed
for the whole wedge. That is an affine function of the point, and its
level sets are straight lines PARALLEL TO THE WALL. A linear gradient
whose axis is the normal therefore describes the travelled distance
exactly:
- `x1,y1` = the middle of the window span (any point of the glass —
they all have depth 0);
- `x2,y2` = that point plus `normal · len · cos` — `SunRay.depth`, the
perpendicular depth a ray reaches after running the full `len`;
- a point `source + dir·u` lands on offset `u / len`, whichever ray it
rode in on.
Hence: the glass is all at offset 0 (invariant 1), the alpha at any
point is a function of how far its own ray has run (invariant 2), and
the parallelogram's far edge — parallel to the wall — IS the gradient's
last iso-alpha line (invariant 3). The «30 % shorter» reach is then a
fact about every SIDE of every wedge, at any sun angle.
The stops (`rayStops()`) ease out to **zero at `RAY_FADE_END` = 85 %**
of the axis: `1 → .86 → .60 → .32 → .10 → 0`. The last 15 % of every
wedge is guaranteed empty, so a shaft that ends in mid-air has nothing
left to draw an edge with.
> **DEV-EB173-01 (fixed).** The previous attempt kept the gradient along
> `dir` from the span's midpoint and bent the GEOMETRY to match,
> extruding the two ends of the window by different amounts so both far
> corners projected onto the same point of that axis. It bought
> invariant 3 with the other two: at a grazing sun the ends of the glass
> themselves sat at offsets ±0.879 — one of them fully transparent
> before the shaft even started — and the two sides came out 5.41 and
> 84.19 long (ratio 15.6), the long one 31 % LONGER than the pre-cut 64
> rather than 30 % shorter. One linear gradient along the ray cannot
> satisfy all three; along the normal it satisfies all three by
> construction.
- the two SIDES carry no falloff at all, on purpose. They are hard
lines, because that is what light through a window looks like. There
is **no filter, no `feGaussianBlur`, no `clip-path`** anywhere in the
sun layer — the polygons arrive from `computeSunRays()` already
intersected with the room, so a wall stops the light by geometry;
- where the room outline does cut a still-lit shaft (the opposite wall,
the inner corner of an L, an OPEN boundary) the edge stays crisp:
that is light landing on a wall, and blurring it was the mistake.
Clipping by the room is unchanged; only the visible edge changed.
### The 3° threshold and the 2-second fade
Wedge opacity does NOT depend on elevation any more — the old ramp-in
over the first ~2° is gone. The contract (owner 2026-08-03) is a hard
threshold:
- `elevation < 3°` → NO rays at all;
- `elevation ≥ 3°` → rays at full strength (`rayPeakAlpha`, cloud cover
being the only multiplier).
Crossing the threshold is animated, but on the LAYER, never on the
geometry: the `<g class="sunlayer">` fades in with `hp-sunfade-in` and
out with `hp-sunfade-out`, both exactly 2 s (`RAY_FADE_MS` in
`src/sun.ts` must stay in sync with `styles.ts`). To let the fade-out
play at all, the card keeps the layer mounted with `.out` for those two
seconds and only then drops it. `prefers-reduced-motion: reduce` skips
the animation entirely — the rays are simply there or simply gone.
Everything else that removes wedges — leaving view mode, switching the
feature off, night (`elevation ≤ 0`), rain — is instant: those are not
threshold crossings, and a wedge lingering while you enter the editor
would just be a bug.
Layer order: ABOVE room fills (and the glow layer), BELOW devices and
labels (those live in the HTML `devlayer` anyway). Night
@@ -165,3 +292,17 @@ Backend validation: string or null.
- `custom_components/houseplan/validation.py` — the four settings at
both levels; tests in `tests_backend/test_validation.py`.
- `demo/smoke_sun.mjs` — end-to-end behaviour against the demo rig.
- `demo/smoke_sun_soft.mjs` — the −30 % reach and the "dissolves along
the ray only" contract: the gradient axis is the wall normal and is
`len · cos` long, an offset is exactly how far a ray has run, the
stops die at 85 %, the sides are sharp (no filter on the wedge, no
`feGaussianBlur` at all), and at an oblique sun nothing is drawn past
the end of the gradient — the kerb cannot come back. It re-runs the
DEV-EB173-01 grazing repro end to end (west window 80, elevation 90,
azimuth 190): equal sides of the nominal length, peak alpha at BOTH
ends of the glass, and no wedge at all below `RAY_MIN_COS`.
- `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` — stills at a low and a high sun
(`node demo/shot_sun_short.mjs <outdir> <prefix>`).
+274
View File
@@ -128,6 +128,13 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
perpendicular dashed tick appears and the center magnet-snaps — Shift
disables the magnet; badges and tick vanish on release
[auto: smoke_opening_measure + unit openingShoulders]
- [ ] The SAME rulers while PLACING a new opening (2026-08-03): with the
Opening tool, moving along a wall shows the dashed ghost together with a
badge on each shoulder of the would-be opening (default 90 cm, measured
on the snapped room's OWN edge), a perpendicular tick + magnet at that
edge's centre, Shift opting out; the click places the opening at the
magnetised point and ghost, badges and tick all disappear at once
[auto: smoke_opening_measure, the «PLACING a new opening» section]
## Onboarding ★
@@ -269,6 +276,68 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
device classes (explicit per-device toggle still works for them)
[auto: smoke_tap_run + unit resolveTapAction/runServiceFor + backend
test_run_target_is_bounded_to_runnable_domains]
- [ ] Tap opens/closes a cover (dev, owner's spec 2026-08-03): the tap-action
list gains "Open/close (curtains/blinds)" — offered ONLY when the binding
has a cover entity, and never for the guarded classes garage/door/gate
(a value smuggled into the config there degrades to the info card).
closed -> cover.open_cover, open (incl. ajar) -> cover.close_cover,
opening/closing -> cover.stop_cover, no readable state -> cover.toggle;
the «ask for confirmation» checkbox guards it like toggle/run.
Indication: while travelling the icon breathes a soft yellow ring
(.covermove, 2.2s, static under prefers-reduced-motion) and the plate
stays NEUTRAL in EVERY state (2026-08-04, see the next item); the icon
morphs by state + device_class (blinds/shutter/curtain…), unknown state
morphs nothing; no position percentages anywhere
[auto: smoke_cover_tap + units resolveTapAction/coverService/stateIcon +
backend test_cover_tap_action_is_accepted]
- [ ] A cover is NEVER painted (dev, owner 2026-08-04): «у штор не должно быть
жёлтой подложки никогда, индикация открыто/закрыто за счёт морфинга
иконки». Walk one curtain through closed / open / ajar / opening /
closing: the plate is the plain neutral badge every time — never the
yellow «включено» one, never the orange «открыто» frame it used to wear
while open — the icon is the only open/closed signal, and the breathing
.covermove ring appears in the two travelling states and nowhere else.
The morph is exhaustive: every device class gives two DIFFERENT glyphs
(awning included), a cover with no device_class morphs within its own
auto-icon family (mdi:roller-shade, mdi:garage-variant), a hand-picked
icon morphs only inside the pair it was picked from, and an
unknown/unavailable state morphs nothing. NOT touched: an open door /
window binary sensor, an unlocked lock and an open valve still wear the
orange «открыто» frame (a valve has no icon pair, so the frame is all it
has) [auto: smoke_cover_no_plate + unit stateIcon «every class, both
ways»]
- [ ] Open/close works when the cover is NOT the primary entity (dev, owner's
report 2026-08-04): a curtain driver that ships its `cover.*` hidden by
the integration next to a visible `switch.*_reverse_direction` (Aqara
E1 — his own) has a SWITCH for a primary; picking «Open/close» in the
dialog, saving it and tapping the marker sends `cover.open_cover` to the
cover, never touches the service switch and never falls back to the info
card. The guarded class is read off that same cover, so a garage still
degrades to info and is still not offered in the dialog
[auto: smoke_cover_not_primary + unit coverEntityOf]
- [ ] Curtain INDICATION follows the same cover (dev, owner 2026-08-04): with
«Open/close» chosen on that same Aqara marker, the plan shows the cover
and not the service switch — the breathing ring while it travels
(`covermove`, opening AND closing), the `mdi:curtains-closed` /
`mdi:curtains` morph, a neutral plate throughout (the «открыто» frame
was retired for covers later the same day), and no yellow plate when
`switch.*_reverse_direction` happens to be on. The rule is exactly
the explicit action (docs/FILTERING.md «What a marker SHOWS»): take the
action away and the marker speaks for its primary again; a lit lamp that
also owns a cover keeps its yellow and its own icon
[auto: smoke_cover_not_primary (the indication section)]
- [ ] Nothing paints over an explicit curtain (dev, audit DEV-1DA1-01): the
cover is the FIRST rule of «What a marker SHOWS», above `controls` and
above a lit light. Two markers set to «Открыть/закрыть»: one on a mixed
device whose own `light.*` is ON, one whose bound `controls` switch is
ON. In every cover state (closed / open / opening / closing) the plate
stays neutral — never the yellow «включено», never the orange «открыто»
— the travelling ring breathes, the icon morphs with the cover, and in
glow fill the ring is still there (that is where a yellow-plated curtain
used to lose BOTH indicators). Untouched: the same mixed device without
the explicit action is yellow again, a wall switch still mirrors its
controls, and an «Открыть/закрыть» marker whose device has no `cover.*`
at all falls back to its primary [auto: smoke_cover_plate_precedence]
- [ ] Light-source badges (v1.52.0): in glow fill a lit lamp's badge stays
standard (the spot is the indicator) and a lit socket stays yellow; in
other fills a lit lamp is plain yellow with no RGB tint; morphing and
@@ -910,6 +979,15 @@ require hands on real hardware — they remain for the human pass.
- [ ] Wedge direction follows the compass; length grows toward
sunrise/sunset and shrinks toward noon; every wedge is clipped by its
room polygon; night = no wedges at all
- [ ] Brightness + the 3° threshold (2026-08-03): wedges are visibly brighter
(peak alpha 0.30, was 0.18) yet still readable over white paper AND the
dark glow canvas; there is NO gradual ramp near the horizon — below 3°
no rays at all, at/above 3° full strength; crossing the threshold fades
the whole layer in/out over exactly 2 s (CSS on `.sunlayer`, the
geometry never moves), and `prefers-reduced-motion` makes it instant.
Every other way of losing the wedges (editor, feature off, night, rain)
stays instant [auto: smoke_sun «the 3° threshold» + unit rayAlpha/
raysVisible/rayPeakAlpha; shots: demo/shot_sun_bright.mjs]
- [ ] Weather entity (optional, global): cloudy fades the wedges, rain/snow
removes them, a dead/unknown weather sensor changes nothing
- [ ] Sun geometry recomputes ONLY when the sun attributes or the config
@@ -930,3 +1008,199 @@ require hands on real hardware — they remain for the human pass.
(registry-wide climate, like hidden thermometers); the tick survives
dialog recreation [auto: smoke_climate_temp; units: test/devices.test.mjs;
backend: tests_backend/test_validation.py (use_climate_temp)]
## Infinite canvas (docs/CANVAS.md, dev)
- [ ] **A plan drawn past the old square opens whole**: a space whose rooms
live at normalised 1.5..3.0 renders complete and centred (it used to
frame empty canvas with the house off-screen) [auto:
smoke_infinite_canvas; units: test/canvas.test.mjs]
- [ ] **Nothing stops at an edge any more**: in the Plan / Devices / Decor
editors a room, a marker and a decor shape can be drawn, dragged and
SAVED far outside `0..1`, on any floor; reload keeps them there
[backend: tests_backend/test_validation.py::test_infinite_canvas_range]
- [ ] **A typical small plan is visually unchanged** — same framing, same
room and label positions as before the feature. The ONE intended
difference is icon size (below) [auto: smoke_infinite_canvas
(legacyFrameUnchanged); the whole smoke suite is the regression net]
- [ ] **Icons no longer grow with zoom** (§6, owner is aware): a marker keeps
the same pixel size at zoom 1, 4 and at the zoom-out floor; the
per-device size multiplier, kiosk icon/font scales, badges, LQI chips
and presence rings all still scale from `--dev-size`
- [ ] **Start view follows the content**: opening a space frames what is
drawn plus a small margin, on every floor, with and without a backdrop
image (with one the IMAGE sets the extent — it must not be cropped to
the outlined rooms)
- [ ] **What is not drawn does not frame** (audit DEV-2C947-01): tick «hide
from plan» on a marker standing far from the house and the view snaps
back to the house — the hidden marker neither renders nor stretches the
frame, on the full card and on `houseplan-space-card`. Untick it and the
frame takes it in again; room LQI counted it the whole time
[auto: smoke_canvas_frame]
- [ ] **The editor frame does not follow you out** (audit DEV-2C947-02): move
the only room five canvases away in the Plan editor (the frame grows
there, deliberately), close the editor — View frames the room where it
is NOW, not the union with where it was; re-entering the editor starts
from the current geometry [auto: smoke_canvas_frame]
- [ ] **A far stray does not inflate the icons either** (audit DEV-2C947-03):
one ROOM dragged an order of magnitude away is rejected from the frame
(as before) and the markers of the main plan keep the size they have
without it; auto-placement spacing goes with them
[auto: smoke_canvas_frame + unit canvas.test.mjs]
- [ ] **A far stray does not break the view** (§4.1): a marker dragged an
order of magnitude away leaves the opening view alone and raises the
inline chip «Объектов далеко от плана: N» with «Показать». No modal.
«Показать» fits the plan AND the stray; the chip then disappears
- [ ] **«Вписать всё»** (middle zoom button): fits the content from any pan
and any zoom, is never disabled, tooltip en/ru
- [ ] **Zoom-out floor**: the wheel / the minus button stop at three times the
content frame; zoom-in still stops at 800 %
- [ ] **Pan has slack, not walls**: you can pan a full screen past the plan in
every direction; when the plan is fully off screen a small arrow points
home and one click fits it back
- [ ] **Pan at ANY zoom** (owner's report 2026-08-04): dragging empty scene
moves the view at 100 %, at 50 % and at the zoom-out floor — in View and
in all three editors, with every plan tool selected. The tools keep the
pointer they own (a resize handle resizes, a device badge in the Devices
editor moves the device, an opening slides along its wall — none of them
pan), two fingers still pinch, and on a kiosk screen a horizontal drag
is still the floor swipe (a vertical one pans) [auto: smoke_pan_any_zoom]
- [ ] **Kiosk: a pan stays a pan to the very end** (dev, audit DEV-1DA1-02):
on a wall tablet at 100 % start a drag with a small VERTICAL lead-in
(the plan starts following the finger — the gesture is locked as `pan`),
then curve it far to the left or right and lift. The floor must NOT
change: the decision taken on the first movement is final, and only a
gesture locked as a swipe may switch storeys. Mirror check: a horizontal
lead-in locks the swipe — the plan never slides under it, and if the
trajectory then bends vertically and no longer qualifies as a swipe, the
gesture simply does nothing (it does not turn into a pan). Straight
swipes still switch, straight vertical drags still pan, a motionless
double tap still resets the zoom [auto: smoke_kiosk_pan_lock]
- [ ] **Adaptive grid** (§7): in the Plan editor zoomed far out the grid does
not merge into a grey wash — fine dots thin out, every 5th/10th node
stays bigger; zoomed in the grid is the usual one and snapping still
lands on the same nodes as before
- [ ] **Everything else on a far-out plan**: sun wedges, glow radii, open
boundaries, room resize handles/rulers, opening rulers, split/merge,
vacuum trails, the static `houseplan-space-card` and kiosk carousel all
behave exactly as on a plan inside `0..1`
- [ ] **Real config regression**: a production config (e.g. the dacha, 3
floors / 106 markers) frames bit-identically to the previous release —
no outliers reported, no frame movement
## Batch 2026-08-04 (dev, unreleased)
- [ ] **Room borders have no teeth** (owner 2026-08-04): draw a room with a
sharp corner (a wedge with a 30-60° apex, or an L) — the corner is
ROUNDED off by the stroke's own radius, never a spike sticking out past
the two walls and never a flat bevel. Same in the plan View, in the Plan
editor and on the static `houseplan-space-card`; a room with OPEN
boundaries (its trimmed outline) has round corners too
[auto: smoke_render_parity, still: demo/shot_room_joins.mjs]
- [ ] **The Background editor measures what you draw** (owner 2026-08-04, «в
редакторе подложки у линий писать длину»): while a decor LINE is being
dragged out, a badge on the MIDDLE of the segment shows «length · angle»
in the HA unit system (`cell_cm`, metres or feet) and turns green on a
45° multiple — exactly the badge a wall gets in the Plan editor. It
updates on every move, is absent before the drag has any length, and is
gone the moment the shape is committed. Rectangles and ovals show their
bounding box «W × H» instead [auto: smoke_decor]
- [ ] **Grazing sunlight** (dev, audit DEV-EB173-01): with `sun_rays` on, set
the sun almost ALONG a wall carrying a window (e.g. a west window,
azimuth 190°, elevation 90° at north_deg 0). The shaft is a true
parallelogram — both sides exactly as long as the nominal reach, 70 % of
the pre-v1.57 curve — the whole pane of glass is at FULL brightness (no
end of the window starts out transparent), and the light fades along the
ray, dying out before the far edge. A sun within ~3° of the wall plane
(`RAY_MIN_COS`) casts nothing at all [auto: smoke_sun_soft + unit sun.test]
- [ ] **Nothing stops at the old canvas border** (owner 2026-08-04, DEV-B58-01:
«названия комнат и устройства не перетаскиваются дальше старых границ
холста»). On an ORDINARY plan (rooms inside 0..1), in the Devices editor
drag a marker far outside the drawing — to about 2.5 / 2.2 normalised. It
follows the cursor the whole way, the position is stored, the plan's frame
grows to include it, and it is still there after a reload. Repeat with a
room NAME in the Plan editor (this one used to stop at the old unit square
exactly), with a decor shape in the Background editor (draw it far out,
then drag it further), and with an opening on a wall that lives past the
old square. The only thing that still stops you is ±5000 — drag wildly and
the marker parks there instead of at 1e12 [auto: smoke_drag_bounds]
- [ ] **Everything lands on the grid** (owner 2026-08-04, docs/CANVAS.md §9):
place a device, a room name, a decor rectangle, a decor text, a room
vertex and a resize handle with the mouse — each ends exactly on a grid
node, never between two. An opening is the one exception and deliberately
so: it stays ON its wall, at a whole number of steps along it. Hold
**Shift** while dropping and the element lands where the cursor is,
off-grid — that is the only way to get there [auto: smoke_grid_snap]
- [ ] **«Выровнять всё по сетке»** (owner 2026-08-04): gear → general settings →
**Grid** → the button. On an already tidy plan it says everything is
already on the grid and offers no confirm button. On a plan with elements
between the nodes it names how many will move and the largest shift in cm,
and warns there is no undo. Press it: rooms, decor, markers and room names
snap to nodes in ONE write, openings stay on their walls, and pressing the
button a second time reports nothing to do. Cancel does nothing at all
[auto: smoke_grid_snap + unit test/align-grid.test.mjs]
## Backdrop picture: move & scale (docs/BACKDROP.md, dev)
- [ ] **The frame is there, and only there** (owner 2026-08-04): open a space
that HAS an uploaded plan image → **Редактор подложки**. A dashed frame
hugs the picture straight away, with four round corner handles, and the
toolbar has gained a «Картинка-подложка» tool. Switch to Line /
Rectangle / Oval / Text / Erase — the frame disappears (those tools own
the drag). Leave for View, the Plan editor, the Device editor and the
kiosk — no frame anywhere. A space with NO image never shows one, and
its toolbar has no extra button [auto: smoke_backdrop, smoke_decor]
- [ ] **Dragging the picture moves the picture, and nothing else** (owner
2026-08-04): pick the «Картинка-подложка» tool (the cursor over the
picture becomes a hand), grab the picture by its body and pull it aside.
It follows the finger the whole way — it must NOT drift away from the
cursor or jump when the toolbar changes — while the rooms, walls, doors,
windows, devices, room names and decor stay exactly where they were.
Release, reload the page — the picture is still where you left it
[auto: smoke_backdrop]
- [ ] **One-finger pan survives** (regression, DEV-B58 «таскать план при любом
масштабе»): back on the «Выбрать» tool, drag across the middle of the
picture — the PLANE pans, the picture does not move. Same at 50 % and
33 % zoom [auto: smoke_backdrop, smoke_pan_any_zoom]
- [ ] **The corners scale it evenly**: pull a corner handle. The picture grows
and shrinks in BOTH directions at once, keeps its proportions (nothing
is stretched), and the OPPOSITE corner does not move a pixel. Try all
four corners; the cursor over a handle is a diagonal resize arrow. On a
tablet the handles are big enough to hit with a finger [auto: smoke_backdrop]
- [ ] **Live size in metres**: while dragging or scaling, a badge in the middle
of the picture states its real size, «Ш × В», in the same units the wall
ruler uses (metres, or feet on an imperial HA). Change the space's
`cell_cm` in its settings and the numbers change with it. The badge
disappears on release [auto: smoke_backdrop]
- [ ] **Snap, and Shift**: after a plain drag the picture's corner sits on a
grid node (zoom in on the corner — it is on a crossing, not between
two). After a corner scale one side of the picture ends on a node too.
Hold **Shift** during the gesture and it lands exactly where the cursor
is instead [auto: smoke_backdrop]
- [ ] **«Вернуть картинку»**: the button appears in the backdrop toolbar only
after the picture has been moved or scaled. Press it — the picture goes
back to centred, at its own size, and the button disappears again
[auto: smoke_backdrop]
- [ ] **NEW PAPER RULE — the sheet is the rooms** (owner 2026-08-04, changes
the old behaviour): set a loud `bg_color` (or `daynight`) on a space
that has BOTH a picture and drawn rooms, then shrink the picture with a
corner handle. The opaque white/card-coloured sheet follows the ROOM
CONTOURS — it is no longer a rectangle the size of the picture. The
scene colour is visible around the rooms, including in the pocket of an
L-shaped house and between detached buildings. The picture is drawn ON
that sheet: above it, below the walls, doors, decor and devices. A space
with a picture and NO rooms has no sheet at all, so a transparent PNG
shows the scene through itself — deliberate
[auto: smoke_backdrop + smoke_bg_color, still: demo/shot_backdrop.mjs]
- [ ] **«Вписать всё» does not lose the picture**: drag the picture well away
from the rooms (or scale it right down), leave the editor, press «Вписать
всё». The view frames the rooms AND the picture; the "home is that way"
arrow points at them together [auto: smoke_backdrop]
- [ ] **The static card and the kiosk agree**: put a `houseplan-space-card` for
the same space on a dashboard and open the kiosk view. Both draw the
picture at the same offset and size, with the same room-contour paper
[auto: smoke_backdrop, smoke_render_parity]
- [ ] **Old plans are untouched**: a space whose picture has never been moved
renders exactly as before the update — same place, same size. Nothing is
written to its config until the first drag [auto: unit test/backdrop.test.mjs
+ tests_backend/test_validation.py]
+13
View File
@@ -64,6 +64,19 @@ Header in View: space tabs, device count, zoom cluster. Nothing else.
👁 "Show hidden" (local editor tool; replaced the shared show-all toggle),
⬡ icon rules.
## Background — the decor underlay
- Toolbar tools: select / line / rect / oval / text / erase, plus colour, width
and fill. Shapes are drag-drawn with grid snap and a live preview.
- **Live size badge** (owner 2026-08-04): while a LINE is being dragged out, the
same `.measurelabel` the Plan editor puts on a wall shows «length · angle» —
`segmentCm` over the space's `cell_cm`, metres or feet per the HA unit system,
green on a 45° multiple. It rides the MIDDLE of the segment (a wall badge
follows the cursor because the cursor is the wall's free end; a decor line is
pulled out by both ends at once), updates on every move and disappears the
moment the shape is committed. Rectangles and ovals have no length, so they
show their bounding box «W × H» instead; a draft with no size shows nothing.
## Deprecations decided
1. "Drag anywhere" (v1.9) — reversed by this design.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "houseplan-card",
"version": "1.56.0",
"version": "1.58.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "houseplan-card",
"version": "1.56.0",
"version": "1.58.0",
"license": "MIT",
"dependencies": {
"lit": "^3.1.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "houseplan-card",
"version": "1.56.0",
"version": "1.58.0",
"description": "Interactive house plan Lovelace card for Home Assistant",
"license": "MIT",
"type": "module",
+269
View File
@@ -0,0 +1,269 @@
/**
* «Выровнять всё по сетке» / «Align everything to the grid» — docs/CANVAS.md §9.
*
* WHY THIS IS AN ACTION AND NOT A MIGRATION
* -----------------------------------------
* Every gesture in the editor now lands on the grid, but plans drawn before
* that contract existed may hold coordinates between the nodes. The obvious
* fix — quietly rounding everything on the next update — is the wrong one:
*
* 1. It 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 things are off-grid ON PURPOSE — a small decor label nudged next to
* an icon, a diagonal wall's window, a plan traced over a photo where the
* scale never was 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.
*
* So the alignment lives behind an explicit button that first says how many
* elements it would move and by how much at most, and only then writes — once,
* in a single config+layout operation.
*
* WALL-BOUND VS GRID-BOUND (the contract this file implements)
* -----------------------------------------------------------
* Room vertices, decor geometry, device markers and room labels are GRID-BOUND:
* they are rounded to the nearest node. Openings are WALL-BOUND: a door that
* sits on a grid node but half a metre off its wall is broken geometry, so an
* opening is re-projected onto the nearest wall and its offset ALONG that wall
* is snapped to the same step. On an axis-aligned wall with grid-aligned
* corners the two rules agree exactly.
*
* THE REPORT IS A PROMISE, SO IT IS AN UPPER BOUND (AUD-158B1-01)
* --------------------------------------------------------------
* The confirmation is the only safety gate in front of an action with no undo.
* A number that is merely typical of what happens is worse than no number at
* all, so the maximum is measured on the geometry that is actually written
* back. Two things used to make it a sample instead of a bound:
*
* * a rect was measured by its origin and its far corner only. The far corner
* is then pushed out again by the minimum-size correction, and the other two
* corners — the ones that carry the X error of one side together with the Y
* error of the other — were never looked at, understating an ordinary box by
* up to √2 and a box thinner than one step by much more.
* * the single NORMALISED maximum was turned into centimetres by the caller,
* through the `cell_cm` of the FIRST space. Two floors drawn at 5 cm and at
* 100 cm per cell made that promise twenty times too small.
*
* So displacement is accumulated in CENTIMETRES, every space through its own
* `cell_cm`, and the report says which space the maximum belongs to.
*
* AN OPENING ALSO CARRIES AN ANGLE (AUD-158B1-02)
* ----------------------------------------------
* The batch rewrites `angle` as well as `x`/`y`. A window whose centre is
* already on its wall but whose stored angle is wrong is therefore a real
* correction — yet it used to come back inside `changed:false`, so the dialog
* said there was nothing to do and the fix could never be applied. The angle is
* part of the diff now, and displacement is measured on the opening's ENDS, so
* turning it is not free in the report either.
*/
import { GRID_N, GRID_STEP_N } from './space-geometry';
import { snapToWall } from './logic';
/** Anything closer than this to a node already counts as being on it. */
const EPS = GRID_STEP_N * 1e-6;
/** How far an opening may be from a wall and still be re-projected onto it. */
const WALL_TOL = GRID_STEP_N * 6;
/** What one cell is worth when a space does not say — the card's own default. */
const DEFAULT_CELL_CM = 5;
/** Round a NORMALISED coordinate to the nearest grid node, idempotently.
* A value already on a node is returned UNCHANGED (bit for bit), so a second
* run of the alignment writes nothing at all. */
export function snapN(v: number): number {
if (!Number.isFinite(v)) return v;
const s = Math.round(v / GRID_STEP_N) * GRID_STEP_N;
return Math.abs(s - v) <= EPS ? v : s;
}
export interface AlignReport {
/** Elements whose coordinates the run would change. */
moved: number;
/** Elements examined (rooms, decor shapes, openings, markers, labels). */
total: number;
/** Largest displacement in NORMALISED units (1 = the plan's width). */
maxShift: number;
/** The same maximum in CENTIMETRES — every space through its own `cell_cm`,
* because a normalised number means nothing until it meets a scale. This is
* the number the confirmation promises, and it is an upper bound. */
maxShiftCm: number;
/** Which space holds that maximum (`''` when nothing moves). */
maxSpace: string;
/** Openings whose stored `angle` is corrected — possibly without moving. */
rotated: number;
}
export interface AlignResult {
spaces: any[];
layout: Record<string, any>;
report: AlignReport;
changed: boolean;
}
const dist = (ax: number, ay: number, bx: number, by: number) => Math.hypot(bx - ax, by - ay);
/** The displacement of an axis-aligned box: the largest of its FOUR corners,
* measured against the box that is really written back (minimum-size
* correction included). The worst corner combines the largest X error of
* either side with the largest Y error of either side — which is why the two
* that were never measured are exactly the two that can be the worst. */
const boxShift = (
x: number, y: number, w: number, h: number,
nx: number, ny: number, nw: number, nh: number,
): number => Math.hypot(
Math.max(Math.abs(nx - x), Math.abs((nx + nw) - (x + w))),
Math.max(Math.abs(ny - y), Math.abs((ny + nh) - (y + h))),
);
/** The displacement of an opening, measured on its two ENDS so that turning it
* in place is not free. An opening is a symmetric segment on its wall: 180°
* apart is the same segment, so the pairing of ends that gives the smaller
* answer is the true one — a flipped angle is a rewrite, not a move. */
const openingShift = (
x: number, y: number, a: number, len: number,
nx: number, ny: number, na: number,
): number => {
const h = Math.max(Number(len) || 0, 0) / 2;
const R = Math.PI / 180;
const ux = Math.cos(a * R) * h, uy = Math.sin(a * R) * h;
const vx = Math.cos(na * R) * h, vy = Math.sin(na * R) * h;
const same = Math.max(dist(x + ux, y + uy, nx + vx, ny + vy),
dist(x - ux, y - uy, nx - vx, ny - vy));
const flip = Math.max(dist(x + ux, y + uy, nx - vx, ny - vy),
dist(x - ux, y - uy, nx + vx, ny + vy));
return Math.min(same, flip);
};
const cellCmOf = (sp: any): number => {
const v = Number(sp?.cell_cm);
return v > 0 ? v : DEFAULT_CELL_CM;
};
/**
* The whole batch, as a pure function: give it the spaces and the layout, get
* back new ones plus the report the confirmation dialog shows. Nothing here
* touches the network, so the dialog can call it twice — once to preview, once
* to write — and be certain the numbers it promised are the numbers it did.
*/
export function alignAllToGrid(
spacesIn: any[], layoutIn: Record<string, any>,
): AlignResult {
const spaces = JSON.parse(JSON.stringify(spacesIn || []));
const layout: Record<string, any> = JSON.parse(JSON.stringify(layoutIn || {}));
let moved = 0, total = 0, maxShift = 0, maxShiftCm = 0, maxSpace = '', rotated = 0;
// a marker or a room label names its space; the scale of THAT space is the
// one its centimetres are in
const cellById: Record<string, number> = {};
let cellWorst = DEFAULT_CELL_CM;
for (const sp of spaces) {
const c = cellCmOf(sp);
if (sp?.id != null) cellById[String(sp.id)] = c;
if (c > cellWorst) cellWorst = c;
}
/** Record one element. `d` is normalised, `cellCm` turns it into the
* centimetres of its own space. `forced` is for a change that is real
* without being a displacement — an opening's angle. */
const note = (d: number, cellCm: number, spaceId: string, forced = false): void => {
if (!(d > EPS) && !forced) return;
moved++;
if (d > maxShift) maxShift = d;
const cm = d * GRID_N * cellCm;
if (cm > maxShiftCm) { maxShiftCm = cm; maxSpace = spaceId; }
};
for (const sp of spaces) {
const cell = cellCmOf(sp);
const sid = sp?.id != null ? String(sp.id) : '';
// ---- rooms: every vertex to the nearest node ----------------------
for (const r of sp.rooms || []) {
total++;
let d = 0;
if (r.poly?.length) {
r.poly = r.poly.map((p: number[]) => {
const q = [snapN(p[0]), snapN(p[1])];
d = Math.max(d, dist(p[0], p[1], q[0], q[1]));
return q;
});
} else if (r.x != null && r.y != null) {
// a rect keeps its far corner on the grid too, hence w/h are snapped
// as corners and not as sizes (a snapped size on an off-grid origin
// would leave the other side between the nodes).
const x0 = r.x, y0 = r.y, w0 = r.w || 0, h0 = r.h || 0;
const x2 = snapN(x0 + w0), y2 = snapN(y0 + h0);
const nx = snapN(x0), ny = snapN(y0);
const nw = Math.max(GRID_STEP_N, x2 - nx), nh = Math.max(GRID_STEP_N, y2 - ny);
d = boxShift(x0, y0, w0, h0, nx, ny, nw, nh);
r.x = nx; r.y = ny; r.w = nw; r.h = nh;
}
note(d, cell, sid);
}
// ---- decor ---------------------------------------------------------
for (const sh of sp.decor || []) {
total++;
let d = 0;
if (sh.kind === 'line') {
const a = [snapN(sh.x1), snapN(sh.y1)], b = [snapN(sh.x2), snapN(sh.y2)];
d = Math.max(dist(sh.x1, sh.y1, a[0], a[1]), dist(sh.x2, sh.y2, b[0], b[1]));
sh.x1 = a[0]; sh.y1 = a[1]; sh.x2 = b[0]; sh.y2 = b[1];
} else {
const nx = snapN(sh.x), ny = snapN(sh.y);
if (sh.w != null && sh.h != null) {
const x2 = snapN(sh.x + sh.w), y2 = snapN(sh.y + sh.h);
const nw = Math.max(GRID_STEP_N, x2 - nx), nh = Math.max(GRID_STEP_N, y2 - ny);
d = boxShift(sh.x, sh.y, sh.w, sh.h, nx, ny, nw, nh);
sh.w = nw; sh.h = nh;
} else {
d = dist(sh.x, sh.y, nx, ny);
}
sh.x = nx; sh.y = ny;
}
note(d, cell, sid);
}
// ---- openings: wall-bound, snapped ALONG the (already aligned) wall --
// The very same helper the live editor uses, so the button cannot disagree
// with the drag. A stray opening with no wall within WALL_TOL is left
// exactly where it is rather than teleported across the plan.
for (const o of sp.openings || []) {
total++;
const q = snapToWall([o.x, o.y], sp.rooms || [], WALL_TOL,
{ step: GRID_STEP_N, length: Number(o.length) || 0 });
if (!q) continue;
// the angle is rewritten too, so it belongs in the diff: an opening
// already on its wall with a wrong angle is a correction that must be
// offerable, not a silent no-op (AUD-158B1-02)
const raw = Number(o.angle);
const turned = !(Number.isFinite(raw) && raw === q.angle);
const d = openingShift(o.x, o.y, Number.isFinite(raw) ? raw : q.angle,
Number(o.length) || 0, q.x, q.y, q.angle);
o.x = q.x; o.y = q.y; o.angle = q.angle;
if (turned) rotated++;
note(d, cell, sid, turned);
}
}
// ---- layout: device markers AND room labels (rl_<id>) ----------------
for (const [k, v] of Object.entries(layout)) {
if (!v || typeof v !== 'object') continue;
const p: any = v;
if (typeof p.x !== 'number' || typeof p.y !== 'number') continue;
total++;
const nx = snapN(p.x), ny = snapN(p.y);
const d = dist(p.x, p.y, nx, ny);
layout[k] = { ...p, x: nx, y: ny };
// an entry whose space is gone still moves, and the promise must not
// shrink because of it: the largest scale on the plan is the safe one
const sid = typeof p.s === 'string' ? p.s : '';
note(d, cellById[sid] ?? cellWorst, sid);
}
return {
spaces, layout,
report: { moved, total, maxShift, maxShiftCm, maxSpace, rotated },
changed: moved > 0,
};
}
+1337 -203
View File
File diff suppressed because it is too large Load Diff
+26 -2
View File
@@ -19,7 +19,7 @@
"btn.no_area": "No area",
"title.zoom_in": "Zoom in",
"title.zoom_out": "Zoom out",
"title.zoom_reset": "Reset zoom",
"title.zoom_fit": "Fit all",
"title.add_device": "Add a device to the plan",
"title.show_all": "Show hidden devices (ghosted, this tab only)",
"title.markup": "Room markup: grid, lines, outlines",
@@ -277,6 +277,10 @@
"decor.size_s": "Small",
"decor.size_m": "Medium",
"decor.size_l": "Large",
"decor.backdrop": "Backdrop image",
"decor.backdrop_hint": "Drag the picture to move it, pull a corner to resize it. Shift — off the grid.",
"decor.backdrop_reset": "Reset the picture",
"decor.backdrop_reset_done": "The picture is back at its original place and size",
"marker.icon_auto": "Auto: {icon} (by icon rules; pick one to override)",
"mode.plan_tip": "Plan editor — the geometry of the home: draw and split/merge rooms, bind them to HA areas, place doors and windows, move room cards, set the scale",
"mode.devices_tip": "Device editor — everything about icons: drag to position, click to edit binding/icon/display, add virtual devices, icon rules",
@@ -347,6 +351,7 @@
"marker.hide": "Hide device from plan",
"marker.hide_tip": "The device is not drawn on the plan but still counts toward the room signal. To see it: the \"Show hidden\" button in the device editor.",
"tap.run": "Run automation/script/scene",
"tap.cover": "Open/close (curtains/blinds)",
"marker.run_target_label": "What to run",
"marker.run_search_ph": "Search: automation, script or scene…",
"marker.run_target_gone": "Target {id} not found — pick again",
@@ -357,6 +362,7 @@
"run.scene": "scene",
"confirm.tap_run": "Run \"{name}\"?",
"confirm.tap_toggle": "Toggle \"{name}\"?",
"confirm.tap_cover": "Open/close \"{name}\"?",
"toast.run_started": "Started: {name}",
"toast.run_target_missing": "Run target not found — check the device settings",
"toast.run_target_required": "Pick an automation, script or scene",
@@ -400,6 +406,10 @@
"gs.sun_rays": "Sunlight through windows",
"gs.weather": "Weather entity",
"gs.weather_ph": "weather.home (optional)",
"gs.about_group": "About",
"gs.about_version": "Houseplan Card v{v}",
"gs.about_github": "GitHub · docs & issues",
"gs.about_telegram": "Telegram chat",
"space.bg_color": "Background around the plan",
"space.bg_inherit": "Inherit general",
"space.bg_inherited": "inherits general settings",
@@ -409,5 +419,19 @@
"space.sun_rays": "Sunlight through windows",
"space.sun_inherit": "Inherit general",
"space.sun_on": "On",
"space.sun_off": "Off"
"space.sun_off": "Off",
"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": "Grid",
"gs.grid_hint": "Everything you draw or drag now lands on the grid. Hold Shift to place something between the nodes on purpose.",
"gs.align_all": "Align everything to the grid",
"gs.align_title": "Align everything to the grid",
"gs.align_none": "Everything is already on the grid — nothing to move.",
"gs.align_count": "{n} of {total} elements will move, by at most {cm} cm.",
"gs.align_where": "The largest shift is in \u201c{s}\u201d.",
"gs.align_turned": "Openings whose angle is corrected: {n}.",
"gs.align_warn": "This rewrites the plan and the device positions in one go. There is no undo: the only way back is to move things by hand. If some elements are deliberately between the nodes, cancel.",
"gs.align_run": "Align",
"gs.align_done": "Aligned to the grid: {n} elements moved"
}
+26 -2
View File
@@ -19,7 +19,7 @@
"btn.no_area": "Без зоны",
"title.zoom_in": "Приблизить",
"title.zoom_out": "Отдалить",
"title.zoom_reset": "Сбросить масштаб",
"title.zoom_fit": "Вписать всё",
"title.add_device": "Добавить устройство на план",
"title.show_all": "Показать скрытые устройства (полупрозрачными, только в этой вкладке)",
"title.markup": "Разметка комнат: сетка, линии, контуры",
@@ -277,6 +277,10 @@
"decor.size_s": "Мелкий",
"decor.size_m": "Средний",
"decor.size_l": "Крупный",
"decor.backdrop": "Картинка-подложка",
"decor.backdrop_hint": "Тяните картинку — двигается, тяните угол — меняет размер. Shift — мимо сетки.",
"decor.backdrop_reset": "Вернуть картинку",
"decor.backdrop_reset_done": "Картинка вернулась на своё место и в свой размер",
"marker.icon_auto": "Авто: {icon} (по правилам иконок; выберите свою, чтобы заменить)",
"mode.plan_tip": "Редактор плана — геометрия дома: рисование и объединение/разделение комнат, привязка к зонам HA, двери и окна, карточки комнат, масштаб",
"mode.devices_tip": "Редактор устройств — всё про значки: перетаскивание, клик — настройка привязки/иконки/отображения, виртуальные устройства, правила иконок",
@@ -347,6 +351,7 @@
"marker.hide": "Скрыть устройство с плана",
"marker.hide_tip": "Устройство не отображается на плане, но участвует в расчёте сигнала комнаты. Показать: кнопка «Показать скрытые» в редакторе устройств.",
"tap.run": "Запустить автоматизацию/скрипт/сцену",
"tap.cover": "Открыть/закрыть (шторы/жалюзи)",
"marker.run_target_label": "Что запускать",
"marker.run_search_ph": "Поиск: автоматизация, скрипт или сцена…",
"marker.run_target_gone": "Цель {id} не найдена — выберите заново",
@@ -357,6 +362,7 @@
"run.scene": "сцена",
"confirm.tap_run": "Запустить «{name}»?",
"confirm.tap_toggle": "Переключить «{name}»?",
"confirm.tap_cover": "Открыть/закрыть «{name}»?",
"toast.run_started": "Запущено: {name}",
"toast.run_target_missing": "Цель запуска не найдена — проверьте настройки устройства",
"toast.run_target_required": "Выберите автоматизацию, скрипт или сцену",
@@ -400,6 +406,10 @@
"gs.sun_rays": "Солнце в окнах",
"gs.weather": "Сущность погоды",
"gs.weather_ph": "weather.home (необязательно)",
"gs.about_group": "О карточке",
"gs.about_version": "Houseplan Card v{v}",
"gs.about_github": "GitHub · документация и issues",
"gs.about_telegram": "Чат в Telegram",
"space.bg_color": "Цвет фона вокруг плана",
"space.bg_inherit": "Наследовать общий",
"space.bg_inherited": "наследуется из общих настроек",
@@ -409,5 +419,19 @@
"space.sun_rays": "Солнце в окнах",
"space.sun_inherit": "Наследовать общий",
"space.sun_on": "Вкл",
"space.sun_off": "Выкл"
"space.sun_off": "Выкл",
"canvas.far_objects": "Объектов далеко от плана: {n}",
"canvas.show_far": "Показать",
"canvas.home_tip": "План там — нажмите, чтобы вписать",
"gs.grid_group": "Сетка",
"gs.grid_hint": "Всё, что вы рисуете и перетаскиваете, ложится строго на сетку. Shift — временно поставить элемент между узлами.",
"gs.align_all": "Выровнять всё по сетке",
"gs.align_title": "Выровнять всё по сетке",
"gs.align_none": "Всё уже на сетке — двигать нечего.",
"gs.align_count": "Сдвинется элементов: {n} из {total}, максимум на {cm} см.",
"gs.align_where": "Наибольший сдвиг — в пространстве «{s}».",
"gs.align_turned": "Проёмов с исправлением угла: {n}.",
"gs.align_warn": "Действие разом перезапишет план и позиции устройств. Отмены нет: вернуть можно только руками. Если какие-то элементы стоят между узлами намеренно — отмените.",
"gs.align_run": "Выровнять",
"gs.align_done": "Выровнено по сетке: сдвинуто элементов — {n}"
}
+191 -8
View File
@@ -9,9 +9,14 @@ export function lqiColor(lqi: number): string {
return `hsl(${Math.round(hue)}, 85%, 55%)`;
}
/** Snap a coordinate to the nearest grid node with step pitch. */
/** Snap a coordinate to the nearest grid node with step pitch.
* A value already ON a node comes back bit-identical: the round-trip through
* a non-dyadic pitch (1000/240) otherwise turns an exact 500 into
* 500.00000000000006, and "is it on the grid?" starts answering no. */
export function snapToGrid(v: number, pitch: number): number {
return Math.round(v / pitch) * pitch;
if (!Number.isFinite(v) || !(pitch > 0)) return v;
const q = Math.round(v / pitch) * pitch;
return Math.abs(q - v) <= pitch * 1e-9 ? v : q;
}
/** Real-world length (cm) of a segment given the grid pitch (render units per cell) and cm per cell. */
@@ -111,8 +116,15 @@ export function roomEdges(rooms: any[]): number[][] {
* placed with this stays valid however rooms are later edited — it keeps absolute
* coordinates and is not tied to a room id or edge index.
*/
export interface WallSnapOpts {
/** Quantise the offset along the wall to this step (render or config units). */
step?: number;
/** Length of the thing being placed, so it is kept inside its wall. */
length?: number;
}
export function snapToWall(
p: number[], rooms: any[], maxDist: number,
p: number[], rooms: any[], maxDist: number, opts: WallSnapOpts = {},
): { x: number; y: number; angle: number } | null {
let best: { x: number; y: number; angle: number } | null = null;
let bestD = maxDist;
@@ -133,12 +145,58 @@ export function snapToWall(
let angle = (Math.atan2(dy, dx) * 180) / Math.PI;
if (angle >= 90) angle -= 180;
else if (angle < -90) angle += 180;
best = { x: q[0], y: q[1], angle };
// docs/CANVAS.md §9: an opening is WALL-bound, so it cannot be rounded to
// a grid node — that would lift it off a diagonal wall. What IS quantised
// is its offset ALONG the wall, in the same step, measured from the
// wall's first corner. On an axis-aligned wall whose corners are on the
// grid (i.e. every wall the editor itself draws) the two agree exactly.
if (opts.step && opts.step > 0) {
const len = Math.sqrt(len2);
const half = Math.min(Math.max(opts.length || 0, 0) / 2, len / 2);
let along = Math.round((t * len) / opts.step) * opts.step;
// the wall's own CENTRE wins inside one step of it: that is the editor's
// magnet (docs/CANVAS.md §9.3), and a batch alignment must not knock a
// deliberately centred window off centre by a couple of centimetres
if (Math.abs(t * len - len / 2) <= opts.step / 2) along = len / 2;
along = Math.max(half, Math.min(len - half, along));
const u = along / len;
best = { x: x1 + u * dx, y: y1 + u * dy, angle };
} else {
best = { x: q[0], y: q[1], angle };
}
}
}
return best;
}
/** Snap the point to the nearest wall EDGE of a single polygon, quantising the
* offset along that edge — the split tool's flavour of the same contract. */
export function snapPointAlongPoly(
p: number[], poly: number[][], step: number,
): number[] | null {
let best: number[] | null = null;
let bestD = Infinity;
for (let i = 0; i < poly.length; i++) {
const [x1, y1] = poly[i];
const [x2, y2] = poly[(i + 1) % poly.length];
const dx = x2 - x1, dy = y2 - y1;
const len2 = dx * dx + dy * dy;
if (!len2) continue;
let t = ((p[0] - x1) * dx + (p[1] - y1) * dy) / len2;
t = Math.max(0, Math.min(1, t));
const d = Math.hypot(p[0] - (x1 + t * dx), p[1] - (y1 + t * dy));
if (d >= bestD) continue;
bestD = d;
const len = Math.sqrt(len2);
const along = step > 0
? Math.max(0, Math.min(len, Math.round((t * len) / step) * step))
: t * len;
const u = along / len;
best = [x1 + u * dx, y1 + u * dy];
}
return best;
}
export interface OpeningShoulders {
/** Endpoints of the wall edge — the ONE room-polygon edge the opening sits on. */
wallA: number[];
@@ -690,7 +748,7 @@ export function safeUrl(url: string | null | undefined): string | null {
// ---------------- tap actions ----------------
export type TapAction = 'info' | 'more-info' | 'toggle' | 'run';
export type TapAction = 'info' | 'more-info' | 'toggle' | 'run' | 'cover';
/** Domains a card-wide `tap_action: toggle` may toggle (accidental-tap safe). */
/**
@@ -709,7 +767,7 @@ export type TapAction = 'info' | 'more-info' | 'toggle' | 'run';
* Adding an option here and forgetting the schema now fails the test suite.
*/
export const DISPLAY_MODES = ['badge', 'ripple', 'icon_ripple', 'value'] as const;
export const TAP_ACTIONS = ['info', 'more-info', 'toggle', 'run'] as const;
export const TAP_ACTIONS = ['info', 'more-info', 'toggle', 'run', 'cover'] as const;
/** Space-level fill: 'glow' is a whole-space light model, not a per-room one. */
// 'glow' leads: it is the default for new spaces since v1.54 — the owner's
// call, it sells the card best. Existing configs keep whatever they chose;
@@ -755,6 +813,14 @@ export function resolveTapAction(
// 'run' is EXPLICIT-only by construction: it needs a per-marker target, so
// it can never arrive as a card-wide default
if (want === 'run') return explicit === 'run' ? 'run' : 'info';
// 'cover' (open/close/stop, owner's spec 2026-08-03) is EXPLICIT-only and
// cover-only. The guarded classes never get it: the option is not offered
// in the dialog, and a value smuggled into the config degrades to 'info'
// — exactly what a card-wide toggle does for a garage door.
if (want === 'cover') {
if (explicit !== 'cover' || domain !== 'cover') return 'info';
return COVER_GUARDED_CLASSES.has(String(deviceClass || '')) ? 'info' : 'cover';
}
if (want !== 'toggle') return 'info';
if (!domain || TOGGLE_FORBIDDEN_DOMAINS.has(domain)) return 'info';
if (explicit === 'toggle') return 'toggle';
@@ -765,6 +831,47 @@ export function resolveTapAction(
return 'toggle';
}
/**
* The cover service one tap should call, from the entity's CURRENT state
* (owner's spec 2026-08-03):
* closed -> open_cover
* open (incl. ajar) -> close_cover
* opening / closing -> stop_cover (a tap during travel is a STOP;
* the next tap then travels the other way, which
* is simply what HA's own state machine does)
* anything else/unknown -> toggle (no meaningful state to reason about)
*/
export function coverService(state: string | null | undefined): string {
const s = String(state || '');
if (s === 'closed') return 'open_cover';
if (s === 'open') return 'close_cover';
if (s === 'opening' || s === 'closing') return 'stop_cover';
return 'toggle';
}
/**
* The cover entity a marker's «Open/close» acts on: the FIRST `cover.*` among
* ALL the device's entities — not necessarily the primary one.
*
* Why this exists (owner's report 2026-08-04, verified on his own install):
* an Aqara E1 curtain driver ships the `cover.*` HIDDEN by the integration and
* a perfectly visible `switch.*_reverse_direction` next to it. `primaryEntity`
* ranks visible before hidden, so the marker's primary was the service switch;
* the tap then resolved on the domain `switch`, and the action the user had
* explicitly chosen in the dialog degraded to the info card — while the dialog
* kept offering it, because THAT check already looked at every entity of the
* device. Same lesson as the climate temperature: what a device DOES is not
* always what its primary entity is.
*/
export function coverEntityOf(entIds: string[] | null | undefined): string | null {
return (entIds || []).find((e) => e.startsWith('cover.')) || null;
}
/** Is a cover travelling right now? Drives the breathing ring on the icon. */
export function coverMoving(state: string | null | undefined): boolean {
return state === 'opening' || state === 'closing';
}
/** Domains a tap may RUN (owner's spec 2026-07-29): the runnable units of
* HA. An automation is triggered, a script and a scene are turned on. */
export const RUN_TARGET_DOMAINS = ['automation', 'script', 'scene'] as const;
@@ -837,7 +944,13 @@ export interface SpaceDisplay {
bgColor: string | null;
}
export const DEFAULT_ROOM_COLOR = '#3ea6ff';
// Default for borders + room names when room_color is absent. Dark slate
// grey since 2026-08-03 (owner call, was the accent '#3ea6ff'): reads on
// the white paper of drawn plans and on the glow-dark theme alike.
// CHANGELOG draft: "Default room border/name colour is now dark grey
// (#55606c). Spaces with an explicitly chosen room_color keep it; only
// spaces that never touched the colour pick up the new default."
export const DEFAULT_ROOM_COLOR = '#55606c';
export const DEFAULT_ROOM_OPACITY = 0.55;
export const DEFAULT_TEMP_MIN = 20;
export const DEFAULT_TEMP_MAX = 25;
@@ -1014,6 +1127,54 @@ export function roomFillColor(
// ---------------- state-reflecting icons ----------------
/**
* cover device_class -> [closed icon, open icon] (owner's spec 2026-08-03).
* Same idea as core HA's cover icons; kept here so the plan's morphing lives
* in one table with the door/window/lock pairs below.
*/
const COVER_ICONS: Record<string, [string, string]> = {
blind: ['mdi:blinds', 'mdi:blinds-open'],
shade: ['mdi:blinds', 'mdi:blinds-open'],
shutter: ['mdi:window-shutter', 'mdi:window-shutter-open'],
curtain: ['mdi:curtains-closed', 'mdi:curtains'],
window: ['mdi:window-closed', 'mdi:window-open'],
awning: ['mdi:awning-outline', 'mdi:awning'],
door: ['mdi:door-closed', 'mdi:door-open'],
garage: ['mdi:garage', 'mdi:garage-open'],
gate: ['mdi:gate', 'mdi:gate-open'],
damper: ['mdi:circle-slice-8', 'mdi:circle-outline'],
};
/**
* Extra [closed, open] pairs recognised on the BASE icon only (owner's
* contract 2026-08-04: for a cover the morph is the ONLY open/closed signal,
* so it must not fall silent on the icons the card itself hands out). These
* are never picked by device_class — they only let a cover whose class is
* missing (z2m ships plenty) or whose icon the user chose by hand still swap
* within its OWN icon family. `mdi:roller-shade` is what the name rule
* «штор|curtain|blind|shade» gives every curtain, `mdi:garage-variant` what
* «ворота|garage|gate» gives every gate.
*/
const COVER_ICON_ALIASES: [string, string][] = [
['mdi:roller-shade-closed', 'mdi:roller-shade'],
['mdi:blinds-horizontal-closed', 'mdi:blinds-horizontal'],
['mdi:garage-variant', 'mdi:garage-open-variant'],
['mdi:door', 'mdi:door-open'],
];
/** Every [closed, open] pair a cover's BASE icon may be recognised by. */
function coverPairs(): [string, string][] {
return [...Object.values(COVER_ICONS), ...COVER_ICON_ALIASES];
}
/** The pair a cover icon belongs to, or null when it is not a known one. */
function coverPairOf(base: string): [string, string] | null {
for (const pair of coverPairs()) {
if (base === pair[0] || base === pair[1]) return pair;
}
return null;
}
/**
* Swap the auto icon for a state variant (open door, unlocked lock…), like core
* HA does. Conservative: only well-known pairs, only when the user has NOT set
@@ -1026,12 +1187,34 @@ export function stateIcon(
state: string | null | undefined,
hasCustomIcon: boolean,
): string {
if (hasCustomIcon || !state || state === 'unavailable' || state === 'unknown') return base;
if (!state || state === 'unavailable' || state === 'unknown') return base;
if (hasCustomIcon) {
// A hand-picked icon normally wins outright. The ONE exception is a cover:
// its plate is neutral in every state (owner 2026-08-04), so the morph is
// all it has — and morphing WITHIN the very pair the user picked from
// (mdi:curtains -> mdi:curtains-closed) shows the state without ever
// trading their icon for a different family.
const pair = domain === 'cover' ? coverPairOf(base) : null;
if (!pair) return base;
return state === 'closed' ? pair[0] : pair[1];
}
if (domain === 'binary_sensor') {
if (deviceClass === 'door') return state === 'on' ? 'mdi:door-open' : 'mdi:door-closed';
if (deviceClass === 'window') return state === 'on' ? 'mdi:window-open' : 'mdi:window-closed';
if (deviceClass === 'garage_door') return state === 'on' ? 'mdi:garage-open-variant' : 'mdi:garage-variant';
}
if (domain === 'cover') {
const pair = COVER_ICONS[String(deviceClass || '')];
if (pair) return state === 'closed' ? pair[0] : pair[1];
// no device_class: morph only when the base icon IS one of the known
// pairs, so a hand-picked auto icon is never swapped for a guess.
// «Closed» is the only state that shows the closed icon: open, ajar
// (HA reports plain 'open' with a position) and both travelling states
// all read as open, exactly like the classed branch above.
const own = coverPairOf(base);
if (own) return state === 'closed' ? own[0] : own[1];
return base;
}
if (domain === 'lock') return state === 'locked' ? 'mdi:lock' : 'mdi:lock-open-variant';
if (domain === 'light' && base === 'mdi:lightbulb') return state === 'on' ? 'mdi:lightbulb-on' : base;
return base;
+352 -53
View File
@@ -24,6 +24,44 @@ export function fitInSquare(ratio: number | null | undefined, side: number) {
return { x: (side - w) / 2, y: (side - h) / 2, w, h };
}
/** Uniform backdrop scale bounds — mirrors validation.py (docs/BACKDROP.md). */
export const PLAN_SCALE_MIN = 0.01;
export const PLAN_SCALE_MAX = 100;
/**
* WHERE THE BACKDROP IMAGE SITS (docs/BACKDROP.md).
*
* `fitInSquare` is only the DEFAULT placement: the image centred in the square
* canvas at its own proportions. On top of it a space may carry three optional
* numbers, the ones the owner produces by dragging the transform frame in the
* backdrop editor:
*
* plan_x, plan_y — offset of the image's top-left corner from that default,
* in NORMALISED units (the very units rooms, openings and
* decor use, bounded by ±CANVAS_LIMIT);
* plan_scale — ONE uniform multiplier for both sides, anchored at the
* (already offset) top-left corner. There is no rotation.
*
* All three are OPTIONAL and their absence is exactly the pre-v1.58.0
* behaviour, so every plan written before renders bit-identically and there is
* no migration to run.
*/
export function planRect(space: any, side = NORM_W): Rect {
const base = fitInSquare(space?.plan_aspect, side);
const raw = Number(space?.plan_scale);
const k = Number.isFinite(raw) && raw > 0
? Math.min(PLAN_SCALE_MAX, Math.max(PLAN_SCALE_MIN, raw))
: 1;
const dx = Number(space?.plan_x);
const dy = Number(space?.plan_y);
return {
x: base.x + (Number.isFinite(dx) ? clampCanvasN(dx) : 0) * side,
y: base.y + (Number.isFinite(dy) ? clampCanvasN(dy) : 0) * side,
w: base.w * k,
h: base.h * k,
};
}
export type Pt = { x: number; y: number };
export type Layout = Record<string, { s?: string; x: number; y: number } | undefined>;
@@ -78,67 +116,323 @@ export function spaceModels(cfg: ServerConfig | null): SpaceModel[] {
id: s.id,
title: s.title,
vb: [vb[0] * NORM_W, vb[1] * H, vb[2] * NORM_W, vb[3] * H],
bg: s.plan_url ? { href: contentUrl(s.plan_url), ...fitInSquare(s.plan_aspect, NORM_W) } : null,
// the image's own placement — the centred default plus whatever the
// backdrop frame has stored (plan_x/plan_y/plan_scale, docs/BACKDROP.md)
bg: s.plan_url ? { href: contentUrl(s.plan_url), ...planRect(s, NORM_W) } : null,
rooms: (s.rooms || []).map(scale),
} as SpaceModel;
});
}
/**
* What the plan actually occupies, padded by `pad` of the larger side.
*
* The canvas is a square big enough for any house, so a small hand-drawn plan
* used to open as a speck in the middle of it. Zooming to the CONTENT instead
* of the canvas is what people expect — but only when there is no background
* image: with one, the image is the plan, and cropping to the rooms would hide
* the parts of it nobody has outlined yet.
*
* Returns null when there is nothing drawn, so the caller keeps the full canvas.
*/
export function contentBounds(
space: SpaceModel, pad = 0.05, extra?: ReadonlyArray<readonly [number, number]>,
): { x: number; y: number; w: number; h: number } | null {
/* =====================================================================
* INFINITE CANVAS (docs/CANVAS.md)
* ===================================================================== */
/** Sane coordinate range in NORMALISED units — mirrors validation.py.
* Not a frame: insurance against a stored 1e100 (HP-1500-03/HP-1501-01). */
export const CANVAS_LIMIT = 5000;
/** The same range in RENDER units. */
export const SANE_LIMIT = CANVAS_LIMIT * NORM_W;
/** Grid points across the plan width — the lattice the editor snaps to.
* It is derived from NORM_W alone, so it is the SAME step for every plan and
* it did NOT change when the canvas became infinite (docs/CANVAS.md §9). */
export const GRID_N = 240;
/** One grid step in RENDER units. */
export const GRID_PITCH = NORM_W / GRID_N;
/** One grid step in NORMALISED units — what the config and the layout store. */
export const GRID_STEP_N = 1 / GRID_N;
/** Snap a RENDER-unit coordinate to the editor's grid (docs/CANVAS.md §9). */
export function snapR(v: number): number {
if (!Number.isFinite(v)) return v;
// integer node index first, then back — dividing by 1000/240 directly turns
// an exact 500 into 500.00000000000006 and every equality downstream lies
const q = (Math.round((v * GRID_N) / NORM_W) * NORM_W) / GRID_N;
return Math.abs(q - v) <= GRID_PITCH * 1e-9 ? v : q;
}
/** …and a point. Used for AUTO placements (a device with no saved position, a
* room label nobody has dragged) so that "everything is on the grid" holds for
* what the card puts there itself, not only for what the user drags. */
export function snapPt(p: { x: number; y: number }): { x: number; y: number } {
return { x: snapR(p.x), y: snapR(p.y) };
}
/** Clamp a RENDER-unit coordinate to the sane canvas range (docs/CANVAS.md §9).
* This is the ONLY bound any editor gesture may impose: the plan has no edges
* any more, only a garbage limit that mirrors validation.py. */
export function clampCanvasR(v: number): number {
return Number.isFinite(v) ? Math.min(SANE_LIMIT, Math.max(-SANE_LIMIT, v)) : 0;
}
/** The same range in NORMALISED units. */
export function clampCanvasN(v: number): number {
return Number.isFinite(v) ? Math.min(CANVAS_LIMIT, Math.max(-CANVAS_LIMIT, v)) : 0;
}
/** Zoom-out floor: three times the content frame and no further (CANVAS.md §5). */
export const MIN_ZOOM = 1 / 3;
/** How far past the content frame panning may go, in screens (CANVAS.md §5). */
export const PAN_SLACK = 1;
/** Outlier vote tuning — see docs/CANVAS.md §4.1. */
export const OUTLIER_K = 10; // "an order of magnitude further"
export const MIN_VOTERS = 4; // fewer items: nobody to be far FROM
export const MIN_SPREAD = NORM_W * 0.05; // ~a small room: floor for the scale
export const OUTLIER_MAX_SHARE = 1 / 3; // more than this: not strays, a wide plan
/** A degenerate axis (a lone marker) is grown to this, so the SVG paints. */
export const DEGENERATE = NORM_W * 0.03;
export const FLOOR = NORM_W * 0.2;
export type Rect = { x: number; y: number; w: number; h: number };
/** One drawn/placed object, as its own bounding box (render units). */
export type ContentItem = { minX: number; minY: number; maxX: number; maxY: number };
/** Bounding box of a point cloud, as a ContentItem. */
export function itemOf(pts: ReadonlyArray<readonly [number, number] | number[]>): ContentItem | null {
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
// Only points within a bounded envelope around the canvas command the
// opening view. The server bounds what it ACCEPTS now (±4 normalised,
// HP-1501-01), but a store may already hold an absurd coordinate from
// before that door existed — and one 1e100 vertex framed the space so wide
// the plan was a dot for every client. An out-of-envelope point is still
// rendered wherever it is; it just does not decide the frame. This applies
// to ROOM GEOMETRY and device positions alike (HP-1500-03, HP-1501-01).
const lo = -NORM_W * 0.25, hi = NORM_W * 1.25;
const add = (x: number, y: number) => {
if (!Number.isFinite(x) || !Number.isFinite(y)) return;
if (x < lo || x > hi || y < lo || y > hi) return;
for (const p of pts) {
const x = Number(p[0]), y = Number(p[1]);
if (!Number.isFinite(x) || !Number.isFinite(y)) continue;
if (x < minX) minX = x;
if (y < minY) minY = y;
if (x > maxX) maxX = x;
if (y > maxY) maxY = y;
};
for (const r of space.rooms || []) {
if (r.poly) for (const p of r.poly) add(p[0], p[1]);
else if (r.x != null && r.y != null) {
add(r.x, r.y);
add(r.x + (r.w || 0), r.y + (r.h || 0));
}
}
// things that live outside any room still count as content — a gate sensor
// by the fence, a camera on a pole (the card passes device positions here)
for (const p of extra || []) add(p[0], p[1]);
return minX > maxX ? null : { minX, minY, maxX, maxY };
}
/** The room's own bounding box as a content item (polygon or legacy rect). */
export function roomItem(r: RoomCfg): ContentItem | null {
if (r.poly && r.poly.length) return itemOf(r.poly);
if (r.x == null || r.y == null) return null;
return itemOf([[r.x, r.y], [r.x + (r.w || 0), r.y + (r.h || 0)]]);
}
/**
* Every object of a space that counts as content (docs/CANVAS.md §4):
* the rooms, the backdrop image rectangle, plus whatever the caller adds
* (devices, openings, decor — the model does not carry those).
*/
export function contentItems(
space: SpaceModel,
extra?: ReadonlyArray<ContentItem | readonly [number, number]>,
): ContentItem[] {
const out: ContentItem[] = [];
for (const r of space.rooms || []) { const it = roomItem(r); if (it) out.push(it); }
// The backdrop image is ONE OF the objects of the space, exactly like a room
// (docs/BACKDROP.md §4): cropping to the outlined rooms would hide the parts
// of the picture nobody has drawn over yet, and — since v1.58.0 — the
// rectangle here is the MOVED and SCALED one, so «Вписать всё» follows the
// picture wherever the owner has dragged it.
if (space.bg) out.push({ minX: space.bg.x, minY: space.bg.y, maxX: space.bg.x + space.bg.w, maxY: space.bg.y + space.bg.h });
for (const e of extra || []) {
if (Array.isArray(e)) { const it = itemOf([e as any]); if (it) out.push(it); }
else out.push(e as ContentItem);
}
return out;
}
const median = (a: number[]): number => {
if (!a.length) return 0;
const s = [...a].sort((p, q) => p - q);
const m = s.length >> 1;
return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
};
/** Rank-based quantile of an ALREADY SORTED array (nearest-rank, clamped). */
const quantile = (sorted: number[], q: number): number => {
if (!sorted.length) return 0;
const i = Math.min(sorted.length - 1, Math.max(0, Math.round(q * (sorted.length - 1))));
return sorted[i];
};
const boxOf = (items: ContentItem[]): Rect | null => {
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const it of items) {
if (it.minX < minX) minX = it.minX;
if (it.minY < minY) minY = it.minY;
if (it.maxX > maxX) maxX = it.maxX;
if (it.maxY > maxY) maxY = it.maxY;
}
if (minX > maxX || minY > maxY) return null;
return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
};
/** Pad by `pad` of the longer side and lift a degenerate axis off zero. */
function padRect(b: Rect, pad: number): Rect {
let { x, y, w, h } = b;
// A single marker (or a collinear row of them) has no area, and an SVG
// viewBox with a zero axis draws nothing at all (HP-1500-03). An axis with
// essentially no span — nothing there but icons — opens up to a floor:
// enough canvas around a lone marker to see where it stands. A REAL thin
// shape (a 100-unit corridor) keeps its tight frame; only the degenerate
// case is padded, so the threshold sits at about an icon's size.
const FLOOR = NORM_W * 0.2;
const DEGENERATE = NORM_W * 0.03;
if (maxX - minX < DEGENERATE) { const c = (minX + maxX) / 2; minX = c - FLOOR / 2; maxX = c + FLOOR / 2; }
if (maxY - minY < DEGENERATE) { const c = (minY + maxY) / 2; minY = c - FLOOR / 2; maxY = c + FLOOR / 2; }
const m = Math.max(maxX - minX, maxY - minY) * pad;
const x = minX - m, y = minY - m;
return { x, y, w: (maxX - minX) + m * 2, h: (maxY - minY) + m * 2 };
// viewBox with a zero axis draws nothing at all (HP-1500-03). Only the
// DEGENERATE case is inflated — a real 100-unit corridor keeps its frame.
if (w < DEGENERATE) { x = x + w / 2 - FLOOR / 2; w = FLOOR; }
if (h < DEGENERATE) { y = y + h / 2 - FLOOR / 2; h = FLOOR; }
const m = Math.max(w, h) * pad;
return { x: x - m, y: y - m, w: w + m * 2, h: h + m * 2 };
}
export interface ContentFrame {
/** The main mass, padded — the opening view. */
core: Rect | null;
/** Everything, padded — what "show the far objects" fits. */
all: Rect | null;
/** How many items the core deliberately leaves out. */
outliers: number;
}
/**
* The content frame (docs/CANVAS.md §4). Pure and rank-based: one absurd
* coordinate can move neither the median nor the 75th percentile, so it
* cannot decide the frame — but it is never DELETED either, it is simply
* not a voter, and "show everything" still reaches it.
*/
export function contentFrame(
items: ReadonlyArray<ContentItem>,
opts: { pad?: number; k?: number; minSpread?: number } = {},
): ContentFrame {
const pad = opts.pad ?? 0.05;
const k = opts.k ?? OUTLIER_K;
const minSpread = opts.minSpread ?? MIN_SPREAD;
// Step 1 — corruption, not content: anything past the range the backend
// itself accepts is dropped outright (it is not reachable by "show all"
// either, because it is not a coordinate anybody meant).
const sane = items.filter((it) =>
Number.isFinite(it.minX) && Number.isFinite(it.minY)
&& Number.isFinite(it.maxX) && Number.isFinite(it.maxY)
&& Math.abs(it.minX) <= SANE_LIMIT && Math.abs(it.maxX) <= SANE_LIMIT
&& Math.abs(it.minY) <= SANE_LIMIT && Math.abs(it.maxY) <= SANE_LIMIT);
if (!sane.length) return { core: null, all: null, outliers: 0 };
const all = boxOf(sane)!;
if (sane.length < MIN_VOTERS) {
const r = padRect(all, pad);
return { core: r, all: r, outliers: 0 };
}
const cx = sane.map((it) => (it.minX + it.maxX) / 2);
const cy = sane.map((it) => (it.minY + it.maxY) / 2);
const mx = median(cx), my = median(cy);
const d = sane.map((_, i) => Math.max(Math.abs(cx[i] - mx), Math.abs(cy[i] - my)));
const spread = Math.max(quantile([...d].sort((p, q) => p - q), 0.75), minSpread);
const far = d.map((v) => v > k * spread);
const n = far.filter(Boolean).length;
// Majority veto: a plan whose objects are simply spread out has no strays.
const keep = n && n <= sane.length * OUTLIER_MAX_SHARE ? sane.filter((_, i) => !far[i]) : sane;
const core = boxOf(keep) || all;
return {
core: padRect(core, pad),
all: padRect(all, pad),
outliers: keep === sane ? 0 : n,
};
}
/**
* What the plan actually occupies, padded by `pad` of the larger side —
* the MAIN MASS only (docs/CANVAS.md §4.1). Returns null when nothing is
* drawn, so the caller can fall back to the stored view_box hint.
*
* Kept as the narrow entry point used by the renderers; `contentFrame`
* is the one that also reports the outliers and the fit-everything box.
*/
export function contentBounds(
space: SpaceModel, pad = 0.05, extra?: ReadonlyArray<readonly [number, number]>,
): Rect | null {
return contentFrame(contentItems(space, extra), { pad }).core;
}
/** The rectangle a renderer frames a space with: content, else the stored
* view_box HINT, else the legacy unit square (docs/CANVAS.md §4). */
export function spaceFrame(
space: SpaceModel,
extra?: ReadonlyArray<ContentItem | readonly [number, number]>,
pad = 0.05,
): Rect {
const f = contentFrame(contentItems(space, extra), { pad });
if (f.core) return f.core;
const vb = space.vb && space.vb.length === 4 && space.vb[2] > 0 && space.vb[3] > 0
? space.vb : [0, 0, NORM_W, NORM_W];
return { x: vb[0], y: vb[1], w: vb[2], h: vb[3] };
}
/** Middle of the content (fallback anchor for a device with no position). */
export function spaceCenter(space: SpaceModel): Pt {
const r = spaceFrame(space);
return { x: r.x + r.w / 2, y: r.y + r.h / 2 };
}
/**
* The BASE UNIT one icon-percent is measured against, in render units
* (docs/CANVAS.md §6). It is both the icon's own footprint and the
* auto-placement spacing, so the two can never drift apart.
*
* For any plan that fits the old square this is exactly NORM_W — which is
* what `vb.w` was for every plan the card itself ever wrote (the editor only
* ever stored `view_box: [0,0,1,1]`), so sizes and layouts are bit-identical
* to the pre-infinite-canvas card. A plan drawn three canvases wide gets a
* proportionally bigger unit, which is what keeps its markers from
* degenerating into dots once the frame is the content (see `iconCqw`).
* Rooms only — deterministic, so the full card and the static card agree.
*
* The MAIN MASS only, by the very same vote the frame uses (§4.1). A single
* room dragged into the far corner is already rejected from `contentFrame.core`
* — but it used to keep its distance in this numerator, so the plan the user
* actually looks at grew icons ~91x too big while the frame stayed correct
* (DEV-2C947-03). What is out of the frame is out of the icon unit: one notion
* of "the plan", not two.
*/
export function iconUnit(space: SpaceModel): number {
const items: ContentItem[] = [];
for (const r of space.rooms || []) { const it = roomItem(r); if (it) items.push(it); }
// pad 0: this is a UNIT, not a viewport — the frame's 5 % breathing room has
// no business inflating the icons. Degenerate axes are still lifted off zero,
// which cannot matter here (FLOOR < NORM_W).
const b = contentFrame(items, { pad: 0 }).core;
if (!b) return NORM_W;
return Math.max(NORM_W, Math.min(SANE_LIMIT, Math.max(b.w, b.h)));
}
/**
* `--icon-size` in cqw (docs/CANVAS.md §6) — the ONE expression both
* renderers use.
*
* An icon is a percentage of the PLAN, not of the viewport: it scales with
* the plan as you zoom, exactly as it did before the infinite canvas (owner,
* 2026-08-03). In render units the marker always occupies
* `iconPct/100 * iconUnit`, whatever the frame or the zoom happens to be;
* dividing by the width of the visible view turns that into a percentage of
* the container, which is what `cqw` means.
*
* The only thing the infinite canvas changed here is the numerator: it used
* to be `vb.w`, the stored view_box, and a plan drawn past the old square
* kept a 1000-unit numerator while its frame grew to tens of thousands —
* markers would shrink to invisible dots. `iconUnit` is that same 1000 for
* an ordinary plan and grows with an outsized one.
*/
export function iconCqw(
iconPct: number, space: SpaceModel, viewW: number, kioskIcon = 1,
): number {
const w = Number(viewW);
const k = Number.isFinite(kioskIcon) && kioskIcon > 0 ? kioskIcon : 1;
// No view yet (first paint, zero-width stage): fall back to the plain
// percentage rather than to Infinity/NaN — the frame arrives a tick later.
if (!Number.isFinite(w) || w <= 0) return iconPct * k;
return (iconPct * iconUnit(space) * k) / w;
}
/** Grid step multipliers offered to the adaptive grid (docs/CANVAS.md §7). */
export const GRID_STEPS = [1, 2, 5, 10, 20, 50, 100, 200, 500, 1000];
/**
* Adaptive grid density: which multiple of the base pitch is still legible
* at the current scale, and which coarser one carries the accent dots.
* `null` = even the coarsest step is sub-pixel, draw no grid at all.
*/
export function gridLevels(
pitch: number, pxPerUnit: number, minPx = 7,
): { fine: number; coarse: number } | null {
if (!(pitch > 0) || !(pxPerUnit > 0) || !Number.isFinite(pxPerUnit)) return null;
const fine = GRID_STEPS.find((m) => pitch * m * pxPerUnit >= minPx);
if (fine === undefined) return null;
const coarse = GRID_STEPS.find((m) => m >= fine * 5) ?? fine * 5;
return { fine, coarse };
}
/** Bounding rectangle of a room (rect or polygon) in render units. */
@@ -165,7 +459,10 @@ export function roomCenter(r: RoomCfg): number[] {
/** Auto grid positions for a single space's area devices (identical to the full card). */
export function defaultPositions(devs: DevItem[], model: SpaceModel, iconPct: number): Record<string, Pt> {
const map: Record<string, Pt> = {};
const minDist = (iconPct / 100) * NORM_W * 1.3;
// NOT a bare NORM_W any more (docs/CANVAS.md §6): on a plan wider than the
// old square the icons are proportionally larger in render units, so the
// declump distance has to grow with the plan or auto-placed markers overlap.
const minDist = (iconPct / 100) * iconUnit(model) * 1.3;
for (const r of model.rooms) {
if (!r.area) continue;
const ds = devs.filter((d) => d.area === r.area);
@@ -182,7 +479,7 @@ export function defaultPositions(devs: DevItem[], model: SpaceModel, iconPct: nu
y: b.y + pad + ch * (Math.floor(i / cols) + 0.5),
}));
declump(pts, b, minDist, pad * 0.5);
ds.forEach((d, i) => (map[d.id] = pts[i]));
ds.forEach((d, i) => (map[d.id] = snapPt(pts[i])));
}
return map;
}
@@ -194,8 +491,9 @@ export function markerPos(d: DevItem, layout: Layout, cfg: ServerConfig, defPos:
return { x: saved.x * NORM_W, y: saved.y * NORM_W };
}
if (defPos[d.id]) return defPos[d.id];
const vb = model.vb;
return { x: vb[0] + vb[2] / 2, y: vb[1] + vb[3] / 2 };
// no saved position, no room to auto-place in: the middle of what IS drawn,
// not the middle of a canvas that no longer has edges (docs/CANVAS.md)
return snapPt(spaceCenter(model));
}
/** Saved room-label position (layout key rl_<roomId>) or the room centre. */
@@ -204,6 +502,7 @@ export function labelPos(r: RoomCfg, spaceId: string, layout: Layout, cfg: Serve
if (saved && saved.s === spaceId) {
return { x: saved.x * NORM_W, y: saved.y * NORM_W };
}
// never dragged: the centroid, put on the nearest node (docs/CANVAS.md §9)
const c = roomCenter(r);
return { x: c[0], y: c[1] };
return snapPt({ x: c[0], y: c[1] });
}
+38 -19
View File
@@ -14,7 +14,8 @@ import { t, type Lang } from './i18n';
import { bgModeOf, northDegOf, sunStateOf, dayPhase } from './sun';
import type { ServerConfig } from './types';
import {
spaceModels, roomCenter, defaultPositions, markerPos, labelPos, type Layout,
spaceModels, roomCenter, defaultPositions, markerPos, labelPos, spaceFrame, iconCqw, NORM_W,
type Layout, type ContentItem,
} from './space-geometry';
export { spaceModels } from './space-geometry';
@@ -43,7 +44,6 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
const models = spaceModels(o.cfg);
const space = models.find((s) => s.id === o.spaceId);
if (!space) return null;
const vb = space.vb;
const disp = spaceDisplayOf(o.cfg.spaces.find((s: any) => s.id === o.spaceId));
const cfgSize = o.iconSize ?? 2.5;
const iconPct = cfgSize > 8 ? 2.5 : cfgSize;
@@ -80,6 +80,24 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
// two cards (HP-1511-01). Rendering still draws `devs` only.
const defPos = defaultPositions(spaceDevs, space, iconPct);
// docs/CANVAS.md §4: the static card frames the CONTENT, exactly like the
// full one — `space.vb` is only the stored hint now, and on a plan drawn
// past the old unit square it framed empty canvas with the house off-screen.
// Markers placed outside every room count too (a gate sensor by the fence)
// — but only the ones this card DRAWS: a hidden device is never painted
// here, so it must not stretch the frame either (DEV-2C947-01). It keeps
// its grid cell above; the frame is presentation, the roster is not.
const placed: ContentItem[] = [];
for (const d of devs) {
const sv = o.layout[d.id];
if (sv && sv.s === o.spaceId) {
const x = sv.x * NORM_W, y = sv.y * NORM_W;
placed.push({ minX: x, minY: y, maxX: x, maxY: y });
}
}
const fr = spaceFrame(space, placed);
const vb = [fr.x, fr.y, fr.w, fr.h];
const roomShapes = space.rooms
.filter((r) => r.area || disp.showBorders)
.map((r) => {
@@ -158,32 +176,33 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
}
const stageBg = sunBg || stageBgOf(o.cfg?.settings, disp);
// Opaque plan paper (owner 2026-08-03), same contract as the full card: the
// scene background never bleeds through the plan. With a backdrop image the
// paper is the image rect; a hand-drawn plan papers the ROOM CONTOURS
// (paperRoomShapes) — never their bounding box, so the scene colour reaches
// the exterior walls of an L-shaped house and fills the gaps between
// detached buildings. An empty drawn space has no paper at all.
const paper = space.bg
? { x: space.bg.x, y: space.bg.y, w: space.bg.w, h: space.bg.h }
: null;
// Opaque plan paper, same contract as the full card (docs/BACKDROP.md §3):
// the paper is ALWAYS the ROOM CONTOURS and only them — never their bounding
// box, and (since v1.58.0) never the backdrop image rect either. The scene
// colour therefore reaches the exterior walls of an L-shaped house, fills the
// gaps between detached buildings, and an empty space has no paper at all,
// image or no image. The picture is drawn ON the paper, one layer above.
return html`
<div class="hp-static-stage" style="aspect-ratio:${vb[2]}/${vb[3]}${stageBg ? ';background:' + stageBg : ''}">
<svg viewBox="${vb[0]} ${vb[1]} ${vb[2]} ${vb[3]}" preserveAspectRatio="xMidYMid meet">
${paper
? svg`<rect class="hp-paper" x="${paper.x}" y="${paper.y}" width="${paper.w}" height="${paper.h}"></rect>`
: paperRoomShapes(space.rooms).map((sh) =>
'poly' in sh
? svg`<polygon class="hp-paper" points="${sh.poly}"></polygon>`
: svg`<rect class="hp-paper" x="${sh.rect.x}" y="${sh.rect.y}" width="${sh.rect.w}" height="${sh.rect.h}" rx="${sh.rect.rx}"></rect>`,
)}
${paperRoomShapes(space.rooms).map((sh) =>
'poly' in sh
? svg`<polygon class="hp-paper" points="${sh.poly}"></polygon>`
: svg`<rect class="hp-paper" x="${sh.rect.x}" y="${sh.rect.y}" width="${sh.rect.w}" height="${sh.rect.h}" rx="${sh.rect.rx}"></rect>`,
)}
${bgHref
? svg`<image href="${bgHref}" x="${space.bg!.x}" y="${space.bg!.y}" width="${space.bg!.w}" height="${space.bg!.h}" preserveAspectRatio="none" />`
: nothing}
${roomShapes}
</svg>
<div class="devlayer" style="--icon-size:${iconPct}cqw">${markers}${labels}</div>
${''/* docs/CANVAS.md §6: the same expression as the full card. The
static card has no zoom, but its frame is the CONTENT now, so a
bare `iconPct` would make markers shrink relative to the plan the
tighter the frame got. `iconCqw` keeps the marker's footprint at
iconPct% of the plan's base unit, which is what it was when the
frame was the stored view_box. */}
<div class="devlayer" style="--icon-size:${iconCqw(iconPct, space, vb[2]).toFixed(3)}cqw">${markers}${labels}</div>
</div>
`;
}
+212 -1
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,
@@ -314,6 +322,60 @@ export const cardStyles = css`
opacity: 0.4;
pointer-events: none;
}
/* docs/CANVAS.md §4.1: objects an order of magnitude away from the plan
do not decide the opening view — one quiet chip says so and offers to
take them in. Never a modal (owner). */
.farhint {
position: absolute;
left: 50%;
transform: translateX(-50%);
bottom: var(--sp-4);
z-index: 12;
display: flex;
align-items: center;
gap: var(--sp-2);
max-width: calc(100% - var(--sp-8));
background: var(--card-background-color, var(--hp-bg));
opacity: 0.94;
color: var(--hp-txt);
border: 1px solid var(--divider-color, #33404d);
border-radius: var(--rad-m);
padding: var(--sp-1) var(--sp-3);
font-size: var(--fs-s);
}
.farhint ha-icon {
--mdc-icon-size: 18px;
color: var(--hp-warn, #e2a03f);
flex: none;
}
.farhint span {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* docs/CANVAS.md §5: the plane has no edges, so you can pan until nothing
is on screen. One pointer home, one click back. */
.homearrow {
position: absolute;
z-index: 12;
transform: translate(-50%, -50%);
width: 40px;
height: 40px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
background: var(--card-background-color, var(--hp-bg));
color: var(--hp-accent);
border: 1px solid var(--hp-accent);
opacity: 0.9;
padding: 0;
}
.homearrow ha-icon {
--mdc-icon-size: 22px;
line-height: 1;
}
.zoombadge {
position: absolute;
left: var(--sp-4);
@@ -358,9 +420,19 @@ export const cardStyles = css`
.stage.daynight .hp-paperg {
filter: drop-shadow(0 2px 8px rgba(10, 16, 26, 0.28));
}
/* Owner 2026-08-04: «углы границ комнат всё ещё с зубцами». A miter join
on a 30-45° corner shoots a spike far past the wall (and flips to an
ugly bevel once past the miter limit) — the same defect the decor lines
had before they got round caps. Every room border, in EVERY renderer
that reuses these styles (plan view, plan editor, static space-card),
joins its walls with a ROUND join instead: the corner reads as the
stroke's own radius, never as a tooth. The linecap matters only for the
open outlines below, but it costs nothing to state it here. */
.room {
transition: 0.12s;
cursor: default; /* v1.40.1: rooms are not clickable — the label's link icon is */
stroke-linejoin: round;
stroke-linecap: round;
}
.room.overlay {
fill: transparent;
@@ -814,6 +886,16 @@ export const cardStyles = css`
cursor: pointer;
}
.stage.mode-decor.dtool-select .decorlayer .dshape { cursor: move; }
/* …but a DRAWING tool owns the canvas: existing shapes stop being targets
entirely, so a new line can start exactly on the end of an old one
instead of grabbing it (owner, 2026-08-04). Same for the picture tool —
a shape lying over the plan must not block the picture's own drag.
Only select (move) and erase (delete) keep the shapes clickable. */
.stage.mode-decor.dtool-line .decorlayer .dshape,
.stage.mode-decor.dtool-rect .decorlayer .dshape,
.stage.mode-decor.dtool-ellipse .decorlayer .dshape,
.stage.mode-decor.dtool-text .decorlayer .dshape,
.stage.mode-decor.dtool-backdrop .decorlayer .dshape { pointer-events: none; }
.decorlayer .dsel {
filter: drop-shadow(0 0 3px var(--hp-accent));
}
@@ -832,6 +914,37 @@ export const cardStyles = css`
outline: 2px solid #26a69a;
outline-offset: -2px;
}
/* backdrop transform frame (docs/BACKDROP.md §2). Editor chrome: the
outline never takes a pointer, the four corner handles do — and they are
finger-sized (r = 2 % of the visible view), because this is dragged on a
tablet as often as with a mouse. */
.bdframe .bdbox {
fill: none;
stroke: var(--hp-accent);
stroke-width: 2;
stroke-dasharray: 10 7;
vector-effect: non-scaling-stroke;
pointer-events: none;
opacity: 0.9;
}
.bdframe .bdhandle {
fill: var(--hp-accent);
stroke: #fff;
stroke-width: 1.5;
vector-effect: non-scaling-stroke;
pointer-events: all;
touch-action: none;
}
.bdframe .bd-nwse { cursor: nwse-resize; }
.bdframe .bd-nesw { cursor: nesw-resize; }
/* the picture itself is the drag target for a move (grab, then grabbing) */
.stage.mode-decor.bdgrab { cursor: grab; }
.stage.mode-decor.bdgrabbing,
.stage.mode-decor.bdgrabbing .bdframe .bdhandle { cursor: grabbing; }
.measurelabel.bdmeasure {
transform: translate(-50%, -50%);
border: 1px solid var(--hp-accent);
}
.stage.mode-decor.dtool-line, .stage.mode-decor.dtool-rect,
.stage.mode-decor.dtool-ellipse, .stage.mode-decor.dtool-text {
cursor: crosshair;
@@ -853,6 +966,13 @@ export const cardStyles = css`
background: var(--hp-bg2, transparent); color: var(--hp-txt); border: 1px solid var(--hp-muted);
padding: var(--sp-2) var(--sp-3);
}
.decorbar .bdhint {
font-size: var(--fs-s);
color: var(--hp-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.decorbar .dfill {
display: inline-flex; align-items: center; gap: var(--sp-2); font-size: var(--fs-s); cursor: pointer;
}
@@ -904,9 +1024,15 @@ export const cardStyles = css`
.room.noedge {
stroke-opacity: 0 !important;
}
/* rooms with open boundaries draw their walls as separate M..L subpaths,
so a corner between two of them is two stroke ENDS meeting: round caps
fill it in the same way a round join fills a closed contour's corner
(owner 2026-08-04 — no teeth anywhere on a room border). */
.room-outline {
fill: none;
stroke-width: 2.5;
stroke-linejoin: round;
stroke-linecap: round;
pointer-events: none;
}
/* Plan editor: trimmed outlines use the markup blue */
@@ -999,16 +1125,29 @@ export const cardStyles = css`
stroke-width: 3;
fill: rgba(255, 193, 77, 0.25);
}
/* Owner 2026-08-04: the grid is a HINT, not content — at full strength the
dots argued with the plan on white paper. Both levels are muted, the
hierarchy is kept (major still denser than fine). */
.griddot {
fill: var(--hp-accent);
opacity: 0.75;
opacity: 0.35;
stroke: rgba(0, 0, 0, 0.35);
stroke-width: 0.4;
}
/* docs/CANVAS.md §7: every coarse node (5x/10x the live step) keeps a
bigger, more opaque dot, so zoomed far out the grid still reads as a
grid instead of a grey wash. */
.griddot.major {
opacity: 0.5;
stroke-width: 0;
}
/* the contour being drawn in the Plan editor: each wall is its own <line>,
so the round cap IS the corner (matches the finished .room border) */
.seg {
stroke: var(--hp-accent);
stroke-width: 2.5;
stroke-linecap: round;
stroke-linejoin: round;
}
.pathline {
stroke: #ffc14d;
@@ -1106,6 +1245,50 @@ export const cardStyles = css`
opacity: 0.4;
pointer-events: none;
}
/* COVER ON THE MOVE (owner 2026-08-03): «не жёлтая подложка, а лёгкая
пульсация вокруг значка в стиле шайбы пылесоса». Same yellow as the
sense rings, the vacuum puck's 2.2s period, and a moderate opacity so
two curtains travelling at once never turn into a strobe. The plate
itself stays neutral — yellow means «включено», nothing else. */
/* Sun wedges (docs/SUN.md). The layer is present ONLY above the 3°
threshold; crossing it fades the whole layer in or out over EXACTLY
2 s (owner 2026-08-03 — RAY_FADE_MS in src/sun.ts must match). The
geometry is untouched: this is a plain opacity animation on the group,
so overlapping wedges keep their own blending while it plays. */
.sunlayer {
animation: hp-sunfade-in 2s linear both;
}
.sunlayer.out {
animation: hp-sunfade-out 2s linear both;
}
@keyframes hp-sunfade-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes hp-sunfade-out {
from { opacity: 1; }
to { opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
/* no fade at all: the rays are simply there or simply gone */
.sunlayer, .sunlayer.out { animation: none; }
.sunlayer.out { opacity: 0; }
}
.dev.covermove::after {
content: '';
position: absolute;
inset: calc(var(--dev-size, var(--icon-size, 2.5cqw)) * -0.35);
border: 2px solid var(--hp-on);
border-radius: 50%;
opacity: 0.45;
animation: hp-covermove 2.2s ease-in-out infinite;
pointer-events: none;
}
@keyframes hp-covermove {
0% { transform: scale(0.92); opacity: 0.16; }
50% { transform: scale(1.12); opacity: 0.5; }
100% { transform: scale(0.92); opacity: 0.16; }
}
@keyframes hp-sense {
0% { transform: scale(0.9); opacity: 0.5; }
60% { transform: scale(1.12); opacity: 0.12; }
@@ -1139,6 +1322,9 @@ export const cardStyles = css`
by design already */
.dev.senseflash::after,
.dev.senseflash.sf2::after { animation: none; opacity: 0.4; } /* HP-1543-02: retrip re-arms the window, ring stays static */
/* a travelling cover keeps a STATIC ring — the movement is still shown,
it just stops breathing */
.dev.covermove::after { animation: none; opacity: 0.4; }
}
.dev .newdot {
position: absolute;
@@ -1627,6 +1813,12 @@ export const cardStyles = css`
vector-effect: non-scaling-stroke;
pointer-events: none;
}
/* the decor draft badge rides the MIDDLE of the shape, so it is centred
horizontally and lifted clear of the line instead of trailing the
cursor the way a wall badge does (owner 2026-08-04) */
.measurelabel.dmeasure {
transform: translate(-50%, -160%);
}
.measurelabel.rszarea {
transform: translate(-50%, -50%);
background: rgba(0, 0, 0, 0.6);
@@ -1858,6 +2050,25 @@ export const cardStyles = css`
font-size: var(--fs-m);
color: var(--hp-muted);
}
.alignmsg { margin: 0 0 8px; font-size: 13px; line-height: 1.45; }
.btn.alignall { width: 100%; justify-content: center; }
.aboutver {
font-size: var(--fs-s);
color: var(--hp-muted);
margin: var(--sp-2) 0 var(--sp-3);
}
.aboutlink {
display: flex;
align-items: center;
gap: var(--sp-3);
width: fit-content;
color: var(--hp-accent);
text-decoration: none;
font-size: var(--fs-m);
padding: var(--sp-1) 0;
}
.aboutlink:hover { text-decoration: underline; }
.aboutlink ha-icon { --mdc-icon-size: 18px; line-height: 1; }
.dialogwrap {
background: rgba(0, 0, 0, 0.45);
display: flex;
+174 -16
View File
@@ -119,26 +119,70 @@ export function windowWallInfo(
: { normal: n, roomId: minus!.id };
}
/** Does the sun actually shine INTO this window right now? (A grazing sun
* exactly along the wall does not count — hence the epsilon, which also
* swallows the sin/cos float dust of the right-angle directions.) */
/**
* How square the sun has to be to a wall before that wall's windows cast
* anything: the cosine of the angle of incidence, i.e. `outward normal · dir
* to the sun`. 0.05 is ~87.1°, so the sun has to clear the plane of the wall
* by ~2.9° — the same order as the 3° elevation threshold, and for the same
* reason: below it there is no light worth painting. Glass agrees (Fresnel
* reflects almost everything at that incidence), and so does the geometry —
* the shaft's perpendicular depth is `len · cos`, so under this threshold the
* whole wedge is a sliver thinner than the wall it came through, drawn with a
* gradient axis shorter than a pixel (DEV-EB173-01).
*/
export const RAY_MIN_COS = 0.05;
/** Does the sun actually shine INTO this window right now? (A sun grazing
* along the wall does not count — see RAY_MIN_COS, which also swallows the
* sin/cos float dust of the right-angle directions.) */
export function windowLit(normal: number[], sunDir: number[], elevation: number): boolean {
return elevation > 0 && normal[0] * sunDir[0] + normal[1] * sunDir[1] > 1e-9;
return elevation > 0 && normal[0] * sunDir[0] + normal[1] * sunDir[1] > RAY_MIN_COS;
}
// ---------------- wedge geometry ----------------
/**
* Wedge length in WINDOW LENGTHS: longest (~2.5) at sunrise/sunset, shortest
* (~0.8) at the zenith. `0.8 + 1.7·(1 − e/90)^1.6` — long low shafts, short
* noon pools, smooth in between (docs/SUN.md).
* Wedge length in WINDOW LENGTHS: longest (~1.75) at sunrise/sunset, shortest
* (~0.56) at the zenith. `0.56 + 1.19·(1 − e/90)^1.6` — long low shafts, short
* noon pools, smooth in between (docs/SUN.md). Owner 2026-08-04: «лучи от
* солнца сделать короче на 30%» — the whole curve is the old
* `0.8 + 1.7·(1 − e/90)^1.6` scaled by RAY_LENGTH_K, so the "low sun reaches
* further" shape is untouched.
*/
export const RAY_LENGTH_K = 0.7;
export function rayLength(elevation: number): number {
const e = Math.min(90, Math.max(0, elevation));
return 0.8 + 1.7 * Math.pow(1 - e / 90, 1.6);
return RAY_LENGTH_K * (0.8 + 1.7 * Math.pow(1 - e / 90, 1.6));
}
/** The unclipped wedge: window span a-b extruded by `len` along `dir`. */
/**
* The unclipped wedge: the window span a-b extruded along `dir` by the SAME
* `len` at both ends. An honest parallelogram — every ray through the glass
* travels exactly the wedge's reach, so the promised "30 % shorter" holds for
* each side of every wedge, at any sun angle.
*
* Its far edge is parallel to the WALL, and that is not a compromise: it is
* the iso-alpha line of the gradient the card actually draws. For parallel
* rays the distance travelled from the glass is `depth / cos`, an affine
* function of the point whose level sets are lines PARALLEL TO THE WALL, so
* the fade must run along the wall's NORMAL (see `SunRay.normal/depth` and
* docs/SUN.md), not along `dir`. With that axis all three invariants hold at
* once: the whole pane of glass sits at offset 0 (peak alpha end to end), the
* alpha at any point depends only on how far its own ray has travelled, and
* the wedge's far edge coincides with the gradient's end — a bright kerb is
* impossible by construction.
*
* The previous attempt (DEV-EB173-01) kept the gradient along `dir` from the
* span's midpoint and bent the GEOMETRY to match, extruding the two ends by
* different amounts. At a grazing sun that put one end of the glass itself at
* offset 0.88 — fully transparent before the shaft even started — and made
* the long side 88 % longer than the nominal reach instead of 30 % shorter.
*
* The two SIDES stay razor-sharp on purpose — owner 2026-08-04: «с лучами
* солнца ты сделал фигню — не надо размывать их боковые грани». A shaft of
* light through a window HAS crisp sides; only its reach fades.
*/
export function rayQuad(a: number[], b: number[], dir: number[], len: number): number[][] {
return [
[a[0], a[1]],
@@ -177,8 +221,21 @@ export interface SunRay {
b: number[];
/** Direction the light travels (AWAY from the sun), unit vector. */
dir: [number, number];
/** Wedge reach in render units (the gradient's fade distance). */
/** Wedge reach in render units: how far along `dir` every ray travels. */
len: number;
/**
* INWARD wall normal (unit) — the axis of the fade. The distance a point
* has travelled from the glass is the same affine function of the point as
* its depth under the wall, so the gradient's iso-alpha lines are parallel
* to the wall and its axis is this normal (docs/SUN.md, DEV-EB173-01).
*/
normal: [number, number];
/**
* Length of that axis: `len · (dir·normal)` — the perpendicular depth a ray
* reaches after travelling `len`. A point `source + dir·u` therefore lands
* at offset `u/len`: the glass is all at 0, the far edge all at 1.
*/
depth: number;
}
/**
@@ -213,20 +270,53 @@ export function computeSunRays(
const len = k * w.length;
const polys = clipToRoom(rayQuad(a, b, away, len), room.poly);
if (!polys.length) continue;
out.push({ openingId: w.id, roomId: info.roomId, polys, a, b, dir: away, len });
// inward normal + how deep the ray gets: cos of the incidence angle,
// which windowLit() has already found to be above RAY_MIN_COS
const normal: [number, number] = [-info.normal[0], -info.normal[1]];
const cos = away[0] * normal[0] + away[1] * normal[1];
out.push({ openingId: w.id, roomId: info.roomId, polys, a, b, dir: away, len,
normal, depth: len * cos });
}
return out;
}
// ---------------- wedge dressing ----------------
/** Peak wedge opacity; two overlapping wedges stay readable (docs/SUN.md). */
export const RAY_MAX_ALPHA = 0.18;
/**
* Peak wedge opacity (owner 2026-08-03: «лучи поярче, иногда плохо видны» —
* raised from 0.18). Two overlapping wedges still stay under a readable
* ceiling on white paper AND on the dark glow canvas (docs/SUN.md).
*/
export const RAY_MAX_ALPHA = 0.3;
/** Wedge opacity: ramps in over the first ~2° so sunrise never pops. */
/**
* The elevation threshold, degrees. Below it there are NO rays at all, above
* it they are at FULL strength — the owner's 2026-08-03 contract replacing
* the old gradual ramp-in. The switch itself is not instant: the card fades
* the whole layer in/out over RAY_FADE_MS (CSS, not geometry).
*/
export const RAY_ELEVATION_MIN = 3;
/** Duration of that fade, ms — «ровно 2 секунды» (mirrored in styles.ts). */
export const RAY_FADE_MS = 2000;
/** Are the wedges present at this elevation at all? (The hard 3° threshold.) */
export function raysVisible(elevation: number): boolean {
return Number(elevation) >= RAY_ELEVATION_MIN;
}
/**
* Full-strength wedge opacity for the current cloud cover — elevation plays
* no part. The card feeds this into the gradient and lets CSS fade the layer,
* so a wedge dissolving at the threshold keeps its colour while it goes.
*/
export function rayPeakAlpha(cloud = 1): number {
return RAY_MAX_ALPHA * clamp01(cloud);
}
/** Wedge opacity: nothing below the threshold, full strength above it. */
export function rayAlpha(elevation: number, cloud = 1): number {
if (!(elevation > 0)) return 0;
return RAY_MAX_ALPHA * Math.min(1, elevation / 2) * clamp01(cloud);
return raysVisible(elevation) ? rayPeakAlpha(cloud) : 0;
}
/** Wedge color: warm orange at the horizon → neutral daylight. */
@@ -234,6 +324,74 @@ export function rayColor(warmth: number): string {
return lerpColor('#ffe9c2', '#ff9a45', clamp01(warmth));
}
/**
* Where the shaft is already fully dissolved, as a fraction of its own length.
* Owner 2026-08-04: «проверить, чтобы они всегда плавно рассеивались (сейчас
* есть ощущение, что они упираются во что-то невидимое)». The old gradient ran
* to alpha 0 exactly AT the far edge, so any wedge that ended in mid-air still
* carried a sliver of colour up to its last pixel — and the eye reads the
* straight line of a polygon edge long before the alpha reaches zero. The last
* visible light now sits at 85 % of the length; the remaining 15 % is empty.
*/
export const RAY_FADE_END = 0.85;
/**
* Gradient stops along the shaft: `[offset 0..1, share of the peak alpha]`.
* Convex ease-out — bright at the glass, half gone by a third of the way,
* a whisper at two thirds, nothing from RAY_FADE_END on. Consumed by the card
* as SVG <stop>s over the FULL wedge length, so the geometry and the gradient
* always describe the same shaft (docs/SUN.md).
*
* This gradient is the ONLY thing that dissolves a wedge: the falloff runs
* along the ray, from the glass inward, and the sides of the shaft keep the
* hard edge light actually has (owner 2026-08-04: «не надо размывать их
* боковые грани»). No blur is involved anywhere.
*/
export function rayStops(): [number, number][] {
return [
[0, 1],
[0.26, 0.86],
[0.46, 0.6],
[0.64, 0.32],
[0.77, 0.1],
[RAY_FADE_END, 0],
[1, 0],
];
}
/**
* 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). */
+253
View File
@@ -0,0 +1,253 @@
// «Выровнять всё по сетке» (docs/CANVAS.md §9) — the batch that the general
// settings offer as an explicit action, never as a silent migration.
import test from 'node:test';
import assert from 'node:assert/strict';
import { alignAllToGrid, snapN } from '../test-build/align-grid.js';
import { GRID_N, GRID_STEP_N } from '../test-build/space-geometry.js';
const S = GRID_STEP_N; // 1/240 of the plan width
const onGrid = (v) => Math.abs(v * GRID_N - Math.round(v * GRID_N)) < 1e-9;
/** A plan deliberately knocked off the grid in every direction. */
const detuned = () => ({
spaces: [{
id: 'f1', view_box: [0, 0, 1, 1],
rooms: [
// a polygon a third of a step off on every vertex
{ id: 'r1', name: 'Living', area: 'a1',
poly: [[0.2 + S / 3, 0.2], [0.5, 0.2 - S / 3], [0.5 + S / 3, 0.5], [0.2, 0.5]] },
// a rect whose far corner is off even though its origin is not
{ id: 'r2', name: 'Kitchen', area: 'a2', x: 0.5, y: 0.2, w: 0.3 + S / 3, h: 0.3 },
],
openings: [
// just off the r1 top wall, and not at a whole number of steps along it
{ id: 'o1', type: 'window', x: 0.3 + S / 3, y: 0.2 + S / 5, angle: 0, length: 0.06 },
],
decor: [
{ id: 'd1', kind: 'line', x1: 0.1 + S / 3, y1: 0.7, x2: 0.9, y2: 0.7 - S / 4 },
{ id: 'd2', kind: 'rect', x: 0.1 + S / 4, y: 0.8, w: 0.2 + S / 3, h: 0.05 },
{ id: 'd3', kind: 'text', x: 0.4 + S / 3, y: 0.9, text: 'hi' },
],
}],
markers: [], settings: {},
});
const detunedLayout = () => ({
dev1: { s: 'f1', x: 0.3 + S / 3, y: 0.3 },
dev2: { s: 'f1', x: 0.35, y: 0.35, k: 1.5 }, // already on a node
rl_r1: { s: 'f1', x: 0.25 + S / 2.5, y: 0.25 }, // a room LABEL counts too
junk: { s: 'f1' }, // no coordinates: skipped
});
test('snapN is idempotent and leaves a node bit-identical', () => {
const node = 12 / GRID_N;
assert.equal(snapN(node), node); // untouched, not "re-rounded"
const off = node + S / 3;
assert.ok(Math.abs(snapN(off) - node) < 1e-12);
assert.equal(snapN(snapN(off)), snapN(off));
});
test('alignAllToGrid puts every grid-bound element on a node', () => {
const { spaces, layout, report } = alignAllToGrid(detuned().spaces, detunedLayout());
const sp = spaces[0];
for (const p of sp.rooms[0].poly) { assert.ok(onGrid(p[0])); assert.ok(onGrid(p[1])); }
const r2 = sp.rooms[1];
assert.ok(onGrid(r2.x) && onGrid(r2.y));
assert.ok(onGrid(r2.x + r2.w) && onGrid(r2.y + r2.h)); // the FAR corner, not the size
const [l, rc, tx] = sp.decor;
assert.ok(onGrid(l.x1) && onGrid(l.y1) && onGrid(l.x2) && onGrid(l.y2));
assert.ok(onGrid(rc.x) && onGrid(rc.y) && onGrid(rc.x + rc.w) && onGrid(rc.y + rc.h));
assert.ok(onGrid(tx.x) && onGrid(tx.y));
assert.ok(onGrid(layout.dev1.x) && onGrid(layout.dev1.y));
assert.ok(onGrid(layout.rl_r1.x) && onGrid(layout.rl_r1.y));
assert.ok(report.moved > 0 && report.moved <= report.total);
});
test('an opening stays ON its wall — it is wall-bound, not node-bound', () => {
const { spaces } = alignAllToGrid(detuned().spaces, {});
const o = spaces[0].openings[0];
const wall = spaces[0].rooms[0].poly; // the aligned r1
// the top edge runs wall[0] → wall[1]; the opening must lie on that segment
const [ax, ay] = wall[0], [bx, by] = wall[1];
const t = ((o.x - ax) * (bx - ax) + (o.y - ay) * (by - ay)) / ((bx - ax) ** 2 + (by - ay) ** 2);
const px = ax + t * (bx - ax), py = ay + t * (by - ay);
assert.ok(Math.hypot(o.x - px, o.y - py) < 1e-9, 'opening is off its wall');
assert.ok(t > 0 && t < 1, 'opening slid off the end of its wall');
});
test('idempotent: a second run moves nothing and changes nothing', () => {
const first = alignAllToGrid(detuned().spaces, detunedLayout());
const second = alignAllToGrid(first.spaces, first.layout);
assert.equal(second.report.moved, 0);
assert.equal(second.changed, false);
assert.deepEqual(second.spaces, first.spaces);
assert.deepEqual(second.layout, first.layout);
});
test('an already-aligned plan reports nothing to do', () => {
const clean = {
spaces: [{ id: 'f1', rooms: [{ id: 'r1', poly: [[0.25, 0.25], [0.5, 0.25], [0.5, 0.5], [0.25, 0.5]] }] }],
};
const r = alignAllToGrid(clean.spaces, { d1: { s: 'f1', x: 0.25, y: 0.5 } });
assert.equal(r.report.moved, 0);
assert.equal(r.changed, false);
assert.ok(r.report.total >= 2); // it still LOOKED at everything
});
test('the report is honest: it counts what moved and the largest shift', () => {
const r = alignAllToGrid(detuned().spaces, detunedLayout());
// never more than half a step in each axis → at most half a step diagonally… ×√2
assert.ok(r.report.maxShift <= S * 0.71 + 1e-12, String(r.report.maxShift / S));
assert.ok(r.report.maxShift > 0);
// "junk" has no coordinates and is not counted; dev2 is on a node already
assert.equal(r.report.total, 2 /*rooms*/ + 3 /*decor*/ + 1 /*opening*/ + 3 /*layout*/);
assert.ok(r.report.moved < r.report.total);
});
test('nothing is invented: the input objects are not mutated', () => {
const cfg = detuned();
const before = JSON.stringify(cfg.spaces);
const lay = detunedLayout();
const layBefore = JSON.stringify(lay);
alignAllToGrid(cfg.spaces, lay);
assert.equal(JSON.stringify(cfg.spaces), before);
assert.equal(JSON.stringify(lay), layBefore);
});
test('a stray opening with no wall in reach is left exactly where it is', () => {
const cfg = detuned();
cfg.spaces[0].openings.push({ id: 'o2', type: 'door', x: 3.7, y: 4.2, angle: 0, length: 0.06 });
const { spaces } = alignAllToGrid(cfg.spaces, {});
const o2 = spaces[0].openings.find((o) => o.id === 'o2');
assert.equal(o2.x, 3.7);
assert.equal(o2.y, 4.2);
});
test('far-out coordinates align just as well as near ones (infinite canvas)', () => {
const cfg = { spaces: [{ id: 'f1', rooms: [
{ id: 'r1', poly: [[2.5 + S / 3, 2.2], [3.0, 2.2], [3.0, 2.6], [2.5, 2.6]] }] }] };
const { spaces, report } = alignAllToGrid(cfg.spaces, { d: { s: 'f1', x: 2.5 + S / 3, y: 2.2 } });
for (const p of spaces[0].rooms[0].poly) { assert.ok(onGrid(p[0])); assert.ok(onGrid(p[1])); }
assert.ok(report.moved >= 1);
});
// ---------------------------------------------------------------------------
// AUD-158B1-01 — the confirmation promises an exact maximum before an action
// with no undo, so the report must be an UPPER BOUND of what the run does.
// ---------------------------------------------------------------------------
/** What the run REALLY did, measured on the returned objects: the largest
* displacement of any corner / vertex / end, in the centimetres of the space
* it happened in. The report may never be smaller than this. */
const reallyMovedCm = (before, after, cell = {}) => {
const cm = (sp) => (Number(sp?.cell_cm) > 0 ? Number(sp.cell_cm) : 5) * GRID_N;
let max = 0;
const hit = (d, sp) => { if (d * cm(sp) > max) max = d * cm(sp); };
const box = (a, b, sp) => {
const dx = Math.max(Math.abs(b.x - a.x), Math.abs((b.x + b.w) - (a.x + (a.w || 0))));
const dy = Math.max(Math.abs(b.y - a.y), Math.abs((b.y + b.h) - (a.y + (a.h || 0))));
hit(Math.hypot(dx, dy), sp);
};
before.forEach((sp, i) => {
const sq = after[i];
(sp.rooms || []).forEach((r, j) => {
const q = sq.rooms[j];
if (r.poly?.length) r.poly.forEach((p, k) => hit(Math.hypot(q.poly[k][0] - p[0], q.poly[k][1] - p[1]), sp));
else if (r.x != null) box(r, q, sp);
});
(sp.decor || []).forEach((d, j) => {
const q = sq.decor[j];
if (d.kind === 'line') {
hit(Math.hypot(q.x1 - d.x1, q.y1 - d.y1), sp);
hit(Math.hypot(q.x2 - d.x2, q.y2 - d.y2), sp);
} else if (d.w != null) box(d, q, sp);
else hit(Math.hypot(q.x - d.x, q.y - d.y), sp);
});
(sp.openings || []).forEach((o, j) => {
const q = sq.openings[j];
const h = (Number(o.length) || 0) / 2, R = Math.PI / 180;
const e = (px, py, a, s) => [px + s * Math.cos(a * R) * h, py + s * Math.sin(a * R) * h];
const same = Math.max(
Math.hypot(...e(q.x, q.y, q.angle, 1).map((v, k) => v - e(o.x, o.y, o.angle, 1)[k])),
Math.hypot(...e(q.x, q.y, q.angle, -1).map((v, k) => v - e(o.x, o.y, o.angle, -1)[k])));
const flip = Math.max(
Math.hypot(...e(q.x, q.y, q.angle, -1).map((v, k) => v - e(o.x, o.y, o.angle, 1)[k])),
Math.hypot(...e(q.x, q.y, q.angle, 1).map((v, k) => v - e(o.x, o.y, o.angle, -1)[k])));
hit(Math.min(same, flip), sp);
});
});
return max;
};
test('AUD-158B1-01: the maximum is converted through the scale of ITS OWN space', () => {
const spaces = [
{ id: 'first', cell_cm: 5, rooms: [{ id: 'a', poly: [[0.1, 0.1], [0.2, 0.1], [0.2, 0.2], [0.1, 0.2]] }] },
{ id: 'second', cell_cm: 100, rooms: [{ id: 'b',
poly: [[0.2 + S / 2, 0.2], [0.3, 0.2], [0.3, 0.3], [0.2, 0.3]] }] },
];
const { report } = alignAllToGrid(spaces, {});
// half a step of a 100 cm cell is 50 cm — NOT the 2.5 cm of the first floor
assert.ok(Math.abs(report.maxShiftCm - 50) < 1e-9, String(report.maxShiftCm));
assert.equal(report.maxSpace, 'second');
});
test('AUD-158B1-01: a rect thinner than one step is measured AFTER it is widened', () => {
const spaces = [{ id: 'f1', cell_cm: 5,
rooms: [{ id: 'r', x: 0.5, y: 0.5, w: 0.001, h: 0.001 }] }];
const before = JSON.parse(JSON.stringify(spaces));
const res = alignAllToGrid(spaces, {});
const real = reallyMovedCm(before, res.spaces);
assert.ok(res.report.maxShiftCm + 1e-9 >= real,
`promised ${res.report.maxShiftCm} cm, moved ${real} cm`);
});
test('AUD-158B1-01: a rect whose X and Y errors land on opposite corners', () => {
// origin off in X only, far corner off in Y only → the mixed corner moves by
// both at once, and that diagonal is the honest maximum
const spaces = [{ id: 'f1', cell_cm: 5,
rooms: [{ id: 'r', x: 0.25 + S * 0.4, y: 0.25, w: 0.1 - S * 0.4, h: 0.1 + S * 0.4 }] }];
const before = JSON.parse(JSON.stringify(spaces));
const res = alignAllToGrid(spaces, {});
assert.ok(Math.abs(res.report.maxShift - S * 0.4 * Math.SQRT2) < 1e-12,
String(res.report.maxShift / S));
assert.ok(res.report.maxShiftCm + 1e-9 >= reallyMovedCm(before, res.spaces));
});
test('AUD-158B1-01: the promise is an upper bound for the whole detuned plan', () => {
const cfg = detuned();
const before = JSON.parse(JSON.stringify(cfg.spaces));
const res = alignAllToGrid(cfg.spaces, detunedLayout());
assert.ok(res.report.maxShiftCm + 1e-9 >= reallyMovedCm(before, res.spaces),
`promised ${res.report.maxShiftCm} cm`);
});
// ---------------------------------------------------------------------------
// AUD-158B1-02 — an opening whose only error is its angle
// ---------------------------------------------------------------------------
test('AUD-158B1-02: an angle-only correction is a change and can be applied', () => {
const spaces = [{ id: 'f1', cell_cm: 5,
rooms: [{ id: 'r1', poly: [[0.2, 0.2], [0.5, 0.2], [0.5, 0.5], [0.2, 0.5]] }],
openings: [{ id: 'o', type: 'window', x: 0.35, y: 0.2, angle: 90, length: 0.1 }] }];
const res = alignAllToGrid(spaces, {});
const o = res.spaces[0].openings[0];
assert.equal(o.x, 0.35); // the centre was already right…
assert.equal(o.y, 0.2);
assert.equal(o.angle, 0); // …only the angle was wrong
assert.equal(res.changed, true, 'output differs but changed:false');
assert.equal(res.report.moved, 1);
assert.equal(res.report.rotated, 1);
assert.ok(res.report.maxShiftCm > 0); // its ENDS really do move
});
test('AUD-158B1-02: turning an opening end over end is not a move', () => {
// 180 apart is the same segment on the same wall: it counts as a correction
// (the field is rewritten) but it displaces nothing
const spaces = [{ id: 'f1', cell_cm: 5,
rooms: [{ id: 'r1', poly: [[0.2, 0.2], [0.5, 0.2], [0.5, 0.5], [0.2, 0.5]] }],
openings: [{ id: 'o', type: 'window', x: 0.35, y: 0.2, angle: 180, length: 0.1 }] }];
const res = alignAllToGrid(spaces, {});
assert.equal(res.spaces[0].openings[0].angle, 0);
assert.equal(res.changed, true);
assert.equal(res.report.rotated, 1);
assert.equal(res.report.maxShiftCm, 0);
});
+95
View File
@@ -0,0 +1,95 @@
/**
* The backdrop transform and the paper contract (docs/BACKDROP.md).
*
* Two questions only, and both are pure geometry:
* 1. where the picture ends up once it has been moved and scaled, and
* 2. that the frame ("Вписать всё") counts THAT rectangle, not the old one.
* The third half of the feature — the paper is the rooms and only the rooms —
* lives in the renderers, so it is asserted in the DOM by demo/smoke_backdrop.mjs.
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import {
NORM_W, planRect, fitInSquare, spaceModels, contentItems, contentBounds,
PLAN_SCALE_MIN, PLAN_SCALE_MAX, CANVAS_LIMIT,
} from '../test-build/space-geometry.js';
import { paperRoomShapes } from '../test-build/logic.js';
const wide = { id: 's', title: 's', plan_url: '/p.svg', plan_aspect: 2, view_box: [0, 0, 1, 1], rooms: [] };
const cfgOf = (space) => ({ spaces: [space], markers: [], settings: {} });
test('planRect: no fields at all is EXACTLY the centred default (no migration)', () => {
assert.deepEqual(planRect(wide), fitInSquare(2, NORM_W));
assert.deepEqual(planRect(wide), { x: 0, y: 250, w: 1000, h: 500 });
// an explicitly neutral transform is the same rectangle, to the bit
assert.deepEqual(planRect({ ...wide, plan_x: 0, plan_y: 0, plan_scale: 1 }),
{ x: 0, y: 250, w: 1000, h: 500 });
// …and so is every kind of junk a legacy store could hold. A non-number is
// no transform at all; for the SCALE zero and negatives are junk too (it is
// a multiplier), while for the OFFSET they are perfectly good coordinates.
for (const bad of [null, undefined, 'x', NaN, Infinity])
assert.deepEqual(planRect({ ...wide, plan_scale: bad, plan_x: bad, plan_y: bad }),
{ x: 0, y: 250, w: 1000, h: 500 }, `plan_* = ${String(bad)}`);
for (const bad of [0, -3])
assert.deepEqual(planRect({ ...wide, plan_scale: bad }),
{ x: 0, y: 250, w: 1000, h: 500 }, `plan_scale = ${String(bad)}`);
});
test('planRect: the offset is normalised, the scale is uniform about the top-left', () => {
// 0.25 of the canvas right, 0.1 down
assert.deepEqual(planRect({ ...wide, plan_x: 0.25, plan_y: 0.1 }),
{ x: 250, y: 350, w: 1000, h: 500 });
// half size: BOTH sides halve, the top-left corner stays put — no rotation,
// no stretch, one number
assert.deepEqual(planRect({ ...wide, plan_scale: 0.5 }),
{ x: 0, y: 250, w: 500, h: 250 });
// offset and scale compose: the offset moves the (already scaled) corner
assert.deepEqual(planRect({ ...wide, plan_x: -0.5, plan_y: 0.2, plan_scale: 2 }),
{ x: -500, y: 450, w: 2000, h: 1000 });
// the aspect ratio is preserved by construction, whatever the scale
const r = planRect({ ...wide, plan_scale: 3.7 });
assert.ok(Math.abs(r.w / r.h - 2) < 1e-12);
});
test('planRect: the stored numbers are clamped, never trusted', () => {
const far = planRect({ ...wide, plan_x: 1e9, plan_y: -1e9 });
assert.equal(far.x, 0 + CANVAS_LIMIT * NORM_W);
assert.equal(far.y, 250 - CANVAS_LIMIT * NORM_W);
assert.equal(planRect({ ...wide, plan_scale: 1e9 }).w, 1000 * PLAN_SCALE_MAX);
assert.equal(planRect({ ...wide, plan_scale: 1e-9 }).w, 1000 * PLAN_SCALE_MIN);
});
test('spaceModels: the model carries the TRANSFORMED image rectangle', () => {
const [m] = spaceModels(cfgOf({ ...wide, plan_x: 0.5, plan_y: 0.5, plan_scale: 0.5 }));
assert.deepEqual(m.bg, { href: '/p.svg', x: 500, y: 750, w: 500, h: 250 });
// and an untouched space is bit-identical to what it rendered before
const [old] = spaceModels(cfgOf(wide));
assert.deepEqual(old.bg, { href: '/p.svg', x: 0, y: 250, w: 1000, h: 500 });
});
test('the content frame follows the picture (Вписать всё never loses it)', () => {
const moved = spaceModels(cfgOf({
...wide, plan_x: 2, plan_y: 2, plan_scale: 0.5,
rooms: [{ id: 'r', name: 'r', poly: [[0.4, 0.4], [0.5, 0.4], [0.5, 0.5], [0.4, 0.5]] }],
}))[0];
// the picture is still ONE content item, exactly like a room
const items = contentItems(moved);
assert.equal(items.length, 2);
const img = items[1];
assert.deepEqual(img, { minX: 2000, minY: 2250, maxX: 2500, maxY: 2500 });
const b = contentBounds(moved);
assert.ok(b.x <= 400 && b.x + b.w >= 2500, 'the frame reaches the moved picture');
assert.ok(b.y <= 400 && b.y + b.h >= 2500);
// …and shrinking the picture shrinks the frame with it
const small = spaceModels(cfgOf({ ...wide, plan_scale: 0.25 }))[0];
const sb = contentBounds(small);
assert.ok(sb.w < 400, `a quarter-scale picture frames tight, got ${sb.w}`);
});
test('the paper is the ROOMS, image or no image (docs/BACKDROP.md §3)', () => {
// paperRoomShapes knows nothing about plan_url — which is the whole point:
// there is no longer a branch where the picture makes paper of its own.
const rooms = [{ id: 'r', name: 'r', poly: [[100, 100], [500, 100], [500, 500], [100, 500]] }];
assert.equal(paperRoomShapes(rooms).length, 1);
assert.equal(paperRoomShapes([]).length, 0, 'an empty space has no paper at all');
});
+266
View File
@@ -0,0 +1,266 @@
// Infinite canvas — pure geometry (docs/CANVAS.md).
import test from 'node:test';
import assert from 'node:assert/strict';
import {
NORM_W, CANVAS_LIMIT, SANE_LIMIT, MIN_ZOOM, OUTLIER_K, MIN_VOTERS,
spaceModels, contentItems, contentFrame, contentBounds, spaceFrame, spaceCenter,
iconUnit, iconCqw, gridLevels, itemOf, roomItem, defaultPositions,
} from '../test-build/space-geometry.js';
const model = (space) => spaceModels({ spaces: [{ view_box: [0, 0, 1, 1], rooms: [], ...space }], markers: [] })[0];
const pt = (x, y) => ({ minX: x, minY: y, maxX: x, maxY: y });
const box = (x0, y0, x1, y1) => ({ minX: x0, minY: y0, maxX: x1, maxY: y1 });
const r = (o) => [o.x, o.y, o.w, o.h].map((n) => Math.round(n));
// ---------------------------------------------------------------- constants
test('canvas limits mirror the backend and the spec', () => {
assert.equal(CANVAS_LIMIT, 5000);
assert.equal(SANE_LIMIT, 5000 * NORM_W);
assert.ok(Math.abs(MIN_ZOOM - 1 / 3) < 1e-12, 'zoom out stops at 3x the content');
assert.equal(OUTLIER_K, 10);
});
// ------------------------------------------------------- the ordinary plan
test('typical small plan: the frame is exactly what is drawn (unchanged behaviour)', () => {
const m = model({ id: 's', rooms: [{ id: 'r', poly: [[0.4, 0.4], [0.6, 0.4], [0.6, 0.6], [0.4, 0.6]] }] });
assert.deepEqual(contentBounds(m), { x: 390, y: 390, w: 220, h: 220 });
// the stored view_box has no say once there IS content
const withVb = model({ id: 's', view_box: [0.2, 0.2, 0.1, 0.1],
rooms: [{ id: 'r', poly: [[0.4, 0.4], [0.6, 0.4], [0.6, 0.6], [0.4, 0.6]] }] });
assert.deepEqual(contentBounds(withVb), { x: 390, y: 390, w: 220, h: 220 });
});
// --------------------------------------------- (b) the plan PAST the square
test('a plan drawn far outside the old unit square is framed whole', () => {
// rooms at normalised 1.5 .. 3.0 — the case that used to break: the old
// -25%..125% envelope threw every one of these points away and the frame
// collapsed onto whatever happened to be near the origin.
const m = model({ id: 's', rooms: [
{ id: 'a', poly: [[1.5, 1.5], [2.0, 1.5], [2.0, 2.0], [1.5, 2.0]] },
{ id: 'b', poly: [[2.0, 1.5], [3.0, 1.5], [3.0, 2.4], [2.0, 2.4]] },
] });
const b = contentBounds(m);
assert.deepEqual(r(b), [1425, 1425, 1650, 1050]);
assert.ok(b.x < 1500 && b.x + b.w > 3000, 'both far rooms are inside the frame');
// and a device placed even further out still counts as content
const withDev = contentBounds(m, 0.05, [[3500, 2000]]);
assert.ok(withDev.x + withDev.w > 3500);
});
test('a plan past the square keeps its icon spacing in proportion', () => {
const small = model({ id: 's', rooms: [{ id: 'r', area: 'a', poly: [[0.1, 0.1], [0.5, 0.1], [0.5, 0.5], [0.1, 0.5]] }] });
const big = model({ id: 's', rooms: [{ id: 'r', area: 'a', poly: [[1.0, 1.0], [3.0, 1.0], [3.0, 3.0], [1.0, 3.0]] }] });
assert.equal(iconUnit(small), NORM_W, 'anything inside the old square is unchanged');
assert.equal(iconUnit(big), 2000, 'a 2-canvas-wide plan scales the spacing with it');
// the auto grid still lands inside the room in both cases
const devs = [0, 1, 2, 3].map((i) => ({ id: 'd' + i, space: 's', area: 'a', entities: [] }));
for (const [mm, lo, hi] of [[small, 100, 500], [big, 1000, 3000]]) {
const pos = defaultPositions(devs, mm, 2.5);
for (const k of Object.keys(pos)) {
assert.ok(pos[k].x >= lo && pos[k].x <= hi && pos[k].y >= lo && pos[k].y <= hi, 'inside the room');
}
}
});
// ------------------------------------------------------------- icon size
test('icon size: a percentage of the PLAN, exactly as before the canvas', () => {
// The pre-infinite-canvas card rendered `--icon-size: iconPct * vb.w / view.w`
// and the editor only ever wrote view_box [0,0,1,1], i.e. vb.w === NORM_W.
// iconCqw must reproduce that number to the last digit on such a plan.
const small = model({ id: 's', rooms: [{ id: 'r', poly: [[0.04, 0.14], [0.96, 0.14], [0.96, 0.86], [0.04, 0.86]] }] });
const legacy = (pct, viewW) => (pct * NORM_W) / viewW;
for (const viewW of [1000, 1100, 1093.969144460028, 550, 275, 137.5]) {
assert.equal(iconCqw(3.4, small, viewW), legacy(3.4, viewW));
}
// and that number IS "scales with the plan": half the view, twice the icon
assert.equal(iconCqw(2.5, small, 500) / iconCqw(2.5, small, 1000), 2);
assert.equal(iconCqw(2.5, small, 8000) / iconCqw(2.5, small, 1000), 0.125);
// the kiosk icon multiplier is a plain factor, as it was
assert.equal(iconCqw(2.5, small, 1000, 2), 2 * iconCqw(2.5, small, 1000));
});
test('icon size: a plan drawn past the old square keeps its markers', () => {
// rooms at 1.0..3.0 — 2 canvases wide, framed by ~2.2 canvases once the
// frame is the content. With the old fixed NORM_W numerator the marker
// would be 2.2x smaller than on an ordinary plan, and 55x smaller on a
// plan 50 canvases wide: the reason the numerator is iconUnit.
const big = model({ id: 's', rooms: [{ id: 'r', poly: [[1, 1], [3, 1], [3, 3], [1, 3]] }] });
const small = model({ id: 's', rooms: [{ id: 'r', poly: [[0, 0], [1, 0], [1, 1], [0, 1]] }] });
const frame = (m) => { const f = spaceFrame(m); return f.w; }; // content + 5%
assert.equal(frame(small), NORM_W * 1.1);
assert.equal(frame(big), 2 * NORM_W * 1.1);
// same percentage of the frame on both plans — the marker did not degenerate
assert.equal(iconCqw(2.5, big, frame(big)), iconCqw(2.5, small, frame(small)));
assert.ok(iconCqw(2.5, big, frame(big)) > 2, 'and it is a real size, not a dot');
// the degenerate alternative, for the record
assert.ok((2.5 * NORM_W) / frame(big) < iconCqw(2.5, big, frame(big)) / 1.9);
// the size unit and the auto-placement spacing are the same unit
assert.equal(iconUnit(big), 2 * NORM_W);
});
test('icon size: no view yet is a plain percentage, never NaN', () => {
const m = model({ id: 's', rooms: [{ id: 'r', poly: [[0.1, 0.1], [0.5, 0.5]] }] });
for (const bad of [0, -1, NaN, Infinity, null, undefined]) {
assert.equal(iconCqw(2.5, m, bad), 2.5);
}
assert.equal(iconCqw(2.5, m, 0, 1.5), 3.75);
});
// DEV-2C947-03: what is out of the frame is out of the icon unit
test('a room-outlier is excluded from the frame AND from the icon unit', () => {
// three rooms in the core, a fourth one dragged 90 canvases out — MIN_VOTERS
// is exactly met, so the vote runs and the frame rejects the stray.
const rooms = [
{ id: 'a', poly: [[0.10, 0.10], [0.30, 0.10], [0.30, 0.30], [0.10, 0.30]] },
{ id: 'b', poly: [[0.30, 0.10], [0.55, 0.10], [0.55, 0.35], [0.30, 0.35]] },
{ id: 'c', poly: [[0.10, 0.30], [0.35, 0.30], [0.35, 0.55], [0.10, 0.55]] },
{ id: 'far', poly: [[90, 90], [91, 90], [91, 91], [90, 91]] },
];
const m = model({ id: 's', rooms });
const f = contentFrame(contentItems(m));
assert.equal(f.outliers, 1, 'the far room is rejected from the frame');
assert.ok(f.core.w < 1000, 'the opening view is the house');
// ...and the icon unit agrees: it used to be boxOf(ALL rooms) = 91000 units,
// which made every marker of the ordinary plan ~91x too big while the frame
// stayed correct (audit dev@2c947f4).
assert.equal(iconUnit(m), NORM_W, 'the stray does not stretch the icon unit');
const houseOnly = model({ id: 's', rooms: rooms.slice(0, 3) });
assert.equal(iconUnit(m), iconUnit(houseOnly), 'same unit as the plan without it');
// one shared notion of "the plan": the unit follows the frame's core
assert.ok(iconCqw(2.5, m, 1000) < 3, 'the marker is a marker, not a wall');
assert.equal(iconCqw(2.5, m, 1000), iconCqw(2.5, houseOnly, 1000));
// a genuinely wide plan still scales — no vote, no rejection
const wide = model({ id: 's', rooms: [
{ id: 'a', poly: [[0, 0], [1, 0], [1, 1], [0, 1]] },
{ id: 'b', poly: [[1, 0], [2, 0], [2, 1], [1, 1]] },
{ id: 'c', poly: [[0, 1], [1, 1], [1, 2], [0, 2]] },
{ id: 'd', poly: [[1, 1], [2, 1], [2, 2], [1, 2]] },
] });
assert.equal(iconUnit(wide), 2 * NORM_W);
});
// ------------------------------------------------------------- the outlier
test('an outlier does not command the frame, but "show all" reaches it', () => {
const items = [
box(400, 400, 500, 500), box(500, 400, 620, 520), box(400, 500, 520, 640),
box(520, 520, 640, 660), pt(450, 450), pt(600, 600),
pt(90000, 90000), // one marker an order of magnitude away
];
const f = contentFrame(items);
assert.equal(f.outliers, 1);
assert.ok(f.core.x + f.core.w < 1000, 'the stray is outside the opening view');
assert.ok(f.all.x + f.all.w > 90000, 'but the fit-everything box holds it');
assert.ok(f.all.w > f.core.w * 50);
});
test('an outlier vote needs a majority to be far FROM', () => {
// three objects: no vote at all (MIN_VOTERS), so the far one still counts
const few = contentFrame([box(400, 400, 500, 500), box(500, 500, 600, 600), pt(90000, 90000)]);
assert.equal(few.outliers, 0);
assert.deepEqual(few.core, few.all);
assert.ok(MIN_VOTERS === 4);
// a genuinely spread-out plan (detached buildings) is not a pile of strays:
// no object is an order of magnitude further than the 75th percentile
const spread = contentFrame([pt(0, 0), pt(3000, 0), pt(0, 3000), pt(3000, 3000), pt(1500, 1500)]);
assert.equal(spread.outliers, 0);
assert.deepEqual(spread.core, spread.all);
// majority veto: half the objects "far" means the plan is wide, not stray
const half = contentFrame([pt(0, 0), pt(10, 0), pt(0, 10), pt(90000, 0), pt(90000, 10), pt(90010, 0)]);
assert.equal(half.outliers, 0);
});
test('a tight cluster does not call its own neighbour an outlier (MIN_SPREAD)', () => {
// five markers within one room; the "furthest" is 60 units away — with a
// percentile-only scale that would be 10x the p75 and get thrown out.
const f = contentFrame([pt(500, 500), pt(502, 501), pt(499, 503), pt(501, 498), pt(560, 500)]);
assert.equal(f.outliers, 0);
assert.ok(f.core.x + f.core.w > 560);
});
test('corruption is dropped outright, not shown by "show all"', () => {
const f = contentFrame([box(400, 400, 500, 500), box(500, 500, 600, 600),
box(400, 500, 500, 600), box(500, 400, 600, 500), pt(1e100, 1e100)]);
assert.equal(f.outliers, 0, 'not an outlier — not content at all');
assert.ok(f.all.w < 1000, 'the fit-everything box does not chase 1e100');
// exactly at the sane limit it is still content
const edge = contentFrame([pt(0, 0), pt(1, 1), pt(2, 2), pt(SANE_LIMIT, SANE_LIMIT)]);
assert.ok(edge.all.x + edge.all.w >= SANE_LIMIT);
assert.equal(contentFrame([pt(NaN, 0), pt(0, Infinity)]).core, null);
});
// ---------------------------------------------------- degenerate and empty
test('empty space: no frame, the caller falls back to the view_box hint', () => {
const empty = model({ id: 's', view_box: [0.1, 0.2, 0.5, 0.4] });
assert.equal(contentBounds(empty), null);
assert.deepEqual(spaceFrame(empty), { x: 100, y: 200, w: 500, h: 400 });
// a broken stored hint falls back to the legacy square
const broken = model({ id: 's', view_box: [0, 0, 0, 0] });
assert.deepEqual(spaceFrame(broken), { x: 0, y: 0, w: 1000, h: 1000 });
});
test('one room, one marker: the frame never has a zero axis', () => {
const one = model({ id: 's', rooms: [{ id: 'r', poly: [[0.4, 0.4], [0.6, 0.4], [0.6, 0.6], [0.4, 0.6]] }] });
const b = contentBounds(one);
assert.ok(b.w > 0 && b.h > 0);
const lone = contentBounds(model({ id: 's' }), 0.05, [[2500, 2500]]);
assert.ok(lone.w >= 200 && lone.h >= 200, 'a lone marker far out still frames some canvas');
assert.ok(Math.abs(lone.x + lone.w / 2 - 2500) < 1, 'centred on it');
// a real thin corridor keeps its tight frame (only DEGENERATE is inflated)
const corridor = contentBounds(model({ id: 's', rooms: [{ id: 'r', x: 0.1, y: 0.4, w: 0.6, h: 0.1 }] }));
assert.equal(Math.round(corridor.h), 160);
});
// ------------------------------------------------------------ image plans
test('with a backdrop the image still sets the extent', () => {
const m = model({ id: 's', plan_url: '/p.svg', plan_aspect: 2,
rooms: [{ id: 'r', poly: [[0.4, 0.4], [0.5, 0.4], [0.5, 0.5], [0.4, 0.5]] }] });
const items = contentItems(m);
assert.equal(items.length, 2);
const b = contentBounds(m);
assert.ok(b.x <= 0 && b.x + b.w >= 1000, 'the whole image width is framed');
assert.ok(b.y <= 250 && b.y + b.h >= 750, 'the whole image height is framed');
// and content drawn OUTSIDE the image widens the frame further
const past = contentBounds(m, 0.05, [[2000, 500]]);
assert.ok(past.x + past.w > 2000);
});
test('spaceCenter is the middle of the content', () => {
const m = model({ id: 's', rooms: [{ id: 'r', poly: [[2.0, 2.0], [3.0, 2.0], [3.0, 3.0], [2.0, 3.0]] }] });
const c = spaceCenter(m);
assert.ok(Math.abs(c.x - 2500) < 1 && Math.abs(c.y - 2500) < 1);
});
// ------------------------------------------------------------ helper units
test('itemOf / roomItem', () => {
assert.deepEqual(itemOf([[1, 2], [5, 0]]), { minX: 1, minY: 0, maxX: 5, maxY: 2 });
assert.equal(itemOf([]), null);
assert.deepEqual(roomItem({ x: 10, y: 20, w: 30, h: 40 }), { minX: 10, minY: 20, maxX: 40, maxY: 60 });
assert.deepEqual(roomItem({ poly: [[0, 0], [4, 9]] }), { minX: 0, minY: 0, maxX: 4, maxY: 9 });
assert.equal(roomItem({ name: 'no geometry' }), null);
});
// ------------------------------------------------------------ adaptive grid
test('gridLevels: fine dots vanish before they merge, every 5th stays', () => {
const pitch = NORM_W / 240; // ~4.167 render units, the drawing grid
// zoomed in / normal: 1 px per unit — the base grid is legible
assert.deepEqual(gridLevels(pitch, 2), { fine: 1, coarse: 5 });
// zoomed out 4x: the base step is ~1 px, so every 2nd survives, accent 10th
assert.deepEqual(gridLevels(pitch, 0.9), { fine: 2, coarse: 10 });
// far out: only every 20th, accent every 100th
assert.deepEqual(gridLevels(pitch, 0.09), { fine: 20, coarse: 100 });
// monotone: zooming out never makes the grid finer
let prev = 0;
for (const s of [4, 2, 1, 0.5, 0.2, 0.1, 0.05, 0.02, 0.01]) {
const g = gridLevels(pitch, s);
if (!g) break;
assert.ok(g.fine >= prev, 'never finer as we zoom out');
assert.ok(g.coarse >= g.fine * 5, 'the accent step is at least every 5th');
assert.ok(pitch * g.fine * s >= 7, 'and it is still legible');
prev = g.fine;
}
// absurdly far out: no grid at all rather than a grey fog
assert.equal(gridLevels(pitch, 1e-6), null);
assert.equal(gridLevels(0, 1), null);
assert.equal(gridLevels(pitch, 0), null);
assert.equal(gridLevels(pitch, NaN), null);
});
+106 -2
View File
@@ -15,7 +15,7 @@ import {
contentUrl, chunk, referencedContentUrls, MAX_SIGN_PATHS,
interiorPoint,
segmentCm, formatLength, roomEdges, roomPoly, paperRoomShapes, pointOnBoundary, pointStrictlyInside, roomsOverlap,
mergeRooms, splitRoom, polygonArea, closestPointOnBoundary, isActiveState, snapToWall, openingAmount, openingShoulders, fillColorsOf, lerpColor, roomFillStyle, stateIcon, lightColorOf, isAlarmState, parseRoomRef, diffNewDevices, poleOfInaccessibility, runServiceFor, TOGGLE_SAFE_DOMAINS } from '../test-build/logic.js';
mergeRooms, splitRoom, polygonArea, closestPointOnBoundary, isActiveState, snapToWall, openingAmount, openingShoulders, fillColorsOf, lerpColor, roomFillStyle, stateIcon, lightColorOf, isAlarmState, parseRoomRef, diffNewDevices, poleOfInaccessibility, runServiceFor, TOGGLE_SAFE_DOMAINS, coverService, coverMoving, coverEntityOf } from '../test-build/logic.js';
import {
iconFor, compileIconRules, isValidPattern, iconFromDeviceClasses,
} from '../test-build/rules.js';
@@ -245,7 +245,7 @@ test('spaceDisplayOf: defaults differ for spaces with and without a plan', () =>
assert.equal(s.opacity, 1);
assert.equal(s.fill, 'lqi');
const g = spaceDisplayOf({ settings: { room_color: 'javascript:alert(1)', fill_mode: 'weird' } });
assert.equal(g.color, '#3ea6ff');
assert.equal(g.color, '#55606c'); // dark-grey default since 2026-08-03
assert.equal(g.fill, 'none');
});
@@ -531,6 +531,110 @@ test('stateIcon: doors/locks/bulbs reflect state; custom icons and outages never
assert.equal(stateIcon('mdi:cctv', 'camera', undefined, 'recording', false), 'mdi:cctv'); // unknown pair
});
test('stateIcon: covers morph by device_class (owner 2026-08-03)', () => {
assert.equal(stateIcon('mdi:blinds', 'cover', 'blind', 'closed', false), 'mdi:blinds');
assert.equal(stateIcon('mdi:blinds', 'cover', 'blind', 'open', false), 'mdi:blinds-open');
assert.equal(stateIcon('mdi:window-shutter', 'cover', 'shutter', 'open', false), 'mdi:window-shutter-open');
assert.equal(stateIcon('mdi:window-shutter', 'cover', 'shutter', 'closed', false), 'mdi:window-shutter');
assert.equal(stateIcon('mdi:curtains', 'cover', 'curtain', 'closed', false), 'mdi:curtains-closed');
assert.equal(stateIcon('mdi:curtains', 'cover', 'curtain', 'open', false), 'mdi:curtains');
// travelling covers show where they are HEADED: closing keeps the open art
assert.equal(stateIcon('mdi:blinds', 'cover', 'blind', 'opening', false), 'mdi:blinds-open');
assert.equal(stateIcon('mdi:blinds', 'cover', 'blind', 'closing', false), 'mdi:blinds-open');
// no state at all — never morph, never guess
assert.equal(stateIcon('mdi:blinds', 'cover', 'blind', 'unknown', false), 'mdi:blinds');
assert.equal(stateIcon('mdi:blinds', 'cover', 'blind', 'unavailable', false), 'mdi:blinds');
assert.equal(stateIcon('mdi:custom', 'cover', 'blind', 'open', true), 'mdi:custom'); // user icon wins
// no device_class: only a known base icon morphs, an unrelated one stays
assert.equal(stateIcon('mdi:blinds', 'cover', undefined, 'open', false), 'mdi:blinds-open');
assert.equal(stateIcon('mdi:window-shutter', 'cover', null, 'closed', false), 'mdi:window-shutter');
assert.equal(stateIcon('mdi:sofa', 'cover', undefined, 'open', false), 'mdi:sofa');
});
test('stateIcon: a cover morphs for EVERY class, both ways (owner 2026-08-04)', () => {
// With the «открыто» plate gone from covers, the morph is the only signal —
// so no class may map both states to the same glyph, and the icons the card
// itself hands out (rules.ts) have to morph even without a device_class.
const CLASSES = ['blind', 'shade', 'shutter', 'curtain', 'window', 'awning',
'door', 'garage', 'gate', 'damper'];
for (const dc of CLASSES) {
const closed = stateIcon('mdi:chip', 'cover', dc, 'closed', false);
const open = stateIcon('mdi:chip', 'cover', dc, 'open', false);
assert.notEqual(closed, open, `${dc}: closed and open share one icon`);
// ajar and travelling all read as OPEN (HA reports a positioned cover 'open')
assert.equal(stateIcon('mdi:chip', 'cover', dc, 'opening', false), open);
assert.equal(stateIcon('mdi:chip', 'cover', dc, 'closing', false), open);
}
assert.equal(stateIcon('mdi:chip', 'cover', 'awning', 'closed', false), 'mdi:awning-outline');
assert.equal(stateIcon('mdi:chip', 'cover', 'awning', 'open', false), 'mdi:awning');
// no device_class: the auto icons of rules.ts morph within their own family
assert.equal(stateIcon('mdi:roller-shade', 'cover', undefined, 'closed', false), 'mdi:roller-shade-closed');
assert.equal(stateIcon('mdi:roller-shade-closed', 'cover', undefined, 'open', false), 'mdi:roller-shade');
assert.equal(stateIcon('mdi:garage-variant', 'cover', undefined, 'open', false), 'mdi:garage-open-variant');
assert.equal(stateIcon('mdi:blinds-horizontal', 'cover', null, 'closed', false), 'mdi:blinds-horizontal-closed');
// a HAND-PICKED icon morphs only inside the pair it was picked from — never
// traded for another family, never touched outside the cover domain
assert.equal(stateIcon('mdi:curtains', 'cover', 'blind', 'closed', true), 'mdi:curtains-closed');
assert.equal(stateIcon('mdi:curtains-closed', 'cover', undefined, 'open', true), 'mdi:curtains');
assert.equal(stateIcon('mdi:sofa', 'cover', 'blind', 'open', true), 'mdi:sofa');
assert.equal(stateIcon('mdi:door-closed', 'binary_sensor', 'door', 'on', true), 'mdi:door-closed');
assert.equal(stateIcon('mdi:lock', 'lock', undefined, 'unlocked', true), 'mdi:lock');
});
test('coverService: closed→open, open→close, travelling→stop (owner 2026-08-03)', () => {
assert.equal(coverService('closed'), 'open_cover');
assert.equal(coverService('open'), 'close_cover'); // incl. ajar: HA reports 'open'
assert.equal(coverService('opening'), 'stop_cover'); // a tap during travel STOPS
assert.equal(coverService('closing'), 'stop_cover');
assert.equal(coverService('unknown'), 'toggle'); // no readable state → plain toggle
assert.equal(coverService('unavailable'), 'toggle');
assert.equal(coverService(null), 'toggle');
assert.equal(coverService(undefined), 'toggle');
});
test('coverEntityOf: the cover among ALL entities, primary or not (owner 2026-08-04)', () => {
// the owner's Aqara E1 curtain driver, entity for entity: the cover is
// hidden by the integration and sits BEHIND the service switch, so
// primaryEntity picks the switch — the tap has to look wider than that
const aqara = [
'cover.office_curtain', 'sensor.office_curtain_battery',
'switch.office_curtain_reverse', 'sensor.office_curtain_motor_state',
];
assert.equal(coverEntityOf(aqara), 'cover.office_curtain');
assert.equal(coverEntityOf(['switch.a', 'cover.b']), 'cover.b'); // order does not matter
assert.equal(coverEntityOf(['cover.first', 'cover.second']), 'cover.first');
assert.equal(coverEntityOf(['light.only']), null);
assert.equal(coverEntityOf([]), null);
assert.equal(coverEntityOf(null), null);
assert.equal(coverEntityOf(undefined), null);
// a `cover_something` sensor is not a cover
assert.equal(coverEntityOf(['sensor.cover_position']), null);
});
test('coverMoving: only opening/closing breathe', () => {
assert.equal(coverMoving('opening'), true);
assert.equal(coverMoving('closing'), true);
assert.equal(coverMoving('open'), false);
assert.equal(coverMoving('closed'), false);
assert.equal(coverMoving('unknown'), false);
assert.equal(coverMoving(null), false);
});
test("tap action 'cover': explicit-only, cover-only, never a guarded class", () => {
assert.equal(resolveTapAction('cover', null, 'cover', 'curtain'), 'cover');
assert.equal(resolveTapAction('cover', null, 'cover', 'blind'), 'cover');
assert.equal(resolveTapAction('cover', null, 'cover', null), 'cover'); // no class = a plain cover
// the guarded classes degrade to the info card even if the value was saved
assert.equal(resolveTapAction('cover', null, 'cover', 'garage'), 'info');
assert.equal(resolveTapAction('cover', null, 'cover', 'door'), 'info');
assert.equal(resolveTapAction('cover', null, 'cover', 'gate'), 'info');
// and it is meaningless anywhere but the cover domain
assert.equal(resolveTapAction('cover', null, 'light'), 'info');
assert.equal(resolveTapAction('cover', null, 'lock'), 'info');
// never a card-wide default (like 'run'): it needs a conscious per-marker choice
assert.equal(resolveTapAction(null, 'cover', 'cover', 'curtain'), 'info');
});
test('lightColorOf: rgb of an on light; off/unavailable/no-color → null', () => {
assert.equal(lightColorOf({ state: 'on', attributes: { rgb_color: [255, 20, 40] } }), 'rgb(255, 20, 40)');
assert.equal(lightColorOf({ state: 'off', attributes: { rgb_color: [255, 20, 40] } }), null);
+4 -2
View File
@@ -58,8 +58,10 @@ test('markerPos: saved layout → default grid → space centre', () => {
assert.deepEqual(markerPos(dev, {}, cfg, defPos, model), defPos.d1);
const b = roomBounds(model.rooms[0]);
assert.ok(defPos.d1.x >= b.x && defPos.d1.x <= b.x + b.w && defPos.d1.y >= b.y && defPos.d1.y <= b.y + b.h);
// no layout, no defPos → space centre
assert.deepEqual(markerPos(dev, {}, cfg, {}, model), { x: 500, y: 500 });
// no layout, no defPos → the middle of the CONTENT, not of a canvas that no
// longer has edges (docs/CANVAS.md): f1 is a 2:1 image (y 250..750) plus a
// room at y 100..500, so the content spans y 100..750 — centre 425.
assert.deepEqual(markerPos(dev, {}, cfg, {}, model), { x: 500, y: 425 });
});
test('labelPos: saved rl_<id> → render units; else room centre', () => {
+184 -8
View File
@@ -5,6 +5,9 @@ import {
isExteriorWall, windowWallInfo, windowLit,
rayLength, rayQuad, clipToRoom, computeSunRays,
rayAlpha, rayColor, cloudFactor, RAY_MAX_ALPHA,
raysVisible, rayPeakAlpha, RAY_ELEVATION_MIN, RAY_FADE_MS,
RAY_LENGTH_K, RAY_FADE_END, rayStops, RAY_MIN_COS,
SKY_SNAP_DEG, skyNeedsSnap, skyElevation,
northDegOf, bgModeOf, sunRaysOn, weatherEntityOf, sunStateOf,
} from '../test-build/sun.js';
@@ -93,20 +96,108 @@ test('isExteriorWall probes the outer side', () => {
assert.ok(!isExteriorWall([500, 300], [1, 0], ROOMS)); // r2 is outside r1 here
});
test('windowLit: above the horizon AND facing the sun', () => {
test('windowLit: above the horizon, facing the sun, and NOT along the wall', () => {
const east = [1, 0];
assert.ok(windowLit(east, sunDirOnPlan(90, 0), 10));
assert.ok(!windowLit(east, sunDirOnPlan(270, 0), 10)); // sun behind the house
assert.ok(!windowLit(east, sunDirOnPlan(90, 0), 0)); // sunset moment
assert.ok(!windowLit(east, sunDirOnPlan(90, 0), -5)); // night
// DEV-EB173-01: a sun sliding ALONG the wall lights nothing. The dot product
// is the cosine of the incidence angle: for this wall it is exactly sin(az).
assert.equal(RAY_MIN_COS, 0.05);
const cos = (az) => Math.sin((az * Math.PI) / 180);
assert.ok(cos(2) < RAY_MIN_COS && !windowLit(east, sunDirOnPlan(2, 0), 40));
assert.ok(cos(4) > RAY_MIN_COS && windowLit(east, sunDirOnPlan(4, 0), 40));
// ~87.1° of incidence, i.e. the sun ~2.9° clear of the wall's own plane
assert.ok(near((Math.acos(RAY_MIN_COS) * 180) / Math.PI, 87.13, 0.01));
});
test('rayLength: longest at the horizon, shortest at noon, monotonic', () => {
assert.ok(near(rayLength(0), 2.5, 1e-9));
assert.ok(near(rayLength(90), 0.8, 1e-9));
test('rayLength: 30% shorter than v1.56 (owner 2026-08-04), same shape', () => {
// the old curve, kept here so the -30% stays a fact and not a memory
const before = (e) => 0.8 + 1.7 * Math.pow(1 - Math.min(90, Math.max(0, e)) / 90, 1.6);
assert.equal(RAY_LENGTH_K, 0.7);
assert.ok(near(rayLength(0), 1.75, 1e-9)); // was 2.5
assert.ok(near(rayLength(90), 0.56, 1e-9)); // was 0.8
for (const e of [-5, 0, 3, 10, 30, 45, 60, 89, 90, 120]) {
assert.ok(near(rayLength(e), before(e) * 0.7, 1e-12), 'exactly 70% at ' + e);
}
// the shape survives: a low sun still reaches much further than a high one
assert.ok(rayLength(10) > rayLength(30));
assert.ok(rayLength(30) > rayLength(60));
assert.ok(near(rayLength(-5), 2.5, 1e-9)); // clamped
assert.ok(near(rayLength(-5), 1.75, 1e-9)); // clamped
});
test('rayStops: the shaft is fully dissolved BEFORE its own far edge', () => {
const stops = rayStops();
assert.ok(near(stops[0][0], 0) && near(stops[0][1], 1), 'brightest at the glass');
assert.equal(RAY_FADE_END, 0.85);
// offsets are sorted, alphas never rise, and the tail is a hard zero
for (let i = 1; i < stops.length; i++) {
assert.ok(stops[i][0] > stops[i - 1][0] || stops[i][0] === 1, 'offsets ascend');
assert.ok(stops[i][1] <= stops[i - 1][1], 'alpha never brightens inward');
}
assert.ok(near(stops[stops.length - 1][0], 1), 'the gradient spans the FULL wedge');
for (const [off, k] of stops) {
if (off >= RAY_FADE_END) assert.equal(k, 0, 'nothing left at/after ' + RAY_FADE_END);
else assert.ok(k > 0, 'still lit at ' + off);
}
// half gone well before the middle — the eye must not find a straight edge
const half = stops.find(([, k]) => k <= 0.5);
assert.ok(half[0] <= 0.65, 'past half-dark by two thirds of the way');
});
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: an honest parallelogram, both sides exactly `len` (DEV-EB173-01)', () => {
// «Не надо размывать их боковые грани» — the sides are hard lines, so the
// only thing that may dissolve a shaft is the gradient. That gradient runs
// along the wall's NORMAL (see SunRay.normal/depth), and ITS iso-alpha lines
// are parallel to the wall — which is exactly where an equal extrusion of
// both ends puts the far edge. So the wedge is a plain parallelogram again
// and every side is the full, promised reach.
const a = [100, 100];
const b = [100, 200]; // a window along +y
const len = 300;
for (const deg of [0, 20, 45, 70, -35, -60]) {
const rad = (deg * Math.PI) / 180;
const dir = [Math.cos(rad), Math.sin(rad)]; // oblique sun in most cases
const q = rayQuad(a, b, dir, len);
assert.equal(q.length, 4);
// the near edge is still the window itself
assert.deepEqual(q[0], [100, 100]);
assert.deepEqual(q[1], [100, 200]);
for (const [near0, far] of [[q[0], q[3]], [q[1], q[2]]]) {
const ex = far[0] - near0[0];
const ey = far[1] - near0[1];
// both sides run exactly along the ray — razor-sharp, never splayed
assert.ok(Math.abs(ex * dir[1] - ey * dir[0]) < 1e-9, 'side parallel to the ray at ' + deg);
assert.ok(ex * dir[0] + ey * dir[1] > 0, 'side runs away from the glass');
// ...and each is the FULL reach: the 30 % cut is a fact on every side,
// at every sun angle (the old skewed quad made one side 88 % longer)
assert.ok(near(Math.hypot(ex, ey), len, 1e-9), 'side is exactly len at ' + deg);
}
// the far edge is parallel to the wall — the gradient's last iso-alpha line
const fx = q[2][0] - q[3][0];
const fy = q[2][1] - q[3][1];
const sx = b[0] - a[0];
const sy = b[1] - a[1];
assert.ok(Math.abs(fx * sy - fy * sx) < 1e-6, 'far edge parallel to the wall at ' + deg);
}
// head-on sun: the classic parallelogram, unchanged
const straight = rayQuad(a, b, [1, 0], len);
assert.deepEqual(straight, [[100, 100], [100, 200], [400, 200], [400, 100]]);
});
test('rayQuad + clipToRoom: the wedge is cut by the room outline', () => {
@@ -145,6 +236,67 @@ test('computeSunRays: evening west sun → west window', () => {
assert.deepEqual(rays.map((r) => r.openingId), ['wW']);
});
test('grazing sun: the auditor\'s repro, fixed by a normal-axis fade (DEV-EB173-01)', () => {
// The report's browser probe: a WEST window 80 render units long, elevation
// 90 (so the nominal reach is 0.56 · 80 = 44.8 — «на 30 % короче»), azimuth
// 190 at north_deg 0, i.e. the light enters the glass but travels only 10°
// off the wall's own direction. It measured sides of 5.408 and 84.192
// (ratio 15.57, the long one 31 % LONGER than the pre-cut 64) and source
// offsets of ±0.879 — one end of the glass already fully transparent,
// because rayStops() is dead from 0.85 on.
const win = { id: 'wW', x: 100, y: 300, angle: 90, length: 80 };
const rays = computeSunRays(ROOMS, [win], 190, 90, 0);
assert.equal(rays.length, 1);
const r = rays[0];
assert.ok(near(r.dir[0], 0.17365, 1e-5) && near(r.dir[1], -0.98481, 1e-5));
assert.ok(near(r.len, 44.8, 1e-9), 'nominal reach is the 70 % one');
// 1) EQUAL sides, each exactly the nominal reach
const q = rayQuad([r.a[0], r.a[1]], [r.b[0], r.b[1]], r.dir, r.len);
const side = (p0, p1) => Math.hypot(p1[0] - p0[0], p1[1] - p0[1]);
const sides = [side(q[0], q[3]), side(q[1], q[2])];
assert.ok(near(sides[0], sides[1], 1e-9), 'sides equal (was a ratio of 15.57)');
for (const l of sides) assert.ok(near(l, 44.8, 1e-9), 'each side is 44.8 (was 5.41 / 84.19)');
// 2) the fade axis is the INWARD wall normal, len · cos(incidence) long
assert.ok(near(r.normal[0], 1, 1e-12) && near(r.normal[1], 0, 1e-12));
const cos = r.dir[0] * r.normal[0] + r.dir[1] * r.normal[1];
assert.ok(near(cos, 0.17365, 1e-5), 'a 10°-off-the-wall sun');
assert.ok(near(r.depth, 44.8 * cos, 1e-9));
assert.ok(near(r.depth, 7.7794, 1e-4));
// 3) offsets along THAT axis: the whole pane of glass at 0 (peak alpha at
// BOTH ends — the probe's ±0.879 is gone), the far edge exactly at 1
const mx = (r.a[0] + r.b[0]) / 2;
const my = (r.a[1] + r.b[1]) / 2;
const off = (p) => ((p[0] - mx) * r.normal[0] + (p[1] - my) * r.normal[1]) / r.depth;
assert.ok(near(off(r.a), 0, 1e-12) && near(off(r.b), 0, 1e-12), 'glass all at peak alpha');
assert.ok(near(off(q[2]), 1, 1e-12) && near(off(q[3]), 1, 1e-12), 'far edge on the last iso-alpha line');
// 4) ...and the offset of any point is exactly how far ITS ray has run
for (const u of [0, 0.25, 0.5, 0.85, 1]) {
for (const src of [r.a, r.b, [r.a[0], r.a[1] + 17]]) {
const p = [src[0] + r.dir[0] * r.len * u, src[1] + r.dir[1] * r.len * u];
assert.ok(near(off(p), u, 1e-9), 'offset = travelled / len at u=' + u);
}
}
// 5) nothing drawn past the gradient, on the clipped geometry too
for (const poly of r.polys) for (const p of poly) {
assert.ok(off(p) >= -1e-6 && off(p) <= 1 + 1e-6, 'inside the gradient');
}
});
test('grazing sun: below RAY_MIN_COS a window casts nothing at all', () => {
// azimuth 182° at north_deg 0 puts the sun 2° off the west wall's plane:
// cos = sin(2°) = 0.035 < RAY_MIN_COS. 186° (0.105) still lights it.
const win = { id: 'wW', x: 100, y: 300, angle: 90, length: 80 };
assert.deepEqual(computeSunRays(ROOMS, [win], 182, 90, 0), []);
assert.equal(computeSunRays(ROOMS, [win], 186, 90, 0).length, 1);
// the surviving wedge is never thinner than 5 % of its own reach
const r = computeSunRays(ROOMS, [win], 186, 90, 0)[0];
assert.ok(r.depth >= r.len * RAY_MIN_COS);
});
test('computeSunRays: night → nothing at all', () => {
assert.deepEqual(computeSunRays(ROOMS, ALL, 90, 0, 0), []);
assert.deepEqual(computeSunRays(ROOMS, ALL, 90, -10, 0), []);
@@ -163,15 +315,39 @@ test('computeSunRays: rotating the compass swings the light to another window',
}
});
test('rayAlpha: capped, ramps in near the horizon, scaled by clouds', () => {
assert.equal(rayAlpha(0), 0);
test('rayAlpha: nothing below 3°, full strength above (owner 2026-08-03)', () => {
// the old gradual ramp-in is gone: it is a threshold, not a fade
assert.equal(rayAlpha(-3), 0);
assert.ok(near(rayAlpha(1), RAY_MAX_ALPHA / 2));
assert.equal(rayAlpha(0), 0);
assert.equal(rayAlpha(1), 0);
assert.equal(rayAlpha(2.99), 0);
assert.ok(near(rayAlpha(3), RAY_MAX_ALPHA)); // exactly at the threshold: on
assert.ok(near(rayAlpha(3.1), RAY_MAX_ALPHA));
assert.ok(near(rayAlpha(30), RAY_MAX_ALPHA));
assert.ok(near(rayAlpha(89), RAY_MAX_ALPHA)); // no elevation shaping at all
// clouds still scale it, rain still kills it
assert.ok(near(rayAlpha(30, 0.25), RAY_MAX_ALPHA * 0.25));
assert.equal(rayAlpha(30, 0), 0);
});
test('raysVisible / rayPeakAlpha: the threshold and the cloud-only ceiling', () => {
assert.equal(RAY_ELEVATION_MIN, 3);
assert.equal(RAY_FADE_MS, 2000); // «ровно 2 секунды», mirrored in styles.ts
assert.equal(raysVisible(2.9), false);
assert.equal(raysVisible(3), true);
assert.equal(raysVisible(45), true);
assert.equal(raysVisible(-10), false);
// the peak is what the gradient uses while the layer fades — cloud only
assert.ok(near(rayPeakAlpha(), RAY_MAX_ALPHA));
assert.ok(near(rayPeakAlpha(1), RAY_MAX_ALPHA));
assert.ok(near(rayPeakAlpha(0.4), RAY_MAX_ALPHA * 0.4));
assert.equal(rayPeakAlpha(0), 0);
});
test('RAY_MAX_ALPHA is the brighter 0.3 ceiling (owner 2026-08-03)', () => {
assert.equal(RAY_MAX_ALPHA, 0.3);
});
test('rayColor: warm at the horizon, neutral by day', () => {
assert.equal(rayColor(1), '#ff9a45');
assert.equal(rayColor(0), '#ffe9c2');
+115 -2
View File
@@ -107,6 +107,35 @@ def test_space_schema_drops_the_old_aspect_and_bounds_the_image_ratio():
v.SPACE_SCHEMA({**ok, "view_box": [0, 0, 1]})
def test_space_backdrop_transform_is_optional_and_bounded():
"""plan_x / plan_y / plan_scale — the backdrop placement (docs/BACKDROP.md).
Optional to the letter: a space that has never been through the backdrop
editor validates exactly as it did before, and the keys stay absent (there
is no migration and no default written into anybody's store).
"""
ok = {"id": "f1", "title": "1", "view_box": [0, 0, 1, 1], "rooms": []}
out = v.SPACE_SCHEMA(ok)
assert "plan_x" not in out and "plan_y" not in out and "plan_scale" not in out
v.SPACE_SCHEMA({**ok, "plan_x": 0.25, "plan_y": -1.5, "plan_scale": 2.5})
v.SPACE_SCHEMA({**ok, "plan_x": None, "plan_y": None, "plan_scale": None})
# the offset is a coordinate: the whole canvas range, both signs
v.SPACE_SCHEMA({**ok, "plan_x": v.CANVAS_LIMIT, "plan_y": -v.CANVAS_LIMIT})
# the scale is a MULTIPLIER: strictly positive, human-sized
v.SPACE_SCHEMA({**ok, "plan_scale": v.PLAN_SCALE_MIN})
v.SPACE_SCHEMA({**ok, "plan_scale": v.PLAN_SCALE_MAX})
for bad in ({"plan_x": v.CANVAS_LIMIT + 1}, {"plan_y": -v.CANVAS_LIMIT - 1},
{"plan_x": float("nan")}, {"plan_y": float("inf")},
{"plan_scale": 0}, {"plan_scale": -1},
{"plan_scale": v.PLAN_SCALE_MIN / 2},
{"plan_scale": v.PLAN_SCALE_MAX * 2},
{"plan_scale": float("nan")}):
with pytest.raises(vol.Invalid):
v.SPACE_SCHEMA({**ok, **bad})
def test_marker_schema():
v.MARKER_SCHEMA({"id": "m1", "binding": "device:abc"})
v.MARKER_SCHEMA({"id": "m2", "binding": "virtual", "name": "X",
@@ -273,8 +302,8 @@ def test_finite_on_every_coordinate():
def test_geometry_magnitudes_are_bounded():
"""HP-1501-01: any finite float used to pass, and one schema-valid 1e100
room vertex made the space unviewable for every client — the exact failure
HP-1500-03 closed for layout positions, one schema over. ±4 is slack for a
vertex nudged past an edge, not an envelope for absurdity."""
HP-1500-03 closed for layout positions, one schema over. The range is
garbage insurance, not an envelope for the plan (docs/CANVAS.md)."""
base = {"id": "s1", "title": "S", "view_box": [0, 0, 1, 1], "rooms": []}
huge = 1e100
for cfg in (
@@ -295,6 +324,80 @@ def test_geometry_magnitudes_are_bounded():
{"id": "r", "name": "R", "poly": [[-0.2, 0], [1.3, 0], [1, 1]]}]}]})
def test_infinite_canvas_range():
"""docs/CANVAS.md §3: the canvas is unbounded, so a plan drawn far past the
old unit square must SAVE. The limits moved from ±4 to ±5000: 4.5 and 1000
are ordinary plans now, 6000 is still refused."""
base = {"id": "s1", "title": "S", "view_box": [0, 0, 1, 1], "rooms": []}
assert v.CANVAS_LIMIT == 5000.0
# --- coordinates that used to be rejected by the ±4 envelope -----------
for good in (4.5, 12.0, 1000.0, -4.5, -1000.0):
assert v.CONFIG_SCHEMA({"spaces": [{**base, "rooms": [
{"id": "r", "name": "R", "poly": [[good, good], [good + 0.5, good],
[good + 0.5, good + 0.5]]}]}]})
assert v.LAYOUT_SCHEMA({"d1": {"s": "s1", "x": good, "y": good}})
assert v.CONFIG_SCHEMA({"spaces": [{**base, "openings": [
{"id": "o", "type": "door", "x": good, "y": good,
"angle": 30, "length": 0.1}]}]})
assert v.CONFIG_SCHEMA({"spaces": [{**base, "decor": [
{"id": "d", "kind": "rect", "x": good, "y": good, "w": 0.2, "h": 0.2}]}]})
# sizes follow the same ceiling but stay strictly positive
assert v.CONFIG_SCHEMA({"spaces": [{**base, "rooms": [
{"id": "r", "name": "R", "x": 1.5, "y": 1.5, "w": 4.5, "h": 1000.0}]}]})
assert v.CONFIG_SCHEMA({"spaces": [{**base, "view_box": [1.5, 1.5, 4.5, 1000.0]}]})
assert v.CONFIG_SCHEMA({"spaces": [{**base, "openings": [
{"id": "o", "type": "door", "x": 2.5, "y": 2.5, "angle": 0, "length": 4.5}]}]})
# --- and 6000 is still garbage ---------------------------------------
for bad in (6000.0, -6000.0):
with pytest.raises(vol.Invalid):
v.CONFIG_SCHEMA({"spaces": [{**base, "rooms": [
{"id": "r", "name": "R", "poly": [[bad, 0], [1, 0], [1, 1]]}]}]})
with pytest.raises(vol.Invalid):
v.LAYOUT_SCHEMA({"d1": {"s": "s1", "x": bad, "y": 0.5}})
with pytest.raises(vol.Invalid):
v.CONFIG_SCHEMA({"spaces": [{**base, "decor": [
{"id": "d", "kind": "line", "x1": bad, "y1": 0, "x2": 1, "y2": 1}]}]})
with pytest.raises(vol.Invalid):
v.CONFIG_SCHEMA({"spaces": [{**base, "view_box": [0, 0, 6000.0, 1]}]})
with pytest.raises(vol.Invalid):
v.CONFIG_SCHEMA({"spaces": [{**base, "rooms": [
{"id": "r", "name": "R", "x": 0, "y": 0, "w": 6000.0, "h": 1}]}]})
# sizes are still not coordinates: zero and negative stay refused
with pytest.raises(vol.Invalid):
v.CONFIG_SCHEMA({"spaces": [{**base, "openings": [
{"id": "o", "type": "door", "x": 0.5, "y": 0.5, "angle": 0, "length": 0}]}]})
def test_existing_plans_stay_valid():
"""No migration (docs/CANVAS.md §1): a config written by any released
version must validate untouched after the limits moved."""
legacy = {
"spaces": [{
"id": "f1", "title": "Ground", "aspect": 1.0, "plan_url": "/local/f1.svg",
"plan_aspect": 1.25, "view_box": [0, 0, 1, 1],
"segments": [[0, 0, 1, 0]],
"rooms": [
{"id": "r1", "name": "Living", "area": "living_room",
"poly": [[0.04, 0.14], [0.55, 0.14], [0.55, 0.58], [0.04, 0.58]]},
{"id": "r2", "name": "Kitchen", "x": 0.55, "y": 0.14, "w": 0.41, "h": 0.32},
],
"openings": [{"id": "o1", "type": "window", "x": 0.3, "y": 0.14,
"angle": 0, "length": 0.08}],
"decor": [{"id": "d1", "kind": "line", "x1": 0.1, "y1": 0.1,
"x2": 0.9, "y2": 0.1, "color": "#ffffff"},
{"id": "d2", "kind": "text", "x": 0.5, "y": 0.9, "text": "Porch"}],
"settings": {"show_borders": True, "room_opacity": 0.5},
}],
"markers": [{"id": "m1", "binding": "device:abc", "space": "f1", "size": 1.4}],
"settings": {"glow_radius_cm": 300},
}
out = v.CONFIG_SCHEMA(legacy)
assert out["spaces"][0]["rooms"][0]["poly"][0] == [0.04, 0.14]
assert "segments" not in out["spaces"][0]
assert v.LAYOUT_SCHEMA({"d_light1": {"s": "f1", "x": 0.22, "y": 0.22},
"rl_r1": {"s": "f1", "x": 0.3, "y": 0.3}})
def test_sizes_are_not_coordinates():
"""HP-1502-01: a size must be strictly positive — SVG refuses zero and
negative width/height, and the clients divide by these. view_box [0,0,0,0]
@@ -466,6 +569,16 @@ def test_every_tap_action_the_editor_offers_is_accepted():
v.MARKER_SCHEMA(_marker(tap_action="launch-missiles"))
def test_cover_tap_action_is_accepted():
"""Owner's spec 2026-08-03: a marker may carry tap_action='cover'
(open/close/stop). The card offers it only for non-guarded covers, but the
schema is the door the config walks through — reject it and the whole plan
stops saving (the v1.26.0 'value' lesson, see DISPLAY_MODES above)."""
assert "cover" in _ts_list("TAP_ACTIONS")
v.MARKER_SCHEMA(_marker(tap_action="cover"))
v.MARKER_SCHEMA(_marker(tap_action="cover", tap_confirm=True))
def _space(**settings):
return {
"id": "f1", "title": "F1", "aspect": 1.4, "view_box": [0, 0, 1, 1],
+2 -1
View File
@@ -15,6 +15,7 @@
"src/devices.ts",
"src/types.ts",
"src/space-geometry.ts",
"src/signing.ts"
"src/signing.ts",
"src/align-grid.ts"
]
}