Commit Graph
86 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 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
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 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
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 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 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 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 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 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 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 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 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 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 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 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 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 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 3e976562ff tap action: run an automation, a script or a scene — with a confirm guard
Owner's spec (2026-07-29), agreed points: one 'Run' action covering the
three runnable domains of HA (a script is the idiomatic 'action' — with
automations alone people would build trigger-less dummies); the confirm
checkbox guards BOTH toggle and run; covers and valves join the card-wide
toggle so curtains work natively.

- marker.tap_action gains 'run'; marker.tap_target (schema-bounded to
  automation./script./scene. ids); marker.tap_confirm.
- the dialog: a searchable picker over the three domains (friendly name +
  kind), save refuses a run action without a target, a vanished target gets
  a warning hint; the checkbox shows for any actionable tap (explicit or
  effective-default toggle).
- the tap: automation.trigger / script.turn_on / scene.turn_on, started/
  error toasts; with confirm on — our own dialog (not window.confirm, it
  must work on a wall tablet), Esc/backdrop/Cancel = no call. The guard
  covers the controls-toggle path too.
- 'run' is explicit-only by construction: it needs a per-marker target, so
  it can never arrive as a card-wide default.
- covers: the old test pinned 'garage stays shut' — that intent survives as
  COVER_GUARDED_CLASSES (garage/door/gate stay out of the CARD-WIDE toggle;
  an explicit per-device toggle remains the owner's conscious choice).
  Locks/alarms stay forbidden everywhere, run included is not affected —
  we do not inspect automation contents, same trust as HA's own Run button.

Tests: unit resolveTapAction/runServiceFor + cover guard, backend schema
parity picks 'run' automatically + tap_target bounds, smoke_tap_run with 11
assertions (picker, search, save guard, confirm cancel/ok, per-domain
services, missing target). smoke_tap_ctx: 4 options now.
Inventory: 148 / 52 / 43 / 72.
2026-07-30 01:26:30 +03:00
Matysh fa15598e67 room settings button: centroid pull breaks the plateau tie
The inscribed-circle criterion is FLAT along the long axis of any elongated
room — every midline point fits the same circle — and a plain argmax took
the first plateau sample: left of centre on the owner's kitchen-living
room, above centre in the sauna. The score now subtracts a soft pull
toward the area centroid (shoelace-weighted): on the plateau the nearest-
to-centroid point wins, while real clearance differences still dominate,
so the point never wanders into a thinner limb of an L.

Verified on a replica of the owner's floor: kitchen slab centre within a
few units, sauna dead-centre both axes. Units: wide and tall rectangles
centre on both axes; the L keeps to its slab near the centroid x.
2026-07-29 14:00:52 +03:00
Matysh 9eec856d50 room settings button: the VISUAL centre for L-shaped rooms
The owner's kitchen-living room is L-shaped, and interiorPoint() only
promises 'somewhere inside' — the button sat near the seam, visibly
off-centre. poleOfInaccessibility() (largest inscribed circle, grid search
plus one refinement pass) puts it in the middle of the widest open space:
the slab of an L, the exact centre of a rectangle or square. Cached per
poly array in a WeakMap — the memoized model keeps the arrays stable, so
the search runs once per geometry, not per render.

Unit: square -> centre; thick-slab L -> mid-slab, always inside.
2026-07-29 13:55:03 +03:00
Matysh 694e1e9a3b filtering: hiding is an explicit per-device flag (docs/FILTERING.md)
Agreed with the owner: whether a device is on the plan is a CHECKBOX
('Hide device from plan', every kind incl. virtual), not a runtime
algorithm. The old filter survives only as the SEEDER of those flags.

- marker.hidden is the flag; hidden devices are BUILT (room LQI counts
  them — owner's decision) but rendered only in the device editor with
  'Show hidden' on, ghosted. They cast no glow and no light fill: an
  invisible device casts no visible light (owner's decision).
- seedHiddenBindings(): non-physical devices (excluded domains, Group,
  scene, bridge, myheat children, grouped lamps) in bound areas WITHOUT a
  marker. The editing client materialises them into hidden:true stub
  markers, sets settings.filter_seeded, retires settings.show_all, and
  strips fresh-hidden ids from the red-dot list. Unticking the checkbox
  keeps a hidden:false marker — the seeder never revisits a marked device,
  so the user's decision is final. New non-physical devices hide silently;
  physical ones keep the red-dot flow.
- legacy configs (no filter_seeded) keep the OLD behaviour verbatim —
  runtime filter, shared show_all, hidden-means-gone — until an editing
  client materialises them, so a read-only tablet never sees a half-state.
- 'Show all' is renamed 'Show hidden' and is LOCAL to the tab; the shared
  settings.show_all retires with the runtime filter.
- 'Remove from plan' disappears for auto/entity devices (the checkbox is
  the way); a virtual device's Delete remains a real deletion.
- docs/FILTERING.md is the source of truth for the mechanism.

Tests: seeder/seeded/legacy/lights units (146), smoke_hidden_flag with 12
assertions (68 smokes). Inventory: 146 / 51 / 43 / 68.
2026-07-29 11:35:35 +03:00
Matysh 996a7442ec yellow means working: one principle for the glowing icon
Validate / hacs (push) Failing after 6s
Validate / hassfest (push) Failing after 7s
Validate / frontend (push) Failing after 1m33s
Validate / smoke (push) Skipped
Validate / backend (push) Failing after 6m17s
Research on the owner's install (verified live): the radiator heads that
glowed yellow were the ones with SCALE PROTECTION on, and the ones actually
heating stayed dark. Cause: the primary-entity search ran domains outside
tiers, and switch outranks climate — so a vendor's config switch (anti
scaling, child lock) became the device's primary, driving the color, the
icon morphing and tap-toggle alike.

The principle now: yellow = the device is doing its main job RIGHT NOW.

- primaryEntity: tiers outside, domains inside — a service entity never
  beats the visible main function; a hidden lamp still beats a visible
  config switch (grouped lights), and a plug's switch stays primary.
- climate joins the state table: yellow by hvac_action (heating/cooling/
  drying/fan) — 'which radiators are heating', not 'enabled for winter';
  the coarser state is only a fallback when the integration reports no
  action.
- one truth for light: litLightEntity() is asked by BOTH the glow pool and
  the icon color, in every fill mode — the pool and the icon can no longer
  disagree. The 'is a light source' flag keeps counting controls first.
- README (en+ru): the color table, in words.

Tests: TRV + plug primary units, litLightEntity unit, smoke_yellow_principle
(heating yellow / idle dark / off dark / fallback / lit-light wins / forced
source). Inventory: 142 / 51 / 43 / 67.
2026-07-29 11:07:34 +03:00
Matysh df65d25348 v1.50.3: sizes are not coordinates (HP-1502-01)
The ±4 bound from v1.50.2 measured view_box[2:4] and room w/h with the same
ruler as coordinates, so zero and negative sizes still passed the schema —
and viewBox='0 0 0 0' draws nothing on every client, with the static card
computing aspect-ratio: 0 / 0 on top. _EXTENT now requires strictly positive
sizes with a floor of one thousandth of the canvas (1 render unit — far
below any real room, keeps the maths finite); coordinates stay allowed to be
negative, a crop origin legitimately sits past the edge.

Defensive layer for stores that already hold a broken viewport: spaceModels
falls back to the whole canvas — both cards render from that model, so both
get the fallback — and a legacy rectangle with a negative size reads as the
same rectangle drawn from the other corner.

Also: the room settings button is the bottom row of the room card, and the
room name renders in the same spot in view and plan modes (owner's request,
committed earlier on dev).
2026-07-29 08:16:12 +03:00
Matysh 5392dadeaa v1.50.2: the v1.50.1 review (HP-1501-01, HP-1501-02)
- HP-1501-01: v1.50.1 bounded layout positions and left room rectangles,
  polygon vertices, view_box and opening coordinates on bare _finite — the
  same absurd-magnitude failure, one schema over. _GEOM (±4) covers them all
  now, opening angles get ±360. And because a store may already hold such a
  vertex from before the door existed, contentBounds applies its canvas
  envelope to room geometry exactly as it does to device positions: the
  point renders where it is, the frame ignores it, a space of nothing but
  absurd points falls back to the whole canvas.
- HP-1501-02: a repair matching zero positions answered ok/moved:0 and
  replaced the one-deep backup with an empty one — a typo right after
  repairing the wrong space destroyed the promised way back. Empty match is
  nothing_to_repair now: no write, no revision bump, backup intact.

Old test fixtures carried view_box [0,0,100,100] from the render-unit days;
they now use the normalised box the product actually stores.
2026-07-29 07:30:22 +03:00
Matysh a8ce6020f4 v1.50.1: the v1.50.0 review (HP-1500-01..03)
- HP-1500-02: the stage budget was the absolute document coordinate, so any
  tall dashboard content before the card was billed as header and the stage
  collapsed to 0px. Measure our own chrome relative to the card plus a
  bounded (<=120px) allowance for what the viewport keeps above us; re-measure
  on window resize, remove the listener in disconnectedCallback.
- HP-1500-03, both layers: contentBounds opens a near-zero axis (< ~an icon)
  up to a 200-unit floor and ignores extra points outside a canvas envelope
  (-25%..125%) for FRAMING purposes only; the server bounds layout coordinates
  to +-4 — any finite float used to pass, and one 1e100 hid the plan from
  every viewer. A thin real room keeps its tight frame; the gate sensor past
  the edge still stretches it.
- HP-1500-01: no automatic double-transform — a correct layout and a stranded
  one are indistinguishable, and guessing wrong corrupts good data. Explicit
  admin command houseplan/geometry/repair: dry_run previews, the backup rides
  the same store write, undo restores, and routine layout writes now preserve
  unrelated store keys instead of eating the backup.

Tests: contentBounds guards (unit), layout coordinate bounds + repair
lifecycle (harness), card-below-content smoke. Inventory: 139 / 49 / 42 / 64.
2026-07-29 01:39:10 +03:00
Matysh 6c90e03427 zoom-out, device-aware content frame, and the editor no longer shifts the plan
Three owner reports:

- The content frame behind the default zoom only looked at rooms, and devices
  are allowed to stand outside every one of them — a gate sensor by the fence
  was left outside the opening view. contentBounds() takes the marker positions
  now, and they count as content even on a space with no rooms at all.

- Entering an editor 'strangely shifted' the plan. The stage height was
  100dvh minus a hard-coded 118px of header, and the editor header is ~90px
  taller than that: the whole scene slid down by the difference and its bottom
  went below the fold. The card now measures where the stage actually starts
  (HA toolbar, margins and our header included) and gives it the rest of the
  viewport; the measurement is deferred a frame because setting state straight
  from a ResizeObserver callback trips the 'undelivered notifications' error,
  which smoke_dialog_zombie rightly counts as a page error.

- Zoom stopped at the base fit. The floor is 0.4× now, and zoomed out the
  clamp centres the content instead of pinning it to the top-left corner —
  with the view larger than the plan there is nothing to clamp against.

New smoke: smoke_zoom_out.mjs (editor keeps the stage inside the viewport,
0.4 floor, centring, the device-stretched frame); a unit test for the extra
points of contentBounds. Not released — the next release goes out after the
v1.49.0 audit.
2026-07-28 23:40:39 +03:00
Matysh f5e6c0318d v1.49.0: content-fit zoom, swipe animation, wording, and the v1.47.0 review
Owner's batch:
- zoom now opens on what is DRAWN (rooms + 5% margin) for spaces with no
  background image; with one the image is the plan and still fits whole. A small
  plan on the square canvas no longer opens as a speck.
- swiping between spaces, and the kiosk carousel, slide sideways; honours
  prefers-reduced-motion.
- the room settings button reads 'Room settings' and lightens on hover.
- 'curation' is filtering everywhere: UI strings, docs, code.

Checked the yard while I was there: its drawing sits off-centre because it was
drawn that way — before the migration x spanned 0.12..0.54 with 0.12 and 0.46 of
margin. The migration added 0.1465 on each side, symmetrically. Content-fit zoom
makes it moot anyway.

From the v1.47.0 review:
- HP-1470-02: the picker let you delete the plan you had just selected — it is
  not in the stored config yet, so the server rightly called it free, and the
  save then stored a url with no file. The button is disabled, and since two
  clients can do this in either order, config/set now verifies every internal
  plan url against the disk under the write lock and answers .
  External and legacy urls are not ours to police.
- HP-1470-01: growth is bounded at the door rather than by deleting old files —
  that mistake cost real plans twice. check_quota refuses an upload that would
  push the store past 256 MB / 200 plans (1 GB / 1000 attachments) or leave less
  than 512 MB free. The plan list is capped at 60 newest with a total, and
  thumbnails load lazily.
- HP-1470-03: picking a saved plan waited for nothing and stored a fallback
  ratio when the signature had not arrived — a square plan came out stretched.
  It waits for the signature, binds the result to the dialog that asked, and the
  dialog preview is signed too.
- report §5: the last lifecycle comments still described age-based collection.

Not released yet — the owner asked for a release once the batch is done.
2026-07-28 22:44:09 +03:00
Matysh 94b298962a v1.48.0: the canvas is always square, the plan is centred inside it
A space carried an aspect ratio, and coordinates were normalised against it: x
by the width, y by the height. Every geometric question therefore depended on a
per-space number, and picking a canvas orientation was a decision the user had
no reason to make. The render space is now NORM_W x NORM_W and a plan image is
fitted into it by its OWN ratio, centred — wide plans get margins above and
below, tall ones at the sides.

Migration (geometry_migration.py, pure and unit-tested) runs once at setup under
the write lock. Nothing about a drawing changes: the old box is padded out to a
square and every coordinate re-expressed against it — rooms as rects and
polygons, openings and their lengths, decor, view_box, and the marker positions
in the separate layout store. In render units it is a uniform scale plus an
offset, so angles and proportions are exact. cell_cm is scaled for tall plans,
because the grid pitch is a fraction of the width: without it a wall would
measure less than it does.

 is now dropped by the schema rather than accepted — a stale tab sending
it would be sending coordinates from the old normalisation too, and honouring
the field would not make them right.

The demo fixture was migrated with the same transform, so the smokes exercise
the new geometry rather than a square-native fake; six of them needed their
render-space helpers updated and one its click coordinates.

Not released — dev only, per the owner's instruction.
2026-07-28 22:20:59 +03:00
Matysh a49b5e6d2e fix: collision names must survive the sanitiser the content view applies
unique_filename produced 'manual (2).pdf'; HouseplanContentView sanitises the
name in the REQUEST too, turning ' (2)' into '_2_', so the file was written and
then 404'd. The same pattern was already in files/migrate, so a rebind that hit
a name collision has been producing dead links. Both use the shared helper now,
with '-2', which round-trips sanitize_filename — asserted.
2026-07-28 16:16:07 +03:00
Matysh 96d387ff1d v1.45.4: review of v1.45.3 — R5-1, R5-2
Validate / hassfest (push) Failing after 49s
Validate / hacs (push) Failing after 52s
Validate / frontend (push) Successful in 1m43s
Validate / backend (push) Failing after 8m30s
Validate / smoke (push) Successful in 4m52s
R5-1: the backend signs each path independently and answers successfully with
whatever it managed, skipping (and logging) the rest. The card read any
successful call as 'the batch is done', cleared the backoff for every path in
it, then wrote only the urls that came back — so a path the backend kept
skipping was asked for again on every render, the exact amplification the
backoff was added to stop. A path now counts as signed only when the answer
carries a url for it; the others back off individually, keys that were not
requested are ignored, and onUpdate fires only when a new signature landed.

R5-2: docs/STATUS.md still described main as holding releases up to v1.40.1 and
quoted test counts several releases old, while the version line beside them was
kept current — a handoff reader got a wrong branch model and less coverage than
exists. Branch roles are now accurate, and the counts are gone rather than
corrected: scripts/inventory.mjs (npm run inventory) prints them from the tree,
so there is nothing left to drift.

Tests: three unit cases for empty/partial/foreign-key answers, verified to fail
against a v1.45.3 checkout; a backend test pinning the partial-success contract
by making async_sign_path raise for one path of two.
Docs: CHANGELOG.md + CHANGELOG.ru.md + TESTING.md + STATUS.md.
2026-07-28 08:49:11 +03:00
Matysh 2e2d353b04 v1.45.2: hardening from the v1.45.1 review — R4-1, R4-2
R4-1: collecting superseded plan files runs after the configuration is already
durable, but an error listing the directory propagated out of config/set. The
client saw a failure for a revision the server had committed, and its retry
came back as a conflict. collect_plans now reports 0 instead of raising, and
config/set logs and proceeds — the event fires, the revision is returned.

R4-2: the pending set was cleared when a batch went out, not when it came back,
so every render during an in-flight content/sign queued another request: six
calls where one was needed, and unbounded on a socket that is slow rather than
busy. Queued and in-flight are separate states now; a failure backs off (2 s
doubling to 60 s) instead of retrying on the next frame; an in-flight entry
expires after 15 s so a promise that never settles cannot wedge retries; a late
answer after dispose() no longer renders.

Tests: test/signing.test.mjs — eight cases with hand-settled promises, verified
against a v1.45.1 checkout where four of them fail (2 sign calls instead of 1,
no backoff, a late answer rendering after teardown). Backend: a broken
collector still yields a successful save whose revision the next CAS accepts.
Pure collector: a disappearing directory returns 0.
Docs: CHANGELOG.md + CHANGELOG.ru.md + ARCHITECTURE.md + TESTING.md + STATUS.md.
2026-07-28 00:13:45 +03:00
Matysh 5d2dbb1009 v1.45.0: external review of v1.44.8 — R2-1, R2-2, R2-3
R2-1 (high): plan replacement committed filesystem state before the config CAS.
The upload wrote the final name and unlinked the other extension, so a rejected
config write left the live plan already replaced — or the stored config
pointing at a deleted file. Uploads now go to <space>.<token>.<ext> and delete
nothing; houseplan/plan/cleanup runs only after the config write is accepted.
The '.' separator is load-bearing: a space id cannot contain one, so cleaning
'f1' can never reach the files of 'f1-attic'.

R2-2: the backend signs at most MAX_SIGN_PATHS (200) per request and ignores
the rest silently, while the card sent its whole cache in one call and trusted
any cached entry forever — past 200 attachments the later ones stopped being
refreshed and expired for good. Requests are chunked to the shared constant,
entries carry their issue time (aging urls keep rendering while a replacement
is fetched, expired ones are dropped), and the cache is pruned to urls the live
config still references.

R2-3: areaClimate() rescanned the whole registry per room and per measurement.
areaClimateMap() classifies once and returns Map<area,{temp,hum}>, memoized on
hass identity so fresh states are always observed. Smoke measurement: 133
registry scans per update with 44 rooms before, 2 after, flat in room count.

Also: smoke_ux_fixes wrote its screenshot to a hard-coded /tmp path and could
not run on Windows.

Tests: smoke_plan_upload_reject, smoke_sign_cap, smoke_climate_once (all fail
on v1.44.8), three backend tests for versioned plan names and cleanup scoping,
unit tests for chunk/referencedContentUrls and areaClimateMap.
Docs: CHANGELOG.md + CHANGELOG.ru.md + ARCHITECTURE.md + TESTING.md + STATUS.md.
2026-07-27 21:08:34 +03:00
Matysh ebeaa5c0c6 v1.44.6: room climate counts only air temperature
After v1.44.5 read the area registry instead of visible icons, every hidden
temperature entity in the area became a candidate, including ones measuring
something other than room air. Verified against a live 60-area install: a NAS
processor temperature, kettle water, a 90 C sauna heater and a virtual
better_thermostat all leaked into room averages.

- areaClimate(): skip entity_category (diagnostic/config), skip EXCLUDED_DOMAINS
  platforms, skip entity ids naming a non-air medium (water/coolant/flow_temp/
  return_temp/target/setpoint/chip/cpu/processor/board/device_temp/batter/
  freezer/fridge/oven/kettle/boiler).
- rules.ts: kettle/thermopot -> mdi:kettle, sauna/harvia -> mdi:hot-tub, so they
  no longer fall through to the generic thermometer rule.
- test: all four real false positives asserted out, one real sensor left.
- docs: CHANGELOG.md + CHANGELOG.ru.md + STATUS.md snapshot.
2026-07-27 14:38:50 +03:00
Matysh 715a93ec61 fix v1.44.5: room climate counts hidden sensors; drop the stale room tooltip
- areaClimate() walks the HA registry for the area instead of the list
  of VISIBLE icons: a thermometer hidden by curation or by the user was
  silently dropped from the room card, tooltip and temperature fill
  (field report). Curation still filters fridges/TRVs; the auto icon is
  used on purpose so a custom marker icon cannot change what a device
  measures; an explicit per-room source still wins
- room tooltip no longer says 'open the area' — room clicks were removed
  in v1.40.1 (the link icon does it)
- +1 unit test (120); both changelogs updated
2026-07-27 14:21:50 +03:00
Matysh ae9168f6ec fix v1.44.2: external review CR-1..CR-3
CR-1: the lock invariant is restated precisely (never by an accidental
tap; the door card's labeled button is the ONE sanctioned surface),
unlocking now confirms, and smoke_lock_invariant exercises all five
actuation paths (icon tap, controls[], card entities, _cardToggle,
opening card).

CR-2: attachment migration is transactional — the server COPIES files,
the config is committed with its revision check, and only then the old
folder is removed via the new houseplan/files/cleanup. A rejected save
no longer leaves the stored urls pointing at an emptied folder.

CR-3: migrate returns an exact {source: written} mapping; only confirmed
copies are rewritten, destination name collisions get a unique name
instead of silently linking a pre-existing file, and a failed migration
raises a toast instead of being swallowed.

+1 unit test (119), +1 backend test, +1 smoke (51 total); docs
same-commit
2026-07-27 12:58:27 +03:00
Matysh 49b0cb4e05 perf/fix v1.43.1: external audit P1 — render cost, drag threshold, geometry, backend
L1: memoized space model + open pairs (structural fingerprint key, epoch
bumped synchronously at mutation time, not inside the debounce); hoisted
per-room geometry out of the render loop; smoke asserts zero recomputation
across state pushes.

L4: openings get the 3 px drag threshold used by every other pipeline and
only write when the geometry actually changed — taps open the dialog again.

G2: interiorPoint() replaces the vertex mean, so island rooms inside
concave (U/L) parents are accepted and their evenodd holes render; traced
duplicates still are not containment.

G3: segKey rounds before ordering — one shared wall, one key.

B2: _check_write fails closed when the entry is unavailable.
B3: layout/set honours expected_rev and returns the new rev.
B4: config/set without expected_rev over a non-empty store logs a warning.
B5: coordinates reject NaN/Infinity; spaces/rooms/markers/decor/layout capped.

+2 unit tests (118), +2 backend tests (14), smoke_render_perf; docs
same-commit
2026-07-27 10:58:18 +03:00
Matysh 0fd0ba408d fix v1.43.0: external audit P0 — data loss, split geometry, auth, dialog zombies
L2 (silent data loss): debounce gains flush()/pending(); _reloadConfigOnly
flushes a pending write and defers while one is in flight; conflict path
forces; failed reload now toasts instead of an empty catch; teardown flushes.

G1 (split corruption): same-edge cuts carve the niche properly instead of
walking the outline twice; partition invariant (parts sum to the original)
rejects anything else; +1 unit test covering 5 niche shapes and both legacy
cut shapes.

B1 (unauthenticated content): plans and marker files move to
HouseplanContentView (/api/houseplan/content/..., requires_auth); only the
card bundle stays static; contentUrl() rewrites legacy URLs on read (no
storage migration); repairs.py accepts both prefixes; +1 unit test.

L3 (dialog zombies): all four save catch-blocks guard against a closed
dialog; the card no longer blanks when a save fails after Esc.

smokes: smoke_save_race, smoke_dialog_zombie; docs (TESTING/CHANGELOG/
ARCHITECTURE incl. the optimistic-UI note) same-commit
2026-07-27 10:44:58 +03:00
Matysh 19e19b5c5f feat v1.42.0: room settings — the third settings tier
- four-tier principle fixed in ARCHITECTURE: global > space > room >
  device, specific overrides general, unset inherits
- room.settings { fill_mode, temp_source, hum_source } with backend
  schema; pure roomFillModeOf + sourceValue (+2 test suites, 114)
- gear on room cards in the Plan editor opens Room settings: rename,
  re-area (current area included in the list), fill override (may opt
  out of glow darkness), explicit temp/hum source with a searchable
  device+entity dropdown; the same section in the creation dialog
- source feeds the room card, tooltip and temp fill; works for rooms
  without an HA area (user-feedback case #1)
- smoke_room_settings.mjs (10 checks); TESTING/CHANGELOG/ARCHITECTURE
  same-commit
2026-07-27 09:58:21 +03:00
Matysh 88dc0d1de7 fix v1.41.2: uploaded files survive marker rebinding
Validate / hacs (push) Failing after 5s
Validate / hassfest (push) Failing after 5s
Validate / frontend (push) Successful in 56s
Validate / backend (push) Failing after 6m31s
- new WS houseplan/files/migrate moves /files/<oldId>/ to the new id
  (admin-only, sanitized ids, merge-safe, removes the empty old dir)
- _saveMarker calls it and rewrites pdf urls (migratePdfUrls pure
  helper, +1 unit test, 112) when rebinding changes the id
- field incident: the sauna heater manuals pointed at an orphaned
  old-id folder that got cleaned up; data restored by hand on the
  home instance (official Harvia PDFs re-downloaded)
- TESTING/CHANGELOG same-commit
2026-07-26 17:42:28 +03:00
Matysh 1d6ca968e8 feat v1.41.0: kiosk mode for wall tablets and TVs
- kiosk: true — header hidden, editors hard-blocked, full-height stage;
  full View interactivity preserved (live states, glow, taps, locks)
- swipe between spaces at 1:1 (swipeTarget pure helper, wrap + dots
  indicator), pan wins while zoomed, double tap resets zoom
- cycle: N auto-carousel with 60 s pause after any touch (TV/burn-in)
- per-SCREEN size multipliers (icons x0.5-3, room-card font) in
  localStorage via a 3 s long-press popover; clampScale helper
- GUI editor fields, README wall-tablet recipe, +2 unit tests (111),
  smoke_kiosk.mjs (12 checks); TESTING/CHANGELOG same-commit
2026-07-23 21:12:58 +03:00
Matysh 82eed100b2 ux v1.40.2: default speaker icon for smart speakers (Alice et al.)
- rules split: mdi:soundbar only for soundbars; колонки/станции/yandex/
  alice/speaker -> mdi:speaker; +1 unit test (109)
2026-07-23 18:20:38 +03:00
Matysh df9e158efb feat v1.40.0: smart guides — alignment helper in every editor
- alignGuides/segmentAngle/is45 pure helpers (+2 unit tests, 108);
  per-context candidates (room vertices + path pts / other icons /
  decor endpoints+corners / other room cards), nearest per axis, max
  two guides, dashed accent lines with a source dot
- cursor badge shows length + angle, green on 45deg multiples
- indication only, no magnetism (owner's decision); guides live in
  plan/devices/decor, never in View
- smoke_align_guides.mjs (9 checks); TESTING/CHANGELOG same-commit
2026-07-23 17:38:33 +03:00