Commit Graph
100 Commits
Author SHA1 Message Date
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
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
Matysh 9be44dab1e v1.56.0
Validate / hacs (push) Failing after 10s
Validate / hassfest (push) Failing after 8s
Validate / frontend (push) Successful in 2m29s
Validate / backend (push) Failing after 7m51s
Validate / smoke (push) Failing after 9m38s
2026-08-03 13:51:04 +03:00
Matysh ba88782ce1 v1.56.0 2026-08-03 13:46:24 +03:00
Matysh a20b73621f DEV-B701-01: sun-ray memo keyed by _cfgEpoch, not _cfgRev (stale wedge after local geometry edits)
The wedge cache key carried the SERVER revision, which only moves after the
debounced houseplan/config/set is acked. Every local mutation path ends in
_saveConfig(), which bumps _cfgEpoch synchronously — so a dragged window or
an edited room kept its old wedge for the whole write window (forever on a
failed write). The memo now uses the epoch, the same signal the model/
geometry caches key on (audit L1).

smoke_sun.mjs no longer masks the defect: touchCfg() bumps _cfgEpoch (what
production does) instead of faking a server rev, and a new regression drives
the REAL path — a pointer drag of the east window, exit from the editor
inside the debounce window (rev untouched), a room shrink through
_saveConfig() that must re-clip the wedge, and a late-ack survival check.
Fails on b701537, green with the fix. 218 unit + 79 backend + 85/85 smokes.
2026-08-03 13:28:57 +03:00
Matysh b70153769a OPENING DRAG: shoulders measure the ONE room edge under the opening, no collinear merge (owner 2026-08-03)
Validate / hacs (push) Failing after 6s
Validate / hassfest (push) Failing after 7s
Validate / frontend (push) Successful in 1m24s
Validate / backend (push) Failing after 5m25s
Validate / smoke (push) Failing after 7m57s
Owner: 'not like that — the whole wall is counted now, only the wall of ONE
room must count'. openingShoulders no longer merges collinear touching edges
of neighbouring rooms into a physical run: the wall is exactly the room-
polygon edge the opening is snapped to. Selection mirrors snapToWall
(nearest collinear edge, first in roomEdges order on a tie), so the ruler
always measures the same edge the drag snapped to; the center tick/magnet
now targets that edge's middle. Unit tests flipped to the new contract plus
a staggered shared-wall case; smoke_opening_measure recalculated for r1's
own edge 40..550 and grew a shared-wall scenario (both failed on the old
build, green now); docs/TESTING.md wording updated.
2026-08-03 12:21:02 +03:00
Matysh 5e5c06f126 OPENING DRAG: shoulder rulers + center tick with a soft magnet (owner 2026-08-03)
While an opening is dragged along a wall, a measure badge sits on the middle
of EACH shoulder: the along-the-wall distance from the wall end to the nearest
opening edge, live (segmentCm/formatLength, so metric/imperial and cell_cm are
honoured). Collinear touching room edges count as ONE physical wall — a user
thinks in whole walls, not the fragments roomEdges derives. When the opening's
center reaches the wall's center (±half a grid step) a perpendicular dashed
tick (alignment-guide look) appears through the wall center and the center
magnet-snaps; Shift disables the magnet. Everything vanishes on release.
Angled walls work: distances run along the wall, the tick is perpendicular.

- src/logic.ts: openingShoulders() — pure shoulder/centered math (unit-tested)
- src/houseplan-card.ts: _opMeasure state fed by _opPointerMove, badge layer
  next to the resize badges, _renderOpeningCenterTick in the SVG
- demo/smoke_opening_measure.mjs: real-pointer drag; numbers checked against
  the demo geometry (4.56/5.52 m, 5.04/5.04 m at center), magnet == 0.5,
  Shift keeps 0.4987, everything gone after drop (was red without the feature)
- docs/TESTING.md: checklist line
2026-08-03 11:59:57 +03:00
Matysh c4a80bcb9f DIALOGS: native ha-switch / ha-slider with a hard fallback to plain inputs
Boolean .srcrow checkboxes (14 rows: sun rays, vacuum live position,
opening invert/flipH/flipV, marker show-entities/tap-confirm/climate-temp/
is-light/hide-from-plan, space borders/names/lqi + the 4 room-card label
flags) and every dialog range slider (9: fill opacities, kiosk icon/font,
ripple size, marker size/angle, card font, room opacity, room name/label
scale) render through _boolInput/_rangeInput. Each helper picks the native
HA element via customElements.get(...) at render time - the ha-* API is
undocumented and drifts between HA releases, so the presence check is the
only coupling; without the element (old HA, smoke env) the EXISTING input
renders unchanged, and both branches feed one handler (change/.checked,
input+change/.value). Radios, selects, the decorbar fill flag and the
import-dialog floor rows stay native on purpose. New smoke_ha_controls
covers both branches with ha-* stubs (two-way value flow); the other 83
smokes keep exercising the fallback, which stays pixel-identical.
2026-08-03 11:09:00 +03:00
Matysh 9ccc3831d1 STYLES: design-token pass — spacing/radius/font/shadow scales in :host
209 hardcoded px values in styles.ts now resolve through design tokens
(--sp-1..6: 2/4/6/8/12/16, --rad-s/m/l: 6/8/12, --fs-s/m/l: 12/13/15,
--shadow-1/2/3). 151 swaps are value-identical; 58 stray values (3/5/7/9/
13/14px paddings, 11/12.5/13.5px fonts, 4/5/10/14px radii, two odd shadows)
are unified onto the nearest step, max +-2px by design. Untouched: %, all
calc() off --icon-size/--dev-size/--puck-size, viewBox units (compass text,
.rlabel, .vacfit), z-index, animation timings, colors. The phantom
--hp-panel/--hp-fg vars in .vaccalbar (defined nowhere) fold into
--hp-bg/--hp-txt. .modetab keeps its 10px h-padding: +2px wrapped the
header modes row at ~900px. 10px spacings stay literal for now - 10 is
equidistant from --sp-4/--sp-5 and moving it either way shifts the header;
candidate for its own step in a future pass.
2026-08-03 11:01:13 +03:00
Matysh 33a960031e CLIMATE TEMP: opt-in room temperature from climate devices (owner 2026-08-03)
Marker dialog grows a checkbox (climate devices only, default OFF):
'Use the device's temperature sensor'. When ticked, the AC/thermostat's
attributes.current_temperature shows as the standard .tval badge next to
the icon (scales with --dev-size, honours show_temperature) and joins the
room average like a thermometer. Unavailable / missing attribute = no
badge, no vote; several climate entities - the first valid one wins;
hidden devices keep voting (room climate stays registry-wide, exactly
like hidden thermometers). Stored as marker.use_climate_temp (bool|None,
validated in MARKER_SCHEMA). Units in test/devices.test.mjs, smoke
demo/smoke_climate_temp.mjs (real checkbox click, 20 + 23.5 -> 21.8 on
the room card), backend test, docs/TESTING.md.
2026-08-03 10:24:51 +03:00
Matysh 3a6a819dec SUN: white day — the brightest moment of the day is white (owner 2026-08-03)
- BG_STOPS: +10deg #e8ddcf (morning light), +30..90deg #ffffff; night half
  of the scale untouched (-4 #131a28, -12..-90 #070c14)
- drawn-plan paper vs white sky: all paper shapes now sit in one
  .hp-paperg group; in daynight mode the group gets a subtle
  drop-shadow so the sheet contour stays readable at high sun
  (static mode and night unaffected)
- pinned colors updated: test/sun.test.mjs, demo/smoke_sun.mjs;
  smoke_bg_color paperUnderneath follows the .hp-paperg wrapper
- docs/SUN.md: explicit BG_STOPS table
- demo/shot_daynight.mjs: noon/sunset/night stills of the scale
2026-08-03 09:56:36 +03:00
Matysh c024c6d75a BG: drawn-plan paper follows the room contours, not their bounding box
Owner: the white backing must hug the ROOMS — an L-shaped house or detached
buildings grew a white square around the plan. Drawn plans now paper one
opaque shape per room (paperRoomShapes in logic.ts) in exactly the room's own
geometry — polygon points / rounded rect verbatim — so the union of the stack
is the paper: islands paint over their parent, open (virtual) boundaries
change nothing, and the scene bg_color / daynight sky reaches the exterior
walls, shows in the L's pocket and between buildings. Image plans keep the
backdrop-image rect (the canvas IS the paper). A live resize preview
(_rszPreview) feeds _renderCfg, so the paper moves WITH a dragged wall.
Static space-card follows the same contract. Paper is fill-only (stroke:none).

smoke_bg_color §11–13 rewritten: L-shaped + detached test rooms, paper-per-
room DOM checks, resize-preview wiring, pixel probes (acid in the pocket and
between buildings, none inside rooms); §13 injects the snapshot directly —
the module-level config-store cache made the old WS mock a no-op. Was 8 red
on the previous build, green now. docs/SUN.md + docs/TESTING.md contract
updated; unit test for paperRoomShapes.
2026-08-03 08:38:48 +03:00
Matysh a8bb145ffb BG: opaque plan paper — the scene background never bleeds through the plan
Owner request 2026-08-03: bg_color (and the daynight sky) used to shine
through the plan itself — a hand-drawn plan's translucent room fills sat
directly on the scene colour, and a transparent backdrop image let it
through too. An opaque rect.hp-paper now sits under everything the plan
draws and hugs the plan's extents (the backdrop image rect, or the drawn
content bounds the opening view fits). Its colour is the pre-bg_color
canvas: white for drawn plans (.stage.noplan), the theme card background
under an image and on the static space-card. The daynight night keeps
dimming the plan via the zoomwrap brightness filter ONLY — the paper's
alpha never changes. The scene colour is visible strictly AROUND the
plan, in view/kiosk/editors and the static card alike.

smoke_bg_color grew the contract (sections 11–13): paper presence,
geometry and opacity in view/editors/night, the white drawn-plan paper,
the static card's paper, plus a pixel proof against an acid #ff00ff
background (screenshot → canvas: no acid admixture inside the plan, acid
right outside it). The suite fails on the previous build.

docs: SUN.md background contract + TESTING.md checklist item.
2026-08-03 08:16:48 +03:00
Matysh 1e0295a370 SUN: adjust pinned dialog inventories + screenshot script
Validate / hassfest (push) Failing after 6s
Validate / frontend (push) Successful in 1m22s
Validate / backend (push) Failing after 4m10s
Validate / hacs (push) Failing after 7s
Validate / smoke (push) Failing after 12m53s
smoke_general_settings: the settings dialog now has 14 gsrows and a Sun
group; smoke_temp_fill: the space dialog gained the per-space compass
field. demo/shot_sun.mjs renders the evening-wedges and compass shots.
2026-08-03 01:41:26 +03:00
Matysh c74ce39eb3 SUN: backend validation + card render, dialogs, i18n, smoke (docs/SUN.md)
- validation.py: north_deg (strict int 0-359), bg_mode, sun_rays at both
  levels + weather_entity (global); tests_backend coverage
- card: memoised window-wedge layer (recomputes only on sun/config change),
  day/night stage background with slow CSS transition and ~10% plan dim,
  compass dial + number input in the general settings, per-space
  bg_mode/north_deg/sun_rays overrides (empty = inherit), weather datalist
- static space-card: background honours bg_mode (wedges full-card-only)
- i18n en/ru, styles, docs/TESTING.md checklist
- demo/smoke_sun.mjs: 4-wall windows, compass rotation, night, clipping,
  inheritance, clouds, memo, editors clean, real compass drag; verified to
  FAIL against the pre-feature build (16 named failures)
2026-08-03 01:36:03 +03:00
Matysh ee0ee9a1d3 SUN: spec (docs/SUN.md) + pure logic src/sun.ts with unit tests
planSunAngle/sunDirOnPlan (compass wrap), dayPhase palette, exterior-wall
probing, window wedges (rayQuad + polyclip room clipping), cloudFactor map,
north_deg/bg_mode/sun_rays inheritance. 26 new unit tests.
2026-08-03 01:26:38 +03:00
Matysh a8d40e4d99 v1.55.3
Validate / smoke (push) Failing after 12m47s
Validate / hacs (push) Failing after 9s
Validate / hassfest (push) Failing after 8s
Validate / frontend (push) Successful in 2m24s
Validate / backend (push) Failing after 7m57s
2026-08-02 15:01:37 +03:00
Matysh 63a8274623 AUD-1552-01/02: the v1.55.2 boot-veil audit findings, fixed with regressions
The v1.55.2 recheck (verdict NEEDS FIX) found two lifecycle holes in the
first-open boot veil (HP-1552); both are closed here and covered by
demo/smoke_preloader_lifecycle.mjs, which fails on v1.55.2.

AUD-1552-01 (high): disconnect/reconnect while booting hid the plan
FOREVER. disconnectedCallback cleared the boot timer but kept its
truthy id, so 'updated()' never restarted the watcher. Now the id is
nulled on disconnect and connectedCallback restarts the whole veil
lifecycle: a fresh watch (fresh clock, BOOT_MAX_MS hard cap) while
booting, the tail timers when detached mid-fade or mid-grace.

AUD-1552-02 (medium): two equal reads at 200/400 ms revealed the plan
at ~400 ms, so HA chrome landing at 450+ ms jumped on a VISIBLE plan.
The veil now holds a full protective window (BOOT_MIN_MS=700 — the old
600 ms plus a frame-latency margin: a shift applied at ~590 ms only
materializes in the stage height a couple frames later) with 100 ms
sampling and trailing quiescence (BOOT_QUIET_MS=250 restarts on every
height change), capped by BOOT_MAX_MS=1200. After the reveal a short
soft grace (BOOT_SOFT_MS=1500, .stage.hpsettle) turns later passive
shifts into a 0.25 s height glide — the viewport ResizeObserver refits
the plan along the transition; deliberate height changes (_setMode into
an editor) cancel the grace so the plan never drifts under the pointer.
Reduced motion disables the glide.

Regressions (demo/smoke_preloader_lifecycle.mjs, all FAIL on v1.55.2):
- A: detach before the first tick -> reattach -> veil lifts within the
  cap, plan visible, no hpboot class, no zombie veil after a mid-fade
  remount either;
- B: parameterized layout shifts at 150/300/450/590 ms -> not a single
  frame shows the plan at a non-final stage height;
- C: a shift after the reveal glides (>=3 intermediate frames), no snap.

smoke_modes.mjs now strips the transient hpsettle class from its exact
stage-class assertions. Docs: CHANGELOG en+ru, STATUS.
2026-08-02 14:56:58 +03:00
Matysh 1a75ee1ddf v1.55.2 2026-08-02 12:54:36 +03:00
Matysh e81e841ed6 v1.55.2 2026-08-02 12:50:43 +03:00
Matysh 3e23ff58c3 ui(resize): wall handles — half-size wall-with-arrows glyph, grab cursor
Validate / hacs (push) Failing after 10s
Validate / hassfest (push) Failing after 8s
Validate / frontend (push) Successful in 1m22s
Validate / backend (push) Failing after 4m37s
Validate / smoke (push) Failing after 6m17s
Owner request: in the resize tool make the wall-drag handles twice
smaller, replace the circle with a 'wall + two opposite arrows' icon,
drag cursor.

- visible glyph: wall segment with two arrows perpendicular to the
  edge (the drag directions), rotated per wall orientation; accent ink
  over a --hp-bg halo, readable on any plan
- HIT area unchanged: an invisible circle of the original finger-sized
  radius keeps touch targets and the HP-1550-04 hit priority over
  openings (04_handle_wins_hit_test still passes)
- cursor: grab on hover, grabbing while dragging (:active)
- scale-frame corner handles untouched (classic filled circles,
  nwse-resize)
- docs/RESIZE.md: handle appearance updated
2026-08-02 11:50:49 +03:00
Matysh 9bcfebe8f4 fix: no default-fit flash when opening with a saved zoom (HP-1551)
The cached config (LS_CFG) painted its first frames with _zoom=1 because
the saved per-space zoom was applied only by _restoreZoom()'s rAF after
the server config round-trip - the plan visibly jumped to the saved scale.

- setConfig now arms _zoom from LS_ZOOM for the resolved space before the
  first view computation (view mode, no established view only);
- _restoreZoom applies the view synchronously when the stage is already
  measured (space tabs, kiosk carousel, editor exit included); the rAF
  path remains only for an unmeasured stage, where updated() already fits
  with the correct zoom before the first paint.

New smoke_zoom_flash.mjs samples every rAF frame from the first possible
moment with a primed config cache, saved zoom 1.8 and a 350 ms server
delay: a visible stage with a default-scale viewBox fails the run
(22 such frames before the fix, 0 after).
2026-08-02 10:08:04 +03:00
Matysh 8b439ea0f3 v1.55.1
Validate / smoke (push) Failing after 11m44s
Validate / hacs (push) Failing after 8s
Validate / hassfest (push) Failing after 10s
Validate / frontend (push) Successful in 2m20s
Validate / backend (push) Failing after 7m27s
2026-08-01 19:01:35 +03:00
Matysh b7d5cb8a8f v1.55.1 2026-08-01 18:58:23 +03:00
Matysh 84f8bcf0e6 HP-1550-01..04: the v1.55.0 resize audit findings, fixed with regressions
01 (high): the live resize preview no longer touches _serverCfg — it lives in
the _rszPreview overlay served to renders via _curSpaceCfg/_renderCfg, so a
debounced write queued from a previous edit can never carry mid-drag geometry
to the server; commit happens once, on pointerup; Esc just drops the overlay.
03: pointercancel/lostpointercapture take the cancel path (no commit, no undo
step, no write) for edge and corner handles alike.
04: in the resize tool the wall handles own the hit test — the transparent
.op-hit is inert and the resize layer renders above the openings; a door at a
wall midpoint no longer shadows the handle, other tools unchanged.
02: the 30 cm floor is orientation-independent — minSpanClearance (band sweep
of the moved stretch) for wall drags, minPolyWidth (calipers) for the scale
frame; already-thin rooms may improve, never worsen.
2026-08-01 18:58:23 +03:00
Matysh 10927ffaed v1.55.0 2026-08-01 15:48:24 +03:00
Matysh 6ff79106b8 v1.55.0 2026-08-01 15:45:14 +03:00
Matysh f49ac613b5 room resize: smoke (numeric vertex asserts) + TESTING.md checklist
demo/smoke_room_resize.mjs drives real pointer events over the handles:
T-stack drag (r1 grows, r2 translates, r3 becomes a 6-vertex L — checked
numerically), live badges appear and change, opening rides the wall,
neighbour 30 cm stop, opening-anchor stop, scale frame proportional with
static neighbours and a neighbour stop, Esc-cancel, one-step undo, no
handles in any other tool/mode. Fails on the pre-feature blob (verified).
2026-08-01 13:48:43 +03:00
Matysh ec58b0602a room resize: the 'Размер' tool in the Plan editor (docs/RESIZE.md)
Wall-midpoint handles for every room (finger-sized, pointer-captured, no
stage-pan conflict); dragging moves the wall along its normal with live
preview through the config snapshot, shared stretches drag the neighbour
(T-junctions insert vertices), openings on the wall travel along. Click a
room for the corner scale frame (uniform, about the opposite corner,
neighbours never dragged). Live badges: dragged+adjacent wall lengths and
m² per reshaped room. Grid snap, Esc cancels the drag, Ctrl+Z pops the
resize undo stack (one release = one step). Legacy rects are saved back
as polygons; three blob copies rebuilt.
2026-08-01 13:48:43 +03:00
Matysh 65070c0520 room resize: spec (docs/RESIZE.md) + pure geometry in src/resize.ts with unit tests
Mechanism A (wall drag along its normal, shared stretches of neighbours move
together, T-junctions insert vertices) and mechanism B (corner scale frame)
with every stop: min room size ~30 cm, self-intersection, foreign rooms
(polyclip area check — roomsOverlap alone misses collinear slide-over),
islands, opening anchors. node:test units pin each stop numerically.
2026-08-01 13:36:35 +03:00
Matysh fa32afa9f3 HP-1543: editor-exit zoom after cross-floor switch; motion flash restart on rapid retrip
HP-1543-01 (medium): exiting an editor AFTER switching floors inside it left
the editor working zoom on screen in view mode — the snapshot guard
(snap.space) dropped the restore and nothing else put the viewport back.
A space-mismatched exit now falls back to _restoreZoom() of the current
floor's saved view zoom (per-space store/LS_ZOOM were never polluted, the
view-only _saveZoom guard is untouched). Regression: crossFloor* asserts in
demo/smoke_zoom_out.mjs — red on v1.54.3, green now.

HP-1543-02 (low): a rapid off->on retrip before the current flash ended did
not restart the CSS animation: the senseflash class and animation-name never
changed, so the browser kept the old timeline and the second detection played
nothing once the first one-shot had finished (base opacity 0). Every
witnessed trip now bumps a generation counter; its parity alternates the
identical keyframes hp-sense / hp-sense-b via the .sf2 marker — a new
animation identity forces a fresh timeline per detection. flashTs and the
window timer re-arm as before; prefers-reduced-motion keeps the static ring
(retrip only extends the window). Regression: rapid* asserts in
demo/smoke_motion_sense.mjs — red on v1.54.3, green now.
2026-08-01 13:09:55 +03:00
Matysh adaed9be7c v1.54.3 2026-08-01 12:40:39 +03:00
Matysh b508c20ad3 v1.54.3 2026-08-01 12:37:24 +03:00
Matysh ca07579b63 motion sensors: one-shot flash on detection; occupancy/presence hold a static ring
Owner's contract (2026-08-01, вариант «б») replaces yesterday's 'sense' class
(29dae45, lived <1 day, stored nowhere — no migration):

- MOTION (device_class motion): a SHORT one-shot flash at the off→on
  transition — 3 beats of the yellow ring (hp-sense 1.1s x3, ~3.3s), then
  silence even while the entity still reports 'on': «движение = разовая
  вспышка в момент обнаружения; cool-down не пульсирует». A new off→on trip
  flashes again. Tracking lives in _senseRt (markerId → last state + flash
  ts), stamped by _senseTick on the hass tick (the _vacTick pattern); one
  setTimeout per entry repaints the card when the window closes so the
  'senseflash' class is dropped without a state change (cleared in
  disconnectedCallback). First sight already-'on' and unavailable→on do not
  flash — not a witnessed detection.
- OCCUPANCY/PRESENCE while 'on': 'sensehold' — a STATIC yellow ring, no
  animation, opacity 0.4 (the reduced-motion brightness): «присутствие =
  статичное кольцо пока обитаемо».
- Unchanged: the yellow FILL stays reserved for «включено», alarm's red ring
  wins on the shared ::after, ghosts draw nothing.
- prefers-reduced-motion: the flash becomes a static ring for the same ~3.3s
  window (consistent with alarm); sensehold is static by design.

Smoke smoke_motion_sense.mjs rewritten to the new contract (13 asserts fail
on the pre-fix bundle): finite iteration count '3', flash gone after 3.6s
while state is still 'on' (the key assert), re-trip flashes again,
occupancy/presence static ring, neutral badge, hidden ghost inert.
shot_motion_sense.mjs now cycles off→on to arm the flash.
2026-08-01 12:17:17 +03:00
Matysh 29dae45e2a motion sensors: tripped motion/occupancy/presence pulses a soft yellow ring
Validate / hacs (push) Failing after 42s
Validate / hassfest (push) Failing after 40s
Validate / frontend (push) Successful in 1m33s
Validate / backend (push) Failing after 6m16s
Validate / smoke (push) Successful in 5m8s
Owner's rule (2026-08-01): the yellow FILL is reserved for 'on' — a tripped
sensor keeps the neutral badge and gets a new 'sense' state class instead:
a gentle .dev.sense::after ring in the .dev.on yellow (--hp-on), 2.4s period,
max opacity 0.5 — the soft sibling of the red .dev.alarm siren. Declared
before .dev.alarm so red wins on the shared ::after if both ever apply;
ghosts don't pulse (hidden gate already strips the state class).
prefers-reduced-motion: static faint ring, same treatment as alarm.

Smoke smoke_motion_sense.mjs (fails on the pre-fix bundle): sense class on/off,
::after animation, neutral background (not --hp-on), occupancy/presence,
hidden ghost. shot_motion_sense.mjs captures the ring for review.
2026-08-01 11:54:23 +03:00
Matysh 88008961e3 zoom: _saveZoom is view-only — editor 500% can no longer leak into the per-space store
The exit-editor restore re-saved the view zoom from a rAF; on a slow
tablet the floor-tab click lands before that rAF (input runs first in
the frame), the snap.space guard skipped the fix-up save and the editor
wheel zoom (500%) stayed in _zoomBySpace/LS_ZOOM — the second floor
switch brought it back into view mode (owner's dacha report). Now
_saveZoom simply refuses to write while _mode is not 'view': the wheel
inside the editors keeps zooming but never touches the per-space view
store, so no fix-up is needed at all. The snapshot restore keeps
bringing the pre-editor viewport back. Smoke: smoke_zoom_out.mjs grows
the owner's exact scenario (both floors zoomed in view, editor 5.0,
same-tick switch after exit, two floor switches) — red before, green
now; the 6d16f69 asserts stay green.
2026-08-01 11:31:18 +03:00
Matysh 6d16f69f38 zoom: editor zoom is a working tool — leaving any editor restores the view-mode viewport
Entering an editor snapshots the view-mode zoom+center (per space);
leaving back to view brings it back and re-saves it to LS_ZOOM, so wheel
zoom done inside the editors no longer leaks into the viewing zoom.
Editor-to-editor switches and the per-space view zoom keep working as
before. Smoke: smoke_zoom_out.mjs grows the 1.6 -> editor 2.5 -> 1.6
scenario (zoom, center, LS), the no-touch no-jump case and the
space-switch persistence guard.
2026-08-01 11:04:19 +03:00
Matysh 92ea8fa03c device satellites: the size multiplier scales the value plate and labels too
Owner report (2026-08-01, screenshot): a device scaled up via marker.size
kept its value badge at the default size — an enlarged icon next to a tiny
'26.6°'. Same bug class as the v1.51.3 glyph fix (3456706): satellite
metrics were pinned to the base --icon-size, so --dev-scale grew the box
but nothing that belongs to it visually.

All .dev satellites now derive from var(--dev-size, var(--icon-size,
2.5cqw)) — which equals icon-size * dev-scale — keeping the exact same
coefficients, so at size=1 nothing changes pixel-wise:

- .valonly plate padding (0.16) and .valtext font (0.45)
- .tval/.hval plates: margin (0.1), radius (0.18), padding (0.14),
  line-height (0.68) — font was already dev-size, the plate was not
- .lqi label: margin (0.05), font (0.38)
- .newdot: offsets (-0.12), diameter (0.34)
- .alarm ring inset (-0.35)

smoke_icon_scale extended: value badge and temp plate must double at
size=2 (getBoundingClientRect + computed font-size, 1.8-2.2 tolerance)
and keep the exact old defaults at size=1; verified failing on the
pre-fix build. space-card renders bare icons only, unaffected.
2026-08-01 10:27:34 +03:00
Matysh 6743b6efc4 hassfest: services.yaml for the stand-only demo_guard
Validate / hacs (push) Failing after 7s
Validate / hassfest (push) Failing after 8s
Validate / frontend (push) Successful in 1m35s
Validate / backend (push) Failing after 6m48s
Validate / smoke (push) Failing after 8m42s
Hassfest scans every manifest in the repo, and demo_guard (which
re-registers homeassistant.restart/stop as no-ops on the public stand)
tripped the SERVICES check. It defines no services of its own; the file
documents exactly that.
2026-07-31 18:44:08 +03:00
Matysh c9775141c9 stand: demo_guard neuters homeassistant.restart/stop for visitor-admins; console reset countdown (www/stand-reset-timer.js)
Visitors hold admin sessions (the editor demo needs is_admin), and some of
them restarted HA from the UI — exit code 100 between hourly resets looked
like stand instability. demo_guard re-registers restart/stop as no-op
services after startup; the hourly docker-level reset is untouched.
stand-reset-timer.js logs a per-minute countdown to the :00 reset in the
browser console (warn for the last 5 minutes); stand-dev-info.js is the
one-shot dev-stand note. Also on the stand host: mem_limit 1536m per
container in compose.yml as an OOM safety net.
2026-07-31 15:16:36 +03:00
Matysh 9c224e7606 ci: re-run Validate on dev
smoke_grid_fade flaked on the duplicate dev run of 93d97e1a (roomFaded
timing on a busy runner); the identical tree is green on main and on the
v1.54.2 tag. Empty commit to re-run — the PAT cannot rerun failed jobs.
2026-07-31 14:37:47 +03:00
Matysh 93d97e1a83 v1.54.2
Validate / hassfest (push) Failing after 11s
Validate / hacs (push) Failing after 13s
Validate / frontend (push) Successful in 1m39s
Validate / backend (push) Failing after 6m22s
Validate / smoke (push) Failing after 8m37s
2026-07-31 13:47:22 +03:00
Matysh fad2e87ab5 v1.54.2: HP-1541-01 — vacuum selected_map fallback obeys the not-nullish map-id contract
The v1.54.1 contract (first not-None value wins, zero is a value) covered
the source entity but not the card's fallback on the vacuum's own
selected_map: _vacMapId still used truthiness, so selected_map: 0 became
'default' on the frontend while trails.py resolve_map_id stored the run
under '0'. Calibration and server trails split across two keys and the
recorded run never rendered after reload.

The fallback is now the shared pure helper vacMapIdWithFallback (nullish
check), mirroring resolve_map_id. Cross-runtime regressions added for
selected_map = 0, '0' and '' on both sides; the frontend cases fail on the
old truthiness code.
2026-07-31 13:44:10 +03:00
Matysh 909bb6fbc7 v1.54.1 2026-07-31 13:15:12 +03:00
Matysh 4e3d8f1d53 Test harness: run async recorder regressions on a private event loop
asyncio.run() clears the thread's current-loop slot when it finishes; the
CI HA harness keeps a session event loop, so every test that followed the
new HP-1540-05 regression failed at SETUP with 'There is no current event
loop'. The pure-only local run never sees the harness and stayed green —
which is exactly how it slipped through. The regressions now spin up an
isolated loop and leave the ambient one untouched.
2026-07-31 13:12:14 +03:00
Matysh a1f7fb4161 v1.54.1: the v1.54.0 audit findings, sealed
Version 1.54.0 -> 1.54.1 in package.json, package-lock.json, manifest.json,
const.py and CARD_VERSION; rebuilt bundle in all three tracked copies
(dist/, demo/srv/assets/, custom_components/houseplan/frontend/).
Changelog entries (EN+RU) — one line per finding, HP-1540-01..06 — and
docs/STATUS.md bumped.

Suites on this exact tree: 161 frontend unit, 74 pure-backend, 74 browser
smokes — all green.
2026-07-31 13:07:37 +03:00
Matysh 257a71123a Trail recorder: zero map ids, one source per N markers, serialized refresh
Audit HP-1540-02: the recorder chose the map id with an or-chain, so a
valid numeric map_index=0 fell through to selected_map (or 'default') and
the server stored the run under a key the renderer never looks up. The
choice is now resolve_map_id() — the explicit backend half of the contract
shared with vacMapIdFromAttrs in src/vacuum.ts: the first value that is
not None wins, zero and empty string included.

HP-1540-03: pairs was a plain source -> (marker, vacuum) dict, so the
second placement of the same robot (the documented two-floor case) evicted
the first and its server history silently stopped. A source now maps to a
list of pairs, every marker gets its own copy of the run, and the state
subscription set is deduplicated.

HP-1540-05: every config/set spawns async_refresh as a detached task; two
of them interleaving across the awaited config load could both subscribe,
overwriting one unsub handle — a callback leak until HA restart. Refresh
is serialized with an asyncio.Lock and teardown flags the recorder closed
first, so a refresh parked on its await can never resubscribe afterwards.

Regressions cover map_index 0/'0'/''/selected_map cross-checks, one
source feeding two floor markers across a map switch, pair-list refresh
with a deduplicated entity set, and two overlapping refreshes leaving
exactly one live subscription (zero after teardown). On the v1.54.0
recorder 11 of these tests fail.
2026-07-31 13:07:27 +03:00
Matysh 69c5a4c41d Vacuum first-use path: materialize the marker, count rectangle rooms, fix the toasts
Audit HP-1540-01 (High): an auto-discovered vacuum has no config marker
until the device dialog is saved once, yet the live-position section was
already interactive. setVac, _vacSaveMatrix and auto-calibration all did
cfg.markers.find(...) and silently bailed out — while the auto-calibration
toast still claimed success. Every vacuum edit now materializes a minimal
marker (same id/binding the dialog Save would produce), _vacSaveMatrix
reports whether the write landed, and success toasts are gated on it.

HP-1540-04: the auto-calibration room matcher accepted only polygon rooms
and told users their room names did not match. It goes through the shared
roomPoly() now, so legacy x/y/w/h rectangles count like everywhere else.

HP-1540-06: the no-rooms/no-match/rough-fit toasts pointed at the removed
point calibration; they now point at the fit panel that shipped instead,
and docs/VACUUM.md Setup UX describes the actual UI. Also extracted
vacMapIdFromAttrs as the explicit frontend half of the map-id contract
(backend half lands with HP-1540-02).

Regressions: demo/smoke_vacuum_firstuse.mjs starts from cfg.markers=[]
(the fixture gap the audit called out) with rectangle plan rooms and a
zero map_index, and fails 11 checks on the v1.54.0 bundle; i18n unit test
asserts no point/точк wording in either language.
2026-07-31 13:07:16 +03:00
Matysh 77327c07d4 TESTING-DEMO.md: every checklist line answered with a stand recipe or an honest 'not here'
Manual testers kept asking which parts of docs/TESTING.md the public stand
can actually exercise. The answer used to live in nobody's head: the stand
was missing a vacuum, any LQI at all, toggleable leak/smoke alarms, an
hvac_action marker, and its automations/scripts/scenes YAML was never
!included - so the tap-run marker pointed at a script that did not exist.
With those gaps closed on the stand, this doc maps each checklist item to a
concrete click path on demo.houseplan.tech, and openly lists what only
local setups or real hardware can verify (broken stores, HACS flows,
non-admin users, anything that must outlive the hourly reset).
2026-07-31 12:49:02 +03:00
Matysh 18b8589438 Scripted demo-stand vacuum: the checklist needs a robot no hardware can provide
The public stand cannot run any Tier-A map integration (no radios, no
robots), which left the whole vacuum section of docs/TESTING.md untestable
outside the owner's home. demo_robot fakes exactly the surface the card
consumes: a Tasshack-shaped map sensor (dict vacuum_position, rooms named
after the plan rooms so auto-calibration has something to match, flipped Y
so the mirror default is meaningful) and a vacuum that drives a serpentine
route through every room in ~3.5 minutes and docks itself.
2026-07-31 12:48:53 +03:00
Matysh 9870b2fb98 A booting HA must not split the cleanup run
At startup the vacuum entity reads unavailable; the recorder took that
for 'stopped' and ended the open run, so every HA restart mid-cleanup
rotated the trail into previous and began a fresh one (observed live:
a 21-point run became previous and restarted at 5). Unavailable and
unknown now mean 'no verdict'.
2026-07-31 12:09:31 +03:00
Matysh 7de7cfb8c4 v1.54.0 2026-07-31 12:07:50 +03:00
Matysh 18ac9b459f Trail recorder reads object-style positions — the real-world fix
Caught live on the owner's X50 mid-cleanup with temporary logging: the
recorder saw every camera state change and rejected every one, because
server-side Tasshack keeps vacuum_position as a Point OBJECT — it only
becomes a dict when serialised to the frontend, which is why the card
adapter (and MCP inspection) always saw a dict and the stub test
faithfully reproduced the same wrong assumption. getattr fallback added,
regression test pinned, diagnostic logging removed (setup line kept at
INFO).
2026-07-31 12:04:33 +03:00
Matysh 4e355dcbd2 The stub loader no longer poisons the HA harness
test_trail_recorder injected fake homeassistant modules into sys.modules
at import time; every pytest_homeassistant_custom_component fixture in
the same session then failed with 'module homeassistant has no attribute
util'. The stubs now live inside a snapshot that is restored in a finally
block.
2026-07-31 11:54:01 +03:00
Matysh f9b612a389 v1.54.0: live robot vacuums
The plan shows the robot at work: the marker stays at its dock while a
puck drives the plan, calibration is one click or a drag-and-stretch
overlay, and the path is recorded server-side (current + previous run)
with never/cleaning/always display modes. Also: glow is the default
fill for new spaces, and the run-target search renders again.
2026-07-31 11:50:31 +03:00
Matysh d27864e90e docs: bring the whole set up to the shipped vacuum feature
TESTING gets a manual checklist matching the current contracts (modes,
teleport-on-view-change, tip glued to the icon, fit panel, multi-floor);
ARCHITECTURE gains trails.py and the trail/get command; VACUUM records
the display modes and what P1 actually shipped; README/PRODUCT/ROADMAP/
STATUS mention the feature.
2026-07-31 11:48:29 +03:00
Matysh f3bc3278e5 Trail recorder: seed a run already in progress, and test the wiring
Only TrailBook was covered; the HA-facing half — subscription callback,
attribute dialects, map-id resolution, run end on docking — had no test
at all. It does now, against a stubbed hass, which is also where the
missing behaviour showed up: recording started at the NEXT state change,
so an HA restart (or finishing calibration) mid-cleanup dropped the
opening seconds of the path. Sampling is factored out and runs once per
source on setup and on every refresh.
2026-07-31 11:47:31 +03:00
Matysh 75524d9d85 Trail modes + the tip grows glued to the icon
«Показывать путь робота» is a three-way choice now: never / while
cleaning (the default — the line hides the moment the run ends) /
always (the only mode that also shows the faded previous run).
trail_mode rides next to the legacy bool, which still maps in.

The last segment no longer pops in when the next telemetry point
arrives: a rAF sampler drags a tip line's endpoint to the puck's
animated centre every frame, so the path visually pours out strictly
from under the icon. The sampler runs only while pucks exist and stops
itself.
2026-07-31 11:30:17 +03:00
Matysh 089c5ef462 websocket_api: import DOMAIN — the trail-recorder refresh crashed config/set
Validate / hacs (push) Failing after 7s
Validate / hassfest (push) Failing after 11s
Validate / frontend (push) Successful in 1m39s
Validate / smoke (push) Failing after 29s
Validate / backend (push) Failing after 4m42s
Caught only by the CI HA harness; the pure suite never exercises the
import. NameError inside ws_config_set turned every save into
unknown_error.
2026-07-31 11:11:56 +03:00
Matysh 8dbc7db0b2 trail recorder: hass.data[DOMAIN] via setdefault
The CI harness sets the entry up without async_setup having created the
domain dict; the KeyError failed every WS test downstream.
2026-07-31 11:07:38 +03:00
Matysh 23daa28cf4 Server-side trails: the current run and one previous
The integration now records the path itself (trails.py): it watches the
source entity's state changes, so recording needs no open card, has no
multi-tab write races, and every screen sees the same line — reloads
included, which retires the localStorage snapshot after one day of
life. Stored per marker: the current run plus exactly one previous
(owner call — users want cleaned-vs-uncleaned at a glance). The
previous run renders at 40% opacity; the current one still trims its
live tail so it never outruns the puck. Runs rotate on start or map
switch, points cap at 2000 with decimation, store writes debounce 10 s,
and houseplan_trail_updated pushes live cards. TrailBook is pure under
5 backend tests; the WS command degrades silently on older backends.
2026-07-31 11:03:47 +03:00
Matysh d063453670 The trail survives a page reload
The self-recorded points now snapshot into localStorage per marker
(raw robot coords, so recalibration does not invalidate them). Restore
is gated: fresher than the linger window, same map, and never into a
run that started after the snapshot ended — the old trail must not
leak into a new cleanup. The smoke simulates the reload by wiping the
runtime map and asserts both the restore and the new-run discard.
2026-07-31 10:56:10 +03:00
Matysh f83577afa7 The trail is half as thick (owner call): casing 2.25, core 0.9 2026-07-31 10:53:19 +03:00
Matysh eddc8b41db The fit overlay is actually touchable
The devlayer is pointer-events: none and every child opts back in; the
overlay never did, so every real click fell through to the plan while
the smoke's synthetic dispatch — which skips hit-testing — kept
passing. The overlay now opts in, the corner handles grew to finger
size, and the smoke performs REAL elementFromPoint hit-tests through
the shadow root so an untouchable overlay can never pass again.
2026-07-31 10:45:36 +03:00
Matysh 1eabfeeee8 The fit panel replaces the three-point wizard
Calibration is now a direct-manipulation overlay: the robot's rooms as
a dashed translucent ghost over the plan, dragged into place and
stretched by four corner handles (uniform scale about the opposite
corner). Quarter-turn and mirror buttons re-anchor about the ghost
centre; mirror defaults on because every robot map seen so far flips Y
versus the screen — measured on the owner's X50. Everything folds into
the same stored 6-number matrix, and legacy matrices reopen in the
panel with rotation snapped to a quarter. The park-the-robot-three-
times wizard is deleted outright: it was the most fragile part of the
feature (owner: «плохо работает»). fitMatrix/fitFromMatrix/initialFit/
reanchorFit are pure and unit-tested; the smoke drives the panel end to
end — drag, corner-stretch, rotate, save, puck on the new matrix.
2026-07-31 10:38:49 +03:00
Matysh 11186371a9 The puck teleports on view changes; the trail never outruns it
Two owner reports. One: zoom, space switch or a tab return animated the
puck's left/top through the viewport change — it looked like the robot
driving across the whole plan. The view signature now forces the jump
class for that render, and a visibilitychange listener covers returning
to the browser tab. Two: a trail segment appeared the moment new
telemetry arrived, ahead of the still-gliding icon — the self-recorded
trail now lags exactly one point behind (the previous target is what
the puck has just reached), and an integration path is trimmed of its
live tail while moving.
2026-07-31 10:31:53 +03:00
Matysh 692bb91e57 Trail casing: dark halo + light core
The accent line vanished on same-hue fills. Blend modes (difference/
exclusion) all keep a blind luminance where the stroke disappears and
composite expensively on old kiosk WebViews, so the trail now uses the
cartographers' trick instead: a neutral dark halo under a light core —
one of the two always contrasts with whatever is underneath. Verified
over glow fill (dark rooms + light pools) in one screenshot.
2026-07-31 09:59:27 +03:00
Matysh 893ccff94f Centre the puck glyph
ha-icon inside the puck lacked the .dev centering recipe (flex +
line-height: 0), so the glyph sat on its text baseline and floated
around the circle. The smoke now measures the glyph centre against the
puck centre to sub-pixel tolerance.
2026-07-31 09:50:52 +03:00
Matysh d2faa070b5 No heading arrow on the puck (owner call)
The wedge looked like clutter at badge size; the trail already shows
where the robot is going. Smoke asserts its absence now.
2026-07-31 09:46:46 +03:00
Matysh f5c56d2f8e The puck is the base badge, round and 20% smaller
Owner's wording: «иконка похожа на иконку базы, только круглая и чуть
меньше» — same plate colors and shadow as a regular device badge
(var(--hp-bg)/--hp-line/--hp-txt), circle, 0.8 of the device size. The
smoke now compares the puck against a NEUTRAL badge computed-style for
plate parity — the robot's own base is yellow while cleaning and would
never match.
2026-07-31 09:43:56 +03:00
Matysh 0142d37bec Vacuum adapter vs a live Dreame X50 Master
Checked against the real robot at the dacha: room centres arrive as
plain x/y next to the bbox, and the active-map name (selected_map,
'Первый этаж'/'Второй этаж') lives on the VACUUM entity, not on the
camera — without reading it both floors would silently share one
calibration matrix. Parser and card resolver adjusted; the captured
attribute shape is now a unit fixture.
2026-07-31 09:28:20 +03:00
Matysh 40abbccbf0 Live robot vacuums, P1 (docs/VACUUM.md)
The base marker never moves — it is the dock. While the robot cleans, a
round pulsing puck (no badge plate) drives the plan over an affine
transform solved from vacuum-map coordinates: auto-calibration matches
the robot's room list against plan rooms by name, and a three-point
wizard covers integrations without room data. The trail rides the
integration's own path when offered (it predates the card being opened)
and a self-recorded thinned buffer otherwise, lingering ten minutes
after docking. Adapters read the Map Extractor / Tasshack / Valetudo
attribute dialects through one tolerant parser. Display only — no
commands, per the owner's decision.

vacuum.ts is pure logic under 8 new unit tests; the marker schema grew
an optional vacuum block (56 backend tests); smoke_vacuum drives 19
browser asserts including the wizard end to end.
2026-07-31 09:19:56 +03:00
Matysh 831e694f83 docs: the live-vacuum spec, approved scope
The base marker never moves — it is the dock. A separate round puck
(no badge plate, soft pulse) drives the plan while cleaning and
dissolves into the base on docking. All three Tier-A adapters and the
trail ship in P1; commands are out entirely.
2026-07-31 09:05:38 +03:00
Matysh b350893448 merge main (README cherry-picks) 2026-07-30 21:24:07 +03:00
Matysh fc8b6f85a4 README: a live-demo badge in the shield row
Validate / hacs (push) Failing after 7s
Validate / hassfest (push) Failing after 6s
Validate / frontend (push) Successful in 1m33s
Validate / backend (push) Failing after 5m52s
Validate / smoke (push) Successful in 4m33s
2026-07-30 21:24:04 +03:00
Matysh 6fc60e3a50 README: the live demo stand, right under the fold
demo.houseplan.tech with demo/demo — a real HA anyone can break, it heals
itself hourly. Placed above the feature list: 'try it now' converts better
than any bullet.
2026-07-30 21:24:04 +03:00