Commit Graph
369 Commits
Author SHA1 Message Date
Matysh ade8daab16 Icons are measured by the same plan the frame is
Audit dev@2c947f4, DEV-2C947-03 (P2). Three rooms in the core plus one dragged
90 canvases out: the frame rejected the stray exactly as §4.1 promises, and
then a perfectly ordinary marker on the main plan came out 90.89x too big and
covered the house. `contentFrame` voted; `iconUnit` did not — it took
`boxOf(every room)`, so the distance to the stray the frame had just thrown
away lived on in the numerator of `iconCqw`.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Smokes: smoke_warm_remount (fails pre-fix: veil + hidden plan on re-mount;
resize invalidation stays cold), smoke_ws_resilience (fails pre-fix:
_serverCfg cleared after 8 tries, no revalidation after recovery); the
preloader smokes now reset the warm memo — they simulate a COLD first open.
2026-08-03 15:36:32 +03:00
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
v1.56.0
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
v1.55.3
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 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
Codex 21786ed5a1 fix: first-open boot veil — no residual jump under HA panels (HP-1552)
In normal (non-kiosk) mode the stage is calc(100dvh - _hdrH), and _hdrH is
measured from HA's chrome, which finishes loading AFTER the card's first
paint — late panels nudged the height and the freshly painted plan visibly
jumped (kiosk is a flat 100dvh, hence 'perfect in kiosk').

Fix, per the owner's sketch:
- until the stage height reads the same twice in a row (200 ms cadence) or
  a hard ~600 ms cap, the plan stays hidden (.hpboot) and is revealed only
  after a refit — not a single frame paints at a stale height;
- that window is covered by a quiet product-style preloader: dark veil,
  small pulsing outline house (the card's own mdi:home-city outline,
  inline SVG), no text, 0.15 s opacity fade-out;
- prefers-reduced-motion gets a static house; kiosk never shows the veil;
  first open of the card instance only — floor/mode switches are untouched.

smoke_preloader simulates an HA panel landing at t=300 ms and asserts the
veil, zero plan-visible frames at a non-final height, the kiosk opt-out and
the reduced-motion variant; serve.mjs starts every smoke after the reveal,
where the user starts.
2026-08-02 10:49:15 +03:00
Codex 6414873fb0 feat: background color around the plan — global + per-space (HP-1554)
New setting 'background around the plan' (#rrggbb):
- global: config.settings.bg_color, edited in the gear dialog with a live
  preview and a 'theme default' reset (empty = keep the stylesheet default);
- per-space override in the space dialog next to the room colors, empty =
  inherit the general setting (the show_lqi/fill_mode pattern);
- applied to the stage in view and kiosk modes (editors keep their own
  canvas) and to the static houseplan-space-card;
- backend validates both keys with the same strict #rrggbb match as
  room_color; garbage strings are rejected (test_bg_color_setting).
smoke_bg_color covers apply/override/inherit/reset, dialog previews, the
wire format of the cleared override, kiosk and the static card.
2026-08-02 10:48:51 +03:00
Codex 941d2709fd fix: round caps/joins on background-editor lines (HP-1553)
Lines drawn in the background (decor) editor showed notched 'teeth' where
two segments met at an angle. Decor <line> elements — both the saved render
and the live drawing preview — now carry stroke-linecap/linejoin=round, so
every line end reads as a circle of the stroke width and joints are smooth.
smoke_decor asserts the round attributes on the draft and the saved lines.
2026-08-02 10:47: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
v1.55.1
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 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 v1.54.3 2026-08-01 12:40:39 +03:00
Matysh b508c20ad3 v1.54.3 2026-08-01 12:37:24 +03:00