mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-05 22:29:05 +00:00
162c8a3beff3ffffca6c78dc3dd417396fd0df88
281
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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
|
||
|
|
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. |
||
|
|
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). |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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 |
||
|
|
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. |
||
|
|
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. |
||
|
|
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) |
||
|
|
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). |
||
|
|
f3bc3278e5 |
Trail recorder: seed a run already in progress, and test the wiring
Only TrailBook was covered; the HA-facing half — subscription callback, attribute dialects, map-id resolution, run end on docking — had no test at all. It does now, against a stubbed hass, which is also where the missing behaviour showed up: recording started at the NEXT state change, so an HA restart (or finishing calibration) mid-cleanup dropped the opening seconds of the path. Sampling is factored out and runs once per source on setup and on every refresh. |
||
|
|
d2faa070b5 |
No heading arrow on the puck (owner call)
The wedge looked like clutter at badge size; the trail already shows where the robot is going. Smoke asserts its absence now. |
||
|
|
40abbccbf0 |
Live robot vacuums, P1 (docs/VACUUM.md)
The base marker never moves — it is the dock. While the robot cleans, a round pulsing puck (no badge plate) drives the plan over an affine transform solved from vacuum-map coordinates: auto-calibration matches the robot's room list against plan rooms by name, and a three-point wizard covers integrations without room data. The trail rides the integration's own path when offered (it predates the card being opened) and a self-recorded thinned buffer otherwise, lingering ten minutes after docking. Adapters read the Map Extractor / Tasshack / Valetudo attribute dialects through one tolerant parser. Display only — no commands, per the owner's decision. vacuum.ts is pure logic under 8 new unit tests; the marker schema grew an optional vacuum block (56 backend tests); smoke_vacuum drives 19 browser asserts including the wizard end to end. |
||
|
|
3e976562ff |
tap action: run an automation, a script or a scene — with a confirm guard
Owner's spec (2026-07-29), agreed points: one 'Run' action covering the three runnable domains of HA (a script is the idiomatic 'action' — with automations alone people would build trigger-less dummies); the confirm checkbox guards BOTH toggle and run; covers and valves join the card-wide toggle so curtains work natively. - marker.tap_action gains 'run'; marker.tap_target (schema-bounded to automation./script./scene. ids); marker.tap_confirm. - the dialog: a searchable picker over the three domains (friendly name + kind), save refuses a run action without a target, a vanished target gets a warning hint; the checkbox shows for any actionable tap (explicit or effective-default toggle). - the tap: automation.trigger / script.turn_on / scene.turn_on, started/ error toasts; with confirm on — our own dialog (not window.confirm, it must work on a wall tablet), Esc/backdrop/Cancel = no call. The guard covers the controls-toggle path too. - 'run' is explicit-only by construction: it needs a per-marker target, so it can never arrive as a card-wide default. - covers: the old test pinned 'garage stays shut' — that intent survives as COVER_GUARDED_CLASSES (garage/door/gate stay out of the CARD-WIDE toggle; an explicit per-device toggle remains the owner's conscious choice). Locks/alarms stay forbidden everywhere, run included is not affected — we do not inspect automation contents, same trust as HA's own Run button. Tests: unit resolveTapAction/runServiceFor + cover guard, backend schema parity picks 'run' automatically + tap_target bounds, smoke_tap_run with 11 assertions (picker, search, save guard, confirm cancel/ok, per-domain services, missing target). smoke_tap_ctx: 4 options now. Inventory: 148 / 52 / 43 / 72. |
||
|
|
08a9cc7d27 |
v1.52.2: the v1.52.1 review (HP-1521-01, HP-1521-02)
- HP-1521-01: the plan-mode assertion looked for ANY .dev.on and the lit kettle satisfied it — a false positive hiding the very regression it guards. It targets d_lamp now (kettle asserted separately), and the mutation check proves it: reverting the v1.52.1 gate fails the smoke. - HP-1521-02: the checklist entry and the _stateClass comment still said 'yellow in every fill mode'. Both now state the two-part contract: the state predicate is the glow-pool condition; the renderer keeps the badge only where the spot is not drawn. |
||
|
|
d7c20ff6a5 |
v1.52.1: the v1.52.0 review (HP-1520-01/-02, HP-1513-01)
- HP-1520-01: the glow layer is hidden in the plan editor, but the yellow suppression still fired there — a lit lamp had NEITHER indicator. The gate now equals the layer's visibility (disp.fill === 'glow' && !this._markup), so the badge returns exactly where the spot is absent. - HP-1513-01: the static card ignored marker.size and marker.angle — the same stored marker looked different on the two cards. It mirrors --dev-scale and the icon rotation now; geometry only, no live dressing. - HP-1520-02: TESTING/UX-MODES still demanded the removed RGB icon tint, and the lightC comment described the old use. All three brought to the v1.52.0 contract. smoke_light_badges grew the editor-mode vectors; new smoke_size_angle_parity asserts the x3 ratio inside each card (absolute px are incomparable across containers) and rotation on both. Inventory: 147 / 51 / 43 / 71. |
||
|
|
60d6167ecd |
v1.52.0: one look for light sources, whatever flipped them
Owner's rule, agreed 2026-07-29 after a field report (a lamp turned off by tap looked different from one turned off by the wall switch): - a lamp's colour lives ONLY in its glow. The v1.27 RGB tint of the icon, border and shadow is deleted — that tint was the fork: with colour data the lamp rendered dark-with-coloured-icon, without it plain yellow, and the same lamp crossed the fork depending on how it was switched. - in glow fill the indicator IS the spot: a source's badge stays standard, lit or not (litLightEntity — the exact condition that casts the spot — gates the suppression, so a lit socket keeps its yellow even in glow). - in every other fill a lit source is plain yellow, like a heating TRV. - icon morphing stays everywhere; the ripple colour still falls back to the light colour (both explicitly confirmed by the owner). smoke_light_badges covers the whole table (8 assertions); smoke_rgb_alarm re-asserted: no rgb class, lit lamp yellow, ripple fallback keeps the colour. README colour language updated. Inventory: 147 / 51 / 43 / 70. |
||
|
|
1310c84a32 |
device icon: the size multiplier scales the glyph, not just the badge
User report via the owner: change a marker's size and the icon stays at its default size — a big empty box around a small glyph. The badge, ripple and value badges all derive from --dev-size (base size x per-device multiplier), but --mdc-icon-size was pinned to the BASE --icon-size, so the multiplier never reached the glyph. One calc argument: --dev-size. New smoke_icon_scale: at size 3 the glyph grows with the badge and keeps the 0.62 proportion. Inventory: 147 / 51 / 43 / 69. |
||
|
|
5615afa33b |
v1.51.2: the v1.51.1 review (HP-1511-01, HP-1511-02)
- HP-1511-01: defaultPositions ran over different rosters — the full card reserves grid cells for hidden devices, the static card compacted them away, so an undragged marker sat in different spots on the two cards. The static card feeds spaceDevs (hidden included) to the shared grid and renders devs (visible) — exactly the split HP-1510-01 introduced for LQI. - HP-1511-02: a hidden ripple-display marker rendered as an icon-less inactive pulse. A ghost drops the display dressing entirely: ripple presentation off, noicon off, base icon on, whatever marker.display says. smoke_hidden_flag: the weak 'has icon OR noicon' assertion is gone — every ghost must carry a base icon; new autoGridParity vector (vb-coordinate comparison, the cards render in different view systems) and a ripple-ghost vector. The demo stub got a connection.subscribeEvents so the static card's module-level config cache can be invalidated between in-test cards. |
||
|
|
fc95a1f09b |
v1.51.1: the v1.51.0 review (HP-1510-01, HP-1510-02)
- HP-1510-01: the static card's visibility filter had quietly become its aggregation filter — the same room showed different Zigbee health on the two cards. Two lists now: aggregation (room LQI, temp) sees every device of the space including hidden ones, rendering sees visible only. Light fill keeps excluding hidden through areaLights itself, so the contract stays exactly as agreed: hidden counts toward signal, casts no light. - HP-1510-02: the ghost suppressed state colors but still painted value text, temperature, humidity, the LQI badge and the state-morphed icon. All live numbers are gated on d.hidden now — a ghost is the base icon and the name, nothing else. smoke_hidden_flag grew both audit vectors: the 42 kW value-display ghost renders no numbers, and a room whose only Zigbee devices are hidden paints the identical lqi fill on the full and the static card. |
||
|
|
09f4dc4115 |
room settings button: room-centred, icon-sized, zooms with the plan
Owner's spec: the button is no longer glued to the room NAME (which the user can drag anywhere) — it anchors to the geometric centre of the ROOM (interiorPoint for polygons, so an L-shaped room gets a point actually inside it), one button-height below centre so it never covers the name, whose default position is that same centre. Height is 70% of a device icon box, and since --icon-size already rescales with the view, the button zooms with the plan instead of keeping a constant screen size (verified: x2.2 zoom -> x2.20 button). The small metric rows under the room name (temperature, humidity, signal, lights) now render in the plan editor too — they used to be view-mode only. smoke_room_cards updated: plainInPlan now asserts metrics ARE present in the editor (the old assertion pinned the old behaviour), plus gearDetached. |
||
|
|
2551b4ea0e |
hidden devices: blue ghosts, no live-state paint
A hidden device and an unavailable one both rendered as translucent dark — indistinguishable at a glance, and a lit hidden lamp still glowed yellow through the ghost (owner's report). A ghost is CONFIGURATION, not status: - blue dashed ghost (accent-tinted, color-mix with an rgba fallback for old WebViews), clearly apart from the grey 'unavailable' icon; - no state classes, no RGB tint, no alarm pulse, no active ripple on hidden devices — the only thing a ghost says is 'I am hidden, click to unhide'. smoke_hidden_flag grew two assertions: the ghost carries no state classes and is blue/dashed. |
||
|
|
694e1e9a3b |
filtering: hiding is an explicit per-device flag (docs/FILTERING.md)
Agreed with the owner: whether a device is on the plan is a CHECKBOX
('Hide device from plan', every kind incl. virtual), not a runtime
algorithm. The old filter survives only as the SEEDER of those flags.
- marker.hidden is the flag; hidden devices are BUILT (room LQI counts
them — owner's decision) but rendered only in the device editor with
'Show hidden' on, ghosted. They cast no glow and no light fill: an
invisible device casts no visible light (owner's decision).
- seedHiddenBindings(): non-physical devices (excluded domains, Group,
scene, bridge, myheat children, grouped lamps) in bound areas WITHOUT a
marker. The editing client materialises them into hidden:true stub
markers, sets settings.filter_seeded, retires settings.show_all, and
strips fresh-hidden ids from the red-dot list. Unticking the checkbox
keeps a hidden:false marker — the seeder never revisits a marked device,
so the user's decision is final. New non-physical devices hide silently;
physical ones keep the red-dot flow.
- legacy configs (no filter_seeded) keep the OLD behaviour verbatim —
runtime filter, shared show_all, hidden-means-gone — until an editing
client materialises them, so a read-only tablet never sees a half-state.
- 'Show all' is renamed 'Show hidden' and is LOCAL to the tab; the shared
settings.show_all retires with the runtime filter.
- 'Remove from plan' disappears for auto/entity devices (the checkbox is
the way); a virtual device's Delete remains a real deletion.
- docs/FILTERING.md is the source of truth for the mechanism.
Tests: seeder/seeded/legacy/lights units (146), smoke_hidden_flag with 12
assertions (68 smokes). Inventory: 146 / 51 / 43 / 68.
|
||
|
|
996a7442ec |
yellow means working: one principle for the glowing icon
Research on the owner's install (verified live): the radiator heads that glowed yellow were the ones with SCALE PROTECTION on, and the ones actually heating stayed dark. Cause: the primary-entity search ran domains outside tiers, and switch outranks climate — so a vendor's config switch (anti scaling, child lock) became the device's primary, driving the color, the icon morphing and tap-toggle alike. The principle now: yellow = the device is doing its main job RIGHT NOW. - primaryEntity: tiers outside, domains inside — a service entity never beats the visible main function; a hidden lamp still beats a visible config switch (grouped lights), and a plug's switch stays primary. - climate joins the state table: yellow by hvac_action (heating/cooling/ drying/fan) — 'which radiators are heating', not 'enabled for winter'; the coarser state is only a fallback when the integration reports no action. - one truth for light: litLightEntity() is asked by BOTH the glow pool and the icon color, in every fill mode — the pool and the icon can no longer disagree. The 'is a light source' flag keeps counting controls first. - README (en+ru): the color table, in words. Tests: TRV + plug primary units, litLightEntity unit, smoke_yellow_principle (heating yellow / idle dark / off dark / fallback / lit-light wins / forced source). Inventory: 142 / 51 / 43 / 67. |
||
|
|
098a147f87 |
editor: gestures work on touch — pinch zooms, a moving finger pans
The stage pointerdown bailed out whenever _markup was set, so in the plan editor no pointer was ever tracked: no pinch, no pan — on a phone the plan could not be zoomed or moved at all (owner's report). But drawing is CLICK-based, so the two coexist: a finger that moves pans (and suppresses the synthesized click so the release feeds no tool), two fingers pinch, a clean tap still draws. Pointers that start on labels, handles, markers or buttons stay out — those run their own drags. The tool preview keeps following the tracked finger. New smoke: smoke_editor_gestures (pinch in plan mode, pan without drawing, tap still draws). Inventory: 140 / 51 / 43 / 66. |
||
|
|
9f36b39379 |
v1.50.4: one model builder for both cards (HP-1503-01)
The full card's _buildModel() was a hand-copied twin of spaceModels(), and the twin missed the legacy-store fallbacks v1.50.3 gave the shared builder — the same broken store rendered recovered in the static card and as viewBox='0 0 0 0' with negative-width rects in the main one. The divergence of the duplicates IS the bug, so the duplicate is gone: the full card calls spaceModels() and only swaps the raw plan url back in (its signing flow must not bake a signed url into a memoized model — 2026-07-27). New smoke_legacy_geometry runs the audit's exact vector (zero viewport + negative rect) through both models and both DOM trees and asserts parity: full-canvas fallback, normalised rectangle, no negative SVG attributes. Inventory: 140 / 51 / 43 / 65. |
||
|
|
df65d25348 |
v1.50.3: sizes are not coordinates (HP-1502-01)
The ±4 bound from v1.50.2 measured view_box[2:4] and room w/h with the same ruler as coordinates, so zero and negative sizes still passed the schema — and viewBox='0 0 0 0' draws nothing on every client, with the static card computing aspect-ratio: 0 / 0 on top. _EXTENT now requires strictly positive sizes with a floor of one thousandth of the canvas (1 render unit — far below any real room, keeps the maths finite); coordinates stay allowed to be negative, a crop origin legitimately sits past the edge. Defensive layer for stores that already hold a broken viewport: spaceModels falls back to the whole canvas — both cards render from that model, so both get the fallback — and a legacy rectangle with a negative size reads as the same rectangle drawn from the other corner. Also: the room settings button is the bottom row of the room card, and the room name renders in the same spot in view and plan modes (owner's request, committed earlier on dev). |
||
|
|
5392dadeaa |
v1.50.2: the v1.50.1 review (HP-1501-01, HP-1501-02)
- HP-1501-01: v1.50.1 bounded layout positions and left room rectangles, polygon vertices, view_box and opening coordinates on bare _finite — the same absurd-magnitude failure, one schema over. _GEOM (±4) covers them all now, opening angles get ±360. And because a store may already hold such a vertex from before the door existed, contentBounds applies its canvas envelope to room geometry exactly as it does to device positions: the point renders where it is, the frame ignores it, a space of nothing but absurd points falls back to the whole canvas. - HP-1501-02: a repair matching zero positions answered ok/moved:0 and replaced the one-deep backup with an empty one — a typo right after repairing the wrong space destroyed the promised way back. Empty match is nothing_to_repair now: no write, no revision bump, backup intact. Old test fixtures carried view_box [0,0,100,100] from the render-unit days; they now use the normalised box the product actually stores. |
||
|
|
a8ce6020f4 |
v1.50.1: the v1.50.0 review (HP-1500-01..03)
- HP-1500-02: the stage budget was the absolute document coordinate, so any tall dashboard content before the card was billed as header and the stage collapsed to 0px. Measure our own chrome relative to the card plus a bounded (<=120px) allowance for what the viewport keeps above us; re-measure on window resize, remove the listener in disconnectedCallback. - HP-1500-03, both layers: contentBounds opens a near-zero axis (< ~an icon) up to a 200-unit floor and ignores extra points outside a canvas envelope (-25%..125%) for FRAMING purposes only; the server bounds layout coordinates to +-4 — any finite float used to pass, and one 1e100 hid the plan from every viewer. A thin real room keeps its tight frame; the gate sensor past the edge still stretches it. - HP-1500-01: no automatic double-transform — a correct layout and a stranded one are indistinguishable, and guessing wrong corrupts good data. Explicit admin command houseplan/geometry/repair: dry_run previews, the backup rides the same store write, undo restores, and routine layout writes now preserve unrelated store keys instead of eating the backup. Tests: contentBounds guards (unit), layout coordinate bounds + repair lifecycle (harness), card-below-content smoke. Inventory: 139 / 49 / 42 / 64. |
||
|
|
8c5d5ba5c5 |
v1.50.0: the v1.49.0 review (HP-1490-01..04) and the owner's zoom batch
Owner's batch (committed to dev earlier today, released here):
- devices count as content for the default zoom;
- the editor no longer shifts the plan — the stage measures its own top
instead of assuming 118px of header;
- zoom goes out to 0.4x, centred.
From the review:
- HP-1490-01: the square-canvas migration wrote two stores in sequence, and
the first write deleted the aspects the second needed — a crash between
them stranded the layout in the old coordinates with nothing able to
finish it. The intent {space: old aspect} is durable now: saved to the
layout store before anything moves, cleared by the same write that stores
the migrated layout, each half idempotent behind its own trigger. The
update event fires only after both halves are on disk. Proven at the exact
crash boundary by a harness test that fails the layout write once.
- HP-1490-02: check_quota and the file write were two executor jobs with
nothing between them, so N parallel uploads all measured the store before
any of them wrote. One job under a dedicated upload_lock now — narrower
than write_lock on purpose, a directory scan must not stall config saves.
A failed write reserves nothing.
- HP-1490-03: the content frame fed pan, zoom, clamp AND pointer maths, so
the editors were boxed into yesterday's drawing. Edit modes measure from
the full square; mode switches refit rather than carry a view clamped
against the wrong base.
- HP-1490-04: Save could outrun the proportions read and ship the previous
file's ratio. Picking a plan clears it immediately; Save awaits the
bounded read and stores 'unknown' over a lie.
- §5: package-lock version synced, duplicated comment removed.
New: smoke_audit_1490.mjs, migration crash-recovery pure + harness tests,
parallel-quota harness test. Inventory: 138 unit / 49 pure / 40 harness / 64
smokes.
|
||
|
|
f5e6c0318d |
v1.49.0: content-fit zoom, swipe animation, wording, and the v1.47.0 review
Owner's batch: - zoom now opens on what is DRAWN (rooms + 5% margin) for spaces with no background image; with one the image is the plan and still fits whole. A small plan on the square canvas no longer opens as a speck. - swiping between spaces, and the kiosk carousel, slide sideways; honours prefers-reduced-motion. - the room settings button reads 'Room settings' and lightens on hover. - 'curation' is filtering everywhere: UI strings, docs, code. Checked the yard while I was there: its drawing sits off-centre because it was drawn that way — before the migration x spanned 0.12..0.54 with 0.12 and 0.46 of margin. The migration added 0.1465 on each side, symmetrically. Content-fit zoom makes it moot anyway. From the v1.47.0 review: - HP-1470-02: the picker let you delete the plan you had just selected — it is not in the stored config yet, so the server rightly called it free, and the save then stored a url with no file. The button is disabled, and since two clients can do this in either order, config/set now verifies every internal plan url against the disk under the write lock and answers . External and legacy urls are not ours to police. - HP-1470-01: growth is bounded at the door rather than by deleting old files — that mistake cost real plans twice. check_quota refuses an upload that would push the store past 256 MB / 200 plans (1 GB / 1000 attachments) or leave less than 512 MB free. The plan list is capped at 60 newest with a total, and thumbnails load lazily. - HP-1470-03: picking a saved plan waited for nothing and stored a fallback ratio when the signature had not arrived — a square plan came out stretched. It waits for the signature, binds the result to the dialog that asked, and the dialog preview is signed too. - report §5: the last lifecycle comments still described age-based collection. Not released yet — the owner asked for a release once the batch is done. |
||
|
|
94b298962a |
v1.48.0: the canvas is always square, the plan is centred inside it
A space carried an aspect ratio, and coordinates were normalised against it: x by the width, y by the height. Every geometric question therefore depended on a per-space number, and picking a canvas orientation was a decision the user had no reason to make. The render space is now NORM_W x NORM_W and a plan image is fitted into it by its OWN ratio, centred — wide plans get margins above and below, tall ones at the sides. Migration (geometry_migration.py, pure and unit-tested) runs once at setup under the write lock. Nothing about a drawing changes: the old box is padded out to a square and every coordinate re-expressed against it — rooms as rects and polygons, openings and their lengths, decor, view_box, and the marker positions in the separate layout store. In render units it is a uniform scale plus an offset, so angles and proportions are exact. cell_cm is scaled for tall plans, because the grid pitch is a fraction of the width: without it a wall would measure less than it does. is now dropped by the schema rather than accepted — a stale tab sending it would be sending coordinates from the old normalisation too, and honouring the field would not make them right. The demo fixture was migrated with the same transform, so the smokes exercise the new geometry rather than a square-native fake; six of them needed their render-space helpers updated and one its click coordinates. Not released — dev only, per the owner's instruction. |
||
|
|
85491d0fea |
v1.47.0: pick a plan you already uploaded
Closes both findings from the v1.46.6 review with one feature, because they are the same gap seen from two sides. HP-1466-02: a detached plan stayed on disk and could not be re-attached from the card — the old url is nowhere in the config, and the backend test 'proved' reattach by remembering it in a Python variable. HP-1466-01: files kept forever with no way to see or remove them is not a policy, it is accumulation. New: houseplan/plans/list (name, url, size, modified, and which spaces use it) and houseplan/plans/delete, which refuses while a space still references the file — the stored configuration answers that, not the client. In the space dialog, 'Already uploaded' shows the list with thumbnails; one click attaches, reading the aspect from the image as an upload does; the trash button is the only way a plan file is ever deleted. That also bounds the disk without any timer, which is the part every automatic attempt got wrong: v1.46.4 deleted detached plans, v1.46.5 raced the retry that was about to reference an upload. The user decides, and can now see what they are deciding about. Docs: comments in plans.py and websocket_api.py still described the age-based collection v1.46.6 removed (report §6); ARCHITECTURE gained the two new routes and an explanation of why the listing is what makes 'never delete' livable. |
||
|
|
9868f1035f |
v1.46.6: the detach promise, actually kept this time
v1.46.4 and v1.46.5 documented that detaching a plan leaves the image on disk,
added guards for it, and shipped tests. The guards were never reached: they sit
behind 'not superseded', and a file that left the configuration was called
superseded. From old_refs - new_refs alone, replacing a plan, detaching one and
deleting its space are indistinguishable — so all three deleted the file, at the
moment of the save, before any scheduled pass ever ran.
Every test I wrote for this called collect_plans(d, cfg, cfg): old config equal
to new, i.e. only the scheduled pass. The transition that mattered was never
exercised. Codex reproduced it in four lines.
Classification is by owner now:
space in both, plan A -> plan B : the user picked another image -> removed
space in both, plan -> none : detached -> kept
space gone : kept (the image was imported; a thirty-day
grace measured from file age is meaningless
anyway, it was uploaded months ago)
space has a plan, other file : rejected upload -> 1 h
Attachments follow the same shape: dropped from a device that still exists ->
removed (a trash button promises nothing); device gone -> kept; staging folder
-> 1 h.
Tests: a matrix per rule in the pure module, and — the part that was missing —
test_detaching_a_plan_keeps_the_file, which goes through real config/set calls:
attach, detach, assert the file is there, restart, assert again, re-attach,
replace, assert the replaced one is gone, delete the space, assert the plan
survives. Also strengthened the sweep/save race test to assert the save actually
succeeded and the config points at the specific expected file, per the report.
|
||
|
|
33e71ca96c |
v1.46.5: audit of every automatic deletion
Owner's decision after the incident: a detached plan is never deleted, at any age. v1.46.4 gave it a month; this makes it permanent and, more importantly, writes the reasoning where the next change will trip over it — docs/SCOPE.md now carries the standing rule. The component may delete a file only when a user action says so. 'Nothing points at this any more' is not such an action, because the two errors are not symmetrical: wasted disk is visible, cheap and reversible; a deleted file is none of those. Went through every other automatic deletion with the same question. One more was wrong: houseplan/files/cleanup rmtree'd whatever folder the card named. A partial migration leaves urls pointing into it — files/migrate deliberately does not rewrite the ones it could not confirm — so those were live links to files being deleted; and a wrong or stale id from any client destroyed a live device's manuals. The server now reads the stored config under its lock and removes only what nothing references, keeping the rest and saying so. Also: a plan of a DELETED space now waits thirty days rather than an hour. Deleting a space is deliberate; an hour is a short window to notice a misclick. The rest came out clean: layout/delete and marker/room/space removal are all confirm-guarded user actions, upload temporaries are never user-visible, and dropping legacy 'segments' is a documented migration. |
||
|
|
ef270d11b7 |
v1.46.4: detached plans were being collected as garbage — data loss
Deployed v1.46.3 to my own instance, restarted, and the startup sweep deleted
both floor plans: config/houseplan/plans/ went from f1.svg + f2.png to empty.
The backup is a SecureTar, so they are gone.
The rule was wrong, not the code. v1.46.0 introduced collection that treats
'nothing references this right now' as abandoned and gives it an hour. But
detaching a plan — switching a space to 'draw' — is a normal, reversible action,
and the editor's own comment says the file stays on disk. Those two plans had
been detached for weeks; every pass since v1.46.0 was entitled to remove them,
and the one that finally ran did.
New rule, one for every path:
* superseded by a commit (was in the old revision, is not in the new) — goes
immediately; that is the one thing a commit knows for certain;
* belongs to a space or marker that still exists — never collected, at any
age, because unreferenced is not abandoned;
* a per-dialog staging folder (up_*) — one hour, unchanged: by construction it
only ever holds an upload from a dialog that was never saved;
* anything else — thirty days.
The flag I added an hour ago is gone with it: two rules for the same
question is how this happened. Tests updated to the new grace, plus two that pin
the distinction directly.
I am sorry about the files.
|
||
|
|
75279308c1 |
v1.46.3: re-check of v1.46.2 — HP-1462-01
The startup sweep resolved its runtime data with get_data(hass), which lists only LOADED entries — during async_setup_entry the entry is still SETUP_IN_PROGRESS, so it always got None and degraded to removing streaming temporaries. The real collection was then 24 hours away, and an instance that restarts more often than that never ran it at all. It closes over the object created a few lines above instead; the callback is unregistered with the entry, so that matches the lifecycle. The test that was meant to prove the previous fix passed for the wrong reason: it seeded the strays BEFORE config/set, which collects too, so nothing was left for the restart to find. Now seeded after the save, plus two more — one firing the interval callback on its own, and one running a reload and a save concurrently to assert the accepted config never references a file the sweep removed (they share the write lock; this pins that they must). Docs: CHANGELOG.md + CHANGELOG.ru.md + TESTING.md + STATUS.md. |
||
|
|
379fb68db2 |
v1.46.2: re-check of v1.46.1 — HP-1461-01, -02
HP-1461-01: collection was tied to config/set, which is the right scope for what a commit supersedes but leaves a file nobody references with no future write to notice it — cancel a dialog after the upload finished, drop the connection just after, or call the upload API directly. The daily sweep added in v1.46.1 only removed streaming temporaries, so the documented 'a cancelled attachment is collected an hour later' did not hold on an instance nobody edits. The scheduled pass now loads the stored config under the same write_lock a commit uses and runs collect_attachments/collect_plans with it as BOTH sides: nothing counts as superseded, referenced files are preserved, aged unreferenced ones go. Doing it under the lock keeps it from deciding on a snapshot a commit is about to replace. HP-1461-02: _reloadLayoutOnly captured the dirty set AFTER flushing the pending write, and the flush empties it first — so during a real drag (where a write is already scheduled) the snapshot was empty and the server's older position was merged over the user's move. The snapshot is taken before the flush, by value, and a _sentPos map now holds positions that are sent but unacknowledged, which closes the same window for a write that was already in flight. Tests: the upload test now cancels the request task for real (the previous one claimed to and only walked error paths); smoke_layout_sync schedules a genuine debounced write and delays it — verified failing on a v1.46.1 build with exactly the reported symptom; a new backend test reloads the entry and asserts the scheduled sweep takes an aged cancelled attachment and an orphan plan while keeping everything the config still references. Docs: CHANGELOG.md + CHANGELOG.ru.md + ARCHITECTURE.md + TESTING.md + STATUS.md. |