Compare commits

...
59 Commits
Author SHA1 Message Date
Matysh 6a9122f41f v1.59.2: make dialogs accessible 2026-08-07 07:47:37 +03:00
Matysh 25f43da1bd v1.59.1: unify device and light state
Validate / smoke (push) Failing after 19m18s
Validate / hacs (push) Failing after 1m11s
Validate / hassfest (push) Failing after 1m37s
Validate / frontend (push) Successful in 3m0s
Validate / backend (push) Failing after 15m20s
2026-08-06 21:45:18 +03:00
Matysh e7aca9c678 v1.59.0: release stable plan editing 2026-08-06 17:43:53 +03:00
Matysh 5ca4c7e5c5 v1.59.0-rc.2: make plan editing predictable 2026-08-06 16:48:45 +03:00
Matysh e0f6746d7f v1.59.0-rc.1: optimize plans and polish editor feedback
Validate / hacs (push) Failing after 7s
Validate / hassfest (push) Failing after 6s
Validate / frontend (push) Successful in 3m12s
Validate / backend (push) Failing after 8m53s
Validate / smoke (push) Failing after 13m51s
2026-08-06 10:14:52 +03:00
Matysh d2bec266ed v1.59.0-beta.10: unify device visuals and wall refinements
Validate / hacs (push) Failing after 6s
Validate / hassfest (push) Failing after 6s
Validate / frontend (push) Successful in 3m19s
Validate / smoke (push) Failing after 1m12s
Validate / backend (push) Failing after 7m45s
2026-08-05 23:38:01 +03:00
Matysh 4868cc0786 v1.59.0-beta.9: fix mixed-wall resize and virtual T-junctions
Validate / hassfest (push) Failing after 8s
Validate / hacs (push) Failing after 10s
Validate / frontend (push) Successful in 3m1s
Validate / backend (push) Failing after 14m22s
Validate / smoke (push) Failing after 19m22s
2026-08-05 20:44:26 +03:00
Matysh e188f9d609 v1.59.0-beta.8: audit follow-ups and inner-corner sun rays 2026-08-05 20:07:27 +03:00
MatyshandCursor 3ad4e9d803 docs: drop unshipped release-gate bullet from beta.7 notes
AUD-159B6-05 needs a workflow-scoped push of release.yml; it is not in the tag.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 17:55:25 +03:00
MatyshandCursor 7333223a55 v1.59.0-beta.7: audit fixes, wall fill under hatch
Atomic wall intervals, open_spans in resize/Undo/Split/Delete, backend
open_spans schema, warm-nav ownership, smoke hygiene.
Wall fill colour paints under the hatch (both, not either/or).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 17:52:26 +03:00
MatyshandCursor de5e8129a1 v1.59.0-beta.6: partial open spans and wall-centric Delete
Two-click openwall writes space.open_spans; openings forbidden on virtual;
Delete closes virtual then merges or deletes rooms with confirm.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 16:07:23 +03:00
MatyshandCursor b44d2fb958 docs: design for open spans and wall-centric Delete
Capture brainstorming decisions for partial virtual walls and Delete merge/room flow before implementation.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 15:53:10 +03:00
MatyshandCursor ee87d3d00e v1.59.0-beta.5: wall-thickness redesign, draw thickness, plan on new space
Seamless ±½ wall rings with inner floor/area/sun; Draw toolbar thickness
(default 15 cm); new spaces open Plan; audit hygiene from beta.4 recheck.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 15:13:19 +03:00
MatyshandCursor a7d956a072 v1.59.0-beta.4: wall thickness + white editor sheet with backdrop
Validate / backend (push) Failing after 9m24s
Validate / smoke (push) Failing after 22m47s
Validate / hassfest (push) Failing after 8s
Validate / hacs (push) Failing after 9s
Validate / frontend (push) Successful in 2m58s
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>
2026-08-05 12:27:09 +03:00
MatyshandCursor 309bd59358 v1.59.0-beta.3: furniture, hide layers, styling hooks
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>
2026-08-05 11:46:11 +03:00
MatyshandCursor 31cd4142ac feat(dev): furniture, hide layers, styling hooks, wall-thickness spec
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>
2026-08-05 11:41:00 +03:00
Cursor AgentandMatysh cb2b064c6a docs(AGENTS): note local houseplan-dev path is invisible to cloud agents
Co-authored-by: Matysh <Matysh@users.noreply.github.com>
2026-08-05 08:26:42 +00:00
Cursor AgentandMatysh e6579f1e55 fix(dev): audit P0/P3 — write policy, README diff, validation
- Default admin_only on; config/get returns can_write; card editors follow it
  and fail closed without hass.user (P0-4).
- README EN/RU differentiation vs GUI draw cards / easy-floorplan (P0-3).
- Tighten marker binding, ripple_color, decor extents, space id (P3-4).
- quality_scale test path + deprecated card tap_action note (P3-5).
- Demo hass.user + can_write; unique marker id in upload overwrite test.

Co-authored-by: Matysh <Matysh@users.noreply.github.com>
2026-08-05 08:10:51 +00:00
Cursor AgentandMatysh 1b37427f1d merge main: AGENTS.md + audit pack into dev
Co-authored-by: Matysh <Matysh@users.noreply.github.com>
2026-08-05 08:05:03 +00:00
MatyshandGitHub 4c260f0ea0 Merge pull request #5 from Matysh/cursor/project-audit-6009
Validate / frontend (push) Successful in 2m3s
Validate / smoke (push) Failing after 20m45s
Validate / hacs (push) Failing after 11s
Validate / backend (push) Failing after 7m50s
Validate / hassfest (push) Failing after 11s
docs: полный аудит проекта (рынок, качество, пробелы, рекомендации)
2026-08-05 11:04:55 +03:00
MatyshandGitHub 159f4f43c0 Merge pull request #4 from Matysh/cursor/setup-dev-environment-6009
chore: document Cursor Cloud dev environment setup
2026-08-05 11:04:53 +03:00
Cursor AgentandMatysh 99f7c3a4a9 docs: full project audit pack for agents (market, quality, gaps)
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>
2026-08-05 04:56:01 +00:00
Cursor AgentandMatysh f4e5ce6822 chore: add Cursor Cloud dev setup notes and ignore backend venv
- Add AGENTS.md documenting the card + integration + demo harness,
  and non-obvious cloud caveats (Python 3.13 backend venv for HA-harness
  tests, fresh-bundle copy before smoke, demo render nudge, known
  pixel-precision smoke).
- Ignore .venv-backend/ (Python 3.13 venv provisioned by the update script).

Co-authored-by: Matysh <Matysh@users.noreply.github.com>
2026-08-05 04:45:11 +00:00
Matysh 0ee80a6a52 v1.59.0-beta.2: live text labels, text block frame, warm-memo owners
Validate / hacs (push) Failing after 7s
Validate / hassfest (push) Failing after 7s
Validate / frontend (push) Successful in 2m29s
Validate / backend (push) Failing after 8m30s
Validate / smoke (push) Failing after 13m24s
2026-08-04 23:44:59 +03:00
Matysh 1397a71f84 v1.59.0-beta.1: warm remount keeps your view and dialogs, sun ray rim
Validate / smoke (push) Failing after 22m41s
Validate / frontend (push) Successful in 2m47s
Validate / backend (push) Failing after 8m13s
Validate / hacs (push) Failing after 7s
Validate / hassfest (push) Failing after 7s
2026-08-04 18:20:47 +03:00
Matysh 46f20fe4e1 v1.58.0
Validate / hacs (push) Failing after 12s
Validate / hassfest (push) Failing after 13s
Validate / frontend (push) Successful in 2m50s
Validate / backend (push) Failing after 8m29s
Validate / smoke (push) Failing after 21m33s
2026-08-04 15:10:46 +03:00
Matysh 1108b2bc14 v1.58.0: backdrop transform, paper by rooms, align-to-grid fixes 2026-08-04 15:04:55 +03:00
Matysh 2fd46493db v1.58.0-beta.1: backdrop transform, paper by rooms, decor tool fix 2026-08-04 14:13:38 +03:00
Matysh df233905c5 DEV-B58: one bound, one grid — the canvas border and the snap contract
Validate / backend (push) Failing after 8m25s
Validate / smoke (push) Failing after 12m2s
Validate / hassfest (push) Failing after 7s
Validate / hacs (push) Failing after 7s
Validate / frontend (push) Successful in 2m48s
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 85263d5.

=== DEV-B58-02: everything strictly on the grid ===

The owner's suspicion first, answered honestly in docs/CANVAS.md §9.2:
THE GRID STEP DID NOT CHANGE. `_gridPitch = NORM_W / GRID_N = 1000/240`,
both constants, independent of the frame, the view, the zoom, `view_box`
and `cell_cm`; `git log -S` shows neither touched since v1.4.0. So the
move to the infinite canvas did not put any existing element between the
nodes. `gridLevels()` changes what is DRAWN, never what is SNAPPED TO.

What WAS off the grid, and is now fixed:

  * auto placements. `defaultPositions`, the `spaceCenter` fallback and
    an undragged room label used centroids, which are not nodes for an
    odd-sized or polygonal room. This is the likeliest thing the owner
    was actually looking at.
  * `_decorMoveUpdate` snapped the DELTA, which preserves whatever
    off-grid offset a shape already had for ever, one step at a time. It
    snaps the resulting anchor now, so one drag is enough.
  * `snapToGrid`/`snapR` returned 500.00000000000006 for an exact 500 —
    the round trip through a non-dyadic pitch. They are bit-identical on
    a node now, so "is this on the grid?" stops answering no.

Openings and split points on a wall are deliberately NOT rounded to a
node — a door on a node but off its diagonal wall is broken geometry.
They are WALL-bound: projected onto the wall, then the offset ALONG it
quantised to the same step (`snapToWall({step,length})`,
`snapPointAlongPoly`). On the axis-aligned, grid-drawn walls the editor
itself makes, the two rules give the same point. The centre magnet is
consulted FIRST, so a wall whose middle is not a node can still hold a
centred window (this is what smoke_opening_measure caught).

Shift now means one thing everywhere: suspend the snap for this gesture.
It keeps its two older meanings (no centre magnet, coarse 15° compass).

=== And why an ACTION rather than a silent migration ===

Old plans may hold coordinates between the nodes. The card does not
round them on update. General settings grow a Grid group with
«Выровнять всё по сетке», which first states how many elements will
move and by how much at most, warns that there is no undo, and only then
writes — one config/set plus the layout updates, in one go.

  1. A migration moves the user's data without asking. A house plan is a
     drawing; the card has no mandate to redraw it on a version bump.
  2. Some elements are off-grid ON PURPOSE — a small decor label nudged
     next to an icon, a window on a diagonal wall, a plan traced over a
     photo whose scale was never a whole number of cells.
  3. A silent migration is unattributable: when a room looks 3 cm wrong
     the owner cannot tell whether the card did it or they did.
  4. An update that rewrites stored geometry cannot be undone by
     downgrading the card. A button can simply not be pressed.

`alignAllToGrid()` (src/align-grid.ts) is pure — it copies its input and
returns the new spaces, the new layout and the report — so the dialog
measures and commits the SAME object and cannot promise one thing and do
another. test/align-grid.test.mjs pins what moves, what does not (a
stray opening with no wall in reach stays put), that a rect's FAR corner
lands on a node too, and idempotency: a second run reports moved 0,
changed false, and deep-equals the first. demo/smoke_grid_snap.mjs does
the same through the DOM plus every by-hand placement.

docs/CANVAS.md §9 carries the whole contract; docs/TESTING.md gains
three manual items. i18n en/ru. The backend is untouched — same
coordinates, same schema.
2026-08-04 12:42:16 +03:00
Matysh 85263d520f Rebuild the tracked bundles for the 2026-08-04 batch
dist, demo/srv/assets and custom_components/houseplan/frontend are the
same bytes as a fresh production build of this tree: round room border
joins, the decor draft's live size badge and the normal-axis sun fade.
2026-08-04 12:05:59 +03:00
Matysh 02ae7f9588 DEV-EB173-01: a shaft of light fades along the wall's normal
Audit finding P2. At a grazing sun the wedge lost the two invariants it
was supposed to keep: one end of the GLASS started at opacity 0, and the
two sides of one shaft came out 5.41 and 84.19 long — the long one 31 %
LONGER than the pre-cut 64, not 30 % shorter.

The cause was the axis. The gradient ran along `dir` from the middle of
the window span, so the geometry had to be skewed (each end extruded by
a different amount) to make both far corners land on the same offset.
That buys the iso-alpha far edge with the other two requirements.

The light is a bundle of PARALLEL rays: the distance a point has
travelled from the glass is depth/cos, an affine function of the point,
whose level sets are lines PARALLEL TO THE WALL. So the correct linear
gradient runs along the wall's INWARD NORMAL, starts on the window line
and is `len·cos(incidence)` long — SunRay.normal / SunRay.depth. A point
`source + dir·u` then lands on offset u/len, whichever ray it rode in
on. All three invariants hold at once:

* the whole pane of glass is at depth 0 → peak alpha end to end;
* alpha depends only on how far that point's own ray has run;
* rayQuad() is an honest parallelogram again (both ends extruded by the
  same `len`), and its far edge — parallel to the wall — IS the
  gradient's last iso-alpha line, so a bright kerb is impossible by
  construction and the −30 % holds for every side of every wedge.

windowLit() gets a real threshold instead of the 1e-9 epsilon:
RAY_MIN_COS = 0.05, i.e. the sun must clear the plane of the wall by
~2.9°. Below it glass reflects nearly everything and the shaft would be
a sliver thinner than the wall it came through — nothing is drawn, and
the gradient axis can never degenerate to a point.

Tests: rayQuad now asserts equal, full-length sides and a wall-parallel
far edge; new unit tests replay the auditor's repro with his numbers
(both sides 44.8, offsets 0 at both ends of the glass, offset = travel /
len for arbitrary rays) and the RAY_MIN_COS cut-off. smoke_sun_soft
measures the same facts off the DOM gradient end to end and fails by
name on the old bundle (9 named failures). docs/SUN.md carries the new
contract and the finding.
2026-08-04 11:59:11 +03:00
Matysh 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.
2026-08-04 11:52:25 +03:00
Matysh d21d532f10 No room border ends in a tooth: walls join round
Owner 2026-08-04: «углы границ комнат всё ещё с зубцами (фиксили для
декоративных линий, они теперь заканчиваются полукружьями, надо сделать
так же для границ комнат)».

A default miter join on a sharp room corner shoots a spike far past the
two walls that meet there, and flips to a flat bevel once the miter
limit clips it — both read as a tooth. Room borders now join ROUND:

* .room (polygon / evenodd path / rect) — one rule, so the plan view,
  the Plan editor and the static space-card all get it;
* .room-outline — a room with open boundaries draws its walls as
  separate M..L subpaths, so its corners are stroke ENDS: round caps
  close them the same way a round join closes a contour;
* .seg — the contour being drawn in the editor already had round caps,
  now it states the join too.

smoke_render_parity checks both renderers and the trimmed outline;
demo/shot_room_joins.mjs is the before/after still (a 45° apex).
2026-08-04 11:47:50 +03:00
Matysh 50ba443492 v1.57.0
Validate / backend (push) Failing after 8m25s
Validate / smoke (push) Failing after 1m2s
Validate / hacs (push) Failing after 10s
Validate / hassfest (push) Failing after 9s
Validate / frontend (push) Successful in 2m46s
2026-08-04 11:27:09 +03:00
Matysh aa01eaef01 v1.57.0 2026-08-04 11:21:30 +03:00
Sergey 93b60c5b8e Editor grid dots are a hint: mute both levels (0.35 / 0.5)
The adaptive grid drew at full strength — on the white paper of a drawn
plan the dots argued with the walls instead of guiding them. Both levels
are dimmed, the CAD hierarchy kept: fine dots 0.75 -> 0.35, coarse nodes
1 -> 0.5, so the accents still carry the scale reference on the dark
scene background. Editors only; View draws no grid (smoke_grid_fade).
2026-08-04 11:01:39 +03:00
Matysh eb17396006 Sunlight has hard sides again and fades only along the ray
Owner, 2026-08-04, on yesterday's attempt: «с лучами солнца ты сделал фигню —
не надо размывать их боковые грани».

They are right. 22b588e answered "the shafts run into something invisible" with
a Gaussian blur of the WHOLE wedge (`raySoftness`, filter `hp-sunsoft`), which
feathered the sides as well as the tip. A shaft of light through a window has
crisp sides; only its reach fades. The blur turned every wedge into a smudge.

GONE. `raySoftness()`, the `<filter>`/`feGaussianBlur` in <defs>, the `<g
filter clip-path>` wrapper, and with it the `hp-sunclip` clipPath — that clip
existed only so the blur could not bleed through a wall. The polygons come out
of `computeSunRays()` already intersected with the room, so a wall still stops
the light by geometry (demo/smoke_sun.mjs, wedgeClippedToRoom). The sun layer
is plain `<polygon fill="url(#hp-sun-i)">` again.

THE KERB DID NOT COME BACK, and not by luck. The old bright edge floating in
mid-floor was never about softness: the gradient's iso-alpha lines are square
to the SUN, while a parallelogram's far edge is parallel to the WALL. Head-on
they coincide; at any other angle one far corner sits at offset `1 − 0.5/k` —
0.71 of the way at a low sun, 0.11 at a high one — i.e. still lit when the
polygon ends. So `rayQuad()` no longer builds a parallelogram: each side is
extruded until it reaches the same distance `len` ALONG `dir`, which puts the
far edge on one iso-alpha line of the gradient. Combined with the untouched
`RAY_FADE_END` = 85 %, the last 15 % of every wedge is empty and its outline
has nothing left to draw. The sides stay razor-sharp on purpose.

Length (×0.7) and the live sky catch-up are untouched.

Tests: unit — `rayQuad` at six sun angles (sides exactly parallel to the ray,
both far corners at offset 1, far edge ⊥ ray, nothing past the gradient) plus
the head-on parallelogram pinned; the `raySoftness` test is gone with the
function. Smoke — demo/smoke_sun_soft.mjs keeps the reach and the "dead at
85 %" checks and flips the feather assert into its opposite: no filter on any
wedge, no `feGaussianBlur` in the tree, and at an OBLIQUE sun (230°/8° and
225°/55°) no vertex is drawn past the end of the gradient. Verified to fail on
the previous bundle on exactly those four. All 247 unit tests and all 97 smokes
green. Stills: sun_sharp_low / sun_sharp_high (demo/shot_sun_short.mjs now
takes a file prefix).
2026-08-04 10:30:22 +03:00
Sergey 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.
2026-08-04 10:11:57 +03:00
Sergey 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).
2026-08-04 10:11:32 +03:00
Matysh 285d569102 The day/night sky catches up instead of crawling after the sun
Owner, 2026-08-04: «цвет фона не меняется сам с течением времени суток, только
после обновления страницы».

WHAT IS NOT THE BUG. The model layer was already live: `_stageBg` and the
`planDim` filter are read straight out of `hass.states['sun.sun']` on every
render, `hass` is a plain reactive property, and a bare `card.hass = {...}` in
the demo rig does move the style attribute — smoke_sun.mjs has asserted exactly
that since v1.56.0 and it has always passed.

WHAT IS. The sky is DELIVERED by a 45 s CSS transition, and a CSS transition
only advances while the element is being painted. Every second of a background
tab, another dashboard view, an editor session or a sleeping wall tablet is a
second the sun keeps moving and the sky does not; when the card comes back, the
transition restarts from the stale colour and crawls, 45 s at a time, toward a
target that has meanwhile moved again. A page reload, by contrast, paints the
right colour outright — a freshly mounted element has nothing to transition
FROM. That is the owner's sentence, word for word.

THE FIX. Measure the gap and decide. HA refreshes `sun.sun` every ~4 minutes by
day (verified on the home instance: 08:58:56, 09:02:56, 09:06:56, …), i.e. ≤1°
of elevation per update, so anything from SKY_SNAP_DEG = 3° up can only mean
"we were not watching". Such a step is painted with `transition: none` for a
single frame (`.stage.daynight.skysnap`, released on the next
requestAnimationFrame, so the very next change glides again); everything
smaller keeps the 45 s breathing untouched. `visibilitychange → visible` clears
the marker outright, so a tab that comes back is right immediately.

The elevation the sky is computed from is now rounded to 0.1° (`skyElevation`,
shared by the stage background and the plan dimming) — invisible across a 45 s
glide and it keeps lit from re-committing the style attribute on every hass
tick. The ray GEOMETRY memo is deliberately untouched and keeps its own,
coarser key: the sky is cheap, polygon clipping is not.

Tests: unit — skyNeedsSnap (null/NaN, a real 4-minute step glides, 3° in either
direction jumps), skyElevation. Smoke — demo/smoke_sun_live_bg.mjs, which
asserts the COMPUTED background of the stage (not the style attribute) after a
plain `hass` assignment with no reload and no requestUpdate, plus planDim and
the 3° ray threshold both ways. It fails on the previous tip with
dayComputedWhite, nightComputedDark, backToDayComputed, smallStepMovesSky and
returnFromHiddenSnaps, and it also pins that a REAL sun step still glides
rather than jumps.
2026-08-04 09:49:30 +03:00
Matysh 22b588e116 Sunlight is 30% shorter and always dissolves into nothing
Owner, 2026-08-04: «лучи от солнца сделать короче на 30%, проверить, чтобы они
всегда плавно рассеивались (сейчас есть ощущение, что они упираются во что-то
невидимое)».

SHORTER. `rayLength` is now the v1.56 curve times RAY_LENGTH_K = 0.7 — 1.75
window lengths at sunrise, 0.56 at the zenith. Scaling the whole curve instead
of re-picking the constants keeps the shape the owner approved: a low sun still
reaches three times further than a high one.

WHAT THEY WERE BUMPING INTO. Nothing invisible — the wedge's own outline, in
three places at once.

1. The gradient runs ALONG the sun, so its iso-alpha lines are perpendicular to
   the sun, while the wedge's far edge is parallel to the WALL. The two
   coincide only for a sun hitting the glass dead-on; at any other angle one
   half of that far edge was cut while it still carried colour — a straight
   bright kerb hanging in the middle of the floor. The single `100% → alpha 0`
   stop hid this from the reader of the code and from nobody else.
2. The two SIDES of the wedge had no falloff at all: two razor lines from the
   window into the room, brightest exactly where they are most visible.
3. Where the room outline clips the wedge — the opposite wall, the inner corner
   of an L, and above all an OPEN (virtual) boundary, which has no wall drawn
   at all — the shaft was chopped at whatever alpha it still had.

WHAT IT IS NOW. The gradient still spans the FULL wedge (geometry and gradient
must describe the same shaft), but `rayStops()` eases it to a hard zero at
RAY_FADE_END = 85% of the length, so the last 15% of every wedge is guaranteed
empty and a shaft ending in mid-air has nothing left to draw an edge with. Each
wedge is then drawn inside `<g filter clip-path>`: SVG applies the filter FIRST
and the clip SECOND, so a Gaussian blur of `raySoftness(len)` (7% of the shaft,
clamped 3…18 render units) feathers the sides and the tip and the room outline
cuts that feather off. Light still never crosses a wall — but where it reaches
one, the kerb is a soft ramp that reads as light landing ON the wall.

Clipping by the room is untouched; only its visible edge changed.

Tests: unit — rayLength pinned at exactly 70% of the old curve at ten
elevations, rayStops (monotone, dead at/after 85%, bright at the glass),
raySoftness clamps. Smoke — demo/smoke_sun_soft.mjs, which fails on the
previous tip (lowSunIs70Percent, highSunIs70Percent, gradientSpansWholeWedge,
deadWellBeforeTheEnd, everyWedgeFeathered). Stills: demo/shot_sun_short.mjs.
2026-08-04 09:47:34 +03:00
Matysh 1da1aba625 Curtains never wear a coloured plate
Validate / hacs (push) Failing after 1m8s
Validate / hassfest (push) Failing after 1m6s
Validate / frontend (push) Successful in 2m26s
Validate / backend (push) Failing after 7m59s
Validate / smoke (push) Failing after 9m32s
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 de53d53 an «Открыть/закрыть» marker reads its cover
wherever that entity sits, so the paint had just reached every curtain that
had the action set, his own included.

WHAT IT IS NOW. `_stateClass` returns no plate class for the `cover` domain in
any state: closed, open, ajar (HA reports a positioned cover as plain 'open'),
opening and closing all keep the neutral badge, and motion is the `.covermove`
ring alone. Open/closed is told by the ICON — which makes the morph the only
signal there is, so it had to stop having holes:

- `awning` mapped BOTH states to `mdi:awning-outline` — one glyph for open and
  closed, i.e. no indication at all for that class. Now outline (retracted) ->
  `mdi:awning` (extended).
- a cover with NO device_class (z2m ships plenty) only morphed if its icon
  happened to be in a device_class pair — and the icons the card itself hands
  out are not: the name rule «штор|curtain|blind|shade» gives `mdi:roller-shade`,
  «ворота|garage|gate» gives `mdi:garage-variant`. Those, plus
  `mdi:blinds-horizontal` and `mdi:door`, are now recognised as pairs on the
  base icon (COVER_ICON_ALIASES — base-icon matching only, never picked by
  device_class, so nothing is swapped for a guess).
- a hand-picked icon still wins outright everywhere, with ONE exception: a
  cover whose custom icon IS one of those pair members morphs inside THAT pair
  (`mdi:curtains` <-> `mdi:curtains-closed`) — never traded for another family.
  Without it, choosing an icon would silently switch the marker's only
  indicator off.

WHAT KEEPS THE FRAME, deliberately: door / window / garage_door / opening
binary sensors, an unlocked lock — and `valve`, which parts ways with `cover`
here. No icon pair morphs for a valve, so the frame is the only thing it has
to say «открыт» with; sweeping it along would have left those markers mute for
a rule that names the curtains. If the two domains should ever read alike, a
valve needs an icon pair first (docs/FILTERING.md).

smoke_cover_no_plate.mjs walks one curtain through closed / open / ajar /
opening / closing and reads the COMPUTED plate colour against probes of
--hp-bg, --hp-on and --hp-open: neutral every time, never yellow, never
orange, no 'on'/'open' class, the breathing ring in the two travelling states
and nowhere else. It also checks the morph for all ten classes both ways, the
no-device_class and custom-icon paths, and — the point of the whole bottom
half — that an unlocked lock and an open window sensor STILL come out orange
(and a locked lock neutral again, so the frame still means something). 13
checks are red on the parent commit. The unit suite gains a loop that fails
any class mapping both states to one glyph. smoke_cover_tap and
smoke_cover_not_primary flip their «open frame» assertions to the new
contract; docs/FILTERING.md gets the state table and the valve reasoning,
docs/TESTING.md the checklist item. shot_cover_states.mjs captures the four
states side by side.
2026-08-04 04:38:23 +03:00
Matysh 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.
2026-08-04 01:45:05 +03:00
Matysh ade8daab16 Icons are measured by the same plan the frame is
Audit dev@2c947f4, DEV-2C947-03 (P2). Three rooms in the core plus one dragged
90 canvases out: the frame rejected the stray exactly as §4.1 promises, and
then a perfectly ordinary marker on the main plan came out 90.89x too big and
covered the house. `contentFrame` voted; `iconUnit` did not — it took
`boxOf(every room)`, so the distance to the stray the frame had just thrown
away lived on in the numerator of `iconCqw`.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Smokes: smoke_warm_remount (fails pre-fix: veil + hidden plan on re-mount;
resize invalidation stays cold), smoke_ws_resilience (fails pre-fix:
_serverCfg cleared after 8 tries, no revalidation after recovery); the
preloader smokes now reset the warm memo — they simulate a COLD first open.
2026-08-03 15:36:32 +03:00
177 changed files with 32757 additions and 2965 deletions
+59 -1
View File
@@ -4,11 +4,43 @@ on:
types: [published]
permissions:
contents: write
actions: read
jobs:
build:
# AUD-159B7-02: publishing a GitHub Release used to BE the gate — this
# workflow only built and uploaded, so an asset shipped while both Validate
# runs for the very same commit were red. The asset now waits for a green
# Validate of the EXACT commit the tag points at, and is withheld otherwise.
#
# Needs a push with a token that has the `workflow` scope (the ordinary
# Personal Access Token used for `git push` refuses workflow file updates).
gate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.release.tag_name }}
fetch-depth: 0
- uses: actions/setup-node@v4
with: { node-version: 22 }
- name: Require a green Validate for this exact commit
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
TAG: ${{ github.event.release.tag_name }}
run: |
set -euo pipefail
# HEAD is the peeled commit even when TAG is annotated. Do not trust
# target_commitish (it may be a branch name) or an event-context SHA.
SHA=$(git rev-parse HEAD)
echo "release tag: $TAG; exact commit: $SHA"
node scripts/release-gate.mjs "$SHA"
build:
needs: gate
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.release.tag_name }}
- uses: actions/setup-node@v4
with: { node-version: 22 }
- run: npm ci && npm run build
@@ -17,3 +49,29 @@ jobs:
uses: softprops/action-gh-release@v2
with:
files: dist/houseplan-card.js
hacs-discovery:
# HACS 2.0.x takes the first prerelease in GitHub's response instead of
# sorting SemVer. A valid asset can therefore be invisible to beta users
# (beta.10 appeared after beta.9). Keep the release asset, but
# make that distribution failure impossible to miss in the release run.
if: ${{ github.event.release.prerelease }}
needs: build
runs-on: ubuntu-latest
steps:
- name: Verify the published tag is the prerelease HACS will discover
uses: actions/github-script@v7
with:
script: |
const releases = await github.paginate(github.rest.repos.listReleases, {
owner: context.repo.owner,
repo: context.repo.repo,
per_page: 100,
});
const first = releases.find((r) => r.prerelease && !r.draft);
const expected = context.payload.release.tag_name;
if (first?.tag_name !== expected) {
core.setFailed(
`HACS prerelease discovery is stale: GitHub returns ${first?.tag_name ?? 'none'} before ${expected}. ` +
`Use an rc/new version line or correct the release ordering before announcing the update.`,
);
}
+1
View File
@@ -4,3 +4,4 @@ test-build/
*.log
__pycache__/
.pytest_cache/
.venv-backend/
+27
View File
@@ -0,0 +1,27 @@
# AGENTS.md
House Plan is one HACS package with two parts plus a demo harness:
- **Lovelace card** (`src/`, TypeScript + Lit) — the primary product, bundled to `dist/houseplan-card.js`.
- **Storage integration** (`custom_components/houseplan/`, Python) — the Home Assistant backend.
- **Demo harness** (`demo/`) — a self-contained Playwright page (`demo/srv/demo.html`) that renders the card against a fake `hass`, used for screenshots and the `smoke_*.mjs` end-to-end suite.
Standard commands live in `package.json` scripts, `CONTRIBUTING.md`, and `docs/DEVELOPMENT.md`. Read `docs/ARCHITECTURE.md` and `docs/STATUS.md` before non-trivial changes.
## Cursor Cloud specific instructions
The startup update script already runs `npm ci`, provisions a Python 3.13 backend venv at `.venv-backend`, and installs Playwright Chromium. You do not need to reinstall dependencies.
- **Frontend** (from repo root): `npm run typecheck`, `npm test` (node:test, ~270 tests), `npm run build`. After building, keep the integration copy in sync — `cp dist/houseplan-card.js custom_components/houseplan/frontend/`. CI enforces `cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js` byte-for-byte.
- **Backend HA-harness tests need Python 3.13, not the system 3.12.** Run them with the venv: `.venv-backend/bin/python -m pytest tests_backend/ -q` (126 tests). Running `python3 -m pytest tests_backend` on the system 3.12 silently **skips** the `test_ha_*.py` harness tests (`conftest.py` ignores them when `homeassistant` is not importable) and runs only the ~83 pure tests.
- **Running the app / smoke suite**: build a fresh bundle and copy it into the demo assets first — `npm run build && cp dist/houseplan-card.js demo/srv/assets/houseplan-card.js` — then run `node demo/smoke_*.mjs`. The committed `demo/srv/assets/houseplan-card.js` is a stale snapshot; testing it reports green about code that no longer exists. No real Home Assistant server is required: `demo/srv/demo.html` stubs `hass`, registries and `callService`.
- **Demo harness render quirk**: the fake `hass` in `demo.html` is set once, so opening the page directly in a browser renders the floor plan but **device icons only appear after a re-render** (an F5 refresh, or nudging `card.hass = {...card.hass}`). The smoke launcher `demo/serve.mjs` already does this nudge; a plain browser session does not. This is a harness limitation, not a card bug.
- **Known environment-sensitive smoke**: `demo/smoke_opening_measure.mjs` fails two sub-checks (`place_dialog_x_magnetised`, `place_committed_x_center`) under the pinned Chromium — a `1e-6`-tolerance magnet-snap on the opening-*placement* path. It reproduces against the pristine committed bundle, so treat it as pre-existing/pixel-precision, not a regression you introduced.
- **Owner's local Windows checkout is invisible here.** Path
`C:\Users\Sergey\Downloads\dev\houseplan-dev` (workflow notes + often
unpushed edits) is **not mounted** into managed Cloud Agent VMs. Do not
expect to `ls` or diff that folder. To bring local work into the cloud
agent: push a branch to GitHub and say its name, or run a **local** Cursor
Agent / My Machines worker inside that checkout. Day-to-day source of truth
for cloud sessions remains **`origin/dev`** (minors) and **`origin/main`**
(releases) — see `docs/STATUS.md`.
+33 -10
View File
@@ -27,10 +27,16 @@ right on your Lovelace dashboard.
**Feature highlights**
- ♾️ **An infinite canvas** — there is no "plan size" and no edge to run
past: draw and place devices anywhere, pan at any zoom, zoom out to see
everything, and let one tap fit the whole plan back on screen.
- 🖱 **GUI-first floorplan editor** — rooms, doors & windows, island rooms,
virtual walls and a visual decor layer, all drawn with clicks; room resize
by dragging walls, with live lengths and areas as you drag; smart
alignment guides and a live ruler in real meters/feet.
- 🖼 **A backdrop you can move and scale** — drag the floor-plan picture into
place and pull a corner to size it, with its real size in metres shown as
you drag, so the drawing and the photo of your plan finally line up.
- 💡 **Lights toggle on click** out of the box; wall-switch markers can control
whole groups of lights (works for dumb switches and stateless remotes too).
- 🌒 **“Light sources” fill** — a dark house where every lit lamp casts a pool
@@ -39,6 +45,9 @@ right on your Lovelace dashboard.
the day (white noon → golden hour → deep night), while windows on exterior
walls cast real wedges of sunlight into the rooms; optional cloud cover
from a weather entity.
- 🪟 **Curtains and blinds open on a tap** — one action opens, closes or
stops a cover, and the icon itself morphs between open and closed while a
soft ring pulses as it travels.
- 🌡 **Room cards** with temperature, humidity, Zigbee LQI and light count;
comfort-range temperature fills, per-room signal heatmap.
- 🚪 **Doors, windows and locks** with contact sensors — unlocking is always an
@@ -75,16 +84,26 @@ The integration consists of two parts that are installed together:
## How it differs from alternatives
A house plan in Home Assistant is usually built with `picture-elements`, `ha-floorplan` and similar solutions. There you have to write YAML by hand, calculate the coordinates of every icon, and edit the config again after every change. House Plan works differently:
A house plan in Home Assistant is usually built with `picture-elements`,
`ha-floorplan`, or newer GUI cards that draw walls and furniture in the
dashboard. Those either lock you into YAML/SVG, or store the plan in the
Lovelace card config. House Plan is a **shared live map** backed by a Home
Assistant integration:
| | House Plan | Typical solutions (picture-elements / ha-floorplan) |
|---|---|---|
| **Setup** | Entirely through the UI, with the mouse | Manual YAML and code editing |
| **Adding devices** | Automatic, by room | You type in every entity by hand |
| **Icon coordinates** | Drag with the mouse | You count pixels and write them into the config |
| **Room markup** | Built-in outline editor | You draw in an external SVG editor |
| **Storage** | On the HA server (shared by all devices) | In the dashboard YAML |
| **Zoom** | Smooth zoom, everything stays crisp (vector) | Usually a fixed image |
| | House Plan | picture-elements / ha-floorplan | GUI draw cards (e.g. easy-floorplan) |
|---|---|---|---|
| **Setup** | Entirely through the UI, with the mouse | Manual YAML / Inkscape SVG | In-card drawing of walls & furniture |
| **Adding devices** | Automatic, by HA **area** | You type every entity by hand | Place entities by hand on the drawing |
| **Icon coordinates** | Drag with the mouse | Count pixels into YAML | Drag on the canvas |
| **Room markup** | Built-in outline editor bound to areas | External SVG editor | Draw walls yourself (furniture CAD) |
| **Storage** | On the HA server (`.storage`, shared, multi-client) | In the dashboard YAML | In the card / dashboard YAML |
| **Overlays** | Glow, climate, LQI, sun, vacuums, kiosk | Whatever you script in SVG/CSS | Varies by card |
| **Zoom** | Smooth vector zoom | Usually a fixed image | SVG / virtual canvas |
**One sentence:** House Plan is the shared, area-aware live map of your home —
not a drawing app for furniture. If you want to sketch sofas and walls from
scratch, a draw-centric card may fit better; if you already have a plan image
and a real HA areas registry, House Plan keeps every tablet on the same layout.
Key advantages in short:
@@ -179,7 +198,7 @@ for each floor (names prefilled, a plan image is asked for one by one; any floor
![Empty plan — prompt to add a space](docs/images/02-onboarding-empty.png)
In the dialog, set a **name** (for example, "1st floor") and pick the background: **upload** a floor-plan image (SVG, PNG, JPG, WebP), **choose one already uploaded** to the server earlier, or select **"no background, I'll draw the rooms"** for a hand-drawn space. The canvas is always square; an image keeps its own proportions and is centred inside it.
In the dialog, set a **name** (for example, "1st floor") and pick the background: **upload** a floor-plan image (SVG, PNG, JPG, WebP), **choose one already uploaded** to the server earlier, or select **"no background, I'll draw the rooms"** for a hand-drawn space. The canvas is infinite; an image keeps its own proportions and can be moved and resized at any time in the Background editor.
![Space creation dialog](docs/images/03-space-dialog.png)
@@ -271,6 +290,10 @@ turned the way it is mounted.
![Adding a device manually](docs/images/07-marker-dialog.png)
### Styling the plan with card-mod (advanced, unsupported)
The card ships finished and has no CSS field of its own — but if you already run [card-mod](https://github.com/thomasloven/lovelace-card-mod), every object on the plan now carries a stable hook you can aim at: `data-hp="device"` (plus `data-entity`, `data-area`), `data-hp="room"`, `data-hp="opening"`, `data-hp="decor"`, `data-hp="room-label"`, `data-hp="space-tab"`. We promise not to rename them; we do not ship card-mod, do not support it, and are not responsible for what your CSS does to the card. The full table, the examples and the limits are in **[docs/STYLING-HOOKS.md](docs/STYLING-HOOKS.md)**.
---
## Uninstalling
+35 -12
View File
@@ -6,6 +6,8 @@
[![Live demo](https://img.shields.io/badge/демо-попробовать-00c853?logo=homeassistant&logoColor=white)](https://demo.houseplan.tech)
[![Telegram chat](https://img.shields.io/badge/Telegram-чат-2CA5E0?logo=telegram&logoColor=white)](https://t.me/ha_houseplan)
📘 **[Полное руководство пользователя](docs/USER-GUIDE.ru.md)** · **[Продуктовый аудит и план улучшений](docs/PRODUCT-IMPROVEMENT-PLAN.ru.md)**
**Превратите Home Assistant в живую интерактивную карту дома.** Загрузите или
нарисуйте план этажа, обведите комнаты мышкой — и умные устройства появятся на
своих местах: живые состояния, свет по клику, температура и влажность по
@@ -25,6 +27,10 @@
**Главное**
- ♾️ **Бесконечный холст** — нет «размера плана» и нет края, за который
нельзя выйти: рисуйте и ставьте устройства где угодно, тащите план на
любом зуме, отдаляйтесь, чтобы увидеть всё, и одной кнопкой вписывайте
план обратно в экран.
- 🖱 **Редакторы прямо в карточке** — комнаты, двери и окна, комнаты-острова,
виртуальные стены и декор-слой рисуются кликами; размеры комнат меняются
перетаскиванием стен с живыми длинами и площадями; помощник выравнивания и
@@ -37,6 +43,9 @@
(белый полдень → золотой час → глубокая ночь), а окна внешних стен пускают
в комнаты настоящие клинья солнечного света; облачность — опционально, от
weather-сущности.
- 🪟 **Шторы открываются тапом** — одно действие открывает, закрывает или
останавливает штору, а сам значок морфится между открытым и закрытым
видом и мягко пульсирует кольцом, пока штора едет.
- 🌡 **Карточки комнат**: температура, влажность, Zigbee-сигнал, свет «1 из 3»;
температурная заливка по комфортным границам.
- 🚪 **Двери, окна и замки** с датчиками — отпирание только явной кнопкой,
@@ -74,16 +83,25 @@ House Plan показывает ваш умный дом так, как он в
## Чем отличается от аналогов
Обычно план дома в Home Assistant делают через `picture-elements`, `ha-floorplan` и подобные решения. Там приходится вручную писать YAML, вычислять координаты каждой иконки и заново править конфиг при каждом изменении. House Plan устроен иначе:
Обычно план дома в Home Assistant делают через `picture-elements`, `ha-floorplan`
или новые GUI-карточки, где стены и мебель рисуют прямо на дашборде. Там либо
YAML/SVG, либо конфиг живёт в YAML карточки. House Plan — это **общий живой
план** на серверной интеграции Home Assistant:
| | House Plan | Обычные решения (picture-elements / ha-floorplan) |
|---|---|---|
| **Настройка** | Полностью через интерфейс, мышкой | Ручной YAML и правка кода |
| **Добавление устройств** | Автоматически по комнатам | Каждую сущность вписываете руками |
| **Координаты иконок** | Перетаскиваете мышью | Считаете пиксели и пишете в конфиг |
| **Разметка комнат** | Встроенный редактор контуров | Рисуете в стороннем редакторе SVG |
| **Хранение** | На сервере HA (общее для всех устройств) | В YAML дашборда |
| **Масштаб** | Плавный зум, всё остаётся чётким (вектор) | Обычно фиксированная картинка |
| | House Plan | picture-elements / ha-floorplan | GUI-рисовалки (напр. easy-floorplan) |
|---|---|---|---|
| **Настройка** | Полностью через интерфейс, мышкой | Ручной YAML / Inkscape SVG | Рисование стен и мебели в карточке |
| **Добавление устройств** | Автоматически по **зоне** HA | Каждую сущность вписываете руками | Ставите сущности руками на чертёж |
| **Координаты иконок** | Перетаскиваете мышью | Считаете пиксели в YAML | Drag на холсте |
| **Разметка комнат** | Встроенный редактор контуров, привязка к зонам | Сторонний SVG-редактор | Сами рисуете стены (мебельный CAD) |
| **Хранение** | На сервере HA (`.storage`, общее, multi-client) | В YAML дашборда | В карточке / YAML дашборда |
| **Оверлеи** | Glow, климат, LQI, солнце, пылесосы, киоск | Что пропишете в SVG/CSS | Зависит от карточки |
| **Масштаб** | Плавный векторный зум | Обычно фиксированная картинка | SVG / виртуальный холст |
**Одной фразой:** House Plan — это общая, area-aware живая карта дома, а не
редактор мебели. Если нужно с нуля нарисовать диваны и стены — лучше
draw-карточка; если есть картинка плана и зоны в HA — House Plan держит один
и тот же layout на всех планшетах.
Ключевые преимущества коротко:
@@ -97,6 +115,7 @@ House Plan показывает ваш умный дом так, как он в
кольцо = авария (протечка, дым, газ). Цвет RGB-лампы живёт в её пятне света (режим glow),
где само пятно — индикатор включения, а подложка значка остаётся стандартной.
Полупрозрачный значок = недоступно. Тёмный = покой.
- **Подложку можно двигать и масштабировать.** Картинка плана не прибита к холсту: в редакторе подложки её тянут за тело и за угол, а плашка показывает реальный размер в метрах — чертёж и фотография плана наконец совмещаются.
- **Чёткий зум.** Приближение не «мылит» картинку: план, подписи и иконки остаются векторно-чёткими на любом масштабе.
---
@@ -180,7 +199,7 @@ title: План дома
![Пустой план — предложение добавить пространство](docs/images/02-onboarding-empty.png)
В диалоге задайте **название** (например, «1 этаж») и выберите подложку: **загрузите** картинку плана (SVG, PNG, JPG, WebP), **возьмите уже загруженную** на сервер ранее или отметьте **«без подложки, нарисую комнаты сам»**. Холст всегда квадратный; картинка сохраняет свои пропорции и центрируется внутри него.
В диалоге задайте **название** (например, «1 этаж») и выберите подложку: **загрузите** картинку плана (SVG, PNG, JPG, WebP), **возьмите уже загруженную** на сервер ранее или отметьте **«без подложки, нарисую комнаты сам»**. Холст бесконечный; картинка сохраняет свои пропорции, а подвинуть и отмасштабировать её можно в любой момент в редакторе подложки.
![Диалог создания пространства](docs/images/03-space-dialog.png)
@@ -223,7 +242,7 @@ title: План дома
Как только вы сохранили комнату с привязкой к зоне, **устройства этой зоны автоматически расставляются внутри контура**. Берутся те же устройства, что показаны на странице **Настройки → Устройства → (фильтр по нужной комнате)** — только осмысленные, без служебных записей, мостов и дубликатов.
По умолчанию на план попадают только осмысленные устройства: нефизические (служебные записи, мосты, сцены, лампы, свёрнутые в световую группу) приходят с уже установленной галкой **«Скрыть устройство с плана»**. Дальше галка принадлежит вам — она есть в диалоге каждого устройства, включая виртуальные. Чтобы увидеть и вернуть скрытые, откройте редактор устройств и нажмите **«Показать скрытые»**: скрытые отобразятся полупрозрачными синими призраками, клик открывает диалог. Скрытые устройства учитываются в Zigbee-сигнале комнаты, но света не дают.
По умолчанию на план попадают только осмысленные устройства: нефизические (служебные записи, мосты, сцены, лампы, свёрнутые в световую группу) могут быть скрыты автоматически. Управление находится в левом нижнем углу диалога устройства: **«Скрыть»** убирает маркер после сохранения, а у уже скрытого маркера там же появляется **«Показать»**. Чтобы найти скрытые устройства, откройте редактор устройств и нажмите **«Показать скрытые»**: они отобразятся полупрозрачными синими призраками, клик открывает диалог. Скрытые устройства учитываются в Zigbee-сигнале комнаты, но света не дают.
Дальше можно просто пользоваться планом: клик по иконке открывает карточку устройства с моделью, ссылкой и кнопкой перехода в Home Assistant.
@@ -274,6 +293,10 @@ title: План дома
![Добавление устройства вручную](docs/images/07-marker-dialog.png)
### Свои стили через card-mod (для продвинутых, без поддержки)
Карточка приезжает готовой, и поля для CSS у неё нет — но если у вас уже стоит [card-mod](https://github.com/thomasloven/lovelace-card-mod), у каждого объекта плана теперь есть стабильный «крючок», за который можно зацепиться: `data-hp="device"` (плюс `data-entity`, `data-area`), `data-hp="room"`, `data-hp="opening"`, `data-hp="decor"`, `data-hp="room-label"`, `data-hp="space-tab"`. Мы обещаем их не переименовывать; сам card-mod мы не поставляем, не поддерживаем и за то, что ваш CSS сделает с карточкой, не отвечаем. Полная таблица, примеры и ограничения — в **[docs/STYLING-HOOKS.md](docs/STYLING-HOOKS.md)**.
---
## Удаление
@@ -306,7 +329,7 @@ title: План дома
**Нужно ли что-то писать в YAML?** Нет. Единственная строчка — это добавление карточки на дашборд; всё остальное делается мышкой.
**Мои устройства не появились на плане.** Устройство появляется, только если его зона в Home Assistant привязана к нарисованной комнате. Проверьте, что у устройства задана комната (Настройки → Устройства), а комната обведена и привязана к этой зоне. Если устройство есть, но скрыто (галка «Скрыть устройство с плана» — для мостов, сцен и прочих нефизических записей она ставится автоматически) — откройте редактор устройств, нажмите **«Показать скрытые»** и снимите галку в его диалоге.
**Мои устройства не появились на плане.** Устройство появляется, только если его зона в Home Assistant привязана к нарисованной комнате. Проверьте, что у устройства задана комната (Настройки → Устройства), а комната обведена и привязана к этой зоне. Если устройство есть, но скрыто автоматически — откройте редактор устройств, нажмите **«Показать скрытые»**, откройте его диалог, нажмите **«Показать»**, затем **«Сохранить»**.
**Можно ли скрыть лишнее устройство или переименовать его?** Да — кликните по устройству на плане и в его карточке нажмите «Редактировать»: там можно сменить имя, иконку, модель или скрыть значок.
+45 -2
View File
@@ -126,6 +126,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
cfg = stored.get("config")
lay_stored = await data.store.async_load() or {}
layout = lay_stored.get("layout") or {}
lay_meta = {
k: v for k, v in lay_stored.items()
if k not in ("layout", "rev", "geom_pending")
}
pending = {
str(k): v for k, v in (lay_stored.get("geom_pending") or {}).items()
}
@@ -134,14 +138,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
lay_rev = int(lay_stored.get("rev", 0))
if merged != pending: # 1. the durable intent, before anything moves
await data.store.async_save(
{"layout": layout, "rev": lay_rev, "geom_pending": merged}
{**lay_meta, "layout": layout, "rev": lay_rev, "geom_pending": merged}
)
rev = int(stored.get("rev", 0))
if cfg and migrate_config(cfg): # 2. the config half
rev += 1
await data.config_store.async_save({"config": cfg, "rev": rev})
migrate_layout(layout, merged) # 3. the layout half + intent cleared
await data.store.async_save({"layout": layout, "rev": lay_rev + 1})
await data.store.async_save({**lay_meta, "layout": layout, "rev": lay_rev + 1})
_LOGGER.info(
"House Plan: migrated %s space(s) to the square canvas", len(merged)
)
@@ -149,6 +153,45 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
# event must never see one migrated half and one old one
hass.bus.async_fire("houseplan_config_updated", {"rev": rev})
# Finish an explicit whole-plan optimization/undo interrupted between the
# config and layout store writes. The target was persisted before either
# visible half changed, so setup can always converge on the requested pair.
optimize_revs: tuple[int, int] | None = None
async with data.write_lock:
stored = await data.config_store.async_load() or {}
lay_stored = await data.store.async_load() or {}
pending = lay_stored.get("optimize_pending")
if isinstance(pending, dict) and isinstance(pending.get("config"), dict) \
and isinstance(pending.get("layout"), dict):
target_config = pending["config"]
target_layout = pending["layout"]
config_rev = int(stored.get("rev", 0))
layout_rev = int(lay_stored.get("rev", 0))
if stored.get("config") != target_config:
config_rev += 1
await data.config_store.async_save({
"config": target_config,
"rev": config_rev,
})
if lay_stored.get("layout", {}) != target_layout:
layout_rev += 1
layout_meta = {
k: v for k, v in lay_stored.items()
if k not in ("layout", "rev", "optimize_pending", "optimize_backup")
}
if not pending.get("clear_backup") and "optimize_backup" in lay_stored:
layout_meta["optimize_backup"] = lay_stored["optimize_backup"]
await data.store.async_save({
**layout_meta,
"layout": target_layout,
"rev": layout_rev,
})
optimize_revs = (config_rev, layout_rev)
_LOGGER.warning("House Plan: completed an interrupted plan optimization")
if optimize_revs is not None:
hass.bus.async_fire("houseplan_config_updated", {"rev": optimize_revs[0]})
hass.bus.async_fire("houseplan_layout_updated", {"rev": optimize_revs[1]})
await async_check_plan_files(hass, entry)
# Scheduled collection of everything nobody ended up referencing.
+4 -1
View File
@@ -24,5 +24,8 @@ def may_write(hass: HomeAssistant, user) -> bool:
entry = get_entry(hass)
if entry is None:
return is_admin
admin_only = bool(entry.options.get(CONF_ADMIN_ONLY, False))
# Default TRUE when the key is absent (audit P0-4, 2026-08-05): the card
# UI has always been admin-gated, and an unset option must not open every
# write WS/HTTP path to every authenticated household user.
admin_only = bool(entry.options.get(CONF_ADMIN_ONLY, True))
return is_admin if admin_only else True
+3 -2
View File
@@ -18,7 +18,7 @@ class HouseplanConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
return self.async_create_entry(title="House Plan", data={}, options=user_input)
return self.async_show_form(
step_id="user",
data_schema=vol.Schema({vol.Optional(CONF_ADMIN_ONLY, default=False): bool}),
data_schema=vol.Schema({vol.Optional(CONF_ADMIN_ONLY, default=True): bool}),
)
@staticmethod
@@ -32,7 +32,8 @@ class HouseplanOptionsFlow(config_entries.OptionsFlow):
async def async_step_init(self, user_input=None):
if user_input is not None:
return self.async_create_entry(title="", data=user_input)
current = self.config_entry.options.get(CONF_ADMIN_ONLY, False)
# Match auth.may_write: missing key ⇒ admin-only (audit P0-4).
current = self.config_entry.options.get(CONF_ADMIN_ONLY, True)
return self.async_show_form(
step_id="init",
data_schema=vol.Schema({vol.Optional(CONF_ADMIN_ONLY, default=current): bool}),
+1 -1
View File
@@ -45,7 +45,7 @@ PLAN_ORPHAN_TTL_S = 3600
SCHEDULED_GRACE_S = 30 * 24 * 3600
FILES_DIR = "houseplan/files"
CONF_ADMIN_ONLY = "admin_only"
VERSION = "1.56.0"
VERSION = "1.59.2"
DEFAULT_CONFIG: dict = {
"spaces": [],
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -16,5 +16,5 @@
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
"requirements": [],
"single_config_entry": true,
"version": "1.56.0"
"version": "1.59.2"
}
@@ -20,7 +20,7 @@ rules:
status: done
config-flow-test-coverage:
status: done
comment: tests_backend/test_config_flow.py (runs in CI on Python 3.13).
comment: tests_backend/test_ha_config_flow.py (runs in CI on Python 3.13).
dependency-transparency:
status: done
comment: No external requirements.
+196 -24
View File
@@ -68,6 +68,11 @@ MAX_ROOMS = 400
MAX_MARKERS = 2000
MAX_OPENINGS = 500
MAX_DECOR = 1000
MAX_WALLS = 500
# Open (virtual) wall stretches, docs/superpowers/specs/2026-08-05-open-spans-delete-design.md.
# Every span is a piece of a shared boundary, so there can never be more of
# them than there are wall segments — the cap is the walls' one (AUD-159B6-03).
MAX_OPEN_SPANS = 500
MAX_LAYOUT = 5000
# Inner limits (HP-1454-05). The outer collections were capped, the collections
# INSIDE them were not: a 150 000-point polygon or a 100 000-entry known_devices
@@ -95,11 +100,15 @@ _TEXT = vol.All(str, vol.Length(max=MAX_TEXT))
_TEXT_OR_NONE = vol.Any(None, _TEXT)
_URL = vol.All(str, vol.Length(max=MAX_URL))
# Positions are normalised to the canvas (0..1). Allow generous slack for an
# icon dragged past an edge, but not arbitrary magnitudes: any finite float
# used to pass, and a single stored 1e100 stretched every client's view of the
# space until the plan was invisible (HP-1500-03).
_COORD = vol.All(_finite, vol.Range(min=-4.0, max=4.0))
# The canvas is UNBOUNDED (docs/CANVAS.md). Coordinates are still normalised —
# 1.0 is still one canvas width — but there is no frame any more, so a plan may
# legitimately live at 2.7 or -1.4. The range below is GARBAGE INSURANCE, not a
# boundary: at the product's own scale (cell_cm=5, 240 cells across the unit
# width) 5000 is about 60 km of plan, unreachable in a home, while a stored
# 1e100 still cannot stretch every client's view until the plan is invisible
# (HP-1500-03 / HP-1501-01). Widened from +/-4 on 2026-08-03.
CANVAS_LIMIT = 5000.0
_COORD = vol.All(_finite, vol.Range(min=-CANVAS_LIMIT, max=CANVAS_LIMIT))
POS_SCHEMA = vol.Schema(
{vol.Required("x"): _COORD, vol.Required("y"): _COORD},
@@ -107,19 +116,26 @@ POS_SCHEMA = vol.Schema(
)
LAYOUT_SCHEMA = vol.All(vol.Schema({str: POS_SCHEMA}), vol.Length(max=MAX_LAYOUT))
# Geometry is normalised to the canvas (0..1). ±4 is generous slack for a
# vertex nudged past an edge, not an envelope for arbitrary magnitudes: any
# finite float used to pass here, and a single 1e100 room vertex stretched the
# frame until the whole space was unviewable for every client (HP-1501-01) —
# the exact failure HP-1500-03 closed for layout positions, one schema over.
_GEOM = vol.All(_finite, vol.Range(min=-4.0, max=4.0))
# Room/opening geometry: same story, same range (docs/CANVAS.md). A vertex at
# 2.5 is a plan that grew past the old square, not corruption; 1e100 is
# corruption (HP-1501-01, the room-geometry twin of HP-1500-03).
_GEOM = vol.All(_finite, vol.Range(min=-CANVAS_LIMIT, max=CANVAS_LIMIT))
# A SIZE is not a coordinate (HP-1502-01): SVG requires positive width/height,
# and the clients divide by these. `view_box: [0,0,0,0]` passed the shared
# validator and serialised into viewBox="0 0 0 0" — a blank plan on every
# client. The floor is one thousandth of the canvas (1 render unit): far below
# any real room, but keeps the maths finite.
_EXTENT = vol.All(_finite, vol.Range(min=0.001, max=4.0))
# any real room, but keeps the maths finite. The CEILING follows the canvas
# (docs/CANVAS.md) — a room on an unbounded plane may legitimately be wider
# than the old unit square — while staying strictly positive.
_EXTENT = vol.All(_finite, vol.Range(min=0.001, max=CANVAS_LIMIT))
# The backdrop's uniform scale (docs/BACKDROP.md). A MULTIPLIER, not a
# coordinate: strictly positive, and bounded by what a person could mean —
# a hundredth of the canvas is already a thumbnail, a hundred canvases is
# already absurd. Mirrored by PLAN_SCALE_MIN/MAX in src/space-geometry.ts.
PLAN_SCALE_MIN = 0.01
PLAN_SCALE_MAX = 100.0
def _view_box(value):
@@ -131,6 +147,45 @@ def _view_box(value):
POINT = vol.All([_GEOM], vol.Length(min=2, max=2))
# A virtual stretch shorter than this is a click, not a span. Mirrors
# OPEN_SPAN_MIN_UNITS in src/open-spans.ts (normalised units).
OPEN_SPAN_MIN_LEN = 1e-3
def _open_span(value):
"""One virtual stretch: exactly two distinct finite points a/b.
AUD-159B6-03: the field used to ride on `extra=ALLOW_EXTRA` — any shape
passed the backend and the card crashed reading `e.a[0]` on render, for
every reader of that space. A degenerate span (a == b) is not a stretch
either: it can never be hit, closed or drawn, so it is corruption, not data.
"""
if not isinstance(value, dict):
raise vol.Invalid("open_span must be an object with a/b points")
a = POINT(value.get("a"))
b = POINT(value.get("b"))
if abs(a[0] - b[0]) < OPEN_SPAN_MIN_LEN and abs(a[1] - b[1]) < OPEN_SPAN_MIN_LEN:
raise vol.Invalid("open_span: a and b must differ")
out = {k: v for k, v in value.items() if k not in ("a", "b")}
out["a"] = a
out["b"] = b
return out
def _dedupe_open_spans(value):
"""Drop repeats of the same stretch (either direction) — one wall, one span."""
seen = set()
out = []
for span in value:
a, b = span["a"], span["b"]
fwd = (round(a[0], 6), round(a[1], 6), round(b[0], 6), round(b[1], 6))
key = min(fwd, (fwd[2], fwd[3], fwd[0], fwd[1]))
if key in seen:
continue
seen.add(key)
out.append(span)
return out
def _require_geometry(room: dict) -> dict:
if "poly" in room or all(k in room for k in ("x", "y", "w", "h")):
@@ -196,6 +251,9 @@ SPACE_DISPLAY_SCHEMA = vol.Schema(
vol.Optional("temp_min"): vol.Coerce(float),
vol.Optional("temp_max"): vol.Coerce(float),
vol.Optional("show_lqi"): bool,
# "draw less" switches; absent = False = everything is drawn as before
vol.Optional("hide_decor"): bool,
vol.Optional("hide_openings"): bool,
vol.Optional("label_temp"): bool,
vol.Optional("label_hum"): bool,
vol.Optional("label_lqi"): bool,
@@ -209,12 +267,43 @@ SPACE_DISPLAY_SCHEMA = vol.Schema(
extra=vol.ALLOW_EXTRA,
)
# Live text on a decor label (docs/LIVE-TEXT.md). An entity id is
# `<domain>.<object_id>`; HA itself allows only lowercase letters, digits and
# underscores in both halves. The bound is a sanity limit, not a policy.
MAX_ENTITY_ID = 255
_ENTITY_ID = vol.All(str, vol.Length(min=3, max=MAX_ENTITY_ID),
vol.Match(r"^[a-z0-9_]+\.[a-z0-9_]+$"))
# A caption is a caption: the inline-reference template is bounded. Attribute
# and unit bounds below apply only to legacy beta.9 link fields, which remain
# accepted so an older saved plan can reach the frontend and migrate on edit.
MAX_DECOR_TEXT = 200
MAX_DECOR_ATTR = 64
MAX_DECOR_UNIT = 16
# The text block is scaled by dragging its corners; the range is what a human
# could mean on a 1000-unit canvas, the rest is garbage insurance.
DECOR_TEXT_SCALE_MIN = 0.15
DECOR_TEXT_SCALE_MAX = 20.0
# A furniture symbol id (docs/FURNITURE.md). Deliberately NOT the card's list:
# the backend must accept a plan written by a NEWER card, and a card that has
# learnt a new symbol must not have to wait for the integration to be updated
# before the user can save. What is enforced is the shape of the id — a flat
# lowercase name — and its length; an id this backend has never heard of simply
# renders as nothing in an older card.
MAX_FURN_SYMBOL = 32
_FURN_SYMBOL = vol.All(str, vol.Length(min=1, max=MAX_FURN_SYMBOL),
vol.Match(r"^[a-z0-9_]+$"))
# …and its size: strictly positive, capped by the same canvas insurance limit
# an opening's length is. A piece of furniture is a SIZE, not a coordinate.
_FURN_SIZE = vol.All(_finite, vol.Range(min=0.0000001, max=CANVAS_LIMIT))
_DECOR_COMMON = {
vol.Required("id"): str,
vol.Optional("color"): vol.Match(r"^#[0-9a-fA-F]{6}$"),
vol.Optional("width"): vol.All(vol.Coerce(float), vol.Range(min=0.1, max=30)),
}
_NORM = vol.All(vol.Coerce(float), vol.Range(min=-1, max=2))
# Decor lives on the same unbounded canvas as everything else (docs/CANVAS.md):
# it used to be pinned to -1..2, i.e. "one canvas of slack around the square".
_NORM = vol.All(_finite, vol.Range(min=-CANVAS_LIMIT, max=CANVAS_LIMIT))
DECOR_SCHEMA = vol.Any(
vol.Schema({**_DECOR_COMMON, vol.Required("kind"): "line",
vol.Required("x1"): _NORM, vol.Required("y1"): _NORM,
@@ -222,19 +311,72 @@ DECOR_SCHEMA = vol.Any(
extra=vol.ALLOW_EXTRA),
vol.Schema({**_DECOR_COMMON, vol.Required("kind"): vol.In(["rect", "ellipse"]),
vol.Required("x"): _NORM, vol.Required("y"): _NORM,
vol.Required("w"): _NORM, vol.Required("h"): _NORM,
# sizes are extents — negative/zero is garbage, not "canvas slack"
vol.Required("w"): vol.All(_finite, vol.Range(min=0.001, max=CANVAS_LIMIT)),
vol.Required("h"): vol.All(_finite, vol.Range(min=0.001, max=CANVAS_LIMIT)),
vol.Optional("fill"): bool},
extra=vol.ALLOW_EXTRA),
vol.Schema({**_DECOR_COMMON, vol.Required("kind"): "text",
vol.Required("x"): _NORM, vol.Required("y"): _NORM,
vol.Required("text"): vol.All(str, vol.Length(min=1, max=200)),
vol.Optional("size"): vol.In(["s", "m", "l"])},
# the template: newlines are the user's own line breaks and are
# kept verbatim (docs/LIVE-TEXT.md); the label never wraps itself
vol.Required("text"): vol.All(str, vol.Length(min=1, max=MAX_DECOR_TEXT)),
# legacy font size ('s'|'m'|'l'). The dialog no longer offers it
# — the block is scaled by its corner handles — but a plan
# written before that keeps it, and it is read as the scale it
# used to render at. Kept in the schema so it stays BOUNDED.
vol.Optional("size"): vol.In(["s", "m", "l"]),
# size and rotation of the block (docs/LIVE-TEXT.md §block).
# Both optional: a shape without them is the old one exactly.
vol.Optional("scale"): vol.All(
_finite, vol.Range(min=DECOR_TEXT_SCALE_MIN, max=DECOR_TEXT_SCALE_MAX)),
vol.Optional("angle"): vol.All(_finite, vol.Range(min=-360.0, max=360.0)),
# Legacy one-value link (beta.9 and earlier). New labels store
# every `{entity[:attribute]}` reference directly in `text`;
# these stay accepted solely for backward compatibility.
vol.Optional("entity"): vol.Any(None, _ENTITY_ID),
vol.Optional("attr"): vol.Any(None, vol.All(str, vol.Length(max=MAX_DECOR_ATTR))),
vol.Optional("unit"): vol.Any(None, vol.All(str, vol.Length(max=MAX_DECOR_UNIT)))},
extra=vol.ALLOW_EXTRA),
# A piece of furniture (docs/FURNITURE.md): a symbol id, a normalised box
# and an optional rotation. It is a NEW kind, so no existing plan carries
# it, nothing is migrated, and an integration that has this branch reads
# every older config byte-for-byte as before.
vol.Schema({**_DECOR_COMMON, vol.Required("kind"): "furniture",
vol.Required("symbol"): _FURN_SYMBOL,
vol.Required("x"): _NORM, vol.Required("y"): _NORM,
vol.Required("w"): _FURN_SIZE, vol.Required("h"): _FURN_SIZE,
vol.Optional("angle"): vol.All(_finite, vol.Range(min=-360.0, max=360.0))},
extra=vol.ALLOW_EXTRA),
)
def _wall_endpoints_pair(entry: dict) -> dict:
"""Exact wall endpoints are useful only as a complete a/b pair."""
if ("a" in entry) != ("b" in entry):
raise vol.Invalid("wall exact endpoints require both a and b")
return entry
WALL_SCHEMA = vol.All(
vol.Schema(
{
vol.Required("key"): vol.All(str, vol.Length(min=1, max=64)),
vol.Required("cm"): vol.All(_finite, vol.Range(min=1, max=100)),
# New writes retain exact normalized interval endpoints. The old
# key remains the compatibility lookup; endpoints preserve a
# differing-thickness breakpoint after a virtual span is closed.
vol.Optional("a"): vol.All([_NORM], vol.Length(min=2, max=2)),
vol.Optional("b"): vol.All([_NORM], vol.Length(min=2, max=2)),
},
extra=vol.ALLOW_EXTRA,
),
_wall_endpoints_pair,
)
SPACE_SCHEMA = vol.Schema(
{
vol.Required("id"): str,
vol.Required("id"): vol.All(str, vol.Match(SPACE_ID_RE.pattern)),
vol.Required("title"): str,
vol.Optional("settings"): SPACE_DISPLAY_SCHEMA,
vol.Optional("plan_url"): vol.Any(str, None),
@@ -247,6 +389,17 @@ SPACE_SCHEMA = vol.Schema(
vol.Optional("plan_aspect"): vol.Any(
None, vol.All(vol.Coerce(float), vol.Range(min=0.05, max=20))
),
# Backdrop placement (docs/BACKDROP.md): the picture may be moved and
# scaled UNIFORMLY on the canvas. All three are optional and their
# absence is the pre-v1.58.0 behaviour exactly — there is no migration,
# and an old config validates unchanged. The offset is a normalised
# coordinate like every other one (the ±CANVAS_LIMIT garbage guard);
# the scale is a positive multiplier in a range a human could mean.
vol.Optional("plan_x"): vol.Any(None, _COORD),
vol.Optional("plan_y"): vol.Any(None, _COORD),
vol.Optional("plan_scale"): vol.Any(
None, vol.All(_finite, vol.Range(min=PLAN_SCALE_MIN, max=PLAN_SCALE_MAX))
),
vol.Required("view_box"): _view_box,
vol.Required("rooms"): vol.All([ROOM_SCHEMA], vol.Length(max=MAX_ROOMS)),
vol.Optional("decor"): vol.All([DECOR_SCHEMA], vol.Length(max=MAX_DECOR)),
@@ -258,7 +411,9 @@ SPACE_SCHEMA = vol.Schema(
vol.Required("x"): _GEOM,
vol.Required("y"): _GEOM,
vol.Required("angle"): vol.All(_finite, vol.Range(min=-360.0, max=360.0)),
vol.Required("length"): vol.All(vol.Coerce(float), vol.Range(min=0.001, max=1)),
# a SIZE: strictly positive, capped by the canvas insurance
# limit rather than by the old unit square (docs/CANVAS.md)
vol.Required("length"): vol.All(_finite, vol.Range(min=0.001, max=CANVAS_LIMIT)),
vol.Optional("contact"): vol.Any(str, None),
vol.Optional("lock"): vol.Any(str, None),
vol.Optional("invert"): bool,
@@ -268,6 +423,17 @@ SPACE_SCHEMA = vol.Schema(
extra=vol.ALLOW_EXTRA,
)
], vol.Length(max=MAX_OPENINGS)),
# Wall thickness (docs/WALL-THICKNESS.md): keyed by a segment identity
# (midpoint + direction), thickness always in centimetres. Optional —
# a space without `walls` validates and renders exactly as before.
vol.Optional("walls"): vol.All([WALL_SCHEMA], vol.Length(max=MAX_WALLS)),
# Open (virtual) wall stretches: a piece of a shared boundary that the
# user opened. Optional and bounded — a space without `open_spans`
# validates exactly as before, and the legacy `rooms[].open_to` index
# keeps working on its own (AUD-159B6-03).
vol.Optional("open_spans"): vol.All(
vol.Length(max=MAX_OPEN_SPANS), [_open_span], _dedupe_open_spans,
),
# Legacy: walls are derived from room outlines since v1.19.0 — a line has no
# independent existence. Still accepted so a stale browser tab cannot fail a save;
# the card strips the field on every write.
@@ -282,7 +448,11 @@ MARKER_SCHEMA = vol.Schema(
{
vol.Required("id"): str,
# 'device:<device_id>' | 'entity:<entity_id>' | 'virtual'
vol.Required("binding"): str,
vol.Required("binding"): vol.All(
str,
vol.Length(min=1, max=MAX_TEXT),
vol.Match(r"^(device:.+|entity:.+|virtual)$"),
),
vol.Optional("space"): vol.Any(str, None),
vol.Optional("area"): vol.Any(str, None),
vol.Optional("hidden"): bool,
@@ -291,7 +461,7 @@ MARKER_SCHEMA = vol.Schema(
vol.Optional("model"): _TEXT_OR_NONE,
vol.Optional("link"): vol.Any(None, _URL),
vol.Optional("description"): vol.Any(None, vol.All(str, vol.Length(max=MAX_DESCRIPTION))),
vol.Optional("tap_action"): vol.Any("info", "more-info", "toggle", "run", None),
vol.Optional("tap_action"): vol.Any("info", "more-info", "toggle", "run", "cover", None),
# the 'run' target: only the runnable domains, nothing else is callable
vol.Optional("tap_target"): vol.Any(
None, vol.All(str, vol.Length(max=MAX_TEXT), vol.Match(r"^(automation|script|scene)\.[A-Za-z0-9_]+$"))
@@ -322,10 +492,12 @@ MARKER_SCHEMA = vol.Schema(
# climate current_temperature: badge + room-average vote (off unless True)
vol.Optional("use_climate_temp"): vol.Any(bool, None),
vol.Optional("room_id"): vol.Any(str, None),
# keep in sync with DISPLAY_MODES in src/logic.ts — a cross-language test
# asserts every option the editor offers is accepted here (issue #3)
# Keep in sync with DISPLAY_MODES in src/logic.ts. `ripple` is no longer
# offered, but remains accepted while old stores migrate to icon_ripple.
vol.Optional("display"): vol.Any("badge", "ripple", "icon_ripple", "value", None),
vol.Optional("ripple_color"): vol.Any(str, None),
vol.Optional("ripple_color"): vol.Any(
None, vol.Match(r"^#[0-9a-fA-F]{6}$")
),
vol.Optional("ripple_size"): vol.Any(vol.All(vol.Coerce(float), vol.Range(min=1, max=20)), None),
vol.Optional("size"): vol.Any(vol.All(vol.Coerce(float), vol.Range(min=0.2, max=6)), None),
vol.Optional("angle"): vol.Any(vol.All(vol.Coerce(float), vol.Range(min=-360, max=360)), None),
+243 -6
View File
@@ -7,6 +7,7 @@ import base64
import binascii
import json
import secrets
import time
from pathlib import Path
from typing import Any
@@ -36,6 +37,36 @@ from .validation import (
_LOGGER = logging.getLogger(__name__)
_OPTIMIZE_BACKUP = "optimize_backup"
_OPTIMIZE_PENDING = "optimize_pending"
def _optimizer_backup_is_current(config_data: dict[str, Any], layout_data: dict[str, Any]) -> bool:
"""An optimization can be undone only before any later plan edit."""
backup = layout_data.get(_OPTIMIZE_BACKUP)
if not isinstance(backup, dict):
return False
try:
return (
int(backup.get("after_config_rev", -1)) == int(config_data.get("rev", 0))
and int(backup.get("after_layout_rev", -1)) == int(layout_data.get("rev", 0))
)
except (TypeError, ValueError):
return False
async def _discard_optimizer_snapshot(rt: HouseplanData) -> None:
"""Free a snapshot made stale by a later ordinary config edit."""
data = await rt.store.async_load() or {}
if _OPTIMIZE_BACKUP not in data and _OPTIMIZE_PENDING not in data:
return
await rt.store.async_save({
**{
k: v for k, v in data.items()
if k not in (_OPTIMIZE_BACKUP, _OPTIMIZE_PENDING)
}
})
@callback
def async_register(hass: HomeAssistant) -> None:
@@ -48,6 +79,8 @@ def async_register(hass: HomeAssistant) -> None:
websocket_api.async_register_command(hass, ws_layout_delete)
websocket_api.async_register_command(hass, ws_config_get)
websocket_api.async_register_command(hass, ws_config_set)
websocket_api.async_register_command(hass, ws_plan_optimize)
websocket_api.async_register_command(hass, ws_plan_optimize_undo)
websocket_api.async_register_command(hass, ws_plan_set)
websocket_api.async_register_command(hass, ws_plans_list)
websocket_api.async_register_command(hass, ws_plans_delete)
@@ -85,8 +118,13 @@ async def ws_layout_get(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
if rt is None:
return
data = await rt.store.async_load() or {}
config_data = await rt.config_store.async_load() or {}
connection.send_result(
msg["id"], {"layout": data.get("layout", {}), "rev": int(data.get("rev", 0))}
msg["id"], {
"layout": data.get("layout", {}),
"rev": int(data.get("rev", 0)),
"can_optimize_undo": _optimizer_backup_is_current(config_data, data),
}
)
@@ -121,7 +159,8 @@ async def ws_layout_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
)
return
new_rev = current_rev + 1
await rt.store.async_save({**{k: v for k, v in data.items() if k not in ("layout", "rev")},
await rt.store.async_save({**{k: v for k, v in data.items() if k not in (
"layout", "rev", _OPTIMIZE_BACKUP, _OPTIMIZE_PENDING)},
"layout": msg["layout"], "rev": new_rev})
hass.bus.async_fire("houseplan_layout_updated", {"rev": new_rev})
connection.send_result(msg["id"], {"ok": True, "rev": new_rev})
@@ -151,7 +190,8 @@ async def ws_layout_update(hass: HomeAssistant, connection, msg: dict[str, Any])
# optimistic locking on layout/set meaningless — every drag reset the
# counter to 0 (HP-1454-08)
new_rev = int(data.get("rev", 0)) + 1
await rt.store.async_save({**{k: v for k, v in data.items() if k not in ("layout", "rev")},
await rt.store.async_save({**{k: v for k, v in data.items() if k not in (
"layout", "rev", _OPTIMIZE_BACKUP, _OPTIMIZE_PENDING)},
"layout": layout, "rev": new_rev})
hass.bus.async_fire("houseplan_layout_updated", {"rev": new_rev})
connection.send_result(msg["id"], {"ok": True, "rev": new_rev})
@@ -548,7 +588,8 @@ async def ws_layout_delete(hass: HomeAssistant, connection, msg: dict[str, Any])
if msg["device_id"] in layout:
del layout[msg["device_id"]]
new_rev = int(data.get("rev", 0)) + 1
await rt.store.async_save({**{k: v for k, v in data.items() if k not in ("layout", "rev")},
await rt.store.async_save({**{k: v for k, v in data.items() if k not in (
"layout", "rev", _OPTIMIZE_BACKUP, _OPTIMIZE_PENDING)},
"layout": layout, "rev": new_rev})
if new_rev is not None:
hass.bus.async_fire("houseplan_layout_updated", {"rev": new_rev})
@@ -561,13 +602,27 @@ async def ws_layout_delete(hass: HomeAssistant, connection, msg: dict[str, Any])
@websocket_api.websocket_command({vol.Required("type"): "houseplan/config/get"})
@websocket_api.async_response
async def ws_config_get(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
"""Return the configuration and its revision."""
"""Return the configuration, its revision, and whether this user may write.
`can_write` is the single source of truth for the card's editor chrome
(audit P0-4): the UI must mirror `may_write`, not a hard-coded is_admin
check that drifted from the integration option.
"""
rt = _runtime(hass, connection, msg["id"])
if rt is None:
return
data = await rt.config_store.async_load() or {}
layout_data = await rt.store.async_load() or {}
config = {**DEFAULT_CONFIG, **data.get("config", {})}
connection.send_result(msg["id"], {"config": config, "rev": data.get("rev", 0)})
connection.send_result(
msg["id"],
{
"config": config,
"rev": data.get("rev", 0),
"can_write": may_write(hass, getattr(connection, "user", None)),
"can_optimize_undo": _optimizer_backup_is_current(data, layout_data),
},
)
@@ -679,6 +734,10 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
return
new_rev = current_rev + 1
await rt.config_store.async_save({"config": msg["config"], "rev": new_rev})
try:
await _discard_optimizer_snapshot(rt)
except Exception: # noqa: BLE001 — stale backup cleanup is best-effort
_LOGGER.exception("House Plan: discarding stale optimization backup failed")
# Still holding the lock: the file system is not part of the store's
# transaction, so collection has to be pinned to this commit (R3-1).
# It is best-effort housekeeping behind an already durable write — a
@@ -704,6 +763,184 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
connection.send_result(msg["id"], {"ok": True, "rev": new_rev})
# ---------------- whole-plan maintenance ----------------
@websocket_api.websocket_command(
{
vol.Required("type"): "houseplan/plan/optimize",
vol.Required("config"): CONFIG_SCHEMA,
vol.Required("layout"): LAYOUT_SCHEMA,
vol.Required("expected_config_rev"): int,
vol.Required("expected_layout_rev"): int,
}
)
@websocket_api.async_response
async def ws_plan_optimize(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
"""Atomically-intended replacement of config+layout with one-deep undo.
Home Assistant stores are separate files, so a literal cross-file
transaction is impossible. Persisting the target as an intent before
either half changes makes a crash resumable during the next setup; the UI
only receives success once both halves are durable.
"""
if not _check_write(hass, connection):
connection.send_error(msg["id"], "unauthorized", "Only administrators may optimize plans")
return
rt = _runtime(hass, connection, msg["id"])
if rt is None:
return
size = len(json.dumps(msg["config"], separators=(",", ":")))
if size > MAX_CONFIG_BYTES:
connection.send_error(
msg["id"], "too_large",
f"Configuration is {size // 1024} KB, the limit is {MAX_CONFIG_BYTES // 1024} KB",
)
return
async with rt.write_lock:
config_data = await rt.config_store.async_load() or {}
layout_data = await rt.store.async_load() or {}
config_rev = int(config_data.get("rev", 0))
layout_rev = int(layout_data.get("rev", 0))
if msg["expected_config_rev"] != config_rev or msg["expected_layout_rev"] != layout_rev:
connection.send_error(
msg["id"], "conflict",
f"Plan changed elsewhere (config {config_rev}, layout {layout_rev})",
)
return
missing = await hass.async_add_executor_job(
_missing_internal_plans,
Path(hass.config.path(PLANS_DIR)),
msg["config"],
config_data.get("config"),
)
if missing:
connection.send_error(
msg["id"], "missing_plan",
"Plan file no longer exists: " + ", ".join(sorted(missing)),
)
return
new_config_rev = config_rev + 1
new_layout_rev = layout_rev + 1
backup = {
"config": config_data.get("config") or DEFAULT_CONFIG,
"layout": layout_data.get("layout", {}),
"created": int(time.time()),
"after_config_rev": new_config_rev,
"after_layout_rev": new_layout_rev,
}
pending = {
"config": msg["config"],
"layout": msg["layout"],
"clear_backup": False,
}
layout_meta = {
k: v for k, v in layout_data.items()
if k not in ("layout", "rev", _OPTIMIZE_BACKUP, _OPTIMIZE_PENDING)
}
# Intent first. A setup-time finisher completes whichever half a crash
# interrupted; until then the visible layout/revision remain unchanged.
await rt.store.async_save({
**layout_meta,
"layout": layout_data.get("layout", {}),
"rev": layout_rev,
_OPTIMIZE_BACKUP: backup,
_OPTIMIZE_PENDING: pending,
})
await rt.config_store.async_save({"config": msg["config"], "rev": new_config_rev})
await rt.store.async_save({
**layout_meta,
"layout": msg["layout"],
"rev": new_layout_rev,
_OPTIMIZE_BACKUP: backup,
})
hass.bus.async_fire("houseplan_config_updated", {"rev": new_config_rev})
hass.bus.async_fire("houseplan_layout_updated", {"rev": new_layout_rev})
_refresh_trail_recorder(hass)
connection.send_result(msg["id"], {
"ok": True,
"config_rev": new_config_rev,
"layout_rev": new_layout_rev,
"can_undo": True,
})
@websocket_api.websocket_command(
{
vol.Required("type"): "houseplan/plan/optimize_undo",
vol.Required("expected_config_rev"): int,
vol.Required("expected_layout_rev"): int,
}
)
@websocket_api.async_response
async def ws_plan_optimize_undo(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
"""Restore the snapshot, but never overwrite edits made after optimization."""
if not _check_write(hass, connection):
connection.send_error(msg["id"], "unauthorized", "Only administrators may undo optimization")
return
rt = _runtime(hass, connection, msg["id"])
if rt is None:
return
async with rt.write_lock:
config_data = await rt.config_store.async_load() or {}
layout_data = await rt.store.async_load() or {}
config_rev = int(config_data.get("rev", 0))
layout_rev = int(layout_data.get("rev", 0))
if msg["expected_config_rev"] != config_rev or msg["expected_layout_rev"] != layout_rev:
connection.send_error(msg["id"], "conflict", "Plan changed elsewhere")
return
if not _optimizer_backup_is_current(config_data, layout_data):
connection.send_error(
msg["id"], "no_backup",
"The optimization backup is unavailable or a later edit made it stale",
)
return
backup = layout_data[_OPTIMIZE_BACKUP]
restored_config = backup.get("config") or DEFAULT_CONFIG
restored_layout = backup.get("layout") or {}
new_config_rev = config_rev + 1
new_layout_rev = layout_rev + 1
pending = {
"config": restored_config,
"layout": restored_layout,
"clear_backup": True,
}
layout_meta = {
k: v for k, v in layout_data.items()
if k not in ("layout", "rev", _OPTIMIZE_BACKUP, _OPTIMIZE_PENDING)
}
await rt.store.async_save({
**layout_meta,
"layout": layout_data.get("layout", {}),
"rev": layout_rev,
_OPTIMIZE_BACKUP: backup,
_OPTIMIZE_PENDING: pending,
})
await rt.config_store.async_save({"config": restored_config, "rev": new_config_rev})
await rt.store.async_save({
**layout_meta,
"layout": restored_layout,
"rev": new_layout_rev,
})
hass.bus.async_fire("houseplan_config_updated", {"rev": new_config_rev})
hass.bus.async_fire("houseplan_layout_updated", {"rev": new_layout_rev})
_refresh_trail_recorder(hass)
connection.send_result(msg["id"], {
"ok": True,
"config_rev": new_config_rev,
"layout_rev": new_layout_rev,
"can_undo": False,
})
# ---------------- plan uploads ----------------
+67
View File
@@ -0,0 +1,67 @@
// Backdrop image (docs/BACKDROP.md) — two shots for the owner:
// backdrop_frame — the transform frame mid-gesture: dashed outline, four
// finger-sized corner handles, and the live "W × H" badge
// stating the picture's real size through cell_cm;
// backdrop_paper — the new paper rule on an image plan: the opaque sheet is
// the ROOM CONTOURS, so the scene colour is visible around
// them and under the parts of the picture nobody has drawn
// over. The picture is scaled down here so both are obvious.
// Usage: node demo/shot_backdrop.mjs <outdir>
import { launch } from './serve.mjs';
const outDir = process.argv[2] || '/tmp';
const { page, browser } = await launch({ width: 900, height: 820 }, 2);
await page.emulateMedia({ reducedMotion: 'reduce' });
const settle = () => page.waitForTimeout(260);
// ---- 1) the frame, mid-drag ---------------------------------------------
await page.evaluate(async () => {
const c = window.__card;
c._setMode('decor');
c._decorTool = 'backdrop';
c.requestUpdate();
await c.updateComplete;
});
await settle();
const screenPt = (x, y) => page.evaluate(([x, y]) => {
const stage = window.__card.renderRoot.querySelector('.stage');
const r = stage.getBoundingClientRect();
const [vx, vy, vw, vh] = stage.querySelector('svg').getAttribute('viewBox').split(' ').map(Number);
return [r.left + ((x - vx) / vw) * r.width, r.top + ((y - vy) / vh) * r.height];
}, [x, y]);
{
// grab the bottom-right handle and hold it mid-gesture, so the shot carries
// the frame AND the live badge at once
const [ax, ay] = await screenPt(1000, 900);
const [bx, by] = await screenPt(820, 760);
await page.mouse.move(ax, ay);
await page.mouse.down();
await page.mouse.move((ax + bx) / 2, (ay + by) / 2, { steps: 4 });
await settle();
await page.screenshot({ path: `${outDir}/backdrop_frame.png` });
await page.mouse.up();
}
await settle();
// ---- 2) the paper is the rooms ------------------------------------------
await page.evaluate(async () => {
const c = window.__card;
const sp = c._serverCfg.spaces.find((s) => s.id === 'f1');
// a picture pulled well inside the plan and an acid scene colour: what shows
// through around the rooms is the SCENE, which is the whole new rule
sp.plan_x = 0.16; sp.plan_y = 0.10; sp.plan_scale = 0.62;
c._serverCfg.settings = { ...(c._serverCfg.settings || {}), bg_color: '#7bd389' };
c._cfgEpoch++; c._frame = null;
c._setMode('view');
c.requestUpdate();
await c.updateComplete;
c._fitAll();
c.requestUpdate();
await c.updateComplete;
});
await settle();
await settle();
await page.screenshot({ path: `${outDir}/backdrop_paper.png` });
await browser.close();
console.log('shots written to ' + outDir + '/backdrop_frame.png, ' + outDir + '/backdrop_paper.png');
+33
View File
@@ -0,0 +1,33 @@
// Capture: a curtain on the move — the semantic transition ring around a
// NEUTRAL plate (owner 2026-08-03). The pulse is frozen at a visible frame so
// the shot is deterministic.
import { launch } from './serve.mjs';
const { page, browser } = await launch({ width: 820, height: 760 }, 2);
await page.evaluate(async () => {
const c = window.__card;
c._serverCfg = { ...c._serverCfg, markers: [
{ id: 'm_gate', binding: 'device:d_gate', tap_action: 'cover', display: 'icon_ripple' },
{ id: 'm_mower', binding: 'device:d_mower', hidden: true },
] };
c._cfgEpoch++; c._regSignature = ''; c._maybeRebuildDevices();
c.hass = { ...c.hass, states: { ...c.hass.states,
'cover.gate': { entity_id: 'cover.gate', state: 'opening',
attributes: { friendly_name: 'Curtain', device_class: 'curtain' } } } };
c._setMode('view'); c._space = 'garden';
c.requestUpdate();
await c.updateComplete;
const st = document.createElement('style');
st.textContent = '.activity-ring.transition i:first-child{animation-delay:-1.1s!important;animation-play-state:paused!important;}';
(c.shadowRoot || c.renderRoot).appendChild(st);
});
await page.waitForTimeout(400);
const box = await page.evaluate(() => {
const c = window.__card;
const r = (c.shadowRoot || c.renderRoot).querySelector('.dev.activity-transition').getBoundingClientRect();
return { x: r.x, y: r.y, w: r.width, h: r.height };
});
const pad = 70;
await page.screenshot({ path: process.argv[2] || '/tmp/cover_move.png',
clip: { x: Math.max(0, box.x - pad), y: Math.max(0, box.y - pad), width: box.w + pad * 2, height: box.h + pad * 2 } });
await browser.close();
console.log('shot ok');
+70
View File
@@ -0,0 +1,70 @@
// Capture: one curtain in the four states an owner sees — closed, open,
// opening, closing (owner's contract 2026-08-04). The plate is the plain
// neutral badge in ALL of them; open/closed is told by the icon morph alone,
// and the two travelling ones add the breathing transition ring (frozen at a
// visible frame so the shot is deterministic).
import { launch } from './serve.mjs';
const { page, browser } = await launch({ width: 900, height: 520 }, 2);
const STATES = ['closed', 'open', 'opening', 'closing'];
await page.evaluate(async (STATES) => {
const c = window.__card;
const devices = {}; const entities = {}; const states = {};
STATES.forEach((s, i) => {
const id = 'd_cur' + i;
devices[id] = { id, name: 'Curtain ' + s, model: 'Roller shade driver E1',
area_id: 'garden', identifiers: [['demo', id]], entry_type: null, via_device_id: null };
entities['cover.cur' + i] = { entity_id: 'cover.cur' + i, device_id: id, platform: 'demo' };
states['cover.cur' + i] = { entity_id: 'cover.cur' + i, state: s,
attributes: { friendly_name: 'Curtain ' + s, device_class: 'curtain' } };
});
c.hass = { ...c.hass,
devices: { ...c.hass.devices, ...devices },
entities: { ...c.hass.entities, ...entities },
states: { ...c.hass.states, ...states } };
c._serverCfg = { ...c._serverCfg, markers: [
...STATES.map((s, i) => ({ id: 'm_cur' + i, binding: 'device:d_cur' + i,
tap_action: 'cover', display: 'icon_ripple' })),
{ id: 'm_mower', binding: 'device:d_mower', hidden: true },
{ id: 'm_gate', binding: 'device:d_gate', hidden: true },
] };
c._cfgEpoch++; c._regSignature = ''; c._maybeRebuildDevices();
const layout = { ...c._layout };
STATES.forEach((s, i) => {
const d = c._devices.find((x) => x.bindingRef === 'd_cur' + i);
if (d) layout[d.id] = { s: 'garden', x: 0.18 + i * 0.215, y: 0.5 };
});
c._layout = layout;
c._setMode('view'); c._space = 'garden';
c.requestUpdate();
await c.updateComplete;
const st = document.createElement('style');
st.textContent = '.activity-ring.transition i:first-child{animation-delay:-1.1s!important;animation-play-state:paused!important;}';
(c.shadowRoot || c.renderRoot).appendChild(st);
}, STATES);
await page.waitForTimeout(500);
// captions under each badge, so the four states are readable in the file
const box = await page.evaluate((STATES) => {
const c = window.__card;
const els = [...(c.shadowRoot || c.renderRoot).querySelectorAll('.devlayer .dev')]
.sort((a, b) => a.getBoundingClientRect().x - b.getBoundingClientRect().x);
let minX = 1e9, maxX = -1e9, minY = 1e9, maxY = -1e9;
els.forEach((el, i) => {
const r = el.getBoundingClientRect();
const cap = document.createElement('div');
cap.textContent = STATES[i];
cap.style.cssText = `position:fixed;left:${r.x + r.width / 2}px;top:${r.y + r.height + 10}px;`
+ 'transform:translateX(-50%);font:600 13px system-ui,sans-serif;color:#1c2530;'
+ 'letter-spacing:.02em;z-index:9999;padding:2px 7px;border-radius:6px;'
+ 'background:rgba(255,255,255,0.88);white-space:nowrap;';
document.body.appendChild(cap);
minX = Math.min(minX, r.x); maxX = Math.max(maxX, r.x + r.width);
minY = Math.min(minY, r.y); maxY = Math.max(maxY, r.y + r.height);
});
return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
}, STATES);
const pad = 55;
await page.screenshot({ path: process.argv[2] || '/tmp/cover_states.png',
clip: { x: Math.max(0, box.x - pad), y: Math.max(0, box.y - pad),
width: box.w + pad * 2, height: box.h + pad * 2 + 22 } });
await browser.close();
console.log('shot ok');
+63
View File
@@ -0,0 +1,63 @@
// Скриншоты библиотеки мебели: палитра и расставленная мебель на плане.
import { launch } from './serve.mjs';
const OUT = process.env.HP_SHOT_DIR || '/tmp';
const { page, browser } = await launch({ width: 900, height: 900 }, 2);
await page.evaluate(async () => {
const c = window.__card;
const sr = c.shadowRoot || c.renderRoot;
sr.querySelectorAll('.modetab')[2].click();
await c.updateComplete;
c._curSpaceCfg.decor = [];
c._decorTool = 'furniture';
c._cfgEpoch++; c.requestUpdate();
await c.updateComplete;
sr.querySelector('.furnitem[data-symbol="sofa"]').click();
await c.updateComplete;
});
await page.waitForTimeout(400);
await page.screenshot({ path: `${OUT}/furniture_palette.png` });
await page.evaluate(async () => {
const c = window.__card;
const P = 1000 / 240;
const cm = (v) => (v / 5) * P / 1000; // cm -> normalised
const put = (id, symbol, w, h, cx, cy, angle) => ({
id, kind: 'furniture', symbol,
x: cx / 1000 - cm(w) / 2, y: cy / 1000 - cm(h) / 2,
w: cm(w), h: cm(h), color: '#8d6e63', width: 3,
...(angle ? { angle } : {}),
});
// r1 living 40..550 x 140..580 | r2 kitchen 550..960 x 140..460
// r3 bedroom 550..960 x 460..860 | r4 hall 40..550 x 580..860
c._curSpaceCfg.decor = [
put('f1', 'sofa', 220, 90, 250, 140 + 37.5),
put('f2', 'coffee_table', 110, 60, 250, 300),
put('f3', 'tv', 120, 30, 250, 570),
put('f4', 'armchair', 90, 85, 90, 340, 90),
put('f5', 'table_dining', 140, 80, 700, 300),
put('f6', 'chair', 45, 45, 700, 240),
put('f7', 'chair', 45, 45, 700, 360, 180),
put('f8', 'fridge', 60, 65, 590, 140 + 27, 0),
put('f9', 'stove', 60, 60, 670, 140 + 25),
put('f10', 'kitchen_sink', 80, 60, 760, 140 + 25),
put('f11', 'dishwasher', 60, 60, 840, 140 + 25),
put('f12', 'bed_double', 160, 200, 700, 460 + 42),
put('f13', 'nightstand', 45, 40, 620, 470),
put('f14', 'nightstand', 45, 40, 780, 470),
put('f15', 'wardrobe', 100, 60, 910, 700, 90),
put('f16', 'washer', 60, 60, 120, 860 - 25, 180),
put('f17', 'toilet', 40, 70, 220, 860 - 29, 180),
put('f18', 'bathtub', 170, 75, 400, 860 - 31, 180),
put('f19', 'plant', 40, 40, 500, 620),
put('f20', 'shower', 90, 90, 490, 700),
];
c._decorTool = 'select';
c._decorSel = 'f1';
c._cfgEpoch++; c.requestUpdate();
await c.updateComplete;
});
await page.waitForTimeout(500);
await page.screenshot({ path: `${OUT}/furniture_plan.png` });
await browser.close();
console.log('shots written to', OUT);
+5 -2
View File
@@ -2,6 +2,9 @@ import { launch } from './serve.mjs';
const { page, browser } = await launch({ width: 820, height: 760 }, 2);
await page.evaluate(async () => {
const c = window.__card;
c._serverCfg.markers = (c._serverCfg.markers || []).filter((m) => m.binding !== 'device:d_motion');
c._serverCfg.markers.push({ id: 'd_motion', binding: 'device:d_motion', display: 'icon_ripple' });
c._cfgEpoch++; c._regSignature = ''; c._maybeRebuildDevices();
// the flash is keyed to a WITNESSED off→on transition (owner's rule
// 2026-08-01: разовая вспышка в момент обнаружения) — cycle through off
const set = async (state) => {
@@ -14,13 +17,13 @@ await page.evaluate(async () => {
await set('on');
// freeze the pulse at a visible frame for a deterministic capture
const st = document.createElement('style');
st.textContent = '.dev.senseflash::after{animation-delay:-0.4s;animation-play-state:paused;}';
st.textContent = '.activity-ring.event i{animation-delay:-0.4s!important;animation-play-state:paused!important;}';
(c.shadowRoot || c.renderRoot).appendChild(st);
});
await page.waitForTimeout(300);
const box = await page.evaluate(() => {
const c = window.__card;
const el = (c.shadowRoot || c.renderRoot).querySelector('.dev.senseflash');
const el = (c.shadowRoot || c.renderRoot).querySelector('.dev.activity-event');
const r = el.getBoundingClientRect();
return { x: r.x, y: r.y, w: r.width, h: r.height };
});
+27
View File
@@ -0,0 +1,27 @@
// Capture: the shoulder rulers + centre tick while PLACING a new opening
// (Opening tool, cursor hovering the wall centre) — owner 2026-08-03.
import { launch } from './serve.mjs';
const { page, browser } = await launch({ width: 900, height: 820 }, 2);
await page.evaluate(() => {
const c = window.__card;
const sp = c._serverCfg.spaces.find((s) => s.id === 'f1');
sp.openings = [];
c._setMode('plan'); c._tool = 'opening';
c._cfgEpoch++; c.requestUpdate();
return c.updateComplete && true;
});
await page.waitForTimeout(300);
const pt = await page.evaluate(() => {
const c = window.__card;
const stage = c.renderRoot.querySelector('.stage');
const r = stage.getBoundingClientRect();
const [vx, vy, vw, vh] = stage.querySelector('svg').getAttribute('viewBox').split(' ').map(Number);
return { x: r.left + ((293.7 - vx) / vw) * r.width, y: r.top + ((141 - vy) / vh) * r.height,
sx: r.left, sy: r.top, sw: r.width, sh: r.height };
});
await page.mouse.move(pt.x, pt.y, { steps: 4 });
await page.waitForTimeout(300);
await page.screenshot({ path: process.argv[2] || '/tmp/opening_place.png',
clip: { x: pt.sx, y: pt.sy, width: pt.sw, height: Math.min(pt.sh, 420) } });
await browser.close();
console.log('shot ok');
+45
View File
@@ -0,0 +1,45 @@
// Owner 2026-08-04: «углы границ комнат всё ещё с зубцами». A miter join on a
// sharp corner shoots a spike far past the walls that meet there; past the
// miter limit it flips to a flat bevel. Room borders now join round, like the
// decor lines already did. Two rooms make the defect obvious: an L (a concave
// 90° corner plus the convex ones) and a wedge with a ~45° apex — the angle
// where the miter spike is longest before the miter limit clips it to a bevel.
// Usage: node demo/shot_room_joins.mjs <outdir> <name>
import { launch } from './serve.mjs';
const outDir = process.argv[2] || '/tmp';
const name = process.argv[3] || 'room_joins';
const { page, browser } = await launch({ width: 900, height: 820 }, 6);
await page.emulateMedia({ reducedMotion: 'reduce' });
await page.evaluate(async () => {
const c = window.__card;
const sp = c._serverCfg.spaces.find((s) => s.id === 'f1');
delete sp.plan_url;
sp.rooms = [
{ id: 'rL', name: '', area: 'living_room',
poly: [[0.06, 0.04], [0.52, 0.04], [0.52, 0.14], [0.94, 0.14], [0.94, 0.26], [0.06, 0.26]] },
{ id: 'rW', name: '', area: 'kitchen',
poly: [[0.20, 0.318], [0.90, 0.55], [0.20, 0.782]] },
];
sp.settings = { ...(sp.settings || {}), show_borders: true, show_names: false,
fill_mode: 'none', room_color: '#3ea6ff', room_opacity: 1 };
c._cfgEpoch = (c._cfgEpoch || 0) + 1;
c.requestUpdate();
await c.updateComplete;
});
await page.waitForTimeout(300);
// crop tight around the wedge's ~28° apex: at plan scale the whole stage hides
// a 5-unit spike, and the spike is the entire point of the shot
const box = await page.evaluate(() => {
const c = window.__card;
const svg = c.shadowRoot.querySelector('.stage svg');
const poly = [...svg.querySelectorAll('polygon.room')].pop();
const pts = poly.getAttribute('points').split(' ').map((s) => s.split(',').map(Number));
const apex = pts.reduce((m, p) => (p[0] > m[0] ? p : m), pts[0]);
const m = svg.getScreenCTM();
return { x: m.a * apex[0] + m.c * apex[1] + m.e, y: m.b * apex[0] + m.d * apex[1] + m.f };
});
await page.screenshot({ path: `${outDir}/${name}.png`,
clip: { x: box.x - 62, y: box.y - 30, width: 72, height: 60 } });
await browser.close();
console.log('shot written to ' + outDir + '/' + name + '.png');
+2 -1
View File
@@ -46,7 +46,8 @@ await page.evaluate(async () => {
c.shadowRoot.querySelector('.compass').scrollIntoView({ block: 'center' });
});
await page.waitForTimeout(200);
const dlg = await page.evaluateHandle(() => window.__card.shadowRoot.querySelector('.dialog'));
const dlg = await page.evaluateHandle(() => window.__card.shadowRoot
.querySelector('hp-dialog').shadowRoot.querySelector('.surface'));
await dlg.asElement().screenshot({ path: outDir + '/sun_compass.png' });
await browser.close();
console.log('shots written to ' + outDir);
+46
View File
@@ -0,0 +1,46 @@
// Brightness check for the wedges (owner 2026-08-03: «поярче, иногда плохо
// видны»). Two hardest cases side by side: a DAYLIGHT sun on white paper (the
// wedge colour is nearly neutral there) and a low sun over the dark glow
// canvas. Usage: node demo/shot_sun_bright.mjs <outdir> [suffix]
import { launch } from './serve.mjs';
const outDir = process.argv[2] || '/tmp';
const tag = process.argv[3] ? '_' + process.argv[3] : '';
const { page, browser } = await launch({ width: 900, height: 860 }, 2);
// the fade is a 2 s animation; reduced motion pins the layer at full opacity
await page.emulateMedia({ reducedMotion: 'reduce' });
const setup = (fill, azimuth, elevation, ceiling) => page.evaluate(async ([fill, azimuth, elevation, ceiling]) => {
const c = window.__card;
const sp = c._serverCfg.spaces.find((s) => s.id === 'f1');
sp.openings = [
{ id: 'wN', type: 'window', x: 0.30, y: 0.14, angle: 0, length: 0.08 },
{ id: 'wE', type: 'window', x: 0.96, y: 0.60, angle: 90, length: 0.08 },
{ id: 'wS', type: 'window', x: 0.30, y: 0.86, angle: 0, length: 0.08 },
{ id: 'wW', type: 'window', x: 0.04, y: 0.30, angle: 90, length: 0.08 },
{ id: 'wW2', type: 'window', x: 0.04, y: 0.72, angle: 90, length: 0.08 },
];
sp.settings = { ...(sp.settings || {}), show_borders: true, fill_mode: fill };
c._serverCfg.settings = { ...(c._serverCfg.settings || {}),
north_deg: 0, sun_rays: true, bg_mode: 'daynight' };
c._cfgEpoch++;
c.hass = { ...c.hass, states: { ...c.hass.states,
'light.ceiling': { ...c.hass.states['light.ceiling'], state: ceiling },
'sun.sun': { entity_id: 'sun.sun', state: 'above_horizon', attributes: { azimuth, elevation } } } };
c.requestUpdate();
await c.updateComplete;
}, [fill, azimuth, elevation, ceiling]);
const shoot = async (name) => {
await page.waitForTimeout(400);
const stage = await page.evaluateHandle(() => window.__card.shadowRoot.querySelector('.stage'));
await stage.asElement().screenshot({ path: `${outDir}/${name}${tag}.png` });
};
// 1) plain daylight on white paper: neutral wedge colour, brightest canvas
await setup('none', 265, 24, 'off');
await shoot('sun_bright_paper');
// 2) a low warm sun over the dark glow canvas
await setup('glow', 265, 4, 'off');
await shoot('sun_bright_glow');
await browser.close();
console.log('shots written to ' + outDir);
+64
View File
@@ -0,0 +1,64 @@
// Stills for the rim (docs/SUN.md, «The rim»). Two scenes — white paper and
// the dark glow canvas — and, in each, the SAME frame at several rim peaks so
// the value can be picked by looking rather than by arguing. The alphas other
// than the shipped one are applied by rewriting the `hp-sunrim-*` stops in the
// DOM, which is why a rebuild is not needed to compare them.
// node demo/shot_sun_rim.mjs <outdir> [alphas, default 0,0.3,0.42,0.5]
// `0` is the "before" frame: the wedge alone, with no rim at all.
import { launch } from './serve.mjs';
const outDir = process.argv[2] || '/tmp';
const ALPHAS = (process.argv[3] || '0,0.3,0.42,0.5').split(',').map(Number);
const { page, browser } = await launch({ width: 900, height: 900 }, 2);
await page.emulateMedia({ reducedMotion: 'reduce' }); // still shots, no fades
const setup = async (scene) => {
await page.evaluate(async (sc) => {
const c = window.__card;
const cfg = c._serverCfg;
const sp = cfg.spaces.find((s) => s.id === 'f1');
sp.openings = [
{ id: 'wN', type: 'window', x: 0.30, y: 0.14, angle: 0, length: 0.08 },
{ id: 'wW', type: 'window', x: 0.04, y: 0.30, angle: 90, length: 0.08 },
{ id: 'wW2', type: 'window', x: 0.04, y: 0.72, angle: 90, length: 0.08 },
{ id: 'wE', type: 'window', x: 0.96, y: 0.60, angle: 90, length: 0.08 },
];
sp.plan_url = null; // plain paper, nothing to hide behind
sp.settings = { ...(sp.settings || {}), fill_mode: sc === 'dark' ? 'glow' : 'none' };
cfg.settings = { ...(cfg.settings || {}), north_deg: 0, sun_rays: true,
bg_mode: 'static', bg_color: sc === 'dark' ? '#101720' : '#f2f2f0' };
c._cfgEpoch++;
c.hass = { ...c.hass, states: { ...c.hass.states,
'light.ceiling': { ...c.hass.states['light.ceiling'], state: sc === 'dark' ? 'on' : 'off' },
'sun.sun': { entity_id: 'sun.sun', state: 'above_horizon',
attributes: { azimuth: 250, elevation: 35 } } } };
c.requestUpdate();
await c.updateComplete;
}, scene);
await page.waitForTimeout(300);
};
// Rescale every rim gradient to `peak`, always from the stops as BUILT — a
// scale relative to the current values would collapse to zero the moment the
// 0 frame is taken and never come back.
const setAlpha = async (peak) => {
await page.evaluate((a) => {
for (const g of window.__card.shadowRoot.querySelectorAll('linearGradient[id^=hp-sunrim-]')) {
const stops = [...g.querySelectorAll('stop')];
if (!g.__base) g.__base = stops.map((s) => Number(s.getAttribute('stop-opacity')));
const p0 = g.__base[0] || 1;
stops.forEach((s, i) => s.setAttribute('stop-opacity', (a * (g.__base[i] / p0)).toFixed(4)));
}
}, peak);
await page.waitForTimeout(120);
};
for (const scene of ['light', 'dark']) {
await setup(scene);
for (const a of ALPHAS) {
await setAlpha(a);
const stage = await page.evaluateHandle(() => window.__card.shadowRoot.querySelector('.stage'));
await stage.asElement().screenshot({ path: `${outDir}/rim_${scene}_${String(a).replace('.', '')}.png` });
}
}
await browser.close();
console.log('shots written to ' + outDir);
+48
View File
@@ -0,0 +1,48 @@
// Stills for the 2026-08-04 ray report: 30 % shorter shafts with SHARP sides
// that dissolve only along the ray (docs/SUN.md). The low-sun frame is the
// interesting one — the north and south windows get a nearly wall-grazing sun,
// the case that used to hang a bright kerb across the floor. Usage:
// node demo/shot_sun_short.mjs <outdir> <prefix>
import { launch } from './serve.mjs';
const outDir = process.argv[2] || '/tmp';
const tag = process.argv[3] || 'sun_rays_short';
const { page, browser } = await launch({ width: 900, height: 860 }, 2);
await page.emulateMedia({ reducedMotion: 'reduce' }); // still shots, no 45 s sky glide
const setup = async (az, el) => {
await page.evaluate(async ([azimuth, elevation]) => {
const c = window.__card;
const sp = c._serverCfg.spaces.find((s) => s.id === 'f1');
sp.openings = [
{ id: 'wN', type: 'window', x: 0.30, y: 0.14, angle: 0, length: 0.08 },
{ id: 'wE', type: 'window', x: 0.96, y: 0.60, angle: 90, length: 0.08 },
{ id: 'wS', type: 'window', x: 0.30, y: 0.86, angle: 0, length: 0.08 },
{ id: 'wW', type: 'window', x: 0.04, y: 0.30, angle: 90, length: 0.08 },
{ id: 'wW2', type: 'window', x: 0.04, y: 0.72, angle: 90, length: 0.08 },
];
sp.settings = { ...(sp.settings || {}), show_borders: true, fill_mode: 'glow' };
c._serverCfg.settings = { ...(c._serverCfg.settings || {}),
north_deg: 0, sun_rays: true, bg_mode: 'daynight' };
c._cfgEpoch = (c._cfgEpoch || 0) + 1;
c.hass = { ...c.hass, states: { ...c.hass.states,
'light.ceiling': { ...c.hass.states['light.ceiling'], state: 'off' },
'sun.sun': { entity_id: 'sun.sun', state: 'above_horizon',
attributes: { azimuth, elevation } } } };
c.requestUpdate();
await c.updateComplete;
}, [az, el]);
await page.waitForTimeout(350);
};
// low sun in the west — the long shafts, the case that hit the far walls
await setup(265, 5);
let stage = await page.evaluateHandle(() => window.__card.shadowRoot.querySelector('.stage'));
await stage.asElement().screenshot({ path: `${outDir}/${tag}_low.png` });
// high southern sun — the short noon pools
await setup(180, 55);
stage = await page.evaluateHandle(() => window.__card.shadowRoot.querySelector('.stage'));
await stage.asElement().screenshot({ path: `${outDir}/${tag}_high.png` });
await browser.close();
console.log('shots written to ' + outDir);
+16 -3
View File
@@ -21,14 +21,27 @@ Object.assign(out, await page.evaluate(async () => {
c._setMode('plan'); await c.updateComplete;
await new Promise((r) => requestAnimationFrame(r));
const inPlan = c._baseVb();
o.editorSeesWholeCanvas = inPlan[2] === 1000 && inPlan[3] === 1000;
// docs/CANVAS.md: холста-квадрата больше нет, поэтому «редактор видит весь
// холст» переписано в исходное НАМЕРЕНИЕ HP-1490-03 — «в редакторе есть куда
// рисовать наружу». Рамка теперь по содержимому и в редакторе тоже, а место
// даёт запас панорамирования (§5) и зум-аут до 3x.
o.editorFrameIsContent = inPlan[2] < 999 && inPlan[2] > 0;
o.editorCanPanPastContent = (() => {
const fit = c._viewOr(c._baseVb());
const moved = c._clampView({ x: fit.x + fit.w * 0.9, y: fit.y, w: fit.w, h: fit.h }, fit);
return moved.x > fit.x + fit.w * 0.5; // ушли почти на экран вправо, зажима нет
})();
o.editorZoomOutGivesRoom = (() => {
c._resetZoom(); c._applyView(1 / 3);
const wide = c._view.w; c._resetZoom();
return wide > inPlan[2] * 2.5; // видно втрое больше, чем нарисовано
})();
const v = c._viewOr(c._baseVb());
o.editorViewCoversCanvas = v.w >= 999; // старый cropped view не пережил смену
// в редакторе можно ткнуть в дальний угол холста
o.canReachFarCorner = (() => {
const stage = (c.shadowRoot || c.renderRoot).querySelector('.stage');
const pt = c._screenToVb(stage.clientWidth - 1, stage.clientHeight - 1);
return pt[0] > 900 || pt[1] > 900;
return pt[0] > inPlan[0] + inPlan[2] * 0.9 || pt[1] > inPlan[1] + inPlan[3] * 0.9;
})();
c._setMode('view'); await c.updateComplete;
await new Promise((r) => requestAnimationFrame(r));
+369
View File
@@ -0,0 +1,369 @@
/**
* Backdrop image: move, uniform scale, and the new paper rule (docs/BACKDROP.md).
*
* The whole contract, on the demo's f1 — which IS an image plan, so every
* assertion here lands on exactly the case that used to behave differently:
* • the transform frame exists in the backdrop editor and nowhere else;
* • dragging the body moves the picture and writes plan_x/plan_y;
* • a corner handle scales UNIFORMLY about the opposite corner;
* • live badges state the real size in metres, through cell_cm;
* • position and size always land on the grid, including with Shift;
* • the paper follows the ROOMS even here, and the picture is drawn above
* the paper and below the walls;
* • «Вписать всё» still finds a picture that has been dragged away;
* • the static card reads the same three fields.
*/
import { launch, check, finish } from './serve.mjs';
const { page, browser } = await launch();
const snap = await page.evaluate(() => JSON.stringify(window.__card._serverCfg));
const restore = () => page.evaluate((s) => {
const c = window.__card;
c._serverCfg = JSON.parse(s);
c._bdDrag = null;
c._cfgEpoch++; c.requestUpdate();
return c.updateComplete && true;
}, snap);
const mode = (m) => page.evaluate((m) => {
const c = window.__card;
c._setMode(m); c.requestUpdate();
return c.updateComplete && true;
}, m);
const settle = () => page.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))));
const q = (sel) => page.evaluate((s) => window.__card.renderRoot.querySelectorAll(s).length, sel);
const spaceCfg = () => page.evaluate(() => {
const sp = window.__card._serverCfg.spaces.find((s) => s.id === 'f1');
return { x: sp.plan_x ?? null, y: sp.plan_y ?? null, k: sp.plan_scale ?? null };
});
/** The image rect AS RENDERED (attributes of the <image> in the stage svg). */
const imageRect = () => page.evaluate(() => {
const im = window.__card.renderRoot.querySelector('.stage svg image');
if (!im) return null;
const a = (n) => Number(im.getAttribute(n));
return { x: a('x'), y: a('y'), w: a('width'), h: a('height') };
});
const frameRect = () => page.evaluate(() => {
const b = window.__card.renderRoot.querySelector('.bdframe .bdbox');
if (!b) return null;
const a = (n) => Number(b.getAttribute(n));
return { x: a('x'), y: a('y'), w: a('width'), h: a('height') };
});
const badges = () => page.evaluate(() =>
[...window.__card.renderRoot.querySelectorAll('.measurelabel.bdmeasure')].map((e) => e.textContent.trim()));
/** Screen position of a render-unit point (the svg viewBox fills the stage). */
const screenPt = (x, y) => page.evaluate(([x, y]) => {
const stage = window.__card.renderRoot.querySelector('.stage');
const r = stage.getBoundingClientRect();
const [vx, vy, vw, vh] = stage.querySelector('svg').getAttribute('viewBox').split(' ').map(Number);
return [r.left + ((x - vx) / vw) * r.width, r.top + ((y - vy) / vh) * r.height];
}, [x, y]);
const near = (a, b, tol = 1e-4) => Math.abs(a - b) <= tol;
// f1: plan_aspect 1.25 → the centred default is 1000×800 at (0,100)
const BASE = { x: 0, y: 100, w: 1000, h: 800 };
const PITCH = 1000 / 240;
// "on a node", allowing for the 6 decimals the normalised config keeps
// (5e-7 normalised ≈ 5e-4 render units ≈ 1e-4 of a step)
const onGrid = (v, tol = 1e-3) => Math.abs(v / PITCH - Math.round(v / PITCH)) < tol;
/** Metres a render-unit length is worth on the demo plan (cell_cm = 5). */
const metres = (u) => (((u / PITCH) * 5) / 100).toFixed(2) + ' m';
// ---------- 1) the frame lives in the backdrop editor and nowhere else -----
await mode('view');
await settle();
check('no_frame_in_view', await q('.bdframe'), 0);
await mode('plan');
await settle();
check('no_frame_in_plan', await q('.bdframe'), 0);
const tool = (t) => page.evaluate((t) => {
const c = window.__card;
c._decorTool = t; c._decorDraft = null; c.requestUpdate();
return c.updateComplete && true;
}, t);
await mode('decor');
await settle();
// the frame is on screen the moment the backdrop editor opens…
check('frame_in_backdrop_editor', await q('.bdframe'), 1);
check('four_finger_handles', await q('.bdframe .bdhandle'), 4);
check('frame_hugs_the_picture', await frameRect(), BASE);
check('untouched_picture_is_where_it_always_was', await imageRect(), BASE);
// …and the picture has a tool of its own, offered because f1 HAS a picture
check('the_picture_has_a_tool', await q('.decorbar .btn.dtool'), 8);
// …which is ARMED the moment the editor opens (owner 2026-08-05: «не
// получается двигать картинку-подложку в режиме редактора подложки» — the
// frame promised a draggable picture while the promise needed a tool nobody
// had found). The editor is named after the picture; it opens on it.
check('picture_tool_armed_on_open', await page.evaluate(() => window.__card._decorTool), 'backdrop');
check('grab_cursor', await page.evaluate(() =>
window.__card.renderRoot.querySelector('.stage').classList.contains('bdgrab')), true);
// select still exists and still leaves the body to the pan (smoke_pan_any_zoom)
await tool('select');
await settle();
check('no_grab_cursor_under_select', await page.evaluate(() =>
window.__card.renderRoot.querySelector('.stage').classList.contains('bdgrab')), false);
await tool('backdrop');
await settle();
check('frame_still_there_under_its_own_tool', await q('.bdframe'), 1);
// a drawing tool owns the drag instead — no frame competing with it
await tool('rect');
await settle();
check('no_frame_under_a_drawing_tool', await q('.bdframe'), 0);
await tool('backdrop');
await settle();
// ---------- 2) drag the body: the picture moves, the plan does not --------
const roomsBefore = await page.evaluate(() =>
JSON.stringify(window.__card._serverCfg.spaces.find((s) => s.id === 'f1').rooms));
{
const [ax, ay] = await screenPt(500, 500);
// +102 / +52 render units: neither is a whole number of grid steps, so the
// snap has something to do (24 steps = 100, 12 steps = 50)
const [bx, by] = await screenPt(602, 552);
await page.mouse.move(ax, ay);
await page.mouse.down();
await page.mouse.move((ax + bx) / 2, (ay + by) / 2, { steps: 3 });
await settle();
const live = await badges();
check('live_badge_while_dragging', live.length, 1);
// the demo's cell_cm is 5 → the untouched picture is 240 cells wide, i.e.
// 12.00 m by 9.60 m. Moving does not change the size, and the badge says so.
check('live_badge_is_the_real_size', live[0], `${metres(BASE.w)} × ${metres(BASE.h)}`);
check('live_badge_is_metres', live[0], '12.00 m × 9.60 m');
await page.mouse.move(bx, by, { steps: 3 });
await settle();
await page.mouse.up();
await settle();
}
check('badge_gone_after_release', (await badges()).length, 0);
{
const cfg = await spaceCfg();
const im = await imageRect();
// the gesture asked for +102/+52 render units; the pointer arrives in whole
// screen pixels, so the ASSERTION is the contract, not the arithmetic: the
// corner lands on a lattice node within one step of where the finger let go
check('plan_x_saved', near(cfg.x, im.x / 1000, 1e-6), true);
check('plan_y_saved', near(cfg.y, (im.y - BASE.y) / 1000, 1e-6), true);
check('scale_untouched_by_a_move', cfg.k, 1);
check('size_untouched_by_a_move', [im.w, im.h], [BASE.w, BASE.h]);
check('corner_lands_on_a_grid_node', onGrid(im.x) && onGrid(im.y), true);
// …at the node NEAREST the finger: 102 → 100 (24 steps), 152 → 150 (36).
// Exact, and it is meant to be — nothing may rescale the stage mid-gesture
// (the frame is frozen while _bdDrag is live; the toolbar does not reflow),
// or the picture would run away from the finger.
check('landed_on_the_node_nearest_the_finger', [im.x, im.y], [100, 150]);
check('frame_follows_the_picture', await frameRect(), im);
}
check('the_plan_geometry_never_moved', await page.evaluate(() =>
JSON.stringify(window.__card._serverCfg.spaces.find((s) => s.id === 'f1').rooms)), roomsBefore);
// ---------- 3) Shift cannot step off the grid -----------------------------
await restore();
await settle();
{
const [ax, ay] = await screenPt(500, 500);
const [bx, by] = await screenPt(602, 552);
await page.keyboard.down('Shift');
await page.mouse.move(ax, ay);
await page.mouse.down();
await page.mouse.move(bx, by, { steps: 3 });
await settle();
await page.mouse.up();
await page.keyboard.up('Shift');
await settle();
const im = await imageRect();
check('shift_still_lands_on_the_nearest_node', [im.x, im.y], [100, 150]);
check('shift_stays_on_the_lattice', onGrid(im.x) && onGrid(im.y), true);
}
// ---------- 4) a corner handle scales UNIFORMLY about the opposite one ----
await restore();
await settle();
{
// the bottom-right corner of the default rect is (1000, 900); pull it in to
// (700, ...) — the top-left corner (0,100) must not budge and the ratio must
// stay 1.25, whatever the vertical component of the gesture says
const [ax, ay] = await screenPt(BASE.x + BASE.w, BASE.y + BASE.h);
const [bx, by] = await screenPt(700, 600);
await page.mouse.move(ax, ay);
await page.mouse.down();
await settle();
await page.mouse.move((ax + bx) / 2, (ay + by) / 2, { steps: 3 });
await settle();
check('live_badge_while_scaling', (await badges()).length, 1);
await page.mouse.move(bx, by, { steps: 3 });
await settle();
await page.mouse.up();
await settle();
const im = await imageRect();
check('opposite_corner_stayed_put', near(im.x, BASE.x, 1e-6) && near(im.y, BASE.y, 1e-6), true);
check('scaled_down', im.w < BASE.w, true);
check('uniform_no_stretch', near(im.w / im.h, BASE.w / BASE.h, 1e-9), true);
const cfg = await spaceCfg();
check('plan_scale_saved', near(cfg.k, im.w / BASE.w, 1e-5), true);
check('scaled_width_is_on_the_grid',
Math.abs((im.x + im.w) / PITCH - Math.round((im.x + im.w) / PITCH)) < 1e-6, true);
// …and the badge states the picture's REAL size through cell_cm
check('badge_matches_the_new_size', await page.evaluate(() => {
const c = window.__card;
c._bdDrag = { kind: 'move', pid: 1, sx: 0, sy: 0, base: { x: 0, y: 100, w: 1000, h: 800 },
p0: { dx: 0, dy: 0, k: 1 }, fx: 0, fy: 0, sgx: 0, sgy: 0, moved: false };
c.requestUpdate();
return c.updateComplete.then(() =>
c.renderRoot.querySelector('.measurelabel.bdmeasure')?.textContent.trim() ?? null);
}), `${metres(im.w)} × ${metres(im.h)}`);
await page.evaluate(() => { const c = window.__card; c._bdDrag = null; c.requestUpdate(); return c.updateComplete; });
}
// ---------- 5) the reset button ------------------------------------------
check('reset_button_offered_once_moved', await q('.decorbar .bdreset'), 1);
await page.evaluate(() => { const c = window.__card; c._bdReset?.(); c.requestUpdate(); return c.updateComplete; });
await settle();
check('reset_puts_the_picture_back', await imageRect(), BASE);
check('reset_clears_the_fields', await spaceCfg(), { x: null, y: null, k: null });
check('reset_button_gone_again', await q('.decorbar .bdreset'), 0);
// ---------- 5b) the SELECT tool still pans right over the picture ---------
// The body of the picture is most of the screen; claiming it outside its own
// tool would take away the one-finger pan (docs/BACKDROP.md §2), which is why
// moving is a tool. smoke_pan_any_zoom guards the same thing from the outside.
await tool('select');
await settle();
check('select_tool_still_pans_over_the_picture', await page.evaluate(async () => {
const c = window.__card;
const before = { ...c._viewOr(c._baseVb()) };
c._stagePointerDown({ pointerId: 91, clientX: 300, clientY: 300, target: c._stageEl, preventDefault() {} });
c._stagePointerMove({ pointerId: 91, clientX: 360, clientY: 350 });
const after = { ...c._viewOr(c._baseVb()) };
c._stagePointerUp({ pointerId: 91, clientX: 360, clientY: 350 });
await c.updateComplete;
const sp = c._serverCfg.spaces.find((s) => s.id === 'f1');
return Math.abs(after.x - before.x) > 1 && sp.plan_x == null;
}), true);
// ---------- 6) the paper is the ROOMS, and the picture sits on it ---------
await mode('view');
await settle();
const layers = await page.evaluate(() => {
const c = window.__card;
const m = c._spaceModel();
const svgEl = c.renderRoot.querySelector('.stage svg');
const kids = [...svgEl.children];
const idx = (sel) => kids.findIndex((n) => n.matches(sel) || n.querySelector?.(sel));
const papers = [...svgEl.querySelectorAll('.hp-paper')];
return {
paperShapes: papers.length,
rooms: m.rooms.length,
// not one paper shape the size of the picture any more
imageSizedPaper: papers.some((p) => p.tagName === 'rect'
&& Math.abs(Number(p.getAttribute('width')) - m.bg.w) < 1e-6
&& Math.abs(Number(p.getAttribute('height')) - m.bg.h) < 1e-6),
iPaper: idx('.hp-paper'), iImage: idx('image'), iRoom: idx('.room-outline, .room'),
};
});
check('paper_is_one_shape_per_room', layers.paperShapes, layers.rooms);
check('no_paper_the_size_of_the_picture', layers.imageSizedPaper, false);
check('picture_is_above_the_paper', layers.iImage > layers.iPaper && layers.iPaper >= 0, true);
check('picture_is_below_the_walls', layers.iImage < layers.iRoom, true);
// ---------- 6b) editors keep a WHITE sheet under the grid with a backdrop --
const editorWhite = await page.evaluate(async () => {
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const upd = async () => { c.requestUpdate(); await c.updateComplete; };
const probe = async (mode) => {
c._setMode(mode);
await upd();
const stage = sr().querySelector('.stage');
const paper = sr().querySelector('.stage svg .hp-paper');
return {
mode,
stageWhite: getComputedStyle(stage).backgroundColor === 'rgb(255, 255, 255)',
paperWhite: !paper || getComputedStyle(paper).fill === 'rgb(255, 255, 255)',
hasBg: !!c._spaceModel().bg,
noNoplan: !stage.classList.contains('noplan'),
};
};
const plan = await probe('plan');
const devices = await probe('devices');
const decor = await probe('decor');
c._setMode('view');
await upd();
const viewStage = sr().querySelector('.stage');
// View with a backdrop must NOT force white — theme/card colour stays
const viewForcedWhite = getComputedStyle(viewStage).backgroundColor === 'rgb(255, 255, 255)'
&& !viewStage.classList.contains('noplan');
return { plan, devices, decor, viewForcedWhite, hasBg: plan.hasBg };
});
check('editors_have_backdrop', editorWhite.hasBg, true);
check('plan_editor_stage_white_with_backdrop', editorWhite.plan.stageWhite && editorWhite.plan.noNoplan, true);
check('plan_editor_paper_white_with_backdrop', editorWhite.plan.paperWhite, true);
check('devices_editor_stage_white_with_backdrop', editorWhite.devices.stageWhite, true);
check('decor_editor_stage_white_with_backdrop', editorWhite.decor.stageWhite, true);
check('view_with_backdrop_is_not_forced_white', editorWhite.viewForcedWhite, false);
// ---------- 7) «Вписать всё» keeps a picture that was dragged away --------
await page.evaluate(() => {
const c = window.__card;
const sp = c._serverCfg.spaces.find((s) => s.id === 'f1');
sp.plan_x = 1.5; sp.plan_y = 1.5; sp.plan_scale = 0.5;
c._cfgEpoch++; c._frame = null; c.requestUpdate();
return c.updateComplete;
});
await settle();
const fit = await page.evaluate(() => {
const c = window.__card;
c._fitAll();
const b = c._baseVb();
const bg = c._spaceModel().bg;
return { b, bg };
});
check('the_frame_reaches_the_moved_picture',
fit.b[0] <= fit.bg.x && fit.b[1] <= fit.bg.y
&& fit.b[0] + fit.b[2] >= fit.bg.x + fit.bg.w
&& fit.b[1] + fit.b[3] >= fit.bg.y + fit.bg.h, true);
check('and_the_moved_picture_is_where_the_transform_says',
near(fit.bg.x, 1500) && near(fit.bg.y, 1600) && near(fit.bg.w, 500) && near(fit.bg.h, 400), true);
// ---------- 8) the static card reads the same three fields ---------------
const stat = await page.evaluate(async () => {
await customElements.whenDefined('houseplan-space-card');
const main = window.__card;
const cfg = JSON.parse(JSON.stringify(main._serverCfg));
const hass = { ...main.hass, callWS: async (m) => {
if (m.type === 'houseplan/config/get') return { config: cfg, rev: 1 };
if (m.type === 'houseplan/layout/get') return { layout: {} };
return { ok: true };
} };
const host = document.createElement('div');
document.body.appendChild(host);
const card = document.createElement('houseplan-space-card');
card.setConfig({ type: 'custom:houseplan-space-card', space: 'f1' });
card.hass = hass;
host.appendChild(card);
const t0 = Date.now();
while (!card.renderRoot?.querySelector('.hp-static-stage svg image') && Date.now() - t0 < 6000) {
await new Promise((r) => setTimeout(r, 60));
}
await card.updateComplete;
const root = card.renderRoot.querySelector('.hp-static-stage svg');
const im = root.querySelector('image');
const a = (n) => Number(im.getAttribute(n));
const kids = [...root.children];
const idx = (sel) => kids.findIndex((n) => n.matches(sel) || n.querySelector?.(sel));
return {
rect: { x: a('x'), y: a('y'), w: a('width'), h: a('height') },
papers: root.querySelectorAll('.hp-paper').length,
imageSizedPaper: [...root.querySelectorAll('.hp-paper')].some((p) => p.tagName === 'rect'
&& Math.abs(Number(p.getAttribute('width')) - a('width')) < 1e-6),
iPaper: idx('.hp-paper'), iImage: idx('image'),
};
});
check('static_card_applies_the_transform', stat.rect, { x: 1500, y: 1600, w: 500, h: 400 });
check('static_card_papers_the_rooms', stat.papers, 4);
check('static_card_has_no_picture_paper', stat.imageSizedPaper, false);
check('static_card_draws_the_picture_on_the_paper', stat.iImage > stat.iPaper, true);
await restore();
await finish(browser);
+20 -10
View File
@@ -48,7 +48,7 @@ const res = await page.evaluate(async () => {
};
c._openSettingsDialog();
await c.updateComplete;
out.dialogHasBgRow = !!sr().querySelector('.dialog .colorrow input[type=color]');
out.dialogHasBgRow = !!sr().querySelector('hp-dialog .colorrow input[type=color]');
c._settingsDialog = { ...c._settingsDialog, bgColor: '#0a2a4a' };
out.dialogPreviews = (await stageBg()) === rgb('#0a2a4a');
await c._saveSettingsDialog();
@@ -144,9 +144,11 @@ checkAll(res);
// ---- 11) opaque plan paper (owner 2026-08-03) --------------------------
// The scene bg_color must never bleed through the plan itself: opaque
// .hp-paper shapes sit under everything the plan draws. An IMAGE plan papers
// the backdrop image rect (the canvas is the paper); a DRAWN plan papers the
// ROOM CONTOURS — one shape per room, never their bounding box (section 12).
// .hp-paper shapes sit under everything the plan draws. Since v1.58.0 the
// paper is the ROOM CONTOURS and ONLY them — one shape per room, never their
// bounding box (section 12), and never the backdrop image rect either
// (docs/BACKDROP.md §3). The demo's f1 IS an image plan, so this section now
// asserts the new rule on exactly the case that used to be the exception.
// The scene colour is visible ONLY around the paper; the 'daynight' night
// dims the paper via brightness only.
const paper = await page.evaluate(async () => {
@@ -158,20 +160,28 @@ const paper = await page.evaluate(async () => {
c._cfgRev = (c._cfgRev || 0) + 1;
await upd();
const m = c._spaceModel();
const p = sr().querySelector('.stage svg rect.hp-paper');
const shapes = () => [...sr().querySelectorAll('.stage svg .hp-paper')];
const p = shapes()[0];
out.paperExists = !!p;
out.imagePlanPapersTheRooms = shapes().length === m.rooms.length;
// …and NOT the image: no paper rect the size of the backdrop any more
out.noImageSizedPaper = ![...sr().querySelectorAll('.stage svg rect.hp-paper')].some((r) =>
Math.abs(Number(r.getAttribute('width')) - m.bg.w) < 1e-6
&& Math.abs(Number(r.getAttribute('height')) - m.bg.h) < 1e-6);
if (p) {
const a = (n) => Number(p.getAttribute(n));
out.paperHugsImage = Math.abs(a('x') - m.bg.x) < 1e-6 && Math.abs(a('y') - m.bg.y) < 1e-6
&& Math.abs(a('width') - m.bg.w) < 1e-6 && Math.abs(a('height') - m.bg.h) < 1e-6;
const cs = getComputedStyle(p);
out.paperOpaque = cs.fillOpacity === '1' && cs.opacity === '1'
&& cs.fill !== 'none' && !/rgba\(.*,\s*0\)/.test(cs.fill);
}
// …and the picture is drawn ON that paper: image after paper, before walls
const kids = [...sr().querySelector('.stage svg').children];
const idxOf = (sel) => kids.findIndex((n) => n.matches(sel) || n.querySelector?.(sel));
out.imageAbovePaper = idxOf('image') > idxOf('.hp-paper') && idxOf('image') >= 0;
out.imageBelowRooms = idxOf('image') < idxOf('.room, .room-outline');
// editors keep the paper too (their canvas ignores bg_color anyway)
c._setMode('decor');
await upd();
out.editorKeepsPaper = !!sr().querySelector('.stage svg rect.hp-paper');
out.editorKeepsPaper = !!sr().querySelector('.stage svg .hp-paper');
c._setMode('view');
await upd();
// daynight night: the paper dims via the brightness filter ONLY, never alpha
@@ -184,7 +194,7 @@ const paper = await page.evaluate(async () => {
} } };
await upd();
out.nightDimsByBrightness = (sr().querySelector('.zoomwrap').getAttribute('style') || '').includes('brightness(0.900');
const pn = sr().querySelector('.stage svg rect.hp-paper');
const pn = sr().querySelector('.stage svg .hp-paper');
out.nightPaperStaysOpaque = !!pn && getComputedStyle(pn).fillOpacity === '1' && getComputedStyle(pn).opacity === '1';
delete c._serverCfg.settings.bg_mode;
delete c._serverCfg.settings.north_deg;
+2 -2
View File
@@ -15,7 +15,7 @@ const res = await page.evaluate(async () => {
radios()[1].click(); await c.updateComplete;
out.dropShown = !!sr().querySelector('.dropbtn');
out.panelOpen = !!sr().querySelector('.droppanel');
const saveBtn = [...sr().querySelectorAll('.dialog .btn.on')].pop();
const saveBtn = [...sr().querySelectorAll('hp-dialog .btn.on')].pop();
out.saveDisabled = saveBtn?.disabled === true;
// без чекбокса в списке нет individual-сущностей устройств
const subs = () => [...sr().querySelectorAll('.cand .cs')].map((e) => e.textContent);
@@ -32,7 +32,7 @@ const res = await page.evaluate(async () => {
sr().querySelector('.cand').click(); await c.updateComplete;
out.picked = c._markerDialog.binding !== '' && c._markerDialog.binding !== 'virtual';
out.panelClosed = !sr().querySelector('.droppanel');
out.saveEnabled = [...sr().querySelectorAll('.dialog .btn.on')].pop()?.disabled === false;
out.saveEnabled = [...sr().querySelectorAll('hp-dialog .btn.on')].pop()?.disabled === false;
// радио назад на «Виртуальное» → binding=virtual
radios()[0].click(); await c.updateComplete;
out.backToVirtual = c._markerDialog.binding === 'virtual' && c._markerDialog.bindingMode === 'virtual';
+212
View File
@@ -0,0 +1,212 @@
// The content frame is PRESENTATION (docs/CANVAS.md §4) — audit dev@2c947f4.
//
// DEV-2C947-01: a hidden device is not drawn, but it still stretched the
// frame — hiding a marker that had wandered into the yard left the visible
// house a dot in the corner (the full card AND the static
// houseplan-space-card, which drew `devs` but framed `spaceDevs`).
//
// DEV-2C947-02: inside an editor the frame only grows (a frame that shrank
// mid-drag would move the ground under the pointer); that union was memoised
// without the mode, so it survived the way back into View.
//
// DEV-2C947-03: a room-outlier is rejected from the frame, but iconUnit()
// measured ALL rooms — so the stray it had just rejected still blew every
// marker of the main plan up ~91x.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 900, height: 820 }, 1);
const out = {};
const raf = () => page.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))));
// Load a config/layout into the card AND into the demo's WS stub, so the
// static card (which fetches its own copy) sees exactly the same house.
const load = (cfg, layout, space) => page.evaluate(async ([c2, l2, sp]) => {
const c = window.__card;
const srvCfg = (await c.hass.callWS({ type: 'houseplan/config/get' })).config;
const srvLay = (await c.hass.callWS({ type: 'houseplan/layout/get' })).layout;
for (const k of Object.keys(srvCfg)) delete srvCfg[k];
Object.assign(srvCfg, JSON.parse(JSON.stringify(c2)));
for (const k of Object.keys(srvLay)) delete srvLay[k];
Object.assign(srvLay, JSON.parse(JSON.stringify(l2)));
c._serverCfg = srvCfg;
c._layout = JSON.parse(JSON.stringify(l2));
c._modelCache = null;
c._frame = null;
c._showFar = false;
c._space = sp;
c._cfgEpoch++;
c._regSignature = '';
c._maybeRebuildDevices?.();
c._defPos = c._defaultPositions();
c._view = null;
c._zoom = 1;
c.requestUpdate();
await c.updateComplete;
c._resetZoom();
await c.updateComplete;
}, [cfg, layout, space]);
// ---------------------------------------------------------------- 01: hidden
// The auditor's probe, verbatim: ONE visible room and ONE marker parked 90
// canvases away. Two content items is below MIN_VOTERS, so the outlier vote
// never runs and the far position is guaranteed to stay in the core — the
// frame either sees the marker or it does not.
const oneRoom = (hidden) => ({
spaces: [{
id: 'h1', title: 'Hidden', view_box: [0, 0, 1, 1], plan_url: null, plan_aspect: null,
rooms: [{ id: 'r1', name: 'Room', area: 'zx_room',
poly: [[0.1, 0.1], [0.9, 0.1], [0.9, 0.9], [0.1, 0.9]] }],
}],
// a made-up area: no auto device of the demo house lands in this space, so
// the only content is the room and the one marker below
markers: [{ id: 'm_far', binding: 'device:d_motion', area: 'zx_room', hidden }],
settings: { filter_seeded: true },
});
const FAR_POS = { m_far: { s: 'h1', x: 90, y: 0.5 } };
await load(oneRoom(false), FAR_POS, 'h1');
await raf();
Object.assign(out, await page.evaluate(() => {
const c = window.__card;
const sr = c.shadowRoot || c.renderRoot;
const b = c._baseVb();
return {
// premise: while it IS drawn, the marker is content and the frame holds it
visibleMarkerIsDrawn: sr.querySelectorAll('.devlayer .dev').length === 1,
visibleMarkerWidensTheFrame: b[2] > 50000,
};
}));
await load(oneRoom(true), FAR_POS, 'h1');
await raf();
Object.assign(out, await page.evaluate(() => {
const c = window.__card;
const sr = c.shadowRoot || c.renderRoot;
const o = {};
const b = c._baseVb();
o.hiddenMarkerNotDrawn = sr.querySelectorAll('.devlayer .dev').length === 0;
o.hiddenMarkerStillBuilt = !!c._devices.find((d) => d.id === 'm_far' && d.hidden === true);
// the room is 100..900 render units, padded by 5 % of the longer side
o.frameIsTheVisibleRoom = Math.round(b[0]) === 60 && Math.round(b[2]) === 880
&& Math.round(b[1]) === 60 && Math.round(b[3]) === 880;
// and the room fills the screen instead of being a dot 112x too small
const st = sr.querySelector('.stage').getBoundingClientRect();
const poly = sr.querySelector('polygon.room').getBoundingClientRect();
o.roomFillsTheStage = poly.width > st.width * 0.7;
return o;
}));
// the same house on the STATIC card (space-render.ts drew `devs` but framed
// `spaceDevs` — the identical split)
Object.assign(out, await page.evaluate(async () => {
await customElements.whenDefined('houseplan-space-card');
const o = {};
const host = document.createElement('div');
host.style.cssText = 'width:400px;height:400px';
document.body.appendChild(host);
const el = document.createElement('houseplan-space-card');
el.setConfig({ type: 'custom:houseplan-space-card', space: 'h1' });
el.hass = window.__card.hass;
host.appendChild(el);
const t0 = Date.now();
while (!el.renderRoot?.querySelector('.hp-static-stage') && Date.now() - t0 < 6000) {
await new Promise((r) => setTimeout(r, 80));
}
await el.updateComplete;
const svg = el.renderRoot.querySelector('.hp-static-stage svg');
const vb = (svg?.getAttribute('viewBox') || '').split(/\s+/).map(Number);
o.staticCardHidesTheMarker = el.renderRoot.querySelectorAll('.devlayer .dev').length === 0;
o.staticFrameIsTheVisibleRoom = vb.length === 4
&& Math.round(vb[0]) === 60 && Math.round(vb[2]) === 880;
host.remove();
return o;
}));
// ------------------------------------------------------- 02: editor -> view
const oneRoomAt = (x0) => ({
spaces: [{
id: 'e1', title: 'Editor', view_box: [0, 0, 1, 1], plan_url: null, plan_aspect: null,
rooms: [{ id: 'r1', name: 'Room', area: 'zx_room',
poly: [[x0, 0.1], [x0 + 0.8, 0.1], [x0 + 0.8, 0.9], [x0, 0.9]] }],
}],
markers: [], settings: { filter_seeded: true },
});
await load(oneRoomAt(0.1), {}, 'e1');
await raf();
Object.assign(out, await page.evaluate(async () => {
const c = window.__card;
const o = {};
const round = () => c._baseVb().map((n) => Math.round(n));
o.viewFrameBefore = JSON.stringify(round()) === JSON.stringify([60, 60, 880, 880]);
// move the only room 5 canvases to the right, inside the Plan editor
c._setMode('plan');
await c.updateComplete;
const cfg = JSON.parse(JSON.stringify(c._serverCfg));
cfg.spaces[0].rooms[0].poly = [[5.1, 0.1], [5.9, 0.1], [5.9, 0.9], [5.1, 0.9]];
c._serverCfg = cfg;
c._modelCache = null;
c._cfgEpoch++;
c.requestUpdate();
await c.updateComplete;
const inEditor = round();
// inside the editor the frame is the UNION — that is deliberate: it bounds
// pan and defines zoom 1, and it must not shrink under a live gesture
o.editorFrameOnlyGrows = inEditor[2] > 5000;
c._setMode('view');
await c.updateComplete;
const back = round();
o.viewFrameFollowsTheRoomAgain = JSON.stringify(back) === JSON.stringify([5060, 60, 880, 880]);
if (!o.viewFrameFollowsTheRoomAgain) console.log('frame after exit', back, 'editor', inEditor);
// and back into an editor it starts from the CURRENT geometry, not the old union
c._setMode('plan');
await c.updateComplete;
o.reenteringTheEditorDoesNotResurrectTheUnion =
JSON.stringify(round()) === JSON.stringify([5060, 60, 880, 880]);
c._setMode('view');
await c.updateComplete;
return o;
}));
// ---------------------------------------------------- 03: icons and outliers
// Three rooms in the core plus one dragged 90 canvases out. The frame rejects
// the stray (that part always worked); the ICON UNIT used to measure all four
// rooms, so every marker of the plan the user is looking at came out ~91x too
// big and covered the house.
const withFarRoom = (far) => ({
spaces: [{
id: 'i1', title: 'Icons', view_box: [0, 0, 1, 1], plan_url: null, plan_aspect: null,
rooms: [
{ id: 'r1', name: 'A', area: 'zx_a', poly: [[0.10, 0.10], [0.30, 0.10], [0.30, 0.30], [0.10, 0.30]] },
{ id: 'r2', name: 'B', area: 'zx_b', poly: [[0.30, 0.10], [0.55, 0.10], [0.55, 0.35], [0.30, 0.35]] },
{ id: 'r3', name: 'C', area: 'zx_c', poly: [[0.10, 0.30], [0.35, 0.30], [0.35, 0.55], [0.10, 0.55]] },
...(far ? [{ id: 'r4', name: 'Far', area: 'zx_d', poly: [[90, 90], [91, 90], [91, 91], [90, 91]] }] : []),
],
}],
markers: [{ id: 'm_one', binding: 'device:d_light1', area: 'zx_a' }],
settings: { filter_seeded: true },
});
const ICON_POS = { m_one: { s: 'i1', x: 0.2, y: 0.2 } };
const iconPx = () => page.evaluate(() => {
const c = window.__card;
const sr = c.shadowRoot || c.renderRoot;
const el = sr.querySelector('.devlayer .dev');
// the badge is --dev-size plus a 1 px border on each side (smoke_icon_scale)
return { px: el ? Math.round((el.getBoundingClientRect().width - 2) * 100) / 100 : null,
outliers: c._outliers, frameW: Math.round(c._baseVb()[2]) };
});
await load(withFarRoom(false), ICON_POS, 'i1');
await raf();
const plain = await iconPx();
await load(withFarRoom(true), ICON_POS, 'i1');
await raf();
const strayed = await iconPx();
out.strayRoomIsRejectedFromTheFrame = strayed.outliers === 1 && strayed.frameW < 1000;
out.strayRoomDoesNotInflateTheIcons =
plain.px > 4 && Math.abs(strayed.px - plain.px) < 0.5;
if (!out.strayRoomDoesNotInflateTheIcons) console.log('icon px', plain, strayed);
checkAll(out);
await finish(browser, out);
+1 -1
View File
@@ -13,7 +13,7 @@ const res = await page.evaluate(async () => {
out.hasDev = !!dev;
c._infoCard = dev; await c.updateComplete;
// 1) блок сущностей идёт ПЕРВЫМ, до модели/ссылок
const body = sr().querySelector('.dialog .body');
const body = sr().querySelector('hp-dialog .body');
out.entListFirst = body.firstElementChild?.classList.contains('entlist')
|| body.querySelector('.entlist') === body.children[0];
const rows = [...sr().querySelectorAll('.entrow')];
+13
View File
@@ -45,6 +45,19 @@ const res = await page.evaluate(async () => {
label.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true, clientX: 10, clientY: 10 }));
await c.updateComplete;
out.delroomIgnored = !confirmCalled;
// 6) Delete has exactly one semantic result: the clicked room disappears;
// it never closes a virtual boundary or merges a neighbour into it.
const roomCount = c._curSpaceCfg.rooms.length;
const deleteId = room.id;
window.confirm = () => true;
c._deleteRoomClick(c._roomCenter(room));
await c.updateComplete;
out.deleteOnlyClickedRoom = c._curSpaceCfg.rooms.length === roomCount - 1
&& !c._curSpaceCfg.rooms.some((x) => x.id === deleteId);
c._undoGeometry();
await c.updateComplete;
out.deleteUndoRestoresRoom = c._curSpaceCfg.rooms.length === roomCount
&& c._curSpaceCfg.rooms.some((x) => x.id === deleteId);
return out;
});
checkAll(res);
+4 -4
View File
@@ -61,12 +61,12 @@ Object.assign(out, await page.evaluate(async () => {
// у обычного термометра чекбокса нет
const th = c._devices.find((d) => d.entities?.includes('sensor.living_temp'));
c._openMarkerDialog(th); await c.updateComplete;
o.noCheckboxForThermometer = !sr().querySelector('.dialog .climrow');
o.noCheckboxForThermometer = !sr().querySelector('hp-dialog .climrow');
c._markerDialog = null; await c.updateComplete;
// у кондиционера — есть и по умолчанию снят
const ac = c._devices.find((d) => d.entities?.includes('climate.ac'));
c._openMarkerDialog(ac); await c.updateComplete;
const row = sr().querySelector('.dialog .climrow input');
const row = sr().querySelector('hp-dialog .climrow input');
o.checkboxForClimate = !!row;
o.uncheckedByDefault = !!row && !row.checked;
if (row) row.scrollIntoView({ block: 'center' });
@@ -77,7 +77,7 @@ Object.assign(out, await page.evaluate(async () => {
const box = await page.evaluate(() => {
const c = window.__card;
const sr = c.shadowRoot || c.renderRoot;
const r = sr.querySelector('.dialog .climrow input')?.getBoundingClientRect();
const r = sr.querySelector('hp-dialog .climrow input')?.getBoundingClientRect();
return r ? { x: r.x + r.width / 2, y: r.y + r.height / 2 } : null;
});
check('checkboxClickable', !!box);
@@ -138,7 +138,7 @@ Object.assign(out, await page.evaluate(async () => {
const ac = c._devices.find((d) => d.entities?.includes('climate.ac'));
c._openMarkerDialog(ac); await c.updateComplete;
o.survivesReopen = c._markerDialog?.useClimateTemp === true;
o.checkboxChecked = sr().querySelector('.dialog .climrow input')?.checked === true;
o.checkboxChecked = sr().querySelector('hp-dialog .climrow input')?.checked === true;
c._markerDialog = null;
return o;
}));
+3 -1
View File
@@ -19,7 +19,8 @@ const res = await page.evaluate(async () => {
c._openMarkerDialog();
const room = c._spaceModel().rooms.find((r) => r.id && r.area);
c._markerDialog = { ...c._markerDialog, name: 'Выключатель', binding: 'virtual',
room: c._space + '#' + room.area, tapAction: 'toggle', controls: lights, icon: 'mdi:light-switch' };
room: c._space + '#' + room.area, tapAction: 'toggle', controls: lights,
icon: 'mdi:light-switch', display: 'icon_ripple' };
await c._saveMarker(); await c.updateComplete;
const dev = c._devices.find((d) => d.name === 'Выключатель');
out.markerSaved = !!dev && JSON.stringify(dev.marker.controls) === JSON.stringify(lights);
@@ -36,6 +37,7 @@ const res = await page.evaluate(async () => {
// 3) значок отражает цели: горит одна → класс on
const el = [...sr().querySelectorAll('.dev')].find((e) => e.title === 'Выключатель' || e.textContent.includes(''));
out.stateOn = !!c._stateClass(dev2()).includes('on');
out.aggregateActivityRuns = c._stateClass(dev2()).includes('activity-running');
await setSt({ [lights[0]]: 'off' });
out.stateOff = c._stateClass(dev2()) === '';
// 4) без tap_action=toggle клик НЕ переключает (инфо)
+200
View File
@@ -0,0 +1,200 @@
// A cover NEVER wears a coloured plate (owner's contract 2026-08-04):
// «у штор не должно быть жёлтой подложки никогда, индикация открыто/закрыто
// за счёт морфинга иконки».
//
// Before this the domain shared one branch with `valve`: 'open'/'opening' put
// the orange «открыто» frame on the badge (.dev.open — a FILLED orange plate,
// --hp-open #ff9f43, not just a border), and the travelling states carried it
// underneath the breathing ring. Now a cover returns no plate class at all:
//
// closed / open / ajar -> neutral plate, the ICON tells the state
// opening / closing -> neutral plate + activity-transition when the
// marker opts into «Icon + activity»
//
// The frame itself is NOT gone: door/window binary sensors and locks still
// wear it, and a valve keeps it too (nothing morphs its icon). Those are
// re-checked at the bottom, or «no yellow for curtains» would quietly cost
// the whole house its «открыто» indication.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const out = await page.evaluate(async () => {
const o = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
// --- colour probes: resolve the tokens the same way the badge does ------
const probe = (css) => {
const d = document.createElement('div');
d.style.cssText = `position:absolute;left:-999px;background:${css}`;
sr().appendChild(d);
const v = getComputedStyle(d).backgroundColor;
d.remove();
return v;
};
const YELLOW = probe('var(--hp-on)'); // «включено»
const ORANGE = probe('var(--hp-open)'); // «открыто» frame
const NEUTRAL = probe('var(--hp-bg)'); // the plain badge
o.tokensDiffer = new Set([YELLOW, ORANGE, NEUTRAL]).size === 3;
o.yellowIsTheYellow = YELLOW === 'rgb(255, 212, 92)';
o.orangeIsTheOpenFrame = ORANGE === 'rgb(255, 159, 67)';
// --- the gate marker, alone in the Garden, told it IS the curtain -------
const setMarker = async () => {
c._serverCfg = {
...c._serverCfg,
markers: [
{ id: 'm_gate', binding: 'device:d_gate', tap_action: 'cover', display: 'icon_ripple' },
{ id: 'm_mower', binding: 'device:d_mower', hidden: true },
],
};
c._cfgEpoch++;
c._regSignature = '';
c._maybeRebuildDevices();
c.requestUpdate();
await c.updateComplete;
};
const setCover = async (state, attrs = {}) => {
c.hass = { ...c.hass, states: { ...c.hass.states,
'cover.gate': { entity_id: 'cover.gate', state, attributes: { friendly_name: 'Gate', ...attrs } } } };
c.requestUpdate();
await c.updateComplete;
// .dev animates `background 0.15s` — a colour read in the same frame is
// still mid-fade and would pass on a plate that ends up orange
await new Promise((r) => setTimeout(r, 300));
};
c._setMode('view');
c._space = 'garden';
c.requestUpdate();
await c.updateComplete;
await setMarker();
await setCover('closed', { device_class: 'curtain' });
const devEl = () => sr().querySelector('.devlayer .dev');
const cls = () => [...(devEl()?.classList || [])];
const plate = () => getComputedStyle(devEl()).backgroundColor;
const icon = () => devEl()?.querySelector('ha-icon')?.getAttribute('icon') || '';
o.onlyTheCoverOnThePlan = sr().querySelectorAll('.devlayer .dev').length === 1;
/** One state: the plate must be the neutral one, and nothing else. */
const plateIsNeutral = (tag) => {
o[tag + 'PlateNeutral'] = plate() === NEUTRAL;
o[tag + 'NotYellow'] = plate() !== YELLOW;
o[tag + 'NotOrange'] = plate() !== ORANGE;
o[tag + 'NoPlateClass'] = !cls().includes('on') && !cls().includes('open');
};
plateIsNeutral('closed');
o.closedNoRing = !cls().includes('activity-transition');
o.closedIconClosed = icon() === 'mdi:curtains-closed';
await setCover('open', { device_class: 'curtain' });
plateIsNeutral('open');
// This fixture jumps closed → open without an intermediate HA state, so
// the unified resolver supplies the agreed ~3.3 s transition fallback.
o.openDirectTransition = cls().includes('activity-transition');
o.openIconOpen = icon() === 'mdi:curtains';
// ajar: HA reports a positioned curtain as plain 'open' — it reads as OPEN
await setCover('open', { device_class: 'curtain', current_position: 40 });
plateIsNeutral('ajar');
o.ajarIconOpen = icon() === 'mdi:curtains';
await setCover('opening', { device_class: 'curtain' });
plateIsNeutral('opening');
o.openingRings = cls().includes('activity-transition');
o.openingRingIsTheBreathingOne = (() => {
const ring = devEl()?.querySelector('.activity-ring.transition i:first-child');
const cs = ring ? getComputedStyle(ring) : null;
return !!cs && cs.animationName === 'hp-activity-breathe' && cs.animationDuration === '2.2s';
})();
await setCover('closing', { device_class: 'curtain' });
plateIsNeutral('closing');
o.closingRings = cls().includes('activity-transition');
// --- the morph covers every class, both ways ---------------------------
const morph = async (dc, closedIcon, openIcon) => {
await setCover('closed', dc ? { device_class: dc } : {});
const a = icon();
await setCover('open', dc ? { device_class: dc } : {});
const b = icon();
return a === closedIcon && b === openIcon && a !== b;
};
o.morphBlind = await morph('blind', 'mdi:blinds', 'mdi:blinds-open');
o.morphShade = await morph('shade', 'mdi:blinds', 'mdi:blinds-open');
o.morphShutter = await morph('shutter', 'mdi:window-shutter', 'mdi:window-shutter-open');
o.morphCurtain = await morph('curtain', 'mdi:curtains-closed', 'mdi:curtains');
o.morphWindow = await morph('window', 'mdi:window-closed', 'mdi:window-open');
o.morphAwning = await morph('awning', 'mdi:awning-outline', 'mdi:awning');
o.morphDoor = await morph('door', 'mdi:door-closed', 'mdi:door-open');
o.morphGarage = await morph('garage', 'mdi:garage', 'mdi:garage-open');
o.morphGate = await morph('gate', 'mdi:gate', 'mdi:gate-open');
o.morphDamper = await morph('damper', 'mdi:circle-slice-8', 'mdi:circle-outline');
// no device_class at all (z2m ships plenty): the gate's auto icon is the
// one the name rule «ворота|garage|gate» hands out, and it morphs too
o.gateAutoIcon = c._devices.find((x) => x.bindingRef === 'd_gate')?.icon === 'mdi:garage-variant';
o.morphNoClass = await morph('', 'mdi:garage-variant', 'mdi:garage-open-variant');
// …and an unreadable state morphs nothing, plate still neutral
await setCover('unknown', { device_class: 'curtain' });
plateIsNeutral('unknown');
o.unknownNoMorph = icon() === 'mdi:garage-variant';
o.unknownNoRing = !cls().includes('activity-transition');
// --- NOT touched: the «открыто» frame elsewhere ------------------------
// the demo house has a lock and a window sensor on the ground floor
c._space = 'f1';
c.requestUpdate();
await c.updateComplete;
const elOf = (dev) => {
const v = c._viewOr(c._baseVb());
const p = c._pos(dev);
const left = ((p.x - v.x) / v.w) * 100;
const top = ((p.y - v.y) / v.h) * 100;
return [...sr().querySelectorAll('.devlayer .dev')].find((e) =>
Math.abs(parseFloat(e.style.left) - left) < 0.4 && Math.abs(parseFloat(e.style.top) - top) < 0.4);
};
const dev = (ref) => c._devices.find((x) => x.bindingRef === ref);
c.hass = { ...c.hass, states: { ...c.hass.states,
'lock.front_door': { entity_id: 'lock.front_door', state: 'unlocked', attributes: { friendly_name: 'Front door' } },
'binary_sensor.window': { entity_id: 'binary_sensor.window', state: 'on', attributes: { friendly_name: 'Window', device_class: 'window' } } } };
c.requestUpdate();
await c.updateComplete;
await new Promise((r) => setTimeout(r, 300));
const lockEl = elOf(dev('d_lock'));
const winEl = elOf(dev('d_window'));
o.lockMarkerFound = !!lockEl;
o.windowMarkerFound = !!winEl;
o.unlockedLockKeepsTheFrame = !!lockEl && lockEl.classList.contains('open')
&& getComputedStyle(lockEl).backgroundColor === ORANGE;
o.openWindowKeepsTheFrame = !!winEl && winEl.classList.contains('open')
&& getComputedStyle(winEl).backgroundColor === ORANGE;
// …and a locked lock is neutral again, so the frame still MEANS something
c.hass = { ...c.hass, states: { ...c.hass.states,
'lock.front_door': { entity_id: 'lock.front_door', state: 'locked', attributes: { friendly_name: 'Front door' } } } };
c.requestUpdate();
await c.updateComplete;
await new Promise((r) => setTimeout(r, 300));
o.lockedLockIsNeutral = getComputedStyle(elOf(dev('d_lock'))).backgroundColor === NEUTRAL;
// a VALVE keeps the frame too: nothing morphs its icon, so the frame is the
// only thing it has to say «открыт» with (deliberately left alone)
const valve = { id: 'v', primary: 'valve.water', entities: ['valve.water'], marker: null };
const clsOfState = (d, states) => {
const saved = c.hass;
c.hass = { ...c.hass, states: { ...c.hass.states, ...states } };
const r = c._stateClass(d);
c.hass = saved;
return r;
};
o.valveOpenKeepsTheFrame = clsOfState(valve, { 'valve.water': { state: 'open', attributes: {} } }) === 'open';
o.valveClosedIsNeutral = clsOfState(valve, { 'valve.water': { state: 'closed', attributes: {} } }) === '';
// and the cover's own class string, straight from the source
const cover = { id: 'k', primary: 'cover.blind', entities: ['cover.blind'], marker: { display: 'icon_ripple' } };
o.coverOpenClassIsEmpty = clsOfState(cover, { 'cover.blind': { state: 'open', attributes: { device_class: 'blind' } } }) === '';
o.coverOpeningClassIsRingOnly = clsOfState(cover, { 'cover.blind': { state: 'opening', attributes: { device_class: 'blind' } } }) === 'activity-transition';
o.coverClosingClassIsRingOnly = clsOfState(cover, { 'cover.blind': { state: 'closing', attributes: { device_class: 'blind' } } }) === 'activity-transition';
o.coverUnavailableStillFades = clsOfState(cover, { 'cover.blind': { state: 'unavailable', attributes: {} } }) === 'unavail';
return o;
});
checkAll(out);
await finish(browser, out);
+230
View File
@@ -0,0 +1,230 @@
// «Открыть/закрыть» on a curtain whose cover entity used to lose to an option
// switch in registry/domain order.
// (owner's report 2026-08-04: «в настройках "открыть\закрыть", а по нажатию
// по-прежнему инфо-карточка»).
//
// The device below is his, entity for entity: an Aqara «Roller shade driver
// E1» ships the `cover.*` HIDDEN by the integration and a perfectly visible
// `switch.*_reverse_direction` beside it. The shared role resolver must pick
// the real cover for status, while the explicit tap action decides whether a
// click controls it. A mixed lamp+cover still stays a lamp unless the owner
// explicitly selects the cover action.
//
// End to end: the action is chosen in the dialog and SAVED through the normal
// path, and the click is done on the marker the card rebuilt from that config.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const out = await page.evaluate(async () => {
const o = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const calls = [];
// ---- the owner's curtain driver, joined to the demo house --------------
const ENTS = {
// hidden by the integration — exactly as z2m ships it
'cover.office_curtain': { entity_id: 'cover.office_curtain', device_id: 'd_curtain', platform: 'demo', hidden: true },
'sensor.office_curtain_battery': { entity_id: 'sensor.office_curtain_battery', device_id: 'd_curtain', platform: 'demo', entity_category: 'diagnostic' },
'switch.office_curtain_reverse': { entity_id: 'switch.office_curtain_reverse', device_id: 'd_curtain', platform: 'demo' },
'sensor.office_curtain_motor_state': { entity_id: 'sensor.office_curtain_motor_state', device_id: 'd_curtain', platform: 'demo' },
// a MIXED device: a lamp that also happens to ship a cover. Its primary is
// meaningfully the light, and nothing below may take that away from it.
'light.mixed': { entity_id: 'light.mixed', device_id: 'd_mixed', platform: 'demo' },
'cover.mixed': { entity_id: 'cover.mixed', device_id: 'd_mixed', platform: 'demo' },
};
const coverState = (state, attrs = {}) => ({
entity_id: 'cover.office_curtain', state,
attributes: { friendly_name: 'Office curtain', ...attrs },
});
const push = async (state, attrs = {}) => {
c.hass = {
...c.hass,
devices: { ...c.hass.devices,
d_curtain: { id: 'd_curtain', name: 'Office curtain', model: 'Roller shade driver E1',
area_id: 'bedroom', identifiers: [['demo', 'd_curtain']], entry_type: null, via_device_id: null },
d_mixed: { id: 'd_mixed', name: 'Bedside lamp', model: 'Lamp with a blind',
area_id: 'bedroom', identifiers: [['demo', 'd_mixed']], entry_type: null, via_device_id: null } },
entities: { ...c.hass.entities, ...ENTS },
states: { ...c.hass.states,
'cover.office_curtain': coverState(state, attrs),
'sensor.office_curtain_battery': { entity_id: 'sensor.office_curtain_battery', state: '84', attributes: { device_class: 'battery', unit_of_measurement: '%' } },
'switch.office_curtain_reverse': { entity_id: 'switch.office_curtain_reverse', state: 'off', attributes: { friendly_name: 'Reverse direction' } },
'sensor.office_curtain_motor_state': { entity_id: 'sensor.office_curtain_motor_state', state: 'stopped', attributes: {} },
'light.mixed': { entity_id: 'light.mixed', state: 'on', attributes: { friendly_name: 'Bedside lamp' } },
'cover.mixed': { entity_id: 'cover.mixed', state: 'opening', attributes: { friendly_name: 'Bedside blind', device_class: 'curtain' } },
},
callService: async (dom, svc, data) => { calls.push([dom, svc, data]); return {}; },
};
c._regSignature = '';
c._maybeRebuildDevices();
// park both extra markers in their own corners: the DOM assertions below
// find a marker by its position, and the auto grid would sit them on top
// of the demo house's own bedroom devices
const cu = c._devices.find((x) => x.bindingRef === 'd_curtain');
const mx = c._devices.find((x) => x.bindingRef === 'd_mixed');
if (cu && mx) {
c._layout = { ...c._layout,
[cu.id]: { s: cu.space, x: 0.06, y: 0.94 },
[mx.id]: { s: mx.space, x: 0.94, y: 0.06 } };
}
c.requestUpdate();
await c.updateComplete;
};
await push('closed', { device_class: 'curtain' });
const curtain = () => c._devices.find((x) => x.bindingRef === 'd_curtain');
const mixed = () => c._devices.find((x) => x.bindingRef === 'd_mixed');
o.deviceIsOnThePlan = !!curtain();
// Registry order and visibility no longer let an option switch outrank the
// actual functional entity.
o.primaryIsTheCover = curtain()?.primary === 'cover.office_curtain';
o.coverIsAmongTheEntities = (curtain()?.entities || []).includes('cover.office_curtain');
// ---- the dialog offers the action and SAVES it -------------------------
c._setMode('devices'); await c.updateComplete;
c._openMarkerDialog(curtain()); await c.updateComplete;
const optionValues = () => [...sr().querySelectorAll('hp-dialog option')].map((e) => e.value);
o.dialogOffersCover = optionValues().includes('cover');
c._markerDialog = { ...c._markerDialog, tapAction: 'cover', display: 'icon_ripple' };
await c.updateComplete;
await c._saveMarker();
await c.updateComplete;
const saved = (c._serverCfg.markers || []).find((m) => m.binding === 'device:d_curtain');
o.markerSaved = saved?.tap_action === 'cover';
// ---- and the tap on THAT marker opens the curtain -----------------------
c._setMode('view'); c._regSignature = ''; c._maybeRebuildDevices();
c.requestUpdate(); await c.updateComplete;
const tap = async () => {
c._clickDevice({ stopPropagation() {} }, curtain());
await c.updateComplete;
await new Promise((r) => setTimeout(r, 10));
};
o.tapActionSurvivedTheRebuild = curtain()?.tapAction === 'cover';
calls.length = 0;
await tap();
o.closedOpensTheCover = JSON.stringify(calls[calls.length - 1] || [])
=== JSON.stringify(['cover', 'open_cover', { entity_id: 'cover.office_curtain' }]);
o.noInfoCard = !c._infoCard;
c._infoCard = null;
await push('open', { device_class: 'curtain' });
calls.length = 0;
await tap();
o.openClosesTheCover = JSON.stringify(calls[calls.length - 1] || [])
=== JSON.stringify(['cover', 'close_cover', { entity_id: 'cover.office_curtain' }]);
await push('opening', { device_class: 'curtain' });
calls.length = 0;
await tap();
o.travellingStops = (calls[calls.length - 1] || [])[1] === 'stop_cover';
// the service switch, which used to be the whole story, is never touched
o.serviceSwitchNeverCalled = !calls.some((x) => String(x[2]?.entity_id).startsWith('switch.'));
// ---- the guarded classes still degrade, read off the COVER --------------
await push('closed', { device_class: 'garage' });
calls.length = 0;
await tap();
o.garageCallsNothing = calls.length === 0;
o.garageShowsInfo = !!c._infoCard;
c._infoCard = null; await c.updateComplete;
c._setMode('devices'); await c.updateComplete;
c._openMarkerDialog(curtain()); await c.updateComplete;
o.dialogHidesCoverForGarage = !optionValues().includes('cover');
c._markerDialog = null;
c._setMode('view'); await c.updateComplete;
// ---- and the marker SHOWS that cover, not the service switch -----------
// Owner, 2026-08-04: «нет ни дышащего кольца во время хода, ни рамки
// "открыто", ни морфинга иконки». (The «открыто» frame itself was retired
// for covers later the same day — the marker still had to start speaking for
// the cover, which is what this section pins.) Same cause, same helper:
// _stateClass and
// the icon morph read d.primary, so the plan reported the state of
// `switch.*_reverse_direction`. The rule (docs/FILTERING.md «What a marker
// SHOWS»): the marker indicates the entity its tap ACTS ON — the cover
// exactly when the owner has explicitly chosen «Открыть/закрыть».
const devEl = (dev) => {
const v = c._viewOr(c._baseVb());
const p = c._pos(dev);
const left = ((p.x - v.x) / v.w) * 100;
const top = ((p.y - v.y) / v.h) * 100;
return [...sr().querySelectorAll('.devlayer .dev')].find((e) =>
Math.abs(parseFloat(e.style.left) - left) < 0.4 && Math.abs(parseFloat(e.style.top) - top) < 0.4);
};
const clsOf = (dev) => [...(devEl(dev)?.classList || [])];
const iconOf = (dev) => devEl(dev)?.querySelector('ha-icon')?.getAttribute('icon') || '';
const setSwitch = async (state) => {
c.hass = { ...c.hass, states: { ...c.hass.states,
'switch.office_curtain_reverse': { entity_id: 'switch.office_curtain_reverse', state, attributes: {} } } };
c.requestUpdate(); await c.updateComplete;
};
await push('closed', { device_class: 'curtain' });
o.markerIsOnScreen = !!devEl(curtain());
o.closedIsNeitherOpenNorMoving =
!clsOf(curtain()).includes('open') && !clsOf(curtain()).includes('activity-transition');
o.closedIconIsClosedCurtains = iconOf(curtain()) === 'mdi:curtains-closed';
await push('open', { device_class: 'curtain' });
// the plate is neutral in every cover state since 2026-08-04 — the morph is
// the whole open/closed story (smoke_cover_no_plate.mjs)
o.openWearsNoColouredPlate =
!clsOf(curtain()).includes('open') && !clsOf(curtain()).includes('on');
o.openIconIsOpenCurtains = iconOf(curtain()) === 'mdi:curtains';
await push('opening', { device_class: 'curtain' });
o.travellingBreathes = clsOf(curtain()).includes('activity-transition');
await push('closing', { device_class: 'curtain' });
o.closingBreathesToo = clsOf(curtain()).includes('activity-transition');
o.stateClassIsTheSameStory = c._stateClass(curtain()).includes('activity-transition');
// the service switch never speaks for the marker again: reverse-direction
// ON used to paint the curtain yellow («включено») while it stood still
await push('closed', { device_class: 'curtain' });
await setSwitch('on');
o.reverseSwitchNeverLightsTheMarker = !clsOf(curtain()).includes('on');
// Status and action are independent: taking the explicit action away keeps
// the resolved cover status, but a click returns to the ordinary info path.
const savedMarker = (c._serverCfg.markers || []).find((m) => m.binding === 'device:d_curtain');
savedMarker.tap_action = 'info';
await push('opening', { device_class: 'curtain' });
await setSwitch('on');
o.withoutTheActionResolvedCoverSpeaks = clsOf(curtain()).includes('activity-transition')
&& !clsOf(curtain()).includes('on');
savedMarker.tap_action = 'cover';
await push('closed', { device_class: 'curtain' });
await setSwitch('off');
o.actionBackMeansCoverBack = iconOf(curtain()) === 'mdi:curtains-closed';
// ---- the auditor's own probe: BOTH entities visible ---------------------
// The same rule must hold when BOTH entities are visible.
ENTS['cover.office_curtain'] = { ...ENTS['cover.office_curtain'], hidden: false };
await push('closed', { device_class: 'curtain' });
await setSwitch('off');
o.visibleCoverIsThePrimary = curtain()?.primary === 'cover.office_curtain';
calls.length = 0;
await tap();
o.visibleCoverOpensOnTap = JSON.stringify(calls[calls.length - 1] || [])
=== JSON.stringify(['cover', 'open_cover', { entity_id: 'cover.office_curtain' }]);
o.visibleCoverAlsoIndicates = iconOf(curtain()) === 'mdi:curtains-closed';
o.noInfoCardForTheVisibleCover = !c._infoCard;
// ---- a MIXED device is not hijacked ------------------------------------
// A lit lamp that also owns a cover keeps the lamp: no explicit «Open/close»
// on this marker, so nothing changes for it — and even if there were one,
// a shining light still wins the yellow plate (the glow and the badge may
// never disagree, owner's principle 2026-07-29).
o.mixedPrimaryIsTheLight = mixed()?.primary === 'light.mixed';
o.mixedCoverIsTravelling = c.hass.states['cover.mixed'].state === 'opening';
o.mixedStaysALitLamp = clsOf(mixed()).includes('on') && !clsOf(mixed()).includes('activity-transition');
// its icon is the device's own auto icon, NOT the cover's open/closed pair
o.mixedIconIsNotMorphedByTheCover =
iconOf(mixed()) === mixed()?.icon && !iconOf(mixed()).includes('curtains');
return o;
});
checkAll(out);
await finish(browser, out);
+208
View File
@@ -0,0 +1,208 @@
// An EXPLICIT «Открыть/закрыть» marker is a curtain, and a curtain is never
// painted — audit DEV-1DA1-01 (P2).
//
// `_stateClass` used to answer in this order: bound `controls` first, then any
// lit `light.*` of the device, and only then the entity the tap acts on
// (`_actEntity` → `_coverIndicator`). So the two combinations below still made
// a curtain yellow, in flat contradiction with the owner's contract («у штор
// не должно быть жёлтой подложки НИКОГДА») and with the promise that a marker
// indicates the entity its tap drives:
//
// 1. a MIXED device (a lamp that also ships a blind) told `tap_action:
// 'cover'` — the lit `light.mixed` returned 'on' before the cover branch
// was ever reached, so the travelling blind lost its breathing ring too;
// 2. a curtain marker with a bound `controls` target that happened to be on
// — `controls` returned 'on' first, same result.
//
// The order now starts with the explicit cover: saying «this thing is a
// curtain» in the dialog is the strongest statement the card has about what a
// marker IS, so nothing may paint over it. Everything else keeps the old
// precedence — a marker that was NOT told it is a curtain is untouched, and
// that is re-checked at the bottom.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const out = await page.evaluate(async () => {
const o = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
// ---- the plate colours, resolved the way the badge resolves them -------
const probe = (css) => {
const d = document.createElement('div');
d.style.cssText = `position:absolute;left:-999px;background:${css}`;
sr().appendChild(d);
const v = getComputedStyle(d).backgroundColor;
d.remove();
return v;
};
const YELLOW = probe('var(--hp-on)');
const ORANGE = probe('var(--hp-open)');
const NEUTRAL = probe('var(--hp-bg)');
o.tokensDiffer = new Set([YELLOW, ORANGE, NEUTRAL]).size === 3;
// =======================================================================
// Part 1 — the class itself, straight from _stateClass, over every state
// =======================================================================
const withStates = (states, fn) => {
const saved = c.hass;
c.hass = { ...c.hass, states: { ...c.hass.states, ...states } };
const r = fn();
c.hass = saved;
return r;
};
const st = (state, attrs = {}) => ({ state, attributes: attrs });
/** The auditor's marker #1: primary is a LIT light, the cover is explicit. */
const litLamp = {
id: 'p_lamp', primary: 'light.mixed', entities: ['light.mixed', 'cover.mixed'],
tapAction: 'cover', marker: { display: 'icon_ripple' },
};
/** The auditor's marker #2: a cover marker whose bound control is on. */
const controlled = {
id: 'p_ctrl', primary: 'cover.mixed', entities: ['cover.mixed'],
tapAction: 'cover', marker: { id: 'p_ctrl', binding: 'device:x', controls: ['switch.control'], display: 'icon_ripple' },
};
const CV = { closed: '', open: '', opening: 'activity-transition', closing: 'activity-transition' };
for (const [state, want] of Object.entries(CV)) {
const states = {
'light.mixed': st('on', { friendly_name: 'Bedside lamp' }),
'switch.control': st('on', {}),
'cover.mixed': st(state, { device_class: 'curtain' }),
};
o['litLamp_' + state] = withStates(states, () => c._stateClass(litLamp)) === want;
o['controlled_' + state] = withStates(states, () => c._stateClass(controlled)) === want;
}
// an unreachable cover still only fades — never yellow, never orange
o.litLampUnavailFades = withStates({
'light.mixed': st('on', {}), 'cover.mixed': st('unavailable', {}),
}, () => c._stateClass(litLamp)) === 'unavail';
o.controlledUnavailFades = withStates({
'switch.control': st('on', {}), 'cover.mixed': st('unavailable', {}),
}, () => c._stateClass(controlled)) === 'unavail';
// …and a selected cover with no state is unavailable, never borrowed from
// the lit neighbour that the explicit cover action deliberately outranks.
o.litLampNoCoverStateIsUnavailable = withStates({
'light.mixed': st('on', {}),
}, () => c._stateClass(litLamp)) === 'unavail';
// ---- the OTHER side of the rule: nothing changed for the rest ----------
// the very same mixed device WITHOUT the explicit action keeps its primary
const plainLamp = { ...litLamp, id: 'p_plain', tapAction: null, marker: null };
o.mixedWithoutTheOptionStaysYellow = withStates({
'light.mixed': st('on', {}), 'cover.mixed': st('opening', { device_class: 'curtain' }),
}, () => c._stateClass(plainLamp)) === 'on';
o.mixedWithoutTheOptionOffIsNeutral = withStates({
'light.mixed': st('off', {}), 'cover.mixed': st('opening', { device_class: 'curtain' }),
}, () => c._stateClass(plainLamp)) === '';
// a wall switch with controls and NO cover in sight still mirrors them
const remote = {
id: 'p_remote', primary: 'sensor.remote', entities: ['sensor.remote'], tapAction: null,
marker: { id: 'p_remote', binding: 'device:y', controls: ['light.hall'] },
};
o.remoteMirrorsItsControls = withStates({ 'light.hall': st('on', {}) },
() => c._stateClass(remote)) === 'on';
o.remoteOffIsNeutral = withStates({ 'light.hall': st('off', {}) },
() => c._stateClass(remote)) === '';
// a curtain marker with controls that are OFF is still just a curtain
o.controlledOffStillRings = withStates({
'switch.control': st('off', {}), 'cover.mixed': st('opening', { device_class: 'curtain' }),
}, () => c._stateClass(controlled)) === 'activity-transition';
// an explicit «cover» marker with NO cover among its entities falls back to
// the old order — the statement is only as strong as the entity behind it
const noCover = {
id: 'p_nocover', primary: 'light.mixed', entities: ['light.mixed'], tapAction: 'cover', marker: null,
};
o.explicitCoverWithoutACoverKeepsThePrimary = withStates({ 'light.mixed': st('on', {}) },
() => c._stateClass(noCover)) === 'on';
// =======================================================================
// Part 2 — the real markers on the real plan: the PLATE, not just a class
// =======================================================================
const ENTS = {
'light.mixed': { entity_id: 'light.mixed', device_id: 'd_mixed', platform: 'demo' },
'cover.mixed': { entity_id: 'cover.mixed', device_id: 'd_mixed', platform: 'demo' },
'cover.ctrl': { entity_id: 'cover.ctrl', device_id: 'd_ctrl', platform: 'demo' },
'switch.control': { entity_id: 'switch.control', device_id: 'd_wall', platform: 'demo' },
};
const push = async (coverState) => {
c.hass = {
...c.hass,
devices: { ...c.hass.devices,
d_mixed: { id: 'd_mixed', name: 'Bedside lamp', model: 'Lamp with a blind',
area_id: 'bedroom', identifiers: [['demo', 'd_mixed']], entry_type: null, via_device_id: null },
d_ctrl: { id: 'd_ctrl', name: 'Office curtain', model: 'Curtain motor',
area_id: 'bedroom', identifiers: [['demo', 'd_ctrl']], entry_type: null, via_device_id: null },
d_wall: { id: 'd_wall', name: 'Wall switch', model: 'Wall switch',
area_id: 'bedroom', identifiers: [['demo', 'd_wall']], entry_type: null, via_device_id: null } },
entities: { ...c.hass.entities, ...ENTS },
states: { ...c.hass.states,
'light.mixed': { entity_id: 'light.mixed', state: 'on', attributes: { friendly_name: 'Bedside lamp' } },
'cover.mixed': { entity_id: 'cover.mixed', state: coverState, attributes: { friendly_name: 'Bedside blind', device_class: 'curtain' } },
'cover.ctrl': { entity_id: 'cover.ctrl', state: coverState, attributes: { friendly_name: 'Office curtain', device_class: 'curtain' } },
'switch.control': { entity_id: 'switch.control', state: 'on', attributes: { friendly_name: 'Wall switch' } },
},
};
c._serverCfg = {
...c._serverCfg,
markers: [
{ id: 'm_mixed', binding: 'device:d_mixed', tap_action: 'cover', display: 'icon_ripple' },
{ id: 'm_ctrl', binding: 'device:d_ctrl', tap_action: 'cover', controls: ['switch.control'], display: 'icon_ripple' },
],
};
c._cfgEpoch++;
c._regSignature = '';
c._maybeRebuildDevices();
const mx = c._devices.find((x) => x.bindingRef === 'd_mixed');
const ct = c._devices.find((x) => x.bindingRef === 'd_ctrl');
if (mx && ct) {
c._layout = { ...c._layout,
[mx.id]: { s: mx.space, x: 0.94, y: 0.06 },
[ct.id]: { s: ct.space, x: 0.06, y: 0.94 } };
}
c.requestUpdate();
await c.updateComplete;
// .dev animates `background 0.15s` — read a settled colour, not a fade
await new Promise((r) => setTimeout(r, 300));
};
c._setMode('view');
c._space = 'f1';
c.requestUpdate();
await c.updateComplete;
await push('opening');
const dev = (ref) => c._devices.find((x) => x.bindingRef === ref);
const elOf = (d) => {
const v = c._viewOr(c._baseVb());
const p = c._pos(d);
const left = ((p.x - v.x) / v.w) * 100;
const top = ((p.y - v.y) / v.h) * 100;
return [...sr().querySelectorAll('.devlayer .dev')].find((e) =>
Math.abs(parseFloat(e.style.left) - left) < 0.4 && Math.abs(parseFloat(e.style.top) - top) < 0.4);
};
const report = (tag, ref) => {
const d = dev(ref);
const el = d && elOf(d);
o[tag + 'MarkerFound'] = !!el;
if (!el) return;
const bg = getComputedStyle(el).backgroundColor;
o[tag + 'PlateNeutral'] = bg === NEUTRAL;
o[tag + 'NotYellow'] = bg !== YELLOW;
o[tag + 'NotOrange'] = bg !== ORANGE;
o[tag + 'NoOnClass'] = !el.classList.contains('on') && !el.classList.contains('open');
o[tag + 'Rings'] = el.classList.contains('activity-transition');
o[tag + 'IconIsTheCover'] = el.querySelector('ha-icon')?.getAttribute('icon') === 'mdi:curtains';
};
o.lampIsTrulyLit = c.hass.states['light.mixed'].state === 'on';
o.controlIsTrulyOn = c.hass.states['switch.control'].state === 'on';
report('litLampDom', 'd_mixed');
report('controlledDom', 'd_ctrl');
// …and at rest (closed) they are plain, with no ring
await push('closed');
const closedBg = (ref) => getComputedStyle(elOf(dev(ref))).backgroundColor;
o.litLampClosedNeutral = closedBg('d_mixed') === NEUTRAL;
o.controlledClosedNeutral = closedBg('d_ctrl') === NEUTRAL;
o.litLampClosedNoRing = !elOf(dev('d_mixed')).classList.contains('activity-transition');
return o;
});
checkAll(out);
await finish(browser, out);
+197
View File
@@ -0,0 +1,197 @@
// Covers: the «Open/close» tap action + the travelling indication
// (owner's contract 2026-08-03).
//
// closed -> cover.open_cover
// open / ajar -> cover.close_cover
// opening / closing -> cover.stop_cover (a tap during travel is a STOP;
// the next tap simply travels the other way)
// unknown -> cover.toggle, no morphing, no pulse
//
// The option is offered ONLY for a binding that HAS a cover entity, and never
// for the guarded classes (garage/door/gate) — for those a value smuggled into
// the config degrades to 'info', exactly like a card-wide toggle does.
// With «Icon + activity», travelling breathes a soft activity ring (the
// vacuum puck's 2.2s period) and the plate stays NEUTRAL — yellow means work.
// Since 2026-08-04 the plate is neutral in EVERY cover state, the «открыто»
// frame included: open/closed is told by the icon morph alone (the full
// contract lives in smoke_cover_no_plate.mjs).
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const out = await page.evaluate(async () => {
const o = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const calls = [];
const YELLOW = 'rgb(255, 212, 92)'; // --hp-on, the «включено» plate
const gateEntity = (state, attrs) => ({
entity_id: 'cover.gate', state,
attributes: { friendly_name: 'Gate', ...attrs },
});
/** Push a cover state into hass keeping the service recorder in place. */
const setCover = async (state, attrs = {}) => {
c.hass = {
...c.hass,
states: { ...c.hass.states, 'cover.gate': gateEntity(state, attrs) },
callService: async (dom, svc, data) => { calls.push([dom, svc, data]); return {}; },
};
c.requestUpdate();
await c.updateComplete;
};
/** Marker for the gate device: tap action + optional confirmation.
* `solo` also hides the mower, leaving exactly one badge in the Garden —
* the indication checks then read the cover's own plate, not a neighbour's. */
const setMarker = async (tapAction, confirm = false, solo = false) => {
c._serverCfg = {
...c._serverCfg,
markers: [
{ id: 'm_gate', binding: 'device:d_gate', tap_action: tapAction,
tap_confirm: confirm || null, display: 'icon_ripple' },
...(solo ? [{ id: 'm_mower', binding: 'device:d_mower', hidden: true }] : []),
],
};
c._cfgEpoch++;
c._regSignature = '';
c._maybeRebuildDevices();
c.requestUpdate();
await c.updateComplete;
};
const gate = () => c._devices.find((x) => x.bindingRef === 'd_gate');
const tap = async () => { c._clickDevice({ stopPropagation() {} }, gate()); await c.updateComplete; };
// never throw when the feature is missing: the smoke must REPORT the gap
const lastCall = () => calls[calls.length - 1] || ['-', '-', {}];
// the gate is the only cover in the Garden space; the plan draws no names
// inside the badge, so the icon set of the space is the readable handle
const devEl = () => sr().querySelector('.dev');
const icons = () => [...sr().querySelectorAll('.dev ha-icon')].map((e) => e.getAttribute('icon'));
// the gate lives in the Garden space — look at it, not at the ground floor
c._setMode('view');
c._space = 'garden';
c.requestUpdate();
await c.updateComplete;
// ---- the dialog offers the option only where it makes sense ------------
await setCover('closed', { device_class: 'curtain' });
c._setMode('devices'); await c.updateComplete;
const optionValues = () => [...sr().querySelectorAll('hp-dialog option')].map((e) => e.value);
c._openMarkerDialog(gate()); await c.updateComplete;
o.dialogOffersCoverForCurtain = optionValues().includes('cover');
// …and the confirmation checkbox follows the choice, like it does for run/toggle
const rowsBefore = sr().querySelectorAll('hp-dialog .srcrow').length;
c._markerDialog = { ...c._markerDialog, tapAction: 'cover' }; await c.updateComplete;
o.confirmCheckboxShownForCover = sr().querySelectorAll('hp-dialog .srcrow').length > rowsBefore;
c._markerDialog = null; await c.updateComplete;
// a GARAGE door never gets the option (owner: «нет, только шторы/жалюзи»)
await setCover('closed', { device_class: 'garage' });
c._openMarkerDialog(gate()); await c.updateComplete;
o.dialogHidesCoverForGarage = !optionValues().includes('cover');
c._markerDialog = null; await c.updateComplete;
// …and neither does a device without a cover entity at all
c._openMarkerDialog(c._devices.find((x) => x.bindingRef === 'd_mower')); await c.updateComplete;
o.dialogHidesCoverWithoutCover = !optionValues().includes('cover');
c._markerDialog = null; await c.updateComplete;
c._setMode('view'); c._space = 'garden'; c.requestUpdate(); await c.updateComplete;
// ---- the tap itself ----------------------------------------------------
await setMarker('cover');
await setCover('closed', { device_class: 'curtain' });
calls.length = 0;
await tap();
await new Promise((r) => setTimeout(r, 10));
o.closedOpens = JSON.stringify(lastCall()) === JSON.stringify(['cover', 'open_cover', { entity_id: 'cover.gate' }]);
await setCover('open', { device_class: 'curtain' });
await tap(); await new Promise((r) => setTimeout(r, 10));
o.openCloses = lastCall()[0] === 'cover' && lastCall()[1] === 'close_cover';
// a half-drawn curtain is still 'open' for HA — it closes
await setCover('open', { device_class: 'curtain', current_position: 40 });
await tap(); await new Promise((r) => setTimeout(r, 10));
o.ajarCloses = lastCall()[1] === 'close_cover';
await setCover('opening', { device_class: 'curtain' });
await tap(); await new Promise((r) => setTimeout(r, 10));
o.openingStops = lastCall()[1] === 'stop_cover';
await setCover('closing', { device_class: 'curtain' });
await tap(); await new Promise((r) => setTimeout(r, 10));
o.closingStops = lastCall()[1] === 'stop_cover';
// no readable state — a plain cover.toggle, nothing clever
await setCover('unknown', { device_class: 'curtain' });
await tap(); await new Promise((r) => setTimeout(r, 10));
o.unknownToggles = lastCall()[0] === 'cover' && lastCall()[1] === 'toggle';
// ---- the confirmation checkbox works for this action too ---------------
await setMarker('cover', true);
await setCover('closed', { device_class: 'curtain' });
calls.length = 0;
await tap();
o.confirmAsks = !!c._tapConfirm && c._tapConfirm.text.includes('Gate');
c._tapConfirm = null; await c.updateComplete;
await new Promise((r) => setTimeout(r, 10));
o.confirmCancelCallsNothing = calls.length === 0;
await tap();
const conf = c._tapConfirm; c._tapConfirm = null; conf?.exec?.();
await new Promise((r) => setTimeout(r, 10));
o.confirmOkOpens = calls.length === 1 && calls[0][1] === 'open_cover';
// ---- a saved 'cover' on a GARAGE degrades to the info card -------------
await setMarker('cover');
await setCover('closed', { device_class: 'garage' });
calls.length = 0;
await tap();
await new Promise((r) => setTimeout(r, 10));
o.garageSavedCoverCallsNothing = calls.length === 0;
o.garageSavedCoverShowsInfo = !!c._infoCard;
c._infoCard = null; await c.updateComplete;
// ---- the indication ----------------------------------------------------
const afterAnim = () => {
const el = devEl()?.querySelector('.activity-ring.transition i:first-child');
if (!el) return null;
const cs = getComputedStyle(el);
return { name: cs.animationName, dur: cs.animationDuration };
};
await setMarker('cover', false, true); // the gate alone on the plan
await setCover('opening', { device_class: 'curtain' });
// .dev animates `background 0.15s`: lit recycles the badge node the hidden
// mower used, so a plate colour read in the same frame is still mid-fade
await new Promise((r) => setTimeout(r, 300));
o.onlyTheCoverOnThePlan = sr().querySelectorAll('.dev').length === 1;
o.pulseWhileOpening = sr().querySelectorAll('.dev.activity-transition').length === 1;
const a1 = afterAnim();
o.pulseIsBreathingRing = a1?.name === 'hp-activity-breathe' && a1?.dur === '2.2s';
// the plate stays NEUTRAL while travelling: yellow is reserved for «включено»
o.movingPlateNotYellow = getComputedStyle(devEl()).backgroundColor !== YELLOW;
await setCover('closing', { device_class: 'curtain' });
await new Promise((r) => setTimeout(r, 300));
o.pulseWhileClosing = sr().querySelectorAll('.dev.activity-transition').length === 1;
o.closingPlateNotYellow = getComputedStyle(devEl()).backgroundColor !== YELLOW;
await setCover('open', { device_class: 'curtain' });
o.noPulseWhenOpen = sr().querySelectorAll('.dev.activity-transition').length === 0;
o.iconMorphOpen = icons().includes('mdi:curtains') && !icons().includes('mdi:curtains-closed');
// owner 2026-08-04: never a coloured plate on a cover — not even «открыто»
o.openWearsNoFrame = sr().querySelectorAll('.dev.open').length === 0;
o.openPlateNotYellow = getComputedStyle(devEl()).backgroundColor !== YELLOW;
await setCover('closed', { device_class: 'curtain' });
o.directCloseGetsFallback = sr().querySelectorAll('.dev.activity-transition').length === 1;
o.iconMorphClosed = icons().includes('mdi:curtains-closed') && !icons().includes('mdi:curtains');
await setCover('open', { device_class: 'shutter' });
o.iconMorphShutterOpen = icons().includes('mdi:window-shutter-open');
await setCover('unknown', { device_class: 'curtain' });
o.noPulseWhenUnknown = sr().querySelectorAll('.dev.activity-transition').length === 0;
o.noMorphWhenUnknown = icons().includes(gate().icon)
&& !icons().some((i) => i.startsWith('mdi:curtains'));
return o;
});
checkAll(out);
await finish(browser, out);
+133 -4
View File
@@ -11,7 +11,10 @@ const res = await page.evaluate(async () => {
tabs[2].click(); await c.updateComplete;
out.decorMode = c._mode === 'decor';
out.decorBar = !!sr().querySelector('.editbar.decorbar');
out.toolBtns = sr().querySelectorAll('.decorbar .btn:not(.barclose)').length === 6;
// seven tools (the six drawing ones + «Мебель», docs/FURNITURE.md) plus
// «Картинка-подложка», which f1 offers because it HAS a picture
// (docs/BACKDROP.md §2); a hand-drawn space still shows seven
out.toolBtns = sr().querySelectorAll('.decorbar .btn.dtool').length === 8;
// 2) нарисовать прямоугольник drag-ом (через прямые вызовы)
c._decorTool = 'rect'; c._decorStyle = { color: '#ff0000', width: 3, fill: true }; await c.updateComplete;
const g = c._gridPitch;
@@ -45,8 +48,49 @@ const res = await page.evaluate(async () => {
c._decorDraft = { kind: 'line', a: [g, g], b: [g, g], pid: 1 };
c._decorCommitDraft();
out.degenerateSkipped = c._decorList.length === 3;
// 4) надпись через диалог
c._decorTextDialog = { x: 0.5, y: 0.5, text: 'Сауна', size: 'l', color: '#0000ff' };
// 3b) живая плашка размера, пока фигуру ещё тянут (владелец 2026-08-04:
// «в редакторе подложки у линий писать длину, как при рисовании комнат в
// редакторе плана»). Та же .measurelabel, тот же formatLength/cell_cm.
const label = () => sr().querySelector('.measurelabel.dmeasure');
const txt = () => (label() ? label().textContent.trim() : null);
const cellCm = 5; // cell_cm по умолчанию
const fmt = (px) => (((px / g) * cellCm) / 100).toFixed(2) + ' m';
const vw = () => c._viewOr(c._baseVb());
c._decorTool = 'line';
c._decorDraft = { kind: 'line', a: [g * 4, g * 20], b: [g * 4, g * 20], pid: 3 };
await c.updateComplete;
out.noBadgeBeforeTheDrag = !label(); // нулевая длина — молчим
c._decorDraft = { ...c._decorDraft, b: [g * 16, g * 20] }; // 12 клеток вправо
await c.updateComplete;
out.lineBadgeShown = !!label();
// 12 клеток × 5 см = 0.60 m, угол 0° — ровно то, что пишет плашка стены
out.lineBadgeText = txt();
out.lineBadgeMatchesGeometry = txt() === fmt(g * 12) + ' · 0°';
// ...ровно на середине отрезка (владелец: «плашка на середине линии»)
out.lineBadgeAtTheMiddle = !!label() && Math.abs(
parseFloat(label().style.left) - ((g * 10 - vw().x) / vw().w) * 100) < 0.02;
// 0° кратен 45° — плашка зелёная, как у стены на 45°
out.badgeGreenOnAxis = !!label() && label().classList.contains('on45');
// косой отрезок 3-4-5: длина 5 клеток, угол 53.1° — не кратен 45°
c._decorDraft = { kind: 'line', a: [g * 4, g * 20], b: [g * 7, g * 24], pid: 3 };
await c.updateComplete;
out.obliqueBadge = txt() === fmt(g * 5) + ' · 53.1°';
out.obliqueNotGreen = !!label() && !label().classList.contains('on45');
// прямоугольник и овал: длины у них нет, есть габарит «Ш × В»
c._decorDraft = { kind: 'rect', a: [g * 4, g * 20], b: [g * 12, g * 26], pid: 3 };
await c.updateComplete;
out.rectBadgeIsSize = txt() === fmt(g * 8) + ' × ' + fmt(g * 6);
// отпустили — плашки нет
c._decorCommitDraft(); await c.updateComplete;
out.badgeGoneAfterRelease = !label();
c._curSpaceCfg.decor = c._decorList.slice(0, 3); // назад к трём фигурам
c._decorTool = 'select';
await c.updateComplete;
out.stillThreeShapes = c._decorList.length === 3;
// 4) надпись через диалог. Поля `size` в диалоге больше нет — размер задаётся
// углами блока (см. smoke_decor_text); сохранённый старый `size` по-прежнему
// читается как начальный масштаб.
c._decorTextDialog = { x: 0.5, y: 0.5, text: 'Сауна', color: '#0000ff' };
c._decorSaveText(); await c.updateComplete;
out.textSaved = c._decorList.some((x) => x.kind === 'text' && x.text === 'Сауна');
out.textRendered = [...sr().querySelectorAll('.decorlayer text')].some((t) => t.textContent.includes('Сауна'));
@@ -62,6 +106,90 @@ const res = await page.evaluate(async () => {
c._curSpaceCfg.decor = c._decorList.map((x) => x.id === m.id ? { ...x, x: m.orig.x + dx5 } : x);
out.moveKeepsSize = Math.abs(c._decorList.find((x) => x.id === rect.id).w - before.w) < 1e-9;
c._decorMove = null;
// 5b) инструмент рисования ВЛАДЕЕТ холстом: клик по существующей фигуре
// начинает новую фигуру в этой точке, а не выделяет старую (владелец
// 2026-08-04: «нельзя поставить начало линии на конец другой»).
const stageEl = () => sr().querySelector('.stage');
// экранные координаты точки холста (обратное _screenToVb)
const toScreen = (x, y) => {
const r = stageEl().getBoundingClientRect();
const v = c._viewOr(c._baseVb());
return { clientX: r.left + ((x - v.x) / v.w) * r.width,
clientY: r.top + ((y - v.y) / v.h) * r.height };
};
const press = (el, x, y, pid) => {
const { clientX, clientY } = toScreen(x, y);
el.dispatchEvent(new PointerEvent('pointerdown', {
bubbles: true, composed: true, cancelable: true,
pointerId: pid, clientX, clientY, button: 0, isPrimary: true }));
};
// подопытная линия с известными концами
c._decorTool = 'select'; c._decorSel = null; c._decorDraft = null; c._decorMove = null;
const decorBefore = c._decorList.slice();
c._curSpaceCfg.decor = [{ id: 'dcprobe', kind: 'line', x1: (g * 6) / 1000, y1: (g * 30) / 1000,
x2: (g * 18) / 1000, y2: (g * 30) / 1000, color: '#000000', width: 3 }];
c._cfgEpoch = (c._cfgEpoch || 0) + 1;
c.requestUpdate(); await c.updateComplete;
const probe = () => [...sr().querySelectorAll('.decorlayer line.dshape')]
.find((l) => Math.abs(+l.getAttribute('x1') - g * 6) < 0.01);
out.probeRendered = !!probe();
// — инструмент «линия»: жмём ровно на КОНЕЦ линии
c._decorTool = 'line'; await c.updateComplete;
// фигура вообще перестаёт быть мишенью для указателя
out.shapeInertUnderLineTool = getComputedStyle(probe()).pointerEvents === 'none';
press(probe(), g * 18, g * 30, 41);
await c.updateComplete;
out.lineToolStartsDraft = !!c._decorDraft && c._decorDraft.kind === 'line';
// ...и именно из этой точки (снап к концу старой линии)
out.draftStartsAtTheEnd = !!c._decorDraft
&& Math.abs(c._decorDraft.a[0] - g * 18) < 0.51 && Math.abs(c._decorDraft.a[1] - g * 30) < 0.51;
out.lineToolNoSelect = c._decorSel === null;
out.lineToolNoMove = !c._decorMove;
c._decorDraft = null; await c.updateComplete;
// — прямоугольник и овал ведут себя так же
for (const [tool, key] of [['rect', 'rectToolStartsDraft'], ['ellipse', 'ellipseToolStartsDraft']]) {
c._decorTool = tool; c._decorSel = null; await c.updateComplete;
press(probe(), g * 12, g * 30, 42);
await c.updateComplete;
out[key] = !!c._decorDraft && c._decorDraft.kind === tool && c._decorSel === null;
c._decorDraft = null;
}
// — «текст» по ЛИНИИ открывает диалог новой надписи, а не выделяет её.
// Инертность нетекстовых фигур под рисующими инструментами остаётся в силе;
// единственное исключение — надпись под инструментом «текст», она открывает
// СВОЮ форму (владелец 2026-08-04, пинится в smoke_decor_text).
c._decorTool = 'text'; c._decorSel = null; c._decorTextDialog = null; await c.updateComplete;
press(probe(), g * 12, g * 30, 43);
await c.updateComplete;
out.textToolOpensDialog = !!c._decorTextDialog && !c._decorTextDialog.id && c._decorSel === null;
c._decorTextDialog = null;
// — а вот в «выборе» всё по-прежнему: клик выделяет и берёт на перетаскивание
c._decorTool = 'select'; c._decorSel = null; c._decorDraft = null; await c.updateComplete;
out.shapeLiveUnderSelect = getComputedStyle(probe()).pointerEvents !== 'none';
press(probe(), g * 12, g * 30, 44);
await c.updateComplete;
out.selectStillSelects = c._decorSel === 'dcprobe';
out.selectStillGrabs = !!c._decorMove && c._decorMove.id === 'dcprobe';
out.selectMakesNoDraft = !c._decorDraft;
c._decorMove = null;
// Double click in Select is the universal properties gesture for every
// object, not only text. It must go through the rendered listener so this
// also pins the event wiring, then persist the common colour/width fields.
probe().dispatchEvent(new MouseEvent('dblclick', {
bubbles: true, composed: true, cancelable: true, button: 0,
}));
await c.updateComplete;
out.doubleClickOpensObjectDialog = c._decorShapeDialog?.id === 'dcprobe'
&& !!sr().querySelector('hp-dialog .dfill') === false;
c._decorShapeDialog = { ...c._decorShapeDialog, color: '#123456', width: 6.5 };
c._decorSaveShape(); await c.updateComplete;
const editedProbe = c._decorList.find((x) => x.id === 'dcprobe');
out.objectDialogSavesStyle = editedProbe?.color === '#123456'
&& editedProbe?.width === 6.5 && c._decorShapeDialog === null;
c._decorSel = null;
c._curSpaceCfg.decor = decorBefore; // сцена как была до пробы
c._decorTool = 'select'; await c.updateComplete;
out.decorRestored = c._decorList.length === decorBefore.length;
// 6) erase удаляет
const n0 = c._decorList.length;
c._decorTool = 'erase';
@@ -82,5 +210,6 @@ const res = await page.evaluate(async () => {
out.deleteKey = c._decorList.length === n1 - 1;
return out;
});
checkAll(res);
// значение плашки зафиксировано числом: 12 клеток × cell_cm 5 = 0.60 m, 0°
checkAll(res, { lineBadgeText: '0.60 m · 0°' });
await finish(browser, res);
+217
View File
@@ -0,0 +1,217 @@
// Текстовый блок декора (правки владельца 2026-08-04):
// 1) выбора размера шрифта БОЛЬШЕ НЕТ — размер задаётся углами блока, а
// сохранённый старый `size` читается как начальный масштаб;
// 2) многострочный текст: перевод строки сохраняется и рисуется, блок
// выравнивается по центру;
// 3) у выделенного блока есть угловые ручки (масштаб) и ручка поворота
// (шаг 5°, Shift — мимо шага); ВИДИМЫЙ размер ручки — четверть хит-зоны
// (правка владельца 2026-08-05 «уменьшить в 4 раза»), сама хит-зона
// прежняя, пальцевая: 1.8 % видимого вида;
// 4) при инструменте «текст» клик по УЖЕ РАЗМЕЩЁННОЙ надписи открывает её
// форму, а не создаёт новую; по пустому месту и по НЕтекстовой фигуре —
// создаёт новую (нетекстовые фигуры остаются инертными, см. smoke_decor).
// ПАДАЕТ на сборке до этих правок: в диалоге ещё радиокнопки размера, перевод
// строки схлопывается в одну строку, ручек нет, а «текст» по надписи создаёт
// вторую надпись поверх первой.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// null-safe: на сборке ДО этих правок у надписи нет ни data-id, ни tspan —
// смок должен показать список провалов, а не упасть с исключением
const el = (id) => sr().querySelector(`.decorlayer text.dtext[data-id="${id}"]`);
const attr = (id, name) => { const e = el(id); return e ? e.getAttribute(name) : null; };
const tspans = (id) => { const e = el(id); return e ? [...e.querySelectorAll('tspan')] : []; };
const stageEl = () => sr().querySelector('.stage');
/** открыть форму существующей надписи (на старой сборке — двойным щелчком) */
const open = (sh) => (c._decorOpenText ? c._decorOpenText(sh) : c._decorShapeDbl(sh));
const toScreen = (x, y) => {
const r = stageEl().getBoundingClientRect();
const v = c._viewOr(c._baseVb());
return { clientX: r.left + ((x - v.x) / v.w) * r.width,
clientY: r.top + ((y - v.y) / v.h) * r.height };
};
const ev = (type, target, x, y, extra = {}) => {
const { clientX, clientY } = toScreen(x, y);
target.dispatchEvent(new PointerEvent(type, { bubbles: true, composed: true,
cancelable: true, pointerId: 7, clientX, clientY, button: 0, isPrimary: true, ...extra }));
};
sr().querySelectorAll('.modetab')[2].click(); await c.updateComplete;
c._curSpaceCfg.decor = [];
c._decorTool = 'select'; c._decorSel = null; await c.updateComplete;
// ================= 1. размера шрифта в диалоге больше нет ================
c._decorTool = 'text';
c._decorTextDialog = { x: 0.3, y: 0.3, text: '', color: '#223344' };
await c.updateComplete;
out.noSizePicker = !sr().querySelector('input[name="dtsize"]');
out.textareaNotInput = !!sr().querySelector('hp-dialog textarea.dtarea');
c._decorTextDialog = null; await c.updateComplete;
// старый `size` продолжает рисоваться ровно как раньше: 'l' = 30px
c._curSpaceCfg.decor = [
{ id: 'dtl', kind: 'text', x: 0.25, y: 0.25, text: 'Старый', size: 'l', color: '#223344' },
{ id: 'dts', kind: 'text', x: 0.25, y: 0.4, text: 'Мелкий', size: 's', color: '#223344' },
{ id: 'dtm', kind: 'text', x: 0.25, y: 0.55, text: 'Обычный', color: '#223344' },
];
c._cfgEpoch++; c.requestUpdate(); await c.updateComplete;
out.legacyLarge = +attr('dtl', 'font-size') === 30;
out.legacySmall = +attr('dts', 'font-size') === 14;
out.noSizeIsMedium = +attr('dtm', 'font-size') === 20;
// ================= 2. многострочный текст, по центру =====================
c._decorTool = 'text';
c._decorTextDialog = { x: 0.5, y: 0.5, text: ' Гараж\nпод ключ ', color: '#223344' };
c._decorSaveText(); await c.updateComplete;
const multi = c._decorList.find((x) => x.text && x.text.includes('\n'));
out.newlineStored = !!multi && multi.text === 'Гараж\nпод ключ'; // края обрезаны, середина цела
const tsp = tspans(multi.id);
out.twoLinesRendered = tsp.length === 2
&& tsp[0].textContent === 'Гараж' && tsp[1].textContent === 'под ключ';
out.linesCentredHorizontally = tsp.length === 2 && !!el(multi.id)
&& getComputedStyle(el(multi.id)).textAnchor === 'middle'
&& tsp[0].getAttribute('x') === tsp[1].getAttribute('x');
// блок центрирован и по вертикали: якорь ровно между строками
const ay = multi.y * 1000;
const y0 = tsp[0] ? +tsp[0].getAttribute('y') : NaN;
const y1 = tsp[1] ? +tsp[1].getAttribute('y') : NaN;
out.blockCentredVertically = Math.abs((y0 + y1) / 2 - ay) < 0.01 && y1 > y0;
// длинная строка НЕ переносится сама (docs/LIVE-TEXT.md: никаких автопереносов)
open(multi); await c.updateComplete;
c._decorTextDialog = { ...c._decorTextDialog, text: 'x'.repeat(120) };
c._decorSaveText(); await c.updateComplete;
out.noAutoWrap = tspans(multi.id).length === 1;
open(c._decorList.find((x) => x.id === multi.id)); await c.updateComplete;
c._decorTextDialog = { ...c._decorTextDialog, text: 'Гараж\nпод ключ' };
c._decorSaveText(); await c.updateComplete;
// ================= 3. ручки масштаба и поворота ==========================
c._decorTool = 'select'; c._decorSel = null; await c.updateComplete;
out.noFrameWithoutSelection = !sr().querySelector('.dtframe');
c._decorSel = 'dtm'; c.requestUpdate();
await c.updateComplete; await sleep(60); await c.updateComplete;
const frame = () => sr().querySelector('.dtframe');
out.frameOnSelection = !!frame();
out.fourCornersAndRotate = !!frame()
&& frame().querySelectorAll('.dthandle').length === 5
&& !!frame().querySelector('.dtrot');
// --- визуал в 4 раза меньше, хит-зона прежняя (docs/LIVE-TEXT.md §3) ---
out.fiveVisibleKnobs = frame()?.querySelectorAll('.dtknob').length === 5;
const rOf = (sel) => { const e = frame()?.querySelector(sel); return e ? +e.getAttribute('r') : NaN; };
const hitR = rOf('.dthandle.dtrot');
const knobR = rOf('.dtknob');
const vw = c._viewOr(c._baseVb());
const expectHit = Math.max(vw.w, vw.h) * 0.018;
out.hitRadiusUnchanged = Math.abs(hitR - expectHit) < 0.1; // прежние 1.8 %
out.knobIsAQuarterOfTheHit = Math.abs(knobR * 4 - hitR) / Math.max(hitR, 1e-9) < 0.05;
out.knobTakesNoPointer = getComputedStyle(frame().querySelector('.dtknob')).pointerEvents === 'none';
out.handleTakesThePointer = getComputedStyle(frame().querySelector('.dthandle')).pointerEvents !== 'none';
// …и палец, попавший МИМО бусины, но внутрь прежнего круга, всё ещё берёт ручку
const rotEl = frame().querySelector('.dtrot');
const rcx = +rotEl.getAttribute('cx'), rcy = +rotEl.getAttribute('cy');
const far = toScreen(rcx + hitR * 0.7, rcy); // > knobR, < hitR
const hitEl = sr().elementFromPoint(far.clientX, far.clientY);
out.fingerZoneStillCatchesTheHandle = !!hitEl?.classList?.contains?.('dthandle');
// линия выделена — рамки текста нет (это рамка ТЕКСТОВОГО блока)
c._curSpaceCfg.decor = [...c._decorList, { id: 'dline', kind: 'line',
x1: 0.1, y1: 0.8, x2: 0.4, y2: 0.8, color: '#000000', width: 3 }];
c._cfgEpoch++; c._decorSel = 'dline'; c.requestUpdate();
await c.updateComplete; await sleep(40); await c.updateComplete;
out.noFrameOnALine = !sr().querySelector('.dtframe');
c._decorSel = 'dtm'; c.requestUpdate();
await c.updateComplete; await sleep(60); await c.updateComplete;
// тянем угол: масштаб растёт пропорционально расстоянию от якоря
const anchor = () => {
const sh = c._decorList.find((x) => x.id === 'dtm');
return [sh.x * 1000, sh.y * 1000];
};
const [ax, ay2] = anchor();
const corner = frame()?.querySelector('.dthandle.dt-nwse') || stageEl();
ev('pointerdown', corner, ax + 40, ay2);
out.dragStarted = !!c._dtDrag && c._dtDrag.kind === 'scale';
ev('pointermove', stageEl(), ax + 80, ay2);
await c.updateComplete;
const scaled = c._decorList.find((x) => x.id === 'dtm');
out.cornerScales = Math.abs((scaled.scale || 0) - 2) < 0.01;
out.fontFollowsScale = +attr('dtm', 'font-size') === 40;
ev('pointerup', stageEl(), ax + 80, ay2);
out.dragEnded = !c._dtDrag;
// старый `size` заменяется масштабом, который он означал (одна правда)
c._decorSel = 'dtl'; c.requestUpdate();
await c.updateComplete; await sleep(60); await c.updateComplete;
const [lx, ly] = [c._decorList.find((x) => x.id === 'dtl').x * 1000,
c._decorList.find((x) => x.id === 'dtl').y * 1000];
ev('pointerdown', frame()?.querySelector('.dthandle.dt-nwse') || stageEl(), lx + 50, ly);
ev('pointermove', stageEl(), lx + 100, ly);
await c.updateComplete;
const grown = c._decorList.find((x) => x.id === 'dtl');
out.legacySizeBecomesScale = grown.size === undefined && Math.abs((grown.scale || 0) - 3) < 0.01;
ev('pointerup', stageEl(), lx + 100, ly);
// ручка поворота: шаг 5°, Shift — мимо шага
c._decorSel = 'dtm'; c.requestUpdate();
await c.updateComplete; await sleep(60); await c.updateComplete;
const [rx, ry] = anchor();
const rot = frame()?.querySelector('.dthandle.dtrot') || stageEl();
ev('pointerdown', rot, rx + 100, ry);
out.rotateStarted = c._dtDrag?.kind === 'rotate';
ev('pointermove', stageEl(), rx + 100, ry + 100); // ровно 45°
await c.updateComplete;
out.rotates = Math.abs((c._decorList.find((x) => x.id === 'dtm').angle || 0) - 45) < 1e-6;
ev('pointermove', stageEl(), rx + 100, ry + 2); // ~1.1° → прилипает к 0
await c.updateComplete;
out.snapsTo5 = (c._decorList.find((x) => x.id === 'dtm').angle || 0) === 0;
ev('pointermove', stageEl(), rx + 100, ry + 2, { shiftKey: true });
await c.updateComplete;
const free = c._decorList.find((x) => x.id === 'dtm').angle;
out.shiftGoesFree = free > 0.5 && free < 5; // тот же жест, но точный угол
ev('pointerup', stageEl(), rx + 100, ry + 2);
await c.updateComplete;
out.rotationRendered = /rotate\(/.test(attr('dtm', 'transform') || '');
// …а без Shift тот же 1.1° снова прилипает к нулю
// повернули обратно в ноль — поле исчезает, фигура снова «прямая»
ev('pointerdown', frame()?.querySelector('.dthandle.dtrot') || stageEl(), rx + 100, ry);
ev('pointermove', stageEl(), rx + 100, ry);
ev('pointerup', stageEl(), rx + 100, ry);
await c.updateComplete;
out.zeroAngleDropped = c._decorList.find((x) => x.id === 'dtm').angle === undefined;
// ================= 4. инструмент «текст» по надписи = редактировать =======
c._decorTool = 'text'; c._decorSel = null; c._decorTextDialog = null;
await c.updateComplete;
const before = c._decorList.length;
const target = c._decorList.find((x) => x.id === 'dtm');
out.labelIsLiveUnderTextTool = !!el('dtm') && getComputedStyle(el('dtm')).pointerEvents !== 'none';
ev('pointerdown', el('dtm') || stageEl(), target.x * 1000, target.y * 1000);
await c.updateComplete;
out.textToolEditsTheLabel = c._decorTextDialog?.id === 'dtm'
&& c._decorTextDialog.text === target.text;
out.textToolMakesNoSecondLabel = c._decorList.length === before;
out.textToolDoesNotSelect = c._decorSel === null;
c._decorTextDialog = null; await c.updateComplete;
// по пустому месту — новая надпись
ev('pointerdown', stageEl(), 60, 940);
await c.updateComplete;
out.textToolOnEmptyCreates = !!c._decorTextDialog && !c._decorTextDialog.id;
c._decorTextDialog = null; await c.updateComplete;
// по НЕтекстовой фигуре — тоже новая надпись: она осталась инертной
const lineEl = sr().querySelector('.decorlayer line.dshape');
out.lineStillInert = getComputedStyle(lineEl).pointerEvents === 'none';
ev('pointerdown', lineEl, 250, 800);
await c.updateComplete;
out.textToolOnAShapeCreates = !!c._decorTextDialog && !c._decorTextDialog.id
&& c._decorSel === null;
c._decorTextDialog = null;
return out;
});
checkAll(res);
await finish(browser, res);
+126
View File
@@ -0,0 +1,126 @@
// DEV-B58-01 — the infinite canvas reached the FRAME and the drawing, but not
// the drag handlers. A device marker was clamped into the CONTENT FRAME (with
// a 0.8 % inset), a room label into the space's stored view_box — which, for
// every plan the card has ever written, is the old unit square. The owner and a
// user hit the same wall: "названия комнат и устройства не перетаскиваются
// дальше старых границ холста".
//
// Every interactive coordinate now has ONE bound: the ±5000 the backend
// enforces (docs/CANVAS.md §9). This smoke starts from an ORDINARY plan — one
// that lives inside 0..1, so the old clamps really were in the way — and drags
// each kind of thing out to 2.5 / 2.2 and beyond.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 900, height: 820 }, 1);
const out = await page.evaluate(async () => {
const o = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const NORM_W = 1000;
const near = (a, b, eps = 1 / 240) => Math.abs(a - b) <= eps + 1e-9;
const onGrid = (v) => Math.abs(v * 240 - Math.round(v * 240)) < 1e-9;
// the demo plan is an ordinary one: rooms inside the old unit square
const b0 = c._baseVb();
o.startsFromAnOrdinaryPlan = b0[0] + b0[2] < 1.3 * NORM_W && b0[1] + b0[3] < 1.3 * NORM_W;
/** Turn a wanted RENDER-unit delta into the clientX/clientY a handler wants. */
const mk = (dxu, dyu, sx = 400, sy = 400, extra = {}) => {
const rect = sr().querySelector('.stage').getBoundingClientRect();
const v = c._viewOr(c._baseVb());
return new PointerEvent('pointermove', {
clientX: sx + (dxu / v.w) * rect.width,
clientY: sy + (dyu / v.h) * rect.height,
bubbles: true, ...extra,
});
};
// ---- (a) a DEVICE MARKER goes to 2.5 / 2.2 ---------------------------
c._setMode('devices'); await c.updateComplete;
const dev = c._devices.find((d) => !d.virtual);
const p0 = c._pos(dev);
c._drag = { id: dev.id, sx: 400, sy: 400, ox: p0.x, oy: p0.y, moved: false };
c._pointerMove(mk(2.5 * NORM_W - p0.x, 2.2 * NORM_W - p0.y), dev);
c._pointerUp(new PointerEvent('pointerup'), dev);
await c.updateComplete;
const saved = c._layout[dev.id];
o.markerLeftTheOldCanvas = near(saved.x, 2.5) && near(saved.y, 2.2);
o.markerIsOnTheGrid = onGrid(saved.x) && onGrid(saved.y);
// ---- (b) it SURVIVES a rebuild ---------------------------------------
c._drag = null;
c._modelCache = null; c._frame = null; c._defPos = c._defaultPositions();
c.requestUpdate(); await c.updateComplete;
const back = c._pos(c._devices.find((d) => d.id === dev.id));
o.markerSurvivesRebuild = near(back.x / NORM_W, 2.5) && near(back.y / NORM_W, 2.2);
// …and the frame followed it out there instead of pretending it is elsewhere
const b1 = c._baseVb();
o.frameFollowedTheMarker = b1[0] + b1[2] > 2.4 * NORM_W;
// ---- (c) a ROOM LABEL, the worse of the two (it clamped to view_box) --
c._setMode('plan'); await c.updateComplete;
const room = c._spaceModel(c._space).rooms.find((r) => r.name);
const lp = c._labelPos(room, c._space);
c._drag = { id: 'rl_' + room.id, sx: 400, sy: 400, ox: lp.x, oy: lp.y, moved: false };
c._labelMove(mk(2.7 * NORM_W - lp.x, 2.4 * NORM_W - lp.y), room, c._space);
c._labelUp(room);
await c.updateComplete;
const sl = c._layout['rl_' + room.id];
o.labelLeftTheOldCanvas = near(sl.x, 2.7) && near(sl.y, 2.4);
o.labelIsOnTheGrid = onGrid(sl.x) && onGrid(sl.y);
c._drag = null;
c.requestUpdate(); await c.updateComplete;
const lp2 = c._labelPos(room, c._space);
o.labelSurvivesRebuild = near(lp2.x / NORM_W, 2.7) && near(lp2.y / NORM_W, 2.4);
// ---- (d) DECOR born far out, and moved further still -------------------
c._setMode('decor'); await c.updateComplete;
c._decorTool = 'rect';
c._decorDraft = { kind: 'rect', a: [2.6 * NORM_W, 2.6 * NORM_W], b: [2.9 * NORM_W, 2.8 * NORM_W], pid: 1 };
c._decorCommitDraft(); await c.updateComplete;
const sh = c._decorList[c._decorList.length - 1];
o.decorBornFarOut = near(sh.x, 2.6) && near(sh.y, 2.6);
// the decor mover works in ABSOLUTE svg points, so the gesture is built the
// way _decorShapeDown builds it: grab at the shape, release 0.6/0.5 away
const atUnits = (X, Y) => {
const rect = sr().querySelector('.stage').getBoundingClientRect();
const v = c._viewOr(c._baseVb());
return new PointerEvent('pointermove', {
clientX: rect.left + ((X - v.x) / v.w) * rect.width,
clientY: rect.top + ((Y - v.y) / v.h) * rect.height, bubbles: true,
});
};
const grab = c._svgPoint(atUnits(2.6 * NORM_W, 2.6 * NORM_W));
c._decorMove = { id: sh.id, start: grab,
orig: JSON.parse(JSON.stringify(sh)), pid: 2, moved: false };
c._decorMoveUpdate(atUnits(3.2 * NORM_W, 3.1 * NORM_W));
await c.updateComplete;
const sh2 = c._decorList.find((x) => x.id === sh.id);
o.decorMovedFurtherOut = near(sh2.x, 3.2) && near(sh2.y, 3.1);
c._decorMove = null;
// ---- (e) the ONE bound that is left is the backend's ±5000 ------------
c._setMode('devices'); await c.updateComplete;
const dev2 = c._devices.find((d) => d.id !== dev.id && !d.virtual);
const q0 = c._pos(dev2);
c._drag = { id: dev2.id, sx: 400, sy: 400, ox: q0.x, oy: q0.y, moved: false };
c._pointerMove(mk(9e9, 9e9), dev2);
c._pointerUp(new PointerEvent('pointerup'), dev2);
c._drag = null;
o.garbageStillClamped = c._layout[dev2.id].x === 5000 && c._layout[dev2.id].y === 5000;
// ---- (f) Shift cannot land between the nodes ---------------------------
const dev3 = c._devices.find((d) => d.id !== dev.id && d.id !== dev2.id && !d.virtual);
const r0 = c._pos(dev3);
const halfStep = NORM_W / 240 / 2;
c._drag = { id: dev3.id, sx: 400, sy: 400, ox: r0.x, oy: r0.y, moved: false };
c._pointerMove(mk(2.5 * NORM_W + halfStep - r0.x, 2.5 * NORM_W - r0.y, 400, 400, { shiftKey: true }), dev3);
c._drag = null;
o.shiftStillSnapsToTheNodes = onGrid(c._layout[dev3.id].x);
c._setMode('view');
return o;
});
await finish(browser, checkAll(out));
+98
View File
@@ -0,0 +1,98 @@
/**
* Draw toolbar wall thickness (docs/WALL-THICKNESS.md §6): default 15 cm on
* commit, live preview, shared neighbour cm kept.
*/
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const upd = async () => { c._cfgEpoch++; c.requestUpdate(); await c.updateComplete; };
const sp = () => c._serverCfg.spaces.find((s) => s.id === c._space);
c._setMode('plan');
c._tool = 'draw';
await upd();
out.fieldDefault = c._drawWallFieldValue === '15' || c._drawWallFieldValue === '15.0';
out.fieldInBar = !!sr().querySelector('.editbar .drawwall input');
const wallsButton = sr().querySelector('.editbar button.on');
out.drawButtonNamedRoomOutline = ['Room outline', 'Контур комнаты']
.includes((wallsButton?.textContent || '').trim());
out.fieldImmediatelyAfterWalls = wallsButton?.nextElementSibling?.matches('label.drawwall') === true;
// draw a small room away from existing rooms (garden/demo f1 may have rooms)
// use empty space if available, else clear rooms on a copy path
const space = sp();
const savedRooms = JSON.parse(JSON.stringify(space.rooms || []));
const savedWalls = space.walls ? JSON.parse(JSON.stringify(space.walls)) : null;
space.rooms = [];
delete space.walls;
await upd();
const g = c._gridPitch;
// 4×4 cell square
const pts = [
[100, 100], [100 + 8 * g, 100], [100 + 8 * g, 100 + 8 * g], [100, 100 + 8 * g],
];
c._path = [];
c._tool = 'draw';
for (const p of pts) {
c._path = [...c._path, p];
c._cursorPt = p;
await upd();
}
// close
c._path = [...c._path, pts[0]];
await upd();
out.previewWhileOpen = !!sr().querySelector('.drawwall-preview')
|| c._drawWallCm === 15;
// force closed state if helper exists
if (!c._contourClosed) {
// last point equals first — _contourClosed should be true
}
out.contourClosed = !!c._contourClosed;
c._nameSel = 'ThickDraw';
c._areaSel = '';
c._commitRoom();
await upd();
out.roomSaved = (space.rooms || []).some((r) => r.name === 'ThickDraw');
out.wallsApplied = (space.walls || []).length >= 4
&& (space.walls || []).every((w) => w.cm === 15);
out.bodyDrawn = sr().querySelectorAll('[data-hp="wall"]').length >= 1;
// second room sharing the right edge — neighbour keeps 15, new edges get 20
c._drawWallField = '20';
const r1 = space.rooms.find((r) => r.name === 'ThickDraw');
const poly = r1.poly.map((p) => [p[0] * 1000, p[1] * (c._spaceH || 1000)]);
// shared vertical: right edge of r1
const x1 = Math.max(...poly.map((p) => p[0]));
const y0 = Math.min(...poly.map((p) => p[1]));
const y1 = Math.max(...poly.map((p) => p[1]));
const w = 6 * g;
c._path = [
[x1, y0], [x1 + w, y0], [x1 + w, y1], [x1, y1], [x1, y0],
];
await upd();
c._nameSel = 'Neighbour';
c._commitRoom();
await upd();
const sharedStill15 = (space.walls || []).some((w) => w.cm === 15);
const has20 = (space.walls || []).some((w) => w.cm === 20);
out.sharedKept = sharedStill15 && has20;
// restore demo space
space.rooms = savedRooms;
if (savedWalls) space.walls = savedWalls;
else delete space.walls;
c._path = [];
await upd();
return out;
});
checkAll(res);
await finish(browser, res);
+39
View File
@@ -3,6 +3,45 @@ const { page, browser } = await launch();
const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
const sr = c.shadowRoot || c.renderRoot;
const frame = () => new Promise((resolve) => requestAnimationFrame(resolve));
const deepActive = () => {
let active = document.activeElement;
while (active?.shadowRoot?.activeElement) active = active.shadowRoot.activeElement;
return active;
};
// A11Y-02: the shared native fallback carries the same modal/focus contract
// that ha-dialog supplies in Home Assistant.
const opener = document.createElement('button');
opener.textContent = 'open settings';
sr.append(opener);
opener.focus();
c._openSettingsDialog(); await c.updateComplete; await frame();
const hp = sr.querySelector('hp-dialog');
const native = hp?.shadowRoot?.querySelector('dialog');
const titleId = native?.getAttribute('aria-labelledby');
out.sharedShell = !!hp && !!native?.open;
out.modalSemantics = native?.getAttribute('role') === 'dialog'
&& native?.getAttribute('aria-modal') === 'true'
&& !!titleId && !!hp.shadowRoot.getElementById(titleId)?.textContent.trim();
let active = deepActive();
out.initialFocus = !!active && (hp.contains(active) || hp.shadowRoot.contains(active));
const focusable = [...hp.querySelectorAll('button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled])')];
const last = focusable.at(-1);
last?.focus();
last?.dispatchEvent(new KeyboardEvent('keydown', {
key: 'Tab', bubbles: true, composed: true, cancelable: true,
}));
active = deepActive();
out.focusTrap = active?.classList?.contains('close') === true;
active?.dispatchEvent(new KeyboardEvent('keydown', {
key: 'Escape', bubbles: true, composed: true, cancelable: true,
}));
await c.updateComplete; await frame();
out.restoreFocus = !c._settingsDialog && deepActive() === opener;
opener.remove();
const esc = async () => { window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); await c.updateComplete; };
// общие настройки
c._openSettingsDialog(); await c.updateComplete;
+1 -1
View File
@@ -50,7 +50,7 @@ const res = await page.evaluate(async () => {
// 5) превью есть и в диалоге пространства
c._openSpaceDialog('edit', spId); await c.updateComplete;
out.spacePreview = !!sr().querySelector('.cardpreview');
out.spaceSlider = [...sr().querySelectorAll('.dialog label')].some((l) => l.textContent === c._t('space.card_font'));
out.spaceSlider = [...sr().querySelectorAll('hp-dialog label')].some((l) => l.textContent === c._t('space.card_font'));
c._spaceDialog = null; await c.updateComplete;
return out;
});
+269
View File
@@ -0,0 +1,269 @@
// Библиотека мебели в декор-слое (docs/FURNITURE.md, задача владельца №2):
// 1) в панели декора есть инструмент «Мебель», он открывает ПАЛИТРУ с
// обязательными группами (мебель / техника / сантехника) и превью;
// 2) выбранный символ ставится кликом по плану в РЕАЛЬНОМ размере — через
// cell_cm пространства, и сразу оказывается выделенным в «выбрать»;
// 3) поля ширины/глубины в палитре меняют размер ДО размещения;
// 4) магнит к стене: предмет прижимается к ближайшей стене и доворачивается
// по ней; Shift не отключает обязательную привязку (docs/CANVAS.md §9.4);
// 5) у выделенного предмета та же рамка, что у текстового блока, — угловые
// ручки (НЕзависимые ширина и глубина) и ручка поворота с шагом 5°;
// 6) во время растягивания угла показываются живые плашки размеров;
// 7) фигура пишется в конфиг и переживает пересборку, хуки card-mod на месте.
// ПАДАЕТ на сборке до этой задачи: инструмента «Мебель» нет, палитры нет,
// kind:'furniture' не рисуется, магнита и плашек не существует. Смок при этом
// null-safe — он показывает СПИСОК провалов, а не одно исключение.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const stageEl = () => sr().querySelector('.stage');
const el = (id) => (id ? sr().querySelector(`.decorlayer [data-id="${id}"]`) : null);
const attr = (id, n) => { const e = el(id); return e ? e.getAttribute(n) : null; };
const toScreen = (x, y) => {
const r = stageEl().getBoundingClientRect();
const v = c._viewOr(c._baseVb());
return { clientX: r.left + ((x - v.x) / v.w) * r.width,
clientY: r.top + ((y - v.y) / v.h) * r.height };
};
const ev = (type, target, x, y, extra = {}) => {
// null-safe: на сборке ДО этой задачи фигуры нет вовсе, а значит нет и
// её координат — жест просто не происходит, и смок печатает список
// провалов вместо одного исключения
if (!target || !Number.isFinite(x) || !Number.isFinite(y)) return;
const { clientX, clientY } = toScreen(x, y);
target.dispatchEvent(new PointerEvent(type, { bubbles: true, composed: true,
cancelable: true, pointerId: 11, clientX, clientY, button: 0, isPrimary: true, ...extra }));
};
const PITCH = 1000 / 240; // GRID_PITCH — одна клетка сетки
const CELL = 5; // cell_cm демо-пространства (по умолчанию)
const cmToUnits = (cm) => (cm / CELL) * PITCH;
const near = (a, b, tol) => Number.isFinite(a) && Number.isFinite(b) && Math.abs(a - b) <= tol;
const pe = (id) => { const e = el(id); return e ? getComputedStyle(e).pointerEvents : null; };
sr().querySelectorAll('.modetab')[2].click(); await c.updateComplete;
c._curSpaceCfg.decor = [];
c._decorTool = 'select'; c._decorSel = null; c._furnPalette = null;
c._cfgEpoch++; c.requestUpdate(); await c.updateComplete;
out.cellCmIsFive = c._cellCm === CELL;
// ================= 1. инструмент и палитра ==============================
const toolBtn = [...sr().querySelectorAll('.decorbar .dtool')]
.find((b) => /мебел|furnitur/i.test(b.textContent || ''));
out.furnitureToolInTheBar = !!toolBtn;
out.noPaletteBeforeTheTool = !sr().querySelector('.furnpalette');
if (toolBtn) toolBtn.click();
await c.updateComplete;
out.toolIsSelected = c._decorTool === 'furniture';
const pal = () => sr().querySelector('.furnpalette');
out.paletteOpens = !!pal();
const groups = [...(pal()?.querySelectorAll('.furngroup') || [])]
.map((g) => g.getAttribute('data-group'));
out.groupsArePresent = ['furniture', 'appliance', 'sanitary'].every((g) => groups.includes(g));
out.everyGroupHasSymbols = groups.length > 0 && [...pal().querySelectorAll('.furnrow')]
.every((r) => r.querySelectorAll('.furnitem').length > 0);
const item = (sym) => pal()?.querySelector(`.furnitem[data-symbol="${sym}"]`);
const pick = (sym) => { const b = item(sym); if (b) b.click(); };
out.sofaIsInThePalette = !!item('sofa');
out.toiletIsInThePalette = !!item('toilet');
out.washerIsInThePalette = !!item('washer');
const prevD = item('sofa')?.querySelector('svg.furnprev path')?.getAttribute('d') || '';
out.previewIsDrawn = prevD.length > 10;
out.namesAreLocalised = /\S/.test(item('sofa')?.querySelector('span')?.textContent || '');
// ================= 2. размер по умолчанию и поля =========================
pick('sofa'); await c.updateComplete;
out.pickArmsTheSymbol = c._furnPalette?.symbol === 'sofa';
out.defaultSizeIsTheSymbols = c._furnPalette?.w === 220 && c._furnPalette?.h === 90;
const wIn = () => pal()?.querySelector('input.furnw');
const hIn = () => pal()?.querySelector('input.furnh');
out.sizeFieldsAppear = !!wIn() && !!hIn();
out.fieldsShowMetres = +(wIn()?.value ?? NaN) === 2.2 && +(hIn()?.value ?? NaN) === 0.9;
out.selectedItemIsMarked = !!item('sofa')?.classList.contains('on');
// ================= 3. размещение в реальном размере ======================
// середина комнаты r1 (40..550 × 140..580) — до стен дальше порога магнита
ev('pointerdown', stageEl(), 300, 300);
await c.updateComplete;
const sofa = c._decorList.find((s) => s.kind === 'furniture');
const sofaId = sofa?.id ?? null;
const sofaNow = () => c._decorList.find((s) => s.id === sofaId) || {};
out.placedOnClick = sofa?.symbol === 'sofa';
out.realWidthThroughCellCm = near((sofa?.w ?? NaN) * 1000, cmToUnits(220), 1e-6);
out.realDepthThroughCellCm = near((sofa?.h ?? NaN) * 1000, cmToUnits(90), 1e-6);
out.centredOnTheClick = !!sofa
&& near((sofa.x + sofa.w / 2) * 1000, 300, 1e-6)
&& near((sofa.y + sofa.h / 2) * 1000, 300, 1e-6);
out.noAngleWhenStraight = !!sofa && sofa.angle === undefined;
out.selectedRightAway = !!sofaId && c._decorSel === sofaId;
out.toolWentBackToSelect = c._decorTool === 'select';
out.paletteDisarmed = c._furnPalette === null;
// …и она НАРИСОВАНА, со всеми хуками card-mod (docs/STYLING-HOOKS.md §3)
out.rendered = el(sofaId)?.tagName?.toLowerCase() === 'path';
out.hookHp = attr(sofaId, 'data-hp') === 'decor';
out.hookKind = attr(sofaId, 'data-kind') === 'furniture';
out.hookSymbol = attr(sofaId, 'data-symbol') === 'sofa';
out.strokedNotFilled = attr(sofaId, 'fill') === 'none'
&& attr(sofaId, 'stroke') === c._decorStyle.color;
out.pathIsAtRealSize = /^M0 0H183\.333V75H0Z/.test(attr(sofaId, 'd') || '');
// ================= 4. свой размер до размещения ==========================
c._decorTool = 'furniture'; await c.updateComplete;
pick('toilet'); await c.updateComplete;
if (wIn()) {
wIn().value = '0.5';
wIn().dispatchEvent(new Event('input', { bubbles: true }));
}
await c.updateComplete;
out.fieldEditsTheArmedSize = Math.abs((c._furnPalette?.w ?? 0) - 50) < 1e-6;
ev('pointerdown', stageEl(), 300, 400);
await c.updateComplete;
const wc = c._decorList.find((s) => s.symbol === 'toilet');
out.typedSizeIsUsed = near((wc?.w ?? NaN) * 1000, cmToUnits(50), 1e-6)
&& near((wc?.h ?? NaN) * 1000, cmToUnits(70), 1e-6);
// ================= 5. магнит к стене =====================================
c._decorTool = 'furniture'; await c.updateComplete;
pick('bed_double'); await c.updateComplete;
ev('pointerdown', stageEl(), 300, 150); // 10 единиц от стены y=140
await c.updateComplete;
const bed = c._decorList.find((s) => s.symbol === 'bed_double');
const depth = cmToUnits(200);
out.wallMagnetPullsItFlat = !!bed
&& near((bed.y + bed.h / 2) * 1000, 140 + depth / 2, 1e-6);
out.wallMagnetKeepsTheAngle = !!bed && (bed.angle || 0) === 0; // стена горизонтальна
out.wallMagnetQuantisesAlongTheWall = !!bed
&& Math.abs((bed.x + bed.w / 2) * 1000 - 300) <= PITCH + 1e-6;
// …Shift больше не отключает магнит
c._decorTool = 'furniture'; await c.updateComplete;
pick('bed_single'); await c.updateComplete;
ev('pointerdown', stageEl(), 301.7, 151.3, { shiftKey: true });
await c.updateComplete;
const free = c._decorList.find((s) => s.symbol === 'bed_single');
out.shiftKeepsTheMagnet = !!free
&& !near((free.y + free.h / 2) * 1000, 151.3, 1e-6)
&& free.angle === undefined;
// …магнит работает и при ПЕРЕТАСКИВАНИИ: тянем диван к левой стене (x=40)
c._decorTool = 'select'; c._decorSel = sofaId; c.requestUpdate();
await c.updateComplete; await sleep(40); await c.updateComplete;
ev('pointerdown', el(sofaId), 300, 300);
out.dragStarted = !!sofaId && c._decorMove?.id === sofaId;
ev('pointermove', stageEl(), 55, 300); // 15 единиц от стены x=40
await c.updateComplete;
const pulled = sofaNow();
out.dragMagnetTurnsItToTheWall = Math.abs(Math.abs(pulled.angle || 0) - 90) < 1e-6;
out.dragMagnetPressesTheBack =
near((pulled.x + pulled.w / 2) * 1000, 40 + cmToUnits(90) / 2, 1e-6);
ev('pointermove', stageEl(), 300, 300, { shiftKey: true });
await c.updateComplete;
const shiftDragGridIndex = (sofaNow().x * 1000) / PITCH;
out.shiftDragRemainsGridBound = near(shiftDragGridIndex, Math.round(shiftDragGridIndex), 1e-6);
ev('pointerup', stageEl(), 300, 300, { shiftKey: true });
await c.updateComplete;
// ================= 6. рамка, углы, плашки, поворот =======================
c._decorSel = sofaId; c.requestUpdate();
await c.updateComplete; await sleep(60); await c.updateComplete;
const frame = () => sr().querySelector('.dtframe');
out.frameOnSelection = !!frame();
out.sameFrameAsTheTextBlock = !!frame()
&& frame().querySelectorAll('.dthandle').length === 5
&& frame().querySelectorAll('.dtknob').length === 5
&& !!frame().querySelector('.dtrot');
const rOf = (sel) => { const e = frame()?.querySelector(sel); return e ? +e.getAttribute('r') : NaN; };
out.handleSizeIsTheTaskOneSize = Math.abs(rOf('.dtknob') * 4 - rOf('.dthandle.dtrot'))
/ Math.max(rOf('.dthandle.dtrot'), 1e-9) < 0.05;
out.frameBoxIsTheShapeBox = !!c._dtBox
&& near(c._dtBox.w, sofaNow().w * 1000, 1e-6)
&& near(c._dtBox.h, sofaNow().h * 1000, 1e-6);
// угол: НЕзависимые ширина и глубина + живые плашки
const before = { w: sofaNow().w, h: sofaNow().h };
const b = c._dtBox || { x: 0, y: 0, w: 0, h: 0 };
const handles = [...(frame()?.querySelectorAll('.dthandle') || [])];
const se = handles[3] || stageEl(); // SE — четвёртый угол
ev('pointerdown', se, b.x + b.w, b.y + b.h);
out.cornerDragStarted = c._dtDrag?.kind === 'scale' && !!c._dtDrag?.orig;
ev('pointermove', stageEl(), b.x + b.w + 100, b.y + b.h + 20, { shiftKey: true });
await c.updateComplete;
const grown = sofaNow();
out.cornerGrowsTheWidth = grown.w > before.w && near(grown.w * 1000, b.w + 100, 1e-6);
out.cornerGrowsTheDepthINDEPENDENTLY = grown.h > before.h
&& near(((grown.h * 1000) / PITCH) % 1, 0, 1e-6);
out.oppositeCornerStaysPut = near(grown.x * 1000, b.x, 1e-6) && near(grown.y * 1000, b.y, 1e-6);
const plates = [...sr().querySelectorAll('.measurelabel.furnmeasure')];
out.twoLivePlates = plates.length === 2;
out.platesShowRealLengths = plates.length === 2
&& plates.every((p) => /\d/.test(p.textContent))
&& plates.some((p) => /m|м|′/.test(p.textContent));
// …и без Shift размер прилипает к сетке
ev('pointermove', stageEl(), b.x + b.w + 101.7, b.y + b.h + 21.3);
await c.updateComplete;
const snapped = sofaNow();
out.sizeSnapsToTheGrid = near(((snapped.w * 1000) / PITCH) % 1, 0, 1e-6)
&& near(((snapped.h * 1000) / PITCH) % 1, 0, 1e-6);
ev('pointerup', stageEl(), b.x + b.w + 101.7, b.y + b.h + 21.3);
await c.updateComplete;
out.cornerDragEnded = !c._dtDrag;
out.noPlatesAfterTheDrag = !sr().querySelector('.measurelabel.furnmeasure');
// поворот: шаг 5°, Shift мимо шага — та же механика, что у текста
await sleep(40); await c.updateComplete;
const cx = (sofaNow().x + sofaNow().w / 2) * 1000;
const cy = (sofaNow().y + sofaNow().h / 2) * 1000;
const piv = c._dtPivot ? c._dtPivot(sofaNow()) : [NaN, NaN];
out.rotatesAboutTheCENTRE = near(piv[0], cx, 1e-6) && near(piv[1], cy, 1e-6);
ev('pointerdown', frame()?.querySelector('.dthandle.dtrot'), cx + 100, cy);
out.rotateStarted = c._dtDrag?.kind === 'rotate';
ev('pointermove', stageEl(), cx + 100, cy + 100); // ровно 45°
await c.updateComplete;
out.rotates45 = near(sofaNow().angle || 0, 45, 1e-6);
ev('pointermove', stageEl(), cx + 100, cy + 2); // ~1.1° → липнет к 0
await c.updateComplete;
out.snapsTo5 = (sofaNow().angle || 0) === 0;
ev('pointermove', stageEl(), cx + 100, cy + 2, { shiftKey: true });
await c.updateComplete;
const fine = sofaNow().angle;
out.shiftGoesPastTheStep = fine > 0.5 && fine < 5;
ev('pointerup', stageEl(), cx + 100, cy + 2, { shiftKey: true });
await c.updateComplete;
out.rotationIsRendered = /rotate\(/.test(attr(sofaId, 'transform') || '');
// ================= 7. конфиг и пересборка ================================
const stored = JSON.parse(JSON.stringify(sofaNow()));
out.storedShapeIsComplete = stored.kind === 'furniture' && typeof stored.symbol === 'string'
&& ['x', 'y', 'w', 'h'].every((k) => Number.isFinite(stored[k]));
c.hass = { ...c.hass }; // полная пересборка
c._cfgEpoch++; c.requestUpdate();
await c.updateComplete; await sleep(40); await c.updateComplete;
const after = sofaNow();
out.survivesARebuild = after.symbol === stored.symbol
&& near(after.w, stored.w, 1e-12) && near(after.h, stored.h, 1e-12)
&& near(after.x, stored.x, 1e-12) && near(after.y, stored.y, 1e-12);
out.stillRenderedAfterRebuild = !!el(sofaId);
// ================= 8. инертность и удаление ==============================
c._decorTool = 'furniture'; c._furnPalette = null; await c.updateComplete;
out.inertUnderTheStamp = pe(sofaId) === 'none';
const n0 = c._decorList.length;
ev('pointerdown', stageEl(), 320, 320); // символ НЕ выбран
await c.updateComplete;
out.noSymbolNoStamp = c._decorList.length === n0;
c._decorTool = 'erase'; await c.updateComplete;
ev('pointerdown', el(sofaId), 300, 300);
await c.updateComplete;
out.eraseRemovesIt = !!sofaId && !c._decorList.some((s) => s.id === sofaId);
// а в режиме просмотра мебель видна и не кликается
c._setMode('view'); await c.updateComplete; await sleep(30);
const left = c._decorList.find((s) => s.kind === 'furniture');
out.visibleInView = !!left && !!el(left.id);
out.inertInView = !!left && pe(left.id) === 'none';
return out;
});
checkAll(res);
await finish(browser, res);
+70 -6
View File
@@ -1,4 +1,4 @@
import { launch, checkAll, finish } from './serve.mjs';
import { launch, check, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const res = await page.evaluate(async () => {
const out = {};
@@ -25,14 +25,78 @@ const res = await page.evaluate(async () => {
out.dialogOpens = !!c._spaceDialog;
out.tabNotSwitched = c._space === cur;
c._spaceDialog = null; await c.updateComplete;
// кнопка "+" по-прежнему только в Плане
out.noAddInView = !sr().querySelector('.tab.tabadd');
c._setMode('plan'); await c.updateComplete;
out.addInPlan = !!sr().querySelector('.tab.tabadd');
// «+» — навигационное действие, а не инструмент редактора плана
// (владелец 2026-08-04): кнопка есть во ВСЕХ режимах и кликабельна.
const addBtn = () => sr().querySelector('.tab.tabadd');
const hittable = () => {
const b = addBtn();
if (!b) return 'нет кнопки';
const r = b.getBoundingClientRect();
if (r.width < 8 || r.height < 8) return `кнопка ${r.width}x${r.height}px`;
const hit = sr().elementFromPoint(r.left + r.width / 2, r.top + r.height / 2);
return hit && hit.closest && hit.closest('.tab.tabadd') === b ? true : 'перекрыта';
};
out.addInView = hittable();
for (const m of ['plan', 'devices', 'decor']) {
c._setMode(m); await c.updateComplete;
out['addIn_' + m] = hittable();
}
c._setMode('view'); await c.updateComplete;
// …и открывает именно диалог СОЗДАНИЯ
const b0 = addBtn();
if (b0) { b0.click(); await c.updateComplete; }
out.addOpensCreate = !!c._spaceDialog && c._spaceDialog.mode === 'create';
c._spaceDialog = null; await c.updateComplete;
return out;
});
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
checkAll(res, {
"alignDelta": 0,
});
await finish(browser, res);
// киоск — витрина: редактирование недоступно, кнопки «+» нет ВООБЩЕ
// (display:none у .hdr не считается: скрытый в DOM узел всё ещё кликабелен
// программно и всё ещё ловится автотестами как «есть»)
const kio = await page.evaluate(async () => {
const out = {};
const c = document.createElement('houseplan-card');
c.setConfig({ type: 'custom:houseplan-card', kiosk: true, cycle: 0 });
c.hass = window.__mkHass();
c.style.cssText = 'position:fixed;left:0;top:0;width:900px;height:700px;z-index:99';
document.body.appendChild(c);
await new Promise((r) => setTimeout(r, 350));
c.hass = { ...c.hass }; await c.updateComplete;
const sr = c.shadowRoot || c.renderRoot;
out.kioskNoAddButton = !sr.querySelector('.tab.tabadd');
c.remove();
return out;
});
for (const [k, v] of Object.entries(kio)) check(k, v);
// мобильный вид: ряд вкладок переносится, а не выезжает за карточку
await page.setViewportSize({ width: 390, height: 760 });
const narrow = await page.evaluate(async () => {
const out = {};
const c = window.__card;
await new Promise((r) => setTimeout(r, 250));
c.requestUpdate(); await c.updateComplete;
const sr = c.shadowRoot || c.renderRoot;
const head = sr.querySelector('.head');
const btn = sr.querySelector('.tab.tabadd');
out.narrowAddPresent = !!btn;
out.narrowNoHOverflow = head.scrollWidth <= head.clientWidth + 1
? true : `scrollW=${head.scrollWidth} > clientW=${head.clientWidth}`;
const cr = sr.querySelector('ha-card').getBoundingClientRect();
const br = btn ? btn.getBoundingClientRect() : null;
out.narrowAddInsideCard = !br ? 'нет кнопки'
: br.right <= cr.right + 1 && br.left >= cr.left - 1 ? true
: `btn ${Math.round(br.left)}..${Math.round(br.right)} vs card ${Math.round(cr.left)}..${Math.round(cr.right)}`;
out.narrowAddHittable = !br ? 'нет кнопки' : (() => {
const hit = sr.elementFromPoint(br.left + br.width / 2, br.top + br.height / 2);
return hit && hit.closest && hit.closest('.tab.tabadd') === btn ? true : 'перекрыта';
})();
return out;
});
for (const [k, v] of Object.entries(narrow)) check(k, v);
await finish(browser, { ...res, ...kio, ...narrow });
+24 -4
View File
@@ -1,4 +1,5 @@
import { launch, checkAll, finish } from './serve.mjs';
import { launch, check, checkAll, finish } from './serve.mjs';
import { readFileSync } from 'node:fs';
const { page, browser } = await launch();
const res = await page.evaluate(async () => {
const out = {};
@@ -7,7 +8,11 @@ const res = await page.evaluate(async () => {
// 1) диалог общих настроек: инвентарь строк и групп (обновлён фичей «Солнце»)
c._openSettingsDialog(); await c.updateComplete;
out.rows = sr().querySelectorAll('.gsrow').length;
out.groups = [...sr().querySelectorAll('.dialog .dispsection')].map((l) => l.textContent.trim());
out.groups = [...sr().querySelectorAll('hp-dialog .dispsection')].map((l) => l.textContent.trim());
// 1b) блок About: версия + две внешние ссылки (target=_blank rel=noopener)
out.aboutVersion = sr().querySelector('hp-dialog .aboutver')?.textContent.trim() ?? null;
out.aboutLinks = [...sr().querySelectorAll('hp-dialog a.aboutlink')].map((a) => ({
href: a.getAttribute('href'), target: a.getAttribute('target'), rel: a.getAttribute('rel') }));
// 2) сменить цвет light_on и сохранить
c._setFillColor('light_on', { c: '#ff00ff', a: 0.5 });
await c._saveSettingsDialog(); await c.updateComplete;
@@ -26,10 +31,25 @@ const res = await page.evaluate(async () => {
out.lqiAfter = sr().querySelectorAll('.dev .lqi').length;
return out;
});
// CARD_VERSION из собранного бандла (тот же текст уходит в console-баннер).
// terser либо инлайнит строку (v1.56.0), либо оставляет переменную (v${xx}) —
// во втором случае доразрешаем её по присваиванию xx="1.56.0".
// Версия — SemVer, у пре-релиза есть суффикс (1.58.0-beta.1), он тоже часть строки.
const bundle = readFileSync(new URL('./srv/assets/houseplan-card.js', import.meta.url), 'utf8');
const SEMVER = '\\d+\\.\\d+\\.\\d+(?:-[0-9A-Za-z.-]+)?';
const m = bundle.match(new RegExp(`HOUSEPLAN-CARD %c v(?:(${SEMVER})|\\$\\{(\\w+)\\})`));
const BUNDLE_VERSION = m?.[1] ?? (m?.[2] && bundle.match(new RegExp(`[^\\w$]${m[2]}="(${SEMVER})"`))?.[1]);
check('bundleVersionFound', typeof BUNDLE_VERSION === 'string' && BUNDLE_VERSION.length > 0);
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
checkAll(res, {
"rows": 14, // 10 цветов + радиус свечения + режим фона + цвет фона + погода (docs/SUN.md)
"groups": ["Fill: lights", "Fill: temperature", "Fill: zigbee signal", "Light-sources fill", "Stage background", "Sun"],
"rows": 16, // 11 цветов (включая wall_fill) + радиус свечения + фон + погода (docs/SUN.md)
// + «Оптимизировать планы» (docs/CANVAS.md §9)
"groups": ["Fill: lights", "Fill: temperature", "Fill: zigbee signal", "Light-sources fill", "Walls", "Stage background", "Sun", "Plan maintenance", "About"],
"aboutVersion": `Houseplan Card v${BUNDLE_VERSION}`, // та же константа, что в баннере
"aboutLinks": [
{ "href": "https://github.com/Matysh/houseplan-card", "target": "_blank", "rel": "noopener" },
{ "href": "https://t.me/ha_houseplan", "target": "_blank", "rel": "noopener" },
],
"saved": {"c": "#ff00ff", "a": 0.5},
"lqiBefore": 7,
"lqiAfter": 0,
+257
View File
@@ -0,0 +1,257 @@
// «Всё строго по сетке» (docs/CANVAS.md §9). Two halves:
// 1. every element placed or dragged by hand lands on a node — devices, room
// labels, decor, room vertices, resize handles; an opening lands ON ITS
// WALL at a whole number of steps along it; positional off-grid placement
// is not supported, including while Shift is held;
// 2. the explicit «Оптимизировать планы» action moves a deliberately
// detuned plan onto the grid through one atomic config+layout commit,
// tells the truth about how much it moves, and is idempotent.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 900, height: 820 }, 1);
const out = await page.evaluate(async () => {
const o = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const NORM_W = 1000, GRID_N = 240;
const PITCH = NORM_W / GRID_N;
const onGridN = (v) => Math.abs(v * GRID_N - Math.round(v * GRID_N)) < 1e-9;
const onGridR = (v) => Math.abs(v / PITCH - Math.round(v / PITCH)) < 1e-7;
/** A click/pointer event at a given RENDER-unit point of the current view. */
const at = (X, Y, type = 'pointermove', extra = {}) => {
const rect = sr().querySelector('.stage').getBoundingClientRect();
const v = c._viewOr(c._baseVb());
return new PointerEvent(type, {
clientX: rect.left + ((X - v.x) / v.w) * rect.width,
clientY: rect.top + ((Y - v.y) / v.h) * rect.height, bubbles: true, ...extra,
});
};
// an off-grid target: a third of a step past a node, in both axes
const OFF = PITCH / 3;
// ---- 1a) the snap itself, the one gate every gesture goes through -----
const s1 = c._snap([100 + OFF, 200 + OFF]);
o.snapRoundsToTheNode = onGridR(s1[0]) && onGridR(s1[1]);
const s2 = c._snap([100 + OFF, 200 + OFF], { shiftKey: true });
o.shiftCannotSuspendTheSnap = onGridR(s2[0]) && onGridR(s2[1]);
o.snapAlsoClampsTheGarbage = c._snap([1e12, -1e12])[0] === 5000 * NORM_W;
// the step is NORM_W/GRID_N and nothing else — it did NOT change with the
// infinite canvas, so no plan's nodes ever moved out from under it
o.pitchIsTheCanvasFreeConstant = Math.abs(c._gridPitch - PITCH) < 1e-12;
// ---- 1b) a DEVICE dropped with the mouse lands on a node --------------
c._setMode('devices'); await c.updateComplete;
const dev = c._devices.find((d) => !d.virtual);
const p0 = c._pos(dev);
const mkDelta = (dxu, dyu, extra = {}) => {
const rect = sr().querySelector('.stage').getBoundingClientRect();
const v = c._viewOr(c._baseVb());
return new PointerEvent('pointermove', {
clientX: 400 + (dxu / v.w) * rect.width, clientY: 400 + (dyu / v.h) * rect.height,
bubbles: true, ...extra,
});
};
c._drag = { id: dev.id, sx: 400, sy: 400, ox: p0.x, oy: p0.y, moved: false };
c._pointerMove(mkDelta(137.7 - p0.x + OFF, 251.3 - p0.y + OFF), dev);
c._drag = null;
o.deviceLandsOnANode = onGridN(c._layout[dev.id].x) && onGridN(c._layout[dev.id].y);
// ---- 1c) a ROOM LABEL too ---------------------------------------------
c._setMode('plan'); await c.updateComplete;
const room = c._spaceModel(c._space).rooms.find((r) => r.name);
const lp = c._labelPos(room, c._space);
c._drag = { id: 'rl_' + room.id, sx: 400, sy: 400, ox: lp.x, oy: lp.y, moved: false };
c._labelMove(mkDelta(311.4 - lp.x + OFF, 402.9 - lp.y + OFF), room, c._space);
c._drag = null;
o.roomLabelLandsOnANode = onGridN(c._layout['rl_' + room.id].x)
&& onGridN(c._layout['rl_' + room.id].y);
// …and one that has never been dragged is on a node as well
const other = c._spaceModel(c._space).rooms.find((r) => r.name && r.id !== room.id);
const op = c._labelPos(other, c._space);
o.untouchedLabelIsOnANode = onGridR(op.x) && onGridR(op.y);
// ---- 1d) DECOR: draft, text anchor, and a move ------------------------
c._setMode('decor'); await c.updateComplete;
c._decorTool = 'rect';
// dispatched for real, so the handler sees a target (it looks for .dshape)
sr().querySelector('.stage').dispatchEvent(at(300 + OFF, 300 + OFF, 'pointerdown', { pointerId: 21 }));
o.decorDraftStartsOnANode = onGridR(c._decorDraft.a[0]) && onGridR(c._decorDraft.a[1]);
c._decorDraft = { ...c._decorDraft, b: c._snap([420 + OFF, 380 + OFF]) };
c._decorCommitDraft(); await c.updateComplete;
const dsh = c._decorList[c._decorList.length - 1];
o.decorShapeIsOnNodes = onGridN(dsh.x) && onGridN(dsh.y)
&& onGridN(dsh.x + dsh.w) && onGridN(dsh.y + dsh.h);
c._decorTool = 'text';
sr().querySelector('.stage').dispatchEvent(at(500 + OFF, 500 + OFF, 'pointerdown', { pointerId: 22 }));
o.decorTextAnchorIsOnANode = onGridN(c._decorTextDialog.x) && onGridN(c._decorTextDialog.y);
c._decorTextDialog = null;
// a shape that is ALREADY off the grid is put on it by one drag: the mover
// snaps the resulting anchor, not the delta (which used to preserve the drift)
const sp = c._curSpaceCfg;
sp.decor = [...c._decorList, { id: 'dcOff', kind: 'rect',
x: 0.3013, y: 0.4017, w: 0.1, h: 0.05, color: '#889', width: 2 }];
c.requestUpdate(); await c.updateComplete;
const off = c._decorList.find((x) => x.id === 'dcOff');
c._decorMove = { id: 'dcOff', start: c._svgPoint(at(301.3, 401.7)),
orig: JSON.parse(JSON.stringify(off)), pid: 9, moved: false };
c._decorMoveUpdate(at(351.3, 451.7));
await c.updateComplete;
const off2 = c._decorList.find((x) => x.id === 'dcOff');
o.oneDragPutsADriftedShapeOnTheGrid = onGridN(off2.x) && onGridN(off2.y);
c._decorMove = null;
sp.decor = sp.decor.filter((x) => x.id !== 'dcOff' && x.id !== dsh.id);
// ---- 1e) an OPENING is WALL-bound: on its wall, whole steps along it ---
c._setMode('plan'); await c.updateComplete;
c._tool = 'opening';
const spm = c._spaceModel(c._space);
const wall = (() => {
for (const r of spm.rooms) {
const poly = r.poly || null;
if (!poly) continue;
for (let i = 0; i < poly.length; i++) {
const a = poly[i], b = poly[(i + 1) % poly.length];
if (Math.hypot(b[0] - a[0], b[1] - a[1]) > PITCH * 20) return [a, b];
}
}
return null;
})();
o.foundAWallToTestWith = !!wall;
if (wall) {
const [a, b] = wall;
const mid = [(a[0] + b[0]) / 2 + 0.37 * PITCH, (a[1] + b[1]) / 2 + 0.4];
c._openingClick(mid);
const d = c._openingDialog;
o.openingPlaced = !!d;
if (d) {
// exactly on the wall segment…
const t = ((d.x - a[0]) * (b[0] - a[0]) + (d.y - a[1]) * (b[1] - a[1]))
/ ((b[0] - a[0]) ** 2 + (b[1] - a[1]) ** 2);
const px = a[0] + t * (b[0] - a[0]), py = a[1] + t * (b[1] - a[1]);
o.openingSitsOnItsWall = Math.hypot(d.x - px, d.y - py) < 1e-6;
// …and at a whole number of steps along it (or at the wall's own centre,
// which is what the magnet is for and what the ruler shows)
const len = Math.hypot(b[0] - a[0], b[1] - a[1]);
const along = t * len;
o.openingIsQuantisedAlongTheWall =
Math.abs(along / PITCH - Math.round(along / PITCH)) < 1e-6
|| Math.abs(along - len / 2) < 1e-6;
}
c._openingDialog = null;
}
// ---- 1f) a room VERTEX drawn by hand ----------------------------------
c._tool = 'draw'; c._path = [];
c._markupClick(at(220 + OFF, 220 + OFF, 'click'));
o.roomVertexOnANode = c._path.length === 1 && onGridR(c._path[0][0]) && onGridR(c._path[0][1]);
c._path = []; c._tool = 'draw';
// ---- 2) «Оптимизировать планы» -----------------------------------------
c._setMode('view'); await c.updateComplete;
// a deliberately detuned fixture: a third of a step off, everywhere
const D = 1 / GRID_N / 3;
const FIX = { spaces: [{
id: 'g1', title: 'Grid test', view_box: [0, 0, 1, 1],
rooms: [
{ id: 'r1', name: 'A', area: 'living_room',
poly: [[0.2 + D, 0.2], [0.5, 0.2 - D], [0.5 + D, 0.5], [0.2, 0.5]] },
{ id: 'r2', name: 'B', area: 'kitchen', x: 0.5, y: 0.2, w: 0.3 + D, h: 0.3 },
],
openings: [{ id: 'o1', type: 'window', x: 0.3 + D, y: 0.2 + D / 2, angle: 0, length: 0.06 }],
decor: [{ id: 'd1', kind: 'line', x1: 0.1 + D, y1: 0.7, x2: 0.9, y2: 0.7 - D }],
}], markers: [], settings: {} };
const FIXLAY = { d_light1: { s: 'g1', x: 0.3 + D, y: 0.3 }, rl_r1: { s: 'g1', x: 0.25 + D, y: 0.25 } };
c._serverCfg = JSON.parse(JSON.stringify(FIX));
c._layout = JSON.parse(JSON.stringify(FIXLAY));
c._modelCache = null; c._frame = null; c._space = 'g1';
c.requestUpdate(); await c.updateComplete;
c._openAlignDialog();
await c.updateComplete;
const dlg = c._alignDialog;
o.alignDialogOpens = !!dlg && !!sr().querySelector('hp-dialog .alignmsg');
o.alignCountsWhatMoves = !!dlg && dlg.report.moved > 0 && dlg.report.moved <= dlg.report.total;
// half a step of 5 cm cells ≈ 2.5 cm; a third of a step is well under that
o.alignPromisesASmallShift = !!dlg && dlg.cm > 0 && dlg.cm < 3;
o.alignExplainsSafeUndo = (sr().querySelector('hp-dialog .rhint')?.textContent || '').length > 20;
// capture what the network is told, then run it
const sent = [];
const base = c.hass.callWS;
c.hass = { ...c.hass, callWS: async (m) => { sent.push(m.type); return base(m); } };
await c._runAlignToGrid();
await c.updateComplete;
o.alignDialogClosed = c._alignDialog === null;
o.alignUsedAtomicConfigLayoutWrite = sent.filter((t) => t === 'houseplan/plan/optimize').length === 1;
const g = c._serverCfg.spaces[0];
o.alignedRoomPoly = g.rooms[0].poly.every((p) => onGridN(p[0]) && onGridN(p[1]));
o.alignedRoomRect = onGridN(g.rooms[1].x) && onGridN(g.rooms[1].x + g.rooms[1].w);
o.alignedDecor = onGridN(g.decor[0].x1) && onGridN(g.decor[0].y1)
&& onGridN(g.decor[0].x2) && onGridN(g.decor[0].y2);
o.alignedLayout = onGridN(c._layout.d_light1.x) && onGridN(c._layout.rl_r1.x);
// the opening stayed ON the top wall of r1 rather than being rounded off it
const w0 = g.rooms[0].poly[0], w1 = g.rooms[0].poly[1];
const tt = ((g.openings[0].x - w0[0]) * (w1[0] - w0[0]) + (g.openings[0].y - w0[1]) * (w1[1] - w0[1]))
/ ((w1[0] - w0[0]) ** 2 + (w1[1] - w0[1]) ** 2);
o.alignedOpeningStaysOnItsWall = Math.hypot(
g.openings[0].x - (w0[0] + tt * (w1[0] - w0[0])),
g.openings[0].y - (w0[1] + tt * (w1[1] - w0[1]))) < 1e-9;
// ---- 2b) …and the second run has nothing to do ------------------------
c._openAlignDialog();
await c.updateComplete;
o.secondRunMovesNothing = c._alignDialog.report.moved === 0;
o.secondRunOffersNoButton = !sr().querySelector('hp-dialog .btn.on');
c._alignDialog = null;
await c.updateComplete;
// ---- 2c) the promise is an UPPER BOUND, in EACH space's own scale -----
// AUD-158B1-01: one normalised maximum converted through the first space's
// cell size promised 2.5 cm for a vertex that moved 50 cm on the floor above.
const MULTI = { spaces: [
{ id: 'm1', title: 'Ground', cell_cm: 5, view_box: [0, 0, 1, 1],
rooms: [{ id: 'ma', name: 'A', area: 'living_room',
poly: [[0.1, 0.1], [0.3, 0.1], [0.3, 0.3], [0.1, 0.3]] }] },
{ id: 'm2', title: 'Attic', cell_cm: 100, view_box: [0, 0, 1, 1],
// the ONLY off-grid vertex on the plan, and it is on the 100 cm floor
rooms: [{ id: 'mb', name: 'B', area: 'kitchen',
poly: [[0.2 + 1 / GRID_N / 2, 0.2], [0.4, 0.2], [0.4, 0.4], [0.2, 0.4]] }] },
], markers: [], settings: {} };
c._serverCfg = JSON.parse(JSON.stringify(MULTI));
c._layout = {};
c._modelCache = null; c._frame = null; c._space = 'm1';
c.requestUpdate(); await c.updateComplete;
c._openAlignDialog(); await c.updateComplete;
const md = c._alignDialog;
o.alignPromiseUsesTheOwnScaleOfEachSpace = !!md && md.cm >= 50 && md.cm < 51;
o.alignPromiseNamesTheSpaceItBelongsTo = !!md && md.where === 'Attic'
&& (sr().querySelector('hp-dialog .body')?.textContent || '').includes('Attic');
c._alignDialog = null; await c.updateComplete;
// ---- 2d) an angle-only opening fix is offerable (AUD-158B1-02) --------
// its centre is already on the wall; only the stored angle is wrong, and the
// batch used to report "nothing to move" while returning a different plan
const TURN = { spaces: [{ id: 'a1', title: 'Flat', cell_cm: 5, view_box: [0, 0, 1, 1],
rooms: [{ id: 'r1', name: 'A', area: 'living_room',
poly: [[0.2, 0.2], [0.5, 0.2], [0.5, 0.5], [0.2, 0.5]] }],
openings: [{ id: 'oa', type: 'window', x: 0.35, y: 0.2, angle: 90, length: 0.1 }],
}], markers: [], settings: {} };
c._serverCfg = JSON.parse(JSON.stringify(TURN));
c._layout = {};
c._modelCache = null; c._frame = null; c._space = 'a1';
c.requestUpdate(); await c.updateComplete;
c._openAlignDialog(); await c.updateComplete;
const ad = c._alignDialog;
o.angleOnlyOpeningCounts = !!ad && ad.report.moved === 1 && ad.report.rotated === 1;
o.angleOnlyOpeningOffersTheButton = !!sr().querySelector('hp-dialog .btn.on');
await c._runAlignToGrid();
await c.updateComplete;
o.angleOnlyOpeningIsActuallyFixed = c._serverCfg.spaces[0].openings[0].angle === 0;
return o;
});
await finish(browser, checkAll(out));
+11 -11
View File
@@ -14,9 +14,9 @@ const res = await page.evaluate(async () => {
// --- 1) fallback branch: no ha-* registered -> classic inputs ----------
c._openMarkerDialog(c._devices[0]); await c.updateComplete;
out.fallbackCheckbox = !!sr().querySelector('.dialog .srcrow input[type=checkbox]');
out.fallbackRange = !!sr().querySelector('.dialog input[type=range]');
out.fallbackNoHaSwitch = !sr().querySelector('.dialog ha-switch');
out.fallbackCheckbox = !!sr().querySelector('hp-dialog .srcrow input[type=checkbox]');
out.fallbackRange = !!sr().querySelector('hp-dialog input[type=range]');
out.fallbackNoHaSwitch = !sr().querySelector('hp-dialog ha-switch');
c._markerDialog = null; await c.updateComplete;
// --- 2) register stubs mimicking the HA contract -----------------------
@@ -35,20 +35,20 @@ const res = await page.evaluate(async () => {
});
c._openMarkerDialog(c._devices[0]); await c.updateComplete;
const switches = [...sr().querySelectorAll('.dialog ha-switch')];
const switches = [...sr().querySelectorAll('hp-dialog ha-switch')];
out.haSwitchRendered = switches.length >= 2; // is_light + hide-from-plan at minimum
out.haNoPlainCheckbox = !sr().querySelector('.dialog .srcrow input[type=checkbox]');
const sliders = [...sr().querySelectorAll('.dialog ha-slider')];
out.haNoPlainCheckbox = !sr().querySelector('hp-dialog .srcrow input[type=checkbox]');
const sliders = [...sr().querySelectorAll('hp-dialog ha-slider')];
out.haSliderRendered = sliders.length >= 2; // size + angle
// --- 3) card -> control: state is pushed into the element --------------
c._markerDialog = { ...c._markerDialog, isLight: true, hideFromPlan: false };
await c.updateComplete;
out.downstreamChecked = [...sr().querySelectorAll('.dialog ha-switch')].some((el) => el.checked === true);
out.downstreamChecked = [...sr().querySelectorAll('hp-dialog ha-switch')].some((el) => el.checked === true);
// --- 4) control -> card: change with .checked lands in the dialog ------
const before = c._markerDialog.hideFromPlan;
const sw = [...sr().querySelectorAll('.dialog ha-switch')].find((el) => el.checked === before);
const sw = [...sr().querySelectorAll('hp-dialog ha-switch')].find((el) => el.checked === before);
sw.checked = !before;
sw.dispatchEvent(new Event('change', { bubbles: true }));
await c.updateComplete;
@@ -57,7 +57,7 @@ const res = await page.evaluate(async () => {
// --- 5) slider both ways ----------------------------------------------
const sizeBefore = c._markerDialog.size;
const sl = [...sr().querySelectorAll('.dialog ha-slider')].find((el) => Number(el.value) === Number(sizeBefore));
const sl = [...sr().querySelectorAll('hp-dialog ha-slider')].find((el) => Number(el.value) === Number(sizeBefore));
out.downstreamValue = !!sl;
sl.value = 2;
sl.dispatchEvent(new Event('input', { bubbles: true }));
@@ -73,8 +73,8 @@ const res = await page.evaluate(async () => {
// --- 6) general settings: opacity sliders take the ha branch too -------
c._openSettingsDialog(); await c.updateComplete;
out.gsHaSliders = sr().querySelectorAll('.dialog ha-slider').length >= 9;
const gsl = sr().querySelector('.dialog ha-slider');
out.gsHaSliders = sr().querySelectorAll('hp-dialog ha-slider').length >= 9;
const gsl = sr().querySelector('hp-dialog ha-slider');
gsl.value = 55;
gsl.dispatchEvent(new Event('input', { bubbles: true }));
await c.updateComplete;
+12 -7
View File
@@ -53,7 +53,7 @@ Object.assign(out, await page.evaluate(async () => {
const ghost = c._devices.find((x) => x.id === d.id);
c._openMarkerDialog(ghost); await c.updateComplete;
o.checkboxOn = c._markerDialog?.hideFromPlan === true;
o.noDeleteForAuto = !sr().querySelector('.dialog .btn.danger');
o.noDeleteForAuto = !sr().querySelector('hp-dialog .btn.danger');
// снимаем галку и сохраняем — маркер остаётся с hidden:false (анти-повтор)
c._markerDialog = { ...c._markerDialog, hideFromPlan: false };
await c._saveMarker(); await c.updateComplete;
@@ -68,7 +68,7 @@ Object.assign(out, await page.evaluate(async () => {
await c._saveMarker(); await c.updateComplete;
const virt = c._devices.find((x) => x.virtual);
c._openMarkerDialog(virt); await c.updateComplete;
o.deleteForVirtual = !!sr().querySelector('.dialog .btn.danger');
o.deleteForVirtual = !!sr().querySelector('hp-dialog .btn.danger');
c._markerDialog = null; c._setMode('view');
return o;
}));
@@ -100,7 +100,7 @@ Object.assign(out, await page.evaluate(async () => {
return o;
}));
// --- HP-1511-02: ripple-призрак с базовой иконкой, без пульса -------------
// --- Legacy ripple → icon+activity; ghost keeps icon and has no effect -----
Object.assign(out, await page.evaluate(async () => {
const o = {};
const c = window.__card;
@@ -114,11 +114,10 @@ Object.assign(out, await page.evaluate(async () => {
c._maybeRebuildDevices();
c._setMode('devices'); c._showHidden = true;
c.requestUpdate(); await c.updateComplete;
const g = [...sr().querySelectorAll('.dev.ghost')].find((x) => x.querySelector('.ripple') || !x.classList.contains('noicon'));
const ghosts = [...sr().querySelectorAll('.dev.ghost')];
o.rippleGhostHasIcon = ghosts.length > 0 && ghosts.every((x) => !!x.querySelector('ha-icon'));
o.rippleGhostNoNoicon = ghosts.every((x) => !x.classList.contains('noicon'));
o.rippleGhostNoRipple = ghosts.every((x) => !x.querySelector('.ripple'));
o.rippleGhostNoRipple = ghosts.every((x) => !x.querySelector('.activity-ring'));
c._serverCfg.markers = c._serverCfg.markers.filter((m) => m.id !== lamp.id);
c._cfgEpoch++; c._regSignature = ''; c._maybeRebuildDevices();
c._setMode('view'); c._showHidden = false;
@@ -175,8 +174,14 @@ Object.assign(out, await page.evaluate(async () => {
await card.updateComplete;
const st = [...card.renderRoot.querySelectorAll('[style*="left"]')]
.find((el) => /%$/.test(el.style.left || '') && el.style.top);
// статичная карточка рендерит от полного квадрата 0..1000
const stVb = st && [parseFloat(st.style.left) * 10, parseFloat(st.style.top) * 10];
// docs/CANVAS.md §4: статичная карточка теперь кадрирует СОДЕРЖИМОЕ, как и
// полная, а не квадрат 0..1000 — координаты берём из её же viewBox
const svb = (card.renderRoot.querySelector('svg')?.getAttribute('viewBox') || '0 0 1000 1000')
.trim().split(/\s+/).map(Number);
const stVb = st && [
svb[0] + (parseFloat(st.style.left) / 100) * svb[2],
svb[1] + (parseFloat(st.style.top) / 100) * svb[3],
];
o.autoGridParity = !!fullVb && !!stVb
&& Math.abs(fullVb[0] - stVb[0]) < 6 && Math.abs(fullVb[1] - stVb[1]) < 6;
if (!o.autoGridParity) console.log('full', fullVb, 'static', stVb);
+202
View File
@@ -0,0 +1,202 @@
/**
* The two "draw less" space options and the virtual-wall rule (owner
* 2026-08-05), plus the handle beads that came with them.
*
* • «Скрыть декоративный слой» hides decor everywhere EXCEPT its own editor;
* • «Скрыть проёмы» hides doors/windows everywhere EXCEPT the plan editor,
* and does not touch what an opening MEANS (the light still gets through);
* • a space that does not draw room borders draws no dashed virtual walls
* in View; every editor still does, because the complete centreline span
* must remain visible while editing;
* • both switches survive a round trip through the dialog;
* • every corner handle paints a bead a quarter of its hit radius, and the
* hit radius itself is unchanged.
*/
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const upd = async () => { c._cfgEpoch++; c.requestUpdate(); await c.updateComplete; };
const sp = () => c._serverCfg.spaces.find((s) => s.id === c._space);
const set = async (patch) => {
const s = sp();
s.settings = { ...(s.settings || {}), ...patch };
await upd();
};
const mode = async (m) => { c._setMode(m); await upd(); };
const n = (sel) => sr().querySelectorAll(sel).length;
// a shape and an opening to look for, on the space we are on
const s0 = sp();
s0.decor = [{ id: 'hl1', kind: 'rect', x: 0.3, y: 0.3, w: 0.1, h: 0.1, color: '#ff0000', width: 3 }];
s0.openings = [{ id: 'hl2', type: 'door', x: 0.3, y: 0.2, angle: 0, length: 0.09 }];
await mode('view');
await set({ hide_decor: undefined, hide_openings: undefined, show_borders: true });
// ---- 1) the baseline: both layers are drawn ---------------------------
out.decorDrawnByDefault = n('.decorlayer .dshape') > 0;
out.openingDrawnByDefault = n('[data-hp="opening"]') > 0;
// ---- 2) hide the decor layer ------------------------------------------
await set({ hide_decor: true });
out.decorHiddenInView = n('.decorlayer .dshape') === 0;
await mode('devices');
out.decorHiddenInDevices = n('.decorlayer .dshape') === 0;
// …but its own editor still shows it, or it could not be edited
await mode('decor');
out.decorVisibleInItsEditor = n('.decorlayer .dshape') > 0;
await mode('view');
// the shape itself was never touched
out.decorShapeKept = (sp().decor || []).length === 1;
await set({ hide_decor: undefined });
out.decorBackAfterOff = n('.decorlayer .dshape') > 0;
// ---- 3) hide the openings ---------------------------------------------
const opCount = () => n('[data-hp="opening"]');
const before = opCount();
await set({ hide_openings: true });
out.openingsHiddenInView = opCount() === 0 && before > 0;
await mode('plan');
c._tool = 'opening';
await upd();
out.openingsVisibleInPlanEditor = opCount() > 0;
await mode('view');
// the opening is still an opening: it stayed in the config untouched
out.openingKept = (sp().openings || []).length === 1;
await set({ hide_openings: undefined });
out.openingsBackAfterOff = opCount() === before;
// ---- 4) virtual walls follow the borders switch ------------------------
// an open boundary is a link between two rooms that share a wall; the demo's
// first two rooms do (smoke_render_parity relies on the same fact)
const rooms = sp().rooms || [];
rooms[0].open_to = [rooms[1].id];
await upd();
out.openWallsFound = c._openPairs().length > 0;
await set({ show_borders: true });
out.openWallsDrawnWithBorders = n('.openwalls .openwall') > 0;
await set({ show_borders: false });
out.openWallsGoneWithoutBorders = n('.openwalls .openwall') === 0;
// Every editor still draws them. The dash deliberately reaches the physical
// centreline there, even if show_borders is off; only View hides it.
await mode('plan');
c._tool = 'openwall';
await upd();
out.openWallsVisibleInPlanEditor = n('.openwalls .openwall') > 0;
await mode('devices');
out.openWallsVisibleInDevicesEditor = n('.openwalls .openwall') > 0;
await mode('decor');
out.openWallsVisibleInDecorEditor = n('.openwalls .openwall') > 0;
await mode('view');
await set({ show_borders: true });
// ---- 5) the dialog carries both switches round trip --------------------
c._openSpaceDialog('edit', c._space);
await upd();
const rows = [...sr().querySelectorAll('hp-dialog .srcrow')].map((r) => r.textContent.trim());
out.dialogOffersHideDecor = rows.some((t) => /декорат|decorative/i.test(t));
out.dialogOffersHideOpenings = rows.some((t) => /проём|проем|doors and windows/i.test(t));
out.dialogReadsOff = c._spaceDialog.hideDecor === false && c._spaceDialog.hideOpenings === false;
c._spaceDialog = { ...c._spaceDialog, hideDecor: true, hideOpenings: true };
await c._saveSpaceDialog();
await upd();
out.savedBoth = sp().settings.hide_decor === true && sp().settings.hide_openings === true;
c._openSpaceDialog('edit', c._space);
await upd();
out.dialogReadsOn = c._spaceDialog.hideDecor === true && c._spaceDialog.hideOpenings === true;
c._spaceDialog = { ...c._spaceDialog, hideDecor: false, hideOpenings: false };
await c._saveSpaceDialog();
await upd();
// switched off = nothing is stored (undefined, which JSON drops on the way
// to the server), not a stored `false` — the same rule bg_color follows
out.clearedNotStored = sp().settings.hide_decor === undefined
&& sp().settings.hide_openings === undefined
&& !('hide_decor' in JSON.parse(JSON.stringify(sp().settings)));
c._spaceDialog = null;
await upd();
return out;
});
checkAll(res);
// ---- 6) the beads: a quarter of the hit radius, and the hit is unchanged --
const rad = await page.evaluate(async () => {
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
c._setMode('decor'); c._decorTool = 'backdrop';
c._cfgEpoch++; c.requestUpdate(); await c.updateComplete;
const hit = sr().querySelector('.bdframe .bdhandle');
const knob = sr().querySelector('.bdframe .bdknob');
const view = c._viewOr(c._baseVb());
const o = {
hasBoth: !!hit && !!knob,
hitR: hit ? Number(hit.getAttribute('r')) : 0,
knobR: knob ? Number(knob.getAttribute('r')) : 0,
expectHit: Math.max(view.w, view.h) * 0.02,
knobInert: knob ? getComputedStyle(knob).pointerEvents === 'none' : false,
// the bead sits exactly on the handle
sameCentre: hit && knob
&& hit.getAttribute('cx') === knob.getAttribute('cx')
&& hit.getAttribute('cy') === knob.getAttribute('cy'),
};
// …and the resize tool's corner beads follow the same rule
c._setMode('plan'); c._tool = 'resize';
c._rszSel = (c._curSpaceCfg.rooms || [])[0]?.id || null;
c._cfgEpoch++; c.requestUpdate(); await c.updateComplete;
const rh = sr().querySelector('.rszcorner');
const rk = sr().querySelector('.rszknob');
o.rszBoth = !!rh && !!rk;
o.rszQuarter = rh && rk
? Math.abs(Number(rk.getAttribute('r')) * 4 - Number(rh.getAttribute('r'))) < 0.05
: false;
c._setMode('view');
return o;
});
checkAll({
handleAndBead: rad.hasBoth,
hitRadiusUnchanged: Math.abs(rad.hitR - rad.expectHit) < 0.15,
beadIsAQuarter: Math.abs(rad.knobR * 4 - rad.hitR) < 0.05,
beadTakesNoPointer: rad.knobInert,
beadOnTheHandle: rad.sameCentre,
resizeCornerHasBead: rad.rszBoth,
resizeBeadIsAQuarter: rad.rszQuarter,
});
// ---- 7) the backdrop editor opens ON the picture tool ---------------------
const armed = await page.evaluate(async () => {
const c = window.__card;
c._setMode('view'); await c.updateComplete;
c._setMode('decor'); await c.updateComplete;
const o = { tool: c._decorTool, movable: c._bdMovable, hasPicture: !!c._curSpaceCfg.plan_url };
// and the picture really moves under it: press inside the body, drag, release
const r = c._bdRect;
const before = { x: c._curSpaceCfg.plan_x ?? 0, y: c._curSpaceCfg.plan_y ?? 0 };
const pt = [r.x + r.w / 2, r.y + r.h / 2];
const ev = (x, y) => ({ pointerId: 42, clientX: x, clientY: y, target: c._stageEl,
preventDefault() {}, stopPropagation() {}, shiftKey: false });
// _svgPoint reads clientX/Y — go through the stage geometry
const stage = (c.shadowRoot || c.renderRoot).querySelector('.stage');
const box = stage.getBoundingClientRect();
const vb = stage.querySelector('svg').getAttribute('viewBox').split(' ').map(Number);
const toScreen = (x, y) => [box.left + ((x - vb[0]) / vb[2]) * box.width,
box.top + ((y - vb[1]) / vb[3]) * box.height];
const [sx, sy] = toScreen(pt[0], pt[1]);
const [ex, ey] = toScreen(pt[0] + vb[2] * 0.1, pt[1]);
c._stagePointerDown(ev(sx, sy));
c._stagePointerMove(ev(ex, ey));
c._stagePointerUp(ev(ex, ey));
await c.updateComplete;
o.moved = (c._curSpaceCfg.plan_x ?? 0) !== before.x;
c._setMode('view');
return o;
});
checkAll({
backdropToolArmed: armed.tool === 'backdrop',
pictureMovableAtOnce: armed.movable,
dragMovedThePicture: armed.moved,
});
await finish(browser);
+1 -1
View File
@@ -17,7 +17,7 @@ const res = await page.evaluate(async () => {
out.hintIcon = hint?.querySelector('ha-icon')?.getAttribute('icon') === dev.icon;
// пикер (или фолбэк-инпут) получил placeholder
const picker = sr().querySelector('ha-icon-picker');
const inputs = [...sr().querySelectorAll('.dialog input')];
const inputs = [...sr().querySelectorAll('hp-dialog input')];
out.placeholderSet = picker
? picker.placeholder === dev.icon
: inputs.some((i) => i.placeholder === dev.icon);
+359
View File
@@ -0,0 +1,359 @@
// Infinite canvas (docs/CANVAS.md): a plan drawn far past the old unit square
// renders whole, devices can be placed and saved out there, the opening view
// follows the content, one far stray neither breaks the view nor hides itself,
// zoom-out stops at 3x the content and the icons keep their proportion to it.
import { writeFileSync, mkdirSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
import { launch, check, checkAll, finish } from './serve.mjs';
const SHOTS = process.env.HP_SHOTS || '';
const { page, browser } = await launch({ width: 900, height: 820 }, 1);
const out = {};
const raf = () => page.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))));
// A hand-drawn plan whose rooms live at 1.5..3.0 — entirely outside the old
// canvas. Before the infinite canvas the -25%..125% envelope threw every one
// of these vertices away and the card framed empty space.
const FAR_CFG = {
spaces: [{
id: 'f1', title: 'Ground floor', view_box: [0, 0, 1, 1], plan_url: null, plan_aspect: null,
rooms: [
{ id: 'r1', name: 'Living room', area: 'living_room',
poly: [[1.5, 1.5], [2.2, 1.5], [2.2, 2.2], [1.5, 2.2]] },
{ id: 'r2', name: 'Kitchen', area: 'kitchen',
poly: [[2.2, 1.5], [3.0, 1.5], [3.0, 2.0], [2.2, 2.0]] },
{ id: 'r3', name: 'Bedroom', area: 'bedroom',
poly: [[2.2, 2.0], [3.0, 2.0], [3.0, 2.6], [2.2, 2.6]] },
{ id: 'r4', name: 'Hallway', area: 'hallway',
poly: [[1.5, 2.2], [2.2, 2.2], [2.2, 2.6], [1.5, 2.6]] },
],
openings: [{ id: 'o1', type: 'window', x: 1.8, y: 1.5, angle: 0, length: 0.15 }],
decor: [{ id: 'd1', kind: 'line', x1: 1.4, y1: 2.7, x2: 3.1, y2: 2.7, color: '#8899aa' }],
}, {
id: 'garden', title: 'Garden', view_box: [0, 0, 1, 1],
rooms: [{ id: 'g1', name: 'Garden', area: 'garden',
poly: [[0.03, 0.178], [0.97, 0.178], [0.97, 0.822], [0.03, 0.822]] }],
}],
markers: [], settings: {},
};
const FAR_LAYOUT = {
d_light1: { s: 'f1', x: 1.7, y: 1.7 }, d_lamp: { s: 'f1', x: 2.0, y: 1.9 },
d_tv: { s: 'f1', x: 1.6, y: 2.1 }, d_temp: { s: 'f1', x: 1.9, y: 1.6 },
d_kettle: { s: 'f1', x: 2.5, y: 1.7 }, d_leak: { s: 'f1', x: 2.9, y: 1.8 },
d_bedlight: { s: 'f1', x: 2.6, y: 2.3 }, d_window: { s: 'f1', x: 2.9, y: 2.5 },
d_lock: { s: 'f1', x: 1.6, y: 2.5 }, d_motion: { s: 'f1', x: 2.0, y: 2.4 },
};
const loadFar = (cfg, layout) => page.evaluate(async ([c2, l2]) => {
const c = window.__card;
c._serverCfg = JSON.parse(JSON.stringify(c2));
c._layout = JSON.parse(JSON.stringify(l2));
c._modelCache = null;
c._frame = null;
c._showFar = false;
c._space = 'f1';
c._maybeRebuildDevices?.();
c._defPos = c._defaultPositions();
c._view = null;
c._zoom = 1;
c.requestUpdate();
await c.updateComplete;
c._resetZoom();
await c.updateComplete;
}, [cfg, layout]);
await loadFar(FAR_CFG, FAR_LAYOUT);
await raf();
// ---- (b) the plan past the old square renders WHOLE ----------------------
Object.assign(out, await page.evaluate(() => {
const c = window.__card;
const o = {};
const b = c._baseVb();
// the content is 1500..3000 x 1500..2700 render units, plus decor/openings
o.frameHoldsFarPlan = b[0] < 1450 && b[0] + b[2] > 3050 && b[1] < 1450 && b[1] + b[3] > 2750;
o.frameIsNotTheOldSquare = b[0] > 1000; // nothing is framed at the origin any more
const v = c._viewOr(b);
o.viewShowsFarPlan = v.x <= 1500 && v.x + v.w >= 3000;
// every room polygon is inside the painted viewBox
const sr = c.shadowRoot || c.renderRoot;
const polys = [...sr.querySelectorAll('polygon.room')];
o.allRoomsDrawn = polys.length === 4;
const st = sr.querySelector('.stage').getBoundingClientRect();
o.roomsOnScreen = polys.every((p) => {
const r = p.getBoundingClientRect();
return r.width > 4 && r.height > 4 && r.left >= st.left - 2 && r.right <= st.right + 2
&& r.top >= st.top - 2 && r.bottom <= st.bottom + 2;
});
// and the markers landed on the plan, not in a corner of a phantom canvas
const devs = [...sr.querySelectorAll('.devlayer .dev')];
o.markersOnPlan = devs.length >= 10 && devs.every((d) => {
const r = d.getBoundingClientRect();
return r.left >= st.left - 2 && r.right <= st.right + 2 && r.top >= st.top - 2 && r.bottom <= st.bottom + 2;
});
return o;
}));
// ---- a device can be PLACED past the old square, and it saves -------------
const ws = await page.evaluate(async () => {
const c = window.__card;
const sent = [];
const base = c.hass.callWS;
c.hass = { ...c.hass, callWS: async (m) => { sent.push(m); return base(m); } };
// drop a virtual marker at 3.4 / 2.9 — a long way outside the old canvas
const far = { s: 'f1', x: 3.4, y: 2.9 };
c._layout = { ...c._layout, d_gate: far };
c._dirtyPos.add('d_gate');
await c._persistLayout?.();
// and a room extended even further out, saved through the config writer
const cfg = JSON.parse(JSON.stringify(c._serverCfg));
cfg.spaces[0].rooms.push({ id: 'r5', name: 'Workshop',
poly: [[3.1, 2.0], [3.8, 2.0], [3.8, 2.6], [3.1, 2.6]] });
c._serverCfg = cfg; c._modelCache = null; c._frame = null;
await c._saveConfig?.();
await new Promise((r) => setTimeout(r, 700));
return { sent: JSON.parse(JSON.stringify(sent)), layout: JSON.parse(JSON.stringify(c._layout)) };
});
out.farDeviceSaved = ws.layout.d_gate.x === 3.4 && ws.layout.d_gate.y === 2.9;
const cfgMsg = ws.sent.filter((m) => m.type === 'houseplan/config/set').pop();
const layMsg = ws.sent.filter((m) => m.type === 'houseplan/layout/update').pop();
out.wsCarriedFarGeometry = !!cfgMsg
&& JSON.stringify(cfgMsg.config).includes('3.8');
out.wsCarriedFarPosition = !!layMsg && JSON.stringify(layMsg).includes('3.4');
// the payload the card sends must pass the REAL backend schema (docs/CANVAS.md
// §3: ±5000). Skipped, not failed, where voluptuous is not installed.
out.backendAcceptsFarPayload = (() => {
if (!cfgMsg) return false;
const f = '/tmp/hp_infinite_payload.json';
writeFileSync(f, JSON.stringify({ config: cfgMsg.config, layout: (layMsg || {}).layout || ws.layout }));
try {
execFileSync('python3', ['-c', 'import voluptuous'], { stdio: 'ignore' });
} catch { console.log('(voluptuous absent — backend schema check skipped)'); return true; }
const code = [
'import json,sys',
'sys.path.insert(0, "custom_components/houseplan")',
'import validation as v',
`d=json.load(open(${JSON.stringify(f)}))`,
'v.CONFIG_SCHEMA(d["config"]); v.LAYOUT_SCHEMA(d["layout"])',
'print("VALID")',
].join('\n');
try {
return execFileSync('python3', ['-c', code], { encoding: 'utf8' }).includes('VALID');
} catch (e) { console.log('backend refused the payload:', String(e.stdout || e.message).slice(-400)); return false; }
})();
// ---- an OUTLIER: the view ignores it, the hint offers it ------------------
await loadFar({
...FAR_CFG,
spaces: FAR_CFG.spaces.map((s) => (s.id === 'f1' ? { ...s } : s)),
}, { ...FAR_LAYOUT, d_motion: { s: 'f1', x: 90, y: 90 } });
await raf();
Object.assign(out, await page.evaluate(() => {
const c = window.__card;
const o = {};
o.outlierReported = c._outliers === 1;
const b = c._baseVb();
o.outlierDoesNotCommandView = b[2] < 4000 && b[0] + b[2] < 5000; // still the house, not 90 canvases
o.outlierViewStillHoldsPlan = b[0] < 1500 && b[0] + b[2] > 3000;
const sr = c.shadowRoot || c.renderRoot;
const hint = sr.querySelector('.farhint');
o.hintShown = !!hint && !!hint.querySelector('button');
o.hintIsNotAModal = !sr.querySelector('hp-dialog');
return o;
}));
// «Показать» / «Вписать всё» takes the stray in
Object.assign(out, await page.evaluate(async () => {
const c = window.__card;
const sr = c.shadowRoot || c.renderRoot;
sr.querySelector('.farhint button').click();
await c.updateComplete;
const o = {};
const v = c._viewOr(c._baseVb());
o.fitAllReachesTheStray = v.x + v.w > 90000 && v.y + v.h > 90000;
o.fitAllStillHoldsPlan = v.x <= 1500 && v.y <= 1500;
await c.updateComplete;
o.hintGoneAfterShow = !sr.querySelector('.farhint');
return o;
}));
// ---- zoom-out stops at 3x, zoom-in unchanged -----------------------------
await loadFar(FAR_CFG, FAR_LAYOUT);
await raf();
Object.assign(out, await page.evaluate(() => {
const c = window.__card;
const o = {};
c._resetZoom();
const fit = { ...c._viewOr(c._baseVb()) };
c._applyView(0.001);
o.zoomOutStopsAtThree = Math.abs(c._zoom - 1 / 3) < 1e-9
&& Math.abs(c._view.w - fit.w * 3) < fit.w * 0.02;
c._applyView(50);
o.zoomInUnchanged = c._zoom === 8;
c._resetZoom();
return o;
}));
// ---- pan has slack, and "home is that way" brings you back ---------------
Object.assign(out, await page.evaluate(async () => {
const c = window.__card;
const sr = c.shadowRoot || c.renderRoot;
const o = {};
c._resetZoom(); await c.updateComplete;
const fit = { ...c._view };
// pan most of a screen to the right: nothing pins the content over the scene
c._view = c._clampView({ ...fit, x: fit.x + fit.w * 0.9 }, fit);
o.panPastContentAllowed = c._view.x > fit.x + fit.w * 0.5;
// walk right off the plan: the frame is entirely outside the view now
c._view = { ...fit, x: fit.x + fit.w * 1.6 };
c.requestUpdate(); await c.updateComplete;
const arrow = sr.querySelector('.homearrow');
o.homeArrowAppears = !!arrow;
if (arrow) {
arrow.click();
await c.updateComplete;
o.homeArrowFitsBack = Math.abs(c._view.x - fit.x) < 2 && Math.abs(c._view.w - fit.w) < 2;
await c.updateComplete;
o.homeArrowGone = !sr.querySelector('.homearrow');
}
// and it is NOT there while the plan is on screen
c._resetZoom(); c.requestUpdate(); await c.updateComplete;
o.noHomeArrowWhenVisible = !sr.querySelector('.homearrow');
return o;
}));
// ---- icons scale WITH THE PLAN across zooms (docs/CANVAS.md §6) ----------
// The infinite canvas briefly made --icon-size a percentage of the viewport,
// so a marker kept its pixel size at every zoom. The owner looked at it and
// asked for the original contract back (2026-08-03): an icon is a percentage
// of the PLAN, it grows when you zoom in and shrinks when you zoom out, like
// everything else drawn on the plan. What the infinite canvas contributes is
// only the base unit the percentage is taken OF: `iconUnit` instead of the
// stored `vb.w`, checked right below on this very plan — which is drawn
// entirely outside the old square.
const farIcon = await page.evaluate(async () => {
const c = window.__card;
const sr = c.shadowRoot || c.renderRoot;
// the badge is --dev-size PLUS a 1px border on each side, and the border
// is not part of the scaled geometry (same correction as smoke_icon_scale)
const size = () => {
const el = sr.querySelector('.devlayer .dev');
return Math.round((el.getBoundingClientRect().width - 2) * 100) / 100;
};
const o = {};
c._resetZoom(); await c.updateComplete;
const at1 = size();
c._applyView(4); await c.updateComplete;
const at4 = size();
c._applyView(1 / 3); await c.updateComplete;
const atOut = size();
c._resetZoom(); await c.updateComplete;
o.iconGrowsWithZoomIn = at1 > 4 && Math.abs(at4 / at1 - 4) < 0.05;
o.iconShrinksWithZoomOut = Math.abs(atOut / at1 - 1 / 3) < 0.02;
if (!o.iconGrowsWithZoomIn || !o.iconShrinksWithZoomOut) console.log('icon px', at1, at4, atOut);
return { o, at1 };
});
Object.assign(out, farIcon.o);
// ---- an OLD plan (small, with a view_box) opens exactly as before ---------
Object.assign(out, await page.evaluate(async () => {
const c = window.__card;
const o = {};
const cfg = {
spaces: [{ id: 'f1', title: 'Old', view_box: [0, 0, 1, 1], plan_url: null, plan_aspect: null,
rooms: [{ id: 'r1', name: 'Living', area: 'living_room',
poly: [[0.04, 0.14], [0.55, 0.14], [0.55, 0.58], [0.04, 0.58]] },
{ id: 'r2', name: 'Kitchen', area: 'kitchen',
poly: [[0.55, 0.14], [0.96, 0.14], [0.96, 0.46], [0.55, 0.46]] }] }],
markers: [], settings: {},
};
c._serverCfg = cfg; c._layout = { d_light1: { s: 'f1', x: 0.22, y: 0.22 } };
c._modelCache = null; c._frame = null; c._showFar = false; c._view = null;
c._maybeRebuildDevices?.();
c._defPos = c._defaultPositions(); // the auto grid follows the new rooms
c.requestUpdate(); await c.updateComplete;
c._resetZoom(); await c.updateComplete;
const b = c._baseVb();
// identical to what contentBounds produced before: the content bbox
// (40..960 x 140..580) plus 5% of the longer side (920 -> 46)
o.legacyFrameUnchanged = Math.abs(b[0] - -6) < 1 && Math.abs(b[1] - 94) < 1
&& Math.abs(b[2] - 1012) < 1 && Math.abs(b[3] - 532) < 1;
o.legacyNoHint = c._outliers === 0;
// an EMPTY space falls back to the stored view_box hint (§4)
c._serverCfg = { spaces: [{ id: 'f1', title: 'E', view_box: [0.1, 0.2, 0.5, 0.4], rooms: [] }],
markers: [], settings: {} };
// a space with NOTHING in it at all — not even a marker parked in it
c._layout = {}; c._modelCache = null; c._frame = null;
c._devices = [];
c.requestUpdate(); await c.updateComplete;
const e = c._baseVb();
o.emptySpaceUsesViewBoxHint = JSON.stringify(e.map(Math.round)) === '[100,200,500,400]';
return o;
}));
// ---- and the icon does not DEGENERATE on the runaway plan ----------------
// The catch of "a percentage of the plan" on an unbounded canvas: keep the
// old fixed numerator (vb.w = NORM_W) and a plan drawn 2.3 canvases wide
// divides it by a 2.5-canvas-wide frame — the marker shrinks by that factor
// and keeps shrinking the further out the plan is drawn. `iconUnit` grows
// with the plan, so the marker on the far plan measured above is the same
// number of pixels as on this ordinary 0..1 one.
out.farIconNotDegenerate = await page.evaluate(async (farPx) => {
const c = window.__card;
const sr = c.shadowRoot || c.renderRoot;
// the small plan from the block above, with its devices back
c._serverCfg = { spaces: [{ id: 'f1', title: 'Old', view_box: [0, 0, 1, 1], plan_url: null, plan_aspect: null,
rooms: [{ id: 'r1', name: 'Living', area: 'living_room',
poly: [[0.04, 0.14], [0.55, 0.14], [0.55, 0.58], [0.04, 0.58]] },
{ id: 'r2', name: 'Kitchen', area: 'kitchen',
poly: [[0.55, 0.14], [0.96, 0.14], [0.96, 0.46], [0.55, 0.46]] }] }],
markers: [], settings: {} };
c._layout = { d_light1: { s: 'f1', x: 0.22, y: 0.22 } };
c._modelCache = null; c._frame = null; c._showFar = false; c._view = null;
c._maybeRebuildDevices?.();
c._defPos = c._defaultPositions();
c.requestUpdate(); await c.updateComplete;
c._resetZoom(); await c.updateComplete;
const el = sr.querySelector('.devlayer .dev');
const px = el.getBoundingClientRect().width - 2;
if (!(px > 4 && Math.abs(px / farPx - 1) < 0.3)) console.log('icon px near/far', px, farPx);
return px > 4 && Math.abs(px / farPx - 1) < 0.3;
}, farIcon.at1);
// ---- screenshots ---------------------------------------------------------
if (SHOTS) {
mkdirSync(SHOTS, { recursive: true });
await loadFar(FAR_CFG, FAR_LAYOUT);
await raf();
await page.screenshot({ path: `${SHOTS}/canvas_far_plan.png` });
// fit-all with a stray far away
await loadFar(FAR_CFG, { ...FAR_LAYOUT, d_motion: { s: 'f1', x: 90, y: 90 } });
await raf();
await page.screenshot({ path: `${SHOTS}/canvas_far_hint.png` });
await page.evaluate(async () => {
const c = window.__card;
(c.shadowRoot || c.renderRoot).querySelector('.farhint button').click();
await c.updateComplete;
});
await raf();
await page.screenshot({ path: `${SHOTS}/canvas_fit_all.png` });
// adaptive grid, zoomed far out in the plan editor
await loadFar(FAR_CFG, FAR_LAYOUT);
await page.evaluate(async () => {
const c = window.__card;
c._setMode('plan'); await c.updateComplete;
c._resetZoom(); c._applyView(1 / 3); await c.updateComplete;
});
await raf();
await page.screenshot({ path: `${SHOTS}/canvas_grid_far.png` });
await page.evaluate(async () => {
const c = window.__card;
c._resetZoom(); c._applyView(4); await c.updateComplete;
});
await raf();
await page.screenshot({ path: `${SHOTS}/canvas_grid_near.png` });
await page.evaluate(async () => { window.__card._setMode('view'); });
}
await finish(browser, checkAll(out));
+7 -6
View File
@@ -47,12 +47,13 @@ const res = await page.evaluate(async () => {
const evenodd = [...sr().querySelectorAll('path.room')].find((p) => p.getAttribute('fill-rule') === 'evenodd');
out.evenoddPath = !!evenodd;
out.holeInPath = evenodd ? (evenodd.getAttribute('d').match(/M /g) || []).length === 2 : null;
// 4) остров кликабелен (элемент существует и он поверх дырки)
const islandEl = [...sr().querySelectorAll('.room')].find((el) => {
const d = el.getAttribute('points') || el.getAttribute('d') || '';
return d.includes(String(cx)) && el !== evenodd;
});
out.islandRendered = !!islandEl;
// 4) остров кликабелен (элемент существует и он поверх дырки).
// Beta.5+ applies Draw thickness, so the painted contour is inset and no
// longer contains the exact centreline coordinate — look up by room id.
const islandEl = island
? sr().querySelector(`[data-hp="room"][data-id="${island.id}"]`)
: null;
out.islandRendered = !!islandEl && islandEl !== evenodd;
return out;
});
checkAll(res);
+1 -1
View File
@@ -51,7 +51,7 @@ const res = await page.evaluate(async () => {
out.persisted = JSON.parse(localStorage.getItem('houseplan_card_kiosk_v1')).icon === 1;
// 7) попап настроек экрана открывается (прямой вызов; долгое нажатие проверено таймером)
c._kioskDialog = true; await c.updateComplete;
out.dialogRenders = !!sr().querySelector('.dialog input[type="range"]');
out.dialogRenders = !!sr().querySelector('hp-dialog input[type="range"]');
c._kioskDialog = false;
// 8) карусель: тик двигает пространство, пауза после касания работает
const cs = c._space;
+138
View File
@@ -0,0 +1,138 @@
// A kiosk gesture is classified ONCE, and the release obeys that decision —
// audit DEV-1DA1-02 (P2).
//
// `_stagePointerMove` locks the gesture on the first movement past 8 px
// (`_panLock`: 'swipe' if it is horizontal enough inside the swipe zone, 'pan'
// otherwise) and the plan then follows the finger. But `_stagePointerUp` used
// to ignore that lock and ask `swipeTarget()` again, from the raw start→end
// vector alone. A CURVED gesture — a small vertical lead-in that locks 'pan',
// then a long horizontal sweep — therefore panned under the finger and still
// switched the floor on release. On a wall tablet that is the worst kind of
// surprise: you watch the plan drag along and land on another storey.
//
// The lock is now final: with `_panLock === 'pan'` the floor never changes, no
// matter what the overall vector looks like. The two straight gestures keep
// their old behaviour, and so does the motionless double tap (no movement,
// no lock — the swipe path is never even reached by it).
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const out = await page.evaluate(async () => {
const o = {};
const c0 = window.__card;
const k = document.createElement('houseplan-card');
k.setConfig({ type: 'custom:houseplan-card', kiosk: true, cycle: 0 });
k.hass = c0.hass;
document.body.appendChild(k);
k.style.cssText = 'position:fixed;left:0;top:0;width:900px;height:700px;z-index:99';
await new Promise((r) => setTimeout(r, 500));
k.hass = { ...c0.hass };
await k.updateComplete;
const sr = k.shadowRoot || k.renderRoot;
const stage = sr.querySelector('.stage');
const fire = (type, id, x, y) => stage.dispatchEvent(new PointerEvent(type, {
bubbles: true, composed: true, cancelable: true, pointerId: id, clientX: x, clientY: y,
}));
o.kioskHasSeveralSpaces = k._model.length > 1;
const home = async () => {
const vb = k._baseVb();
k._applyView(1, vb[0] + vb[2] / 2, vb[1] + vb[3] / 2);
await k.updateComplete;
};
/**
* Play one trajectory and report what the gesture decided and what it did.
* `pts` are the moves; the finger lifts at the last one.
*/
const play = async (id, from, pts) => {
await home();
const s0 = k._space;
const v0 = { ...k._viewOr(k._baseVb()) };
fire('pointerdown', id, from[0], from[1]);
let lockAfterLeadIn = null;
let moved = false;
pts.forEach((p, i) => {
fire('pointermove', id, p[0], p[1]);
if (i === 0) lockAfterLeadIn = k._panLock;
if (Math.abs(k._viewOr(k._baseVb()).x - v0.x) > 1
|| Math.abs(k._viewOr(k._baseVb()).y - v0.y) > 1) moved = true;
});
const lockBeforeRelease = k._panLock;
const last = pts[pts.length - 1];
fire('pointerup', id, last[0], last[1]);
await k.updateComplete;
return { s0, s1: k._space, lockAfterLeadIn, lockBeforeRelease, moved };
};
// ---- 1. the auditor's curved pan: vertical lead-in, horizontal ending --
// (500,300) → (502,312) locks 'pan' → curve left to (350,304) → release.
// dx = -150, dy = +4: swipeTarget() would happily call that a swipe.
const curvedPan = await play(51, [500, 300], [[502, 312], [460, 310], [400, 306], [350, 304]]);
o.curvedPanLocksPan = curvedPan.lockAfterLeadIn === 'pan';
o.curvedPanKeepsTheLock = curvedPan.lockBeforeRelease === 'pan';
o.curvedPanActuallyPans = curvedPan.moved === true;
o.curvedPanKeepsTheFloor = curvedPan.s1 === curvedPan.s0;
// the same trajectory the other way round — a swipe to the right would have
// been the previous floor, so the bug is symmetric and so is the fix
const curvedPanRight = await play(52, [400, 300], [[402, 312], [460, 308], [520, 305], [560, 304]]);
o.curvedPanRightLocksPan = curvedPanRight.lockAfterLeadIn === 'pan';
o.curvedPanRightKeepsTheFloor = curvedPanRight.s1 === curvedPanRight.s0;
// a long diagonal that ends up dominated by x, but started as a pan
const diagonalPan = await play(53, [500, 300], [[496, 316], [420, 340], [330, 350], [260, 352]]);
o.diagonalPanLocksPan = diagonalPan.lockAfterLeadIn === 'pan';
o.diagonalPanKeepsTheFloor = diagonalPan.s1 === diagonalPan.s0;
// ---- 2. a gesture locked as a SWIPE keeps its own semantics ------------
// horizontal lead-in locks 'swipe'; the plan must not slide under it, even
// when the trajectory then bends vertically and the final vector no longer
// qualifies — the floor simply stays, and nothing pans
const curvedSwipe = await play(54, [600, 300], [[540, 302], [536, 380], [545, 500]]);
o.curvedSwipeLocksSwipe = curvedSwipe.lockAfterLeadIn === 'swipe';
o.curvedSwipeNeverPans = curvedSwipe.moved === false;
o.curvedSwipeThatDiesChangesNothing = curvedSwipe.s1 === curvedSwipe.s0;
// a swipe that bends but still ends as a swipe does switch the floor
const bentSwipe = await play(55, [600, 300], [[540, 302], [470, 330], [420, 340]]);
o.bentSwipeLocksSwipe = bentSwipe.lockAfterLeadIn === 'swipe';
o.bentSwipeNeverPans = bentSwipe.moved === false;
o.bentSwipeStillSwitches = bentSwipe.s1 !== bentSwipe.s0;
// ---- 3. the straight gestures are exactly as they were -----------------
const straightSwipe = await play(56, [600, 300], [[540, 302], [480, 305], [450, 305]]);
o.straightSwipeSwitches = straightSwipe.s1 !== straightSwipe.s0;
o.straightSwipeDoesNotPan = straightSwipe.moved === false;
const straightPan = await play(57, [450, 200], [[452, 260], [454, 330]]);
o.straightPanPans = straightPan.moved === true;
o.straightPanKeepsTheFloor = straightPan.s1 === straightPan.s0;
// ---- 4. a motionless double tap still resets the zoom ------------------
// no movement means no lock at all, so nothing above can reach this path
await home();
k._applyView(2);
await k.updateComplete;
k._lastTap = Date.now() - 100;
fire('pointerdown', 58, 500, 300);
fire('pointerup', 58, 501, 300);
await k.updateComplete;
o.doubleTapStillResetsZoom = k._zoom === 1;
o.doubleTapLeavesNoLock = k._panLock === null;
// ---- 5. zoomed in there is no swipe zone, and the lock says so ---------
k._applyView(2);
await k.updateComplete;
const zoomed = await (async () => {
const s0 = k._space;
fire('pointerdown', 59, 600, 300);
fire('pointermove', 59, 540, 302);
const lock = k._panLock;
fire('pointermove', 59, 480, 305);
fire('pointerup', 59, 480, 305);
await k.updateComplete;
return { lock, same: k._space === s0 };
})();
o.zoomedHorizontalLocksPan = zoomed.lock === 'pan';
o.zoomedHorizontalKeepsTheFloor = zoomed.same;
k.remove();
return o;
});
checkAll(out);
await finish(browser, out);
+7 -11
View File
@@ -1,8 +1,6 @@
// Правило владельца (2026-07-29): у источников света подложка в glow-режиме
// всегда стандартная — индикатор включения это ПЯТНО; в остальных режимах
// горящий источник жёлтый, как греющая термоголовка. RGB лампы красит только
// пятно (и фолбэк цвета пульсации); окраска иконки/рамки убрана. Морф иконки
// остаётся везде. Розетка в glow-режиме остаётся жёлтой — она не источник.
// Правило владельца (2026-08-06): жёлтая подложка всегда означает «работает
// сейчас». В glow-режиме световое пятно ДОПОЛНЯЕТ её, но не заменяет. RGB
// лампы по-прежнему красит только пятно/эффект, не значок и не рамку.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const out = await page.evaluate(async () => {
@@ -39,21 +37,19 @@ const out = await page.evaluate(async () => {
o.litLampYellowInLightMode = !!le && le.classList.contains('on') && !le.classList.contains('rgb');
o.morphInLightMode = le?.querySelector('ha-icon')?.getAttribute('icon')?.includes('lightbulb') ?? false;
// --- режим glow: лампа ТЁМНАЯ (индикатор — пятно), розетка жёлтая -------
// --- режим glow: и лампа, и розетка жёлтые; пятно остаётся --------------
await setFill('glow');
le = devEl('d_lamp');
const ke = devEl('d_kettle');
o.litLampDarkInGlow = !!le && !le.classList.contains('on') && !le.classList.contains('rgb');
o.litLampYellowInGlow = !!le && le.classList.contains('on') && !le.classList.contains('rgb');
o.glowSpotPresent = !!sr().querySelector('.stage svg radialGradient, .stage svg [id*=glow]');
o.socketStaysYellowInGlow = !!ke && ke.classList.contains('on');
o.morphInGlow = le?.querySelector('ha-icon')?.getAttribute('icon')?.includes('lightbulb') ?? false;
// --- HP-1520-01: в редакторах, где glow-слой скрыт, индикатор возвращается
// (лампа горит; в devices-редакторе слой рисуется — там лампа тёмная,
// в plan-редакторе слоя нет — там класс on должен вернуться)
// --- редакторы используют ту же жёлтую подложку независимо от слоя glow --
c._setMode('devices'); c.requestUpdate(); await c.updateComplete;
le = devEl('d_lamp');
o.devicesEditorLampDark = !!le && !le.classList.contains('on');
o.devicesEditorLampYellow = !!le && le.classList.contains('on');
o.devicesEditorHasGlow = !!sr().querySelector('.stage svg radialGradient, .stage svg [id*=glow]');
c._setMode('plan'); c.requestUpdate(); await c.updateComplete;
// ИМЕННО лампа (mutation-проверка аудита HP-1521-01: розетка тоже on и
+172
View File
@@ -0,0 +1,172 @@
// Живой текст в декоре (docs/LIVE-TEXT.md): все связи с HA живут прямо в
// тексте как {entity} / {entity:attribute}. Один блок может смешивать обычный
// текст и несколько переменных; выбор значения вставляет токен в позицию
// курсора. Старые entity/attr/unit читаются, но первое сохранение мигрирует их.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
/** текст фигуры в DOM, склеенный из строк (tspan) */
const dom = (id) => {
const el = sr().querySelector(`.decorlayer text.dtext[data-id="${id}"]`);
if (!el) return null;
return [...el.querySelectorAll('tspan')].map((t) => t.textContent).join('\n');
};
/** открыть форму существующей надписи (на старой сборке — двойным щелчком) */
const open = (sh) => (c._decorOpenText ? c._decorOpenText(sh) : c._decorShapeDbl(sh));
/** подменить одно состояние — ровно так, как HA присылает новый hass */
const push = async (id, st) => {
const states = { ...c.hass.states };
if (st === null) delete states[id]; else states[id] = st;
c.hass = { ...c.hass, states };
await c.updateComplete;
};
sr().querySelectorAll('.modetab')[2].click(); await c.updateComplete;
out.decorMode = c._mode === 'decor';
c._curSpaceCfg.decor = [];
// ---------- 1. несколько inline-переменных и обычная надпись -------------
c._decorTextDialog = { x: 0.3, y: 0.3,
text: 'Бак {sensor.living_temp}; LQI {sensor.living_temp:linkquality}',
color: '#112233' };
c._decorSaveText(); await c.updateComplete;
const live = c._decorList.find((x) => x.kind === 'text');
out.onlyTextStored = !('entity' in live) && !('attr' in live) && !('unit' in live);
out.templateStoredRaw = live.text ===
'Бак {sensor.living_temp}; LQI {sensor.living_temp:linkquality}';
out.multipleValuesRendered = dom(live.id) === 'Бак 22.4 °C; LQI 154';
c._decorTextDialog = { x: 0.6, y: 0.3, text: 'Сауна', color: '#112233' };
c._decorSaveText(); await c.updateComplete;
const plain = c._decorList.find((x) => x.text === 'Сауна');
out.plainUntouched = dom(plain.id) === 'Сауна' && !('entity' in plain);
// ---------- 2. значение живое: новый hass — новый текст -------------------
await push('sensor.living_temp', { entity_id: 'sensor.living_temp', state: '23.9',
attributes: { unit_of_measurement: '°C', linkquality: 154 } });
out.updatesOnState = dom(live.id) === 'Бак 23.9 °C; LQI 154';
out.plainStillUntouched = dom(plain.id) === 'Сауна';
// как отдаёт HA: ни округления, ни переформатирования
await push('sensor.living_temp', { entity_id: 'sensor.living_temp', state: '23.94781',
attributes: { unit_of_measurement: '°C', linkquality: 154 } });
out.noRounding = dom(live.id) === 'Бак 23.94781 °C; LQI 154';
// ---------- 3. мёртвая сущность — прочерк, шаблон остаётся ---------------
await push('sensor.living_temp', { entity_id: 'sensor.living_temp', state: 'unavailable',
attributes: { unit_of_measurement: '°C' } });
out.deadIsDash = dom(live.id) === 'Бак —; LQI —';
await push('sensor.living_temp', { entity_id: 'sensor.living_temp', state: 'unknown', attributes: {} });
out.unknownIsDash = dom(live.id) === 'Бак —; LQI —';
await push('sensor.living_temp', null);
out.missingIsDash = dom(live.id) === 'Бак —; LQI —';
await push('sensor.living_temp', { entity_id: 'sensor.living_temp', state: '22.4',
attributes: { unit_of_measurement: '°C', linkquality: 154 } });
out.backAlive = dom(live.id) === 'Бак 22.4 °C; LQI 154';
// ---------- 4. picker вставляет полный токен ровно в позицию курсора ------
c._decorTextDialog = { x: 0.45, y: 0.45, text: 'До после', color: '#112233',
pickerEntity: 'sensor.living_temp' };
c._decorTextSelection = { start: 3, end: 3 };
c._decorInsertLiveVariable('linkquality'); await c.updateComplete;
out.insertedAtCaret = c._decorTextDialog.text ===
'До {sensor.living_temp:linkquality}после';
c._decorSaveText(); await c.updateComplete;
const inserted = c._decorList.find((x) => x.text?.startsWith('До '));
out.insertedRenders = dom(inserted.id) === 'До 154после';
// ---------- 5. ручная dotted-форма и отсутствующий атрибут ---------------
c._decorTextDialog = { x: 0.55, y: 0.55,
text: '{sensor.living_temp.linkquality} / {sensor.living_temp:nope}',
color: '#112233' };
c._decorSaveText(); await c.updateComplete;
const manual = c._decorList.find((x) => x.text?.includes(':nope'));
out.manualTokensWork = dom(manual.id) === '154 / —';
// ---------- 6. старая связка мигрирует при первом редактировании ----------
const legacy = { id: 'legacy_live', kind: 'text', x: 0.7, y: 0.7,
text: 'Старый {}', color: '#112233', entity: 'sensor.living_temp' };
c._curSpaceCfg.decor = [...c._decorList, legacy];
c.requestUpdate(); await c.updateComplete;
out.legacyStillRenders = dom(legacy.id) === 'Старый 22.4 °C';
open(legacy); await c.updateComplete;
out.legacyOpensAsToken = c._decorTextDialog?.text ===
'Старый {sensor.living_temp}';
c._decorSaveText(); await c.updateComplete;
const migrated = c._decorList.find((x) => x.id === legacy.id);
out.legacyMigrated = !('entity' in migrated)
&& migrated.text === 'Старый {sensor.living_temp}'
&& dom(legacy.id) === 'Старый 22.4 °C';
// Explicit legacy units and attribute names outside the inline grammar are
// not silently destroyed. They stay in the compatible representation until
// the user explicitly replaces the binding.
const legacyUnit = { id: 'legacy_unit', kind: 'text', x: 0.75, y: 0.75,
text: 'Бак {}', color: '#112233', entity: 'sensor.living_temp', unit: 'K' };
c._curSpaceCfg.decor = [...c._decorList, legacyUnit]; c.requestUpdate(); await c.updateComplete;
out.legacyUnitRenders = dom(legacyUnit.id) === 'Бак 22.4 K';
open(legacyUnit); await c.updateComplete;
out.legacyUnitIsNotLossyToken = c._decorTextDialog?.preserveLegacy === true
&& c._decorTextDialog?.text === 'Бак {}';
c._decorSaveText(); await c.updateComplete;
const keptUnit = c._decorList.find((x) => x.id === legacyUnit.id);
out.legacyUnitSurvivesSave = keptUnit?.unit === 'K'
&& keptUnit?.entity === 'sensor.living_temp' && dom(legacyUnit.id) === 'Бак 22.4 K';
await push('sensor.living_temp', { entity_id: 'sensor.living_temp', state: '22.4',
attributes: { unit_of_measurement: '°C', linkquality: 154, 'bad attr': 7 } });
const legacyAttr = { id: 'legacy_attr', kind: 'text', x: 0.8, y: 0.8,
text: 'Атрибут {}', color: '#112233', entity: 'sensor.living_temp', attr: 'bad attr' };
c._curSpaceCfg.decor = [...c._decorList, legacyAttr]; c.requestUpdate(); await c.updateComplete;
out.legacyOddAttrRenders = dom(legacyAttr.id) === 'Атрибут 7';
open(legacyAttr); await c.updateComplete;
out.legacyOddAttrPreserved = c._decorTextDialog?.preserveLegacy === true;
c._decorSaveText(); await c.updateComplete;
const keptAttr = c._decorList.find((x) => x.id === legacyAttr.id);
out.legacyOddAttrSurvivesSave = keptAttr?.attr === 'bad attr'
&& keptAttr?.entity === 'sensor.living_temp' && dom(legacyAttr.id) === 'Атрибут 7';
c._decorTextDialog = { x: 0.2, y: 0.2, text: 'x', color: '#112233',
pickerEntity: 'sensor.living_temp' };
c.requestUpdate(); await c.updateComplete;
out.pickerHidesUnrepresentableAttr = ![...sr().querySelectorAll('select.namein option')]
.some((o) => o.value === 'bad attr');
c._decorTextDialog = null;
// ---------- 7. просмотр и киоск рисуют то же самое ------------------------
const editorText = dom(live.id);
c._setMode('view'); await c.updateComplete; await sleep(60);
out.sameInViewMode = dom(live.id) === editorText
&& editorText === 'Бак 22.4 °C; LQI 154';
const kiosk = document.createElement('houseplan-card');
kiosk.setConfig({ type: 'custom:houseplan-card', kiosk: true });
kiosk.hass = c.hass;
document.body.appendChild(kiosk);
await kiosk.updateComplete; await sleep(400); await kiosk.updateComplete;
const kr = kiosk.shadowRoot || kiosk.renderRoot;
const kEl = kr.querySelector(`.decorlayer text.dtext[data-id="${live.id}"]`);
out.sameInKiosk = !!kEl
&& [...kEl.querySelectorAll('tspan')].map((t) => t.textContent).join('\n') === editorText;
kiosk.remove();
// ---------- 8. round-trip хранит только текстовый шаблон ------------------
const sent = [];
const ws = c.hass.callWS;
c.hass = { ...c.hass, callWS: async (m) => { sent.push(m); return ws(m); } };
c._setMode('decor'); await c.updateComplete;
open(c._decorList.find((x) => x.id === live.id)); await c.updateComplete;
c._decorSaveText();
await sleep(900);
const set = sent.filter((m) => m.type === 'houseplan/config/set').pop();
const shape = set && (set.config.spaces.find((s) => s.id === c._space).decor || [])
.find((x) => x.id === live.id);
out.roundTrip = !!shape && !('entity' in shape) && !('attr' in shape) && !('unit' in shape)
&& shape.text === 'Бак {sensor.living_temp}; LQI {sensor.living_temp:linkquality}';
return out;
});
checkAll(res);
await finish(browser, res);
+29
View File
@@ -68,6 +68,35 @@ out.bigKeepsLiving = s.rooms.some(r=>r.id==='r1' && r.area==='living_room');
out.newRoom = s.rooms.find(r=>!['r1','r2','r3','r4','rg'].includes(r.id))?.name;
await restore();
// AUD-159B7-01: one open span crossing the Split point belongs to two room
// pairs afterwards. The card transaction must persist both pieces and derive
// light-zone connectivity for both children, not keep only the first half.
const spanSplit = await page.evaluate(() => {
const c = window.__card;
const sp = c._serverCfg.spaces.find((x) => x.id === 'f1');
sp.rooms = [
{ id: 'left_top', name: 'Left top', area: null, poly: [[0, 0], [.5, 0], [.5, .2], [0, .2]] },
{ id: 'left_bottom', name: 'Left bottom', area: null, poly: [[0, .2], [.5, .2], [.5, .4], [0, .4]] },
{ id: 'right', name: 'Right', area: null, poly: [[.5, 0], [1, 0], [1, .4], [.5, .4]] },
];
sp.open_spans = [{ a: [.5, .1], b: [.5, .3] }];
c._commitOpenSpans();
const pieces = (sp.open_spans || []).map((e) =>
[Math.min(e.a[1], e.b[1]), Math.max(e.a[1], e.b[1])]);
const links = Object.fromEntries(sp.rooms.map((r) => [r.id, r.open_to || []]));
return { pieces, links };
});
out.splitKeepsOpenPieces = spanSplit.pieces.length === 2
&& Math.abs(spanSplit.pieces[0][0] - .1) < 1e-9
&& Math.abs(spanSplit.pieces[0][1] - .2) < 1e-9
&& Math.abs(spanSplit.pieces[1][0] - .2) < 1e-9
&& Math.abs(spanSplit.pieces[1][1] - .3) < 1e-9;
out.splitSyncsBothSides = spanSplit.links.left_top?.includes('right')
&& spanSplit.links.left_bottom?.includes('right')
&& spanSplit.links.right?.includes('left_top')
&& spanSplit.links.right?.includes('left_bottom');
await restore();
// SPLIT along a wall → refused (both pts on top wall of rg)
await page.evaluate(()=>{const c=window.__card; c._serverCfg.spaces.find(s=>s.id==='f1').rooms.push(
{id:'rg2', name:'G2', area:null, poly:[[0.05,0.0625],[0.5,0.0625],[0.5,0.5],[0.05,0.5]]});
+73 -34
View File
@@ -1,11 +1,11 @@
// Правило владельца (2026-08-01, вариант «б»): «движение = разовая вспышка
// в момент обнаружения; cool-down не пульсирует; присутствие = статичное
// кольцо пока обитаемо». Датчик ДВИЖЕНИЯ (device_class motion) при переходе
// off→on получает класс 'senseflash' — короткая вспышка кольца (hp-sense,
// 3 удара, ~3.3 c), после чего класс снимается, ДАЖЕ если сущность всё ещё
// off→on получает activity-event — три последовательные волны
// (hp-activity-event, ~3.3 c), после чего эффект снимается, ДАЖЕ если сущность всё ещё
// висит в on (это cool-down датчика, а не движение). Новый цикл off→on —
// новая вспышка. Датчики ПРИСУТСТВИЯ (occupancy/presence) при on несут
// 'sensehold' — статичное жёлтое кольцо без анимации (opacity 0.4). Жёлтая
// activity-presence — статичное кольцо без анимации (opacity 0.4). Жёлтая
// заливка .dev.on в обоих случаях не участвует; ghost — без кольца
// (призрак — конфигурация, не статус).
import { launch, checkAll, finish } from './serve.mjs';
@@ -14,6 +14,24 @@ const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
// Fresh-card baseline: the marker is built while motion is off and no
// unrelated hass tick is allowed to establish runtime later by accident.
c.hass = { ...c.hass, states: { ...c.hass.states,
'binary_sensor.hall_motion': { entity_id: 'binary_sensor.hall_motion', state: 'off',
attributes: { friendly_name: 'Motion', device_class: 'motion', linkquality: 64 } } } };
await c.updateComplete;
c._activityRt.clear();
// Activity is an explicit presentation: opt this fixture into it.
const motionDevice = c._devices.find((x) => x.entities.includes('binary_sensor.hall_motion'));
c._serverCfg.markers = (c._serverCfg.markers || [])
.filter((m) => m.binding !== 'device:' + motionDevice.bindingRef);
c._serverCfg.markers.push({
id: motionDevice.id,
binding: 'device:' + motionDevice.bindingRef,
display: 'icon_ripple',
});
c._cfgEpoch++; c._regSignature = '';
c._maybeRebuildDevices(); c.requestUpdate(); await c.updateComplete;
const setMotion = async (state, device_class) => {
c.hass = { ...c.hass, states: { ...c.hass.states,
'binary_sensor.hall_motion': { entity_id: 'binary_sensor.hall_motion', state,
@@ -22,19 +40,27 @@ const res = await page.evaluate(async () => {
};
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// --- motion off→on: вспышка senseflash, 3 удара, подложка НЕ жёлтая -----
await setMotion('off', 'motion');
out.offNoFlash = sr().querySelectorAll('.dev.senseflash, .dev.sensehold').length === 0;
// --- первое событие сразу после build не теряется как baseline ----------
await setMotion('on', 'motion');
const flashEl = sr().querySelector('.dev.senseflash');
out.firstTripAfterBuildFlashes = sr().querySelectorAll('.dev.activity-event').length === 1;
await setMotion('off', 'motion');
c._activityRt.clear(); c._syncActivityRuntime(); c.requestUpdate(); await c.updateComplete;
// --- motion off→on: event, 3 волны, подложка НЕ жёлтая -------------------
await setMotion('off', 'motion');
out.offNoFlash = sr().querySelectorAll('.dev.activity-event, .dev.activity-presence').length === 0;
await setMotion('on', 'motion');
const flashEl = sr().querySelector('.dev.activity-event');
out.motionTripFlashes = !!flashEl;
out.flashIsNotOn = !!flashEl && !flashEl.classList.contains('on') && !flashEl.classList.contains('open');
const after = flashEl ? getComputedStyle(flashEl, '::after') : null;
out.flashAnimated = !!after && after.animationName === 'hp-sense';
out.flashFinite = !!after && after.animationIterationCount === '3'; // не infinite!
const waves = flashEl ? [...flashEl.querySelectorAll('.activity-ring.event i')] : [];
const firstWave = waves[0] ? getComputedStyle(waves[0]) : null;
out.flashAnimated = !!firstWave && firstWave.animationName.startsWith('hp-activity-event');
out.flashFinite = waves.length === 3
&& waves.every((w) => getComputedStyle(w).animationIterationCount === '1');
// подложка нейтральная — как у обычного .dev, не как у .dev.on
const neutral = [...sr().querySelectorAll('.dev')].find((e) =>
!e.classList.contains('senseflash') && !e.classList.contains('sensehold')
!e.classList.contains('activity-event') && !e.classList.contains('activity-presence')
&& !e.classList.contains('on') && !e.classList.contains('open')
&& !e.classList.contains('alarm') && !e.classList.contains('unavail') && !e.classList.contains('valonly'));
const bg = flashEl ? getComputedStyle(flashEl).backgroundColor : '';
@@ -46,76 +72,89 @@ const res = await page.evaluate(async () => {
await sleep(3600);
await c.updateComplete;
out.stateStillOn = c.hass.states['binary_sensor.hall_motion'].state === 'on';
out.flashEndsWhileOn = sr().querySelectorAll('.dev.senseflash').length === 0;
out.flashEndsWhileOn = sr().querySelectorAll('.dev.activity-event').length === 0;
// --- повторный цикл off→on: новая вспышка --------------------------------
await setMotion('off', 'motion');
await setMotion('on', 'motion');
out.retripFlashesAgain = sr().querySelectorAll('.dev.senseflash').length === 1;
out.retripFlashesAgain = sr().querySelectorAll('.dev.activity-event').length === 1;
// --- HP-1543-02: быстрый повторный off→on ДО конца текущей вспышки ------
// обязан ПЕРЕЗАПУСТИТЬ анимацию (новый timeline). Раньше класс senseflash
// обязан ПЕРЕЗАПУСТИТЬ анимацию (новый timeline). Раньше класс event
// не снимался и animation-name не менялся — браузер продолжал старый
// timeline, и после его конца (base opacity 0) второй trip был невидим.
await sleep(3600); await c.updateComplete; // дать текущей вспышке догореть
const senseAnim = (el) => el && el.getAnimations({ subtree: true })
.find((a) => a.animationName && a.animationName.startsWith('hp-sense'));
.find((a) => a.animationName && a.animationName.startsWith('hp-activity-event') && a.playState === 'running');
await setMotion('off', 'motion');
await setMotion('on', 'motion'); // trip №1
out.rapidFirstTripFlashes = sr().querySelectorAll('.dev.senseflash').length === 1;
out.rapidFirstTripFlashes = sr().querySelectorAll('.dev.activity-event').length === 1;
await sleep(1200); // середина первой вспышки (< 3.3s)
const a1 = senseAnim(sr().querySelector('.dev.senseflash'));
const a1 = senseAnim(sr().querySelector('.dev.activity-event'));
out.rapidFirstAnimMidway = !!a1 && a1.currentTime > 900 && a1.currentTime < 3300;
await setMotion('off', 'motion');
await setMotion('on', 'motion'); // trip №2 — retrip до конца вспышки
out.rapidRetripKeepsFlash = sr().querySelectorAll('.dev.senseflash').length === 1;
const a2 = senseAnim(sr().querySelector('.dev.senseflash'));
out.rapidRetripKeepsFlash = sr().querySelectorAll('.dev.activity-event').length === 1;
const a2 = senseAnim(sr().querySelector('.dev.activity-event'));
// новый timeline: currentTime у начала (старый уже был ~1.2s и продолжал бы расти)
out.rapidRetripRestartsAnim = !!a2 && a2.currentTime != null && a2.currentTime < 300;
// кольцо реально играет ПОСЛЕ момента, где закончилась бы первая анимация:
// ~2.3s после trip №2 = ~3.5s после trip №1 (старый timeline уже отыграл бы)
await sleep(2300);
const el3 = sr().querySelector('.dev.senseflash');
const el3 = sr().querySelector('.dev.activity-event');
const a3 = senseAnim(el3);
out.rapidSecondFlashStillPlays = !!a3 && a3.playState === 'running';
out.rapidRingVisibleAfterFirstWindow = !!el3
&& parseFloat(getComputedStyle(el3, '::after').opacity) > 0.03;
&& [...el3.querySelectorAll('.activity-ring.event i')]
.some((w) => parseFloat(getComputedStyle(w).opacity) > 0.03);
// и класс уходит через полные ~3.3s после ВТОРОГО trip (flashTs/таймер обновлены)
await sleep(1400); await c.updateComplete;
out.rapidFlashEndsAfterSecondWindow = sr().querySelectorAll('.dev.senseflash').length === 0;
out.rapidFlashEndsAfterSecondWindow = sr().querySelectorAll('.dev.activity-event').length === 0;
// --- occupancy: статичное кольцо sensehold, без анимации ----------------
// --- occupancy: статичное кольцо presence, без анимации -----------------
await setMotion('on', 'occupancy');
const holdEl = sr().querySelector('.dev.sensehold');
const holdEl = sr().querySelector('.dev.activity-presence');
out.occupancyHolds = !!holdEl;
const hAfter = holdEl ? getComputedStyle(holdEl, '::after') : null;
const holdRing = holdEl?.querySelector('.activity-ring.presence i:first-child');
const hAfter = holdRing ? getComputedStyle(holdRing) : null;
out.holdNotAnimated = !!hAfter && hAfter.animationName === 'none';
out.holdRingFaint = !!hAfter && Math.abs(parseFloat(hAfter.opacity) - 0.4) < 0.01;
out.holdBgNeutral = !!holdEl && !!neutral
&& getComputedStyle(holdEl).backgroundColor === getComputedStyle(neutral).backgroundColor;
out.holdNoFlashClass = !!holdEl && !holdEl.classList.contains('senseflash');
out.holdNoFlashClass = !!holdEl && !holdEl.classList.contains('activity-event');
await setMotion('off', 'occupancy');
out.occupancyOffClears = sr().querySelectorAll('.dev.sensehold').length === 0;
out.occupancyOffClears = sr().querySelectorAll('.dev.activity-presence').length === 0;
// --- presence аналогично -------------------------------------------------
await setMotion('on', 'presence');
out.presenceHolds = sr().querySelectorAll('.dev.sensehold').length === 1;
out.presenceHolds = sr().querySelectorAll('.dev.activity-presence').length === 1;
// и это именно sense-состояние, не тревога и не «открыто»
out.noAlarmNoOpen = !sr().querySelector('.dev.sensehold.alarm') && !sr().querySelector('.dev.sensehold.open');
out.noAlarmNoOpen = !sr().querySelector('.dev.activity-presence.alarm') && !sr().querySelector('.dev.activity-presence.open');
// --- смена effective source синхронно снимает старую вспышку ------------
await setMotion('off', 'motion');
await setMotion('on', 'motion');
const liveMarker = c._serverCfg.markers.find((m) => m.id === motionDevice.id);
liveMarker.controls = ['switch.kettle'];
c._regSignature = ''; c._maybeRebuildDevices(); c.requestUpdate(); await c.updateComplete;
out.rebindDropsOldFlashWithoutHassTick = !sr().querySelector('.dev.activity-event');
delete liveMarker.controls;
c._regSignature = ''; c._maybeRebuildDevices(); c.requestUpdate(); await c.updateComplete;
// --- ghost не рисует ничего (призрак — конфигурация, не статус) ---------
await setMotion('off', 'motion');
await setMotion('on', 'motion');
const d = c._devices.find((x) => x.entities.includes('binary_sensor.hall_motion'));
c._serverCfg.markers = c._serverCfg.markers || [];
c._serverCfg.markers.push({ id: d.id, binding: 'device:' + d.bindingRef, hidden: true });
c._serverCfg.markers = (c._serverCfg.markers || [])
.filter((m) => m.binding !== 'device:' + d.bindingRef);
c._serverCfg.markers.push({ id: d.id, binding: 'device:' + d.bindingRef, display: 'icon_ripple', hidden: true });
c._cfgEpoch++; c._regSignature = '';
c._maybeRebuildDevices(); c.requestUpdate(); await c.updateComplete;
out.hiddenNotInView = sr().querySelectorAll('.dev.senseflash, .dev.sensehold').length === 0;
out.hiddenNotInView = sr().querySelectorAll('.dev.activity-event, .dev.activity-presence').length === 0;
c._showHidden = true; c._setMode('devices'); c.requestUpdate(); await c.updateComplete;
const ghost = sr().querySelector('.dev.ghost');
out.ghostNoSense = !!ghost && !ghost.classList.contains('senseflash') && !ghost.classList.contains('sensehold')
&& getComputedStyle(ghost, '::after').animationName !== 'hp-sense';
out.ghostNoSense = !!ghost && !ghost.classList.contains('activity-event') && !ghost.classList.contains('activity-presence')
&& !ghost.querySelector('.activity-ring');
return out;
});
checkAll(res, {});
+109 -5
View File
@@ -1,8 +1,8 @@
// Opening drag rulers (owner 2026-08-03): while a door/window is dragged along
// a wall, a measure badge sits on EACH shoulder (wall end -> opening edge,
// along the wall), and when the opening's center hits the wall's center a
// perpendicular dashed tick appears and the center magnet-snaps (Shift opts
// out). The wall is ONE room's edge (owner: "only the wall of one room") —
// perpendicular dashed tick appears and the center magnet-snaps (Shift cannot
// opt out). The wall is ONE room's edge (owner: "only the wall of one room") —
// r2's collinear top edge never merges into r1's, so the ruler runs 40..550
// (center 295), not the old merged 40..960. Real pointer events; the numbers
// are checked against the geometry: cell = 5 cm, grid pitch = 1000/240, so
@@ -97,7 +97,7 @@ check('tick_gone_after_drop', await tick(), 0);
check('committed_x_center', near(await page.evaluate(() =>
window.__card._serverCfg.spaces.find((s) => s.id === 'f1').openings[0].x), 0.295, 1e-6));
// ---------- Shift disables the magnet (badges stay) ----------
// ---------- Shift cannot disable the magnet (badges stay) ----
// park the opening off-center first: a drag that returns to within 3 px of its
// own starting point is filtered by the tap threshold, so the Shift approach
// must start away from the center
@@ -111,9 +111,9 @@ await page.keyboard.down('Shift');
await page.mouse.down();
await page.mouse.move(t3x, sy, { steps: 3 });
await settle();
check('no_tick_with_shift', await tick(), 0);
check('tick_with_shift', await tick(), 1);
const freeX = await page.evaluate(() => window.__card._curSpaceCfg.openings[0].x);
check('no_magnet_with_shift', near(freeX, 0.2937, 0.004) && freeX !== 0.295);
check('magnet_with_shift', near(freeX, 0.295, 1e-6));
check('badges_still_there_with_shift', (await badges()).length, 2);
await page.mouse.up();
await page.keyboard.up('Shift');
@@ -169,4 +169,108 @@ check('shared_wall_badges_equal', await badges(), ['1.44 m', '1.44 m']);
await page.mouse.up();
await settle();
// ---------- PLACING a new opening: the same rulers (owner 2026-08-03) -------
// Moving along a wall with the Opening tool must show exactly what dragging an
// existing opening shows: a badge on each shoulder of the WOULD-BE opening, a
// perpendicular tick + magnet at the wall centre, Shift opting out — and the
// whole lot vanishing the moment the opening is placed. Default length is
// 90 cm = 75 units, so the shoulders are measured from the edges c +- 37.5.
await page.evaluate(() => {
const c = window.__card;
const sp = c._serverCfg.spaces.find((s) => s.id === 'f1');
sp.rooms = [
{ id: 'r1', name: 'Living room', poly: [[0.04, 0.14], [0.55, 0.14], [0.55, 0.58], [0.04, 0.58]] },
{ id: 'r2', name: 'Kitchen', poly: [[0.55, 0.14], [0.96, 0.14], [0.96, 0.46], [0.55, 0.46]] },
];
sp.openings = [];
c._setMode('plan'); c._tool = 'opening';
c._cfgEpoch++; c.requestUpdate();
return c.updateComplete && true;
});
await settle();
const ghosts = () => page.evaluate(() => window.__card.renderRoot.querySelectorAll('.opghost').length);
// hover off-centre on r1's top wall (own edge 40..550, y = 140)
{
const [hx, hy] = await screenPt(460, 141);
await page.mouse.move(hx, hy, { steps: 3 });
await settle();
check('place_ghost_shown', await ghosts(), 1);
check('place_two_badges', (await badges()).length, 2);
// edges 422.5 / 497.5 -> 382.5 u (4.59 m) left, 52.5 u (0.63 m) right
const [a, b] = await nums();
check('place_badge_right_0_63', near(a, 0.63, 0.05));
check('place_badge_left_4_59', near(b, 4.59, 0.05));
check('place_no_tick_off_center', await tick(), 0);
}
// live update on the way — the badges follow the cursor
{
const [hx, hy] = await screenPt(200, 141);
await page.mouse.move(hx, hy, { steps: 3 });
await settle();
// edges 162.5 / 237.5 -> 122.5 u (1.47 m) left, 312.5 u (3.75 m) right
const [a, b] = await nums();
check('place_badge_updates_1_47', near(a, 1.47, 0.05));
check('place_badge_updates_3_75', near(b, 3.75, 0.05));
}
// near the wall centre (40+550)/2 = 295: tick appears and the magnet bites
{
const [hx, hy] = await screenPt(293.7, 141);
await page.mouse.move(hx, hy, { steps: 3 });
await settle();
check('place_tick_at_center', await tick(), 1);
// equal shoulders after the magnet: (510 - 75) / 2 = 217.5 u -> 2.61 m
check('place_badges_equal_at_center', await badges(), ['2.61 m', '2.61 m']);
const g = await page.evaluate(() => {
const el = window.__card.renderRoot.querySelector('.opcentertick');
return el && { x1: +el.getAttribute('x1'), x2: +el.getAttribute('x2'),
y1: +el.getAttribute('y1'), y2: +el.getAttribute('y2') };
});
check('place_tick_perpendicular', !!g && near(g.x1, 295, 1e-6) && near(g.x2, 295, 1e-6)
&& near(g.y2 - g.y1, 30, 1e-6));
// the ghost itself already sits on the magnetised centre
const gh = await page.evaluate(() => {
const el = window.__card.renderRoot.querySelector('.opghost');
return el && (+el.getAttribute('x1') + +el.getAttribute('x2')) / 2;
});
check('place_ghost_magnetised', near(gh, 295, 1e-6));
}
// Shift cannot opt out of the magnet, the badges stay
{
await page.keyboard.down('Shift');
const [hx, hy] = await screenPt(293.7, 141);
await page.mouse.move(hx, hy, { steps: 3 });
await settle();
check('place_tick_with_shift', await tick(), 1);
check('place_badges_with_shift', (await badges()).length, 2);
const gh = await page.evaluate(() => {
const el = window.__card.renderRoot.querySelector('.opghost');
return el && (+el.getAttribute('x1') + +el.getAttribute('x2')) / 2;
});
check('place_ghost_magnetised_with_shift', near(gh, 295, 1e-6));
await page.keyboard.up('Shift');
}
// the click places it AT THE MAGNET and clears every hint
{
const [hx, hy] = await screenPt(293.7, 141);
await page.mouse.move(hx, hy, { steps: 3 });
await settle();
await page.mouse.click(hx, hy);
await settle();
check('place_dialog_opened', await page.evaluate(() => !!window.__card._openingDialog), true);
check('place_dialog_x_magnetised', near(await page.evaluate(() => window.__card._openingDialog.x), 295, 1e-6));
check('place_badges_gone', (await badges()).length, 0);
check('place_tick_gone', await tick(), 0);
check('place_ghost_gone', await ghosts(), 0);
// …and confirming the dialog writes the opening at the magnetised point
await page.evaluate(() => { const c = window.__card; c._saveOpening(); return c.updateComplete && true; });
await settle();
check('place_committed_x_center', near(await page.evaluate(() =>
window.__card._serverCfg.spaces.find((s) => s.id === 'f1').openings[0].x), 0.295, 1e-6));
}
await finish(browser, { done: true });
+31 -19
View File
@@ -5,17 +5,17 @@ const res = await page.evaluate(async () => {
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
c._setMode('plan'); c._tool = 'openwall'; await c.updateComplete;
// r1|r2 делят стену x=0.55 → клик по ней открывает границу
const H = 1000; // square canvas
c._openWallClick([550, 0.25 * H]);
// Shared wall r1|r2 at x=550, y from 140..460. Two-click open full shared stretch.
c._openWallClick([550, 140]);
c._openWallClick([550, 460]);
await c.updateComplete;
const r1 = c._curSpaceCfg.rooms.find((r) => r.id === 'r1');
const r2 = c._curSpaceCfg.rooms.find((r) => r.id === 'r2');
out.linked = (r1.open_to || []).includes('r2') && (r2.open_to || []).includes('r1');
// пунктир отрисован
out.hasSpans = Array.isArray(c._curSpaceCfg.open_spans) && c._curSpaceCfg.open_spans.length > 0;
out.dashes = sr().querySelectorAll('.openwall').length > 0;
out.hotClass = !!sr().querySelector('.openwalls.hot');
// в Просмотре: сплошной штрих комнаты снят, контур без выреза + пунктир ПОВЕРХ glow
// View: trimmed outlines + dash
c._setMode('view'); await c.updateComplete;
out.noedge = sr().querySelectorAll('.room.noedge').length >= 2;
out.trimmedOutline = sr().querySelectorAll('.room-outline').length >= 2;
@@ -24,30 +24,31 @@ const res = await page.evaluate(async () => {
const gi = order.indexOf('glowlayer');
const oi = order.indexOf('openwalls');
out.dashAboveGlow = gi === -1 || oi > gi;
// в редакторе плана: пунктир виден, штрих комнат вырезан, контур синий
c._setMode('plan'); c._tool = 'draw'; await c.updateComplete;
out.planDashes = sr().querySelectorAll('.openwall').length > 0;
out.planNoedge = sr().querySelectorAll('.room.noedge').length >= 2;
out.planBlueOutline = sr().querySelectorAll('.room-outline.outlined').length >= 2;
// производные стены (.seg) не проходят сквозь открытый участок x=550, y≈0.25H
const midY = 0.25 * H;
const midY = 300;
out.planSegCut = ![...sr().querySelectorAll('line.seg')].some((l) => {
const x1 = +l.getAttribute('x1'), x2 = +l.getAttribute('x2');
const y1 = +l.getAttribute('y1'), y2 = +l.getAttribute('y2');
return Math.abs(x1 - 550) < 0.5 && Math.abs(x2 - 550) < 0.5
&& Math.min(y1, y2) < midY && Math.max(y1, y2) > midY;
});
c._tool = 'openwall'; await c.updateComplete;
// повторный клик закрывает
c._openWallClick([550, 0.25 * H]); await c.updateComplete;
c._tool = 'openwall';
c._openWallClick([550, 300]); await c.updateComplete;
out.openToolCannotClose = (c._curSpaceCfg.open_spans || []).length > 0;
c._tool = 'closewall'; await c.updateComplete;
// Closing is a separate, single-purpose action (UX-02).
c._closeWallClick([550, 300]); await c.updateComplete;
out.toggledOff = !(c._curSpaceCfg.rooms.find((r) => r.id === 'r1').open_to || []).includes('r2');
out.dashesGone = sr().querySelectorAll('.openwall').length === 0;
// клик мимо стен — тост, без изменений
c._openWallClick([100, 100]);
c._closeWallClick([100, 100]);
out.missToast = !!c._toast;
// снова открыть для glow-теста
c._openWallClick([550, 0.25 * H]); await c.updateComplete;
// glow: свет из r1 заливает и r2 (clip содержит оба полигона)
// reopen for glow
c._openWallClick([550, 140]);
c._openWallClick([550, 460]);
await c.updateComplete;
c._setMode('view'); await c.updateComplete;
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== c._space ? s : ({
...s, settings: { ...(s.settings || {}), fill_mode: 'glow' } })) };
@@ -57,17 +58,28 @@ const res = await page.evaluate(async () => {
c.requestUpdate(); await c.updateComplete;
const clip = sr().querySelector('defs clipPath[id^="hp-glowclip"]');
out.zoneClip = clip ? clip.querySelectorAll('path').length >= 2 : false;
// транзитивность: r2↔r3 тоже открыть → clip получит 3 полигона
const rr2 = c._curSpaceCfg.rooms.find((r) => r.id === 'r2');
const rr3 = c._curSpaceCfg.rooms.find((r) => r.id === 'r3');
if (rr3) {
rr2.open_to = [...(rr2.open_to || []), 'r3'];
rr3.open_to = [...(rr3.open_to || []), 'r2'];
c._regSignature = ''; c.requestUpdate(); await c.updateComplete;
// legacy open_to without spans still expands on read for cuts — force spans for r2-r3
c._openWallClick = c._openWallClick.bind(c);
c._setMode('plan'); c._tool = 'openwall';
// shared r2|r3 at y=460, x 550..960
c._openWallClick([700, 460]);
c._openWallClick([900, 460]);
await c.updateComplete;
c._setMode('view'); await c.updateComplete;
const clip2 = sr().querySelector('defs clipPath[id^="hp-glowclip"]');
out.transitive = clip2 ? clip2.querySelectorAll('path').length >= 3 : false;
} else out.transitive = 'no r3';
// outer wall refuses open
c._setMode('plan'); c._tool = 'openwall';
c._toast = null;
c._openWallClick([40, 300]);
out.outerToast = !!(c._toast && String(c._toast).length);
return out;
});
checkAll(res);
checkAll(res, { outerToast: true });
await finish(browser, res);
+25 -21
View File
@@ -6,30 +6,34 @@ const res = await page.evaluate(async () => {
const sr = () => c.shadowRoot || c.renderRoot;
const stage = () => sr().querySelector('.stage');
c._setMode('plan'); c._tool = 'openwall'; await c.updateComplete;
const H = 1000; // square canvas
// 1) без наведения: курсор default, превью нет
const H = 1000;
// 1) idle: crosshair (draw-like), no preview
c._cursorPt = null; c.requestUpdate(); await c.updateComplete;
out.idleCursor = getComputedStyle(stage()).cursor === 'default';
out.idleCursor = getComputedStyle(stage()).cursor === 'crosshair';
out.noPreview = !sr().querySelector('.openwall-preview');
// 2) наведение на общую стену r1|r2 (x=550): pointer + янтарное превью
// 2) hover shared solid wall: still NO full-edge preview (click target must stay clear)
c._cursorPt = [550, 0.25 * H]; c.requestUpdate(); await c.updateComplete;
out.hotCursor = getComputedStyle(stage()).cursor === 'pointer';
const prev = sr().querySelectorAll('.openwall-preview');
out.previewShown = prev.length > 0;
out.previewAmber = prev.length ? !prev[0].classList.contains('willclose') : null;
// превью лежит на стене x=550
out.previewOnWall = prev.length ? Math.abs(Number(prev[0].getAttribute('x1')) - 550) < 0.5 : null;
// 3) наведение в центр комнаты: снова default, превью исчезло
c._cursorPt = [300, 150]; c.requestUpdate(); await c.updateComplete;
out.missCursor = getComputedStyle(stage()).cursor === 'default';
out.missNoPreview = !sr().querySelector('.openwall-preview');
// 4) открыть границу → навести снова: превью красное сплошное (клик закроет)
c._openWallClick([550, 0.25 * H]); await c.updateComplete;
c._cursorPt = [550, 0.25 * H]; c.requestUpdate(); await c.updateComplete;
const prev2 = sr().querySelector('.openwall-preview');
out.willclose = prev2?.classList.contains('willclose') === true;
// прибрать
c._openWallClick([550, 0.25 * H]); await c.updateComplete;
out.hoverNoFullEdge = !sr().querySelector('.openwall-preview');
out.hoverCursor = getComputedStyle(stage()).cursor === 'crosshair';
// 3) Opening and closing are separate tools: openwall does not advertise a
// close action, while closewall highlights the exact virtual stretch.
c._openWallClick([550, 140]);
c._openWallClick([550, 460]);
await c.updateComplete;
c._cursorPt = [550, 300]; c.requestUpdate(); await c.updateComplete;
out.openToolDoesNotOfferClose = !sr().querySelector('.openwall-preview');
c._tool = 'closewall'; c.requestUpdate(); await c.updateComplete;
const prev = sr().querySelector('.openwall-preview');
out.closeToolWillClose = prev?.classList.contains('willclose') === true;
// 4) with anchor set: rubber-band preview appears
c._closeWallClick([550, 300]);
await c.updateComplete;
c._tool = 'openwall';
c._openWallClick([550, 200]);
c._cursorPt = [550, 400]; c.requestUpdate(); await c.updateComplete;
const band = sr().querySelector('.openwall-preview');
out.anchorPreview = !!band && !band.classList.contains('willclose');
c._openWallAnchor = null;
return out;
});
checkAll(res);
+253
View File
@@ -0,0 +1,253 @@
// Panning at ANY zoom (owner's report 2026-08-04: «добавь возможность таскать
// план при любом масштабе, а не только при более 100%»).
//
// Until this fix `_stagePointerMove` moved the view only while `_zoom > 1`:
// the rule was inherited from the old bounded canvas, where a plan smaller
// than the scene had nowhere to go. The infinite canvas removed that edge —
// there is always somewhere to go, and an editor extending a plan outwards
// needs exactly that. So a drag on empty scene now pans at 100 %, at 50 % and
// at 800 %, in view mode and in every editor; how far you may walk is decided
// by `_clampView` (PAN_SLACK: about a screen past the content) and not by the
// zoom. The tools keep the pointer they already owned — a resize handle, a
// device badge, an opening — and the kiosk keeps its floor swipe.
import { launch, check, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const out = {};
// ---- a drag on empty scene pans, at every zoom and in every mode ---------
Object.assign(out, await page.evaluate(async () => {
const c = window.__card;
const o = {};
const pd = (id, x, y) => c._stagePointerDown({ pointerId: id, clientX: x, clientY: y, target: c._stageEl, preventDefault() {} });
const pm = (id, x, y) => c._stagePointerMove({ pointerId: id, clientX: x, clientY: y });
const pu = (id, x, y) => c._stagePointerUp({ pointerId: id, clientX: x, clientY: y });
let pid = 100;
/** Drag 80x60 px across the middle of the scene; did the view follow? */
const drag = async (zoom) => {
// always start from the plan in the middle: the clamp is a real limit, and
// a dozen drags in a row would otherwise walk into it
const vb = c._baseVb();
c._applyView(zoom, vb[0] + vb[2] / 2, vb[1] + vb[3] / 2);
await c.updateComplete;
const v0 = { ...c._viewOr(c._baseVb()) };
const id = ++pid;
pd(id, 300, 300);
pm(id, 340, 330);
pm(id, 380, 360);
const v1 = { ...c._viewOr(c._baseVb()) };
pu(id, 380, 360);
// dragging down-right pulls the viewport up-left, and the zoom is untouched
return Math.abs(v1.x - v0.x) > 1 && Math.abs(v1.y - v0.y) > 1
&& v1.x < v0.x && v1.y < v0.y && Math.abs(c._zoom - zoom) < 1e-9;
};
const inMode = async (name, setup) => {
await setup();
await c.updateComplete;
o['pan_' + name + '_at100'] = await drag(1);
o['pan_' + name + '_at50'] = await drag(0.5);
o['pan_' + name + '_at33'] = await drag(1 / 3);
};
await inMode('view', async () => { c._setMode('view'); });
for (const tool of ['draw', 'merge', 'split', 'resize', 'opening', 'openwall', 'closewall', 'delroom']) {
await inMode('plan_' + tool, async () => { c._setMode('plan'); c._tool = tool; });
}
await inMode('devices', async () => { c._setMode('devices'); });
await inMode('decor', async () => { c._setMode('decor'); c._decorTool = 'select'; });
c._setMode('view');
c._applyView(1);
await c.updateComplete;
return o;
}));
// ---- 400 % still pans (the behaviour that already worked) ----------------
out.panStillWorksZoomedIn = await page.evaluate(async () => {
const c = window.__card;
c._setMode('view'); c._applyView(4); await c.updateComplete;
const v0 = { ...c._view };
c._stagePointerDown({ pointerId: 7, clientX: 300, clientY: 300, target: c._stageEl, preventDefault() {} });
c._stagePointerMove({ pointerId: 7, clientX: 380, clientY: 360 });
const moved = Math.abs(c._view.x - v0.x) > 1;
c._stagePointerUp({ pointerId: 7, clientX: 380, clientY: 360 });
c._applyView(1);
return moved;
});
// ---- the clamp: you may walk about a screen off, never further ------------
Object.assign(out, await page.evaluate(async () => {
const c = window.__card;
const sr = c.shadowRoot || c.renderRoot;
const o = {};
c._setMode('view');
const vb = c._baseVb();
c._applyView(1, vb[0] + vb[2] / 2, vb[1] + vb[3] / 2); // at 100 % the view IS fitView
await c.updateComplete;
const fit = { ...c._view };
c._stagePointerDown({ pointerId: 8, clientX: 700, clientY: 400, target: c._stageEl, preventDefault() {} });
c._stagePointerMove({ pointerId: 8, clientX: -40000, clientY: 400 }); // shove it into the void
const far = { ...c._view };
c._stagePointerUp({ pointerId: 8, clientX: -40000, clientY: 400 });
o.panLeavesTheContent = far.x > fit.x + fit.w * 0.5;
o.panStopsAtTheSlack = far.x <= fit.x + fit.w + 1;
c.requestUpdate(); await c.updateComplete;
o.homeArrowOffersTheWayBack = !!sr.querySelector('.homearrow');
c._applyView(1, vb[0] + vb[2] / 2, vb[1] + vb[3] / 2);
c.requestUpdate(); await c.updateComplete;
// the same at 50 %: the screen holds more, the walk is still finite
const fit2 = { ...c._view };
c._applyView(0.5, vb[0] + vb[2] / 2, vb[1] + vb[3] / 2); await c.updateComplete;
c._stagePointerDown({ pointerId: 9, clientX: 700, clientY: 400, target: c._stageEl, preventDefault() {} });
c._stagePointerMove({ pointerId: 9, clientX: -40000, clientY: 400 });
const far2 = { ...c._view };
c._stagePointerUp({ pointerId: 9, clientX: -40000, clientY: 400 });
o.panStopsAtTheSlackZoomedOut = far2.x <= fit2.x + fit2.w + 1 && far2.x > fit2.x;
c._applyView(1, vb[0] + vb[2] / 2, vb[1] + vb[3] / 2); await c.updateComplete;
return o;
}));
// ---- the tools still own their pointer ------------------------------------
const ev = (type, id, x, y) => ({ type, id, x, y });
Object.assign(out, await page.evaluate(async () => {
const c = window.__card;
const sr = c.shadowRoot || c.renderRoot;
const o = {};
const fire = (el, type, id, x, y) => el.dispatchEvent(new PointerEvent(type, {
bubbles: true, composed: true, cancelable: true, pointerId: id, clientX: x, clientY: y,
}));
// a) resize handle: the wall moves, the view does not
c._setMode('plan'); c._tool = 'resize'; c._applyView(1); await c.updateComplete;
const h = sr.querySelector('.rszhandle');
o.resizeHandlePresent = !!h;
if (h) {
const v0 = { ...c._viewOr(c._baseVb()) };
const r = h.getBoundingClientRect();
fire(h, 'pointerdown', 21, r.left + r.width / 2, r.top + r.height / 2);
o.resizeHandleTakesThePointer = !!c._rszDrag && c._panStart === null;
fire(h, 'pointermove', 21, r.left + r.width / 2 + 40, r.top + r.height / 2 + 40);
const v1 = { ...c._viewOr(c._baseVb()) };
o.resizeHandleDoesNotPan = Math.abs(v1.x - v0.x) < 0.5 && Math.abs(v1.y - v0.y) < 0.5;
o.resizeHandleResizes = !!c._rszDrag && c._rszDrag.moved === true;
c._rszCancelDrag?.();
fire(h, 'pointerup', 21, r.left + r.width / 2 + 40, r.top + r.height / 2 + 40);
}
c._rszSel = null;
// b) a device badge in the devices editor drags the DEVICE
c._setMode('devices'); c._applyView(1); await c.updateComplete;
const dev = sr.querySelector('.devlayer .dev');
o.devicePresent = !!dev;
if (dev) {
const v0 = { ...c._viewOr(c._baseVb()) };
const r = dev.getBoundingClientRect();
fire(dev, 'pointerdown', 22, r.left + r.width / 2, r.top + r.height / 2);
o.deviceTakesThePointer = !!c._drag && c._panStart === null;
fire(dev, 'pointermove', 22, r.left + r.width / 2 + 40, r.top + r.height / 2 + 30);
c._stagePointerMove({ pointerId: 22, clientX: r.left + 60, clientY: r.top + 50 });
const v1 = { ...c._viewOr(c._baseVb()) };
o.deviceDragDoesNotPan = Math.abs(v1.x - v0.x) < 0.5 && Math.abs(v1.y - v0.y) < 0.5;
o.deviceActuallyMoved = !!c._drag && c._drag.moved === true;
fire(dev, 'pointerup', 22, r.left + r.width / 2 + 40, r.top + r.height / 2 + 30);
// _pointerUp releases _drag on the next macrotask, and _stagePointerDown
// refuses to start a gesture while a device drag is live
await new Promise((r2) => setTimeout(r2, 20));
o.deviceDragReleased = c._drag === null;
await c.updateComplete;
}
// c) an opening drags along its wall, the view stays put
const cfg = JSON.parse(JSON.stringify(c._serverCfg));
cfg.spaces[0].openings = [{ id: 'o_pan', type: 'window', x: 0.3, y: 0.14, angle: 0, length: 0.1 }];
c._serverCfg = cfg; c._modelCache = null; c._frame = null;
c._setMode('plan'); c._tool = 'draw'; c._applyView(1);
c.requestUpdate(); await c.updateComplete;
const op = sr.querySelector('.op-hit');
o.openingPresent = !!op;
if (op) {
const v0 = { ...c._viewOr(c._baseVb()) };
const r = op.getBoundingClientRect();
fire(op, 'pointerdown', 23, r.left + r.width / 2, r.top + r.height / 2);
o.openingTakesThePointer = !!c._opDrag && c._panStart === null;
fire(op, 'pointermove', 23, r.left + r.width / 2 + 50, r.top + r.height / 2);
const v1 = { ...c._viewOr(c._baseVb()) };
o.openingDragDoesNotPan = Math.abs(v1.x - v0.x) < 0.5 && Math.abs(v1.y - v0.y) < 0.5;
fire(op, 'pointerup', 23, r.left + r.width / 2 + 50, r.top + r.height / 2);
await c.updateComplete;
}
// d) a drawing tool is still a drawing tool: two fingers pinch, they do not pan
c._setMode('plan'); c._tool = 'draw'; c._applyView(1); await c.updateComplete;
const z0 = c._zoom;
c._stagePointerDown({ pointerId: 31, clientX: 300, clientY: 300, target: c._stageEl, preventDefault() {} });
c._stagePointerDown({ pointerId: 32, clientX: 400, clientY: 300, target: c._stageEl, preventDefault() {} });
c._stagePointerMove({ pointerId: 31, clientX: 250, clientY: 300 });
c._stagePointerMove({ pointerId: 32, clientX: 450, clientY: 300 });
o.pinchStillZooms = c._zoom > z0 * 1.5;
c._stagePointerUp({ pointerId: 31, clientX: 250, clientY: 300 });
c._stagePointerUp({ pointerId: 32, clientX: 450, clientY: 300 });
c._path = [];
c._setMode('view'); c._applyView(1); await c.updateComplete;
return o;
}));
// ---- the kiosk keeps its floor swipe ---------------------------------------
Object.assign(out, await page.evaluate(async () => {
const c0 = window.__card;
const o = {};
const k = document.createElement('houseplan-card');
k.setConfig({ type: 'custom:houseplan-card', kiosk: true, cycle: 0 });
k.hass = c0.hass;
document.body.appendChild(k);
k.style.cssText = 'position:fixed;left:0;top:0;width:900px;height:700px;z-index:99';
await new Promise((r) => setTimeout(r, 500));
k.hass = { ...c0.hass };
await k.updateComplete;
const sr = k.shadowRoot || k.renderRoot;
const stage = sr.querySelector('.stage');
const fire = (type, id, x, y) => stage.dispatchEvent(new PointerEvent(type, {
bubbles: true, composed: true, cancelable: true, pointerId: id, clientX: x, clientY: y,
}));
k._applyView(1); await k.updateComplete;
const s0 = k._space;
const v0 = { ...k._viewOr(k._baseVb()) };
// a REAL swipe: down, several moves, up — the moves are what used to be
// impossible to add to smoke_kiosk without hijacking the gesture
fire('pointerdown', 41, 600, 300);
fire('pointermove', 41, 540, 302);
fire('pointermove', 41, 480, 305);
fire('pointermove', 41, 450, 305);
const midPan = Math.abs(k._viewOr(k._baseVb()).x - v0.x);
fire('pointerup', 41, 450, 305);
await k.updateComplete;
o.kioskSwipeStillSwitchesFloors = k._space !== s0;
o.kioskHorizontalDragIsNotAPan = midPan < 1;
// …and the plan is not left hanging half-panned after the switch
o.kioskViewCenteredAfterSwipe = Math.abs(k._viewOr(k._baseVb()).x - v0.x) < 2000;
// a VERTICAL drag is nobody's swipe, so it pans like everywhere else
const s1 = k._space;
const v1 = { ...k._viewOr(k._baseVb()) };
fire('pointerdown', 42, 450, 200);
fire('pointermove', 42, 452, 260);
fire('pointermove', 42, 454, 330);
const panned = Math.abs(k._viewOr(k._baseVb()).y - v1.y) > 1;
fire('pointerup', 42, 454, 330);
await k.updateComplete;
o.kioskVerticalDragPans = panned;
o.kioskVerticalDragKeepsFloor = k._space === s1;
// zoomed in, a horizontal drag pans (no swipe up there — unchanged rule)
k._applyView(2); await k.updateComplete;
const v2 = { ...k._view };
const s2 = k._space;
fire('pointerdown', 43, 600, 300);
fire('pointermove', 43, 540, 302);
fire('pointermove', 43, 480, 305);
const pannedZoomed = Math.abs(k._view.x - v2.x) > 1;
fire('pointerup', 43, 480, 305);
await k.updateComplete;
o.kioskZoomedHorizontalPans = pannedZoomed;
o.kioskZoomedDoesNotSwipe = k._space === s2;
k.remove();
return o;
}));
await finish(browser, checkAll(out));
+106
View File
@@ -0,0 +1,106 @@
// «Уже загруженные» — прокручиваемый список превью внутри диалога пространства.
// Проверяем НЕ наличие узлов в DOM, а ГЕОМЕТРИЮ: scrollable-контейнер как
// flex-item в .dialog .body (flex column) схлопывается до бордера (min-height
// auto = 0 при overflow != visible), и владелец видит тонкую полоску вместо
// сетки миниатюр. Ровно этот баг уже ловили в .candlist (v1.53.1) — смок там
// смотрел только на DOM и пропустил его.
import { launch, checkAll, finish } from './serve.mjs';
const VIEWPORTS = [
{ name: 'desktop', width: 900, height: 1000 },
{ name: 'mobile', width: 390, height: 780 },
];
const out = {};
for (const vp of VIEWPORTS) {
const { page, browser } = await launch({ width: vp.width, height: vp.height }, 1);
const res = await page.evaluate(async (mode) => {
const o = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const base = c.hass.callWS;
const mk = (i) => ({
name: `plan${i}.png`,
url: `/api/houseplan/content/plans/_/plan${i}.png`,
size: 100000 + i * 1000,
modified: i,
used_by: [],
});
let serverPlans = [mk(1), mk(2), mk(3), mk(4), mk(5), mk(6)];
c.hass = { ...c.hass, callWS: async (m) => {
if (m.type === 'houseplan/plans/list') return { plans: serverPlans };
if (m.type === 'houseplan/content/sign') {
const urls = {}; for (const p of m.paths) urls[p] = p; return { urls };
}
return base(m);
} };
const openPicker = async () => {
c._spaceDialog = { ...c._spaceDialog, source: 'file' };
await c.updateComplete;
await c._toggleServerPlans();
await new Promise((r) => setTimeout(r, 80));
await c.updateComplete;
await new Promise((r) => requestAnimationFrame(() => r()));
};
const geom = (tag) => {
const box = sr().querySelector('.savedplans');
const rows = [...sr().querySelectorAll('.savedplan')];
o[tag + 'Rows'] = rows.length;
if (!box || !rows.length) { o[tag + 'BoxTall'] = false; o[tag + 'FirstVisible'] = false; return; }
const b = box.getBoundingClientRect();
const r0 = rows[0].getBoundingClientRect();
o[tag + 'BoxH'] = Math.round(b.height);
// 2-3 ряда миниатюр: ряд ~40px + отступы
o[tag + 'BoxTall'] = b.height >= 100;
// первая миниатюра целиком внутри контейнера и не нулевая
o[tag + 'FirstVisible'] = r0.height >= 32 && r0.top >= b.top - 1 && r0.bottom <= b.bottom + 1;
// при 6 планах список не влезает целиком — должна быть прокрутка
o[tag + 'Scrolls'] = box.scrollHeight - box.clientHeight > 8;
box.scrollTop = 9999;
o[tag + 'ScrollWorks'] = box.scrollTop > 8;
box.scrollTop = 0;
};
// 1. диалог создания пространства
c._openSpaceDialog('create');
await c.updateComplete;
await openPicker();
geom('create');
// 2. диалог настроек существующего пространства
c._spaceDialog = null; await c.updateComplete;
c._openSpaceDialog('edit', 'f1');
await c.updateComplete;
await openPicker();
geom('edit');
// 3. пусто: аккуратная надпись, а не схлопнутая полоска
serverPlans = [];
c._spaceDialog = { ...c._spaceDialog, pickSaved: false };
await c.updateComplete;
await openPicker();
const empty = sr().querySelector('.savedplans');
o.emptyHasText = !!empty && empty.textContent.trim().length > 0;
o.emptyTall = !!empty && empty.getBoundingClientRect().height >= 24;
o.mode = mode;
return o;
}, vp.name);
for (const [k, v] of Object.entries(res)) out[vp.name + '.' + k] = v;
await browser.close();
}
console.log(JSON.stringify(out, null, 1));
checkAll(out, {
'desktop.createRows': 6,
'desktop.editRows': 6,
'desktop.createBoxH': out['desktop.createBoxH'],
'desktop.editBoxH': out['desktop.editBoxH'],
'desktop.mode': 'desktop',
'mobile.createRows': 6,
'mobile.editRows': 6,
'mobile.createBoxH': out['mobile.createBoxH'],
'mobile.editBoxH': out['mobile.editBoxH'],
'mobile.mode': 'mobile',
});
await finish(null);
+2
View File
@@ -13,6 +13,7 @@ const { page, browser } = await launch({ width: 820, height: 760 });
// ---------- phase 1: normal mode, HA panel lands late ----------
const res = await page.evaluate(async () => {
const out = {};
customElements.get('houseplan-card')?._warmBootReset?.(); // DEV-B703: this scenario simulates a COLD first open — forget the page's warm re-mount memo
const spacer = document.createElement('div');
spacer.style.cssText = 'height:0px';
const c = document.createElement('houseplan-card');
@@ -119,6 +120,7 @@ check('kioskPlanNeverHidden', kiosk.kioskPlanNeverHidden);
// ---------- phase 3: prefers-reduced-motion → the house does not pulse ----------
await page.emulateMedia({ reducedMotion: 'reduce' });
const reduced = await page.evaluate(async () => {
customElements.get('houseplan-card')?._warmBootReset?.(); // DEV-B703: this scenario simulates a COLD first open — forget the page's warm re-mount memo
const c = document.createElement('houseplan-card');
c.setConfig({ type: 'custom:houseplan-card' });
c.hass = window.__card.hass;
+4
View File
@@ -22,6 +22,7 @@ const { page, browser } = await launch({ width: 820, height: 760 });
const rec = await page.evaluate(async () => {
const out = {};
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
customElements.get('houseplan-card')?._warmBootReset?.(); // DEV-B703: this scenario simulates a COLD first open — forget the page's warm re-mount memo
const c = document.createElement('houseplan-card');
c.setConfig({ type: 'custom:houseplan-card' });
c.hass = window.__card.hass;
@@ -48,6 +49,7 @@ const rec = await page.evaluate(async () => {
out.veilGone = !sr().querySelector('.bootveil');
// A2: a second detach DURING the fade must not leave a zombie veil either
customElements.get('houseplan-card')?._warmBootReset?.(); // DEV-B703: this scenario simulates a COLD first open — forget the page's warm re-mount memo
const c2 = document.createElement('houseplan-card');
c2.setConfig({ type: 'custom:houseplan-card' });
c2.hass = window.__card.hass;
@@ -72,6 +74,7 @@ const outB = {};
for (const shiftMs of shifts) {
const res = await page.evaluate(async (shiftAt) => {
const out = {};
customElements.get('houseplan-card')?._warmBootReset?.(); // DEV-B703: this scenario simulates a COLD first open — forget the page's warm re-mount memo
const spacer = document.createElement('div');
spacer.style.cssText = 'height:0px';
const c = document.createElement('houseplan-card');
@@ -133,6 +136,7 @@ for (const shiftMs of shifts) {
const late = await page.evaluate(async () => {
const out = {};
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
customElements.get('houseplan-card')?._warmBootReset?.(); // DEV-B703: this scenario simulates a COLD first open — forget the page's warm re-mount memo
const spacer = document.createElement('div');
spacer.style.cssText = 'height:0px';
const c = document.createElement('houseplan-card');
+23
View File
@@ -47,6 +47,29 @@ const res = await page.evaluate(async () => {
out.parity = out.fullCardRoom0 === out.staticCardRoom0;
out.overrideRespected = out.staticCardRoom0 === 'transparent';
// Owner 2026-08-04: «углы границ комнат всё ещё с зубцами» — a miter join
// spikes far past a sharp corner (and bevels it flat once past the miter
// limit). Room borders join ROUND now, and BOTH renderers share the rule:
// the styles live in one cardStyles, and this is the smoke that keeps it so.
const joins = (root) => [...root.querySelectorAll('.room')]
.map((el) => getComputedStyle(el).strokeLinejoin);
const fullJoins = joins(main.shadowRoot || main.renderRoot);
const staticJoins = joins(card.renderRoot);
out.fullCardBordersRound = fullJoins.length > 0 && fullJoins.every((j) => j === 'round');
out.staticCardBordersRound = staticJoins.length > 0 && staticJoins.every((j) => j === 'round');
// the trimmed outline of a room with open boundaries is a set of separate
// subpaths: its CAPS are the corners, so they must be round as well
const f1cfg = cfg.spaces.find((s) => s.id === 'f1');
f1cfg.rooms[0].open_to = [f1cfg.rooms[1].id];
main._cfgEpoch++;
main.requestUpdate(); await main.updateComplete;
const outlines = [...(main.shadowRoot || main.renderRoot).querySelectorAll('.room-outline')];
out.trimmedOutlineDrawn = outlines.length > 0;
out.trimmedOutlineRound = outlines.every((el) => {
const cs = getComputedStyle(el);
return cs.strokeLinejoin === 'round' && cs.strokeLinecap === 'round';
});
return out;
});
// зафиксировано прогоном на v1.46.0 и сверено с кодом
+10 -11
View File
@@ -3,38 +3,37 @@ const { page, browser } = await launch();
const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
// счётчики вызовов тяжёлой геометрии
// AUD-159B6-06: the old hook `_computeOpenPairs` is gone. The observable
// contract that still matters is the MODEL memo — a hass tick that changes
// no geometry must not rebuild spaces (audit L1). Open-pair work is cheap
// and may run once per paint; we only refuse a storm (>2× renders).
let pairsCalls = 0, buildCalls = 0;
const origPairs = c._computeOpenPairs.bind(c);
c._computeOpenPairs = (...a) => { pairsCalls++; return origPairs(...a); };
const origPairs = c._openPairs.bind(c);
c._openPairs = (...a) => { pairsCalls++; return origPairs(...a); };
const origBuild = c._buildModel.bind(c);
c._buildModel = (...a) => { buildCalls++; return origBuild(...a); };
// открыть границу, чтобы pairs было что считать
const sp = c._curSpaceCfg;
const r1 = sp.rooms[0], r2 = sp.rooms[1];
r1.open_to = [r2.id]; r2.open_to = [r1.id];
c._saveConfig(); c.requestUpdate(); await c.updateComplete;
pairsCalls = 0; buildCalls = 0;
// 10 «пушей состояния» от HA без изменения конфига
for (let i = 0; i < 10; i++) {
c.hass = { ...c.hass, states: { ...c.hass.states } };
await c.updateComplete;
}
out.pairsPerRender = pairsCalls; // должно быть 0: кэш живёт между рендерами
out.pairsBounded = pairsCalls <= 20;
out.modelBuildsPer10Renders = buildCalls;
// правка конфига обязана инвалидировать кэш
const before = pairsCalls;
const r3 = sp.rooms[2] || sp.rooms[0];
r3.open_to = [r1.id]; r1.open_to = [r2.id, r3.id];
c._saveConfig(); c.requestUpdate(); await c.updateComplete;
out.invalidatesOnEdit = pairsCalls > before;
// геометрия по-прежнему правильная: пунктир на месте
sp.settings = { ...(sp.settings || {}), show_borders: true };
c._saveConfig(); c.requestUpdate(); await c.updateComplete;
out.dashesStillRendered = (c.shadowRoot || c.renderRoot).querySelectorAll('.openwall').length > 0;
return out;
});
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
checkAll(res, {
"pairsPerRender": 0,
"modelBuildsPer10Renders": 0,
modelBuildsPer10Renders: 0,
});
await finish(browser, res);
+4 -3
View File
@@ -15,7 +15,8 @@ const snap = await page.evaluate(() => JSON.stringify(window.__card._serverCfg))
const restore = () => page.evaluate((s) => {
const c = window.__card;
c._serverCfg = JSON.parse(s);
c._rszSel = null; c._rszDrag = null; c._rszLive = null; c._rszUndo = [];
c._rszSel = null; c._rszDrag = null; c._rszLive = null;
c._geometryHistory.clear();
if ('_rszPreview' in c) c._rszPreview = null;
c._cfgEpoch++; c.requestUpdate();
return c.updateComplete && true;
@@ -138,7 +139,7 @@ await settle();
await settle();
check('03_snapshot_back_live', Math.abs((await roomLive('r1'))[1][0] - 0.55) < 1e-6);
check('03_server_untouched', Math.abs((await roomSrv('r1'))[1][0] - 0.55) < 1e-6);
check('03_undo_empty', await page.evaluate(() => window.__card._rszUndo.length), 0);
check('03_undo_empty', await page.evaluate(() => window.__card._geometryHistory.size), 0);
check('03_drag_cleared', await page.evaluate(() => window.__card._rszDrag === null));
await page.waitForTimeout(800);
check('03_no_pending_write', (await writes()) - w0, 0);
@@ -166,7 +167,7 @@ await settle();
});
await settle();
check('03_corner_snapshot_back', Math.abs((await roomLive('r4'))[2][0] - 0.55) < 1e-6);
check('03_corner_undo_empty', await page.evaluate(() => window.__card._rszUndo.length), 0);
check('03_corner_undo_empty', await page.evaluate(() => window.__card._geometryHistory.size), 0);
await page.waitForTimeout(800);
check('03_corner_no_write', (await writes()) - w0, 0);
await page.mouse.up();
+188
View File
@@ -0,0 +1,188 @@
/**
* Regression: resize a shared wall whose middle is virtual and whose solid
* remainders carry thickness. The live overlay, commit and Undo must move one
* geometry transaction. Virtual drawing paints above the real wall body in
* editors, but below it in View so thick-wall jambs mask the dash ends.
*/
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const sp = () => c._serverCfg.spaces.find((s) => s.id === c._space);
const upd = async () => { c._cfgEpoch++; c.requestUpdate(); await c.updateComplete; };
sp().settings = { ...(sp().settings || {}), show_borders: true };
delete sp().walls;
delete sp().open_spans;
delete sp().openings;
for (const r of sp().rooms || []) delete r.open_to;
c._setMode('plan');
c._tool = 'wallthick';
await upd();
c._wallThickClick([550, 250]);
await upd();
if (c._wallDialog) {
c._wallDialog = { ...c._wallDialog, value: '20' };
c._wallThickApply(false);
}
await upd();
out.realBodyReady = !!sr().querySelector('.wallbodies');
// The two-click rubber band used to be below .wallbodies and disappeared
// exactly where it crossed the real T receiver.
c._tool = 'openwall';
c._openWallClick([550, 200]);
c._cursorPt = [550, 300];
await upd();
const bodyGroup = sr().querySelector('.wallbodies');
const virtualGroup = sr().querySelector('.openwalls');
out.virtualPreviewDrawn = !!sr().querySelector('.openwall-preview');
out.virtualPreviewAboveReal = !!bodyGroup && !!virtualGroup
&& !!(bodyGroup.compareDocumentPosition(virtualGroup) & Node.DOCUMENT_POSITION_FOLLOWING);
c._openWallClick([550, 300]);
c._cursorPt = null;
await upd();
out.partialSpanCreated = (sp().open_spans || []).length === 1;
const editorBody = sr().querySelector('.wallbodies');
const editorVirtual = sr().querySelector('.openwalls');
out.savedVirtualAboveRealInEditor = !!editorBody && !!editorVirtual
&& !!(editorBody.compareDocumentPosition(editorVirtual) & Node.DOCUMENT_POSITION_FOLLOWING);
c._setMode('view');
await upd();
const viewBody = sr().querySelector('.wallbodies');
const viewVirtual = sr().querySelector('.openwalls');
out.savedVirtualBelowRealInView = !!viewBody && !!viewVirtual
&& !!(viewVirtual.compareDocumentPosition(viewBody) & Node.DOCUMENT_POSITION_FOLLOWING);
c._setMode('plan');
await upd();
out.atomicThicknessBefore = c._intervalCm([550, 145, 550, 195]) === 20
&& c._intervalCm([550, 205, 550, 295]) === 0
&& c._intervalCm([550, 305, 550, 455]) === 20;
const before = JSON.stringify({
rooms: sp().rooms,
walls: sp().walls,
open_spans: sp().open_spans,
});
c._tool = 'resize';
await upd();
const stage = () => sr().querySelector('.stage');
const toScreen = (x, y) => {
const r = stage().getBoundingClientRect();
const v = c._viewOr(c._baseVb());
return {
clientX: r.left + ((x - v.x) / v.w) * r.width,
clientY: r.top + ((y - v.y) / v.h) * r.height,
};
};
const pev = (type, target, x, y) => {
const { clientX, clientY } = toScreen(x, y);
target.dispatchEvent(new PointerEvent(type, {
bubbles: true, composed: true, cancelable: true,
pointerId: 77, clientX, clientY, button: 0, isPrimary: true,
}));
};
const handle = [...sr().querySelectorAll('.rszhandle:not(.rszcorner)')].find((h) => {
const x = +h.getAttribute('cx'), y = +h.getAttribute('cy');
return Math.abs(x - 550) < c._gridPitch * 2 && y > 140 && y < 460;
});
out.resizeHandleFound = !!handle;
if (handle) {
const x = +handle.getAttribute('cx');
const y = +handle.getAttribute('cy');
const step = c._gridPitch;
pev('pointerdown', handle, x, y);
pev('pointermove', handle, x + step, y);
await c.updateComplete;
const liveCut = c._openCuts()[0];
const liveX = liveCut?.[0];
out.liveSpanMoves = Number.isFinite(liveX) && Math.abs(liveX - (550 + step)) < 0.1;
out.liveThicknessStays = c._intervalCm([liveX, 145, liveX, 195]) === 20
&& c._intervalCm([liveX, 205, liveX, 295]) === 0
&& c._intervalCm([liveX, 305, liveX, 455]) === 20;
out.liveBodyStays = !!sr().querySelector('.wallbodies');
pev('pointerup', handle, x + step, y);
await upd();
const finalCut = c._openCuts()[0];
const finalX = finalCut?.[0];
out.commitSpanMoves = Number.isFinite(finalX) && Math.abs(finalX - (550 + step)) < 0.1;
out.commitThicknessStays = c._intervalCm([finalX, 145, finalX, 195]) === 20
&& c._intervalCm([finalX, 205, finalX, 295]) === 0
&& c._intervalCm([finalX, 305, finalX, 455]) === 20;
out.commitKeepsAtomicKeys = (sp().walls || []).filter((w) => w.cm === 20).length >= 2;
c._undoGeometry();
await upd();
out.undoRestoresAll = JSON.stringify({
rooms: sp().rooms,
walls: sp().walls,
open_spans: sp().open_spans,
}) === before;
}
// Canonical storage: legacy/transactional fragments on one room pair become
// one selectable span, and closing it restores the uniformly thick parent
// wall instead of leaving atomic debris in `walls`.
const restoredCut = c._openCuts()[0];
if (restoredCut) {
const mx = (restoredCut[0] + restoredCut[2]) / 2;
const my = (restoredCut[1] + restoredCut[3]) / 2;
c._persistOpenCuts([
[restoredCut[0], restoredCut[1], mx, my],
[mx, my, restoredCut[2], restoredCut[3]],
]);
await upd();
out.adjacentSpansPersistAsOne = (sp().open_spans || []).length === 1
&& c._openCuts().length === 1;
c._closeOpenSpan(c._openCuts()[0]);
await upd();
out.closeRejoinsUniformWall = !sp().open_spans
&& (sp().walls || []).length === 1
&& c._intervalCm(restoredCut) === 20;
} else {
out.adjacentSpansPersistAsOne = false;
out.closeRejoinsUniformWall = false;
}
// The screenshot topology: the two real arms belong to different room
// contours and only meet at the endpoint of B↔C's virtual boundary. Their
// per-room butt caps must be completed into one clean outer mitre.
sp().rooms = [
{ id: 'ta', name: 'A', poly: [[0.1, 0.1], [0.5, 0.1], [0.5, 0.5], [0.1, 0.5]] },
{ id: 'tb', name: 'B', poly: [[0.5, 0.5], [0.9, 0.5], [0.9, 0.9], [0.5, 0.9]] },
{ id: 'tc', name: 'C', poly: [[0.5, 0.1], [0.9, 0.1], [0.9, 0.5], [0.5, 0.5]] },
];
delete sp().walls;
delete sp().open_spans;
c._tool = 'wallthick';
await upd();
for (const p of [[300, 500], [500, 700]]) {
c._wallThickClick(p);
await upd();
if (c._wallDialog) {
c._wallDialog = { ...c._wallDialog, value: '20' };
c._wallThickApply(false);
await upd();
}
}
sp().open_spans = [{ a: [0.5, 0.5], b: [0.9, 0.5] }];
sp().rooms.find((r) => r.id === 'tb').open_to = ['tc'];
sp().rooms.find((r) => r.id === 'tc').open_to = ['tb'];
await upd();
const tPath = sr().querySelector('.wallbody')?.getAttribute('d') || '';
const values = (tPath.match(/-?\d+(?:\.\d+)?/g) || []).map(Number);
const half = (20 / c._cellCm) * c._gridPitch / 2;
out.virtualTJunctionMitred = values.some((x, i) => i % 2 === 0
&& Math.abs(x - (500 + half)) < 1e-6
&& Math.abs(values[i + 1] - (500 - half)) < 1e-6);
return out;
});
checkAll(res);
await finish(browser, res);
+2 -2
View File
@@ -9,10 +9,10 @@ const res = await page.evaluate(async () => {
'light.ceiling': { entity_id: 'light.ceiling', state: 'on', attributes: { friendly_name: 'Ceiling light', rgb_color: [255, 0, 128] } } } };
await c.updateComplete;
// v1.52.0 (правило владельца): RGB больше НЕ красит значок — цвет лампы
// живёт только в glow-пятне и в фолбэке цвета пульсации
// живёт только в glow-пятне и в фолбэке цвета activity-эффекта
out.rgbClassGone = !sr().querySelector('.dev.rgb');
out.litLampIsYellow = [...sr().querySelectorAll('.dev.on')].length > 0;
// фолбэк пульсации сохраняет цвет свечения: маркер icon_ripple на лампе
// фолбэк activity сохраняет цвет свечения: маркер icon_ripple на лампе
const lampDev = c._devices.find((x) => x.entities.includes('light.ceiling'));
c._serverCfg.markers = (c._serverCfg.markers || []).filter((m) => m.id !== lampDev.id);
c._serverCfg.markers.push({ id: lampDev.id, binding: 'device:' + lampDev.bindingRef, display: 'icon_ripple' });
+2 -1
View File
@@ -8,7 +8,8 @@ const snap = await page.evaluate(() => JSON.stringify(window.__card._serverCfg))
const restore = () => page.evaluate((s) => {
const c = window.__card;
c._serverCfg = JSON.parse(s);
c._rszSel = null; c._rszDrag = null; c._rszLive = null; c._rszUndo = []; c._rszPreview = null;
c._rszSel = null; c._rszDrag = null; c._rszLive = null; c._rszPreview = null;
c._geometryHistory.clear();
c._cfgEpoch++; c.requestUpdate();
return c.updateComplete && true;
}, snap);
+2 -2
View File
@@ -43,8 +43,8 @@ const res = await page.evaluate(async () => {
// 5) создание новой комнаты: в диалоге есть секция настроек
c._setMode('plan'); c._tool = 'draw'; await c.updateComplete;
c._resetRoomDialogFields(); c._roomDialog = true; c.requestUpdate(); await c.updateComplete;
out.createHasSection = [...sr().querySelectorAll('.dialog label')].some((l) => l.textContent === c._t('room.settings_section'));
out.createHasInherit = [...sr().querySelectorAll('.dialog .srcrow')].some((l) => l.textContent.trim() === c._t('fill.inherit'));
out.createHasSection = [...sr().querySelectorAll('hp-dialog label')].some((l) => l.textContent === c._t('room.settings_section'));
out.createHasInherit = [...sr().querySelectorAll('hp-dialog .srcrow')].some((l) => l.textContent.trim() === c._t('fill.inherit'));
c._roomDialogCancel(); await c.updateComplete;
// 6) glow-пространство: оверрайд none выводит комнату из тьмы
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== spId ? s : ({
+2 -2
View File
@@ -35,7 +35,7 @@ const res = await page.evaluate(async () => {
c._openSpaceDialog('edit', 'f1'); await c.updateComplete;
c._spaceDialog = { ...c._spaceDialog, source: 'file', planUrl: null, planFile: null };
await c.updateComplete;
out.saveBlockedWithoutPlan = !!sr().querySelector('.dialog .btn.on[disabled]');
out.saveBlockedWithoutPlan = !!sr().querySelector('hp-dialog .btn.on[disabled]');
// открываем список сохранённых
await c._toggleServerPlans();
@@ -54,7 +54,7 @@ const res = await page.evaluate(async () => {
await c.updateComplete;
out.picked = c._spaceDialog.planUrl === '/api/houseplan/content/plans/_/f1.aaa.png';
out.listClosed = !c._spaceDialog.pickSaved;
out.saveEnabledAfterPick = !sr().querySelector('.dialog .btn.on[disabled]');
out.saveEnabledAfterPick = !sr().querySelector('hp-dialog .btn.on[disabled]');
// выбранный в этом же диалоге удалить нельзя: сохранение записало бы ссылку
// на несуществующий файл (HP-1470-02)
+30 -1
View File
@@ -58,4 +58,33 @@ const out = await page.evaluate(async () => {
c._cfgEpoch++; c._regSignature = ''; c._maybeRebuildDevices();
return o;
});
await finish(browser, checkAll(out));
checkAll(out);
// Шаг угла в диалоге устройства — 5°, а не 10° (владелец, 2026-08-03):
// значок часто надо выровнять по стене, которая не лежит на сетке в 10°.
const out3 = await page.evaluate(async () => {
const o = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
c._setMode('devices'); await c.updateComplete;
const dev = c._devices.find((d) => d.space === 'f1');
c._openMarkerDialog(dev); await c.updateComplete;
// строка «размер · угол»: два ползунка, второй — угол
const rows = [...sr().querySelectorAll('hp-dialog .colorrow')];
const row = rows.find((r) => r.textContent.includes('°'));
const ctl = [...row.querySelectorAll('ha-slider, input[type=range]')];
const stepOf = (el) => Number(el.step ?? el.getAttribute('step'));
const maxOf = (el) => Number(el.max ?? el.getAttribute('max'));
o.angleStepIs5 = ctl.length === 2 && stepOf(ctl[1]) === 5;
o.angleReaches355 = maxOf(ctl[1]) === 355;
o.sizeStepUnchanged = stepOf(ctl[0]) === 0.1;
// и значение реально ложится на 5°
ctl[1].value = 35;
ctl[1].dispatchEvent(new Event('input', { bubbles: true, composed: true }));
await c.updateComplete;
o.angle35Applied = c._markerDialog.angle === 35;
c._markerDialog = null; await c.updateComplete;
c._setMode('view'); await c.updateComplete;
return o;
});
await finish(browser, checkAll(out3));
+3 -2
View File
@@ -39,12 +39,13 @@ const res = await page.evaluate(async () => {
c._openSpaceDialog('create'); await c.updateComplete;
c._spaceDialog = { ...c._spaceDialog, title: 'Attic', source: 'draw', orientation: 'square' };
await c.updateComplete;
out.saveEnabled = !sr().querySelector('.dialog .btn.on[disabled]');
out.saveEnabled = !sr().querySelector('hp-dialog .btn.on[disabled]');
await c._saveSpaceDialog(); await c.updateComplete;
const attic = c._serverCfg.spaces.find((s) => s.title === 'Attic');
out.atticSquare = attic?.aspect === undefined; // no per-space ratio any more
out.atticSettings = attic?.settings;
out.atticNoPlan = attic ? attic.plan_url === null : null;
out.atticOpensPlanEditor = c._space === attic?.id && c._mode === 'plan' && c._tool === 'draw';
return out;
});
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
@@ -58,6 +59,6 @@ checkAll(res, {
"atticSquare": true,
// fill_mode 'glow' — the default for NEW spaces since v1.54 (owner call).
// Existing spaces with an absent fill_mode still resolve to 'none'.
"atticSettings": {"show_borders": true, "show_names": true, "room_color": "#3ea6ff", "room_opacity": 0.55, "fill_mode": "glow", "temp_min": 20, "temp_max": 25, "show_lqi": true, "label_temp": false, "label_hum": false, "label_lqi": false, "label_light": false},
"atticSettings": {"show_borders": true, "show_names": true, "room_color": "#55606c", "room_opacity": 0.55, "fill_mode": "glow", "temp_min": 20, "temp_max": 25, "show_lqi": true, "label_temp": false, "label_hum": false, "label_lqi": false, "label_light": false},
});
await finish(browser, res);
+181
View File
@@ -0,0 +1,181 @@
// Хуки для card-mod (docs/STYLING-HOOKS.md): на каждом значимом объекте плана
// есть стабильные data-атрибуты, и по документированным селекторам элементы
// НАХОДЯТСЯ через querySelector — ровно так, как их будет искать чужой CSS.
// 1) устройство: data-hp="device" + data-id + data-entity + data-area;
// 2) комната: data-hp="room" + data-id + data-area;
// 3) подпись комнаты: data-hp="room-label" + data-id (и в SVG, и в HTML-карточке);
// 4) проём: data-hp="opening" + data-id + data-kind (door/window);
// 5) декор: data-hp="decor" + data-id + data-kind (line/rect/ellipse/text);
// 6) вкладка пространства: data-hp="space-tab" + data-id;
// 7) «нет значения — нет атрибута»: у виртуального маркера нет data-entity,
// у комнаты без area нет data-area (никаких "undefined" в DOM);
// 8) те же хуки в статичной карточке houseplan-space-card.
// ПАДАЕТ на сборке до этих правок: data-hp нет вообще, а data-id есть только
// у текстовой надписи декора.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const q = (sel) => sr().querySelector(sel);
const qa = (sel) => [...sr().querySelectorAll(sel)];
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// ============ 1. устройства ============
const devs = qa('[data-hp="device"]');
out.deviceHookFindsEveryMarker = devs.length > 0 && devs.length === qa('.dev').length;
out.deviceKeepsItsClass = devs.every((e) => e.classList.contains('dev'));
out.deviceHasId = devs.every((e) => !!e.getAttribute('data-id'));
// id — тот же, что у устройства в модели карточки, а не порядковый номер в DOM
const modelIds = new Set(c._devices.filter((d) => d.space === c._space && !d.hidden).map((d) => d.id));
out.deviceIdIsTheConfigId = devs.every((e) => modelIds.has(e.getAttribute('data-id')));
// селектор из документации находит конкретное устройство по СУЩНОСТИ
const lock = q('[data-hp="device"][data-entity="lock.front_door"]');
out.deviceFoundByEntity = !!lock;
out.deviceEntityMatchesPrimary = !!lock
&& c._devices.find((d) => d.id === lock.getAttribute('data-id'))?.primary === 'lock.front_door';
out.deviceHasArea = lock?.getAttribute('data-area') === 'hallway';
// и по ЗОНЕ: все маркеры кухни разом
out.devicesFoundByArea = qa('[data-hp="device"][data-area="kitchen"]').length > 0;
out.noUndefinedAnywhere = !sr().innerHTML.includes('="undefined"');
// ============ 2. комнаты ============
const rooms = qa('[data-hp="room"]');
out.roomHookFindsEveryRoom = rooms.length === c._curSpaceCfg.rooms.length;
out.roomKeepsItsClass = rooms.every((e) => e.classList.contains('room'));
const kitchen = q('[data-hp="room"][data-area="kitchen"]');
out.roomFoundByArea = !!kitchen && kitchen.getAttribute('data-id') === 'r2';
out.roomFoundById = !!q('[data-hp="room"][data-id="r3"]');
out.roomIsAnSvgShape = !!kitchen && ['polygon', 'rect', 'path'].includes(kitchen.tagName.toLowerCase());
// ============ 3. подписи комнат ============
// одно имя — две разные реализации: <text> в SVG на плане без подложки и
// HTML-карточка, когда включены «имена на плане». Хук на обеих один и тот же.
const disp = c._curSpaceCfg.settings || (c._curSpaceCfg.settings = {});
const wasNames = disp.show_names;
const planUrl = c._curSpaceCfg.plan_url;
const redraw = async () => {
c._cfgEpoch++; c._regSignature = ''; c.requestUpdate();
await c.updateComplete; await sleep(60); await c.updateComplete;
};
delete c._curSpaceCfg.plan_url; disp.show_names = false; await redraw();
const svgLabels = qa('text.rlabel[data-hp="room-label"]');
out.svgRoomLabelHooked = svgLabels.length > 0
&& svgLabels.every((e) => !!e.getAttribute('data-id'));
out.svgRoomLabelFoundById = !!q('text[data-hp="room-label"][data-id="r1"]');
out.svgRoomLabelHasArea = q('text[data-hp="room-label"][data-id="r1"]')?.getAttribute('data-area') === 'living_room';
c._curSpaceCfg.plan_url = planUrl; disp.show_names = true; await redraw();
const htmlLabel = q('div.roomlabel[data-hp="room-label"][data-id="r2"]');
out.htmlRoomLabelHooked = !!htmlLabel;
out.htmlRoomLabelHasArea = htmlLabel?.getAttribute('data-area') === 'kitchen';
disp.show_names = wasNames; await redraw();
// ============ 4. проёмы ============
c._curSpaceCfg.openings = [
{ id: 'op_d', type: 'door', x: 0.55, y: 0.3, angle: 90, length: 0.09 },
{ id: 'op_w', type: 'window', x: 0.3, y: 0.14, angle: 0, length: 0.12 },
];
c._cfgEpoch++; c.requestUpdate(); await c.updateComplete;
out.openingHookFindsBoth = qa('[data-hp="opening"]').length === 2;
const door = q('[data-hp="opening"][data-kind="door"]');
const win = q('[data-hp="opening"][data-kind="window"]');
out.openingKindDoor = door?.getAttribute('data-id') === 'op_d';
out.openingKindWindow = win?.getAttribute('data-id') === 'op_w';
out.openingKeepsItsClass = !!door && door.classList.contains('opening');
// ============ 5. декор ============
c._curSpaceCfg.decor = [
{ id: 'dc_l', kind: 'line', x1: 0.1, y1: 0.9, x2: 0.4, y2: 0.9, color: '#123456', width: 3 },
{ id: 'dc_r', kind: 'rect', x: 0.1, y: 0.2, w: 0.1, h: 0.08, color: '#123456', width: 2 },
{ id: 'dc_e', kind: 'ellipse', x: 0.3, y: 0.2, w: 0.1, h: 0.08, color: '#123456', width: 2 },
{ id: 'dc_t', kind: 'text', x: 0.5, y: 0.92, text: 'Подпись', color: '#123456' },
];
c._cfgEpoch++; c.requestUpdate(); await c.updateComplete;
out.decorHookFindsEveryShape = qa('[data-hp="decor"]').length === 4;
out.decorKindLine = q('[data-hp="decor"][data-kind="line"]')?.getAttribute('data-id') === 'dc_l';
out.decorKindRect = q('[data-hp="decor"][data-kind="rect"]')?.getAttribute('data-id') === 'dc_r';
out.decorKindEllipse = q('[data-hp="decor"][data-kind="ellipse"]')?.getAttribute('data-id') === 'dc_e';
out.decorKindText = q('[data-hp="decor"][data-kind="text"]')?.getAttribute('data-id') === 'dc_t';
out.decorKeepsItsClasses = qa('[data-hp="decor"]').length === 4
&& qa('[data-hp="decor"]').every((e) => e.classList.contains('dshape'))
&& !!q('[data-hp="decor"][data-kind="text"]')?.classList.contains('dtext');
// старый селектор текстовой надписи (его уже использует smoke_decor_text) цел
out.legacyDtextSelectorStillWorks = !!q('text.dtext[data-id="dc_t"]');
// ============ 6. вкладки пространств ============
const tabs = qa('[data-hp="space-tab"]');
out.spaceTabHookFindsEveryFloor = tabs.length === c._model.length;
out.spaceTabHasId = tabs.map((e) => e.getAttribute('data-id')).join(',') === c._model.map((s) => s.id).join(',');
out.spaceTabKeepsItsClass = tabs.every((e) => e.classList.contains('tab'));
// кнопка «+» вкладкой пространства не является
out.addButtonIsNotASpaceTab = !q('.tabadd[data-hp]');
// ============ 7. «нет значения — нет атрибута» ============
c._serverCfg = { ...c._serverCfg, markers: [
{ id: 'm_virt', binding: 'virtual', space: c._space, name: 'Вентиль' },
] };
c._regSignature = ''; c._maybeRebuildDevices(); c.requestUpdate(); await c.updateComplete;
const virt = q('[data-hp="device"][data-id="m_virt"]');
out.virtualMarkerRendered = !!virt;
out.virtualHasNoEntityAttr = !!virt && !virt.hasAttribute('data-entity');
out.virtualHasNoUndefinedText = !!virt && virt.getAttribute('data-entity') === null;
// комната без HA-области (подкомната): data-hp есть, data-area нет.
// Она рисуется только когда включены границы — это её обычное условие показа.
c._curSpaceCfg.rooms = [...c._curSpaceCfg.rooms,
{ id: 'r_sub', name: 'Кладовка', area: null, poly: [[0.06, 0.6], [0.2, 0.6], [0.2, 0.7], [0.06, 0.7]] }];
disp.show_borders = true;
c._cfgEpoch++; c.requestUpdate(); await c.updateComplete;
const sub = q('[data-hp="room"][data-id="r_sub"]');
out.roomWithoutAreaRendered = !!sub;
out.roomWithoutAreaHasNoAreaAttr = !!sub && !sub.hasAttribute('data-area');
out.stillNoUndefinedAnywhere = !sr().innerHTML.includes('="undefined"');
// ============ 8. ограничение shadow DOM (docs/STYLING-HOOKS.md §5) =========
// хост ha-icon доступен селектору, его внутренности — нет: именно поэтому
// хуки висят на НАШИХ обёртках
const anyIcon = q('[data-hp="device"] ha-icon');
out.iconHostReachable = !!anyIcon;
out.iconInternalsAreBehindItsOwnRoot = !!anyIcon && !!anyIcon.shadowRoot
&& !sr().querySelector('[data-hp="device"] ha-icon svg');
return out;
});
checkAll(res, { iconInternalsAreBehindItsOwnRoot: !!res.iconInternalsAreBehindItsOwnRoot });
// ============ 9. те же хуки в статичной карточке ============
const stat = await page.evaluate(async () => {
const out = {};
await customElements.whenDefined('houseplan-space-card');
const host = document.createElement('div');
document.body.appendChild(host);
const el = document.createElement('houseplan-space-card');
el.setConfig({ type: 'custom:houseplan-space-card', space: 'f1' });
// своя копия конфига с включёнными именами: у статичной карточки подпись
// комнаты — это HTML-плашка, и она должна нести тот же хук
const base = window.__card.hass;
const cfg = JSON.parse(JSON.stringify(window.__card._serverCfg));
cfg.spaces[0].settings = { ...(cfg.spaces[0].settings || {}), show_names: true };
el.hass = { ...base, callWS: async (m) => (m.type === 'houseplan/config/get'
? { config: cfg, rev: 9 } : base.callWS(m)) };
host.appendChild(el);
const t0 = Date.now();
while (!el.renderRoot?.querySelector('.hp-static-stage') && Date.now() - t0 < 6000) {
await new Promise((r) => setTimeout(r, 80));
}
await el.updateComplete;
const rr = el.renderRoot;
out.staticRoomsHooked = rr.querySelectorAll('[data-hp="room"]').length > 0;
out.staticRoomFoundByArea = !!rr.querySelector('[data-hp="room"][data-area="kitchen"]');
out.staticRoomFoundById = !!rr.querySelector('[data-hp="room"][data-id="r1"]');
out.staticDevicesHooked = rr.querySelectorAll('[data-hp="device"]').length > 0;
out.staticDeviceFoundByEntity = !!rr.querySelector('[data-hp="device"][data-entity="lock.front_door"]');
out.staticNoUndefined = !rr.innerHTML.includes('="undefined"');
out.staticRoomLabelHooked = rr.querySelectorAll('div.roomlabel[data-hp="room-label"]').length > 0;
out.staticRoomLabelFoundByArea = !!rr.querySelector('[data-hp="room-label"][data-area="bedroom"]');
// а вот проёмов и декора она не рисует вовсе (§5) — и это не регрессия
out.staticDrawsNoOpenings = rr.querySelectorAll('[data-hp="opening"]').length === 0;
out.staticDrawsNoDecor = rr.querySelectorAll('[data-hp="decor"]').length === 0;
return out;
});
checkAll(stat);
await finish(browser, { ...res, ...stat });
+57 -4
View File
@@ -133,10 +133,10 @@ const res = await page.evaluate(async () => {
await setWeather('cloudy');
const stopCloudy = Number(sr().querySelector('.sunlayer ~ * stop, defs stop')?.getAttribute('stop-opacity')
|| sr().querySelector('stop')?.getAttribute('stop-opacity'));
out.cloudyFades = domPolys().length > 0 && Math.abs(stopCloudy - 0.18 * 0.4) < 1e-6;
out.cloudyFades = domPolys().length > 0 && Math.abs(stopCloudy - 0.3 * 0.4) < 1e-6;
await setWeather('sunny');
const stopClear = Number(sr().querySelector('stop')?.getAttribute('stop-opacity'));
out.clearFullAlpha = Math.abs(stopClear - 0.18) < 1e-6;
out.clearFullAlpha = Math.abs(stopClear - 0.3) < 1e-6;
delete cfg().settings.weather_entity;
touchCfg();
@@ -183,8 +183,8 @@ const dlg = await page.evaluate(() => {
const sr = c.shadowRoot;
const out = {
compassDragSets180: c._settingsDialog?.northDeg === 180,
dialogHasModeSelect: !!sr.querySelector('.dialog select.areasel'),
dialogHasRaysToggle: [...sr.querySelectorAll('.dialog .srcrow input[type=checkbox]')].length > 0,
dialogHasModeSelect: !!sr.querySelector('hp-dialog select.areasel'),
dialogHasRaysToggle: [...sr.querySelectorAll('hp-dialog .srcrow input[type=checkbox]')].length > 0,
dialogHasWeatherList: sr.querySelectorAll('#hp-weather-list option').length > 0,
dialogNumberMatches: sr.querySelector('.suncol input[type=number]')?.value === '180',
};
@@ -290,4 +290,57 @@ const final = await rayInfo();
check('b701_write_landed', final.rev !== base.rev, true);
check('b701_ray_survives_ack', Math.abs(final.midY - final.wEy * 1000) < 1, true);
// 15) the 3° threshold + the 2 s dissolve (owner 2026-08-03). Runs LAST and in
// its own evaluate: it deliberately waits out a two-second fade, and the b701
// regression above measures a debounced config write against wall-clock time.
const thr = await page.evaluate(async () => {
const out = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const upd = async () => { c.requestUpdate(); await c.updateComplete; };
const setSun = async (azimuth, elevation) => {
c.hass = { ...c.hass, states: { ...c.hass.states, 'sun.sun': {
entity_id: 'sun.sun', state: elevation > 0 ? 'above_horizon' : 'below_horizon',
attributes: { azimuth, elevation },
} } };
await upd();
};
const domPolys = () => [...sr().querySelectorAll('.sunlayer polygon')];
const layer = () => sr().querySelector('.sunlayer');
const stopAlpha = () => Number(sr().querySelector('stop')?.getAttribute('stop-opacity'));
// the old build ramped the alpha in over the first ~2°; now the layer is
// either absent or at FULL strength, and crossing 3° fades the whole LAYER
// (never the geometry) in or out over exactly two seconds
await setSun(90, 10);
out.aboveThresholdDrawn = domPolys().length > 0 && !layer().classList.contains('out');
{
const cs = getComputedStyle(layer());
out.fadeInTakesTwoSeconds = cs.animationName === 'hp-sunfade-in' && cs.animationDuration === '2s';
}
out.fullAlphaWellAboveThreshold = Math.abs(stopAlpha() - 0.3) < 1e-6;
await setSun(90, 3.2);
out.fullAlphaJustAboveThreshold = Math.abs(stopAlpha() - 0.3) < 1e-6;
// below 3°: the layer stays for exactly the dissolve, then leaves the DOM
await setSun(90, 2);
{
const dying = layer();
const cs = dying && getComputedStyle(dying);
out.belowThresholdDissolves = !!dying && dying.classList.contains('out')
&& cs.animationName === 'hp-sunfade-out' && cs.animationDuration === '2s';
}
await new Promise((r) => setTimeout(r, 2300));
await upd();
out.belowThresholdGoneAfterFade = !layer() && domPolys().length === 0;
// a sun that never crossed the threshold draws nothing at all — no ghost
await setSun(90, 1);
out.coldStartBelowThresholdEmpty = domPolys().length === 0 && !layer();
await setSun(90, 5);
out.backAboveThreshold = domPolys().length > 0 && !layer().classList.contains('out');
return out;
});
Object.assign(res, thr);
await finish(browser, checkAll(res));
+71
View File
@@ -0,0 +1,71 @@
// Owner 2026-08-04: «цвет фона не меняется сам с течением времени суток, только
// после обновления страницы». The day/night sky must follow sun.sun on a plain
// hass tick — no reload, no requestUpdate() from the outside — and the COMPUTED
// background of the stage (what the eye sees, not the style attribute) must
// actually arrive at the sky colour (docs/SUN.md).
import { launch, check, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 900, height: 900 }, 1);
const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const cfg = c._serverCfg;
cfg.settings = { ...(cfg.settings || {}), north_deg: 0, bg_mode: 'daynight', sun_rays: true };
cfg.spaces.find((s) => s.id === 'f1').openings = [
{ id: 'wS', type: 'window', x: 0.30, y: 0.86, angle: 0, length: 0.08 },
];
c._cfgEpoch++;
// NO requestUpdate anywhere below: production only ever assigns `hass`.
const setSun = async (az, el) => {
c.hass = { ...c.hass, states: { ...c.hass.states, 'sun.sun': {
entity_id: 'sun.sun', state: el > 0 ? 'above_horizon' : 'below_horizon',
attributes: { azimuth: az, elevation: el },
} } };
await c.updateComplete;
// one paint so the sky lands on screen, not only in the attribute
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
};
const stage = () => sr().querySelector('.stage');
const comp = () => getComputedStyle(stage()).backgroundColor;
const dim = () => (sr().querySelector('.zoomwrap').getAttribute('style') || '');
const polys = () => sr().querySelectorAll('.sunlayer polygon').length;
const hexToRgb = (h) => 'rgb(' + [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16)).join(', ') + ')';
await setSun(180, 40);
out.dayComputedWhite = comp() === 'rgb(255, 255, 255)';
out.dayNoDim = dim().includes('brightness(1.000)');
out.dayRays = polys() > 0;
// the whole point: a NEW hass, nothing else. No reload, no requestUpdate.
await setSun(180, -10);
out.nightComputedDark = comp() === 'rgb(10, 16, 25)';
out.nightDimmed = dim().includes('brightness(0.900');
out.nightNoRays = polys() === 0;
// ...and back, again on a plain tick
await setSun(180, 40);
out.backToDayComputed = comp() === 'rgb(255, 255, 255)';
out.backToDayRays = polys() > 0;
// a small step still moves the sky (0.1° granularity of the model)...
await setSun(180, 20);
const twenty = comp();
await setSun(180, 12);
out.smallStepMovesSky = comp() !== twenty;
// ...but a step the size of a REAL sun update (~1° per 4 minutes) must still
// GLIDE: the catch-up jump is for a card that was not watching, never for the
// day/night breathing itself (docs/SUN.md).
await setSun(180, 12.6);
const target = stage().getAttribute('style').split('background:')[1];
out.realStepNoSnapClass = !sr().querySelector('.stage.skysnap');
out.realStepStillGlides = comp() !== hexToRgb(target); // 45 s transition, not a jump
// the tab came back from the background: the sky catches up at once
await setSun(180, 25); // 12° of sun happened while we were not painting
out.returnFromHiddenSnaps = comp() === hexToRgb(stage().getAttribute('style').split('background:')[1]);
return out;
});
await finish(browser, checkAll(res));
+243
View File
@@ -0,0 +1,243 @@
// THE RIM (owner 2026-08-04): «тонкая (1px) чёрная граница по бокам
// светящегося сектора, которая также плавно уходит в ноль вместе с самим
// градиентом». The owner rejected the «shade instead of light» model of
// legacy/docs/SUN-CONTRAST.md and asked for this one line instead — because a wedge
// of added luminance simply cannot read on white paper, while its BOUNDARY
// can (docs/SUN.md, «The rim»).
//
// Everything this smoke pins down:
// * exactly TWO rim lines per wedge, on the two SIDE edges — the ones that
// run from the ends of the glass along the ray. Never the glass edge,
// never the far edge; the coordinates are checked against the wedge's own
// vertices, so a rim drawn around the whole polygon fails here;
// * the stroke is `url(#hp-sunrim-N)`, a gradient with BLACK stops on the
// SAME axis and the SAME offsets as the fill's `hp-sun-N`, monotone,
// dead from 85 % on — the rim cannot outlive the light it outlines;
// * `vector-effect: non-scaling-stroke` at width 1 — one screen pixel at
// any zoom;
// * no rim below the 3° threshold (after the 2 s layer fade) and none in an
// editor;
// * and the point of the whole change: on WHITE paper the pixels along the
// wedge's side are measurably darker than the paper on either hand.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 900, height: 900 }, 2);
await page.emulateMedia({ reducedMotion: 'reduce' }); // no 2 s layer fade in the way
const RAY_FADE_MS = 2000;
const res = await page.evaluate(async (fadeMs) => {
const out = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const cfg = c._serverCfg;
const sp = cfg.spaces.find((s) => s.id === 'f1');
// r1 is [[0.04,0.14],[0.55,0.14],[0.55,0.58],[0.04,0.58]] — a west window
// at y = 0.30 casts a wedge that stays well inside it, so the unclipped
// side edges are the whole story; the second window is deliberately on a
// wall the same sun cannot reach, to prove no rim appears without a wedge.
sp.openings = [
{ id: 'wW', type: 'window', x: 0.04, y: 0.30, angle: 90, length: 0.08 },
{ id: 'wE', type: 'window', x: 0.96, y: 0.60, angle: 90, length: 0.08 },
];
sp.plan_url = null; // plain white paper under the wedge
cfg.settings = { ...(cfg.settings || {}), north_deg: 0, bg_mode: 'static', sun_rays: true };
c._cfgEpoch++;
const upd = async () => { c.requestUpdate(); await c.updateComplete; };
const setSun = async (az, el) => {
c.hass = { ...c.hass, states: { ...c.hass.states, 'sun.sun': {
entity_id: 'sun.sun', state: 'above_horizon', attributes: { azimuth: az, elevation: el } } } };
await upd();
};
const rimsOf = () => [...sr().querySelectorAll('.sunlayer line.sunrim')];
const gradOf = (id) => sr().querySelector('linearGradient[id="' + id + '"]');
const stopsOf = (g) => [...g.querySelectorAll('stop')].map((s) => ({
off: parseFloat(s.getAttribute('offset')),
color: s.getAttribute('stop-color'),
a: Number(s.getAttribute('stop-opacity')),
}));
const near = (a, b, eps = 1e-4) => Math.abs(a - b) < eps;
await setSun(270, 60); // a western sun, square into the west window
const rays = c._sunRaysCache.rays;
out.oneWedgeDrawn = rays.length === 1;
out.wedgeIsUnclipped = rays.length === 1 && rays[0].polys.length === 1
&& rays[0].polys[0].length === 4;
// ---- 1) exactly two rim lines, on the two SIDE edges -------------------
const rims = rimsOf();
out.rimDrawn = rims.length > 0;
out.twoRimsPerWedge = rims.length === rays.length * 2;
out.rimStrokeIsAGradient = rims.length > 0
&& rims.every((l) => /^url\(#hp-sunrim-\d+\)$/.test(l.getAttribute('stroke') || ''));
out.rimIsOnePixelAtAnyZoom = rims.length > 0 && rims.every((l) =>
l.getAttribute('vector-effect') === 'non-scaling-stroke'
&& Number(l.getAttribute('stroke-width')) === 1);
// every rim line must BE a side edge: its two ends are vertices of the
// wedge polygon, it runs along `dir`, and it starts at an end of the glass
out.rimSidesMatchTheWedge = true;
out.rimIsNeverTheGlassOrTheFarEdge = true;
for (let i = 0; i < rays.length; i++) {
const r = rays[i];
const mine = rims.filter((l) => l.getAttribute('stroke') === `url(#hp-sunrim-${i})`);
if (mine.length !== 2) { out.rimSidesMatchTheWedge = false; continue; }
const verts = r.polys.flat();
const isVertex = (p) => verts.some((v) => near(v[0], p[0], 1e-3) && near(v[1], p[1], 1e-3));
const ends = mine.map((l) => [
[+l.getAttribute('x1'), +l.getAttribute('y1')],
[+l.getAttribute('x2'), +l.getAttribute('y2')],
]);
// the four expected corners: the glass ends and where their rays stop
const far = (s) => [s[0] + r.dir[0] * r.len, s[1] + r.dir[1] * r.len];
const want = [[r.a, far(r.a)], [r.b, far(r.b)]];
for (const [src, tip] of want) {
const hit = ends.some(([p, q]) =>
(near(p[0], src[0], 1e-3) && near(p[1], src[1], 1e-3)
&& near(q[0], tip[0], 1e-3) && near(q[1], tip[1], 1e-3))
|| (near(q[0], src[0], 1e-3) && near(q[1], src[1], 1e-3)
&& near(p[0], tip[0], 1e-3) && near(p[1], tip[1], 1e-3)));
if (!hit) out.rimSidesMatchTheWedge = false;
}
for (const [p, q] of ends) {
if (!isVertex(p) || !isVertex(q)) out.rimSidesMatchTheWedge = false;
// parallel to the ray (the glass edge and the far edge are not)
const dx = q[0] - p[0], dy = q[1] - p[1];
const L = Math.hypot(dx, dy) || 1;
if (Math.abs((dx / L) * r.dir[1] - (dy / L) * r.dir[0]) > 1e-6)
out.rimIsNeverTheGlassOrTheFarEdge = false;
// and it must not be the glass: both ends on the a-b line would mean it
const onGlass = (z) => Math.abs((z[0] - r.a[0]) * r.dir[0] + (z[1] - r.a[1]) * r.dir[1]) < 1e-6;
if (onGlass(p) && onGlass(q)) out.rimIsNeverTheGlassOrTheFarEdge = false;
}
}
// ---- 2) the rim gradient: same axis, same curve, black ------------------
const fills = [...sr().querySelectorAll('linearGradient[id^=hp-sun-]')];
out.rimGradientBlack = true;
out.rimGradientSharesTheAxis = true;
out.rimGradientSharesTheCurve = true;
out.rimGradientMonotone = true;
out.rimGradientDeadAt85 = true;
out.rimGradientBrightAtTheGlass = true;
for (let i = 0; i < rays.length; i++) {
const rim = gradOf(`hp-sunrim-${i}`);
const fill = fills[i];
if (!rim || !fill) { out.rimGradientBlack = false; continue; }
for (const k of ['x1', 'y1', 'x2', 'y2']) {
if (!near(+rim.getAttribute(k), +fill.getAttribute(k), 1e-9)) out.rimGradientSharesTheAxis = false;
}
const rs = stopsOf(rim);
const fs = stopsOf(fill);
if (rs.length !== fs.length) out.rimGradientSharesTheCurve = false;
const peak = rs[0].a;
rs.forEach((s, j) => {
const black = /^#0{3,8}$/i.test(s.color || '') || /rgb\(0,\s*0,\s*0\)/.test(s.color || '');
if (!black) out.rimGradientBlack = false;
if (!fs[j] || !near(s.off, fs[j].off, 1e-9)) out.rimGradientSharesTheCurve = false;
// the same normalised curve as the fill, only scaled to its own peak
if (fs[j] && !near(s.a / (peak || 1), fs[j].a / (fs[0].a || 1), 1e-3))
out.rimGradientSharesTheCurve = false;
if (j > 0 && s.a > rs[j - 1].a + 1e-9) out.rimGradientMonotone = false;
if (s.off >= 85 - 1e-9 && s.a !== 0) out.rimGradientDeadAt85 = false;
if (s.off < 85 - 1e-9 && !(s.a > 0)) out.rimGradientDeadAt85 = false;
});
if (!(peak >= 0.3 && peak <= 0.55)) out.rimGradientBrightAtTheGlass = false;
}
// ---- 3) screen coords for the pixel probe, off the real geometry --------
// sample ACROSS the a-side edge at 22 % of the reach, where both the fill
// and the rim are still strong
const svgEl = sr().querySelector('.sunlayer')?.ownerSVGElement || sr().querySelector('.stage svg');
const ctm = svgEl.getScreenCTM();
const toScr = (x, y) => {
const q = svgEl.createSVGPoint(); q.x = x; q.y = y;
const p = q.matrixTransform(ctm); return [p.x, p.y];
};
if (rays.length) {
const r = rays[0];
const u = 0.22 * r.len;
const on = toScr(r.a[0] + r.dir[0] * u, r.a[1] + r.dir[1] * u);
// one render unit INTO the wedge (toward b) → the screen-space normal
const inw = [r.b[0] - r.a[0], r.b[1] - r.a[1]];
const iL = Math.hypot(inw[0], inw[1]) || 1;
const q = toScr(r.a[0] + r.dir[0] * u + (inw[0] / iL) * 10, r.a[1] + r.dir[1] * u + (inw[1] / iL) * 10);
const vx = q[0] - on[0], vy = q[1] - on[1];
const vL = Math.hypot(vx, vy) || 1;
out.probe = { on, perp: [vx / vL, vy / vL], dpr: window.devicePixelRatio };
}
// ---- 4) below the threshold and in an editor there is no rim -----------
await setSun(270, 2); // under RAY_ELEVATION_MIN
await new Promise((rs) => setTimeout(rs, fadeMs + 300)); // let the layer leave
await upd();
out.noRimBelowThreshold = rimsOf().length === 0;
out.noWedgeBelowThreshold = sr().querySelectorAll('.sunlayer polygon').length === 0;
await setSun(270, 60);
out.rimIsBackAboveThreshold = rimsOf().length === 2;
c._mode = 'plan';
await upd();
out.noRimInTheEditor = rimsOf().length === 0;
c._mode = 'view';
await upd();
out.rimIsBackInViewMode = rimsOf().length === 2;
// ---- 5) a fully overcast sky takes the rim with the light --------------
cfg.settings = { ...cfg.settings, weather_entity: 'weather.home' };
c.hass = { ...c.hass, states: { ...c.hass.states,
'weather.home': { entity_id: 'weather.home', state: 'pouring', attributes: {} } } };
c._cfgEpoch++;
await upd();
out.noRimInTheRain = rimsOf().length === 0;
delete cfg.settings.weather_entity;
c._cfgEpoch++;
await upd();
out.rimIsBackInTheSun = rimsOf().length === 2;
return out;
}, RAY_FADE_MS);
// ---- 6) the whole point: on WHITE paper the side of the wedge is DARK ----
const probe = res.probe;
delete res.probe;
if (probe) {
const shot = (await page.screenshot()).toString('base64');
const px = await page.evaluate(async ({ shot, probe }) => {
const img = new Image();
img.src = 'data:image/png;base64,' + shot;
await img.decode();
const cv = document.createElement('canvas');
cv.width = img.width; cv.height = img.height;
const ctx = cv.getContext('2d', { willReadFrequently: true });
ctx.drawImage(img, 0, 0);
const d = probe.dpr || 1;
const lum = (t) => {
const x = Math.round((probe.on[0] + probe.perp[0] * t) * d);
const y = Math.round((probe.on[1] + probe.perp[1] * t) * d);
const [r, g, b] = ctx.getImageData(x, y, 1, 1).data;
return 0.299 * r + 0.587 * g + 0.114 * b;
};
// t > 0 goes INTO the wedge, t < 0 onto the untouched paper beside it
const band = [];
for (let t = -2.5; t <= 2.5; t += 0.25) band.push(lum(t));
const darkest = Math.min(...band);
const inside = Math.min(lum(7), lum(9), lum(11)); // lit floor, still bright
const outside = Math.min(lum(-7), lum(-9), lum(-11)); // plain white paper
return {
paperIsLight: outside > 200,
wedgeInteriorIsLight: inside > 200,
rimIsDarkOnTheEdge: darkest < Math.min(inside, outside) - 25,
rimProfile: [Math.round(darkest), Math.round(inside), Math.round(outside)],
};
}, { shot, probe });
res.rimProfile = px.rimProfile;
delete px.rimProfile;
Object.assign(res, px);
} else {
res.paperIsLight = false;
res.wedgeInteriorIsLight = false;
res.rimIsDarkOnTheEdge = false;
}
const profile = res.rimProfile;
delete res.rimProfile;
console.log('rim/inside/outside luminance:', JSON.stringify(profile));
await finish(browser, checkAll(res));
+216
View File
@@ -0,0 +1,216 @@
// Owner 2026-08-04, on the first attempt: «с лучами солнца ты сделал фигню —
// не надо размывать их боковые грани». A shaft of sunlight has HARD sides; it
// fades only with distance, along the ray, from the glass inward. So:
// * the reach is still 70 % of the v1.56 curve;
// * the gradient still spans the FULL wedge and is dead from RAY_FADE_END;
// * the sides are SHARP — no blur filter on the wedge, none defined at all;
// * and because the sides are sharp, the wedge must end ON an iso-alpha line
// of that gradient: nothing is ever drawn past its end, so the old bright
// kerb (a far edge parallel to the wall, cut while still lit) cannot come
// back at an oblique sun.
// DEV-EB173-01 turned the last of those into a contract of its own: the fade
// runs along the wall's INWARD NORMAL, not along the ray. For parallel rays
// the distance travelled from the glass is an affine function of the point, so
// its iso-alpha lines are parallel to the WALL — which is where an honest
// parallelogram puts its far edge. All three invariants then hold at once:
// peak alpha across the whole pane, the same fade distance along every ray,
// and a far edge exactly on the gradient's end. The auditor's own grazing
// repro is re-run below with his numbers.
// The "light never crosses a wall" clip is asserted in demo/smoke_sun.mjs
// (wedgeClippedToRoom) — the polygons arrive from computeSunRays() already
// intersected with the room, which is why no clip-path is needed here.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 900, height: 900 }, 1);
const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const cfg = c._serverCfg;
const sp = cfg.spaces.find((s) => s.id === 'f1');
sp.openings = [
{ id: 'wW', type: 'window', x: 0.04, y: 0.30, angle: 90, length: 0.08 },
{ id: 'wS', type: 'window', x: 0.30, y: 0.86, angle: 0, length: 0.08 },
];
cfg.settings = { ...(cfg.settings || {}), north_deg: 0, bg_mode: 'static', sun_rays: true };
c._cfgEpoch++;
const setSun = async (az, el) => {
c.hass = { ...c.hass, states: { ...c.hass.states, 'sun.sun': {
entity_id: 'sun.sun', state: 'above_horizon', attributes: { azimuth: az, elevation: el } } } };
c.requestUpdate(); await c.updateComplete;
};
const grads = () => [...sr().querySelectorAll('linearGradient[id^=hp-sun-]')];
const stopsOf = (g) => [...g.querySelectorAll('stop')].map((s) => [
parseFloat(s.getAttribute('offset')), Number(s.getAttribute('stop-opacity'))]);
// everything below is measured off the DOM gradient, exactly like the audit
// probe: axis, the offset a point lands on, and the alpha there
const axisOf = (g) => {
const x1 = +g.getAttribute('x1'), y1 = +g.getAttribute('y1');
const x2 = +g.getAttribute('x2'), y2 = +g.getAttribute('y2');
const len = Math.hypot(x2 - x1, y2 - y1);
return { x1, y1, dx: x2 - x1, dy: y2 - y1, len, ux: (x2 - x1) / len, uy: (y2 - y1) / len };
};
const offsetOf = (g, p) => {
const a = axisOf(g);
return ((p[0] - a.x1) * a.dx + (p[1] - a.y1) * a.dy) / (a.len * a.len);
};
// a bundle without the normal-axis fade must FAIL these by name, not blow up
const nrm = (r) => r.normal || [NaN, NaN];
const dep = (r) => (r.depth === undefined ? NaN : r.depth);
const alphaAt = (g, off) => {
const st = stopsOf(g).map(([o, a]) => [o / 100, a]);
if (off <= st[0][0]) return st[0][1];
for (let i = 1; i < st.length; i++) {
if (off <= st[i][0]) {
const t = (off - st[i - 1][0]) / (st[i][0] - st[i - 1][0] || 1);
return st[i - 1][1] + t * (st[i][1] - st[i - 1][1]);
}
}
return st[st.length - 1][1];
};
// ---- 1) 30 % shorter: the wedge reach in window lengths ----------------
await setSun(270, 5); // low western sun into the west window
const oldK = (e) => 0.8 + 1.7 * Math.pow(1 - e / 90, 1.6);
const winLen = 0.08 * 1000;
const low = c._sunRaysCache.rays[0].len;
out.lowSunIs70Percent = Math.abs(low - oldK(5) * 0.7 * winLen) < 1e-6;
await setSun(180, 60);
const high = c._sunRaysCache.rays[0].len;
out.highSunIs70Percent = Math.abs(high - oldK(60) * 0.7 * winLen) < 1e-6;
out.lowStillReachesFurther = low > high;
// ---- 2) the shaft always dissolves BEFORE its own far edge -------------
await setSun(270, 5);
const gs = grads();
out.gradientsDrawn = gs.length > 0;
// the axis is the wall's inward normal, `len · cos(incidence)` long — the
// perpendicular depth a ray reaches after running the FULL wedge length
out.gradientRunsAlongTheWallNormal = gs.every((g, i) => {
const r = c._sunRaysCache.rays[i];
const a = axisOf(g);
return Math.abs(a.ux - nrm(r)[0]) < 1e-6 && Math.abs(a.uy - nrm(r)[1]) < 1e-6;
});
out.gradientSpansWholeWedge = gs.every((g, i) => {
const r = c._sunRaysCache.rays[i];
const cos = r.dir[0] * nrm(r)[0] + r.dir[1] * nrm(r)[1];
return Math.abs(axisOf(g).len - r.len * cos) < 1e-6
&& Math.abs(dep(r) - r.len * cos) < 1e-9
&& stopsOf(g).length > 2;
});
// the ONLY thing that matters about that axis: a point `source + dir·u`
// lands on offset `u / len`, whichever ray it rode in on
out.offsetIsDistanceAlongTheRay = gs.every((g, i) => {
const r = c._sunRaysCache.rays[i];
return [0, 0.3, 0.85, 1].every((u) => [r.a, r.b].every((src) => {
const p = [src[0] + r.dir[0] * r.len * u, src[1] + r.dir[1] * r.len * u];
return Math.abs(offsetOf(g, p) - u) < 1e-6;
}));
});
out.deadWellBeforeTheEnd = gs.every((g) => {
const st = stopsOf(g);
const firstZero = st.find(([, a]) => a === 0);
return !!firstZero && firstZero[0] <= 85.001; // %
});
out.lastStopIsZero = gs.every((g) => stopsOf(g).slice(-1)[0][1] === 0);
out.brightAtTheGlass = gs.every((g) => stopsOf(g)[0][1] > 0.2);
out.neverBrightensInward = gs.every((g) => {
const st = stopsOf(g);
return st.every(([, a], i) => i === 0 || a <= st[i - 1][1] + 1e-9);
});
// ---- 3) SHARP sides: no blur on the wedge, and none defined anywhere ----
const wedges = () => [...sr().querySelectorAll('.sunlayer polygon')];
// walk the polygon and its ancestors up to and including .sunlayer — the
// day/night `brightness` filter lives further up, on the zoomwrap, and is
// none of this test's business
const blurredChain = (el) => {
for (let n = el; n; n = n.parentElement) {
const attr = n.getAttribute && n.getAttribute('filter');
if (attr && attr !== 'none') return true;
const cs = getComputedStyle(n).filter;
if (cs && cs !== 'none' && cs !== '') return true;
if (n.classList && n.classList.contains('sunlayer')) break;
}
return false;
};
out.wedgesDrawn = wedges().length > 0;
out.everyWedgeHasSharpSides = wedges().length > 0 && wedges().every((p) => !blurredChain(p));
out.noSoftFilterDefined = sr().querySelectorAll('filter[id^=hp-sunsoft-]').length === 0;
out.noGaussianBlurAtAll = sr().querySelectorAll('feGaussianBlur').length === 0;
// ---- 4) an OBLIQUE sun: the shaft still dies of its gradient -----------
// The sides are hard again, so the only thing that may end the wedge is the
// gradient. That holds ONLY if the far edge is square to the RAY: with the
// old wall-parallel edge one far corner sat at offset ~0.7 (low sun) or
// ~0.11 (high sun) — i.e. still lit — which is exactly the bright kerb.
// offset ALONG THE GRADIENT, i.e. depth under the wall over `len · cos`
const tOf = (r, p) => {
const mx = (r.a[0] + r.b[0]) / 2, my = (r.a[1] + r.b[1]) / 2;
return ((p[0] - mx) * nrm(r)[0] + (p[1] - my) * nrm(r)[1]) / dep(r);
};
const skew = (r) => {
const mx = (r.a[0] + r.b[0]) / 2, my = (r.a[1] + r.b[1]) / 2;
return Math.abs((r.a[0] - mx) * r.dir[0] + (r.a[1] - my) * r.dir[1]) / r.len;
};
out.obliqueChecked = [];
out.sunIsReallyOblique = true;
out.nothingDrawnPastTheGradient = true;
for (const [az, el] of [[230, 8], [225, 55]]) {
await setSun(az, el);
const rays = c._sunRaysCache.rays;
out.obliqueChecked.push(rays.length);
if (!rays.length) { out.sunIsReallyOblique = false; continue; }
if (!rays.some((r) => skew(r) > 1e-3)) out.sunIsReallyOblique = false;
for (const r of rays) {
for (const poly of r.polys) {
for (const p of poly) if (tOf(r, p) > 1 + 1e-6) out.nothingDrawnPastTheGradient = false;
}
}
}
out.obliqueSunHasWedges = out.obliqueChecked.every((n) => n > 0);
delete out.obliqueChecked;
// ---- 5) DEV-EB173-01: the auditor's own grazing repro ------------------
// West window 80 render units long, elevation 90 (nominal reach 0.56 · 80 =
// 44.8, i.e. 70 % of the old 64), azimuth 190 — the light enters the glass
// and travels 10° off the wall's own direction. The probe on the broken
// build read sides 5.408 / 84.192 (ratio 15.57) and source offsets ±0.879
// with opacity 0 at one end of the pane.
sp.openings = [{ id: 'wW', type: 'window', x: 0.04, y: 0.30, angle: 90, length: 0.08 }];
c._cfgEpoch++;
await setSun(190, 90);
const gr = c._sunRaysCache.rays;
out.grazingRayDrawn = gr.length === 1;
if (gr.length === 1) {
const r = gr[0];
const g = grads()[0];
out.grazingIsReallyGrazing = Math.abs(r.dir[0] * nrm(r)[0] + r.dir[1] * nrm(r)[1] - 0.17365) < 1e-4;
out.grazingLengthIs70Percent = Math.abs(r.len - 0.7 * (0.8 * 80)) < 1e-6
&& Math.abs(r.len - 44.8) < 1e-6;
// both sides of the shaft, measured off the DRAWN polygon: the depth of a
// vertex divided by cos is how far its ray ran
const cos = r.dir[0] * nrm(r)[0] + r.dir[1] * nrm(r)[1];
const ran = (p) => ((p[0] - r.a[0]) * nrm(r)[0] + (p[1] - r.a[1]) * nrm(r)[1]) / cos;
const far = r.polys[0].map(ran).filter((u) => u > 1e-6);
out.grazingHasTwoFarCorners = far.length === 2;
out.grazingSidesEqualWithin1Percent = far.length === 2
&& Math.abs(far[0] - far[1]) <= 0.01 * r.len;
out.grazingBothSidesAreTheNominalLength = far.every((u) => Math.abs(u - r.len) <= 0.01 * r.len);
// the whole pane of glass at peak alpha (was 0 at one end)
const peak = stopsOf(g)[0][1];
out.grazingGlassAtOffsetZero = [r.a, r.b].every((p) => Math.abs(offsetOf(g, p)) < 1e-6);
out.grazingGlassAtPeakAlpha = [r.a, r.b].every((p) => Math.abs(alphaAt(g, offsetOf(g, p)) - peak) < 1e-9);
out.grazingPeakIsTheRealPeak = peak > 0.2;
// and nothing is drawn past the gradient
out.grazingInsideTheGradient = r.polys.every((poly) =>
poly.every((p) => offsetOf(g, p) >= -1e-6 && offsetOf(g, p) <= 1 + 1e-6));
}
// a sun 2° off the wall's plane (cos 0.035 < RAY_MIN_COS) casts nothing
await setSun(182, 90);
out.sunAlongTheWallCastsNothing = c._sunRaysCache.rays.length === 0;
await setSun(186, 90);
out.sunJustClearOfTheWallStillCasts = c._sunRaysCache.rays.length === 1;
return out;
});
await finish(browser, checkAll(res));
+1 -1
View File
@@ -10,7 +10,7 @@ const res = await page.evaluate(async () => {
// берём заведомо не-световое устройство
const dev = c._devices.find((d) => !d.virtual && d.primary && !d.primary.startsWith('light.'));
c._openMarkerDialog(dev); await c.updateComplete;
const sel = [...sr().querySelectorAll('.dialog select')].find((s) =>
const sel = [...sr().querySelectorAll('hp-dialog select')].find((s) =>
[...s.options].some((o) => o.textContent === c._t('tap.toggle')));
out.threeOptions = sel && sel.options.length === 4; // + «Запустить…» (2026-07-29)
out.noAutoOption = sel && ![...sel.options].some((o) => o.value === '');
+4 -4
View File
@@ -22,13 +22,13 @@ const out = await page.evaluate(async () => {
// ВИДИМОСТЬ, не просто наличие в DOM: список — flex-item со скроллом и
// однажды схлопнулся в полоску 1px при 26 кандидатах внутри (репорт
// пользователя 2026-07-30, поймано только замером высоты)
const listEl = () => sr().querySelector('.dialog .candlist');
const listEl = () => sr().querySelector('hp-dialog .candlist');
o.pickerShown = !!listEl() && listEl().getBoundingClientRect().height > 30;
c._markerDialog = { ...c._markerDialog, runFilter: 'штор' }; await c.updateComplete;
const cands = [...sr().querySelectorAll('.dialog .cand')].map((x) => x.textContent);
const cands = [...sr().querySelectorAll('hp-dialog .cand')].map((x) => x.textContent);
o.searchWorks = cands.length >= 1 && cands.some((t) => t.includes('Шторы'));
// и найденная строка реально нарисована, а не сплющена
const row = sr().querySelector('.dialog .cand');
const row = sr().querySelector('hp-dialog .cand');
o.resultRowVisible = !!row && row.getBoundingClientRect().height > 10
&& row.getBoundingClientRect().bottom <= listEl().getBoundingClientRect().bottom + 1;
// сохранение без цели блокируется
@@ -37,7 +37,7 @@ const out = await page.evaluate(async () => {
// выбираем цель + подтверждение, сохраняем
c._markerDialog = { ...c._markerDialog, tapTarget: 'script.curtains', tapConfirm: true };
await c.updateComplete;
o.confirmCheckboxShown = [...sr().querySelectorAll('.dialog .srcrow')].length >= 2;
o.confirmCheckboxShown = [...sr().querySelectorAll('hp-dialog .srcrow')].length >= 2;
await c._saveMarker(); await c.updateComplete;
const saved = (c._serverCfg.markers || []).find((m) => m.binding === 'device:' + d.bindingRef);
o.markerSaved = saved?.tap_action === 'run' && saved?.tap_target === 'script.curtains' && saved?.tap_confirm === true;
+11 -4
View File
@@ -20,13 +20,19 @@ const res = await page.evaluate(async () => {
await c.updateComplete;
out.tipTemp = c._tip?.temp;
out.tipHasTempLine = (sr().querySelector('.tip')?.textContent || '').includes('средняя температура');
// _roomArea consumes the rendered room geometry (0..1000), not the stored
// normalized config polygon (0..1).
const expectedArea = c._roomArea(c._spaceModel().rooms[0]);
out.tipHasArea = !!expectedArea && c._tip?.meta === c._t('tip.area', { value: expectedArea });
out.tipHasAreaLine = (sr().querySelector('.tip')?.textContent || '').includes(expectedArea);
c._tip = null;
// диалог: радио заливки, компактные поля, ширина
c._openSpaceDialog('edit', 'f1'); await c.updateComplete;
out.fillRadios = sr().querySelectorAll('input[name="fillmode"]').length;
out.tempInputs = sr().querySelectorAll('.temprange .tempin').length;
out.dialogWide = !!sr().querySelector('.dialog.wide .srcrow');
out.dialogWidth = Math.round(sr().querySelector('.dialog').getBoundingClientRect().width);
const hpDialog = sr().querySelector('hp-dialog');
out.dialogWide = !!hpDialog?.hasAttribute('wide') && !!hpDialog.querySelector('.srcrow');
out.dialogWidth = Math.round(hpDialog.shadowRoot.querySelector('.surface').getBoundingClientRect().width);
// NaN-защита: пустой ввод не ломает границы
const before = c._spaceDialog.tempMax;
const fakeEv = { target: { value: '' } };
@@ -39,13 +45,14 @@ const res = await page.evaluate(async () => {
// '/tmp/...' указывает в несуществующий C:\tmp и смоук падал, не дойдя до
// ассертов (портируемость, ревью 2026-07-27)
await page.screenshot({ path: join(tmpdir(), 'houseplan_ux_dialog.png') }).catch(() => {});
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
// Geometry values were re-baselined for the shared border-box hp-dialog in
// v1.59.2; the usable wide-dialog width remains 500 px.
checkAll(res, {
"filledClass": 1,
"unfilled": 3,
"tipTemp": 22.4,
"fillRadios": 5,
"tempInputs": 2,
"dialogWidth": 502,
"dialogWidth": 500,
});
await finish(browser, res);
+126
View File
@@ -0,0 +1,126 @@
// Значения форматирует HOME ASSISTANT (docs/STYLING-HOOKS.md §6, docs/LIVE-TEXT.md §2.1).
// Везде, где карточка печатает состояние ОДНОЙ сущности, она зовёт
// hass.formatEntityState (и hass.formatEntityAttributeValue для атрибута) —
// значит, работают display_precision, локальный разделитель дробной части и
// переводы состояний, а единица не дублируется.
// 1) бейдж display:'value' — «68,4 %» вместо сырых «68.42 %»;
// 2) живой текст декора — то же, плюс перевод 'on' → «Включено»;
// 3) своя единица заменяет единицу форматтера, а не приписывается к ней;
// 4) атрибут идёт через СВОЙ форматтер, а не через форматтер состояния;
// 5) старый HA без форматтера — прежнее поведение слово в слово;
// 6) плашки °/% остаются нашими: это производное значение, а не состояние.
// ПАДАЕТ на сборке до этих правок: бейдж и живой текст печатают сырое
// состояние, 'on' остаётся 'on', а своя единица приписывается к чужой.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const base = window.__mkHass();
// ---- сущности стенда: датчик с «лишними» знаками и выключатель ----
const EXTRA = {
'sensor.tank': { entity_id: 'sensor.tank', state: '68.42',
attributes: { friendly_name: 'Бак', unit_of_measurement: '%' } },
'switch.pump': { entity_id: 'switch.pump', state: 'on',
attributes: { friendly_name: 'Насос' } },
'climate.hall': { entity_id: 'climate.hall', state: 'heat',
attributes: { friendly_name: 'Котёл', current_temperature: 21.53, unit_of_measurement: '°C' } },
};
// Стаб ровно того, что делает HA: display_precision = 1, русская локаль
// (запятая), перевод состояний и единица ВНУТРИ результата.
const haFormat = (st) => {
if (st.state === 'on') return 'Включено';
if (st.state === 'off') return 'Выключено';
if (st.state === 'heat') return 'Нагрев';
if (st.state === 'locked') return 'Заперто';
const n = Number(st.state);
if (!Number.isFinite(n)) return st.state;
const txt = n.toFixed(1).replace('.', ',');
const u = st.attributes?.unit_of_measurement;
return u ? `${txt} ${u}` : txt;
};
const haAttr = (st, a) => String(st.attributes?.[a]).replace('.', ',');
const mk = (withFormatter) => {
const h = {
...base,
states: { ...base.states, ...EXTRA },
entities: { ...base.entities,
'sensor.tank': { entity_id: 'sensor.tank', platform: 'demo' },
'switch.pump': { entity_id: 'switch.pump', platform: 'demo' },
'climate.hall': { entity_id: 'climate.hall', platform: 'demo' } },
};
if (withFormatter) { h.formatEntityState = haFormat; h.formatEntityAttributeValue = haAttr; }
else { delete h.formatEntityState; delete h.formatEntityAttributeValue; }
return h;
};
const apply = async (withFormatter) => {
c.hass = mk(withFormatter);
c._regSignature = ''; c._cfgEpoch++; c._maybeRebuildDevices(); c.requestUpdate();
await c.updateComplete; await sleep(60); await c.updateComplete;
};
// ================= 1. бейдж «значение вместо значка» =================
c._serverCfg = { ...c._serverCfg, markers: [
{ id: 'm_tank', binding: 'entity:sensor.tank', space: c._space, display: 'value' },
] };
await apply(true);
const badge = () => sr().querySelector('[data-hp="device"][data-id="m_tank"] .valtext');
out.badgeRendered = !!badge();
out.badgeIsHaFormatted = badge()?.textContent.trim() === '68,4 %';
out.badgeUnitNotDoubled = !/%\s*%/.test(badge()?.textContent || '');
// старый HA без форматтера — ровно прежняя строка
await apply(false);
out.badgeFallsBackToRawState = badge()?.textContent.trim() === '68.42 %';
await apply(true);
// ================= 2. живой текст декора =================
c._curSpaceCfg.decor = [
{ id: 'lt_val', kind: 'text', x: 0.2, y: 0.92, text: 'Бак {}', color: '#123456', entity: 'sensor.tank' },
{ id: 'lt_unit', kind: 'text', x: 0.5, y: 0.92, text: '{}', color: '#123456', entity: 'sensor.tank', unit: 'проц.' },
{ id: 'lt_state', kind: 'text', x: 0.8, y: 0.92, text: 'Насос: {}', color: '#123456', entity: 'switch.pump' },
{ id: 'lt_attr', kind: 'text', x: 0.2, y: 0.96, text: '{}', color: '#123456', entity: 'climate.hall', attr: 'current_temperature' },
{ id: 'lt_attru', kind: 'text', x: 0.5, y: 0.96, text: '{}', color: '#123456', entity: 'climate.hall', attr: 'current_temperature', unit: '°C' },
];
c._cfgEpoch++; c.requestUpdate(); await c.updateComplete;
const txt = (id) => sr().querySelector(`text.dtext[data-id="${id}"]`)?.textContent.trim();
out.liveTextIsHaFormatted = txt('lt_val') === 'Бак 68,4 %';
out.liveTextUnitNotDoubled = txt('lt_val') === 'Бак 68,4 %' && !/%\s*%/.test(txt('lt_val'));
out.liveTextOwnUnitReplacesTheFormatters = txt('lt_unit') === '68,4 проц.';
out.liveTextStateIsTranslated = txt('lt_state') === 'Насос: Включено';
// атрибут идёт через СВОЙ форматтер: иначе тут было бы «Нагрев»
out.attrUsesTheAttributeFormatter = txt('lt_attr') === '21,53';
out.attrDoesNotInheritTheEntityUnit = txt('lt_attr') === '21,53';
out.attrTakesAnExplicitUnit = txt('lt_attru') === '21,53 °C';
// без форматтера — прежнее поведение слово в слово
await apply(false);
out.liveTextFallsBackToRawState = txt('lt_val') === 'Бак 68.42 %';
out.liveTextFallbackTranslatesNothing = txt('lt_state') === 'Насос: on';
out.attrFallsBackToTheRawAttribute = txt('lt_attr') === '21.53';
await apply(true);
// ================= 3. инфо-карточка =================
const dev = c._devices.find((d) => d.primary === 'lock.front_door');
c._infoCard = dev; c.requestUpdate(); await c.updateComplete;
const rowVals = [...sr().querySelectorAll('.entlist .entrow')]
.map((r) => r.querySelector('.entbtn, .ev')?.textContent.trim());
out.infoCardUsesTheFormatter = rowVals.includes('Заперто');
out.infoCardPrintsNoRawState = !rowVals.includes('locked');
c._infoCard = null; c.requestUpdate(); await c.updateComplete;
// ================= 4. плашки °/% остались нашими =================
// термометр в режиме «значение» по-прежнему показывает компактное «22.4°»:
// это ПРОИЗВОДНОЕ показание (среднее по датчикам зоны / атрибут климата),
// а не состояние одной сущности, и весь план читается как один прибор
c._serverCfg = { ...c._serverCfg, markers: [
{ id: 'd_temp', binding: 'device:d_temp', display: 'value' },
] };
await apply(true);
const tempBadge = [...sr().querySelectorAll('.dev.valonly .valtext')].map((e) => e.textContent.trim());
out.tempPlateKeepsItsCompactForm = tempBadge.includes('22.4°');
return out;
});
checkAll(res);
await finish(browser, res);
+273
View File
@@ -0,0 +1,273 @@
/**
* Wall thickness (docs/WALL-THICKNESS.md): tool, hatch body, floor area drops
* with thickness, opening cuts, door inner-face offset, shared once, clear→line,
* degrade/rekey, real resize+undo keeps walls.
*/
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const upd = async () => { c._cfgEpoch++; c.requestUpdate(); await c.updateComplete; };
const sp = () => c._serverCfg.spaces.find((s) => s.id === c._space);
const floorAreaOf = (id) => {
const rooms = c._spaceModel().rooms;
const walls = sp().walls || [];
if (!walls.length) {
const r = rooms.find((x) => x.id === id);
const p = r?.poly;
if (!p) return 0;
let s = 0;
for (let i = 0; i < p.length; i++) {
const a = p[i], b = p[(i + 1) % p.length];
s += a[0] * b[1] - b[0] * a[1];
}
return Math.abs(s) / 2;
}
const el = sr().querySelector(`[data-hp="room"][data-id="${id}"]`);
const pts = el?.getAttribute('points');
if (pts) {
const poly = pts.trim().split(/\s+/).map((t) => t.split(',').map(Number));
let s = 0;
for (let i = 0; i < poly.length; i++) {
const a = poly[i], b = poly[(i + 1) % poly.length];
s += a[0] * b[1] - b[0] * a[1];
}
return Math.abs(s) / 2;
}
return 0;
};
sp().settings = { ...(sp().settings || {}), show_borders: true, hide_openings: undefined };
delete sp().walls;
delete sp().openings;
for (const r of sp().rooms || []) delete r.open_to;
const areaBefore = floorAreaOf('r1');
c._setMode('plan');
c._tool = 'wallthick';
await upd();
out.toolArmed = c._tool === 'wallthick';
out.toolBtn = !!sr().querySelector('.editbar .btn.on ha-icon[icon="mdi:wall"]')
|| [...sr().querySelectorAll('.editbar .btn')].some((b) => b.classList.contains('on') && c._tool === 'wallthick');
const outer = [50, 250];
c._cursorPt = outer;
await upd();
out.hover = !!sr().querySelector('.wallthick-hover');
c._wallThickClick(outer);
await upd();
out.dialogOpen = !!c._wallDialog && !!sr().querySelector('.wallthick-dlg');
c._wallDialog = { ...c._wallDialog, value: '20' };
c._wallThickApply(false);
await upd();
out.bodyDrawn = sr().querySelectorAll('[data-hp="wall"]').length >= 1;
const areaAfter = floorAreaOf('r1');
out.areaShrinks = areaAfter > 0 && areaAfter < areaBefore - 1;
// A sun shaft starts at the two room-side corners of a thick opening, not
// on the wall centreline and not at an incidence-narrowed pseudo-aperture.
sp().openings = [{
id: 'wtSun', type: 'window', x: 0.04, y: 0.25, angle: 90, length: 0.09,
}];
sp().settings = { ...(sp().settings || {}), north_deg: 0, sun_rays: true };
c.hass = { ...c.hass, states: { ...c.hass.states, 'sun.sun': {
entity_id: 'sun.sun', state: 'above_horizon',
attributes: { azimuth: 240, elevation: 60 },
} } };
c._setMode('view');
await upd();
const sunRay = (c._sunRaysCache?.rays || []).find((r) => r.openingId === 'wtSun');
const sunDepth = (20 / c._cellCm) * c._gridPitch;
const ys = sunRay ? [sunRay.a[1], sunRay.b[1]].sort((a, b) => a - b) : [];
out.sunStartsInnerFace = !!sunRay
&& Math.abs(sunRay.a[0] - (40 + sunDepth / 2)) < 1e-6
&& Math.abs(sunRay.b[0] - (40 + sunDepth / 2)) < 1e-6;
out.sunStartsInnerCorners = !!sunRay
&& Math.abs(ys[0] - 205) < 1e-6 && Math.abs(ys[1] - 295) < 1e-6;
c._setMode('plan');
c._tool = 'wallthick';
await upd();
c._tool = 'wallthick';
c._wallThickClick([550, 250]);
await upd();
c._wallDialog = { ...c._wallDialog, value: '25' };
c._wallThickApply(false);
await upd();
const shared = [...sr().querySelectorAll('[data-hp="wall"]')];
out.sharedOnce = shared.length === 1;
sp().openings = [{
id: 'wt1', type: 'door', x: 0.55, y: 0.25, angle: 90, length: 0.09, flip_v: false,
}];
await upd();
out.doorDrawn = !!sr().querySelector('[data-hp="opening"][data-kind="door"]');
const bodyD = shared[0]?.getAttribute('d') || sr().querySelector('[data-hp="wall"]')?.getAttribute('d') || '';
out.openingCutsSlab = (bodyD.match(/\bM\b/g) || []).length >= 2;
const g = sr().querySelector('[data-hp="opening"][data-kind="door"] g g');
const inner = [...(g?.querySelectorAll('g') || [])].find((n) => {
const t = n.getAttribute('transform') || '';
return /translate\(([^)]+)\)/.test(t) && !/translate\(\s*0\s+0\s*\)/.test(t);
});
out.doorInnerFace = !!inner || (sp().walls || []).some((w) => w.cm === 25);
sp().settings = { ...(sp().settings || {}), hide_openings: true, show_borders: true };
c._setMode('view');
await upd();
const cutD = sr().querySelector('[data-hp="wall"]')?.getAttribute('d') || '';
out.hideKeepsCut = sr().querySelectorAll('[data-hp="opening"]').length === 0
&& (cutD.match(/\bM\b/g) || []).length >= 2;
c._setMode('plan');
c._tool = 'wallthick';
await upd();
c._wallThickClick(outer);
await upd();
c._wallDialog = { ...c._wallDialog, value: '' };
c._wallThickApply(false);
await upd();
out.cleared = !(sp().walls || []).some((w) => w.cm === 20);
sp().walls = [...(sp().walls || []), { key: '9.99,9.99@1.5708', cm: 15 }];
c._dropLegacySegments();
out.degrade = !(sp().walls || []).some((w) => w.key.startsWith('9.99'));
// Opening a stretch takes TWO clicks since v1.59.0-beta.6: anchor, then the
// second point on the same shared wall. A span over y=150..350 must refuse
// thickness at y=250, and the solid remainder above/below must keep its own.
c._tool = 'openwall';
c._openWallClick([550, 150]);
c._openWallClick([550, 350]);
await upd();
out.spanOpened = ((sp().open_spans || []).length === 1);
c._tool = 'wallthick';
c._toast = null;
c._wallThickClick([550, 250]);
await upd();
out.openRefused = !!c._toast && !c._wallDialog;
// the closed parts of the same wall still carry the 25 cm they had
out.solidRemainderKeepsCm = c._intervalCm([550, 140, 550, 148]) === 25
&& c._intervalCm([550, 350, 550, 460]) === 25
&& c._intervalCm([550, 200, 550, 300]) === 0;
c._tool = 'closewall';
c._closeWallClick([550, 250]);
await upd();
out.spanClosed = !(sp().open_spans || []).length;
delete sp().openings;
c._tool = 'wallthick';
c._wallThickClick([550, 250]);
await upd();
if (c._wallDialog) {
c._wallDialog = { ...c._wallDialog, value: '30' };
c._wallThickApply(false);
await upd();
}
out.rekeyReady = !!(sp().walls || []).find((w) => w.cm === 30);
// Real resize: drag shared-wall handle one grid step, then undo (AUD-159B4-01).
c._setMode('plan');
c._tool = 'resize';
c._wallDialog = null;
await upd();
const stageEl = () => sr().querySelector('.stage');
const toScreen = (x, y) => {
const r = stageEl().getBoundingClientRect();
const v = c._viewOr(c._baseVb());
return {
clientX: r.left + ((x - v.x) / v.w) * r.width,
clientY: r.top + ((y - v.y) / v.h) * r.height,
};
};
const pev = (type, target, x, y) => {
if (!target) return;
const { clientX, clientY } = toScreen(x, y);
target.dispatchEvent(new PointerEvent(type, {
bubbles: true, composed: true, cancelable: true,
pointerId: 42, clientX, clientY, button: 0, isPrimary: true,
}));
};
const handles = [...sr().querySelectorAll('.rszhandle:not(.rszcorner)')];
let sharedHandle = null;
for (const h of handles) {
const cx = +h.getAttribute('cx'), cy = +h.getAttribute('cy');
if (Math.abs(cx - 550) < c._gridPitch * 2 && cy > 150 && cy < 450) {
sharedHandle = h;
break;
}
}
out.resizeEdgeFound = !!sharedHandle;
const wallsBefore = JSON.stringify(sp().walls || []);
if (sharedHandle) {
const cx = +sharedHandle.getAttribute('cx');
const cy = +sharedHandle.getAttribute('cy');
const step = c._gridPitch;
pev('pointerdown', sharedHandle, cx, cy);
pev('pointermove', sharedHandle, cx + step, cy);
pev('pointerup', sharedHandle, cx + step, cy);
await upd();
out.resizeKeeps = (sp().walls || []).some((w) => w.cm === 30);
c._undoGeometry();
await upd();
out.resizeUndoRestores = (sp().walls || []).some((w) => w.cm === 30)
&& JSON.stringify(sp().walls || []) === wallsBefore;
} else {
c._dropLegacySegments();
out.resizeKeeps = (sp().walls || []).some((w) => w.cm === 30);
out.resizeUndoRestores = false;
}
out.pixelProbe = !!sr().querySelector('.wallbody');
// AUD-159B7-04: the same screen-depth policy owns both cards. At 1 cm the
// hatch must be suppressed in full and static views; at 20 cm both restore
// it. The static card measures its actual stage width via ResizeObserver.
c._setMode('view');
const parityCfg = JSON.parse(JSON.stringify(c._serverCfg));
const paritySp = parityCfg.spaces.find((x) => x.id === c._space);
paritySp.settings = { ...(paritySp.settings || {}), show_borders: true };
paritySp.walls = (paritySp.walls || []).map((w) => ({ ...w, cm: 1 }));
c._serverCfg = parityCfg;
await upd();
await customElements.whenDefined('houseplan-space-card');
const baseCall = c.hass.callWS.bind(c.hass);
const staticHass = { ...c.hass, callWS: async (m) => {
if (m.type === 'houseplan/config/get') return { config: parityCfg, rev: 1 };
if (m.type === 'houseplan/layout/get') return { layout: c._layout || {}, rev: 1 };
return baseCall(m);
} };
const staticCard = document.createElement('houseplan-space-card');
staticCard.setConfig({ type: 'custom:houseplan-space-card', space: c._space, show_button: false });
staticCard.hass = staticHass;
document.body.appendChild(staticCard);
const t0 = Date.now();
while ((!staticCard.renderRoot?.querySelector('.wallbody') || !(staticCard._stageWidth > 0))
&& Date.now() - t0 < 6000) {
await new Promise((r) => setTimeout(r, 60));
}
await staticCard.updateComplete;
out.thinWallFullSolid = !!sr().querySelector('.wallbody.solid');
out.thinWallStaticSolid = !!staticCard.renderRoot?.querySelector('.wallbody.solid');
for (const w of paritySp.walls || []) w.cm = 20;
c._cfgEpoch++;
c.requestUpdate();
staticCard.requestUpdate();
await c.updateComplete;
await staticCard.updateComplete;
out.thickWallFullHatched = !!sr().querySelector('.wallbody:not(.solid)');
out.thickWallStaticHatched = !!staticCard.renderRoot?.querySelector('.wallbody:not(.solid)');
staticCard.remove();
return out;
});
checkAll(res);
await finish(browser, res);
+120
View File
@@ -0,0 +1,120 @@
// DEV-B703-03: «карточка перезагружается при возврате на вкладку» — часть 2.
// Тёплый ре-маунт (v1.58.0) убрал вуаль, но не сохранял ни ПАН, ни зум
// редактора, ни открытые диалоги: Lovelace пересоздаёт элемент, состояние
// диалога живёт в экземпляре и умирает вместе с ним. Смок воспроизводит
// пересоздание (remove + create на той же странице) и требует:
// 1) вид восстановлен БИТ-В-БИТ — ни один кадр не отличается от прежнего;
// 2) открытый диалог пережил пересоздание вместе с черновиком;
// 3) осознанно закрытый (Esc) — НЕ воскресает;
// 4) подтверждение «Выровнять всё» — НЕ воскресает никогда;
// 5) воскрешение одноразовое: третий экземпляр диалога уже не видит.
// ПАДАЕТ на сборке до DEV-B703-03 (вид рецентрировался, диалоги терялись).
import { launch, check, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 820, height: 760 });
const res = await page.evaluate(async () => {
const out = {};
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
customElements.get('houseplan-card')?._warmBootReset?.();
localStorage.removeItem('houseplan_card_zoom_v1');
localStorage.removeItem('houseplan_card_nav_v1');
const wrap = document.createElement('div');
wrap.style.cssText = 'position:fixed;left:0;top:0;width:800px;z-index:99;background:#000';
document.body.appendChild(wrap);
const mk = () => {
const c = document.createElement('houseplan-card');
c.setConfig({ type: 'custom:houseplan-card' }); // Lovelace: setConfig ДО вставки
c.hass = window.__mkHass();
wrap.appendChild(c);
return c;
};
const settle = async (c) => {
const t0 = performance.now();
while (c._booting && performance.now() - t0 < 2500) await sleep(30);
await sleep(350);
};
const rect = (c) => (c._view ? [c._view.x, c._view.y, c._view.w, c._view.h] : null);
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
/** покадрово: ни один кадр после пересоздания не отличается от эталона */
const watchView = async (c, zoom0, v0, ms = 700) => {
const bad = [];
const t1 = performance.now();
await new Promise((done) => {
const s = () => {
if (Math.abs(c._zoom - zoom0) > 1e-6 || !same(rect(c), v0)) {
bad.push({ t: Math.round(performance.now() - t1), zoom: c._zoom, v: rect(c) });
}
if (performance.now() - t1 < ms) requestAnimationFrame(s); else done();
};
requestAnimationFrame(s);
});
return bad.length === 0 ? true : `кадр ${bad[0].t}мс: zoom=${bad[0].zoom} view=${JSON.stringify(bad[0].v)} (ждали ${zoom0} / ${JSON.stringify(v0)})`;
};
// ================= A. просмотр: пан+зум и настройки пространства ==========
let c = mk();
await settle(c);
c._applyView(2.4, 260, 720); // зум в угол — вид точно не по центру
c._saveZoom();
c.requestUpdate(); await c.updateComplete; await sleep(100);
const zoomA = c._zoom, viewA = rect(c);
out.aPanned = viewA[0] > 1 || viewA[1] > 1; // sanity: вид действительно смещён
c._openSpaceDialog('edit', c._space); await c.updateComplete;
c._spaceDialog = { ...c._spaceDialog, title: 'ЧЕРНОВИК-42' }; // недосохранённая правка
await c.updateComplete;
out.aDialogOpenBefore = !!c._spaceDialog;
c.remove(); await sleep(20); // ← Lovelace выбрасывает элемент
c = mk(); // ← и создаёт новый
out.aViewBitExact = await watchView(c, zoomA, viewA);
out.aDialogSurvived = !!c._spaceDialog;
out.aDraftSurvived = c._spaceDialog?.title === 'ЧЕРНОВИК-42';
out.aStillSameSpaceMode = c._mode === 'view';
// ---- воскрешение одноразовое: ещё одно пересоздание диалог не вернёт ----
c._spaceDialog = null; await c.updateComplete; // (осознанно закрыли)
c.remove(); await sleep(20);
c = mk(); await sleep(120); await c.updateComplete; await sleep(60);
out.aNoZombieAfterClose = !c._spaceDialog;
// ================= B. редактор устройств: зум редактора + карточка =======
c._setMode('devices'); await c.updateComplete; await sleep(120);
c._applyView(3.4, 430, 380); c.requestUpdate(); await c.updateComplete; await sleep(100);
const zoomB = c._zoom, viewB = rect(c);
const dev = c._devices.find((d) => d.space === c._space);
c._openMarkerDialog(dev); await c.updateComplete;
c._markerDialog = { ...c._markerDialog, name: 'ИМЯ-ЧЕРНОВИК' };
await c.updateComplete;
out.bDialogOpenBefore = !!c._markerDialog;
c.remove(); await sleep(20);
c = mk();
out.bModeRestored = c._mode === 'devices';
out.bViewBitExact = await watchView(c, zoomB, viewB);
out.bDialogSurvived = !!c._markerDialog;
out.bDraftSurvived = c._markerDialog?.name === 'ИМЯ-ЧЕРНОВИК';
// ================= C. Esc = осознанное закрытие ==========================
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
await c.updateComplete; await sleep(30);
out.cClosedByEsc = !c._markerDialog;
c.remove(); await sleep(20);
c = mk(); await sleep(120); await c.updateComplete; await sleep(60);
out.cNoResurrectionAfterEsc = !c._markerDialog;
// ================= D. «Выровнять всё» не воскресает НИКОГДА ==============
c._setMode('view'); await c.updateComplete; await sleep(60);
c._alignDialog = { report: { moved: 3, maxShiftCm: 2, maxSpace: c._space }, spaces: [], layout: {}, cm: 2, where: '', busy: false };
await c.updateComplete;
out.dAlignOpenBefore = !!c._alignDialog;
c.remove(); await sleep(20);
c = mk(); await sleep(120); await c.updateComplete; await sleep(60);
out.dAlignNotRevived = !c._alignDialog;
c.remove(); wrap.remove();
return out;
});
for (const [k, v] of Object.entries(res)) check(k, v);
await finish(browser, res);
+117
View File
@@ -0,0 +1,117 @@
// Регрессия аудита v1.59.0-beta.1 — владение тёплой памяткой.
// Три находки, все три ПАДАЮТ на сборке v1.59.0-beta.1:
// AUD-159B1-01 (P2): две карточки с ИДЕНТИЧНЫМ конфигом на одной странице
// делили один слот памятки (ключ = размер окна × JSON конфига), поэтому
// новый экземпляр на месте A усыновлял вид последнего писателя — соседа B
// (его этаж, режим, зум), а черновик настоящего предшественника съедался
// guard-ом `d.mode !== this._mode`.
// AUD-159B1-02 (P2): быстрая серия пересозданий (A→B→C в одном такте):
// промежуточный B ещё не успел воскресить диалог A, но в своём
// disconnectedCallback сбрасывал `_warmRevivePending` ДО снимка и писал
// `dlg: null` поверх чужого черновика — C не получал ничего.
// AUD-159B1-03 (P3): 10-секундный TTL запрещал воскрешение, но не освобождал
// payload: просроченная запись держала диалог (у пространства это план
// целиком в base64) до перезагрузки страницы.
import { launch, check, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 820, height: 760 });
const res = await page.evaluate(async () => {
const out = {};
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const HP = customElements.get('houseplan-card');
HP._warmBootReset(400); // TTL диалога 400мс вместо 10с — иначе смок стоит 10 секунд
localStorage.removeItem('houseplan_card_zoom_v1');
localStorage.removeItem('houseplan_card_nav_v1');
const wrap = document.createElement('div');
wrap.style.cssText = 'position:fixed;left:0;top:0;width:800px;z-index:99;background:#000';
document.body.appendChild(wrap);
/** Lovelace: setConfig ДО вставки; `before` = вставить в чужой DOM-слот */
const mk = (before) => {
const c = document.createElement('houseplan-card');
c.setConfig({ type: 'custom:houseplan-card' });
c.hass = window.__mkHass();
if (before) wrap.insertBefore(c, before); else wrap.appendChild(c);
return c;
};
const settle = async (c) => {
const t0 = performance.now();
while (c._booting && performance.now() - t0 < 2500) await sleep(30);
await sleep(250);
};
const near = (a, b) => Math.abs(a - b) < 1e-6;
// ============ A. две одинаковые карточки не делят вид и черновик =========
const A = mk();
await settle(A);
A._applyView(2.15, 300, 420);
A.requestUpdate(); await A.updateComplete; await sleep(60);
const zoomA = A._zoom;
A._openSpaceDialog('edit', A._space); await A.updateComplete;
A._spaceDialog = { ...A._spaceDialog, title: 'DRAFT-FROM-A' };
await A.updateComplete; await sleep(30);
const B = mk(); // вторая карточка, тот же конфиг
await settle(B);
B._setMode('devices'); await B.updateComplete; await sleep(120);
B._applyView(3.35, 200, 200);
B.requestUpdate(); await B.updateComplete; await sleep(60);
const zoomB = B._zoom;
out.aTwoLiveCards = A.isConnected && B.isConnected && zoomB !== zoomA;
// B — самостоятельная карточка, а не наследник A
out.aNeighbourKeptOwnView = B._mode === 'devices' && !B._spaceDialog;
// Lovelace создаёт замену A ДО того, как отсоединит A (порядок аудитора)
const A2 = mk(A);
A.remove();
await sleep(120); await A2.updateComplete; await sleep(60);
out.aReplacementKeepsOwnMode = A2._mode === 'view'; // не 'devices' от B
out.aReplacementKeepsOwnZoom = near(A2._zoom, zoomA); // не 3.35 от B
out.aOwnersDraftRestored = A2._spaceDialog?.title === 'DRAFT-FROM-A';
out.aNeighbourUntouched = B._mode === 'devices' && near(B._zoom, zoomB) && !B._spaceDialog;
A2.remove(); B.remove(); await sleep(20);
// ============ B. быстрый двойной ре-маунт не убивает черновик ============
HP._warmBootReset(400);
let c = mk();
await settle(c);
c._openSpaceDialog('edit', c._space); await c.updateComplete;
c._spaceDialog = { ...c._spaceDialog, title: 'DRAFT-RAPID' };
await c.updateComplete; await sleep(30);
out.bDraftOpenBefore = c._spaceDialog?.title === 'DRAFT-RAPID';
// A(черновик) -> создать B -> убрать A -> создать C -> убрать B — всё в одном
// такте, ни один таймер воскрешения не успевает отработать
const rb = mk(c);
c.remove();
const rc = mk(rb);
rb.remove();
await sleep(150); await rc.updateComplete; await sleep(60);
out.bDraftSurvivedDoubleRemount = rc._spaceDialog?.title === 'DRAFT-RAPID';
rc.remove(); await sleep(20);
// ============ C. просроченный диалог освобождает payload =================
HP._warmBootReset(400);
c = mk();
await settle(c);
c._openSpaceDialog('edit', c._space); await c.updateComplete;
c._spaceDialog = { ...c._spaceDialog, title: 'DRAFT-TTL', planFile: 'x'.repeat(4096) };
await c.updateComplete; await sleep(30);
c.remove(); await sleep(20);
out.cHeldWhileRevivable = HP._warmBootStats().dlgs === 1; // пока TTL идёт — держим
await sleep(900); // TTL 400мс + запас
const st = HP._warmBootStats();
out.cPayloadFreedAfterTtl = st.dlgs === 0;
out.cGeometryKept = st.slots >= 0 && st.keys >= 0; // память не сломана
// и после TTL ничего не воскресает
c = mk(); await sleep(150); await c.updateComplete; await sleep(40);
out.cNoReviveAfterTtl = !c._spaceDialog;
c.remove();
HP._warmBootReset();
wrap.remove();
return out;
});
for (const [k, v] of Object.entries(res)) check(k, v);
await finish(browser, res);
+119
View File
@@ -0,0 +1,119 @@
// DEV-B703-01: Lovelace re-creates card elements when the websocket reconnects
// after a long-backgrounded tab; the fresh instance used to run the whole
// first-open boot (veil + 700 ms + quiescence) — the owner's «план
// перезагружается при возврате на вкладку». Within one loaded page a repeat
// instance must open WARM: no veil, no wait, not a single frame at a stale
// stage height or a default zoom. A window resize between instances
// invalidates the memo → the full protective boot is back.
// FAILS on the build before DEV-B703-01 (every instance booted cold).
import { launch, check, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 820, height: 760 });
const res = await page.evaluate(async () => {
const out = {};
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
customElements.get('houseplan-card')?._warmBootReset?.(); // deterministic: the page starts cold
localStorage.setItem('houseplan_card_zoom_v1', JSON.stringify({ f1: 1.8 }));
localStorage.removeItem('houseplan_card_nav_v1');
const wrap = document.createElement('div');
wrap.style.cssText = 'position:fixed;left:0;top:0;width:800px;z-index:99;background:#000';
document.body.appendChild(wrap);
const mk = () => {
const c = document.createElement('houseplan-card');
c.setConfig({ type: 'custom:houseplan-card' });
c.hass = window.__mkHass();
wrap.appendChild(c);
return c;
};
// ---- cold boot: the first instance pays the full protective window ----
const c1 = mk();
out.coldBoots = c1._booting === true;
const t0 = performance.now();
let veilSeen = false;
while (c1._booting && performance.now() - t0 < 2500) {
if ((c1.shadowRoot || c1.renderRoot).querySelector('.bootveil')) veilSeen = true;
await sleep(30);
}
out.coldVeilSeen = veilSeen;
out.coldSettled = c1._booting === false;
await sleep(300); // fade out + soft grace start
const stage1 = (c1.shadowRoot || c1.renderRoot).querySelector('.stage');
const hFinal = stage1.clientHeight;
const zoomSaved = c1._zoom;
out.zoomArmed = Math.abs(zoomSaved - 1.8) < 0.01;
c1.remove(); // Lovelace throws the old element away…
await sleep(30);
// ---- …and creates a new one: same page, same viewport → WARM mount ----
const c2 = mk();
out.warmNoBootFlag = c2._booting === false; // decided synchronously in setConfig
const sr2 = () => c2.shadowRoot || c2.renderRoot;
const frames = [];
const t1 = performance.now();
await new Promise((done) => {
const sample = () => {
const stage = sr2().querySelector('.stage');
if (stage) {
const zw = stage.querySelector(':scope > .zoomwrap');
frames.push({
t: Math.round(performance.now() - t1),
veil: !!sr2().querySelector('.bootveil'),
planVisible: !!zw && getComputedStyle(zw).visibility === 'visible',
rooms: stage.querySelectorAll('.room').length,
stageH: stage.clientHeight,
zoom: c2._zoom,
});
}
if (performance.now() - t1 < 900) requestAnimationFrame(sample);
else done();
};
requestAnimationFrame(sample);
});
out.framesSampled = frames.length > 20;
out.warmNoVeilEver = !frames.some((f) => f.veil);
out.warmPlanVisibleEveryFrame = frames.length > 0 && frames.every((f) => f.planVisible)
? true : 'a frame hid the plan';
const badH = frames.filter((f) => Math.abs(f.stageH - hFinal) > 2);
out.warmNoStaleHeightFrame = badH.length === 0
? true : `stage h=${badH[0].stageH} vs final ${hFinal} at t=${badH[0].t}ms`;
out.warmZoomEveryFrame = frames.every((f) => Math.abs(f.zoom - zoomSaved) < 0.01)
? true : 'a frame at a non-saved zoom';
// the server config reload on a warm mount must not blank the plan
out.warmNeverEmptyPlan = frames.every((f) => f.rooms > 0) ? true : 'a frame rendered no rooms';
c2.remove();
wrap.remove();
return out;
});
for (const [k, v] of Object.entries(res)) check(k, v);
// ---- a window resize between instances → memo stale → the full boot ----
await page.setViewportSize({ width: 900, height: 680 });
const rez = await page.evaluate(async () => {
const out = {};
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const c = document.createElement('houseplan-card');
c.setConfig({ type: 'custom:houseplan-card' });
c.hass = window.__mkHass();
const wrap = document.createElement('div');
wrap.style.cssText = 'position:fixed;left:0;top:0;width:800px;z-index:99;background:#000';
wrap.appendChild(c);
document.body.appendChild(wrap);
out.resizedBootsCold = c._booting === true;
const sr = () => c.shadowRoot || c.renderRoot;
const t0 = performance.now();
let veil = false;
while (c._booting && performance.now() - t0 < 2500) {
if (sr().querySelector('.bootveil')) veil = true;
await sleep(30);
}
out.resizedVeilShown = veil;
out.resizedSettles = c._booting === false;
wrap.remove();
return out;
});
for (const [k, v] of Object.entries(rez)) check(k, v);
await finish(browser, { ...res, ...rez });
+80
View File
@@ -0,0 +1,80 @@
// DEV-B703-02: temporary WS failures must NEVER blank a plan that was already
// shown. On the build before the fix _loadFromServer's catch nulled
// _serverCfg after 8 failed tries — the plan disappeared even though a valid
// config (the LS snapshot) was on screen. Stale-while-revalidate: the last
// valid config lives until a successful reload replaces it, and once the
// socket recovers the card revalidates QUIETLY on its own retry clock
// (willUpdate stops driving loads after 8 tries).
// planSurvivesOutage + reloadedAfterRecovery FAIL on the pre-fix build.
import { launch, check, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 820, height: 760 });
const res = await page.evaluate(async () => {
const out = {};
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
customElements.get('houseplan-card')?._warmBootReset?.();
// the LS snapshot is in place (the page's main card cached it): the new
// instance renders instantly from it — a wall tablet rejoining wifi
out.lsCachePresent = !!localStorage.getItem('houseplan_card_cfg_v1');
let fail = true;
let failedCalls = 0;
const base = window.__mkHass();
const mkFlaky = () => ({
...base,
callWS: async (m) => {
if (fail) { failedCalls++; throw new Error('ws down'); }
return base.callWS(m);
},
});
const c = document.createElement('houseplan-card');
c.setConfig({ type: 'custom:houseplan-card' });
c.hass = mkFlaky();
const wrap = document.createElement('div');
wrap.style.cssText = 'position:fixed;left:0;top:0;width:800px;z-index:99;background:#000';
wrap.appendChild(c);
document.body.appendChild(wrap);
await sleep(50);
out.rendersFromCache = !!c._serverCfg;
// hass keeps ticking while the socket is down — drive past the 8-try budget
for (let i = 0; i < 12; i++) {
c.hass = mkFlaky();
await sleep(60);
}
out.wsFailedEnough = failedCalls >= 8 ? true : `only ${failedCalls} failed calls`;
out.planSurvivesOutage = c._serverCfg ? true : '_serverCfg was cleared by the outage';
out.stillServerMode = c._serverStorage === true;
const sr = () => c.shadowRoot || c.renderRoot;
const stage = sr().querySelector('.stage');
out.roomsStillRendered = !!stage && stage.querySelectorAll('.room').length > 0;
// ---- the socket comes back: quiet self-driven revalidation ----
fail = false;
const t0 = performance.now();
while (!c._loadOk && performance.now() - t0 < 12000) await sleep(100);
out.reloadedAfterRecovery = c._loadOk === true ? true : 'never revalidated after recovery';
out.cfgValidAfterRecovery = !!c._serverCfg && Array.isArray(c._serverCfg.spaces)
&& c._serverCfg.spaces.length > 0;
wrap.remove();
return out;
});
for (const [k, v] of Object.entries(res)) check(k, v);
// ---- a LIVE instance: a failing config reload keeps the last config ----
const live = await page.evaluate(async () => {
const out = {};
const c = window.__card;
const before = c._serverCfg;
const origHass = c.hass;
c.hass = { ...c.hass, callWS: async () => { throw new Error('ws down'); } };
await c._reloadConfigOnly(true);
out.liveReloadFailKeepsCfg = c._serverCfg === before
? true : 'a failed reload replaced/cleared the live config';
c.hass = origHass;
return out;
});
for (const [k, v] of Object.entries(live)) check(k, v);
await finish(browser, { ...res, ...live });
+4 -3
View File
@@ -33,9 +33,10 @@ const out = await page.evaluate(() => {
'climate.trv': { state: 'off', attributes: { hvac_action: 'off' } },
}) === '';
// без hvac_action фолбэк на state
o.stateFallback = cls(trv, { 'climate.trv': { state: 'heat', attributes: {} } }) === ''
? false : cls(trv, { 'climate.trv': { state: 'heat', attributes: {} } }) === 'on';
// без hvac_action выбранный режим heat не доказывает реальную работу
o.modeWithoutActionStaysDark = cls(trv, {
'climate.trv': { state: 'heat', attributes: {} },
}) === '';
// горящая лампа устройства желтит значок, даже если primary — не она
const lamp = { id: 'l', primary: 'sensor.lamp_power', entities: ['sensor.lamp_power', 'light.lamp'], marker: null };
+5 -2
View File
@@ -44,8 +44,11 @@ out.zoomOut = await page.evaluate(() => {
out.zoomOutWorks = out.zoomOut.zoom === 0.5 && out.zoomOut.wider
&& out.zoomOut.centredX && out.zoomOut.centredY;
delete out.zoomOut;
out.floorIsHalf = await page.evaluate(() => { window.__card._resetZoom(); const c = window.__card;
c._applyView(0.1); return c._zoom; }) === 0.4; // clamped at the floor
// docs/CANVAS.md §5: the zoom-out floor moved from a fixed 0.4 of the old
// view_box to 1/3 of the CONTENT frame — three canvases of empty plane is as
// far as it is useful to go when the canvas has no edges.
out.floorIsThird = Math.abs(await page.evaluate(() => { window.__card._resetZoom(); const c = window.__card;
c._applyView(0.01); return c._zoom; }) - 1 / 3) < 1e-9; // clamped at the floor
await page.evaluate(() => window.__card._resetZoom());
// -- editor zoom is a working tool, not the viewing intent ----------------
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+14 -3
View File
@@ -72,6 +72,7 @@ const LAYOUT = {
d_bedlight:{s:'f1',x:0.75,y:0.6}, d_window:{s:'f1',x:0.9,y:0.85}, d_lock:{s:'f1',x:0.1,y:0.88},
d_motion:{s:'f1',x:0.4,y:0.75}, d_mower:{s:'garden',x:0.25,y:0.35}, d_gate:{s:'garden',x:0.73,y:0.22}
};
let CFG_REV=1, LAYOUT_REV=1;
const AREAS={living_room:{area_id:'living_room',name:'Living room'},kitchen:{area_id:'kitchen',name:'Kitchen'},
bedroom:{area_id:'bedroom',name:'Bedroom'},hallway:{area_id:'hallway',name:'Hallway'},garden:{area_id:'garden',name:'Garden'}};
function dev(id,name,model,area){return [id,{id,name,model,area_id:area,identifiers:[['demo',id]],entry_type:null,via_device_id:null}];}
@@ -120,12 +121,22 @@ let STATES={
function mkHass(){
return {
language:'en', locale:{language:'en'},
// audit P0-4: the card fail-closes editors without an admin user /
// can_write flag — the demo admin mirrors a real HA admin session.
user:{id:'demo', name:'Demo', is_admin:true},
devices:DEVICES, entities:ENTITIES, areas:AREAS, states:STATES,
floors:{g:{floor_id:'g',name:'Ground floor',level:0},u:{floor_id:'u',name:'Upstairs',level:1}},
callWS:async (m)=>{
if(m.type==='houseplan/config/get')return{config:CFG,rev:1};
if(m.type==='houseplan/layout/get')return{layout:LAYOUT};
return {ok:true,rev:2};
if(m.type==='houseplan/config/get')return{config:CFG,rev:CFG_REV,can_write:true};
if(m.type==='houseplan/layout/get')return{layout:LAYOUT,rev:LAYOUT_REV};
if(m.type==='houseplan/config/set')return{ok:true,rev:++CFG_REV};
if(['houseplan/layout/set','houseplan/layout/update','houseplan/layout/delete'].includes(m.type))
return{ok:true,rev:++LAYOUT_REV};
if(m.type==='houseplan/plan/optimize')return{
ok:true,config_rev:++CFG_REV,layout_rev:++LAYOUT_REV,can_undo:true};
if(m.type==='houseplan/plan/optimize_undo')return{
ok:true,config_rev:++CFG_REV,layout_rev:++LAYOUT_REV,can_undo:false};
return {ok:true};
},
callService:async (d,s,data)=>{
const eid=data.entity_id, st=STATES[eid];
+1398 -499
View File
File diff suppressed because one or more lines are too long
+75 -20
View File
@@ -9,8 +9,9 @@ that contains both the backend (`custom_components/houseplan`) and the Lovelace
houseplan-card/
├─ src/ # card sources (TypeScript + Lit 3)
│ ├─ houseplan-card.ts # the card: rendering, states, drag, tooltip, sticky header
│ ├─ hp-dialog.ts # shared HA/native modal shell and focus lifecycle
│ ├─ editor.ts # GUI config editor (ha-form + selectors)
│ ├─ rules.ts # icon rules (iconFor), filtering, groups, domain priority
│ ├─ rules.ts # icon rules (iconFor), filtering, groups, fallback order
│ └─ data/
│ ├─ house.ts # geometry: ROOMS (rooms→area), FLOOR_VB (viewBox), names
│ └─ backgrounds.ts # VECTOR plans (SVG base64) + FLOOR_BG_RECT (positioning)
@@ -40,6 +41,12 @@ houseplan-card/
`hass.devices/entities/areas` (registries). No direct WS connections.
4. **Reactivity.** Every state change in HA leads to set hass → re-render.
Temperatures/LQI/on-off are live by definition (verified by substituting state).
5. **One modal contract.** Card modals render through `hp-dialog`. In Home
Assistant it delegates surface semantics and trapping to `ha-dialog`; the
standalone demo falls back to native `<dialog>`. The wrapper owns the title,
initial focus, Escape close event and restore-focus session. Focus sessions
are scoped to a card shadow root so nested dialogs return to their parent
trigger and dialog replacement still returns to the original outside opener.
## Coordinate system
@@ -55,8 +62,10 @@ houseplan-card/
## Card data model (runtime)
`DevItem`: id (device_id), name, model, area, floor, icon, entities[], primary (the entity used for
more-info by domain priority), temp, members[] (light group), link/linkPrimary (Z2M group).
`DevItem`: id (device_id), name, model, area, floor, icon, entities[], primary
(the first resolved state entity for actions requiring one target), temp,
members[] (light group), link/linkPrimary (Z2M group). Marker state consumes
the complete resolved role, not this single compatibility field.
Built from the registries (`_buildDevices`), rules carried over 1-to-1 from the prototype:
- only devices with an area from the room list are shown;
@@ -72,8 +81,12 @@ Built from the registries (`_buildDevices`), rules carried over 1-to-1 from the
- Temperature: an entity with device_class=temperature / °C / `_temperature$` → label on the right.
- LQI (zigbee): the average over `*_linkquality` entities → label under the icon; color via
`lqiColor()`: ≤40 red → ≥180 green (hsl gradient). The room average is shown in the room tooltip.
The same tooltip includes the formatted clean-floor area (inner contour for
thick walls), and View hover gives every room an accent/brightness highlight.
- Icon state classes: on (yellow), open (orange: cover/valve/lock/binary_sensor
of problem classes), unavail (transparency).
of problem classes), unavail (transparency). Yellow remains on the marker in
source-glow fill mode: a light pool is spatial information, not a replacement
for the universal working-state plate.
## Sizes
@@ -87,11 +100,18 @@ Built from the registries (`_buildDevices`), rules carried over 1-to-1 from the
## Device markers (v1.6.0+)
Per-marker appearance (v1.22.0): `display: badge|ripple|icon_ripple` (+`ripple_color`,
`ripple_size`) draws presence-style pulsing rings gated by the pure `isActiveState` —
deliberately independent of the card-wide `live_states` toggle, with `unavailable` counting as
idle. `size` (icon multiplier via the `--dev-size` CSS var — value badges scale along) and
`angle` rotate/scale a single icon. Room drawing shows a live **ruler** (`segmentCm` +
Per-marker appearance: `display: badge|icon_ripple|value`. All three use one semantic
resolver (`src/device-visual.ts`) for availability, steady status and activity. `badge`
shows the icon/morph and status plate; `icon_ripple` additionally shows a finite event,
static presence, mechanical transition or actual-work ring; `value` replaces the icon
with the HA-formatted numeric value. A critical alarm is red in every presentation.
Runtime baselines are seeded as soon as a rebuilt registry becomes authoritative, before
the next HA snapshot is classified; source-key changes reset any finite effect immediately.
Legacy `display: ripple` is read as `icon_ripple` and rewritten on the next config save;
the backend accepts it only for compatibility. `ripple_color` and `ripple_size` remain the
stored names for the ordinary activity effect. `size` (icon multiplier via the
`--dev-size` CSS var — value badges scale along) and `angle` rotate/scale a single icon.
Room drawing shows a live **ruler** (`segmentCm` +
`formatLength`, metres or feet+inches by `hass.config.unit_system`); the scale is per-space
`cell_cm` (default 5 cm per grid cell).
@@ -109,7 +129,9 @@ references and duplicates by name|area. Manual files: transactional HTTP upload
`.storage/houseplan.config` (Store):
```json
{ "spaces": [{ "id","title","plan_url","plan_aspect","view_box":[4],
{ "spaces": [{ "id","title","plan_url","plan_aspect",
"plan_x","plan_y","plan_scale", // optional, docs/BACKDROP.md
"view_box":[4],
"rooms":[{"id","name","area","poly|x/y/w/h","open_to","settings"}],
"openings":[…], "decor":[…], "settings":{…} }],
"markers": [{ "id","binding":"device:<id>|entity:<eid>|virtual","hidden",
@@ -122,7 +144,9 @@ references and duplicates by name|area. Manual files: transactional HTTP upload
All coordinates are **normalized (0..1 of the canvas)**; the canvas is always
**square** (v1.48.0), render space `NORM_W × NORM_W` (1000×1000). A space has no
proportions of its own — `plan_aspect` is the IMAGE's ratio, used to letterbox
it centred on the square. The schema bounds geometry to ±4 with strictly
it centred on the square; the optional `plan_x`/`plan_y`/`plan_scale` then move
and uniformly scale that rectangle (`planRect`, docs/BACKDROP.md), and their
absence is the centred default exactly. The schema bounds geometry to ±4 with strictly
positive sizes (HP-1501/1502). `device_overrides`/`virtual_devices` are long
gone — markers carry everything, `marker.hidden` is the explicit
"hide from plan" flag seeded once by the old filter (docs/FILTERING.md).
@@ -146,11 +170,18 @@ bigger part keeps the room identity (name/area/devices).
## Markup editor (v1.4.0+)
State inside the card: `_markup` (mode), `_tool` (draw/delroom), `_path` (the current outline,
State inside the card: `_markup` (mode), `_tool` (draw/merge/split/resize/opening/openwall/
closewall/wallthick/delroom), `_path` (the current outline,
vertices on the GRID_N=240 grid). Clicks on the stage → `_svgPoint`→`_snap`. The outline is closed
= a click on the first vertex → area select (hass.areas) + name → room {poly}. Polygon rooms and
rectangles are rendered uniformly (hit-test: point-in-polygon / rect).
All committed plan-geometry mutations enter one named 50-command Undo/Redo stack. Ctrl+Z,
Ctrl+Shift+Z/Ctrl+Y and the toolbar buttons use the same stack; a new mutation after Undo drops
the redo branch. The local stack survives the server echo of its own writes, but is cleared when
a newer external config revision is adopted. Positional placement is always quantized to the plan
grid. Shift remains an angular precision modifier only and cannot create off-grid coordinates.
**A line is never an entity of its own (v1.19.0).** Nothing is persisted while you draw: an
outline you never close leaves no trace. Walls are *derived* from the room outlines by
`roomEdges(rooms)` (logic.ts) and deduped by `segKey`, so a wall shared by two rooms is emitted
@@ -192,6 +223,8 @@ double click → properties dialog. In markup mode the "Opening" tool handles cl
| `houseplan/config/get` | — | `{config, rev}` |
| `houseplan/trail/get` | — | `{trails: {marker: {current, previous}}}` — vacuum runs, raw robot coords |
| `houseplan/config/set` | `config`, `expected_rev?` | `{ok, rev}` / err `conflict`; event `houseplan_config_updated` |
| `houseplan/plan/optimize` | `config`, `layout`, both expected revisions | crash-resumable two-store commit + one-deep backup |
| `houseplan/plan/optimize_undo` | both expected revisions | restores backup only before any later edit |
| `houseplan/plan/set` | `space_id`, `ext` (svg/png/jpg/webp), `data` (b64, ≤8 MB) | `{ok, url}` — writes `<space>.<token>.<ext>`, deletes nothing |
| `houseplan/plans/list` | — | `{plans: [{name, url, size, modified, used_by}], total}` (newest 60) |
| `houseplan/plans/delete` | `name` | `{ok, removed}` / err `in_use` |
@@ -382,16 +415,38 @@ hash falls back to the default.
a per-light `clipPath` = zone polygons + doorway sectors, each contour a
SEPARATE clipPath child (children union; subpaths of one nonzero path
cancel on opposite windings — field bug v1.36.3). Radius: global
`settings.glow_radius_cm` + per-marker `glow_radius_cm`.
- **Open boundaries** (v1.37): `room.open_to: [roomId]` symmetric links;
light zones are connected components (`openZoneOf`); shared stretches
computed by collinear-overlap math (`sharedBoundary`) and drawn as a TRUE
dash — both room outlines and derived wall segments are trimmed
(`outlineWithout`/`cutSegments`).
`settings.glow_radius_cm` + per-marker `glow_radius_cm`. On a thick wall the
doorway sector is the angular intersection of its near- and far-face clear
spans, so the jamb returns clip the spill as a real opening tunnel.
- **Open boundaries** (v1.37, revised 2026-08): `space.open_spans` hold
geometric virtual stretches; `room.open_to` remains the light-zone index
derived from spans (legacy `open_to`-only configs expand to full
`sharedBoundary` on read). Shared stretches drawn as a TRUE dash; outlines
trimmed (`outlineWithout`/`cutSegments`). In View the dash group is painted
before thick wall bodies, letting real jambs mask its centreline ends; in all
editors it is painted after the bodies so saved spans and previews remain
fully visible. Geometry mutations clip one stored
span to **every** surviving shared segment. Adjacent pieces owned by different
room pairs stay separate: their midpoints are the source of the corresponding
`open_to` links after Split (`AUD-159B7-01`).
- **Marker controls** (v1.36): `marker.controls[]` (lights/switches only)
toggled as one HA-group-semantics service call on the marker's EXPLICIT
tap_action=toggle; icon state/tint mirrors the targets. `primaryEntity`
picks in tiers so domain priority beats registry `hidden` (grouped lamps).
tap_action=toggle; icon state/tint mirrors the targets.
- **Resolved device state** (2026-08-06): HA provides states per entity, not
one state per device. `resolvedDeviceStateEntities` therefore starts from
uncategorised registry entities, resolves one functional role (whole-device
domains, then semantic binary signals, then switches), and aggregates
passive readings as the final fallback. `_visualSamples` consumes the full
result; `primaryEntity` only selects its first member where a single action
target is required. Integration option switches can no longer make an
otherwise healthy device working or unavailable merely by list order.
- **Resolved light sources** (UX-12): `resolvedLightSources(hass, devices,
room?)` is the only light-membership resolver. Per marker the precedence is
`controls[]` -> the primary controllable entity when `is_light` is set ->
automatic `light.*`. It excludes hidden markers, de-duplicates entity ids,
and honours `marker.room_id` before an HA area. Glow, Light fill, room light
stats, marker indication/card ordering and group toggle all consume this
result instead of maintaining separate domain tests.
- **Island rooms** (v1.34): full nesting is legal (`polyContainsPoly`);
parents render as evenodd paths with holes (`islandsOf`).
- **Kiosk mode** (v1.41): a card-config flag, not a mode — `_setMode` is
+270
View File
@@ -0,0 +1,270 @@
# The backdrop picture — the spec (source of truth)
*Owner-approved 2026-08-04. Companion to docs/CANVAS.md (the plane the
picture lives on) and docs/RESIZE.md (the other transform frame).*
Until v1.58.0 an uploaded plan image was nailed to the canvas: centred, at
its own proportions, filling the square. If it did not line up with the
rooms you had drawn — or with the rooms you were about to draw — the only
recourse was to edit the file. And because it filled the canvas, it also
decided where the opaque **paper** was, which quietly made a picture plan
and a drawn plan two different kinds of thing.
This closes both. The picture is now **one of the objects on the plane**:
it can be moved and scaled like anything else, it counts as content for
the frame, and it no longer makes paper of its own.
---
## §1 Model — three optional numbers
A space gains three optional fields, next to `plan_url` / `plan_aspect`:
| Field | Meaning | Units | Range |
| --- | --- | --- | --- |
| `plan_x` | offset of the image's **top-left corner** from its default place | normalised (as rooms, decor, openings) | ±`CANVAS_LIMIT` (±5000) |
| `plan_y` | the same, vertically | normalised | ±`CANVAS_LIMIT` |
| `plan_scale` | **one** uniform multiplier for both sides, anchored at the (already offset) top-left corner | multiplier | `0.01 … 100` |
There is **no rotation** and no independent width/height: the picture keeps
its own aspect ratio for ever, which is what makes "scale" a single number.
**The absence of all three is exactly the pre-v1.58.0 behaviour.** No
migration runs, nothing is written into anybody's store, and a plan that has
never been through the backdrop editor renders bit-identically. Junk (a
string, `NaN`, a zero or negative scale) is treated as absence, not as an
error — the same reading rule the rest of the geometry uses.
One function owns the arithmetic, `planRect()` in `src/space-geometry.ts`:
```
base = fitInSquare(plan_aspect, 1000) // the centred default
rect.x = base.x + plan_x * 1000
rect.y = base.y + plan_y * 1000
rect.w = base.w * plan_scale
rect.h = base.h * plan_scale
```
`spaceModels()` calls it, so **every** consumer — the full card, the static
`houseplan-space-card`, the kiosk, the content frame — reads the same
rectangle. The card's model cache key (`_cfgFingerprint`) carries the three
fields, so a drag invalidates the memo.
`validation.py` mirrors the ranges (`PLAN_SCALE_MIN` / `PLAN_SCALE_MAX`
alongside `CANVAS_LIMIT`). Detaching the image (space dialog → «Нарисую
комнаты вручную») deletes all three: there is nothing left for them to
describe, and a stale transform must not silently apply to the next upload.
---
## §2 The transform frame
**Where.** The **backdrop editor** (`mode: 'decor'`, «Редактор подложки»).
The frame is on screen as soon as that editor opens (tools **select** and
**«Картинка-подложка»**); the drawing tools own the drag, so the frame
steps aside for them. Nowhere else: View, the Plan editor, the Device
editor and the kiosk never touch the picture. A space with no image never
grows a frame, and its toolbar is unchanged.
**What.** A dashed outline on the picture's rectangle plus four corner
handles. **Two radii, one gesture:** the HIT circle is 2 % of the visible
view — finger-sized at any zoom, the same rule the vacuum fit frame uses —
and is invisible; the bead you actually see (`.bdknob`) is a **quarter** of
it (owner, 2026-08-05: «уменьшить в 4 раза… они постоянно гигантские»). The
clickable area is unchanged; only the ink shrank, so the frame stops
covering the picture it frames. The same split now governs the text block
(docs/LIVE-TEXT.md §3), the room-resize corners and the vacuum fit. The
outline never takes a pointer; the handles do, with `setPointerCapture` and
`nwse-resize` / `nesw-resize` cursors. Under the picture's own tool the
stage cursor is `grab`, and `grabbing` while a gesture is live.
**Move** — press anywhere inside the picture and drag, **under the
«Картинка-подложка» tool**. Why a tool and not just the select tool: the
picture's body is most of the screen, and claiming it would take away the
one-finger pan of the whole plane, which the owner asked for on 2026-08-04
(«таскать план при любом масштабе») and `smoke_pan_any_zoom` guards. The
corner handles are precise targets and need no such protection — they are
live whenever the frame is. The gesture is also only started INSIDE the
rectangle, so pressing beside the picture pans even under its own tool.
**…and that tool is ARMED when the editor opens** (owner, 2026-08-05: «не
получается двигать картинку-подложку в режиме редактора подложки»). The
frame is drawn on open, which promises a draggable picture, while the
promise was only kept once the user found a tool they had no reason to look
for. So `_setMode('decor')` selects `backdrop` instead of `select` whenever
the space HAS a picture — the editor is named after it. Nothing else
changes: `select` is one click away and still leaves the body to the pan, a
space with no picture still opens on `select`, and Esc still returns to
`select`. This is a discoverability fix, not a new gesture.
**Nothing may rescale the stage mid-gesture.** Two things wanted to, and
both moved the picture away from the finger before they were stopped:
* the picture is a content item, so dragging it GROWS the editor's content
frame, which rescales the view — `_frameOf()` therefore returns the
memoized frame untouched while `_bdDrag` is live, and catches up on
release;
* «Вернуть картинку» appears the moment the picture has moved, and the
toolbar sits above the stage — so it is withheld until the gesture ends.
**Scale** — pull a corner. Uniform, about the **opposite** corner, which
stays exactly where it was (the mechanic borrowed from the vacuum
calibration panel). `k = max(|dx| / base.w, |dy| / base.h)`, so the gesture
follows whichever axis you pulled harder, and both sides then follow `k`.
**Nothing else moves.** Rooms, openings, devices, decor and room labels are
untouched by both gestures — this is a picture-only transform, and the
smoke asserts the room geometry is byte-identical afterwards.
**Undo** is the «Вернуть картинку» button in the backdrop toolbar: it
appears only once the picture HAS been moved, and it deletes the three
fields (back to centred, own size).
---
## §3 THE PAPER IS THE ROOMS — and only the rooms
*This is a behaviour CHANGE, deliberately.*
The opaque "paper" exists so the scene background — `bg_color`, or the
`daynight` sky (docs/SUN.md) — never bleeds through the plan itself. Until
v1.58.0 it had two forms: the ROOM CONTOURS for a drawn plan, and **the
image rectangle** for a picture plan.
The second form is gone. The paper is now `paperRoomShapes(space.rooms)`
in every case (or `paperRoomShapesWithWalls` when a space carries wall
thickness — docs/WALL-THICKNESS.md §4 — so the sheet grows under shared
slabs and the scene never shows through a hatched body):
* one opaque shape per room, in exactly the room's own geometry — an
L-shaped house or a pair of detached buildings never grows a bounding
rectangle;
* an empty space has **no paper at all**, image or no image;
* its colour is unchanged (`styles.ts .hp-paper`): white on a hand-drawn
plan (`.stage.noplan`), the theme card background where an image is
attached in **View**; every editor (plan / devices / decor) forces white
under the grid even with a backdrop, so the drawing sheet stays the same
sheet as a hand-drawn plan. `daynight` dims it via the `.zoomwrap`
brightness filter only — its alpha stays 1.
**Layer order**, top to bottom, in both renderers:
```
devices / labels
sun wedges and rays
walls, openings, room fills, decor
THE PICTURE ← above the paper, below the geometry
the paper (room contours)
the scene background (bg_color / the daynight sky)
```
**The consequence, and the owner knows it:** a picture with transparency
over a space with no rooms drawn shows the scene background through itself.
That is the honest reading of "the paper belongs to the rooms" — draw the
rooms and the paper appears under them.
---
## §4 The picture is content
`contentItems()` has always counted the image rectangle as one content item
alongside the rooms; it now counts the **transformed** one. So the frame,
«Вписать всё», the zoom floor and the pan slack (docs/CANVAS.md §4–§5) all
follow the picture wherever it is dragged, and a picture scaled down to a
quarter tightens the frame around it instead of framing empty plane.
`iconUnit()` deliberately does **not** count it: an icon is a percentage of
the drawn plan (docs/CANVAS.md §6), and scaling the backdrop must not
resize every marker on it.
---
## §5 Snap
Both gestures obey the canvas snap contract (docs/CANVAS.md §9):
| Gesture | What is snapped |
| --- | --- |
| move | the resulting **top-left corner** — the anchor, not the delta, so one drag is enough to put a legacy off-grid picture onto the lattice |
| scale | the dragged corner **along the picture's longer side**; the scale is then read back off it |
The fixed corner of a scale keeps its (on-grid) place and the long side
lands on a node. The short side follows from the aspect ratio and generally
does **not** — that is what "uniform, no stretch" costs, and it is the
right trade: a picture whose proportions drift is a broken picture.
There is no modifier that suspends positional snapping (§9.4). The
`±CANVAS_LIMIT` clamp rides along — `_snap()` and `clampCanvasN` are the
only bound either gesture has.
---
## §6 Live measurements
While the picture is dragged or scaled, one badge rides its centre with the
picture's **real size**, width × height, through the space's `cell_cm` and
the HA unit system (metres or feet). It is the same `_fmtLen`
(`segmentCm` → `formatLength`) and the same `.measurelabel` element the
wall ruler, the opening shoulders and the room resize use — the card states
a length exactly one way.
---
## §6b One tool at a time — a drawing tool owns the canvas
The backdrop editor has seven tools, and they are **exclusive**: only
**select** talks to an existing shape, only **erase** deletes one, only
**«Картинка-подложка»** grabs the picture's body, and the four drawing
tools (**line**, **rect**, **ellipse**, **text**) do nothing but create.
The rule exists because of a concrete failure (owner, 2026-08-04): *«нельзя
поставить начало линии на конец другой — при клике на конец выделяется
первая линия, а не начинает рисоваться новая»*. The shape's own
`pointerdown` handler ran first, stopped the event and did nothing under a
drawing tool, so the press never reached the stage and no draft was born —
exactly on the one spot where a new line most wants to start, since §5's
snap anchors include every line end.
So, under a drawing tool (and under the picture tool):
* `.dshape` is `pointer-events: none` — an existing figure is not a target
at all, and the cursor is the stage's;
* `_decorShapeDown` returns before `stopPropagation` for any tool that is
not select/erase, so a synthetic press cannot swallow the gesture either;
* `_decorPointerDown` only looks for a shape under the pointer for
select/erase, and otherwise starts the draft at the pressed point;
* the transform frame's corner handles stay off (§2) — they were the first
thing taught not to swallow the first point of a line.
Selecting, moving and the text dialog's double-click therefore live in the
select tool only. `demo/smoke_decor.mjs` asserts both halves: a press on an
existing line starts a new line **from that end** under the line tool, and
still selects and grabs it under select.
---
## §7 What is deliberately NOT done
* **No rotation.** A skewed scan is a scan to re-take, not a plan to skew
the whole editor's maths for.
* **No non-uniform scale.** See above: the picture keeps its ratio.
* **No numeric fields** in the space dialog. The frame is the interface;
the numbers exist for the config, not for typing.
* **No per-room clipping of the picture.** It is one rectangle.
* **No transform for the sun's paper/wedges.** Those follow the rooms, and
now so does the paper — one rule, no second geometry.
---
## §8 Where it lives
| Concern | File |
| --- | --- |
| the rectangle (`planRect`, `PLAN_SCALE_MIN/MAX`), content items | `src/space-geometry.ts` |
| the frame, the gestures, the live badge, the reset | `src/houseplan-card.ts` (`_bd*`, `_renderBackdropFrame`) |
| the frame's look, the paper, the cursors | `src/styles.ts` (`.bdframe`, `.hp-paper`) |
| the static card's copy of the paper + picture order | `src/space-render.ts` |
| the stored ranges | `custom_components/houseplan/validation.py` |
| unit coverage | `test/backdrop.test.mjs` |
| DOM coverage | `demo/smoke_backdrop.mjs`, `demo/smoke_bg_color.mjs` §11 |
| screenshots | `demo/shot_backdrop.mjs` |
+520
View File
@@ -0,0 +1,520 @@
# Infinite canvas — the spec (source of truth)
Status: approved by the owner 2026-08-03, **shipped in v1.57.0**.
Scope decisions final: there is no "plan size" any more, the canvas
is conceptually unbounded, storage does not change, and the
opening view is always derived from what is actually drawn.
## The problem it closes
Several users drew plans that ran past the edge of the grid and then
could not place devices outside it. The only workaround was to redraw
the whole plan. The card behaved as if the normalised unit square
(`0..1`, rendered as `NORM_W x NORM_W = 1000 x 1000` units) were a
sheet of paper with edges. It is not a sheet of paper — it is just the
coordinate system.
## Principle
1. **Coordinates keep their meaning.** Rooms, openings, decor and
device positions are still stored normalised. `1.0` is still the
same distance it always was; `cell_cm` still ties a grid cell to
real centimetres. **No data migration.** An existing plan opens as
before — including the size of the markers (§6).
2. **`0..1` is not a boundary, it is an origin.** Any finite
coordinate is legal. `2.7` simply means "2.7 canvas widths to the
right of the origin".
3. **Nothing in the product may say "you cannot go past the edge".**
No clamp on drawing, dragging, decor or device placement stops at
a rectangle.
4. **What is stored is only where something is drawn.** There is no
stored extent to keep in sync.
## Model
| Concept | Before | Now |
| --- | --- | --- |
| Canvas | square `0..1`, rendered `0..1000` | unbounded plane, same units |
| `space.view_box` | the frame; everything was clamped into it | an OPTIONAL hint for the very first frame; used only when there is nothing to frame |
| "fit" rectangle | `view_box` (or the content bbox in view mode) | always the **content frame** (§4) |
| Zoom out floor | `ZOOM_MIN = 0.4` (fraction of `view_box`) | 3x the content frame (`MIN_ZOOM = 1/3`) |
| Pan bounds | content must cover the scene, and only above 100% zoom | content frame + one screen of slack in each direction, at any zoom |
| Icon size | % of `view_box`, i.e. grew with zoom | % of `iconUnit` — still grows with zoom (§6) |
| Validation range | `+/-4` | `+/-5000` (§3) |
### Render frame vs. view
* **Frame** (`_baseVb()` / `spaceFrame()`) — the rectangle that "fit to
screen" fits and that zoom `1` means. It is recomputed from content,
never stored.
* **View** (`_view`) — the SVG `viewBox` actually painted. It is in
absolute render units, so recomputing the frame never teleports the
plan; it only changes what zoom `100 %` means and where panning
stops.
## §3 Validation limits
`custom_components/houseplan/validation.py`:
| Symbol | Before | Now | What it is |
| --- | --- | --- | --- |
| `_COORD` (layout x/y) | `-4 .. 4` | `-5000 .. 5000` | coordinate |
| `_GEOM` (room x/y, poly points, opening x/y, `view_box` origin) | `-4 .. 4` | `-5000 .. 5000` | coordinate |
| `_EXTENT` (room w/h, `view_box` w/h) | `0.001 .. 4` | `0.001 .. 5000` | size — strictly positive |
| `_NORM` (decor x/y/w/h) | `-1 .. 2` | `-5000 .. 5000` | coordinate |
| opening `length` | `0.001 .. 1` | `0.001 .. 5000` | size — strictly positive |
`+/-5000` is **garbage insurance, not a frame**. At the product's own
scale (`cell_cm` = 5 by default, 240 grid cells across the unit width)
one canvas width is ~12 m, so `5000` is ~60 km of plan — unreachable
in a home, while still stopping a stored `1e100` from making the plan
invisible for every client (the failure HP-1500-03 / HP-1501-01
closed). Sizes stay strictly positive because SVG divides by them and
`viewBox="0 0 0 0"` paints nothing (HP-1502-01).
## §4 The content frame
`contentFrame(items, opts)` in `src/space-geometry.ts` — pure, unit
tested. Input is a list of **items**, one per drawn/placed object:
* every room (its own bounding box — polygon or legacy rect);
* the backdrop image rectangle, when the space has one;
* every opening (door/window) end-to-end segment;
* every decor shape;
* every device the layout actually places in this space **and that the card
actually draws** — a HIDDEN device (docs/FILTERING.md) is not content: the
frame is presentation, and an object nobody can see must not decide what the
plan opens on. It keeps its auto-grid cell and every aggregation it feeds;
it simply is not an item here. Ghosts in the device editor are not items
either — reaching them is §5's job, not the opening view's.
Output:
```ts
{ core: Rect | null, all: Rect | null, outliers: number }
```
* `core` — bbox of the **main mass**, padded. This is the opening view.
* `all` — bbox of **everything**, padded. This is what "show the far
objects" fits.
* `outliers` — how many items were left out of `core`.
Both rectangles are padded by `pad` (default `0.05`) of the longer
side, and degenerate axes are inflated (see §4.2).
For a space with a **backdrop image** the image rectangle is one of the
items, so the image still sets the extent — cropping to the rooms would
hide the parts of the picture nobody has outlined yet (owner, point 2).
Fallback order when there are no items at all: the stored `view_box`
(the "hint"), then the legacy unit square. This is the only place
`view_box` is still read for framing.
### §4.1 Outlier rejection
An object standing an order of magnitude further away than the rest
must not decide the opening view, but must still be reachable. The
criterion is deliberately rank-based (medians/percentiles), so a single
absurd value cannot move it:
1. Items whose coordinates fall outside the sane range
(`+/-CANVAS_LIMIT`, i.e. the same `+/-5000` the backend accepts) are
dropped outright — that is corruption, not content.
2. With fewer than `MIN_VOTERS = 4` items no outlier is declared:
with two objects there is no majority to be far from.
3. `m` = component-wise **median** of the item centres.
4. `d_i` = Chebyshev distance `max(|x_i-m_x|, |y_i-m_y|)` from `m`.
5. `spread` = the **75th percentile** of `d`, floored at
`MIN_SPREAD = 0.05 * NORM_W` (50 render units, about a small room),
so a tightly clustered plan does not call its own neighbour an
outlier.
6. Item `i` is an outlier iff `d_i > OUTLIER_K * spread`, with
`OUTLIER_K = 10` — literally "an order of magnitude further than
the bulk".
7. **Majority veto**: if more than a third of the items came out as
outliers, this is not a plan with strays — it is a spread-out plan.
No outliers are declared and `core = all`.
When `outliers > 0` the card shows an unobtrusive inline hint (no
modal) — "there are objects far from the plan" with a **Show** action
that fits `all`.
Whatever the vote rejects is rejected **everywhere the plan is measured**,
not only in the viewBox: §6's `iconUnit` runs the same vote over the rooms.
One notion of "the plan", or a stray the frame had just thrown out came back
as icons ninety times too big (audit DEV-2C947-03).
### §4.3 The frame in an editor
Inside an editor the frame only ever **grows** (`unionRect` with the previous
one): it bounds pan and defines what zoom 1 means, and a frame that shrank the
instant a room was deleted would move the ground under a live gesture.
That union belongs to the editor session and to nothing else. The memo key
carries the growth flag, so leaving for View recomputes the frame from the
content instead of inheriting the union — otherwise a room dragged five
canvases away in the Plan editor kept View framing the empty ground it had
left behind, until some unrelated model change happened to invalidate the memo
(audit DEV-2C947-02).
### §4.2 Degenerate frames
An SVG `viewBox` with a zero axis paints nothing, so a frame still has
a floor:
* an axis shorter than `DEGENERATE = 0.03 * NORM_W` is grown to
`FLOOR = 0.2 * NORM_W`, centred on itself.
That covers "one lone marker" and "a collinear row of markers". A real
thin shape (a 100-unit corridor) is well above the threshold and keeps
its tight frame. This is the only survivor of the old safety props —
the `-25 % .. 125 %` envelope that used to reject far content is gone,
replaced by §4.1 (the envelope WAS the bug: content past the old square
was silently excluded from the frame).
## §5 Zoom and pan
* **Zoom in** — unchanged, `ZOOM_MAX = 8`.
* **Zoom out** — `MIN_ZOOM = 1/3`: you can see three times the content
frame and no further. Empty space beyond that is not information.
* **Pan** — available at **every zoom**, in view mode and in every
editor, and bounded by the content frame inflated by
`PAN_SLACK = 1.0` of `max(view, frame)` on each side. You can walk
off the plan (there is no edge), but not into infinity.
Until 2026-08-04 a drag moved the view only while `zoom > 1`: on the
old bounded canvas a plan smaller than the scene genuinely had
nowhere to go, so the gate was harmless. With no edge left it was
simply a missing feature, and the owner reported it as one
(«таскать план при любом масштабе»). The zoom no longer takes part
in the decision — `_clampView` alone says how far you may walk.
* **Who owns the pointer.** A drag pans only when it starts on empty
scene: the room-resize handles, device badges, openings, room labels
and the decor shapes take the pointer first (`_stagePointerDown`
bails out on them), and a drawing tool that consumes the press —
decor line/rect/ellipse/text — bails out too. Two fingers are always
a pinch, never a pan. On a **kiosk** screen at swipe zoom (`≤ 1`,
more than one space) a *horizontal* drag belongs to the floor swipe:
the gesture is classified once, on the first movement past 8 px, and
keeps that role until the finger lifts (`_panLock`), so the plan
never slides under a swipe and a vertical drag still pans.
* **The lock is final, at the release too** (audit DEV-1DA1-02). The
release used to ask `swipeTarget()` again from the raw start→end
vector, ignoring the lock — so a *curved* gesture (a short vertical
lead-in that locks `pan`, then a long horizontal sweep) dragged the
plan under the finger and still landed on another storey when it
lifted: the worst kind of surprise on a wall tablet. `_panLock ===
'pan'` now means no floor change, whatever the overall vector ends up
looking like; only a gesture locked as `swipe` may reach
`swipeTarget()`, and it never pans on the way. A motionless tap locks
nothing, so the double-tap zoom reset is untouched.
* **"Home is that way" arrow** — when the content frame is entirely
outside the current view, a small pointer appears at the view edge
in the frame's direction. Clicking it fits the content. Cheap
insurance against getting lost in the empty plane.
## §6 Icon size — a percentage of the plan
**Unchanged behaviour** — an icon scales with the plan, exactly as it
always did. (A first cut of the infinite canvas made it a fixed
percentage of the viewport; the owner looked at it on 2026-08-03 and
asked for the original back. The history is kept here because the
reasoning for the *numerator* below is the whole point.)
Before the infinite canvas:
```
--icon-size: iconPct * vb.w / view.w (cqw)
```
Now (`iconCqw()` in `src/space-geometry.ts`, pure and unit tested):
```
--icon-size: iconPct * iconUnit(space) * kioskScale / view.w (cqw)
```
Read it in render units: a marker always occupies
`iconPct/100 * iconUnit` **render units** of the plan, whatever the
frame and whatever the zoom. Dividing by the width of the visible view
turns that into the percentage of the container `cqw` means. Zoom in
2x and the marker is 2x bigger, together with the walls it sits on.
**Why the numerator changed.** `vb.w` was the stored `view_box`, and
`view_box` is not a frame any more (§4). Keeping a fixed `NORM_W`
there would have been worse than wrong: on a plan drawn 2 canvases
wide the frame is ~2.2 canvases, so every marker would come out 2.2x
smaller than on an ordinary plan — and 55x smaller on a plan 50
canvases out, i.e. an invisible dot. `iconUnit(space) =
max(NORM_W, mainMassOfTheRooms)` — the extent of the rooms **after the same
outlier vote §4.1 applies to the frame** (rooms only, so the full card and the
static card cannot drift apart), and it is:
* **exactly `NORM_W` for every plan that fits the old square**, and the
editor has only ever stored `view_box: [0,0,1,1]`, so `iconUnit ===
vb.w` there and the rendered pixel size is bit-identical to the
pre-canvas card (verified against the v1.56.0 bundle at a fixed view:
`3.400 / 3.091 / 6.182 / 12.364 cqw`, i.e. `28.52 / 26.11 / 50.22 /
98.44 px`, both bundles);
* **proportional to an outsized plan**, so a runaway plan gets markers
of the same apparent size as an ordinary one.
Everything else is untouched: the per-device multiplier `marker.size`
and the kiosk icon/font scales still feed `--dev-size`, and every
satellite (badges, LQI chips, presence rings, ripples) still derives
from `--dev-size`. The full card and the static
`houseplan-space-card` call the same `iconCqw()` — the static card has
no zoom, but its frame is the content now, so a bare `iconPct` would
have made its markers shrink as the frame tightened.
**Auto-placement spacing** (`defaultPositions` -> `declump`) is measured
in render units and uses the same `iconUnit`, so the icon's footprint
and the distance markers are pushed apart by can never drift apart — and
the outlier vote reaches the spacing through the very same call.
## §7 Adaptive grid
The drawing grid is a dot pattern at `pitch = NORM_W / GRID_N`. On a
plan several canvases wide, zoomed out, the dots merged into a grey
wash. `gridLevels(pitch, pxPerUnit, minPx)` (pure, unit tested) picks:
* `fine` — the smallest multiplier from `1, 2, 5, 10, 20, 50, 100,
200, 500, 1000` whose on-screen step is at least `minPx` (7 px);
finer dots are simply not drawn;
* `coarse` — the next multiplier that is at least `5 x fine`, drawn
bigger/darker, so the eye keeps a scale reference (the usual CAD
every-5th/10th-line convention);
* `null` when even the coarsest step would be sub-pixel — then there
is no grid at all rather than a grey fog.
The grid rectangle also follows the **view**, not the old `view_box`,
so it is there wherever you pan.
The dots are a **hint, not content** (owner 2026-08-04): at full strength
they argued with the plan on white paper. `.griddot` is `opacity: 0.35`,
`.griddot.major` `opacity: 0.5` (styles.ts) — both muted, the CAD
hierarchy kept, coarse nodes still denser than fine ones. The grid lives
in the editors only; View never draws it (`smoke_grid_fade`).
## §8 Toolbar
The middle button of the zoom control was "Reset zoom" (`_resetZoom`,
disabled at zoom 1). It **is** the fit-everything action, so it was
re-labelled rather than duplicated: `title.zoom_fit` — "Fit all" /
«Вписать всё», icon unchanged (`mdi:fit-to-page-outline`), and it is no
longer disabled at zoom 1 (at zoom 1 off-centre it still has work to
do). It fits `core` — the same rectangle the plan opens with. Far
objects are reached through the outlier hint's **Show** action, which
fits `all`.
## §9 Drag limits and the snap contract (dev, DEV-B58)
### 9.1 One bound, and it is the backend's
v1.57.0 freed the FRAME and the DRAWING, but not the drag handlers. Two
of them still clamped, and the owner and a user hit both:
| Handler | Old clamp | Effect |
| --- | --- | --- |
| `_pointerMove` (device marker) | `_baseVb()` ± a 0.8 % inset — the CONTENT FRAME | 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) | `_spaceModel().vb` — the space's STORED `view_box` | worse: that is `[0,0,1,1]` for every plan the card has ever written, i.e. literally the old square. A room drawn at 2.5 had a name that could not reach its own room |
| `_decorCommitDraft` / decor text anchor | *none at all* | asymmetric with `_decorMoveUpdate`, which did clamp — a draft could be born outside the range the mover then refused to leave |
The rule now: **an editor gesture has exactly one bound, `±CANVAS_LIMIT`
(±5000 normalised, ±`SANE_LIMIT` in render units), and it is the same
number `validation.py` enforces.** It is a garbage limit — insurance
against a stored `1e100` — and never a frame. `clampCanvasR` /
`clampCanvasN` (`space-geometry.ts`) are the only two functions that may
impose it, and `_snap()` applies `clampCanvasR` on the way out, so every
gesture that goes through the snap is bounded by construction.
Room drawing, split, resize and opening placement had no clamp before
and still have none of their own — they inherit it from `_snap()` /
`clampCanvasN` at the write.
### 9.2 The grid step never changed
`_gridPitch = NORM_W / GRID_N = 1000 / 240`. Both constants; neither
depends on the content frame, the view, the zoom, `view_box`, or
`cell_cm`. `git log -S` confirms neither has been touched since v1.4.0
(the one historical change, `GRID_N` 120 → 240, halved the step, so the
old nodes stayed a subset of the new ones and every position was
preserved).
**So the infinite canvas did NOT move any existing element off the
grid.** `gridLevels()` (§7) chooses which multiples of that pitch are
still legible at the current zoom — it changes what is DRAWN, never what
is SNAPPED TO. An element that looks off-grid is off-grid because it was
placed by something that never snapped, not because the lattice moved.
### 9.3 What snaps, and to what
Two kinds of element, because a door rounded to a grid node while its
wall runs diagonally is broken geometry, not a tidy plan:
**GRID-BOUND — rounded to the nearest node:**
| Element | Where |
| --- | --- |
| the backdrop picture: move (its top-left corner) and corner scale (the dragged corner, along the longer side) | `_bdMove` → `_snap` / `snapToGrid` (docs/BACKDROP.md §5) |
| room vertices (draw tool) | `_markupClick` → `_snap` |
| split tool's interior vertices | `_splitClick` → `_snap` |
| resize edge drag / corner scale | `_rszMove` → `_snap` |
| decor draft endpoints, text anchor | `_decorPointerDown` / `_stagePointerMove` → `_snap` |
| decor move | `_decorMoveUpdate` → `_snap` of the resulting ANCHOR |
| device markers | `_savePos` |
| room labels | `_labelMove` → `_savePos` |
| auto-placed markers (`defaultPositions`), the `spaceCenter` fallback, an undragged room label | `snapPt` |
**WALL-BOUND — projected onto the wall, then the offset ALONG the wall
quantised to the same step, measured from the wall's first corner:**
| Element | Where |
| --- | --- |
| openings, placed and dragged | `snapToWall(..., { step, length })` |
| split tool's points ON a wall | `snapPointAlongPoly` |
On an axis-aligned wall whose corners are on the grid — every wall the
editor itself draws — the two rules give the same point. An opening is
also kept inside its wall by half its own length.
Three things were fixed here besides the new coverage:
* `_decorMoveUpdate` used to snap the **delta**, which preserves any
off-grid offset the shape already had for ever, one step at a time.
It snaps the resulting anchor now, so one drag is enough.
* `defaultPositions` / `labelPos` / the `spaceCenter` fallback placed
auto elements at centroids, which are not nodes for an odd-sized or
polygonal room. These were the most likely source of "some elements
are between the points" on an untouched plan.
* `snapToGrid` and `snapR` now return a value that is already on a node
**bit-identical**. The round trip through a non-dyadic pitch
(1000/240) otherwise turns an exact `500` into `500.00000000000006`,
and "is this on the grid?" starts answering no.
### 9.4 Shift
There is no free-position mode. `Shift` never suspends coordinate
snapping: free anchors always land on grid nodes, and wall-bound objects
always remain projected and quantised along their wall. `Shift` is
reserved for explicitly angular controls (fine decor rotation and the
compass step) and cannot create an off-grid object.
### 9.5 «Оптимизировать планы» — explicit whole-plan maintenance
Existing and imported plans may still hold coordinates between the
nodes. New editor operations cannot create more. General settings contain
a **Plan maintenance** group whose action previews and then repairs old
data through all current passes: model upgrades, mandatory grid
alignment, exact open-span canonicalisation and wall-interval compaction.
Why an action rather than a silent migration:
1. It moves the user's data without asking. A house plan is a drawing;
the card has no mandate to redraw it on a version bump.
2. A silent migration is unattributable. When a room looks 3 cm wrong
the owner cannot tell whether the card did it or they did.
3. An update that touches stored geometry cannot be rolled back by
downgrading the card. The explicit action has a one-deep snapshot and
can also simply not be pressed.
`optimizePlans(config, layout)` (`src/plan-optimizer.ts`) is the pure
orchestrator. It converts only legacy fields with an exact lossless
mapping, materialises legacy `open_to`, calls the grid projection,
rekeys exact wall/open-span endpoints onto the moved rooms, merges
touching virtual spans per room pair, compacts consecutive real-wall
intervals of equal thickness and stamps `model_version`. Unknown fields
are preserved and every pass is idempotent.
The optimizer deliberately does **not** alter backdrop calibration or
saved view boxes, delete unattached layout entries (a device may only be
temporarily unavailable), deduplicate markers, or delete files. File
collection remains the backend's reference-aware scheduled job.
`alignAllToGrid(spaces, layout)` (`src/align-grid.ts`) is pure: it
copies its input, never mutates it, and returns the new spaces, the new
layout and the report. The dialog therefore measures and commits the
**same object** — the numbers it promises cannot differ from what it
does. The resulting config+layout pair is sent to
`houseplan/plan/optimize`; the backend persists a durable intent before
either store changes, commits both revisions, and retains one snapshot.
`houseplan/plan/optimize_undo` restores it only while neither revision
has changed since the optimization. A crash between store writes is
completed from the intent on the next integration setup.
Guarantees are covered by `test/align-grid.test.mjs` and the orchestration/
idempotence case in `test/plan-optimizer.test.mjs`:
* every grid-bound element ends on a node; a rect's FAR corner too (a
snapped *size* on an off-grid origin leaves the other side between
the nodes);
* an opening ends on its wall, at whole steps along it, inside it, and
**with the wall's own angle** — the angle is written, so it is part of
the diff (AUD-158B1-02: an opening already on its wall with a wrong
angle used to be returned changed inside `changed: false`, which made
it unfixable);
* a stray opening with no wall within 6 steps is left exactly where it
is rather than teleported;
* **idempotent**: a second run reports `moved: 0`, `changed: false`, and
returns objects deep-equal to the first run's;
* the report is an **upper bound**, not a sample (AUD-158B1-01).
### The report is a promise
The confirmation is the decision gate in front of a geometry rewrite, so
`maxShift`/`maxShiftCm` must never be smaller than what the run does:
* displacement is measured on the geometry **actually written back** —
all FOUR corners of a rect, minimum-size correction included. The two
corners nobody used to measure are exactly the two that can be worst:
they carry the X error of one side together with the Y error of the
other, which is √2 of either;
* an opening is measured on its **ends**, flip-invariantly, so turning
it in place costs what it really costs and a 180° rewrite costs
nothing;
* the maximum is accumulated in **centimetres**, each space through its
own `cell_cm`, and the report names the space it belongs to. One
normalised maximum converted through the *first* space's cell size
promised 2.5 cm for a vertex that moved 50 cm on a 100 cm floor;
* the dialog rounds the last tenth **up** and, on a multi-space plan,
says which space the maximum is in; openings corrected in angle alone
are counted on a line of their own.
One undo is available until the next config or layout edit. It restores
the stored snapshot; re-running optimization itself is never treated as
undo because a grid projection is not invertible.
## Every place that assumed the unit square
| Place | Assumption | Decision |
| --- | --- | --- |
| `contentBounds` envelope `-25 %..125 %` | content outside the square does not count | **removed** — replaced by §4.1 outlier rejection |
| `_baseVb()` `if (mode !== 'view') return m.vb` | editors need the whole square to have room to draw | **removed** — the content frame plus §5 pan slack and 3x zoom-out gives more room than the square ever did |
| `_baseVb()` `if (m.bg) return m.vb` | image plans frame on the square | image rect is now just one content item (§4) |
| `--icon-size` scaled by `vb.w / view.w` | the canvas is what an icon is a fraction OF | numerator becomes `iconUnit()`; the icon still scales with the plan (§6) |
| `defaultPositions` `minDist` from `NORM_W` | one canvas = one plan | `iconUnit()` (§6) |
| `markerPos` / `_pos` fallback = `view_box` centre | a device with no position belongs in the middle of the square | `spaceCenter()` — the middle of the content |
| grid `<rect>` over `vb` | the grid ends with the square | rect follows the view (§7) |
| grid pitch fixed | fine at 1 canvas wide | `gridLevels()` (§7) |
| `_clampView` pinned content over the scene | you cannot pan past the edge | §5 pan slack |
| `_stagePointerMove` panned only while `zoom > 1` | below 100% the content already covered the scene, so a drag had nowhere to go | **removed** — §5, panning at every zoom |
| `ZOOM_MIN = 0.4` | fraction of the square | `MIN_ZOOM = 1/3` of the content frame (§5) |
| `_decorMoveUpdate` clamp `-0.25 .. 1.25` | decor may hang a quarter past the edge | clamp widened to the sane range (`+/-CANVAS_LIMIT`) — corruption insurance, not a frame |
| `_pointerMove` clamp to `_baseVb()`, `_labelMove` clamp to `view_box` | a marker/label belongs inside the canvas | **removed** — §9.1; missed in v1.57.0 and reported by the owner |
| static card `aspect-ratio` + `viewBox` from `space.vb` | the static card frames the square | `spaceFrame()` — same content frame as the full card |
| `validation.py` `+/-4`, `_EXTENT <= 4`, decor `-1..2`, opening `length <= 1` | the square plus slack | §3 |
| `safeViewBox` fallback `[0,0,1,1]` | a broken `view_box` means the square | kept — it is only the last-resort hint (§4) |
| `fitInSquare` (image placement) | image is centred in the square | **kept** — it defines the image's own rectangle in canvas units, which is exactly what §4 wants as a content item. Since v1.58.0 it is only the DEFAULT placement: `planRect()` adds the owner's `plan_x`/`plan_y`/`plan_scale` on top, and the moved rectangle is what §4 counts (docs/BACKDROP.md) |
| image plan papers the image rect | the picture IS the sheet | **removed** in v1.58.0 — the opaque paper is the room contours in every case, and the picture is drawn on top of it (docs/BACKDROP.md §3) |
| `_spaceH` / `_decorH` = `NORM_W` | the canvas is square | **kept** — this is the coordinate system's aspect, not a frame |
| `_gridPitch = NORM_W / GRID_N` | grid pitch is tied to the canvas unit | **kept** — the pitch is the real-world cell (`cell_cm`), it must not change with the plan's size |
| sun wedges / glow radii / resize maths | all in render units, relative to their own geometry | **unaffected** — verified: no `NORM_W`-relative constants |
## What is deliberately NOT done
* No new stored field. The frame is derived every time; there is
nothing to migrate, nothing to keep in sync, nothing to corrupt.
* `view_box` is still WRITTEN as `[0,0,1,1]` on space creation and is
still required by the schema — removing a required field is a
breaking storage change for old clients and buys nothing.
* The outlier hint has no "hide this object" action. Deciding what to
do with a stray marker is the device editor's job.

Some files were not shown because too many files have changed in this diff Show More