Plan-editor wall thickness (docs/WALL-THICKNESS.md) and keep the white drawing sheet under the grid in editors even when a backdrop image is loaded.
Co-authored-by: Cursor <cursoragent@cursor.com>
Third 1.59 pre-release: top-view furniture at real size, hide-decor /
hide-openings toggles, stable card-mod data-* hooks, HA value formatting,
editor polish, and the audit P0/P3 follow-ups. Wall thickness is spec-only.
Co-authored-by: Cursor <cursoragent@cursor.com>
Ship the unreleased 1.59 batch on dev: top-view furniture in the decor
layer, space toggles to hide decor/openings, stable card-mod data-*
hooks, HA entity value formatting, and the approved wall-thickness
spec (docs only — not implemented yet).
Co-authored-by: Cursor <cursoragent@cursor.com>
Add docs/AUDIT*.md covering competitive landscape (easy-floorplan 11→430★),
implementation quality, functional integrity, and P0–P3 recommendations.
Refresh PRODUCT.md competitor claim and point STATUS watchlist at the pack.
Co-authored-by: Matysh <Matysh@users.noreply.github.com>
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.
The v1.54.1 contract (first not-None value wins, zero is a value) covered
the source entity but not the card's fallback on the vacuum's own
selected_map: _vacMapId still used truthiness, so selected_map: 0 became
'default' on the frontend while trails.py resolve_map_id stored the run
under '0'. Calibration and server trails split across two keys and the
recorded run never rendered after reload.
The fallback is now the shared pure helper vacMapIdWithFallback (nullish
check), mirroring resolve_map_id. Cross-runtime regressions added for
selected_map = 0, '0' and '' on both sides; the frontend cases fail on the
old truthiness code.
Version 1.54.0 -> 1.54.1 in package.json, package-lock.json, manifest.json,
const.py and CARD_VERSION; rebuilt bundle in all three tracked copies
(dist/, demo/srv/assets/, custom_components/houseplan/frontend/).
Changelog entries (EN+RU) — one line per finding, HP-1540-01..06 — and
docs/STATUS.md bumped.
Suites on this exact tree: 161 frontend unit, 74 pure-backend, 74 browser
smokes — all green.
Manual testers kept asking which parts of docs/TESTING.md the public stand
can actually exercise. The answer used to live in nobody's head: the stand
was missing a vacuum, any LQI at all, toggleable leak/smoke alarms, an
hvac_action marker, and its automations/scripts/scenes YAML was never
!included - so the tap-run marker pointed at a script that did not exist.
With those gaps closed on the stand, this doc maps each checklist item to a
concrete click path on demo.houseplan.tech, and openly lists what only
local setups or real hardware can verify (broken stores, HACS flows,
non-admin users, anything that must outlive the hourly reset).
The plan shows the robot at work: the marker stays at its dock while a
puck drives the plan, calibration is one click or a drag-and-stretch
overlay, and the path is recorded server-side (current + previous run)
with never/cleaning/always display modes. Also: glow is the default
fill for new spaces, and the run-target search renders again.
TESTING gets a manual checklist matching the current contracts (modes,
teleport-on-view-change, tip glued to the icon, fit panel, multi-floor);
ARCHITECTURE gains trails.py and the trail/get command; VACUUM records
the display modes and what P1 actually shipped; README/PRODUCT/ROADMAP/
STATUS mention the feature.
demo.houseplan.tech (public, hourly reset to a pristine synthetic home) and
dev.houseplan.tech (closed, auto-deploys the dev branch). Deployed 2026-07-30
per the plan in houseplan-demo-stand.pdf.
Reported by the owner minutes after v1.53.0: typing a name showed no
results. The results existed — .candlist is a scrollable box, and a
scrollable flex item inside the dialog body collapses happily: 26 matching
rows rendered inside a 1px strip. The binding dropdown never showed this
because it sits inside .droppanel, a block context.
flex: 0 0 auto + a min-height keeps it open. The smoke now MEASURES the
list and the first row instead of counting DOM nodes — counting is exactly
why it passed a build where nothing was visible.
The owner's spec shipped in dev yesterday, released as one:
- tap action 'Run automation/script/scene' with a searchable picker,
per-domain services, save/runtime guards for the target
- 'Ask for confirmation' checkbox guarding toggle and run alike
- covers/valves in the card-wide toggle, garage/door/gate excluded
Inventory: 148 / 52 / 43 / 72.
- 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.
- 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.
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.
- 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.
A sweep of every document against the shipped behaviour:
- README en+ru: the space dialog no longer claims a background is mandatory
(draw-by-hand and the saved-plans picker exist; the canvas is square);
'Show all devices' sections rewritten for the hide-flag world — the
checkbox, 'Show hidden' ghosts, LQI-yes/light-no; troubleshooting updated.
- ARCHITECTURE: the config schema block still described v1.3 —
aspect/device_overrides/virtual_devices/1000x1000-per-aspect/legacy-bundle
fallback, all long gone. Rewritten to the current shape (square canvas,
markers with hidden, filter_seeded, quotas, signed urls). The WS table
dropped houseplan/file/set (removed in v1.10.0) and gained
geometry/repair, layout/delete, files/migrate, files/cleanup,
content/sign, the plans/list cap.
- UX-MODES: the Devices-tab tool list names the checkbox and the local
'Show hidden' instead of the retired shared show-all.
- ROADMAP: repair-issues, system_health, floors import, data icon rules,
click actions, theming and JSON i18n were done releases ago — checked off
with their versions; the HACS pointer is #9004 (bot closed#8995).
- STATUS: SSH port is 22222 and the HA config root is
/mnt/data/supervisor/homeassistant (/config does not exist there); the
key lives in houseplan/.secrets; the PAT note reflects the fine-grained
token; the feature surface gained the v1.42-v1.51 era.
- DEVELOPMENT: deploy instructions with the real port, path and cache
busting.
- The owner's product description (user folder) refreshed the same way:
square canvas, hide flags, the yellow principle.
- 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.
The dev batch since v1.50.4, released as one:
- hiding is a per-device checkbox seeded once from the old filter
(docs/FILTERING.md); blue ghosts under a local 'Show hidden' toggle
- yellow = doing its main job now; TRVs glow by hvac_action, service
switches can no longer become a device's primary
- pinch/pan gestures in every editor on touch
- the room settings button: visual centre (inscribed circle + centroid
pull), icon-derived size, zooms with the plan; metrics visible in the
plan editor
Inventory: 147 frontend / 51 pure / 43 harness / 68 smokes.
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.
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).
- 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.
- 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.
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.
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.
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.
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.
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.
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.