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.
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.
- 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.
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.
- 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 inscribed-circle criterion is FLAT along the long axis of any elongated
room — every midline point fits the same circle — and a plain argmax took
the first plateau sample: left of centre on the owner's kitchen-living
room, above centre in the sauna. The score now subtracts a soft pull
toward the area centroid (shoelace-weighted): on the plateau the nearest-
to-centroid point wins, while real clearance differences still dominate,
so the point never wanders into a thinner limb of an L.
Verified on a replica of the owner's floor: kitchen slab centre within a
few units, sauna dead-centre both axes. Units: wide and tall rectangles
centre on both axes; the L keeps to its slab near the centroid x.
The owner's kitchen-living room is L-shaped, and interiorPoint() only
promises 'somewhere inside' — the button sat near the seam, visibly
off-centre. poleOfInaccessibility() (largest inscribed circle, grid search
plus one refinement pass) puts it in the middle of the widest open space:
the slab of an L, the exact centre of a rectangle or square. Cached per
poly array in a WeakMap — the memoized model keeps the arrays stable, so
the search runs once per geometry, not per render.
Unit: square -> centre; thick-slab L -> mid-slab, always inside.
Owner's follow-up: the button was too large — height is now 0.77 of the
icon-size unit (half the previous), width follows through the derived font
and padding. The below-centre offset is gone: the anchor is the room's
geometric centre on BOTH axes, verified against the polygon centroids in vb
coordinates (exact match on all four demo rooms). Still zooms with the plan.
smoke_feedback_v2's gear assertions pinned the 2026-07-27 feedback sizes
(font >= 10px, box >= 18x40) — superseded by the owner's half-size order;
the contract is now 'sized from the device icon, clickable, opens the
dialog'.
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.
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.
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.
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.
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.
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).
The label box is centred on the room point, so anything taking part in its
layout SHIFTS THE NAME: entering the plan editor pushed it down by the gear
button's height (owner's report). The name is the anchor now — the metrics
and the gear button hang below it as absolutes, outside the centring math —
so the name renders in exactly the same spot in view mode and in the editor,
and the settings button is the bottom row of the card. Verified by measuring
the name's vb-coordinates in both modes: identical to the pixel.
- 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.
Three owner reports:
- The content frame behind the default zoom only looked at rooms, and devices
are allowed to stand outside every one of them — a gate sensor by the fence
was left outside the opening view. contentBounds() takes the marker positions
now, and they count as content even on a space with no rooms at all.
- Entering an editor 'strangely shifted' the plan. The stage height was
100dvh minus a hard-coded 118px of header, and the editor header is ~90px
taller than that: the whole scene slid down by the difference and its bottom
went below the fold. The card now measures where the stage actually starts
(HA toolbar, margins and our header included) and gives it the rest of the
viewport; the measurement is deferred a frame because setting state straight
from a ResizeObserver callback trips the 'undelivered notifications' error,
which smoke_dialog_zombie rightly counts as a page error.
- Zoom stopped at the base fit. The floor is 0.4× now, and zoomed out the
clamp centres the content instead of pinning it to the top-left corner —
with the view larger than the plan there is nothing to clamp against.
New smoke: smoke_zoom_out.mjs (editor keeps the stage inside the viewport,
0.4 floor, centring, the device-stretched frame); a unit test for the extra
points of contentBounds. Not released — the next release goes out after the
v1.49.0 audit.
CI caught what the local pure suite cannot run. Four HA-harness tests store a
plan url whose file is not there — and so, sooner or later, will a user: files
disappear from outside Home Assistant, and one of them is what the 'broken plan'
repair exists to report. Refusing every write that names a missing file would
have locked the owner out of every edit, including detaching it.
So the check compares against the stored configuration and only refuses names it
has not seen before, which is exactly the pick-then-delete window it was written
for. The repairs test now attaches a real plan and removes the file behind it;
the quota test budgets from what the shared test config directory already holds
instead of assuming an empty folder.
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.
Seen on the live instance: in the editors the dot grid covered only part of the
canvas. The grid is drawn over the space's view_box, and I transformed that box
along with everything else — so it still described the old plan area, and the
margins the square canvas had just added were outside it. Nothing to draw on,
which is precisely the room the change exists to give.
The viewport is now reset to the full square. It is also what 'fit to screen'
fits, so the whole canvas is reachable.
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.
Pick a plan you already uploaded: the space dialog lists the plans stored on the
server, attaches one on click, and is the only place a plan file is deleted.