mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-01 04:09:17 +00:00
46f20fe4e1c95849928f5b17bedafa8a62f3d21e
387
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
46f20fe4e1 |
v1.58.0
|
||
|
|
1108b2bc14 | v1.58.0: backdrop transform, paper by rooms, align-to-grid fixes | ||
|
|
2fd46493db | v1.58.0-beta.1: backdrop transform, paper by rooms, decor tool fix v1.58.0-beta.1 | ||
|
|
df233905c5 |
DEV-B58: one bound, one grid — the canvas border and the snap contract
Two owner reports after v1.57.0, both about coordinates.
=== DEV-B58-01: nothing stops at the old canvas border any more ===
The infinite canvas freed the FRAME and the DRAWING; it did not free the
drag handlers, and both the owner and a user hit that within a day:
"названия комнат и устройства не перетаскиваются дальше старых границ
холста".
Two clamps survived v1.57.0, and the second is the worse one:
* `_pointerMove` (device marker) clamped into `_baseVb()` — the CONTENT
FRAME, with a 0.8 % inset. A marker could never be dragged past the
outline of what was already drawn, so a plan could not be extended by
putting a device where the next room was going to be.
* `_labelMove` (room label) clamped into `_spaceModel().vb` — the
space's STORED `view_box`, which is `[0,0,1,1]` for every plan the
card has ever written. Literally the old square: a room drawn at 2.5
had a name that could not reach its own room.
And one asymmetry: `_decorCommitDraft` and the decor text anchor had no
guard at all, while `_decorMoveUpdate` did — a draft could be born
outside the range the mover then refused to leave.
The rule now is one line: an editor gesture has exactly ONE bound,
`+/-CANVAS_LIMIT`, the same number `validation.py` enforces, and it is a
garbage limit rather than a frame. `clampCanvasR` / `clampCanvasN` in
space-geometry.ts are the only two functions allowed to impose it, and
`_snap()` applies it on the way out, so every gesture that goes through
the snap is bounded by construction.
demo/smoke_drag_bounds.mjs starts from an ORDINARY plan (rooms inside
0..1, so the old clamps really were in the way), drags a marker, a room
name, a decor shape and an opening far past the old square, checks each
arrives, is stored, survives a rebuild and takes the frame with it — and
that a wild drag still parks at exactly 5000 rather than 1e12. Seven of
its eleven facts fail by name on
|
||
|
|
85263d520f |
Rebuild the tracked bundles for the 2026-08-04 batch
dist, demo/srv/assets and custom_components/houseplan/frontend are the same bytes as a fresh production build of this tree: round room border joins, the decor draft's live size badge and the normal-axis sun fade. |
||
|
|
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. |
||
|
|
1dc03e1fb1 |
The background editor measures the line you are drawing
Owner 2026-08-04: «в редакторе подложки у линий писать длину, как при рисовании комнат в редакторе плана». The decor draft now feeds the SAME badge a wall gets while a plan is drawn — _fmtLen (segmentCm over the space's cell_cm), the HA unit system, the green .on45 highlight, the .measurelabel chrome. The only difference is where it sits: a wall badge follows the cursor because the cursor is the wall's free end, while a decor line is pulled out by both ends at once, so its badge rides the MIDDLE of the segment (owner: «плашка на середине линии»). Rectangles and ovals have no length but they do have a size, and the same two calls answer it: «W × H» of the bounding box. A draft that has not moved yet shows nothing — a «0» badge is noise, not a measurement. smoke_decor now draws a line and asserts the badge's exact text against the geometry (12 cells x cell_cm 5 = 0.60 m, 0°), its position at the midpoint, the 45° highlight, the oblique 3-4-5 case, the W x H box and that it is gone after the release. |
||
|
|
d21d532f10 |
No room border ends in a tooth: walls join round
Owner 2026-08-04: «углы границ комнат всё ещё с зубцами (фиксили для декоративных линий, они теперь заканчиваются полукружьями, надо сделать так же для границ комнат)». A default miter join on a sharp room corner shoots a spike far past the two walls that meet there, and flips to a flat bevel once the miter limit clips it — both read as a tooth. Room borders now join ROUND: * .room (polygon / evenodd path / rect) — one rule, so the plan view, the Plan editor and the static space-card all get it; * .room-outline — a room with open boundaries draws its walls as separate M..L subpaths, so its corners are stroke ENDS: round caps close them the same way a round join closes a contour; * .seg — the contour being drawn in the editor already had round caps, now it states the join too. smoke_render_parity checks both renderers and the trimmed outline; demo/shot_room_joins.mjs is the before/after still (a 45° apex). |
||
|
|
50ba443492 |
v1.57.0
|
||
|
|
aa01eaef01 | v1.57.0 | ||
|
|
93b60c5b8e |
Editor grid dots are a hint: mute both levels (0.35 / 0.5)
The adaptive grid drew at full strength — on the white paper of a drawn plan the dots argued with the walls instead of guiding them. Both levels are dimmed, the CAD hierarchy kept: fine dots 0.75 -> 0.35, coarse nodes 1 -> 0.5, so the accents still carry the scale reference on the dark scene background. Editors only; View draws no grid (smoke_grid_fade). |
||
|
|
eb17396006 |
Sunlight has hard sides again and fades only along the ray
Owner, 2026-08-04, on yesterday's attempt: «с лучами солнца ты сделал фигню —
не надо размывать их боковые грани».
They are right.
|
||
|
|
0af50a74ae |
A kiosk pan stays a pan all the way to the release
The gesture is classified once, on the first movement past 8 px (`_panLock`), but `_stagePointerUp` ignored that decision and asked `swipeTarget()` again from the raw start→end vector — audit DEV-1DA1-02. So a CURVED gesture could be both: a small vertical lead-in locked `pan`, the plan started following the finger, the trajectory then swept far sideways, and lifting the finger landed the user on another storey. On a wall tablet that is the worst kind of surprise — you watch the plan drag along and end up on a different floor. The lock is now final: with `_panLock === 'pan'` the floor never changes, whatever the overall vector looks like, and only a gesture locked as `swipe` may reach `swipeTarget()`. A motionless tap locks nothing, so the double-tap zoom reset is untouched. Regression: demo/smoke_kiosk_pan_lock.mjs — the auditor's curved pan (both directions and a long diagonal), the mirror case of a swipe that bends vertically (it never pans, and if it stops qualifying it simply does nothing), plus the straight swipe / straight pan / double tap / zoomed-in cases. docs/CANVAS.md §5 and docs/TESTING.md updated. |
||
|
|
232c4807fd |
Nothing paints over a marker that says it is a curtain
An explicit «Открыть/закрыть» marker is the strongest statement the card has about what a marker IS, so its cover now decides the plate BEFORE the bound `controls` and before a lit light of the same device — audit DEV-1DA1-01. Until now the cover came third, and the owner's contract «у штор не должно быть жёлтой подложки НИКОГДА» had two holes: a mixed device (a lamp that also ships a blind) told «Открыть/закрыть» went yellow off its own lit light, and a curtain marker with a bound wall switch went yellow off `controls`. The early `return 'on'` never reached the cover branch, so the travelling curtain lost its breathing ring as well — and in glow fill, where the renderer strips `on` from a shining source, it was left with no indicator at all, while the tap still drove the cover. Everything else keeps the old precedence: the same mixed device WITHOUT the explicit action is yellow again, a wall switch still mirrors its controls, and a «cover» marker whose device carries no cover.* at all falls back to its primary. docs/FILTERING.md «What a marker SHOWS» is renumbered accordingly. Regression: demo/smoke_cover_plate_precedence.mjs (the auditor's two markers, every cover state, class AND resolved plate colour). |
||
|
|
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.
|
||
|
|
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. |
||
|
|
1da1aba625 |
Curtains never wear a coloured plate
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
|
||
|
|
de53d530fa |
A curtain marker shows the cover it opens
Owner, 2026-08-04, on his own curtains: «нет ни дышащего кольца во время хода, ни рамки "открыто", ни морфинга иконки». Same device and the same cause as the tap fix two commits before this branch: his Aqara «Roller shade driver E1» ships the `cover.*` hidden by the integration and a visible `switch.*_reverse_direction`, so `primaryEntity` picks the service switch — and `_stateClass`, the state-morphed icon and the ripple all read `d.primary`. The plan reported the state of the reverse-direction option: a yellow «включено» plate whenever it was on, and nothing at all while the curtain actually travelled. `coverEntityOf` already knew where the cover was; the indication now asks it through one helper, `_coverIndicator` — the device's cover when the marker's tap action is explicitly «Открыть/закрыть», null otherwise — and `_actEntity` (`_coverIndicator || primary`) is what the tap path and the marker presentation now share. Same entity offered in the dialog, driven by the tap and shown on the plan. THE RULE, and why it is the least surprising one (docs/FILTERING.md «What a marker SHOWS»): picking «Открыть/закрыть» is the only statement the card has that means «this marker IS the curtain», and the dialog offers it exactly for the devices that own a cover. Hanging the indication on «the device has a cover somewhere» would have re-decided, silently, what a mixed marker is — a lamp that also owns a blind would stop showing the lamp. The precedence in `_stateClass` is unchanged above it: bound controls first, then a lit light (the glow spot and the badge may never disagree), then the cover, then the primary — so even with the action chosen a shining lamp keeps its yellow. The price is that a curtain left on «Инфо-карточка» still speaks for its primary; that is one click in the dialog, and it is the honest reading of what the marker has been told it is. smoke_cover_not_primary.mjs grows an indication section on the owner's device: closed / open / opening / closing give no class, `open`, `covermove`, `covermove`, the icon morphs `mdi:curtains-closed` <-> `mdi:curtains`, and reverse-direction ON never lights the marker again. The rule's boundary is asserted from both sides (take the action away — the primary speaks again; give it back — the cover does), a lit lamp with a travelling cover keeps its yellow and its own icon, and the auditor's own DEV-2C947-04 shape (both entities VISIBLE) is pinned for the tap as well. Eight checks are red on the parent commit. |
||
|
|
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.
|
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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.
|
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
53e1c7163d |
General settings: About block — card version, GitHub and Telegram links
- 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 |
||
|
|
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. |
||
|
|
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. |
||
|
|
9be44dab1e |
v1.56.0
|
||
|
|
ba88782ce1 | v1.56.0 | ||
|
|
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
|
||
|
|
b70153769a |
OPENING DRAG: shoulders measure the ONE room edge under the opening, no collinear merge (owner 2026-08-03)
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. |
||
|
|
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 |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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 |
||
|
|
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. |
||
|
|
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. |
||
|
|
1e0295a370 |
SUN: adjust pinned dialog inventories + screenshot script
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. |
||
|
|
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) |
||
|
|
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. |
||
|
|
a8d40e4d99 |
v1.55.3
|
||
|
|
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. |