Compare commits

..
146 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
Matysh 9be44dab1e v1.56.0
Validate / hacs (push) Failing after 10s
Validate / hassfest (push) Failing after 8s
Validate / frontend (push) Successful in 2m29s
Validate / backend (push) Failing after 7m51s
Validate / smoke (push) Failing after 9m38s
2026-08-03 13:51:04 +03:00
Matysh ba88782ce1 v1.56.0 2026-08-03 13:46:24 +03:00
Matysh a20b73621f DEV-B701-01: sun-ray memo keyed by _cfgEpoch, not _cfgRev (stale wedge after local geometry edits)
The wedge cache key carried the SERVER revision, which only moves after the
debounced houseplan/config/set is acked. Every local mutation path ends in
_saveConfig(), which bumps _cfgEpoch synchronously — so a dragged window or
an edited room kept its old wedge for the whole write window (forever on a
failed write). The memo now uses the epoch, the same signal the model/
geometry caches key on (audit L1).

smoke_sun.mjs no longer masks the defect: touchCfg() bumps _cfgEpoch (what
production does) instead of faking a server rev, and a new regression drives
the REAL path — a pointer drag of the east window, exit from the editor
inside the debounce window (rev untouched), a room shrink through
_saveConfig() that must re-clip the wedge, and a late-ack survival check.
Fails on b701537, green with the fix. 218 unit + 79 backend + 85/85 smokes.
2026-08-03 13:28:57 +03:00
Matysh b70153769a OPENING DRAG: shoulders measure the ONE room edge under the opening, no collinear merge (owner 2026-08-03)
Validate / hacs (push) Failing after 6s
Validate / hassfest (push) Failing after 7s
Validate / frontend (push) Successful in 1m24s
Validate / backend (push) Failing after 5m25s
Validate / smoke (push) Failing after 7m57s
Owner: 'not like that — the whole wall is counted now, only the wall of ONE
room must count'. openingShoulders no longer merges collinear touching edges
of neighbouring rooms into a physical run: the wall is exactly the room-
polygon edge the opening is snapped to. Selection mirrors snapToWall
(nearest collinear edge, first in roomEdges order on a tie), so the ruler
always measures the same edge the drag snapped to; the center tick/magnet
now targets that edge's middle. Unit tests flipped to the new contract plus
a staggered shared-wall case; smoke_opening_measure recalculated for r1's
own edge 40..550 and grew a shared-wall scenario (both failed on the old
build, green now); docs/TESTING.md wording updated.
2026-08-03 12:21:02 +03:00
Matysh 5e5c06f126 OPENING DRAG: shoulder rulers + center tick with a soft magnet (owner 2026-08-03)
While an opening is dragged along a wall, a measure badge sits on the middle
of EACH shoulder: the along-the-wall distance from the wall end to the nearest
opening edge, live (segmentCm/formatLength, so metric/imperial and cell_cm are
honoured). Collinear touching room edges count as ONE physical wall — a user
thinks in whole walls, not the fragments roomEdges derives. When the opening's
center reaches the wall's center (±half a grid step) a perpendicular dashed
tick (alignment-guide look) appears through the wall center and the center
magnet-snaps; Shift disables the magnet. Everything vanishes on release.
Angled walls work: distances run along the wall, the tick is perpendicular.

- src/logic.ts: openingShoulders() — pure shoulder/centered math (unit-tested)
- src/houseplan-card.ts: _opMeasure state fed by _opPointerMove, badge layer
  next to the resize badges, _renderOpeningCenterTick in the SVG
- demo/smoke_opening_measure.mjs: real-pointer drag; numbers checked against
  the demo geometry (4.56/5.52 m, 5.04/5.04 m at center), magnet == 0.5,
  Shift keeps 0.4987, everything gone after drop (was red without the feature)
- docs/TESTING.md: checklist line
2026-08-03 11:59:57 +03:00
Matysh c4a80bcb9f DIALOGS: native ha-switch / ha-slider with a hard fallback to plain inputs
Boolean .srcrow checkboxes (14 rows: sun rays, vacuum live position,
opening invert/flipH/flipV, marker show-entities/tap-confirm/climate-temp/
is-light/hide-from-plan, space borders/names/lqi + the 4 room-card label
flags) and every dialog range slider (9: fill opacities, kiosk icon/font,
ripple size, marker size/angle, card font, room opacity, room name/label
scale) render through _boolInput/_rangeInput. Each helper picks the native
HA element via customElements.get(...) at render time - the ha-* API is
undocumented and drifts between HA releases, so the presence check is the
only coupling; without the element (old HA, smoke env) the EXISTING input
renders unchanged, and both branches feed one handler (change/.checked,
input+change/.value). Radios, selects, the decorbar fill flag and the
import-dialog floor rows stay native on purpose. New smoke_ha_controls
covers both branches with ha-* stubs (two-way value flow); the other 83
smokes keep exercising the fallback, which stays pixel-identical.
2026-08-03 11:09:00 +03:00
Matysh 9ccc3831d1 STYLES: design-token pass — spacing/radius/font/shadow scales in :host
209 hardcoded px values in styles.ts now resolve through design tokens
(--sp-1..6: 2/4/6/8/12/16, --rad-s/m/l: 6/8/12, --fs-s/m/l: 12/13/15,
--shadow-1/2/3). 151 swaps are value-identical; 58 stray values (3/5/7/9/
13/14px paddings, 11/12.5/13.5px fonts, 4/5/10/14px radii, two odd shadows)
are unified onto the nearest step, max +-2px by design. Untouched: %, all
calc() off --icon-size/--dev-size/--puck-size, viewBox units (compass text,
.rlabel, .vacfit), z-index, animation timings, colors. The phantom
--hp-panel/--hp-fg vars in .vaccalbar (defined nowhere) fold into
--hp-bg/--hp-txt. .modetab keeps its 10px h-padding: +2px wrapped the
header modes row at ~900px. 10px spacings stay literal for now - 10 is
equidistant from --sp-4/--sp-5 and moving it either way shifts the header;
candidate for its own step in a future pass.
2026-08-03 11:01:13 +03:00
Matysh 33a960031e CLIMATE TEMP: opt-in room temperature from climate devices (owner 2026-08-03)
Marker dialog grows a checkbox (climate devices only, default OFF):
'Use the device's temperature sensor'. When ticked, the AC/thermostat's
attributes.current_temperature shows as the standard .tval badge next to
the icon (scales with --dev-size, honours show_temperature) and joins the
room average like a thermometer. Unavailable / missing attribute = no
badge, no vote; several climate entities - the first valid one wins;
hidden devices keep voting (room climate stays registry-wide, exactly
like hidden thermometers). Stored as marker.use_climate_temp (bool|None,
validated in MARKER_SCHEMA). Units in test/devices.test.mjs, smoke
demo/smoke_climate_temp.mjs (real checkbox click, 20 + 23.5 -> 21.8 on
the room card), backend test, docs/TESTING.md.
2026-08-03 10:24:51 +03:00
Matysh 3a6a819dec SUN: white day — the brightest moment of the day is white (owner 2026-08-03)
- BG_STOPS: +10deg #e8ddcf (morning light), +30..90deg #ffffff; night half
  of the scale untouched (-4 #131a28, -12..-90 #070c14)
- drawn-plan paper vs white sky: all paper shapes now sit in one
  .hp-paperg group; in daynight mode the group gets a subtle
  drop-shadow so the sheet contour stays readable at high sun
  (static mode and night unaffected)
- pinned colors updated: test/sun.test.mjs, demo/smoke_sun.mjs;
  smoke_bg_color paperUnderneath follows the .hp-paperg wrapper
- docs/SUN.md: explicit BG_STOPS table
- demo/shot_daynight.mjs: noon/sunset/night stills of the scale
2026-08-03 09:56:36 +03:00
Matysh c024c6d75a BG: drawn-plan paper follows the room contours, not their bounding box
Owner: the white backing must hug the ROOMS — an L-shaped house or detached
buildings grew a white square around the plan. Drawn plans now paper one
opaque shape per room (paperRoomShapes in logic.ts) in exactly the room's own
geometry — polygon points / rounded rect verbatim — so the union of the stack
is the paper: islands paint over their parent, open (virtual) boundaries
change nothing, and the scene bg_color / daynight sky reaches the exterior
walls, shows in the L's pocket and between buildings. Image plans keep the
backdrop-image rect (the canvas IS the paper). A live resize preview
(_rszPreview) feeds _renderCfg, so the paper moves WITH a dragged wall.
Static space-card follows the same contract. Paper is fill-only (stroke:none).

smoke_bg_color §11–13 rewritten: L-shaped + detached test rooms, paper-per-
room DOM checks, resize-preview wiring, pixel probes (acid in the pocket and
between buildings, none inside rooms); §13 injects the snapshot directly —
the module-level config-store cache made the old WS mock a no-op. Was 8 red
on the previous build, green now. docs/SUN.md + docs/TESTING.md contract
updated; unit test for paperRoomShapes.
2026-08-03 08:38:48 +03:00
Matysh a8bb145ffb BG: opaque plan paper — the scene background never bleeds through the plan
Owner request 2026-08-03: bg_color (and the daynight sky) used to shine
through the plan itself — a hand-drawn plan's translucent room fills sat
directly on the scene colour, and a transparent backdrop image let it
through too. An opaque rect.hp-paper now sits under everything the plan
draws and hugs the plan's extents (the backdrop image rect, or the drawn
content bounds the opening view fits). Its colour is the pre-bg_color
canvas: white for drawn plans (.stage.noplan), the theme card background
under an image and on the static space-card. The daynight night keeps
dimming the plan via the zoomwrap brightness filter ONLY — the paper's
alpha never changes. The scene colour is visible strictly AROUND the
plan, in view/kiosk/editors and the static card alike.

smoke_bg_color grew the contract (sections 11–13): paper presence,
geometry and opacity in view/editors/night, the white drawn-plan paper,
the static card's paper, plus a pixel proof against an acid #ff00ff
background (screenshot → canvas: no acid admixture inside the plan, acid
right outside it). The suite fails on the previous build.

docs: SUN.md background contract + TESTING.md checklist item.
2026-08-03 08:16:48 +03:00
Matysh 1e0295a370 SUN: adjust pinned dialog inventories + screenshot script
Validate / hassfest (push) Failing after 6s
Validate / frontend (push) Successful in 1m22s
Validate / backend (push) Failing after 4m10s
Validate / hacs (push) Failing after 7s
Validate / smoke (push) Failing after 12m53s
smoke_general_settings: the settings dialog now has 14 gsrows and a Sun
group; smoke_temp_fill: the space dialog gained the per-space compass
field. demo/shot_sun.mjs renders the evening-wedges and compass shots.
2026-08-03 01:41:26 +03:00
Matysh c74ce39eb3 SUN: backend validation + card render, dialogs, i18n, smoke (docs/SUN.md)
- validation.py: north_deg (strict int 0-359), bg_mode, sun_rays at both
  levels + weather_entity (global); tests_backend coverage
- card: memoised window-wedge layer (recomputes only on sun/config change),
  day/night stage background with slow CSS transition and ~10% plan dim,
  compass dial + number input in the general settings, per-space
  bg_mode/north_deg/sun_rays overrides (empty = inherit), weather datalist
- static space-card: background honours bg_mode (wedges full-card-only)
- i18n en/ru, styles, docs/TESTING.md checklist
- demo/smoke_sun.mjs: 4-wall windows, compass rotation, night, clipping,
  inheritance, clouds, memo, editors clean, real compass drag; verified to
  FAIL against the pre-feature build (16 named failures)
2026-08-03 01:36:03 +03:00
Matysh ee0ee9a1d3 SUN: spec (docs/SUN.md) + pure logic src/sun.ts with unit tests
planSunAngle/sunDirOnPlan (compass wrap), dayPhase palette, exterior-wall
probing, window wedges (rayQuad + polyclip room clipping), cloudFactor map,
north_deg/bg_mode/sun_rays inheritance. 26 new unit tests.
2026-08-03 01:26:38 +03:00
Matysh a8d40e4d99 v1.55.3
Validate / smoke (push) Failing after 12m47s
Validate / hacs (push) Failing after 9s
Validate / hassfest (push) Failing after 8s
Validate / frontend (push) Successful in 2m24s
Validate / backend (push) Failing after 7m57s
2026-08-02 15:01:37 +03:00
Matysh 63a8274623 AUD-1552-01/02: the v1.55.2 boot-veil audit findings, fixed with regressions
The v1.55.2 recheck (verdict NEEDS FIX) found two lifecycle holes in the
first-open boot veil (HP-1552); both are closed here and covered by
demo/smoke_preloader_lifecycle.mjs, which fails on v1.55.2.

AUD-1552-01 (high): disconnect/reconnect while booting hid the plan
FOREVER. disconnectedCallback cleared the boot timer but kept its
truthy id, so 'updated()' never restarted the watcher. Now the id is
nulled on disconnect and connectedCallback restarts the whole veil
lifecycle: a fresh watch (fresh clock, BOOT_MAX_MS hard cap) while
booting, the tail timers when detached mid-fade or mid-grace.

AUD-1552-02 (medium): two equal reads at 200/400 ms revealed the plan
at ~400 ms, so HA chrome landing at 450+ ms jumped on a VISIBLE plan.
The veil now holds a full protective window (BOOT_MIN_MS=700 — the old
600 ms plus a frame-latency margin: a shift applied at ~590 ms only
materializes in the stage height a couple frames later) with 100 ms
sampling and trailing quiescence (BOOT_QUIET_MS=250 restarts on every
height change), capped by BOOT_MAX_MS=1200. After the reveal a short
soft grace (BOOT_SOFT_MS=1500, .stage.hpsettle) turns later passive
shifts into a 0.25 s height glide — the viewport ResizeObserver refits
the plan along the transition; deliberate height changes (_setMode into
an editor) cancel the grace so the plan never drifts under the pointer.
Reduced motion disables the glide.

Regressions (demo/smoke_preloader_lifecycle.mjs, all FAIL on v1.55.2):
- A: detach before the first tick -> reattach -> veil lifts within the
  cap, plan visible, no hpboot class, no zombie veil after a mid-fade
  remount either;
- B: parameterized layout shifts at 150/300/450/590 ms -> not a single
  frame shows the plan at a non-final stage height;
- C: a shift after the reveal glides (>=3 intermediate frames), no snap.

smoke_modes.mjs now strips the transient hpsettle class from its exact
stage-class assertions. Docs: CHANGELOG en+ru, STATUS.
2026-08-02 14:56:58 +03:00
Matysh 1a75ee1ddf v1.55.2 2026-08-02 12:54:36 +03:00
Matysh e81e841ed6 v1.55.2 2026-08-02 12:50:43 +03:00
Matysh 3e23ff58c3 ui(resize): wall handles — half-size wall-with-arrows glyph, grab cursor
Validate / hacs (push) Failing after 10s
Validate / hassfest (push) Failing after 8s
Validate / frontend (push) Successful in 1m22s
Validate / backend (push) Failing after 4m37s
Validate / smoke (push) Failing after 6m17s
Owner request: in the resize tool make the wall-drag handles twice
smaller, replace the circle with a 'wall + two opposite arrows' icon,
drag cursor.

- visible glyph: wall segment with two arrows perpendicular to the
  edge (the drag directions), rotated per wall orientation; accent ink
  over a --hp-bg halo, readable on any plan
- HIT area unchanged: an invisible circle of the original finger-sized
  radius keeps touch targets and the HP-1550-04 hit priority over
  openings (04_handle_wins_hit_test still passes)
- cursor: grab on hover, grabbing while dragging (:active)
- scale-frame corner handles untouched (classic filled circles,
  nwse-resize)
- docs/RESIZE.md: handle appearance updated
2026-08-02 11:50:49 +03:00
Codex 21786ed5a1 fix: first-open boot veil — no residual jump under HA panels (HP-1552)
In normal (non-kiosk) mode the stage is calc(100dvh - _hdrH), and _hdrH is
measured from HA's chrome, which finishes loading AFTER the card's first
paint — late panels nudged the height and the freshly painted plan visibly
jumped (kiosk is a flat 100dvh, hence 'perfect in kiosk').

Fix, per the owner's sketch:
- until the stage height reads the same twice in a row (200 ms cadence) or
  a hard ~600 ms cap, the plan stays hidden (.hpboot) and is revealed only
  after a refit — not a single frame paints at a stale height;
- that window is covered by a quiet product-style preloader: dark veil,
  small pulsing outline house (the card's own mdi:home-city outline,
  inline SVG), no text, 0.15 s opacity fade-out;
- prefers-reduced-motion gets a static house; kiosk never shows the veil;
  first open of the card instance only — floor/mode switches are untouched.

smoke_preloader simulates an HA panel landing at t=300 ms and asserts the
veil, zero plan-visible frames at a non-final height, the kiosk opt-out and
the reduced-motion variant; serve.mjs starts every smoke after the reveal,
where the user starts.
2026-08-02 10:49:15 +03:00
Codex 6414873fb0 feat: background color around the plan — global + per-space (HP-1554)
New setting 'background around the plan' (#rrggbb):
- global: config.settings.bg_color, edited in the gear dialog with a live
  preview and a 'theme default' reset (empty = keep the stylesheet default);
- per-space override in the space dialog next to the room colors, empty =
  inherit the general setting (the show_lqi/fill_mode pattern);
- applied to the stage in view and kiosk modes (editors keep their own
  canvas) and to the static houseplan-space-card;
- backend validates both keys with the same strict #rrggbb match as
  room_color; garbage strings are rejected (test_bg_color_setting).
smoke_bg_color covers apply/override/inherit/reset, dialog previews, the
wire format of the cleared override, kiosk and the static card.
2026-08-02 10:48:51 +03:00
Codex 941d2709fd fix: round caps/joins on background-editor lines (HP-1553)
Lines drawn in the background (decor) editor showed notched 'teeth' where
two segments met at an angle. Decor <line> elements — both the saved render
and the live drawing preview — now carry stroke-linecap/linejoin=round, so
every line end reads as a circle of the stroke width and joints are smooth.
smoke_decor asserts the round attributes on the draft and the saved lines.
2026-08-02 10:47:49 +03:00
Matysh 9bcfebe8f4 fix: no default-fit flash when opening with a saved zoom (HP-1551)
The cached config (LS_CFG) painted its first frames with _zoom=1 because
the saved per-space zoom was applied only by _restoreZoom()'s rAF after
the server config round-trip - the plan visibly jumped to the saved scale.

- setConfig now arms _zoom from LS_ZOOM for the resolved space before the
  first view computation (view mode, no established view only);
- _restoreZoom applies the view synchronously when the stage is already
  measured (space tabs, kiosk carousel, editor exit included); the rAF
  path remains only for an unmeasured stage, where updated() already fits
  with the correct zoom before the first paint.

New smoke_zoom_flash.mjs samples every rAF frame from the first possible
moment with a primed config cache, saved zoom 1.8 and a 350 ms server
delay: a visible stage with a default-scale viewBox fails the run
(22 such frames before the fix, 0 after).
2026-08-02 10:08:04 +03:00
Matysh 8b439ea0f3 v1.55.1
Validate / smoke (push) Failing after 11m44s
Validate / hacs (push) Failing after 8s
Validate / hassfest (push) Failing after 10s
Validate / frontend (push) Successful in 2m20s
Validate / backend (push) Failing after 7m27s
2026-08-01 19:01:35 +03:00
Matysh b7d5cb8a8f v1.55.1 2026-08-01 18:58:23 +03:00
Matysh 84f8bcf0e6 HP-1550-01..04: the v1.55.0 resize audit findings, fixed with regressions
01 (high): the live resize preview no longer touches _serverCfg — it lives in
the _rszPreview overlay served to renders via _curSpaceCfg/_renderCfg, so a
debounced write queued from a previous edit can never carry mid-drag geometry
to the server; commit happens once, on pointerup; Esc just drops the overlay.
03: pointercancel/lostpointercapture take the cancel path (no commit, no undo
step, no write) for edge and corner handles alike.
04: in the resize tool the wall handles own the hit test — the transparent
.op-hit is inert and the resize layer renders above the openings; a door at a
wall midpoint no longer shadows the handle, other tools unchanged.
02: the 30 cm floor is orientation-independent — minSpanClearance (band sweep
of the moved stretch) for wall drags, minPolyWidth (calipers) for the scale
frame; already-thin rooms may improve, never worsen.
2026-08-01 18:58:23 +03:00
Matysh 10927ffaed v1.55.0 2026-08-01 15:48:24 +03:00
Matysh 6ff79106b8 v1.55.0 2026-08-01 15:45:14 +03:00
Matysh f49ac613b5 room resize: smoke (numeric vertex asserts) + TESTING.md checklist
demo/smoke_room_resize.mjs drives real pointer events over the handles:
T-stack drag (r1 grows, r2 translates, r3 becomes a 6-vertex L — checked
numerically), live badges appear and change, opening rides the wall,
neighbour 30 cm stop, opening-anchor stop, scale frame proportional with
static neighbours and a neighbour stop, Esc-cancel, one-step undo, no
handles in any other tool/mode. Fails on the pre-feature blob (verified).
2026-08-01 13:48:43 +03:00
Matysh ec58b0602a room resize: the 'Размер' tool in the Plan editor (docs/RESIZE.md)
Wall-midpoint handles for every room (finger-sized, pointer-captured, no
stage-pan conflict); dragging moves the wall along its normal with live
preview through the config snapshot, shared stretches drag the neighbour
(T-junctions insert vertices), openings on the wall travel along. Click a
room for the corner scale frame (uniform, about the opposite corner,
neighbours never dragged). Live badges: dragged+adjacent wall lengths and
m² per reshaped room. Grid snap, Esc cancels the drag, Ctrl+Z pops the
resize undo stack (one release = one step). Legacy rects are saved back
as polygons; three blob copies rebuilt.
2026-08-01 13:48:43 +03:00
Matysh 65070c0520 room resize: spec (docs/RESIZE.md) + pure geometry in src/resize.ts with unit tests
Mechanism A (wall drag along its normal, shared stretches of neighbours move
together, T-junctions insert vertices) and mechanism B (corner scale frame)
with every stop: min room size ~30 cm, self-intersection, foreign rooms
(polyclip area check — roomsOverlap alone misses collinear slide-over),
islands, opening anchors. node:test units pin each stop numerically.
2026-08-01 13:36:35 +03:00
Matysh fa32afa9f3 HP-1543: editor-exit zoom after cross-floor switch; motion flash restart on rapid retrip
HP-1543-01 (medium): exiting an editor AFTER switching floors inside it left
the editor working zoom on screen in view mode — the snapshot guard
(snap.space) dropped the restore and nothing else put the viewport back.
A space-mismatched exit now falls back to _restoreZoom() of the current
floor's saved view zoom (per-space store/LS_ZOOM were never polluted, the
view-only _saveZoom guard is untouched). Regression: crossFloor* asserts in
demo/smoke_zoom_out.mjs — red on v1.54.3, green now.

HP-1543-02 (low): a rapid off->on retrip before the current flash ended did
not restart the CSS animation: the senseflash class and animation-name never
changed, so the browser kept the old timeline and the second detection played
nothing once the first one-shot had finished (base opacity 0). Every
witnessed trip now bumps a generation counter; its parity alternates the
identical keyframes hp-sense / hp-sense-b via the .sf2 marker — a new
animation identity forces a fresh timeline per detection. flashTs and the
window timer re-arm as before; prefers-reduced-motion keeps the static ring
(retrip only extends the window). Regression: rapid* asserts in
demo/smoke_motion_sense.mjs — red on v1.54.3, green now.
2026-08-01 13:09:55 +03:00
Matysh adaed9be7c v1.54.3 2026-08-01 12:40:39 +03:00
Matysh b508c20ad3 v1.54.3 2026-08-01 12:37:24 +03:00
Matysh ca07579b63 motion sensors: one-shot flash on detection; occupancy/presence hold a static ring
Owner's contract (2026-08-01, вариант «б») replaces yesterday's 'sense' class
(29dae45, lived <1 day, stored nowhere — no migration):

- MOTION (device_class motion): a SHORT one-shot flash at the off→on
  transition — 3 beats of the yellow ring (hp-sense 1.1s x3, ~3.3s), then
  silence even while the entity still reports 'on': «движение = разовая
  вспышка в момент обнаружения; cool-down не пульсирует». A new off→on trip
  flashes again. Tracking lives in _senseRt (markerId → last state + flash
  ts), stamped by _senseTick on the hass tick (the _vacTick pattern); one
  setTimeout per entry repaints the card when the window closes so the
  'senseflash' class is dropped without a state change (cleared in
  disconnectedCallback). First sight already-'on' and unavailable→on do not
  flash — not a witnessed detection.
- OCCUPANCY/PRESENCE while 'on': 'sensehold' — a STATIC yellow ring, no
  animation, opacity 0.4 (the reduced-motion brightness): «присутствие =
  статичное кольцо пока обитаемо».
- Unchanged: the yellow FILL stays reserved for «включено», alarm's red ring
  wins on the shared ::after, ghosts draw nothing.
- prefers-reduced-motion: the flash becomes a static ring for the same ~3.3s
  window (consistent with alarm); sensehold is static by design.

Smoke smoke_motion_sense.mjs rewritten to the new contract (13 asserts fail
on the pre-fix bundle): finite iteration count '3', flash gone after 3.6s
while state is still 'on' (the key assert), re-trip flashes again,
occupancy/presence static ring, neutral badge, hidden ghost inert.
shot_motion_sense.mjs now cycles off→on to arm the flash.
2026-08-01 12:17:17 +03:00
Matysh 29dae45e2a motion sensors: tripped motion/occupancy/presence pulses a soft yellow ring
Validate / hacs (push) Failing after 42s
Validate / hassfest (push) Failing after 40s
Validate / frontend (push) Successful in 1m33s
Validate / backend (push) Failing after 6m16s
Validate / smoke (push) Successful in 5m8s
Owner's rule (2026-08-01): the yellow FILL is reserved for 'on' — a tripped
sensor keeps the neutral badge and gets a new 'sense' state class instead:
a gentle .dev.sense::after ring in the .dev.on yellow (--hp-on), 2.4s period,
max opacity 0.5 — the soft sibling of the red .dev.alarm siren. Declared
before .dev.alarm so red wins on the shared ::after if both ever apply;
ghosts don't pulse (hidden gate already strips the state class).
prefers-reduced-motion: static faint ring, same treatment as alarm.

Smoke smoke_motion_sense.mjs (fails on the pre-fix bundle): sense class on/off,
::after animation, neutral background (not --hp-on), occupancy/presence,
hidden ghost. shot_motion_sense.mjs captures the ring for review.
2026-08-01 11:54:23 +03:00
Matysh 88008961e3 zoom: _saveZoom is view-only — editor 500% can no longer leak into the per-space store
The exit-editor restore re-saved the view zoom from a rAF; on a slow
tablet the floor-tab click lands before that rAF (input runs first in
the frame), the snap.space guard skipped the fix-up save and the editor
wheel zoom (500%) stayed in _zoomBySpace/LS_ZOOM — the second floor
switch brought it back into view mode (owner's dacha report). Now
_saveZoom simply refuses to write while _mode is not 'view': the wheel
inside the editors keeps zooming but never touches the per-space view
store, so no fix-up is needed at all. The snapshot restore keeps
bringing the pre-editor viewport back. Smoke: smoke_zoom_out.mjs grows
the owner's exact scenario (both floors zoomed in view, editor 5.0,
same-tick switch after exit, two floor switches) — red before, green
now; the 6d16f69 asserts stay green.
2026-08-01 11:31:18 +03:00
Matysh 6d16f69f38 zoom: editor zoom is a working tool — leaving any editor restores the view-mode viewport
Entering an editor snapshots the view-mode zoom+center (per space);
leaving back to view brings it back and re-saves it to LS_ZOOM, so wheel
zoom done inside the editors no longer leaks into the viewing zoom.
Editor-to-editor switches and the per-space view zoom keep working as
before. Smoke: smoke_zoom_out.mjs grows the 1.6 -> editor 2.5 -> 1.6
scenario (zoom, center, LS), the no-touch no-jump case and the
space-switch persistence guard.
2026-08-01 11:04:19 +03:00
Matysh 92ea8fa03c device satellites: the size multiplier scales the value plate and labels too
Owner report (2026-08-01, screenshot): a device scaled up via marker.size
kept its value badge at the default size — an enlarged icon next to a tiny
'26.6°'. Same bug class as the v1.51.3 glyph fix (3456706): satellite
metrics were pinned to the base --icon-size, so --dev-scale grew the box
but nothing that belongs to it visually.

All .dev satellites now derive from var(--dev-size, var(--icon-size,
2.5cqw)) — which equals icon-size * dev-scale — keeping the exact same
coefficients, so at size=1 nothing changes pixel-wise:

- .valonly plate padding (0.16) and .valtext font (0.45)
- .tval/.hval plates: margin (0.1), radius (0.18), padding (0.14),
  line-height (0.68) — font was already dev-size, the plate was not
- .lqi label: margin (0.05), font (0.38)
- .newdot: offsets (-0.12), diameter (0.34)
- .alarm ring inset (-0.35)

smoke_icon_scale extended: value badge and temp plate must double at
size=2 (getBoundingClientRect + computed font-size, 1.8-2.2 tolerance)
and keep the exact old defaults at size=1; verified failing on the
pre-fix build. space-card renders bare icons only, unaffected.
2026-08-01 10:27:34 +03:00
Matysh 6743b6efc4 hassfest: services.yaml for the stand-only demo_guard
Validate / hacs (push) Failing after 7s
Validate / hassfest (push) Failing after 8s
Validate / frontend (push) Successful in 1m35s
Validate / backend (push) Failing after 6m48s
Validate / smoke (push) Failing after 8m42s
Hassfest scans every manifest in the repo, and demo_guard (which
re-registers homeassistant.restart/stop as no-ops on the public stand)
tripped the SERVICES check. It defines no services of its own; the file
documents exactly that.
2026-07-31 18:44:08 +03:00
Matysh c9775141c9 stand: demo_guard neuters homeassistant.restart/stop for visitor-admins; console reset countdown (www/stand-reset-timer.js)
Visitors hold admin sessions (the editor demo needs is_admin), and some of
them restarted HA from the UI — exit code 100 between hourly resets looked
like stand instability. demo_guard re-registers restart/stop as no-op
services after startup; the hourly docker-level reset is untouched.
stand-reset-timer.js logs a per-minute countdown to the :00 reset in the
browser console (warn for the last 5 minutes); stand-dev-info.js is the
one-shot dev-stand note. Also on the stand host: mem_limit 1536m per
container in compose.yml as an OOM safety net.
2026-07-31 15:16:36 +03:00
Matysh 9c224e7606 ci: re-run Validate on dev
smoke_grid_fade flaked on the duplicate dev run of 93d97e1a (roomFaded
timing on a busy runner); the identical tree is green on main and on the
v1.54.2 tag. Empty commit to re-run — the PAT cannot rerun failed jobs.
2026-07-31 14:37:47 +03:00
Matysh 93d97e1a83 v1.54.2
Validate / hassfest (push) Failing after 11s
Validate / hacs (push) Failing after 13s
Validate / frontend (push) Successful in 1m39s
Validate / backend (push) Failing after 6m22s
Validate / smoke (push) Failing after 8m37s
2026-07-31 13:47:22 +03:00
Matysh fad2e87ab5 v1.54.2: HP-1541-01 — vacuum selected_map fallback obeys the not-nullish map-id contract
The v1.54.1 contract (first not-None value wins, zero is a value) covered
the source entity but not the card's fallback on the vacuum's own
selected_map: _vacMapId still used truthiness, so selected_map: 0 became
'default' on the frontend while trails.py resolve_map_id stored the run
under '0'. Calibration and server trails split across two keys and the
recorded run never rendered after reload.

The fallback is now the shared pure helper vacMapIdWithFallback (nullish
check), mirroring resolve_map_id. Cross-runtime regressions added for
selected_map = 0, '0' and '' on both sides; the frontend cases fail on the
old truthiness code.
2026-07-31 13:44:10 +03:00
Matysh 909bb6fbc7 v1.54.1 2026-07-31 13:15:12 +03:00
Matysh 4e3d8f1d53 Test harness: run async recorder regressions on a private event loop
asyncio.run() clears the thread's current-loop slot when it finishes; the
CI HA harness keeps a session event loop, so every test that followed the
new HP-1540-05 regression failed at SETUP with 'There is no current event
loop'. The pure-only local run never sees the harness and stayed green —
which is exactly how it slipped through. The regressions now spin up an
isolated loop and leave the ambient one untouched.
2026-07-31 13:12:14 +03:00
Matysh a1f7fb4161 v1.54.1: the v1.54.0 audit findings, sealed
Version 1.54.0 -> 1.54.1 in package.json, package-lock.json, manifest.json,
const.py and CARD_VERSION; rebuilt bundle in all three tracked copies
(dist/, demo/srv/assets/, custom_components/houseplan/frontend/).
Changelog entries (EN+RU) — one line per finding, HP-1540-01..06 — and
docs/STATUS.md bumped.

Suites on this exact tree: 161 frontend unit, 74 pure-backend, 74 browser
smokes — all green.
2026-07-31 13:07:37 +03:00
Matysh 257a71123a Trail recorder: zero map ids, one source per N markers, serialized refresh
Audit HP-1540-02: the recorder chose the map id with an or-chain, so a
valid numeric map_index=0 fell through to selected_map (or 'default') and
the server stored the run under a key the renderer never looks up. The
choice is now resolve_map_id() — the explicit backend half of the contract
shared with vacMapIdFromAttrs in src/vacuum.ts: the first value that is
not None wins, zero and empty string included.

HP-1540-03: pairs was a plain source -> (marker, vacuum) dict, so the
second placement of the same robot (the documented two-floor case) evicted
the first and its server history silently stopped. A source now maps to a
list of pairs, every marker gets its own copy of the run, and the state
subscription set is deduplicated.

HP-1540-05: every config/set spawns async_refresh as a detached task; two
of them interleaving across the awaited config load could both subscribe,
overwriting one unsub handle — a callback leak until HA restart. Refresh
is serialized with an asyncio.Lock and teardown flags the recorder closed
first, so a refresh parked on its await can never resubscribe afterwards.

Regressions cover map_index 0/'0'/''/selected_map cross-checks, one
source feeding two floor markers across a map switch, pair-list refresh
with a deduplicated entity set, and two overlapping refreshes leaving
exactly one live subscription (zero after teardown). On the v1.54.0
recorder 11 of these tests fail.
2026-07-31 13:07:27 +03:00
Matysh 69c5a4c41d Vacuum first-use path: materialize the marker, count rectangle rooms, fix the toasts
Audit HP-1540-01 (High): an auto-discovered vacuum has no config marker
until the device dialog is saved once, yet the live-position section was
already interactive. setVac, _vacSaveMatrix and auto-calibration all did
cfg.markers.find(...) and silently bailed out — while the auto-calibration
toast still claimed success. Every vacuum edit now materializes a minimal
marker (same id/binding the dialog Save would produce), _vacSaveMatrix
reports whether the write landed, and success toasts are gated on it.

HP-1540-04: the auto-calibration room matcher accepted only polygon rooms
and told users their room names did not match. It goes through the shared
roomPoly() now, so legacy x/y/w/h rectangles count like everywhere else.

HP-1540-06: the no-rooms/no-match/rough-fit toasts pointed at the removed
point calibration; they now point at the fit panel that shipped instead,
and docs/VACUUM.md Setup UX describes the actual UI. Also extracted
vacMapIdFromAttrs as the explicit frontend half of the map-id contract
(backend half lands with HP-1540-02).

Regressions: demo/smoke_vacuum_firstuse.mjs starts from cfg.markers=[]
(the fixture gap the audit called out) with rectangle plan rooms and a
zero map_index, and fails 11 checks on the v1.54.0 bundle; i18n unit test
asserts no point/точк wording in either language.
2026-07-31 13:07:16 +03:00
Matysh 77327c07d4 TESTING-DEMO.md: every checklist line answered with a stand recipe or an honest 'not here'
Manual testers kept asking which parts of docs/TESTING.md the public stand
can actually exercise. The answer used to live in nobody's head: the stand
was missing a vacuum, any LQI at all, toggleable leak/smoke alarms, an
hvac_action marker, and its automations/scripts/scenes YAML was never
!included - so the tap-run marker pointed at a script that did not exist.
With those gaps closed on the stand, this doc maps each checklist item to a
concrete click path on demo.houseplan.tech, and openly lists what only
local setups or real hardware can verify (broken stores, HACS flows,
non-admin users, anything that must outlive the hourly reset).
2026-07-31 12:49:02 +03:00
Matysh 18b8589438 Scripted demo-stand vacuum: the checklist needs a robot no hardware can provide
The public stand cannot run any Tier-A map integration (no radios, no
robots), which left the whole vacuum section of docs/TESTING.md untestable
outside the owner's home. demo_robot fakes exactly the surface the card
consumes: a Tasshack-shaped map sensor (dict vacuum_position, rooms named
after the plan rooms so auto-calibration has something to match, flipped Y
so the mirror default is meaningful) and a vacuum that drives a serpentine
route through every room in ~3.5 minutes and docks itself.
2026-07-31 12:48:53 +03:00
Matysh 9870b2fb98 A booting HA must not split the cleanup run
At startup the vacuum entity reads unavailable; the recorder took that
for 'stopped' and ended the open run, so every HA restart mid-cleanup
rotated the trail into previous and began a fresh one (observed live:
a 21-point run became previous and restarted at 5). Unavailable and
unknown now mean 'no verdict'.
2026-07-31 12:09:31 +03:00
Matysh 7de7cfb8c4 v1.54.0 2026-07-31 12:07:50 +03:00
Matysh 18ac9b459f Trail recorder reads object-style positions — the real-world fix
Caught live on the owner's X50 mid-cleanup with temporary logging: the
recorder saw every camera state change and rejected every one, because
server-side Tasshack keeps vacuum_position as a Point OBJECT — it only
becomes a dict when serialised to the frontend, which is why the card
adapter (and MCP inspection) always saw a dict and the stub test
faithfully reproduced the same wrong assumption. getattr fallback added,
regression test pinned, diagnostic logging removed (setup line kept at
INFO).
2026-07-31 12:04:33 +03:00
Matysh 4e355dcbd2 The stub loader no longer poisons the HA harness
test_trail_recorder injected fake homeassistant modules into sys.modules
at import time; every pytest_homeassistant_custom_component fixture in
the same session then failed with 'module homeassistant has no attribute
util'. The stubs now live inside a snapshot that is restored in a finally
block.
2026-07-31 11:54:01 +03:00
Matysh f9b612a389 v1.54.0: live robot vacuums
The plan shows the robot at work: the marker stays at its dock while a
puck drives the plan, calibration is one click or a drag-and-stretch
overlay, and the path is recorded server-side (current + previous run)
with never/cleaning/always display modes. Also: glow is the default
fill for new spaces, and the run-target search renders again.
2026-07-31 11:50:31 +03:00
Matysh d27864e90e docs: bring the whole set up to the shipped vacuum feature
TESTING gets a manual checklist matching the current contracts (modes,
teleport-on-view-change, tip glued to the icon, fit panel, multi-floor);
ARCHITECTURE gains trails.py and the trail/get command; VACUUM records
the display modes and what P1 actually shipped; README/PRODUCT/ROADMAP/
STATUS mention the feature.
2026-07-31 11:48:29 +03:00
Matysh f3bc3278e5 Trail recorder: seed a run already in progress, and test the wiring
Only TrailBook was covered; the HA-facing half — subscription callback,
attribute dialects, map-id resolution, run end on docking — had no test
at all. It does now, against a stubbed hass, which is also where the
missing behaviour showed up: recording started at the NEXT state change,
so an HA restart (or finishing calibration) mid-cleanup dropped the
opening seconds of the path. Sampling is factored out and runs once per
source on setup and on every refresh.
2026-07-31 11:47:31 +03:00
Matysh 75524d9d85 Trail modes + the tip grows glued to the icon
«Показывать путь робота» is a three-way choice now: never / while
cleaning (the default — the line hides the moment the run ends) /
always (the only mode that also shows the faded previous run).
trail_mode rides next to the legacy bool, which still maps in.

The last segment no longer pops in when the next telemetry point
arrives: a rAF sampler drags a tip line's endpoint to the puck's
animated centre every frame, so the path visually pours out strictly
from under the icon. The sampler runs only while pucks exist and stops
itself.
2026-07-31 11:30:17 +03:00
Matysh 089c5ef462 websocket_api: import DOMAIN — the trail-recorder refresh crashed config/set
Validate / hacs (push) Failing after 7s
Validate / hassfest (push) Failing after 11s
Validate / frontend (push) Successful in 1m39s
Validate / smoke (push) Failing after 29s
Validate / backend (push) Failing after 4m42s
Caught only by the CI HA harness; the pure suite never exercises the
import. NameError inside ws_config_set turned every save into
unknown_error.
2026-07-31 11:11:56 +03:00
Matysh 8dbc7db0b2 trail recorder: hass.data[DOMAIN] via setdefault
The CI harness sets the entry up without async_setup having created the
domain dict; the KeyError failed every WS test downstream.
2026-07-31 11:07:38 +03:00
Matysh 23daa28cf4 Server-side trails: the current run and one previous
The integration now records the path itself (trails.py): it watches the
source entity's state changes, so recording needs no open card, has no
multi-tab write races, and every screen sees the same line — reloads
included, which retires the localStorage snapshot after one day of
life. Stored per marker: the current run plus exactly one previous
(owner call — users want cleaned-vs-uncleaned at a glance). The
previous run renders at 40% opacity; the current one still trims its
live tail so it never outruns the puck. Runs rotate on start or map
switch, points cap at 2000 with decimation, store writes debounce 10 s,
and houseplan_trail_updated pushes live cards. TrailBook is pure under
5 backend tests; the WS command degrades silently on older backends.
2026-07-31 11:03:47 +03:00
Matysh d063453670 The trail survives a page reload
The self-recorded points now snapshot into localStorage per marker
(raw robot coords, so recalibration does not invalidate them). Restore
is gated: fresher than the linger window, same map, and never into a
run that started after the snapshot ended — the old trail must not
leak into a new cleanup. The smoke simulates the reload by wiping the
runtime map and asserts both the restore and the new-run discard.
2026-07-31 10:56:10 +03:00
Matysh f83577afa7 The trail is half as thick (owner call): casing 2.25, core 0.9 2026-07-31 10:53:19 +03:00
Matysh eddc8b41db The fit overlay is actually touchable
The devlayer is pointer-events: none and every child opts back in; the
overlay never did, so every real click fell through to the plan while
the smoke's synthetic dispatch — which skips hit-testing — kept
passing. The overlay now opts in, the corner handles grew to finger
size, and the smoke performs REAL elementFromPoint hit-tests through
the shadow root so an untouchable overlay can never pass again.
2026-07-31 10:45:36 +03:00
Matysh 1eabfeeee8 The fit panel replaces the three-point wizard
Calibration is now a direct-manipulation overlay: the robot's rooms as
a dashed translucent ghost over the plan, dragged into place and
stretched by four corner handles (uniform scale about the opposite
corner). Quarter-turn and mirror buttons re-anchor about the ghost
centre; mirror defaults on because every robot map seen so far flips Y
versus the screen — measured on the owner's X50. Everything folds into
the same stored 6-number matrix, and legacy matrices reopen in the
panel with rotation snapped to a quarter. The park-the-robot-three-
times wizard is deleted outright: it was the most fragile part of the
feature (owner: «плохо работает»). fitMatrix/fitFromMatrix/initialFit/
reanchorFit are pure and unit-tested; the smoke drives the panel end to
end — drag, corner-stretch, rotate, save, puck on the new matrix.
2026-07-31 10:38:49 +03:00
Matysh 11186371a9 The puck teleports on view changes; the trail never outruns it
Two owner reports. One: zoom, space switch or a tab return animated the
puck's left/top through the viewport change — it looked like the robot
driving across the whole plan. The view signature now forces the jump
class for that render, and a visibilitychange listener covers returning
to the browser tab. Two: a trail segment appeared the moment new
telemetry arrived, ahead of the still-gliding icon — the self-recorded
trail now lags exactly one point behind (the previous target is what
the puck has just reached), and an integration path is trimmed of its
live tail while moving.
2026-07-31 10:31:53 +03:00
Matysh 692bb91e57 Trail casing: dark halo + light core
The accent line vanished on same-hue fills. Blend modes (difference/
exclusion) all keep a blind luminance where the stroke disappears and
composite expensively on old kiosk WebViews, so the trail now uses the
cartographers' trick instead: a neutral dark halo under a light core —
one of the two always contrasts with whatever is underneath. Verified
over glow fill (dark rooms + light pools) in one screenshot.
2026-07-31 09:59:27 +03:00
Matysh 893ccff94f Centre the puck glyph
ha-icon inside the puck lacked the .dev centering recipe (flex +
line-height: 0), so the glyph sat on its text baseline and floated
around the circle. The smoke now measures the glyph centre against the
puck centre to sub-pixel tolerance.
2026-07-31 09:50:52 +03:00
Matysh d2faa070b5 No heading arrow on the puck (owner call)
The wedge looked like clutter at badge size; the trail already shows
where the robot is going. Smoke asserts its absence now.
2026-07-31 09:46:46 +03:00
Matysh f5c56d2f8e The puck is the base badge, round and 20% smaller
Owner's wording: «иконка похожа на иконку базы, только круглая и чуть
меньше» — same plate colors and shadow as a regular device badge
(var(--hp-bg)/--hp-line/--hp-txt), circle, 0.8 of the device size. The
smoke now compares the puck against a NEUTRAL badge computed-style for
plate parity — the robot's own base is yellow while cleaning and would
never match.
2026-07-31 09:43:56 +03:00
Matysh 0142d37bec Vacuum adapter vs a live Dreame X50 Master
Checked against the real robot at the dacha: room centres arrive as
plain x/y next to the bbox, and the active-map name (selected_map,
'Первый этаж'/'Второй этаж') lives on the VACUUM entity, not on the
camera — without reading it both floors would silently share one
calibration matrix. Parser and card resolver adjusted; the captured
attribute shape is now a unit fixture.
2026-07-31 09:28:20 +03:00
Matysh 40abbccbf0 Live robot vacuums, P1 (docs/VACUUM.md)
The base marker never moves — it is the dock. While the robot cleans, a
round pulsing puck (no badge plate) drives the plan over an affine
transform solved from vacuum-map coordinates: auto-calibration matches
the robot's room list against plan rooms by name, and a three-point
wizard covers integrations without room data. The trail rides the
integration's own path when offered (it predates the card being opened)
and a self-recorded thinned buffer otherwise, lingering ten minutes
after docking. Adapters read the Map Extractor / Tasshack / Valetudo
attribute dialects through one tolerant parser. Display only — no
commands, per the owner's decision.

vacuum.ts is pure logic under 8 new unit tests; the marker schema grew
an optional vacuum block (56 backend tests); smoke_vacuum drives 19
browser asserts including the wizard end to end.
2026-07-31 09:19:56 +03:00
Matysh 831e694f83 docs: the live-vacuum spec, approved scope
The base marker never moves — it is the dock. A separate round puck
(no badge plate, soft pulse) drives the plan while cleaning and
dissolves into the base on docking. All three Tier-A adapters and the
trail ship in P1; commands are out entirely.
2026-07-31 09:05:38 +03:00
Matysh b350893448 merge main (README cherry-picks) 2026-07-30 21:24:07 +03:00
Matysh fc8b6f85a4 README: a live-demo badge in the shield row
Validate / hacs (push) Failing after 7s
Validate / hassfest (push) Failing after 6s
Validate / frontend (push) Successful in 1m33s
Validate / backend (push) Failing after 5m52s
Validate / smoke (push) Successful in 4m33s
2026-07-30 21:24:04 +03:00
Matysh 6fc60e3a50 README: the live demo stand, right under the fold
demo.houseplan.tech with demo/demo — a real HA anyone can break, it heals
itself hourly. Placed above the feature list: 'try it now' converts better
than any bullet.
2026-07-30 21:24:04 +03:00
Matysh dcc0b156af README: a live-demo badge in the shield row 2026-07-30 21:13:26 +03:00
Matysh 9d56708f2d README: the live demo stand, right under the fold
demo.houseplan.tech with demo/demo — a real HA anyone can break, it heals
itself hourly. Placed above the feature list: 'try it now' converts better
than any bullet.
2026-07-30 21:13:16 +03:00
Matysh 6d8ec7b92a Glow is the default fill for new spaces, and leads the options list
Owner call: 'Свет по источникам' is the mode that sells the card, so a new
space starts with it and the settings dialog offers it first. Deliberately
NOT changed: the fallback for an absent fill_mode stays 'none'
(spaceDisplayOf), so updating the card never repaints an existing plan
whose owner made no choice. smoke_space_settings re-pinned to the new
contract.
2026-07-30 21:09:19 +03:00
Matysh a9b999b3e0 STATUS: the demo stand is live
demo.houseplan.tech (public, hourly reset to a pristine synthetic home) and
dev.houseplan.tech (closed, auto-deploys the dev branch). Deployed 2026-07-30
per the plan in houseplan-demo-stand.pdf.
2026-07-30 20:06:52 +03:00
Matysh f36d2cddad v1.53.1
Validate / smoke (push) Failing after 8m31s
Validate / hacs (push) Failing after 9s
Validate / hassfest (push) Failing after 11s
Validate / frontend (push) Successful in 1m50s
Validate / backend (push) Failing after 6m15s
2026-07-30 01:50:20 +03:00
Matysh 1770ca2960 v1.53.1: the run-target search rendered into a 1px sliver
Reported by the owner minutes after v1.53.0: typing a name showed no
results. The results existed — .candlist is a scrollable box, and a
scrollable flex item inside the dialog body collapses happily: 26 matching
rows rendered inside a 1px strip. The binding dropdown never showed this
because it sits inside .droppanel, a block context.

flex: 0 0 auto + a min-height keeps it open. The smoke now MEASURES the
list and the first row instead of counting DOM nodes — counting is exactly
why it passed a build where nothing was visible.
2026-07-30 01:47:19 +03:00
Matysh 764e023996 v1.53.0 2026-07-30 01:37:00 +03:00
Matysh ce6a11f53f v1.53.0: a tap can run your automation
The owner's spec shipped in dev yesterday, released as one:
- tap action 'Run automation/script/scene' with a searchable picker,
  per-domain services, save/runtime guards for the target
- 'Ask for confirmation' checkbox guarding toggle and run alike
- covers/valves in the card-wide toggle, garage/door/gate excluded

Inventory: 148 / 52 / 43 / 72.
2026-07-30 01:34:05 +03:00
Matysh 3e976562ff tap action: run an automation, a script or a scene — with a confirm guard
Owner's spec (2026-07-29), agreed points: one 'Run' action covering the
three runnable domains of HA (a script is the idiomatic 'action' — with
automations alone people would build trigger-less dummies); the confirm
checkbox guards BOTH toggle and run; covers and valves join the card-wide
toggle so curtains work natively.

- marker.tap_action gains 'run'; marker.tap_target (schema-bounded to
  automation./script./scene. ids); marker.tap_confirm.
- the dialog: a searchable picker over the three domains (friendly name +
  kind), save refuses a run action without a target, a vanished target gets
  a warning hint; the checkbox shows for any actionable tap (explicit or
  effective-default toggle).
- the tap: automation.trigger / script.turn_on / scene.turn_on, started/
  error toasts; with confirm on — our own dialog (not window.confirm, it
  must work on a wall tablet), Esc/backdrop/Cancel = no call. The guard
  covers the controls-toggle path too.
- 'run' is explicit-only by construction: it needs a per-marker target, so
  it can never arrive as a card-wide default.
- covers: the old test pinned 'garage stays shut' — that intent survives as
  COVER_GUARDED_CLASSES (garage/door/gate stay out of the CARD-WIDE toggle;
  an explicit per-device toggle remains the owner's conscious choice).
  Locks/alarms stay forbidden everywhere, run included is not affected —
  we do not inspect automation contents, same trust as HA's own Run button.

Tests: unit resolveTapAction/runServiceFor + cover guard, backend schema
parity picks 'run' automatically + tap_target bounds, smoke_tap_run with 11
assertions (picker, search, save guard, confirm cancel/ok, per-domain
services, missing target). smoke_tap_ctx: 4 options now.
Inventory: 148 / 52 / 43 / 72.
2026-07-30 01:26:30 +03:00
Matysh f56bceef27 STATUS: fresh HACS queue numbers (2026-07-29) — 835 ahead, merge rate stalled 2026-07-30 00:49:51 +03:00
204 changed files with 45538 additions and 3380 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`.
+51 -11
View File
@@ -5,6 +5,7 @@
[![GitHub stars](https://img.shields.io/github/stars/Matysh/houseplan-card)](https://github.com/Matysh/houseplan-card/stargazers)
[![CI](https://github.com/Matysh/houseplan-card/actions/workflows/validate.yml/badge.svg)](https://github.com/Matysh/houseplan-card/actions)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
[![Live demo](https://img.shields.io/badge/demo-try_it_live-00c853?logo=homeassistant&logoColor=white)](https://demo.houseplan.tech)
[![Telegram chat](https://img.shields.io/badge/Telegram-chat-2CA5E0?logo=telegram&logoColor=white)](https://t.me/ha_houseplan)
**Turn Home Assistant into a live, interactive map of your home.** Upload or draw
@@ -16,23 +17,48 @@ right on your Lovelace dashboard.
![Interactive Home Assistant floor plan: live rooms, devices, lights and climate on a real floorplan card](docs/images/demo.gif)
> ### 🚀 Try it live — no install needed
> **[demo.houseplan.tech](https://demo.houseplan.tech)** — a real Home Assistant
> with a ready-made plan. Log in as **`demo`** / **`demo`** and click anything:
> toggle lights, open the editors, break things. The stand resets itself to a
> pristine state every hour.
🇷🇺 [Документация на русском](README.ru.md) · 💬 [Telegram chat: **@ha_houseplan**](https://t.me/ha_houseplan)
**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; smart
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
of its own color that spills through doorways and open zone boundaries.
- ☀️ **The sun on the plan** — set the compass and the backdrop lives with
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
explicit button, never an accidental tap.
- 📺 **Kiosk mode** for wall tablets and TVs: fullscreen, swipe between floors,
auto-carousel, per-screen icon sizes.
- 🤖 **Live robot vacuums** — the dock marker stays put while a round puck
drives the plan in real time, pouring its path out from under itself;
current and previous cleanup runs are recorded server-side. Calibration is
one click (rooms matched by name) or a drag-and-stretch overlay. Works with
Xiaomi Cloud Map Extractor, Tasshack dreame-vacuum and Valetudo.
- 🔔 New devices appear automatically with a red “new” dot; the layout is stored
**server-side** — one shared plan for every user and screen, synced live.
@@ -58,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:
@@ -162,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)
@@ -254,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
+53 -13
View File
@@ -3,8 +3,11 @@
[![HACS Custom](https://img.shields.io/badge/HACS-Custom-41BDF5.svg)](https://github.com/hacs/integration)
[![GitHub release](https://img.shields.io/github/v/release/Matysh/houseplan-card)](https://github.com/Matysh/houseplan-card/releases)
[![GitHub stars](https://img.shields.io/github/stars/Matysh/houseplan-card)](https://github.com/Matysh/houseplan-card/stargazers)
[![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 в живую интерактивную карту дома.** Загрузите или
нарисуйте план этажа, обведите комнаты мышкой — и умные устройства появятся на
своих местах: живые состояния, свет по клику, температура и влажность по
@@ -14,23 +17,46 @@
![Интерактивный план дома для Home Assistant: комнаты, устройства, свет и климат на реальном поэтажном плане](docs/images/demo.gif)
> ### 🚀 Попробовать вживую — без установки
> **[demo.houseplan.tech](https://demo.houseplan.tech)** — настоящий Home
> Assistant с готовым планом. Вход **`demo`** / **`demo`**, можно нажимать всё:
> включать свет, открывать редакторы, ломать что угодно. Каждый час стенд сам
> возвращается в исходное состояние.
🇬🇧 [Documentation in English](README.md) · 💬 [Чат в Telegram: **@ha_houseplan**](https://t.me/ha_houseplan)
**Главное**
- ♾️ **Бесконечный холст** — нет «размера плана» и нет края, за который
нельзя выйти: рисуйте и ставьте устройства где угодно, тащите план на
любом зуме, отдаляйтесь, чтобы увидеть всё, и одной кнопкой вписывайте
план обратно в экран.
- 🖱 **Редакторы прямо в карточке** — комнаты, двери и окна, комнаты-острова,
виртуальные стены и декор-слой рисуются кликами; помощник выравнивания и
виртуальные стены и декор-слой рисуются кликами; размеры комнат меняются
перетаскиванием стен с живыми длинами и площадями; помощник выравнивания и
линейка в реальных метрах.
- 💡 **Свет переключается кликом** из коробки; значок выключателя может
управлять группой ламп (в т.ч. «тупые» выключатели и кнопки-пульты).
- 🌒 **Заливка «Свет по источникам»** — тёмный дом, где каждая горящая лампа
даёт пятно своего цвета, проникающее через дверные проёмы и открытые зоны.
- ☀️ **Солнце на плане** — задайте компас, и фон живёт вместе с днём
(белый полдень → золотой час → глубокая ночь), а окна внешних стен пускают
в комнаты настоящие клинья солнечного света; облачность — опционально, от
weather-сущности.
- 🪟 **Шторы открываются тапом** — одно действие открывает, закрывает или
останавливает штору, а сам значок морфится между открытым и закрытым
видом и мягко пульсирует кольцом, пока штора едет.
- 🌡 **Карточки комнат**: температура, влажность, Zigbee-сигнал, свет «1 из 3»;
температурная заливка по комфортным границам.
- 🚪 **Двери, окна и замки** с датчиками — отпирание только явной кнопкой,
никогда случайным тапом.
- 📺 **Киоск-режим** для настенных планшетов и ТВ: полноэкранно, свайп между
этажами, автокарусель, свои размеры на каждом экране.
- 🤖 **Роботы-пылесосы вживую** — маркер-база стоит на месте, а круглая
шайба ездит по плану в реальном времени, «выливая» путь из-под себя;
текущая и прошлая уборки хранятся на сервере. Калибровка — в один клик
(по именам комнат) или перетаскиванием призрака карты. Работают Xiaomi
Cloud Map Extractor, dreame-vacuum (Tasshack) и Valetudo.
- 🔔 Новые устройства сами появляются на плане с красной точкой; раскладка
хранится **на сервере HA** — один план для всех экранов, живая синхронизация.
@@ -57,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 на всех планшетах.
Ключевые преимущества коротко:
@@ -80,6 +115,7 @@ House Plan показывает ваш умный дом так, как он в
кольцо = авария (протечка, дым, газ). Цвет RGB-лампы живёт в её пятне света (режим glow),
где само пятно — индикатор включения, а подложка значка остаётся стандартной.
Полупрозрачный значок = недоступно. Тёмный = покой.
- **Подложку можно двигать и масштабировать.** Картинка плана не прибита к холсту: в редакторе подложки её тянут за тело и за угол, а плашка показывает реальный размер в метрах — чертёж и фотография плана наконец совмещаются.
- **Чёткий зум.** Приближение не «мылит» картинку: план, подписи и иконки остаются векторно-чёткими на любом масштабе.
---
@@ -163,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)
@@ -206,7 +242,7 @@ title: План дома
Как только вы сохранили комнату с привязкой к зоне, **устройства этой зоны автоматически расставляются внутри контура**. Берутся те же устройства, что показаны на странице **Настройки → Устройства → (фильтр по нужной комнате)** — только осмысленные, без служебных записей, мостов и дубликатов.
По умолчанию на план попадают только осмысленные устройства: нефизические (служебные записи, мосты, сцены, лампы, свёрнутые в световую группу) приходят с уже установленной галкой **«Скрыть устройство с плана»**. Дальше галка принадлежит вам — она есть в диалоге каждого устройства, включая виртуальные. Чтобы увидеть и вернуть скрытые, откройте редактор устройств и нажмите **«Показать скрытые»**: скрытые отобразятся полупрозрачными синими призраками, клик открывает диалог. Скрытые устройства учитываются в Zigbee-сигнале комнаты, но света не дают.
По умолчанию на план попадают только осмысленные устройства: нефизические (служебные записи, мосты, сцены, лампы, свёрнутые в световую группу) могут быть скрыты автоматически. Управление находится в левом нижнем углу диалога устройства: **«Скрыть»** убирает маркер после сохранения, а у уже скрытого маркера там же появляется **«Показать»**. Чтобы найти скрытые устройства, откройте редактор устройств и нажмите **«Показать скрытые»**: они отобразятся полупрозрачными синими призраками, клик открывает диалог. Скрытые устройства учитываются в Zigbee-сигнале комнаты, но света не дают.
Дальше можно просто пользоваться планом: клик по иконке открывает карточку устройства с моделью, ссылкой и кнопкой перехода в Home Assistant.
@@ -257,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)**.
---
## Удаление
@@ -289,7 +329,7 @@ title: План дома
**Нужно ли что-то писать в YAML?** Нет. Единственная строчка — это добавление карточки на дашборд; всё остальное делается мышкой.
**Мои устройства не появились на плане.** Устройство появляется, только если его зона в Home Assistant привязана к нарисованной комнате. Проверьте, что у устройства задана комната (Настройки → Устройства), а комната обведена и привязана к этой зоне. Если устройство есть, но скрыто (галка «Скрыть устройство с плана» — для мостов, сцен и прочих нефизических записей она ставится автоматически) — откройте редактор устройств, нажмите **«Показать скрытые»** и снимите галку в его диалоге.
**Мои устройства не появились на плане.** Устройство появляется, только если его зона в Home Assistant привязана к нарисованной комнате. Проверьте, что у устройства задана комната (Настройки → Устройства), а комната обведена и привязана к этой зоне. Если устройство есть, но скрыто автоматически — откройте редактор устройств, нажмите **«Показать скрытые»**, откройте его диалог, нажмите **«Показать»**, затем **«Сохранить»**.
**Можно ли скрыть лишнее устройство или переименовать его?** Да — кликните по устройству на плане и в его карточке нажмите «Редактировать»: там можно сменить имя, иконку, модель или скрыть значок.
+57 -2
View File
@@ -50,6 +50,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
raise ConfigEntryNotReady(f"House Plan storage is not readable: {err}") from err
entry.runtime_data = data
# server-side vacuum trails: the integration records the path itself
from .trails import TrailRecorder
recorder = TrailRecorder(hass, data)
await recorder.async_setup()
# setdefault: the CI harness sets entries up without async_setup, so
# hass.data[DOMAIN] may not exist yet — a KeyError here failed EVERY
# downstream WS test with unknown_error
hass.data.setdefault(DOMAIN, {})["trail_recorder"] = recorder
card_path = Path(__file__).parent / "frontend" / "houseplan-card.js"
plans_path = Path(hass.config.path(PLANS_DIR))
files_path = Path(hass.config.path(FILES_DIR))
@@ -117,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()
}
@@ -125,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)
)
@@ -140,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.
@@ -194,6 +246,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
async def async_unload_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) -> bool:
rec = hass.data.get(DOMAIN, {}).pop("trail_recorder", None)
if rec:
rec.teardown()
"""Unload the entry.
WS commands and the HTTP view are global (async_setup) and stay registered —
+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.52.2"
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.52.2"
"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.
+242
View File
@@ -0,0 +1,242 @@
"""Server-side vacuum trails.
The integration records the robot's path ITSELF by watching the source
entity's state changes — no card involvement. This removes every client-side
race (N open tabs would fight over writes), survives page reloads by
construction, and keeps recording while no card is open at all. Stored: the
current run and one previous run per marker (owner call 2026-07-31 — users
want to see where the cleanup has already been).
"""
from __future__ import annotations
import asyncio
import time
from typing import Any
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.event import async_call_later, async_track_state_change_event
from homeassistant.helpers.storage import Store
from .const import DOMAIN
import logging
_LOGGER = logging.getLogger(__name__)
TRAIL_CAP = 2000 # raw points per run before decimation
SAVE_DELAY_S = 10 # debounce store writes — flash wear over precision
FIRE_THROTTLE_S = 2.0 # event-bus updates for live cards
MOVING_STATES = {"cleaning", "returning", "on"}
def resolve_map_id(src_attrs: Any, vac_attrs: Any) -> str:
"""Map-id normalisation contract, shared with the frontend.
Mirrors src/vacuum.ts vacMapIdFromAttrs (source attrs, `??`-chain) plus the
card's _vacMapId fallback to the vacuum entity's selected_map. The FIRST
value that is not None wins — truthiness is wrong here: a zero-based
`map_index: 0` is a valid first map and an empty string is still an id.
The old `or`-chain dropped the zero, so the server stored trails under a
key the renderer never looked up (HP-1540-02).
"""
for v in (
src_attrs.get("map_name"),
src_attrs.get("current_map"),
src_attrs.get("map_index"),
src_attrs.get("selected_map"),
vac_attrs.get("selected_map"),
):
if v is not None:
return str(v)
return "default"
class TrailBook:
"""Pure run bookkeeping: {marker: {current: run, previous: run}}.
A run is {"map_id", "started", "ended", "points": [[x, y], …]} in RAW
robot coordinates — recalibration never invalidates a stored trail.
"""
def __init__(self, data: dict[str, Any] | None = None) -> None:
self.data: dict[str, Any] = data if isinstance(data, dict) else {}
def on_point(self, marker: str, map_id: str, x: float, y: float, now: float) -> bool:
rec = self.data.setdefault(marker, {})
cur = rec.get("current")
if not cur or cur.get("ended") or cur.get("map_id") != map_id:
# a new run begins: the old one becomes "previous" (and the one
# before it is forgotten — we keep exactly two, per the owner)
if cur:
rec["previous"] = cur
cur = {"map_id": map_id, "started": now, "ended": None, "points": []}
rec["current"] = cur
pts: list[list[float]] = cur["points"]
if pts and pts[-1][0] == x and pts[-1][1] == y:
return False
pts.append([x, y])
if len(pts) > TRAIL_CAP:
# decimate by two but never lose the freshest point
half = pts[0::2]
if half[-1] != pts[-1]:
half.append(pts[-1])
cur["points"] = half
return True
def end_run(self, marker: str, now: float) -> bool:
cur = (self.data.get(marker) or {}).get("current")
if cur and not cur.get("ended"):
cur["ended"] = now
return True
return False
class TrailRecorder:
"""HA wiring: watch the tracked entities, feed the book, persist, notify."""
def __init__(self, hass: HomeAssistant, rt: Any) -> None:
self.hass = hass
self.rt = rt
self.store = Store(hass, 1, f"{DOMAIN}.trails")
self.book = TrailBook()
# HP-1540-03: one source may feed SEVERAL markers — the same robot
# placed on two floors is the documented multi-floor case, and a plain
# source → (marker, vacuum) dict silently kept only the last one
self.pairs: dict[str, list[tuple[str, str]]] = {} # source → [(marker, vacuum), …]
self._unsub_track = None
self._unsub_save = None
self._last_fire = 0.0
# HP-1540-05: config/set fires refresh as a detached task; two of them
# interleaving across the awaited load both subscribed and the loser's
# unsub handle was overwritten — a leak until HA restart
self._refresh_lock = asyncio.Lock()
self._closed = False
async def async_setup(self) -> None:
self.book = TrailBook(await self.store.async_load() or {})
await self.async_refresh()
async def async_refresh(self) -> None:
"""(Re)subscribe after any config change — markers may come and go.
Serialised (HP-1540-05): the lock makes unsubscribe-then-resubscribe
atomic across the awaited config load, so overlapping refresh tasks can
no longer both subscribe and strand one callback forever. The _closed
check covers teardown() racing a refresh that is parked on its await.
"""
async with self._refresh_lock:
stored = await self.rt.config_store.async_load() or {}
if self._closed:
return
cfg = stored.get("config") or {}
pairs: dict[str, list[tuple[str, str]]] = {}
for m in cfg.get("markers") or []:
v = m.get("vacuum") or {}
src = v.get("source")
if not src or v.get("live") is False:
continue
vac = self._vacuum_entity(m)
if vac:
# HP-1540-03: append, never overwrite — every floor's
# marker records its own copy of the run
pairs.setdefault(src, []).append((str(m.get("id")), vac))
self.pairs = pairs
if self._unsub_track:
self._unsub_track()
self._unsub_track = None
# deduplicated: two markers of one robot share source AND vacuum
ents = set(self.pairs) | {vac for ps in self.pairs.values() for _, vac in ps}
_LOGGER.info("Trail recorder: tracking %s", sorted(ents))
if ents:
self._unsub_track = async_track_state_change_event(
self.hass, sorted(ents), self._on_state
)
# A run already in progress (HA restarted mid-cleanup, or the user
# just finished calibrating) must start recording NOW, not at the
# next state change — otherwise the first seconds of the path are
# lost.
for src in self.pairs:
self._sample(src, time.time())
def teardown(self) -> None:
# HP-1540-05: flag FIRST — a refresh parked on its awaited load must
# not re-subscribe after this cleanup has already run
self._closed = True
if self._unsub_track:
self._unsub_track()
self._unsub_track = None
if self._unsub_save:
self._unsub_save()
self._unsub_save = None
def _vacuum_entity(self, m: dict[str, Any]) -> str | None:
b = str(m.get("binding") or "")
if b.startswith("entity:vacuum."):
return b[len("entity:"):]
if b.startswith("device:"):
reg = er.async_get(self.hass)
for e in er.async_entries_for_device(reg, b[len("device:"):]):
if e.entity_id.startswith("vacuum."):
return e.entity_id
return None
def _sample(self, src: str, now: float) -> bool:
"""Record one point (or end the run) for EVERY marker fed by src.
HP-1540-03: the same source serves one marker per floor — all of them
must receive the point, not just whichever survived the dict.
"""
changed = False
for marker, vac in self.pairs.get(src) or ():
st_vac = self.hass.states.get(vac)
# "no state yet" is NOT "stopped": during HA boot the vacuum reads
# unavailable and ending the run here would split one cleanup into
# current+previous on every restart (observed live: 21 points
# became previous, the same run restarted at 5)
if not st_vac or st_vac.state in ("unavailable", "unknown"):
continue
if st_vac.state not in MOVING_STATES:
changed |= self.book.end_run(marker, now)
continue
st_src = self.hass.states.get(src)
attrs = st_src.attributes if st_src else {}
raw = attrs.get("vacuum_position") or attrs.get("robot_position")
# Server-side these attributes are often OBJECTS (Tasshack keeps a
# Point dataclass in memory — it only becomes a dict when
# serialised to the frontend). Caught live on the owner's X50: the
# recorder saw every state change and rejected every single one.
if isinstance(raw, dict):
px, py = raw.get("x"), raw.get("y")
else:
px, py = getattr(raw, "x", None), getattr(raw, "y", None)
try:
x, y = float(px), float(py) # type: ignore[arg-type]
except (TypeError, ValueError):
continue
map_id = resolve_map_id(attrs, st_vac.attributes)
changed |= self.book.on_point(marker, map_id, x, y, now)
return changed
@callback
def _on_state(self, event: Any) -> None:
eid = event.data.get("entity_id")
now = time.time()
changed = False
for src, pair_list in self.pairs.items():
if eid == src or any(eid == vac for _, vac in pair_list):
changed |= self._sample(src, now)
if changed:
self._schedule_save()
if now - self._last_fire >= FIRE_THROTTLE_S:
self._last_fire = now
self.hass.bus.async_fire("houseplan_trail_updated", {})
def _schedule_save(self) -> None:
if self._unsub_save:
return
async def _save(_now: Any) -> None:
self._unsub_save = None
await self.store.async_save(self.book.data)
self._unsub_save = async_call_later(self.hass, SAVE_DELAY_S, _save)
+251 -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")):
@@ -168,31 +223,87 @@ ROOM_SCHEMA = vol.All(
),
_require_geometry,
)
def _north_deg(value):
"""Compass (docs/SUN.md): strict integer degrees, 0..359.
Strict on purpose: Coerce(int) would take "90" and 1.5, and a bool is an
int in Python — none of those is a compass reading a client stored.
"""
if isinstance(value, bool) or not isinstance(value, int):
raise vol.Invalid("north_deg must be an integer in 0..359")
if not 0 <= value <= 359:
raise vol.Invalid("north_deg must be an integer in 0..359")
return value
_BG_MODE = vol.In(["static", "daynight"])
SPACE_DISPLAY_SCHEMA = vol.Schema(
{
vol.Optional("show_borders"): bool,
vol.Optional("show_names"): bool,
vol.Optional("room_color"): vol.Match(r"^#[0-9a-fA-F]{6}$"),
# per-space background around the plan; absent = inherit the global one
vol.Optional("bg_color"): vol.Match(r"^#[0-9a-fA-F]{6}$"),
vol.Optional("room_opacity"): vol.All(vol.Coerce(float), vol.Range(min=0, max=1)),
vol.Optional("fill_mode"): vol.In(["none", "lqi", "light", "temp", "glow"]),
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,
vol.Optional("label_light"): bool,
vol.Optional("card_font_scale"): vol.All(vol.Coerce(float), vol.Range(min=0.5, max=3)),
# sun on the plan (docs/SUN.md): per-space overrides, absent = inherit
vol.Optional("north_deg"): vol.Any(None, _north_deg),
vol.Optional("bg_mode"): vol.Any(None, _BG_MODE),
vol.Optional("sun_rays"): vol.Any(None, bool),
},
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,
@@ -200,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),
@@ -225,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)),
@@ -236,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,
@@ -246,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.
@@ -260,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,
@@ -269,15 +461,43 @@ 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", 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_]+$"))
),
vol.Optional("tap_confirm"): vol.Any(bool, None),
# live robot vacuums (docs/VACUUM.md): everything optional so configs
# from older versions stay valid untouched
vol.Optional("vacuum"): vol.Any(
None,
vol.Schema({
vol.Optional("live"): vol.Any(bool, None),
vol.Optional("trail"): vol.Any(bool, None),
vol.Optional("trail_mode"): vol.Any(
None, vol.In(["never", "cleaning", "always"])
),
vol.Optional("room_highlight"): vol.Any(bool, None),
vol.Optional("source"): vol.Any(str, None),
# one 6-number affine per robot map; numbers must be finite
vol.Optional("calibration"): vol.Schema(
{str: vol.All([_finite], vol.Length(min=6, max=6))}
),
vol.Optional("segment_map"): vol.Schema({str: str}),
}),
),
vol.Optional("controls"): vol.Any(None, vol.All([_TEXT], vol.Length(max=MAX_CONTROLS))),
vol.Optional("glow_radius_cm"): vol.Any(vol.All(vol.Coerce(float), vol.Range(min=10, max=10000)), None),
vol.Optional("is_light"): vol.Any(bool, None),
# 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),
@@ -295,6 +515,13 @@ CONFIG_SCHEMA = vol.Schema(
vol.Optional("settings", default=dict): vol.Schema(
{
vol.Optional("glow_radius_cm"): vol.All(vol.Coerce(float), vol.Range(min=10, max=10000)),
# background around the plan, all spaces (a space may override)
vol.Optional("bg_color"): vol.Match(r"^#[0-9a-fA-F]{6}$"),
# sun on the plan (docs/SUN.md): global defaults
vol.Optional("north_deg"): _north_deg,
vol.Optional("bg_mode"): _BG_MODE,
vol.Optional("sun_rays"): bool,
vol.Optional("weather_entity"): vol.Any(None, _TEXT),
vol.Optional("known_devices"): vol.All([_TEXT], vol.Length(max=MAX_KNOWN_DEVICES)),
vol.Optional("new_device_ids"): vol.All([_TEXT], vol.Length(max=MAX_KNOWN_DEVICES)),
vol.Optional("fill_colors"): vol.Schema(
+261 -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
@@ -16,6 +17,7 @@ from homeassistant.components import websocket_api
from homeassistant.core import HomeAssistant, callback
from .const import (
DOMAIN,
CONF_ADMIN_ONLY, DEFAULT_CONFIG,
CONTENT_URL, FILES_DIR, MAX_PLANS_BYTES, MAX_PLANS_FILES, MAX_PLANS_LISTED,
MAX_SIGN_PATHS,
@@ -35,17 +37,50 @@ 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:
"""Register the WS commands."""
websocket_api.async_register_command(hass, ws_layout_get)
websocket_api.async_register_command(hass, ws_trail_get)
websocket_api.async_register_command(hass, ws_layout_set)
websocket_api.async_register_command(hass, ws_geometry_repair)
websocket_api.async_register_command(hass, ws_layout_update)
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)
@@ -83,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),
}
)
@@ -119,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})
@@ -149,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})
@@ -546,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})
@@ -559,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),
},
)
@@ -677,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
@@ -692,6 +753,7 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
except Exception: # noqa: BLE001 — see above: the commit stands regardless
_LOGGER.exception("House Plan: collecting superseded files failed")
hass.bus.async_fire("houseplan_config_updated", {"rev": new_rev})
_refresh_trail_recorder(hass)
# refresh repair issues (broken plan references) without waiting for a restart
entry = get_entry(hass)
if entry is not None:
@@ -701,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 ----------------
@@ -767,3 +1007,18 @@ async def ws_plan_set(hass: HomeAssistant, connection, msg: dict[str, Any]) -> N
connection.send_error(msg["id"], err.reason, err.detail)
return
connection.send_result(msg["id"], {"ok": True, "url": f"{CONTENT_URL}/plans/_/{name}"})
def _refresh_trail_recorder(hass: HomeAssistant) -> None:
"""Markers changed — the trail recorder must re-resolve what it watches."""
rec = hass.data.get(DOMAIN, {}).get("trail_recorder")
if rec:
hass.async_create_task(rec.async_refresh())
@websocket_api.websocket_command({vol.Required("type"): "houseplan/trail/get"})
@websocket_api.async_response
async def ws_trail_get(hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict) -> None:
"""Current + previous cleanup runs per marker, raw robot coordinates."""
rec = hass.data.get(DOMAIN, {}).get("trail_recorder")
connection.send_result(msg["id"], {"trails": rec.book.data if rec else {}})
+4
View File
@@ -56,6 +56,10 @@ export async function launch(viewport = { width: 820, height: 760 }, scale = 1)
});
await page.goto('http://demo.local/demo.html', { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => window.__card?._model?.length > 0, { timeout: 9000 });
// HP-1552: the first-open boot veil hides the plan (visibility:hidden) until
// the stage height settles — real pointer interaction cannot hit a hidden
// plan, so every smoke starts where the user does: with the plan revealed.
await page.waitForFunction(() => window.__card._booting === false, { timeout: 9000 });
// hass flows continuously in production; the stub sets it once — nudge a rebuild
await page.evaluate(() => { const c = window.__card; c.hass = { ...c.hass }; });
await page.waitForFunction(() => window.__card._devices.length > 0, { timeout: 9000 });
+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');
+36
View File
@@ -0,0 +1,36 @@
// Three stills of the daynight scale (noon/sunset/night) for the white-day review.
import { launch } from './serve.mjs';
const outDir = process.argv[2] || '/tmp';
const { page, browser } = await launch({ width: 700, height: 700 }, 1);
await page.emulateMedia({ reducedMotion: 'reduce' });
await page.evaluate(async () => {
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: 'wW', type: 'window', x: 0.04, y: 0.30, angle: 90, length: 0.08 },
];
sp.plan_url = ''; // hand-drawn plan: white paper — the risky case for a white day
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._cfgRev = (c._cfgRev || 0) + 1;
});
const shot = async (az, el, name) => {
await page.evaluate(async ([az, el]) => {
const c = window.__card;
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 },
} } };
c.requestUpdate(); await c.updateComplete;
}, [az, el]);
await page.waitForTimeout(300);
const stage = await page.evaluateHandle(() => window.__card.shadowRoot.querySelector('.stage'));
await stage.asElement().screenshot({ path: `${outDir}/${name}.png` });
};
await shot(180, 60, 'dn_noon');
await shot(265, 3, 'dn_sunset');
await shot(0, -20, 'dn_night');
await browser.close();
console.log('shots done');
+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);
+34
View File
@@ -0,0 +1,34 @@
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) => {
c.hass = { ...c.hass, states: { ...c.hass.states,
'binary_sensor.hall_motion': { entity_id: 'binary_sensor.hall_motion', state,
attributes: { friendly_name: 'Motion', device_class: 'motion', linkquality: 64 } } } };
await c.updateComplete;
};
await set('off');
await set('on');
// freeze the pulse at a visible frame for a deterministic capture
const st = document.createElement('style');
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.activity-event');
const r = el.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/motion_sense.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');
+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');
+53
View File
@@ -0,0 +1,53 @@
// Screenshots for docs/SUN.md: evening wedges over the glow fill + the compass
// dial in the general settings. Usage: node demo/shot_sun.mjs <outdir>
import { launch } from './serve.mjs';
const outDir = process.argv[2] || '/tmp';
const { page, browser } = await launch({ width: 900, height: 860 }, 2);
// instant colors for the still shot: the day/night transition is tens of seconds
await page.emulateMedia({ reducedMotion: 'reduce' });
await page.evaluate(async () => {
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._cfgRev = (c._cfgRev || 0) + 1;
// a warm evening: the sun low in the west; the living-room light off so
// the glow pool does not swamp the west wedge
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: 265, elevation: 3 },
},
} };
c.requestUpdate();
await c.updateComplete;
});
await page.waitForTimeout(400);
const stage = await page.evaluateHandle(() => window.__card.shadowRoot.querySelector('.stage'));
await stage.asElement().screenshot({ path: outDir + '/sun_rays.png' });
await page.evaluate(async () => {
const c = window.__card;
c._openSettingsDialog();
c._settingsDialog = { ...c._settingsDialog, northDeg: 330 };
c.requestUpdate();
await c.updateComplete;
c.shadowRoot.querySelector('.compass').scrollIntoView({ block: 'center' });
});
await page.waitForTimeout(200);
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);
+355
View File
@@ -0,0 +1,355 @@
// New setting "background color around the plan": global (config.settings.bg_color,
// gear dialog) with a per-space override (space dialog, empty = inherit), applied
// to the stage in view and kiosk modes and to the static space-card. Editors keep
// their own canvas; the open dialogs preview the pending color live.
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 stageBg = async () => {
c.requestUpdate();
await c.updateComplete;
return getComputedStyle(sr().querySelector('.stage')).backgroundColor;
};
const rgb = (hex) => `rgb(${parseInt(hex.slice(1, 3), 16)}, ${parseInt(hex.slice(3, 5), 16)}, ${parseInt(hex.slice(5, 7), 16)})`;
const themeBg = await stageBg(); // theme default from the stylesheet
const sp = c._serverCfg.spaces.find((s) => s.id === c._space);
// 1) global setting colors the stage
c._serverCfg.settings = { ...(c._serverCfg.settings || {}), bg_color: '#224466' };
out.globalApplies = (await stageBg()) === rgb('#224466');
// 2) per-space override wins
sp.settings = { ...(sp.settings || {}), bg_color: '#663311' };
out.spaceOverrides = (await stageBg()) === rgb('#663311');
// 3) clearing the space setting inherits the global again
delete sp.settings.bg_color;
out.clearInheritsGlobal = (await stageBg()) === rgb('#224466');
// 4) clearing the global returns the theme default
delete c._serverCfg.settings.bg_color;
out.clearRestoresTheme = (await stageBg()) === themeBg;
// 5) invalid stored value is ignored (renderer never trusts garbage)
c._serverCfg.settings.bg_color = 'url(javascript:alert(1))';
out.garbageIgnored = (await stageBg()) === themeBg;
delete c._serverCfg.settings.bg_color;
// 6) the settings dialog previews its pending color live + saves it
let saved = null;
const origWS = c.hass.callWS;
c.hass.callWS = async (m) => {
if (m.type === 'houseplan/config/set') { saved = m.config; return { rev: (c._cfgRev || 0) + 1 }; }
return origWS.call(c.hass, m);
};
c._openSettingsDialog();
await c.updateComplete;
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();
out.dialogSaves = saved?.settings?.bg_color === '#0a2a4a';
out.savedApplies = (await stageBg()) === rgb('#0a2a4a');
// 7) the space dialog previews and saves the override
c._openSpaceDialog('edit', c._space);
await c.updateComplete;
c._spaceDialog = { ...c._spaceDialog, bgColor: '#4a0a2a' };
out.spaceDialogPreviews = (await stageBg()) === rgb('#4a0a2a');
saved = null;
await c._saveSpaceDialog();
const savedSp = saved?.spaces?.find((s) => s.id === c._space);
out.spaceDialogSaves = savedSp?.settings?.bg_color === '#4a0a2a';
out.spaceSavedApplies = (await stageBg()) === rgb('#4a0a2a');
// ...and clearing it in the dialog inherits the global again
c._openSpaceDialog('edit', c._space);
await c.updateComplete;
out.spaceDialogPrefilled = c._spaceDialog.bgColor === '#4a0a2a';
c._spaceDialog = { ...c._spaceDialog, bgColor: null };
await c._saveSpaceDialog();
// over the wire `undefined` keys vanish — check the JSON form, like the backend sees it
const wire = JSON.parse(JSON.stringify(saved));
const savedSp2 = wire?.spaces?.find((s) => s.id === c._space);
out.spaceDialogClears = !('bg_color' in (savedSp2?.settings || {}));
out.backToGlobal = (await stageBg()) === rgb('#0a2a4a');
c.hass.callWS = origWS;
// 8) editors keep their own canvas (inline bg only in view/kiosk)
c._setMode('decor');
const editorBg = await stageBg();
out.editorUnpainted = editorBg !== rgb('#0a2a4a');
c._setMode('view');
out.viewPaintedAgain = (await stageBg()) === rgb('#0a2a4a');
// 9) kiosk instance respects the setting
const k = document.createElement('houseplan-card');
k.setConfig({ type: 'custom:houseplan-card', kiosk: true, cycle: 0 });
k.hass = c.hass;
k.style.cssText = 'position:fixed;left:0;top:0;width:860px;height:860px;z-index:99';
document.body.appendChild(k);
await new Promise((r) => setTimeout(r, 300));
// the kiosk instance re-reads the pristine demo config — inject the color
// into ITS copy; what matters here is the kiosk rendering path
k._serverCfg.settings = { ...(k._serverCfg.settings || {}), bg_color: '#0a2a4a' };
k.hass = { ...c.hass };
k.requestUpdate();
await k.updateComplete;
const kStage = (k.shadowRoot || k.renderRoot).querySelector('.stage');
out.kioskApplies = !!kStage && getComputedStyle(kStage).backgroundColor === rgb('#0a2a4a');
k.remove();
// 10) the static space-card paints the same background
await customElements.whenDefined('houseplan-space-card');
const cfg = JSON.parse(JSON.stringify(c._serverCfg));
cfg.settings = { ...(cfg.settings || {}), bg_color: '#123456' };
const hass2 = { ...c.hass, callWS: async (m) => {
if (m.type === 'houseplan/config/get') return { config: cfg, rev: 1 };
if (m.type === 'houseplan/layout/get') return { layout: {} };
return origWS.call(c.hass, m);
} };
const scHost = document.createElement('div');
document.body.appendChild(scHost);
const sc = document.createElement('houseplan-space-card');
sc.setConfig({ type: 'custom:houseplan-space-card', space: c._space });
sc.hass = hass2;
scHost.appendChild(sc);
const t0 = Date.now();
while (!sc.renderRoot?.querySelector('.hp-static-stage') && Date.now() - t0 < 6000) await new Promise((r) => setTimeout(r, 80));
await sc.updateComplete;
const st = sc.renderRoot.querySelector('.hp-static-stage');
out.staticCardApplies = !!st && getComputedStyle(st).backgroundColor === rgb('#123456');
// ...and the space override beats the global there too
cfg.spaces.find((s) => s.id === c._space).settings = { bg_color: '#654321' };
sc.hass = { ...hass2 };
sc._cfg = null; // force a config re-read
await new Promise((r) => setTimeout(r, 200));
sc.requestUpdate(); await sc.updateComplete;
const st2 = sc.renderRoot.querySelector('.hp-static-stage');
out.staticCardSpaceWins = !!st2 && getComputedStyle(st2).backgroundColor === rgb('#654321');
scHost.remove();
// tidy: drop the color so later smokes on this page see the theme default
delete c._serverCfg.settings.bg_color;
const spx = c._serverCfg.spaces.find((s) => s.id === c._space);
if (spx?.settings) delete spx.settings.bg_color;
await stageBg();
return out;
});
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. 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 () => {
const out = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const upd = async () => { c.requestUpdate(); await c.updateComplete; };
c._serverCfg.settings = { ...(c._serverCfg.settings || {}), bg_color: '#ff00ff' };
c._cfgRev = (c._cfgRev || 0) + 1;
await upd();
const m = c._spaceModel();
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 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 .hp-paper');
c._setMode('view');
await upd();
// daynight night: the paper dims via the brightness filter ONLY, never alpha
c._serverCfg.settings.bg_mode = 'daynight';
c._serverCfg.settings.north_deg = 0;
c._cfgRev++;
c.hass = { ...c.hass, states: { ...c.hass.states, 'sun.sun': {
entity_id: 'sun.sun', state: 'below_horizon',
attributes: { azimuth: 180, elevation: -20 },
} } };
await upd();
out.nightDimsByBrightness = (sr().querySelector('.zoomwrap').getAttribute('style') || '').includes('brightness(0.900');
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;
c._cfgRev++;
await upd();
return out;
});
Object.assign(res, paper);
// ---- 12) drawn plan: the paper follows the ROOM CONTOURS ---------------
// Owner: «белая подложка должна быть по границам комнат» — an L-shaped house
// plus a detached building must NOT grow a white bounding rectangle. One
// paper shape per room, in exactly the room's own geometry; the acid scene
// colour shows in the L's pocket and between the buildings, right up to the
// exterior walls; inside the rooms it never shows. A live resize preview
// (_rszPreview) moves the paper together with the dragged wall.
const drawn = 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 sp = c._serverCfg.spaces.find((s) => s.id === c._space);
sp.plan_url = ''; // hand-drawn plan now
// binary-exact fractions -> exact render coords (0.125*1000 = 125 etc.)
sp.rooms = [
{ id: 'rL', name: 'L', poly: [[0.125, 0.125], [0.625, 0.125], [0.625, 0.375], [0.375, 0.375], [0.375, 0.625], [0.125, 0.625]] },
{ id: 'rQ', name: 'Q', x: 0.75, y: 0.75, w: 0.1875, h: 0.1875 },
];
sp.openings = [];
sp.decor = [];
c._devices = []; // no icons over the pixel probes below
c._cfgRev = (c._cfgRev || 0) + 1;
c._cfgEpoch++;
await upd();
const papers = () => [...sr().querySelectorAll('.stage.noplan svg .hp-paper')];
let ps = papers();
out.paperPerRoom = ps.length === sp.rooms.length;
out.paperShapesMatchRooms = ps.length === 2
&& ps[0].tagName.toLowerCase() === 'polygon'
&& ps[0].getAttribute('points') === sp.rooms[0].poly.map((p) => (p[0] * 1000) + ',' + (p[1] * 1000)).join(' ')
&& ps[1].tagName.toLowerCase() === 'rect';
out.drawnPaperWhite = ps.length > 0 && ps.every((p) => getComputedStyle(p).fill === 'rgb(255, 255, 255)');
out.paperNoStroke = ps.length > 0 && ps.every((p) => {
const cs = getComputedStyle(p);
return cs.stroke === 'none' || cs.strokeOpacity === '0' || cs.strokeWidth === '0px';
});
// paper first, everything else on top of it (one .hp-paperg group wraps
// all paper shapes so the daynight drop shadow composites without seams)
const first = sr().querySelector('.stage.noplan svg').firstElementChild;
out.paperUnderneath = !!first && first.classList.contains('hp-paperg')
&& !!first.firstElementChild && first.firstElementChild.classList.contains('hp-paper');
// live resize preview: drag the L's right wall 0.625 -> 0.6875 — the paper
// must move WITH the room, not lag behind until the drop
const pv = JSON.parse(JSON.stringify(sp));
pv.rooms[0].poly = pv.rooms[0].poly.map(([x, y]) => [x === 0.625 ? 0.6875 : x, y]);
c._rszPreview = { space: c._space, sp: pv };
c._cfgEpoch++;
await upd();
out.previewMovesPaper = (papers()[0]?.getAttribute('points') || '').includes('687.5,125');
c._rszPreview = null;
c._cfgEpoch++;
await upd();
out.previewRestores = (papers()[0]?.getAttribute('points') || '').includes('625,125');
// screen coords for the pixel probes (svg user units -> viewport px)
const svgEl = sr().querySelector('.stage svg');
const ctm = svgEl.getScreenCTM();
const toScr = (x, y) => { const q = svgEl.createSVGPoint(); q.x = x; q.y = y; const r = q.matrixTransform(ctm); return [r.x, r.y]; };
out.probes = {
pocket: toScr(500, 500), // the L's notch — bg must show here
gap: toScr(687, 300), // between the two buildings
inside: [toScr(250, 250), toScr(500, 250), toScr(250, 500), toScr(843.75, 843.75)],
};
return out;
});
const { probes } = drawn;
delete drawn.probes;
Object.assign(res, drawn);
// pixel proof: the acid scene colour IS in the L's pocket and between the
// buildings (where the old bounding-box paper drew a white square), and is
// NOT inside any room
if (probes) {
const shot = (await page.screenshot()).toString('base64');
const px = await page.evaluate(async ({ shot, probes }) => {
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 at = ([x, y]) => [...ctx.getImageData(Math.round(x), Math.round(y), 1, 1).data];
const acid = ([r, g, b]) => r > 200 && b > 200 && g < 160; // #ff00ff admixture
return {
acidInPocket: acid(at(probes.pocket)),
acidBetweenBuildings: acid(at(probes.gap)),
noAcidInsideRooms: probes.inside.every((q) => !acid(at(q))),
};
}, { shot, probes });
Object.assign(res, px);
} else {
res.acidInPocket = false;
res.acidBetweenBuildings = false;
res.noAcidInsideRooms = false;
}
// ---- 13) the static space-card follows the same paper contract ----------
const stat = await page.evaluate(async () => {
const out = {};
const c = window.__card;
await customElements.whenDefined('houseplan-space-card');
// NOTE: the config-store cache is module-level and already primed by the
// earlier static-card sections (with the PRE-§12 rooms), so a mocked
// callWS would never reach the card — inject the snapshot directly.
const mk = async (cfg) => {
const host = document.createElement('div');
document.body.appendChild(host);
const el = document.createElement('houseplan-space-card');
el.setConfig({ type: 'custom:houseplan-space-card', space: c._space });
el._snap = { config: cfg, rev: 1, layout: {} };
el._loadedOnce = true;
el.hass = c.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;
return { el, host };
};
const base = JSON.parse(JSON.stringify(c._serverCfg));
base.spaces.find((s) => s.id === c._space).plan_url = '/assets/f1.svg';
base.settings = { ...(base.settings || {}), bg_color: '#ff00ff' };
const a = await mk(base);
const pa = a.el.renderRoot.querySelector('.hp-static-stage svg rect.hp-paper');
out.staticPaperExists = !!pa;
out.staticPaperOpaque = !!pa && getComputedStyle(pa).fillOpacity === '1'
&& getComputedStyle(pa).fill !== 'none';
a.host.remove();
// drawn plan: per-room paper shapes (the L + the detached rect from §12)
const drawnCfg = JSON.parse(JSON.stringify(base));
const dsp = drawnCfg.spaces.find((s) => s.id === c._space);
dsp.plan_url = '';
const b = await mk(drawnCfg);
const pbs = [...b.el.renderRoot.querySelectorAll('.hp-static-stage svg .hp-paper')];
out.staticDrawnPaperPerRoom = pbs.length === dsp.rooms.length;
out.staticDrawnPaperShapes = pbs.length === 2
&& pbs[0].tagName.toLowerCase() === 'polygon'
&& pbs[1].tagName.toLowerCase() === 'rect';
b.host.remove();
return out;
});
Object.assign(res, stat);
checkAll({ ...paper, ...drawn, ...stat,
acidInPocket: res.acidInPocket,
acidBetweenBuildings: res.acidBetweenBuildings,
noAcidInsideRooms: res.noAcidInsideRooms });
await finish(browser, res);
+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);
+147
View File
@@ -0,0 +1,147 @@
// Опция «Использовать датчик температуры устройства» (marker.use_climate_temp):
// климат-устройства (кондиционеры, термостаты) знают температуру комнаты
// (attributes.current_temperature). По умолчанию ВЫКЛ — ни плашки, ни вклада
// в среднюю. Включена через диалог (реальный клик по чекбоксу): плашка .tval
// у значка + голос в средней комнаты (термометр 20 + климат 23.5 → 21.75,
// т.е. 21.8 по сетке карточки 0.1°). Unavailable-климат не ломает ничего.
import { launch, check, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const out = {};
// --- впрыскиваем климат-устройства в демо-hass ---------------------------
await page.evaluate(() => {
const c = window.__card;
const h = window.__mkHass();
const inject = (hass) => ({
...hass,
devices: {
...hass.devices,
d_ac: { id: 'd_ac', name: 'Air conditioner', model: 'AC-12', area_id: 'living_room', identifiers: [['demo', 'd_ac']], entry_type: null, via_device_id: null },
d_trv: { id: 'd_trv', name: 'Radiator valve', model: 'TRV-1', area_id: 'bedroom', identifiers: [['demo', 'd_trv']], entry_type: null, via_device_id: null },
},
entities: {
...hass.entities,
'climate.ac': { entity_id: 'climate.ac', device_id: 'd_ac', platform: 'demo' },
'climate.trv': { entity_id: 'climate.trv', device_id: 'd_trv', platform: 'demo' },
},
states: {
...hass.states,
// термометр гостиной — ровно 20, чтобы средняя с климатом была детерминированной
'sensor.living_temp': { ...hass.states['sensor.living_temp'], state: '20' },
'climate.ac': { entity_id: 'climate.ac', state: 'cool', attributes: { friendly_name: 'Air conditioner', current_temperature: 23.5, temperature: 24 } },
// мёртвый климат: state unavailable, атрибутов нет
'climate.trv': { entity_id: 'climate.trv', state: 'unavailable', attributes: { friendly_name: 'Radiator valve' } },
},
});
window.__injectClimate = () => { c.hass = inject(window.__mkHass()); };
window.__injectClimate();
c._regSignature = '';
c._maybeRebuildDevices();
c.requestUpdate();
});
await page.waitForFunction(() => window.__card._devices.some((d) => d.entities?.includes('climate.ac')));
// --- без опции: ни плашки, ни вклада в среднюю ---------------------------
Object.assign(out, await page.evaluate(async () => {
const o = {};
const c = window.__card;
await c.updateComplete;
const ac = c._devices.find((d) => d.entities?.includes('climate.ac'));
o.acBuilt = !!ac;
o.noBadgeByDefault = c._liveTemp(ac) === null;
o.avgWithoutOption = c._climate().get('living_room')?.temp; // === 20
return o;
}), { avgWithoutOption: 20 });
// --- диалог: чекбокс есть у климат-устройства, нет у обычного, клик мышью --
Object.assign(out, await page.evaluate(async () => {
const o = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
// у обычного термометра чекбокса нет
const th = c._devices.find((d) => d.entities?.includes('sensor.living_temp'));
c._openMarkerDialog(th); await c.updateComplete;
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('hp-dialog .climrow input');
o.checkboxForClimate = !!row;
o.uncheckedByDefault = !!row && !row.checked;
if (row) row.scrollIntoView({ block: 'center' });
return o;
}));
// реальный клик по чекбоксу — координатами, через весь стек событий
const box = await page.evaluate(() => {
const c = window.__card;
const sr = c.shadowRoot || c.renderRoot;
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);
if (box) await page.mouse.click(box.x, box.y);
Object.assign(out, await page.evaluate(async () => {
const o = {};
const c = window.__card;
await c.updateComplete;
o.clickTicksState = c._markerDialog?.useClimateTemp === true;
await c._saveMarker(); await c.updateComplete;
const m = (c._serverCfg.markers || []).find((x) => x.binding === 'device:d_ac');
o.savedInMarker = m?.use_climate_temp === true;
return o;
}));
// --- с опцией: плашка «23.5°», средняя пересчиталась ----------------------
Object.assign(out, await page.evaluate(async () => {
const o = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
await c.updateComplete;
const ac = c._devices.find((d) => d.entities?.includes('climate.ac'));
o.liveTempFromClimate = c._liveTemp(ac); // === 23.5
const badges = [...sr().querySelectorAll('.dev .tval')].map((el) => el.textContent.trim());
o.badgeVisible = badges.includes('23.5°');
// средняя гостиной: (20 + 23.5) / 2 = 21.75 → 21.8 по сетке карточки (0.1°)
o.avgWithOption = c._climate().get('living_room')?.temp;
// и на КАРТОЧКЕ КОМНАТЫ: включаем метрику температуры и читаем текст
const sp = c._serverCfg.spaces.find((s) => s.id === 'f1');
sp.settings = { ...(sp.settings || {}), label_temp: true, show_names: true };
c.requestUpdate(); await c.updateComplete;
const labels = [...sr().querySelectorAll('.roomlabel .rlm')].map((el) => el.textContent.trim());
o.roomCardShowsAvg = labels.some((t) => t.includes('21.8°'));
return o;
}), { liveTempFromClimate: 23.5, avgWithOption: 21.8 });
// --- unavailable-климат: опция включена, но ни плашки, ни вклада ----------
Object.assign(out, await page.evaluate(async () => {
const o = {};
const c = window.__card;
c._serverCfg.markers = [...c._serverCfg.markers,
{ id: 'd_trv', binding: 'device:d_trv', use_climate_temp: true, hidden: false }];
c._cfgEpoch++; c._regSignature = '';
c._maybeRebuildDevices(); c.requestUpdate(); await c.updateComplete;
const trv = c._devices.find((d) => d.entities?.includes('climate.trv'));
o.unavailNoBadge = !!trv && c._liveTemp(trv) === null;
o.unavailNoVote = c._climate().get('bedroom')?.temp ?? null; // в спальне нет термометров
// а гостиная как была 21.8
o.avgStillCorrect = c._climate().get('living_room')?.temp;
return o;
}), { unavailNoVote: null, avgStillCorrect: 21.8 });
// --- чекбокс переживает пересоздание диалога ------------------------------
Object.assign(out, await page.evaluate(async () => {
const o = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
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('hp-dialog .climrow input')?.checked === true;
c._markerDialog = null;
return o;
}));
checkAll(out, { avgWithoutOption: 20, liveTempFromClimate: 23.5, avgWithOption: 21.8, unavailNoVote: null, avgStillCorrect: 21.8 });
await finish(browser, out);
+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);
+145 -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;
@@ -25,16 +28,69 @@ const res = await page.evaluate(async () => {
out.rectRendered = !!sr().querySelector('.decorlayer rect.dshape');
// 3) линия и овал
c._decorDraft = { kind: 'line', a: [g * 2, g * 2], b: [g * 8, g * 2], pid: 1 };
await c.updateComplete;
// превью рисуемой линии уже с круглыми концами (зубцы на стыках)
const draftLine = sr().querySelector('.decorlayer line.ddraft');
out.draftLineRound = !!draftLine
&& draftLine.getAttribute('stroke-linecap') === 'round'
&& draftLine.getAttribute('stroke-linejoin') === 'round';
c._decorCommitDraft();
c._decorDraft = { kind: 'ellipse', a: [g * 30, g * 30], b: [g * 40, g * 36], pid: 1 };
c._decorCommitDraft(); await c.updateComplete;
out.threeShapes = c._decorList.length === 3;
// сохранённые линии рендерятся с круглыми концами: диаметр = толщине линии,
// стык двух линий под углом — без зубца (скриншот владельца)
await c.updateComplete;
const lines = [...sr().querySelectorAll('.decorlayer line.dshape')];
out.savedLineRound = lines.length > 0 && lines.every((l) =>
l.getAttribute('stroke-linecap') === 'round' && l.getAttribute('stroke-linejoin') === 'round');
// вырожденная фигура не сохраняется
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('Сауна'));
@@ -50,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';
@@ -70,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 });
+25 -5
View File
@@ -1,13 +1,18 @@
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 = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
// 1) диалог общих настроек: 7 цветовых строк, 3 группы
// 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": 11,
"groups": ["Fill: lights", "Fill: temperature", "Fill: zigbee signal", "Light-sources fill"],
"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));
+86
View File
@@ -0,0 +1,86 @@
// Native HA controls in dialogs (ha-switch / ha-slider) + the fallback.
// The demo env has no HA frontend, so every OTHER smoke already runs the
// fallback branch (plain input[type=checkbox|range]). Here we check both:
// 1) fallback: without ha-* the dialogs render the classic inputs;
// 2) ha branch: after registering lightweight ha-switch/ha-slider stubs
// (checked/value + `change`/`input`, the real contract of the HA ones)
// a re-opened dialog renders them, values flow BOTH ways.
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;
// --- 1) fallback branch: no ha-* registered -> classic inputs ----------
c._openMarkerDialog(c._devices[0]); await c.updateComplete;
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 -----------------------
customElements.define('ha-switch', class extends HTMLElement {
#on = false;
get checked() { return this.#on; }
set checked(v) { this.#on = !!v; }
get disabled() { return this.hasAttribute('disabled'); }
set disabled(v) { v ? this.setAttribute('disabled', '') : this.removeAttribute('disabled'); }
});
customElements.define('ha-slider', class extends HTMLElement {
#v = 0;
get value() { return this.#v; }
set value(v) { this.#v = Number(v); }
set min(_) {} set max(_) {} set step(_) {}
});
c._openMarkerDialog(c._devices[0]); await c.updateComplete;
const switches = [...sr().querySelectorAll('hp-dialog ha-switch')];
out.haSwitchRendered = switches.length >= 2; // is_light + hide-from-plan at minimum
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('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('hp-dialog ha-switch')].find((el) => el.checked === before);
sw.checked = !before;
sw.dispatchEvent(new Event('change', { bubbles: true }));
await c.updateComplete;
out.upstreamChecked = c._markerDialog.hideFromPlan === !before || c._markerDialog.isLight === !before
|| c._markerDialog.useClimateTemp === !before || c._markerDialog.tapConfirm === !before;
// --- 5) slider both ways ----------------------------------------------
const sizeBefore = c._markerDialog.size;
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 }));
await c.updateComplete;
out.upstreamValue = c._markerDialog.size === 2;
// `change` (fired on release, HA-version dependent) reaches the same handler
sl.value = 2.5;
sl.dispatchEvent(new Event('change', { bubbles: true }));
await c.updateComplete;
out.upstreamValueChangeEvt = c._markerDialog.size === 2.5;
c._markerDialog = null; await c.updateComplete;
// --- 6) general settings: opacity sliders take the ha branch too -------
c._openSettingsDialog(); await c.updateComplete;
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;
out.gsUpstream = Math.round((Object.values(c._settingsDialog.colors)[0].a) * 100) === 55;
c._settingsDialog = null; await c.updateComplete;
return out;
});
checkAll(res, {});
await finish(browser, res);
+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);
+57 -1
View File
@@ -35,4 +35,60 @@ const out = await page.evaluate(async () => {
c._cfgEpoch++; c._regSignature = ''; c._maybeRebuildDevices();
return o;
});
await finish(browser, checkAll(out));
checkAll(out);
// Тот же класс бага для СПУТНИКОВ значка (репорт владельца, 2026-08-01):
// бейдж значения (display:value) и temp-плашка считались от базового
// --icon-size и оставались маленькими при size=2. Всё, что визуально
// принадлежит устройству, обязано расти вместе с --dev-scale.
const out2 = await page.evaluate(async () => {
const o = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const devEl = () => {
const idx = c._devices.filter((x) => x.space === c._space).findIndex((x) => x.id === 'd_temp');
return sr().querySelectorAll('.dev')[idx];
};
const setMarker = async (extra) => {
c._serverCfg.markers = (c._serverCfg.markers || []).filter((m) => m.id !== 'd_temp');
c._serverCfg.markers.push({ id: 'd_temp', binding: 'device:d_temp', ...extra });
c._cfgEpoch++; c._regSignature = '';
c._maybeRebuildDevices(); c.requestUpdate(); await c.updateComplete;
await new Promise((r) => setTimeout(r, 80));
};
const fs = (el) => parseFloat(getComputedStyle(el).fontSize);
// базовый --icon-size в px: ширина немасштабированного бейджа минус рамка 1px×2
const plain = [...sr().querySelectorAll('.dev')].find(
(e) => !e.classList.contains('ghost') && !e.classList.contains('valonly') && !(e.getAttribute('style') || '').includes('--dev-scale'));
const iconPx = plain.getBoundingClientRect().width - 2;
// --- бейдж значения (valonly) ---
await setMarker({ display: 'value', size: 1 });
const v1 = devEl().getBoundingClientRect();
const vf1 = fs(devEl().querySelector('.valtext'));
// при size=1 дефолт не изменился: шрифт ровно прежние 0.45 * icon-size
o.valDefaultKept = Math.abs(vf1 - iconPx * 0.45) < 0.75;
await setMarker({ display: 'value', size: 2 });
const v2 = devEl().getBoundingClientRect();
const vf2 = fs(devEl().querySelector('.valtext'));
o.valFontScales = vf2 / vf1 > 1.8 && vf2 / vf1 < 2.2;
o.valPlateScales = (v2.height - 2) / (v1.height - 2) > 1.8 && (v2.height - 2) / (v1.height - 2) < 2.2
&& v2.width / v1.width > 1.6 && v2.width / v1.width < 2.4;
// --- temp-плашка (.tval) рядом со значком ---
await setMarker({ size: 1 });
const t1 = devEl().querySelector('.tval').getBoundingClientRect();
const tf1 = fs(devEl().querySelector('.tval'));
// дефолт: line-height 0.68 * icon-size + рамка 1px×2
o.tvalDefaultKept = Math.abs(t1.height - (iconPx * 0.68 + 2)) < 1;
await setMarker({ size: 2 });
const t2 = devEl().querySelector('.tval').getBoundingClientRect();
const tf2 = fs(devEl().querySelector('.tval'));
o.tvalScales = (t2.height - 2) / (t1.height - 2) > 1.8 && (t2.height - 2) / (t1.height - 2) < 2.2
&& tf2 / tf1 > 1.8 && tf2 / tf1 < 2.2;
c._serverCfg.markers = c._serverCfg.markers.filter((m) => m.id !== 'd_temp');
c._cfgEpoch++; c._regSignature = ''; c._maybeRebuildDevices();
return o;
});
await finish(browser, checkAll(out2));
+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]]});
+2 -1
View File
@@ -13,7 +13,8 @@ const st = () => page.evaluate(() => {
editBtns: sr.querySelectorAll('.head .btn:not(.zb)').length,
gears: sr.querySelectorAll('.tabedit').length,
markupBar: !!sr.querySelector('.editbar'),
stageClass: sr.querySelector('.stage').className,
// hpsettle is the transient post-boot grace (AUD-1552-02), not a mode class
stageClass: sr.querySelector('.stage').className.replace(/ ?\bhpsettle\b/, ''),
};
});
// 1) старт: view, чистая шапка
+161
View File
@@ -0,0 +1,161 @@
// Правило владельца (2026-08-01, вариант «б»): «движение = разовая вспышка
// в момент обнаружения; cool-down не пульсирует; присутствие = статичное
// кольцо пока обитаемо». Датчик ДВИЖЕНИЯ (device_class motion) при переходе
// off→on получает activity-event — три последовательные волны
// (hp-activity-event, ~3.3 c), после чего эффект снимается, ДАЖЕ если сущность всё ещё
// висит в on (это cool-down датчика, а не движение). Новый цикл off→on —
// новая вспышка. Датчики ПРИСУТСТВИЯ (occupancy/presence) при on несут
// activity-presence — статичное кольцо без анимации (opacity 0.4). Жёлтая
// заливка .dev.on в обоих случаях не участвует; ghost — без кольца
// (призрак — конфигурация, не статус).
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;
// 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,
attributes: { friendly_name: 'Motion', device_class, linkquality: 64 } } } };
await c.updateComplete;
};
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// --- первое событие сразу после build не теряется как baseline ----------
await setMotion('on', 'motion');
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 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('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 : '';
out.flashBgNeutral = !!neutral && bg === getComputedStyle(neutral).backgroundColor;
out.flashBgNotYellow = bg !== '' && bg !== 'rgb(255, 212, 92)'; // --hp-on
// --- КЛЮЧЕВОЙ ассерт: вспышка гаснет, хотя state всё ещё on -------------
// (cool-down датчика — не движение: класс обязан уйти сам по таймеру)
await sleep(3600);
await c.updateComplete;
out.stateStillOn = c.hass.states['binary_sensor.hall_motion'].state === 'on';
out.flashEndsWhileOn = sr().querySelectorAll('.dev.activity-event').length === 0;
// --- повторный цикл off→on: новая вспышка --------------------------------
await setMotion('off', 'motion');
await setMotion('on', 'motion');
out.retripFlashesAgain = sr().querySelectorAll('.dev.activity-event').length === 1;
// --- HP-1543-02: быстрый повторный off→on ДО конца текущей вспышки ------
// обязан ПЕРЕЗАПУСТИТЬ анимацию (новый 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-activity-event') && a.playState === 'running');
await setMotion('off', 'motion');
await setMotion('on', 'motion'); // trip №1
out.rapidFirstTripFlashes = sr().querySelectorAll('.dev.activity-event').length === 1;
await sleep(1200); // середина первой вспышки (< 3.3s)
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.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.activity-event');
const a3 = senseAnim(el3);
out.rapidSecondFlashStillPlays = !!a3 && a3.playState === 'running';
out.rapidRingVisibleAfterFirstWindow = !!el3
&& [...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.activity-event').length === 0;
// --- occupancy: статичное кольцо presence, без анимации -----------------
await setMotion('on', 'occupancy');
const holdEl = sr().querySelector('.dev.activity-presence');
out.occupancyHolds = !!holdEl;
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('activity-event');
await setMotion('off', 'occupancy');
out.occupancyOffClears = sr().querySelectorAll('.dev.activity-presence').length === 0;
// --- presence аналогично -------------------------------------------------
await setMotion('on', 'presence');
out.presenceHolds = sr().querySelectorAll('.dev.activity-presence').length === 1;
// и это именно sense-состояние, не тревога и не «открыто»
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 || [])
.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.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('activity-event') && !ghost.classList.contains('activity-presence')
&& !ghost.querySelector('.activity-ring');
return out;
});
checkAll(res, {});
await finish(browser, res);
+276
View File
@@ -0,0 +1,276 @@
// 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 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
// cm = units * 1.2 and r1's top wall (510 u) is 6.12 m.
import { launch, check, finish } from './serve.mjs';
const { page, browser } = await launch();
await page.evaluate(() => {
const c = window.__card;
const sp = c._serverCfg.spaces.find((s) => s.id === 'f1');
// 80-unit door on r1's top wall (y = 0.14), off-center
sp.openings = [{ id: 'op1', type: 'door', x: 0.2, y: 0.14, angle: 0, length: 0.08 }];
c._setMode('plan'); c._tool = 'opening';
c._cfgEpoch++; c.requestUpdate();
return c.updateComplete && true;
});
const settle = () => page.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))));
await settle();
const screenPt = (x, y) => page.evaluate(([x, y]) => {
const c = window.__card;
const stage = c.renderRoot.querySelector('.stage');
const r = stage.getBoundingClientRect();
const svg = stage.querySelector('svg');
const [vx, vy, vw, vh] = svg.getAttribute('viewBox').split(' ').map(Number);
return [r.left + ((x - vx) / vw) * r.width, r.top + ((y - vy) / vh) * r.height];
}, [x, y]);
const badges = () => page.evaluate(() =>
[...window.__card.renderRoot.querySelectorAll('.measurelabel.opshoulder')].map((el) => el.textContent.trim()).sort());
const nums = async () => (await badges()).map((t) => parseFloat(t));
const tick = () => page.evaluate(() => window.__card.renderRoot.querySelectorAll('.opcentertick').length);
const near = (a, b, tol) => Math.abs(a - b) <= tol;
// ---------- drag to an off-center spot: two live badges, no tick ----------
const [sx, sy] = await screenPt(200, 140);
const [t1x] = await screenPt(460, 140);
await page.mouse.move(sx, sy);
await page.mouse.down();
await page.mouse.move(t1x, sy, { steps: 5 });
await settle();
// center 460 -> edges 420/500; shoulders measured on r1's OWN edge 40..550:
// 420-40=380 u (4.56 m) and 550-500=50 u (0.60 m) — the old merged build
// showed 960-500=460 u (5.52 m) on the right
{
const [a, b] = await nums();
check('two_badges_visible', (await badges()).length, 2);
check('badge_right_0_60', near(a, 0.6, 0.04));
check('badge_left_4_56', near(b, 4.56, 0.04));
// exact invariant, immune to pointer rounding: shoulders + opening = wall
check('badges_sum_to_wall', near(a + b, 6.12 - 0.96, 0.021));
}
check('no_tick_off_center', await tick(), 0);
// ---------- live update on the way ----------
const [t2x] = await screenPt(360, 140);
await page.mouse.move(t2x, sy, { steps: 3 });
await settle();
// center 360 -> shoulders 320-40=280 u (3.36 m) and 550-400=150 u (1.80 m)
{
const [a, b] = await nums();
check('badge_right_updates_1_80', near(a, 1.8, 0.04));
check('badge_left_updates_3_36', near(b, 3.36, 0.04));
}
// ---------- near the OWN edge center (40+550)/2 = 295: tick + magnet ----------
const [t3x] = await screenPt(293.7, 140);
await page.mouse.move(t3x, sy, { steps: 3 });
await settle();
check('tick_at_center', await tick(), 1);
check('magnet_snaps_center', near(await page.evaluate(() =>
window.__card._curSpaceCfg.openings[0].x), 0.295, 1e-6));
// both shoulders equal after the snap: (510 - 80) / 2 = 215 u -> 2.58 m
check('badges_equal_at_center', await badges(), ['2.58 m', '2.58 m']);
// the tick is perpendicular to the wall (horizontal wall -> a vertical dash)
const geom = await page.evaluate(() => {
const el = window.__card.renderRoot.querySelector('.opcentertick');
if (!el) return null;
const dashed = getComputedStyle(el).strokeDasharray;
return { x1: +el.getAttribute('x1'), x2: +el.getAttribute('x2'),
y1: +el.getAttribute('y1'), y2: +el.getAttribute('y2'),
dashed: dashed !== 'none' && dashed !== '' };
});
check('tick_perpendicular_at_wall_center', !!geom
&& near(geom.x1, 295, 1e-6) && near(geom.x2, 295, 1e-6) && near(geom.y2 - geom.y1, 30, 1e-6));
check('tick_dashed', !!geom && geom.dashed);
// ---------- release: everything disappears, the snapped x is committed ----------
await page.mouse.up();
await settle();
check('badges_gone_after_drop', (await badges()).length, 0);
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 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
const [cx0] = await screenPt(295, 140);
await page.mouse.move(cx0, sy);
await page.mouse.down();
await page.mouse.move(t2x, sy, { steps: 3 });
await page.mouse.up();
await settle();
await page.keyboard.down('Shift');
await page.mouse.down();
await page.mouse.move(t3x, sy, { steps: 3 });
await settle();
check('tick_with_shift', await tick(), 1);
const freeX = await page.evaluate(() => window.__card._curSpaceCfg.openings[0].x);
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');
await settle();
check('badges_gone_final', (await badges()).length, 0);
// ---------- opening on a SHARED wall of two rooms: the own room's edge ----------
// staggered fragments on the boundary x=550: r1's right edge 140..460 (its
// room is made the short one), r2's left edge 140..580. The snap resolves to
// r1's edge (nearest, first in roomEdges order on the tie), so the shoulders
// and the center come from 140..460 — the old build measured the merged
// 140..580 union
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.46], [0.04, 0.46]] },
{ id: 'r2', name: 'Kitchen', poly: [[0.55, 0.14], [0.96, 0.14], [0.96, 0.58], [0.55, 0.58]] },
];
sp.openings = [{ id: 'op1', type: 'door', x: 0.55, y: 0.3, angle: 90, length: 0.08 }];
c._cfgEpoch++; c.requestUpdate();
return c.updateComplete && true;
});
await settle();
const [vx0, vy0] = await screenPt(550, 300);
const [, vy1] = await screenPt(550, 360);
await page.mouse.move(vx0, vy0);
await page.mouse.down();
await page.mouse.move(vx0, vy1, { steps: 3 });
await settle();
// center 360 -> edges 320/400; r1's edge 140..460: 320-140=180 u (2.16 m) and
// 460-400=60 u (0.72 m); the merged build showed 580-400=180 u (2.16 m) below
{
const [a, b] = await nums();
check('shared_wall_two_badges', (await badges()).length, 2);
check('shared_wall_bottom_0_72', near(a, 0.72, 0.04));
check('shared_wall_top_2_16', near(b, 2.16, 0.04));
}
check('shared_wall_no_tick_off_center', await tick(), 0);
await page.mouse.up();
await settle();
// approach the OWN edge center (140+460)/2 = 300 — NOT the merged 360
const [, vy2] = await screenPt(550, 301.3);
await page.mouse.move(vx0, vy1);
await page.mouse.down();
await page.mouse.move(vx0, vy2, { steps: 3 });
await settle();
check('shared_wall_tick_at_own_center', await tick(), 1);
check('shared_wall_magnet_y_0_3', near(await page.evaluate(() =>
window.__card._curSpaceCfg.openings[0].y), 0.3, 1e-6));
// equal shoulders on the own edge: (320 - 80) / 2 = 120 u -> 1.44 m
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);
+146
View File
@@ -0,0 +1,146 @@
// HP-1552: residual jump on first open in NORMAL (non-kiosk) mode. The stage is
// calc(100dvh - _hdrH) and _hdrH is measured from HA's chrome, which loads
// AFTER the card's first paint — so the plan painted, then jumped when the
// panels landed. The fix hides the plan behind a boot veil (pulsing house)
// until the height reads the same twice in a row (or ~600 ms). This smoke
// simulates the late HA panel: a spacer above the card grows 0 → 56 px at
// t=300 ms. Assertions: the veil is shown, and there is NOT A SINGLE frame
// where the plan is visible at a non-final stage height. Kiosk: no veil ever.
import { launch, check, finish } from './serve.mjs';
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');
c.setConfig({ type: 'custom:houseplan-card' });
c.hass = window.__card.hass;
const wrap = document.createElement('div');
wrap.style.cssText = 'position:fixed;left:0;top:0;width:800px;z-index:99;background:#000';
wrap.append(spacer, c);
document.body.appendChild(wrap);
const frames = [];
const sr = () => c.shadowRoot || c.renderRoot;
const t0 = performance.now();
let grown = false;
await new Promise((done) => {
const sample = () => {
const t = performance.now() - t0;
if (!grown && t >= 300) {
grown = true; // the "HA panel" arrives: everything above the card grows
spacer.style.height = '56px';
window.dispatchEvent(new Event('resize'));
}
const stage = sr().querySelector('.stage');
if (stage) {
const veil = sr().querySelector('.bootveil');
const wrapEl = stage.querySelector(':scope > .zoomwrap');
const planHidden =
!wrapEl ||
getComputedStyle(wrapEl).visibility === 'hidden' ||
(veil && !veil.classList.contains('off') && getComputedStyle(veil).opacity !== '0');
frames.push({
t: Math.round(t),
planVisible: !planHidden,
veil: !!veil,
veilBg: veil ? getComputedStyle(veil).backgroundColor : null,
house: !!(veil && veil.querySelector('.boothouse path')),
stageH: stage.clientHeight,
});
} else {
frames.push(null);
}
if (t < 1400) requestAnimationFrame(sample);
else done();
};
requestAnimationFrame(sample);
});
const seen = frames.filter(Boolean);
const finalH = seen[seen.length - 1].stageH;
out.sampledFrames = seen.length > 40;
out.veilShownWhileSettling = seen.some((f) => f.veil && !f.planVisible && f.house);
out.veilIsDark = (() => {
const f = seen.find((x) => x.veilBg);
const m = f && f.veilBg.match(/(\d+),\s*(\d+),\s*(\d+)/);
return !!m && +m[1] + +m[2] + +m[3] < 300; // dark, not white/transparent
})();
// THE assertion: no frame may show the plan at a stale (pre-panel) height
const bad = seen.filter((f) => f.planVisible && Math.abs(f.stageH - finalH) > 2);
out.noVisibleFrameAtStaleHeight = bad.length === 0
? true
: `plan visible at h=${bad[0].stageH} (final ${finalH}) at t=${bad[0].t}ms — the jump is visible`;
const shown = seen.filter((f) => f.planVisible);
out.planShownAfterSettle = shown.length >= 5 && Math.abs(shown[0].stageH - finalH) <= 2;
out.veilGoneInTheEnd = !seen[seen.length - 1].veil;
out.heightReactedToPanel = finalH !== seen[0].stageH; // the simulated panel actually moved the stage
wrap.remove();
return out;
});
for (const [k, v] of Object.entries(res)) check(k, v);
// ---------- phase 2: kiosk never shows the veil ----------
const kiosk = 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.__card.hass;
c.style.cssText = 'position:fixed;left:0;top:0;width:800px;height:700px;z-index:99';
document.body.appendChild(c);
const sr = () => c.shadowRoot || c.renderRoot;
let veilEver = false;
let planHiddenEver = false;
const t0 = performance.now();
await new Promise((done) => {
const sample = () => {
const stage = sr().querySelector('.stage');
if (stage) {
if (sr().querySelector('.bootveil')) veilEver = true;
const wrapEl = stage.querySelector(':scope > .zoomwrap');
if (wrapEl && getComputedStyle(wrapEl).visibility === 'hidden') planHiddenEver = true;
}
if (performance.now() - t0 < 900) requestAnimationFrame(sample);
else done();
};
requestAnimationFrame(sample);
});
out.kioskNoVeil = !veilEver;
out.kioskPlanNeverHidden = !planHiddenEver;
c.remove();
return out;
});
check('kioskNoVeil', kiosk.kioskNoVeil);
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;
c.style.cssText = 'position:fixed;left:0;top:0;width:800px;z-index:99';
document.body.appendChild(c);
const sr = () => c.shadowRoot || c.renderRoot;
const t0 = performance.now();
let anim = 'missed';
await new Promise((done) => {
const tick = () => {
const house = sr().querySelector('.bootveil .boothouse');
if (house) { anim = getComputedStyle(house).animationName; done(); return; }
if (performance.now() - t0 < 500) requestAnimationFrame(tick);
else done();
};
requestAnimationFrame(tick);
});
c.remove();
return { anim };
});
check('reducedMotionStaticHouse', reduced.anim, 'none');
await finish(browser, { ...res, ...kiosk, ...reduced });
+184
View File
@@ -0,0 +1,184 @@
// AUD-1552-01 + AUD-1552-02: adversarial lifecycle regressions for the
// first-open boot veil (HP-1552). Both scenarios FAIL on v1.55.2.
//
// A. disconnect/reconnect while booting (Lovelace rebuilds its DOM, a view
// switch remounts the card). v1.55.2 cleared the boot timer on disconnect
// but kept its truthy id, so the reconnect never restarted the watcher and
// the plan stayed hidden FOREVER. Now the veil lifecycle restarts from
// connectedCallback: the veil must lift no later than the hard cap.
// B. layout shifts before, between and after the old poll ticks (150, 300,
// 450, 590 ms). v1.55.2 revealed the plan after two equal reads at
// 200/400 ms, so a panel landing at 450+ ms jumped on a VISIBLE plan. Now
// the veil holds for the full protective window with trailing quiescence:
// not a single frame may show the plan at a non-final stage height.
// C. a shift AFTER the reveal (slower than the cap) must glide, not snap:
// for a short grace the stage height transitions and the plan refits
// along with it (no transition at all on v1.55.2 — instant jump).
import { launch, check, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 820, height: 760 });
// ---------- A: detach before the first tick, reattach, veil must lift ----------
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;
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); // the first boot tick has not fired yet
out.bootingWhenDetached = c._booting === true;
c.remove(); // disconnect: timers die here
await sleep(30);
wrap.appendChild(c); // reconnect — the veil lifecycle must restart
const t0 = performance.now();
const sr = () => c.shadowRoot || c.renderRoot;
while (c._booting && performance.now() - t0 < 2000) await sleep(50);
out.revealMs = Math.round(performance.now() - t0);
out.bootSettledAfterReconnect = c._booting === false;
out.settledWithinCap = performance.now() - t0 < 1500; // hard cap 1200 + slack
await sleep(300); // let the fade finish
const stage = sr().querySelector('.stage');
const wrapEl = stage && stage.querySelector(':scope > .zoomwrap');
out.planVisibleAfterReconnect = !!wrapEl && getComputedStyle(wrapEl).visibility === 'visible';
out.noHpbootClass = !!stage && !stage.classList.contains('hpboot');
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;
wrap.appendChild(c2);
const t1 = performance.now();
while (c2._booting && performance.now() - t1 < 2000) await sleep(50);
// _bootFading is true for ~220 ms after the reveal — detach right inside it
c2.remove();
await sleep(30);
wrap.appendChild(c2);
await sleep(400);
const sr2 = c2.shadowRoot || c2.renderRoot;
out.fadeVeilGoneAfterReattach = !sr2.querySelector('.bootveil') && c2._bootFading === false;
wrap.remove();
return out;
});
for (const [k, v] of Object.entries(rec)) check(k, v, k === 'revealMs' ? rec.revealMs : true);
// ---------- B: shifts at 150/300/450/590 ms — never a visible stale frame ----------
const shifts = [150, 300, 450, 590];
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');
c.setConfig({ type: 'custom:houseplan-card' });
c.hass = window.__card.hass;
const wrap = document.createElement('div');
wrap.style.cssText = 'position:fixed;left:0;top:0;width:800px;z-index:99;background:#000';
wrap.append(spacer, c);
document.body.appendChild(wrap);
const frames = [];
const sr = () => c.shadowRoot || c.renderRoot;
const t0 = performance.now();
let grown = false;
await new Promise((done) => {
const sample = () => {
const t = performance.now() - t0;
if (!grown && t >= shiftAt) {
grown = true; // the "HA panel" arrives
spacer.style.height = '56px';
window.dispatchEvent(new Event('resize'));
}
const stage = sr().querySelector('.stage');
if (stage) {
const veil = sr().querySelector('.bootveil');
const wrapEl = stage.querySelector(':scope > .zoomwrap');
const planHidden =
!wrapEl ||
getComputedStyle(wrapEl).visibility === 'hidden' ||
(veil && !veil.classList.contains('off') && getComputedStyle(veil).opacity !== '0');
frames.push({ t: Math.round(t), planVisible: !planHidden, stageH: stage.clientHeight });
}
if (t < 1900) requestAnimationFrame(sample);
else done();
};
requestAnimationFrame(sample);
});
const seen = frames;
const finalH = seen[seen.length - 1].stageH;
// THE assertion: no frame may show the plan at a stale (pre-panel) height
const bad = seen.filter((f) => f.planVisible && Math.abs(f.stageH - finalH) > 2);
out.noStaleFrame = bad.length === 0
? true
: `plan visible at h=${bad[0].stageH} (final ${finalH}) at t=${bad[0].t}ms`;
const shown = seen.filter((f) => f.planVisible);
out.planShown = shown.length >= 5 && Math.abs(shown[0].stageH - finalH) <= 2;
out.heightReacted = finalH !== seen[0].stageH;
wrap.remove();
return out;
}, shiftMs);
outB[`shift${shiftMs}_noStaleFrame`] = res.noStaleFrame;
outB[`shift${shiftMs}_planShown`] = res.planShown;
outB[`shift${shiftMs}_heightReacted`] = res.heightReacted;
check(`shift${shiftMs}_noStaleFrame`, res.noStaleFrame);
check(`shift${shiftMs}_planShown`, res.planShown);
check(`shift${shiftMs}_heightReacted`, res.heightReacted);
}
// ---------- C: shift AFTER the reveal glides instead of snapping ----------
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');
c.setConfig({ type: 'custom:houseplan-card' });
c.hass = window.__card.hass;
const wrap = document.createElement('div');
wrap.style.cssText = 'position:fixed;left:0;top:0;width:800px;z-index:99;background:#000';
wrap.append(spacer, c);
document.body.appendChild(wrap);
const sr = () => c.shadowRoot || c.renderRoot;
const t0 = performance.now();
while (c._booting && performance.now() - t0 < 2500) await sleep(25);
out.revealed = c._booting === false;
await sleep(120); // clearly after the reveal, still inside the soft grace
const stage = sr().querySelector('.stage');
const h0 = stage.clientHeight;
spacer.style.height = '56px'; // VERY late panel — slower than the cap
window.dispatchEvent(new Event('resize'));
const heights = [];
await new Promise((done) => {
const t1 = performance.now();
const sample = () => {
heights.push(stage.clientHeight);
if (performance.now() - t1 < 700) requestAnimationFrame(sample);
else done();
};
requestAnimationFrame(sample);
});
const hEnd = heights[heights.length - 1];
out.heightMoved = Math.abs(hEnd - h0) > 30;
// the glide: intermediate frames strictly between the endpoints
const mid = heights.filter((h) => Math.abs(h - h0) > 4 && Math.abs(h - hEnd) > 4);
out.lateShiftGlides = mid.length >= 3
? true
: `only ${mid.length} intermediate frame(s): ${h0} -> ${hEnd} snapped`;
out.settledAtFinal = Math.abs(hEnd - (h0 - 56)) <= 2;
wrap.remove();
return out;
});
check('late_revealed', late.revealed);
check('late_heightMoved', late.heightMoved);
check('late_lateShiftGlides', late.lateShiftGlides);
check('late_settledAtFinal', late.settledAtFinal);
await finish(browser, { ...rec, ...outB, ...late });
+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);
+211
View File
@@ -0,0 +1,211 @@
// The v1.55.0 resize audit (HP-1550-01/-03/-04), numeric regressions:
// 01 — a debounced config write pending from a PREVIOUS edit must never carry
// the live resize preview to the server; Esc leaves server === snapshot;
// a normal pointerup produces exactly one write;
// 03 — pointercancel aborts the drag (no commit, no undo step, no write),
// for wall handles and corner handles alike; lostpointercapture after
// a cancel must not double-fire;
// 04 — a door at the exact midpoint of a wall must not shadow the resize
// handle: the handle wins the hit test, the wall drags WITH the door,
// and no opening-edit drag starts in the resize tool.
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._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;
}, snap);
const enter = (tool) => page.evaluate((t) => {
const c = window.__card;
if (!c._markup) c._setMode('plan');
c._tool = t; c._rszSel = null; c.requestUpdate();
return c.updateComplete && true;
}, tool);
const settle = () => page.evaluate(() => new Promise((res) => requestAnimationFrame(() => requestAnimationFrame(res))));
const screenPt = (x, y) => page.evaluate(([x, y]) => {
const c = window.__card;
const stage = c.renderRoot.querySelector('.stage');
const r = stage.getBoundingClientRect();
const svg = stage.querySelector('svg');
const [vx, vy, vw, vh] = svg.getAttribute('viewBox').split(' ').map(Number);
return [r.left + ((x - vx) / vw) * r.width, r.top + ((y - vy) / vh) * r.height];
}, [x, y]);
// geometry the SERVER would get (the mutable config _writeConfig reads)
const roomSrv = (id) => page.evaluate((id) => {
const r = window.__card._serverCfg.spaces.find((s) => s.id === 'f1').rooms.find((x) => x.id === id);
return r?.poly ? r.poly.map((p) => [p[0], p[1]]) : null;
}, id);
// geometry AS RENDERED (includes the live preview overlay when a drag is on)
const roomLive = (id) => page.evaluate((id) => {
const r = window.__card._curSpaceCfg.rooms.find((x) => x.id === id);
return r?.poly ? r.poly.map((p) => [p[0], p[1]]) : null;
}, id);
// spy on every config write with a deep copy taken AT CALL TIME — exactly what
// the backend would persist (a later fix of the mutable object must not hide it)
await page.evaluate(() => {
const c = window.__card;
window.__writes = [];
const orig = c.hass.callWS.bind(c.hass);
c.hass = { ...c.hass, callWS: async (m) => {
if (m.type === 'houseplan/config/set') window.__writes.push(JSON.parse(JSON.stringify(m.config)));
return orig(m);
} };
return c.updateComplete && true;
});
const writes = () => page.evaluate(() => window.__writes.length);
const writeWallAt = (i, x) => page.evaluate(([i, x]) => {
const w = window.__writes[i];
const r1 = w.spaces.find((s) => s.id === 'f1').rooms.find((r) => r.id === 'r1');
return r1.poly.some((p) => Math.abs(p[0] - x) < 0.011);
}, [i, x]);
// ================= HP-1550-01: pending write vs live preview =================
await enter('resize');
await settle();
// precompute every screen point so drag 2 starts well inside the 500 ms window
const [h550x, h550y] = await screenPt(550, 360);
const [h600x] = await screenPt(600, 360);
const [h650x] = await screenPt(650, 360);
const [h700x] = await screenPt(700, 360);
// drag 1 commits r1's right wall 0.55 → 0.60: a write enters the debounce queue
await page.mouse.move(h550x, h550y);
await page.mouse.down();
await page.mouse.move(h600x, h550y, { steps: 3 });
await page.mouse.up();
// drag 2 starts IMMEDIATELY (pending save still queued): preview 0.60 → 0.70, HOLD
await page.mouse.move(h600x, h550y);
await page.mouse.down();
await page.mouse.move(h700x, h550y, { steps: 3 });
await settle();
check('01_committed_wall', Math.abs((await roomSrv('r1'))[1][0] - 0.60) < 0.011);
check('01_preview_live', Math.abs((await roomLive('r1'))[1][0] - 0.70) < 0.011);
// … the queued write fires while the handle is still held (>500 ms)
await page.waitForTimeout(800);
check('01_pending_write_fired', (await writes()) >= 1);
// Esc abandons the drag
await page.keyboard.press('Escape');
await settle();
await page.mouse.up();
await page.waitForTimeout(800); // any stray debounced write would have fired
const nw = await writes();
for (let i = 0; i < nw; i++) {
check(`01_write_${i}_no_preview_coords`, await writeWallAt(i, 0.70), false);
check(`01_write_${i}_has_committed`, await writeWallAt(i, 0.60), true);
}
check('01_server_geom_is_snapshot', Math.abs((await roomSrv('r1'))[1][0] - 0.60) < 1e-6);
check('01_local_geom_is_snapshot', Math.abs((await roomLive('r1'))[1][0] - 0.60) < 1e-6);
// a normal pointerup still produces EXACTLY ONE write
{
const before = await writes();
await page.mouse.move(h600x, h550y);
await page.mouse.down();
await page.mouse.move(h650x, h550y, { steps: 3 });
await settle();
await page.mouse.up();
await page.waitForTimeout(800);
check('01_single_write_per_commit', (await writes()) - before, 1);
check('01_commit_reached_server', Math.abs((await roomSrv('r1'))[1][0] - 0.65) < 0.011);
}
await restore();
// ================= HP-1550-03: pointercancel = abort, not commit =============
await enter('resize');
await settle();
{
const w0 = await writes();
const [ax, ay] = await screenPt(550, 360);
const [bx] = await screenPt(650, 360);
await page.mouse.move(ax, ay);
await page.mouse.down();
await page.mouse.move(bx, ay, { steps: 3 });
await settle();
check('03_preview_moved', Math.abs((await roomLive('r1'))[1][0] - 0.65) < 0.011);
// the system interrupts the stream (app switch, palm rejection)
await page.evaluate(() => {
const c = window.__card;
const pid = c._rszDrag.pid;
const el = c.renderRoot.querySelector('.rszhandle');
el.dispatchEvent(new PointerEvent('pointercancel', { pointerId: pid, bubbles: true }));
// the lostpointercapture that follows a cancel must not double-fire
el.dispatchEvent(new PointerEvent('lostpointercapture', { pointerId: pid, bubbles: true }));
});
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._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);
await page.mouse.up();
}
await restore();
// the corner (scale) handle takes the same abort path
await enter('resize');
await page.evaluate(() => { const c = window.__card; c._rszSel = 'r4'; c.requestUpdate(); return c.updateComplete && true; });
await settle();
{
const w0 = await writes();
const [ax, ay] = await screenPt(550, 860);
const [bx, by] = await screenPt(448, 804); // ≈ k=0.8 about (40,580)
await page.mouse.move(ax, ay);
await page.mouse.down();
await page.mouse.move(bx, by, { steps: 3 });
await settle();
check('03_corner_preview', Math.abs((await roomLive('r4'))[2][0] - 0.55) > 0.02);
await page.evaluate(() => {
const c = window.__card;
const pid = c._rszDrag.pid;
const el = c.renderRoot.querySelector('.rszcorner') || c.renderRoot.querySelector('.rszhandle');
el.dispatchEvent(new PointerEvent('pointercancel', { pointerId: pid, bubbles: true }));
});
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._geometryHistory.size), 0);
await page.waitForTimeout(800);
check('03_corner_no_write', (await writes()) - w0, 0);
await page.mouse.up();
}
await restore();
// ============ HP-1550-04: a door mid-wall must not shadow the handle =========
await enter('resize');
await page.evaluate(() => {
const c = window.__card;
const sp = c._serverCfg.spaces.find((s) => s.id === 'f1');
// exactly at the midpoint of r1's right wall (y 0.14..0.58 → 0.36)
sp.openings = [{ id: 'opm', type: 'door', x: 0.55, y: 0.36, angle: 90, length: 0.08 }];
c._cfgEpoch++; c.requestUpdate();
return c.updateComplete && true;
});
await settle();
{
const [hx, hy] = await screenPt(550, 360);
const [tx] = await screenPt(650, 360);
// the REAL hit test: the topmost interactive element must be the handle
const top = await page.evaluate(([x, y]) => {
const el = window.__card.shadowRoot.elementFromPoint(x, y);
return el ? el.getAttribute('class') || '' : '';
}, [hx, hy]);
check('04_handle_wins_hit_test', String(top).includes('rszhandle'));
await page.mouse.move(hx, hy);
await page.mouse.down();
await page.mouse.move(tx, hy, { steps: 4 });
await settle();
check('04_no_opening_drag', await page.evaluate(() => !window.__card._opDrag));
check('04_wall_drag_started', await page.evaluate(() => !!window.__card._rszDrag));
await page.mouse.up();
await settle();
check('04_wall_moved', Math.abs((await roomSrv('r1'))[1][0] - 0.65) < 0.011);
const op = await page.evaluate(() => window.__card._serverCfg.spaces.find((s) => s.id === 'f1').openings[0]);
check('04_door_travelled_with_wall', Math.abs(op.x - 0.65) < 0.011);
}
await restore();
await finish(browser, { done: true });
+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' });
+250
View File
@@ -0,0 +1,250 @@
// Room resize tool (docs/RESIZE.md): wall drag with shared walls (T-junction),
// live badges, stops (neighbour min size, opening anchor), the corner scale
// frame, Esc-cancel and one-step undo. Vertex positions are checked NUMERICALLY.
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._rszSel = null; c._rszDrag = null; c._rszLive = null; c._rszPreview = null;
c._geometryHistory.clear();
c._cfgEpoch++; c.requestUpdate();
return c.updateComplete && true;
}, snap);
const enter = (tool) => page.evaluate((t) => {
const c = window.__card;
if (!c._markup) c._setMode('plan');
c._tool = t; c._rszSel = null; c.requestUpdate();
return c.updateComplete && true;
}, tool);
const handleCount = () => page.evaluate(() => window.__card.renderRoot.querySelectorAll('.rszhandle').length);
const roomPolyN = (id) => page.evaluate((id) => {
const r = window.__card._serverCfg.spaces.find((s) => s.id === 'f1').rooms.find((x) => x.id === id);
return r?.poly ? r.poly.map((p) => [p[0], p[1]]) : null;
}, id);
// mid-drag geometry AS RENDERED: HP-1550-01 moved the live preview out of
// _serverCfg into the _rszPreview overlay (_curSpaceCfg serves it to renders)
const roomPolyLive = (id) => page.evaluate((id) => {
const r = window.__card._curSpaceCfg.rooms.find((x) => x.id === id);
return r?.poly ? r.poly.map((p) => [p[0], p[1]]) : null;
}, id);
const openingOf = (id) => page.evaluate((id) =>
(window.__card._serverCfg.spaces.find((s) => s.id === 'f1').openings || []).find((o) => o.id === id) || null, id);
// screen position of a render-unit point (the svg viewBox fills the stage exactly)
const screenPt = (x, y) => page.evaluate(([x, y]) => {
const c = window.__card;
const stage = c.renderRoot.querySelector('.stage');
const r = stage.getBoundingClientRect();
const svg = stage.querySelector('svg');
const [vx, vy, vw, vh] = svg.getAttribute('viewBox').split(' ').map(Number);
return [r.left + ((x - vx) / vw) * r.width, r.top + ((y - vy) / vh) * r.height];
}, [x, y]);
const settle = () => page.evaluate(() => new Promise((res) => requestAnimationFrame(() => requestAnimationFrame(res))));
const labels = () => page.evaluate(() =>
[...window.__card.renderRoot.querySelectorAll('.measurelabel')].map((el) => el.textContent.trim()));
const polyClose = (name, got, want, tol = 1e-4) => {
check(name + '.len', got?.length, want.length);
if (got?.length !== want.length) return;
for (let i = 0; i < want.length; i++) {
check(`${name}[${i}]`, Math.hypot(got[i][0] - want[i][0], got[i][1] - want[i][1]) <= tol, true);
}
};
// ---------- no handles outside the resize tool ----------
await enter('draw');
check('draw_tool_no_handles', await handleCount(), 0);
await enter('opening');
check('opening_tool_no_handles', await handleCount(), 0);
// ---------- handles appear in the resize tool ----------
await enter('resize');
await settle();
check('resize_handles_16', await handleCount(), 16); // 4 rooms × 4 walls
// ---------- wall drag: T-stack r1|r2+r3, opening rides along, live badges ----------
// opening op2 sits ON the shared wall x=0.55 → it must travel with it
await page.evaluate(() => {
const c = window.__card;
const sp = c._serverCfg.spaces.find((s) => s.id === 'f1');
sp.openings = [{ id: 'op2', type: 'door', x: 0.55, y: 0.30, angle: 90, length: 0.08 }];
c._cfgEpoch++; c.requestUpdate();
});
await settle();
const [hx, hy] = await screenPt(550, 360); // r1 right-wall handle (mid y of 140..580)
const [tx] = await screenPt(650, 360); // +100 render units
await page.mouse.move(hx, hy);
await page.mouse.down();
await page.mouse.move((hx + tx) / 2, hy, { steps: 4 });
await settle();
const mid = await labels();
check('drag_badges_visible', mid.length >= 6); // 3 wall lengths + 3 areas (r1, r2, r3)
check('drag_badges_have_area', mid.filter((t) => t.includes('m²')).length >= 3);
check('drag_badges_have_len', mid.some((t) => /\d\.\d\d m$/.test(t)));
const midR1 = await roomPolyLive('r1');
check('preview_moves_r1', Math.abs(midR1[1][0] - 0.6) < 0.011); // halfway ≈ 0.60 (snapped)
// HP-1550-01: the preview must NOT leak into the config a queued write reads
const midSrv = await roomPolyN('r1');
check('preview_not_in_servercfg', Math.abs(midSrv[1][0] - 0.55) < 1e-9);
await page.mouse.move(tx, hy, { steps: 4 });
await settle();
const end = await labels();
check('drag_badges_change', JSON.stringify(mid) !== JSON.stringify(end));
check('area_updates_live', mid.find((t) => t.includes('m²')) !== end.find((t) => t.includes('m²')));
await page.mouse.up();
await settle();
// committed geometry: r1 grew, r2 translated, r3 became L-shaped with 6 vertices
polyClose('r1_after', await roomPolyN('r1'), [[0.04, 0.14], [0.65, 0.14], [0.65, 0.58], [0.04, 0.58]]);
polyClose('r2_after', await roomPolyN('r2'), [[0.65, 0.14], [0.96, 0.14], [0.96, 0.46], [0.65, 0.46]]);
polyClose('r3_after', await roomPolyN('r3'),
[[0.65, 0.46], [0.96, 0.46], [0.96, 0.86], [0.55, 0.86], [0.55, 0.58], [0.65, 0.58]]);
const op2 = await openingOf('op2');
check('opening_travels', Math.abs(op2.x - 0.65) < 1e-4 && Math.abs(op2.y - 0.30) < 1e-6);
// ---------- undo: one release = one step back ----------
await page.keyboard.press('Control+z');
await settle();
polyClose('undo_r1', await roomPolyN('r1'), [[0.04, 0.14], [0.55, 0.14], [0.55, 0.58], [0.04, 0.58]]);
polyClose('undo_r2', await roomPolyN('r2'), [[0.55, 0.14], [0.96, 0.14], [0.96, 0.46], [0.55, 0.46]]);
polyClose('undo_r3', await roomPolyN('r3'), [[0.55, 0.46], [0.96, 0.46], [0.96, 0.86], [0.55, 0.86]]);
const op2b = await openingOf('op2');
check('undo_opening_back', Math.abs(op2b.x - 0.55) < 1e-9);
await restore();
// ---------- stop: the shrinking neighbour keeps ~30 cm ----------
await enter('resize');
await settle();
{
const [ax, ay] = await screenPt(550, 360);
const [bx] = await screenPt(1050, 360); // far past r2/r3 (right walls at 0.96)
await page.mouse.move(ax, ay);
await page.mouse.down();
await page.mouse.move(bx, ay, { steps: 6 });
await settle();
await page.mouse.up();
await settle();
const r1 = await roomPolyN('r1');
const r2 = await roomPolyN('r2');
const width2 = (r2[1][0] - r2[0][0]) * 1000;
check('neighbour_min_stop', Math.abs(r1[1][0] - 0.93333) < 0.005); // clamped, NOT 1.05
check('neighbour_min_width', width2 >= 25 - 1e-6 && width2 <= 25 + 1000 / 240 + 1e-6);
}
await restore();
// ---------- stop: an opening anchors the shortening wall ----------
await enter('resize');
await settle();
{
// a door on r1's LEFT wall (x=0.04), spans y 0.16..0.24. Dragging r1's TOP
// wall down shortens that wall from above — it must stop at the door edge.
// (No collinear neighbour wall can adopt this door — a real «упор».)
await page.evaluate(() => {
const c = window.__card;
const sp = c._serverCfg.spaces.find((s) => s.id === 'f1');
sp.openings = [{ id: 'op1', type: 'door', x: 0.04, y: 0.20, angle: 90, length: 0.08 }];
c._cfgEpoch++; c.requestUpdate();
});
await settle();
const [ax, ay] = await screenPt(295, 140); // r1 top-wall handle
const [, by] = await screenPt(295, 300); // try to push far past the door top (y=160)
await page.mouse.move(ax, ay);
await page.mouse.down();
await page.mouse.move(ax, by, { steps: 5 });
await settle();
await page.mouse.up();
await settle();
const r1 = await roomPolyN('r1');
const topY = r1[0][1] * 1000;
check('opening_stop', topY <= 160 + 1e-6); // never below the door top
check('opening_stop_close', topY >= 160 - 1000 / 240 - 1e-6); // and clamped right at it
const op1 = await openingOf('op1');
check('anchored_opening_static', Math.abs(op1.y - 0.2) < 1e-9 && Math.abs(op1.x - 0.04) < 1e-9);
}
await restore();
// ---------- the scale frame: proportional, neighbours stay, stops work ----------
await enter('resize');
await page.evaluate(() => { const c = window.__card; c._rszSel = 'r4'; c.requestUpdate(); return c.updateComplete && true; });
await settle();
check('frame_corners', await page.evaluate(() => window.__card.renderRoot.querySelectorAll('.rszcorner').length), 4);
check('frame_present', await page.evaluate(() => !!window.__card.renderRoot.querySelector('.rszframe')));
{
// shrink about the top-left corner (40,580): drag br (550,860) toward it
const [ax, ay] = await screenPt(550, 860);
const [bx, by] = await screenPt(448, 804); // ≈ k=0.8
await page.mouse.move(ax, ay);
await page.mouse.down();
await page.mouse.move(bx, by, { steps: 5 });
await settle();
await page.mouse.up();
await settle();
const r4 = await roomPolyN('r4');
const base = [[0.04, 0.58], [0.55, 0.58], [0.55, 0.86], [0.04, 0.86]];
// ALL vertices scale by the SAME factor about the fixed corner (0.04, 0.58)
const ks = base.map((b, i) => {
const dx = b[0] - 0.04, dy = b[1] - 0.58;
if (Math.abs(dx) > 1e-9) return (r4[i][0] - 0.04) / dx;
if (Math.abs(dy) > 1e-9) return (r4[i][1] - 0.58) / dy;
return null;
}).filter((k) => k != null);
const kAvg = ks.reduce((a, b) => a + b, 0) / ks.length;
check('scale_shrinks', kAvg > 0.7 && kAvg < 0.9);
check('scale_proportional', ks.every((k) => Math.abs(k - kAvg) < 1e-3));
const ky = base.map((b, i) => (Math.abs(b[1] - 0.58) > 1e-9 ? (r4[i][1] - 0.58) / (b[1] - 0.58) : null)).filter((k) => k != null);
check('scale_uniform_xy', ky.every((k) => Math.abs(k - kAvg) < 1e-3));
// neighbours did NOT move (scale never drags them — docs/RESIZE.md exception)
polyClose('scale_r1_static', await roomPolyN('r1'), [[0.04, 0.14], [0.55, 0.14], [0.55, 0.58], [0.04, 0.58]]);
polyClose('scale_r3_static', await roomPolyN('r3'), [[0.55, 0.46], [0.96, 0.46], [0.96, 0.86], [0.55, 0.86]]);
}
await restore();
await enter('resize');
await page.evaluate(() => { const c = window.__card; c._rszSel = 'r4'; c.requestUpdate(); return c.updateComplete && true; });
await settle();
{
// growing about the top-left corner immediately runs into r3 → the scale stops at ≈1
const [ax, ay] = await screenPt(550, 860);
const [bx, by] = await screenPt(805, 1000); // ≈ k=1.5
await page.mouse.move(ax, ay);
await page.mouse.down();
await page.mouse.move(bx, by, { steps: 5 });
await settle();
await page.mouse.up();
await settle();
const r4 = await roomPolyN('r4');
check('scale_neighbour_stop', Math.abs(r4[1][0] - 0.55) < 0.005 && Math.abs(r4[2][1] - 0.86) < 0.005);
polyClose('scale_stop_r3_static', await roomPolyN('r3'), [[0.55, 0.46], [0.96, 0.46], [0.96, 0.86], [0.55, 0.86]]);
}
await restore();
// ---------- Esc cancels the current drag ----------
await enter('resize');
await settle();
{
const [ax, ay] = await screenPt(550, 360);
const [bx] = await screenPt(700, 360);
await page.mouse.move(ax, ay);
await page.mouse.down();
await page.mouse.move(bx, ay, { steps: 4 });
await settle();
const midPoly = await roomPolyLive('r1');
check('esc_preview_moved', Math.abs(midPoly[1][0] - 0.70) < 0.011);
await page.keyboard.press('Escape');
await settle();
polyClose('esc_restores_r1', await roomPolyN('r1'), [[0.04, 0.14], [0.55, 0.14], [0.55, 0.58], [0.04, 0.58]]);
polyClose('esc_restores_r2', await roomPolyN('r2'), [[0.55, 0.14], [0.96, 0.14], [0.96, 0.46], [0.55, 0.46]]);
check('esc_no_badges', (await labels()).length, 0);
await page.mouse.up();
}
await restore();
// ---------- leaving the tool removes every handle ----------
await enter('merge');
check('merge_tool_no_handles', await handleCount(), 0);
await page.evaluate(() => { window.__card._setMode('devices'); });
await settle();
check('devices_mode_no_handles', await handleCount(), 0);
await finish(browser, { done: true });
+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));
+5 -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)
@@ -56,6 +57,8 @@ checkAll(res, {
"livingStyle": "--room-stroke:#ff8800;--room-stroke-op:0.8;--room-fill:#ffd45c;--room-fill-op:0.180",
"lqiFills": 0,
"atticSquare": true,
"atticSettings": {"show_borders": true, "show_names": true, "room_color": "#3ea6ff", "room_opacity": 0.55, "fill_mode": "none", "temp_min": 20, "temp_max": 25, "show_lqi": true, "label_temp": false, "label_hum": false, "label_lqi": false, "label_light": false},
// 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": "#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 });
+346
View File
@@ -0,0 +1,346 @@
// Sun on the plan (docs/SUN.md): compass gating, window wedges on all four
// walls, direction follows north_deg, night is empty, wedges clip to rooms,
// day/night background vs static, per-space inheritance, cloud fading,
// memoisation across hass ticks, editors stay clean.
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 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 setWeather = async (state) => {
c.hass = { ...c.hass, states: { ...c.hass.states, 'weather.home': {
entity_id: 'weather.home', state, attributes: {},
} } };
await upd();
};
const cfg = () => c._serverCfg;
// DEV-B701-01: local mutations bump _cfgEpoch (what _saveConfig() does
// synchronously). The old helper bumped _cfgRev — a server ack that the
// production save path does NOT produce until the debounced write lands —
// and thereby masked the stale sun-ray cache.
const touchCfg = () => { c._cfgEpoch++; };
const litIds = () => (c._sunRaysCache?.rays || []).map((r) => r.openingId).sort();
const domPolys = () => [...sr().querySelectorAll('.sunlayer polygon')];
const stageStyle = () => sr().querySelector('.stage').getAttribute('style') || '';
// ---- the test plan: windows on all four OUTER walls + one interior ----
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 }, // top of r1
{ id: 'wE', type: 'window', x: 0.96, y: 0.60, angle: 90, length: 0.08 }, // right of r3
{ id: 'wS', type: 'window', x: 0.30, y: 0.86, angle: 0, length: 0.08 }, // bottom of r4
{ id: 'wW', type: 'window', x: 0.04, y: 0.30, angle: 90, length: 0.08 }, // left of r1
{ id: 'wI', type: 'window', x: 0.55, y: 0.30, angle: 90, length: 0.08 }, // r1|r2 wall
{ id: 'dS', type: 'door', x: 0.20, y: 0.86, angle: 0, length: 0.1 }, // doors never glow
];
touchCfg();
// 1) sun present + rays enabled, but NO north_deg anywhere → dead silence
cfg().settings = { ...(cfg().settings || {}), sun_rays: true };
touchCfg();
await setSun(90, 5);
out.silentWithoutNorth = domPolys().length === 0;
out.noDaynightWithoutNorth = !sr().querySelector('.stage.daynight');
// 2) compass set → morning east sun lights ONLY the east window
cfg().settings.north_deg = 0;
touchCfg();
await setSun(90, 5);
out.morningEast = JSON.stringify(litIds()) === '["wE"]';
out.morningDomDrawn = domPolys().length > 0;
// 3) noon south → only the south window, wedge shorter than the morning one
const eastLen = c._sunRaysCache?.rays?.[0]?.len ?? 0;
await setSun(180, 60);
out.noonSouth = JSON.stringify(litIds()) === '["wS"]';
out.noonShorter = (c._sunRaysCache?.rays?.[0]?.len ?? 1e9) < eastLen;
// 4) evening west → only the west window; wedge stays inside its room r1
await setSun(270, 4);
out.eveningWest = JSON.stringify(litIds()) === '["wW"]';
const ray = c._sunRaysCache?.rays?.[0] || { polys: [] };
out.wedgeClippedToRoom = ray.polys.length > 0 && ray.polys.every((poly) => poly.every(([x, y]) =>
x >= 40 - 0.5 && x <= 550 + 0.5 && y >= 140 - 0.5 && y <= 580 + 0.5));
// 5) night → nothing
await setSun(90, -10);
out.nightEmpty = domPolys().length === 0;
// 6) rotated compass: the same morning east sun now lights the NORTH window
cfg().settings.north_deg = 90;
touchCfg();
await setSun(90, 5);
out.rotatedCompass = JSON.stringify(litIds()) === '["wN"]';
cfg().settings.north_deg = 0;
touchCfg();
// 7) memoisation: an unrelated hass tick must NOT recompute the wedges
await setSun(90, 5);
const cacheBefore = c._sunRaysCache;
c.hass = { ...c.hass, states: { ...c.hass.states } }; // plain tick, sun unchanged
await upd();
out.memoSurvivesTick = c._sunRaysCache === cacheBefore;
await setSun(91, 5);
out.memoFollowsSun = c._sunRaysCache !== cacheBefore;
// 8) day/night background: daynight breathes, static does not
await setSun(180, 60);
out.staticBgAtNoon = !stageStyle().includes('background:#ffffff');
cfg().settings.bg_mode = 'daynight';
touchCfg();
await setSun(180, 60);
out.daynightNoonBg = stageStyle().includes('background:#ffffff');
out.daynightClass = !!sr().querySelector('.stage.daynight');
await setSun(180, -20);
out.daynightNightBg = stageStyle().includes('background:#070c14');
out.nightPlanDimmed = (sr().querySelector('.zoomwrap').getAttribute('style') || '').includes('brightness(0.900');
cfg().settings.bg_mode = 'static';
touchCfg();
await setSun(180, -20);
out.staticNightKeepsTheme = !stageStyle().includes('background:#070c14');
delete cfg().settings.bg_mode;
touchCfg();
// 9) per-space inheritance: the space override wins over the global flag
sp.settings = { ...(sp.settings || {}), sun_rays: false };
touchCfg();
await setSun(90, 5);
out.spaceOverridesOff = domPolys().length === 0;
delete sp.settings.sun_rays;
// and a per-space compass override redirects the light
sp.settings.north_deg = 90;
touchCfg();
await setSun(90, 5);
out.spaceNorthOverride = JSON.stringify(litIds()) === '["wN"]';
delete sp.settings.north_deg;
touchCfg();
// 10) clouds: rain kills the wedges, cloudy only fades them
cfg().settings.weather_entity = 'weather.home';
touchCfg();
await setWeather('pouring');
await setSun(90, 5);
out.rainKillsRays = domPolys().length === 0;
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.3 * 0.4) < 1e-6;
await setWeather('sunny');
const stopClear = Number(sr().querySelector('stop')?.getAttribute('stop-opacity'));
out.clearFullAlpha = Math.abs(stopClear - 0.3) < 1e-6;
delete cfg().settings.weather_entity;
touchCfg();
// 11) feature off: without the flag nothing is drawn even with sun+north
delete cfg().settings.sun_rays;
touchCfg();
await setSun(90, 5);
out.offDrawsNothing = domPolys().length === 0;
cfg().settings.sun_rays = true;
cfg().settings.bg_mode = 'daynight';
touchCfg();
// 12) editors stay clean: no wedges, no day/night canvas
await setSun(90, 5);
c._setMode('plan');
await upd();
out.editorNoRays = domPolys().length === 0;
out.editorNoDaynight = !sr().querySelector('.stage.daynight');
c._setMode('view');
await upd();
out.viewRaysBack = domPolys().length > 0;
return out;
});
// 13) the general-settings dialog: a REAL drag on the compass dial sets degrees
await page.evaluate(async () => {
const c = window.__card;
c._openSettingsDialog();
c.requestUpdate();
await c.updateComplete;
c.shadowRoot.querySelector('.compass').scrollIntoView({ block: 'center' });
});
const box = await page.evaluate(() => {
const r = window.__card.shadowRoot.querySelector('.compass').getBoundingClientRect();
return { cx: r.left + r.width / 2, cy: r.top + r.height / 2 };
});
await page.mouse.move(box.cx + 45, box.cy); // on the ring, due right = 90°
await page.mouse.down();
await page.mouse.move(box.cx, box.cy + 45); // drag on to due down = 180°
await page.mouse.up();
const dlg = await page.evaluate(() => {
const c = window.__card;
const sr = c.shadowRoot;
const out = {
compassDragSets180: c._settingsDialog?.northDeg === 180,
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',
};
c._settingsDialog = null;
return out;
});
Object.assign(res, dlg);
// 14) DEV-B701-01 regression: the PRODUCTION local-save path (no touchCfg, no
// fake rev/epoch bump from the test) must move the wedge. A REAL pointer drag
// of the east window ends in _opPointerUp -> _saveConfig(); the WS write is
// debounced 500 ms, so at check time _cfgRev is still the boot revision — the
// ray must follow the window anyway, and again after the late server ack.
const settle = () => page.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))));
const screenPt = (x, y) => page.evaluate(([x, y]) => {
const c = window.__card;
const stage = c.renderRoot.querySelector('.stage');
const r = stage.getBoundingClientRect();
const svgEl = stage.querySelector('svg');
const [vx, vy, vw, vh] = svgEl.getAttribute('viewBox').split(' ').map(Number);
return [r.left + ((x - vx) / vw) * r.width, r.top + ((y - vy) / vh) * r.height];
}, [x, y]);
const rayInfo = () => page.evaluate(() => {
const c = window.__card;
const rays = c._sunRaysCache?.rays || [];
const r = rays.find((q) => q.openingId === 'wE');
return {
ids: rays.map((q) => q.openingId).sort(),
midY: r ? (r.a[1] + r.b[1]) / 2 : NaN,
minX: r ? Math.min(...r.polys.flat().map((p) => p[0])) : NaN,
rev: c._cfgRev,
wEy: c._serverCfg.spaces.find((s) => s.id === 'f1').openings.find((o) => o.id === 'wE').y,
};
});
// baseline: morning east sun, only wE glows, window sits at y = 600
await page.evaluate(async () => {
const c = window.__card;
c.hass = { ...c.hass, states: { ...c.hass.states, 'sun.sun': {
entity_id: 'sun.sun', state: 'above_horizon', attributes: { azimuth: 90, elevation: 5 },
} } };
c.requestUpdate(); await c.updateComplete;
});
const base = await rayInfo();
check('b701_baseline_east_only', base.ids, ['wE']);
check('b701_baseline_mid_600', Math.abs(base.midY - 600) < 1, true);
// the real drag: enter the opening editor and pull wE down the east wall
await page.evaluate(async () => {
const c = window.__card;
c._setMode('plan'); c._tool = 'opening';
c.requestUpdate(); await c.updateComplete;
});
await settle();
const [dx, dy] = await screenPt(960, 600);
const [, dty] = await screenPt(960, 700);
await page.mouse.move(dx, dy);
await page.mouse.down();
await page.mouse.move(dx, dty, { steps: 6 });
await page.mouse.up();
await settle();
// leave the editor IMMEDIATELY — well inside the 500 ms debounce window
const after = await page.evaluate(async () => {
const c = window.__card;
c._setMode('view');
c.requestUpdate(); await c.updateComplete;
return true;
});
check('b701_left_editor', after);
const moved = await rayInfo();
check('b701_window_really_moved', Math.abs(moved.wEy * 1000 - 700) < 15, true);
check('b701_rev_still_boot', moved.rev, base.rev); // the write has NOT landed yet
check('b701_ray_follows_window', Math.abs(moved.midY - moved.wEy * 1000) < 1, true);
check('b701_ray_not_stale', Math.abs(moved.midY - 600) > 50, true);
// same defect class for ROOM geometry: r3 shrinks to a thin strip along its
// east wall through the real mutation endpoint (_saveConfig, still no ack).
// The wedge reaches ~188 units into the room (minX ~772), so the new west
// boundary at x = 900 MUST cut it — a stale clip keeps points near x = 772.
const clipped = await page.evaluate(async () => {
const c = window.__card;
const sp = c._serverCfg.spaces.find((s) => s.id === 'f1');
sp.rooms.find((r) => r.id === 'r3').poly = [[0.90, 0.46], [0.96, 0.46], [0.96, 0.86], [0.90, 0.86]];
c._saveConfig(); // the one true local-save entry point
c.requestUpdate(); await c.updateComplete;
return true;
});
check('b701_room_mutated', clipped);
const reclipped = await rayInfo();
check('b701_room_rev_still_boot', reclipped.rev, base.rev);
check('b701_room_reclips_ray', reclipped.minX >= 900 - 1, true);
// let the debounced write land: the ray must survive the late server ack
await page.waitForTimeout(800);
const acked = await page.evaluate(async () => {
const c = window.__card;
c.requestUpdate(); await c.updateComplete;
return true;
});
check('b701_acked', acked);
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));
+2 -2
View File
@@ -10,9 +10,9 @@ 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 === 3;
out.threeOptions = sel && sel.options.length === 4; // + «Запустить…» (2026-07-29)
out.noAutoOption = sel && ![...sel.options].some((o) => o.value === '');
out.defaultInfo = sel && sel.value === 'info';
c._markerDialog = null; await c.updateComplete;
+84
View File
@@ -0,0 +1,84 @@
// ТЗ 2026-07-29: действие по нажатию «Запустить автоматизацию/скрипт/сцену»
// с пикером и поиском + чекбокс «Спрашивать подтверждение» для toggle/run.
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 = [];
c.hass = { ...c.hass,
states: { ...c.hass.states,
'automation.evening': { state: 'on', attributes: { friendly_name: 'Вечерний свет' } },
'script.curtains': { state: 'off', attributes: { friendly_name: 'Шторы' } },
'scene.movie': { state: '', attributes: { friendly_name: 'Кино' } } },
callService: async (dom, svc, data) => { calls.push([dom, svc, data]); return {}; } };
// --- диалог: выбор run + пикер с поиском --------------------------------
const d = c._devices.find((x) => x.space === 'f1' && x.bindingKind === 'device');
c._setMode('devices'); await c.updateComplete;
c._openMarkerDialog(d); await c.updateComplete;
c._markerDialog = { ...c._markerDialog, tapAction: 'run' }; await c.updateComplete;
// ВИДИМОСТЬ, не просто наличие в DOM: список — flex-item со скроллом и
// однажды схлопнулся в полоску 1px при 26 кандидатах внутри (репорт
// пользователя 2026-07-30, поймано только замером высоты)
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('hp-dialog .cand')].map((x) => x.textContent);
o.searchWorks = cands.length >= 1 && cands.some((t) => t.includes('Шторы'));
// и найденная строка реально нарисована, а не сплющена
const row = sr().querySelector('hp-dialog .cand');
o.resultRowVisible = !!row && row.getBoundingClientRect().height > 10
&& row.getBoundingClientRect().bottom <= listEl().getBoundingClientRect().bottom + 1;
// сохранение без цели блокируется
await c._saveMarker(); await c.updateComplete;
o.saveBlockedWithoutTarget = !!c._markerDialog;
// выбираем цель + подтверждение, сохраняем
c._markerDialog = { ...c._markerDialog, tapTarget: 'script.curtains', tapConfirm: true };
await c.updateComplete;
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;
// --- тап: подтверждение → отмена → вызова нет ---------------------------
c._setMode('view'); c._regSignature = ''; c._maybeRebuildDevices();
c.requestUpdate(); await c.updateComplete;
const dev = c._devices.find((x) => x.id === (saved?.id ?? d.id)) || c._devices.find((x) => x.bindingRef === d.bindingRef);
c._clickDevice({ stopPropagation() {} }, dev); await c.updateComplete;
o.confirmDialogShown = !!c._tapConfirm && c._tapConfirm.text.includes('Шторы');
c._tapConfirm = null; await c.updateComplete;
o.cancelNoCall = calls.length === 0;
// --- тап: подтвердили → script.turn_on ----------------------------------
c._clickDevice({ stopPropagation() {} }, dev); await c.updateComplete;
const conf = c._tapConfirm; c._tapConfirm = null; conf.exec();
await new Promise((r) => setTimeout(r, 10));
o.runCalled = calls.length === 1 && calls[0][0] === 'script' && calls[0][1] === 'turn_on'
&& calls[0][2].entity_id === 'script.curtains';
// --- без подтверждения: сразу вызов; автоматизация → trigger ------------
c._serverCfg.markers = c._serverCfg.markers.map((m) =>
m.id === (saved?.id ?? d.id) ? { ...m, tap_target: 'automation.evening', tap_confirm: null } : m);
c._cfgEpoch++; c._regSignature = ''; c._maybeRebuildDevices(); await c.updateComplete;
const dev2 = c._devices.find((x) => x.bindingRef === d.bindingRef);
calls.length = 0;
c._clickDevice({ stopPropagation() {} }, dev2); await c.updateComplete;
await new Promise((r) => setTimeout(r, 10));
o.automationTriggered = calls.length === 1 && calls[0][0] === 'automation' && calls[0][1] === 'trigger';
o.noConfirmWhenOff = !c._tapConfirm;
// --- цель удалена → тост, вызова нет ------------------------------------
const states2 = { ...c.hass.states }; delete states2['automation.evening'];
c.hass = { ...c.hass, states: states2 };
calls.length = 0;
c._clickDevice({ stopPropagation() {} }, dev2); await c.updateComplete;
o.missingTargetSafe = calls.length === 0 && !!c._toast;
// очистка
c._serverCfg.markers = c._serverCfg.markers.filter((m) => m.id !== (saved?.id ?? d.id));
c._cfgEpoch++; c._regSignature = ''; c._maybeRebuildDevices();
return o;
});
await finish(browser, checkAll(out));
+2 -2
View File
@@ -32,7 +32,7 @@ checkAll(res, {
"cold": ["#4fc3f7", "transparent", "transparent", "transparent"],
"hot": ["#ffd45c", "transparent", "transparent", "transparent"],
"swapped": ["#66d17a", "transparent", "transparent", "transparent"],
"dialogTempFields": 3,
"dialogHiddenWhenNone": 1,
"dialogTempFields": 4, // масштаб + мин/макс + компас пространства (docs/SUN.md)
"dialogHiddenWhenNone": 2, // мин/макс скрыты; масштаб и компас остаются
});
await finish(browser, res);
+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);
+231
View File
@@ -0,0 +1,231 @@
// Live vacuum P1 (docs/VACUUM.md): puck, trail, calibration, hidden rules.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const out = await page.evaluate(async () => {
const c = window.__card;
const o = {};
const sr = () => c.shadowRoot || c.renderRoot;
// a vacuum device bound to space f1, dock placed by layout, plus a source
// camera whose coords live in "robot mm": target = 0.02*x+? — we calibrate
// via the 6-number matrix directly (0.5 scale: robot 0..2000 -> canvas 0..1000)
const M = [0.5, 0, 0, 0, 0.5, 0];
const mkAttrs = (x, y, extra) => ({ vacuum_position: { x, y, a: 45 }, map_name: 'm1', ...extra });
c.hass = { ...c.hass, states: { ...c.hass.states,
'vacuum.robo': { state: 'docked', attributes: { friendly_name: 'Робот' } },
'camera.robo_map': { state: 'idle', attributes: mkAttrs(600, 800) },
} };
const cfg = c._serverCfg;
cfg.markers = cfg.markers || [];
cfg.markers.push({ id: 'e_vacuum_robo', binding: 'entity:vacuum.robo', space: 'f1',
vacuum: { source: 'camera.robo_map', calibration: { m1: M } } });
c._layout['e_vacuum_robo'] = { s: 'f1', x: 0.1, y: 0.1 };
c._regSignature = ''; c._setMode('view'); await c.updateComplete;
o.dockedNoPuck = !sr().querySelector('.vacpuck');
o.baseMarkerThere = !!sr().querySelector('.dev');
// start cleaning -> puck appears at transformed coords, base marker stays
c.hass = { ...c.hass, states: { ...c.hass.states,
'vacuum.robo': { state: 'cleaning', attributes: { friendly_name: 'Робот' } } } };
await c.updateComplete; await new Promise((r) => setTimeout(r, 50));
let puck = sr().querySelector('.vacpuck');
o.puckAppears = !!puck;
o.puckRound = puck && getComputedStyle(puck).borderRadius === '50%';
// owner 2026-07-31: the base badge, but round and 20% smaller — same plate
// vs a NEUTRAL badge: the robot's own base is yellow while cleaning
const devEl = sr().querySelector('.dev:not(.on)');
const pcs = getComputedStyle(puck), dcs = getComputedStyle(devEl);
o.puckPlateMatchesDev = pcs.backgroundColor === dcs.backgroundColor
&& pcs.borderTopColor === dcs.borderTopColor;
o.puck80pct = Math.abs(puck.getBoundingClientRect().width
- devEl.getBoundingClientRect().width * 0.8) < 2;
o.noWedge = !sr().querySelector('.vacwedge'); // owner 2026-07-31: no heading arrow
// the glyph is DEAD centre (owner report: it floated on the text baseline)
const pr = puck.getBoundingClientRect();
const ir = sr().querySelector('.vacpuck ha-icon').getBoundingClientRect();
o.iconCentred = Math.abs(ir.left + ir.width / 2 - (pr.left + pr.width / 2)) < 0.75
&& Math.abs(ir.top + ir.height / 2 - (pr.top + pr.height / 2)) < 0.75;
o.baseStaysDuringCleaning = !!sr().querySelector('.dev');
// drive: two more points -> trail polyline grows; puck moves
const p1 = puck.getBoundingClientRect();
for (const [x, y] of [[900, 800], [900, 1100]]) {
c.hass = { ...c.hass, states: { ...c.hass.states,
'camera.robo_map': { state: 'idle', attributes: mkAttrs(x, y) } } };
await c.updateComplete; await new Promise((r) => setTimeout(r, 30));
}
const trail = sr().querySelector('.vactrail polyline');
// the trail LAGS one point behind the puck: after 3 telemetry points only
// the first two are drawn — a segment never outruns the icon (owner)
o.trailDrawn = !!trail && trail.getAttribute('points').split(' ').length === 2;
o.trailScaledByMatrix = !!trail && trail.getAttribute('points').startsWith('300.0,400.0');
o.trailBehindPuck = !!trail && !trail.getAttribute('points').includes('450.0,550.0');
// a zoom/view change TELEPORTS the puck instead of gliding across the plan
c._applyView(1.35); c.requestUpdate(); await c.updateComplete;
o.zoomTeleports = sr().querySelector('.vacpuck').classList.contains('jump');
c._applyView(1); c.requestUpdate(); await c.updateComplete;
// casing pair: dark halo + light core with identical geometry — the trail
// must survive any room fill underneath (owner 2026-07-31)
const tcase = sr().querySelector('.vactrail .case');
const tcore = sr().querySelector('.vactrail .core');
o.trailCasing = !!tcase && !!tcore
&& tcase.getAttribute('points') === tcore.getAttribute('points')
&& parseFloat(getComputedStyle(tcase).strokeWidth) > parseFloat(getComputedStyle(tcore).strokeWidth)
&& getComputedStyle(tcase).stroke.includes('0, 0, 0')
&& getComputedStyle(tcore).stroke.includes('255, 255, 255');
// trail off via marker option
cfg.markers.find((m) => m.id === 'e_vacuum_robo').vacuum.trail = false;
c._regSignature = ''; c.requestUpdate(); await c.updateComplete;
o.trailToggleOff = !sr().querySelector('.vactrail');
cfg.markers.find((m) => m.id === 'e_vacuum_robo').vacuum.trail = null;
// dock -> puck dissolves, trail lingers (linger window)
c.hass = { ...c.hass, states: { ...c.hass.states,
'vacuum.robo': { state: 'docked', attributes: { friendly_name: 'Робот' } } } };
c._regSignature = ''; c.requestUpdate(); await c.updateComplete; await new Promise((r) => setTimeout(r, 30));
o.puckGoneWhenDocked = !sr().querySelector('.vacpuck');
// default mode 'cleaning': the trail HIDES the moment the run ends (owner)
o.trailHiddenAfterDock = !sr().querySelector('.vactrail polyline');
// hidden marker renders neither puck nor trail
c.hass = { ...c.hass, states: { ...c.hass.states,
'vacuum.robo': { state: 'cleaning', attributes: { friendly_name: 'Робот' } } } };
cfg.markers.find((m) => m.id === 'e_vacuum_robo').hidden = true;
c._regSignature = ''; c.requestUpdate(); await c.updateComplete;
o.hiddenNoPuck = !sr().querySelector('.vacpuck') && !sr().querySelector('.vactrail');
cfg.markers.find((m) => m.id === 'e_vacuum_robo').hidden = false;
// no calibration for the active map -> no puck (graceful degradation)
c.hass = { ...c.hass, states: { ...c.hass.states,
'camera.robo_map': { state: 'idle', attributes: mkAttrs(900, 1100, { map_name: 'm2' }) } } };
c._regSignature = ''; c.requestUpdate(); await c.updateComplete;
o.unknownMapNoPuck = !sr().querySelector('.vacpuck');
// ---- server-side runs: current + one previous (owner 2026-07-31) ----
cfg.markers.find((m) => m.id === 'e_vacuum_robo').vacuum.trail_mode = 'always';
c._vacSrvTrails = { e_vacuum_robo: {
current: { map_id: 'm1', started: 1, ended: null,
points: [[700, 500], [900, 500], [1100, 640], [950, 1150]] },
previous: { map_id: 'm1', started: 0, ended: 1,
points: [[600, 1300], [800, 1300], [800, 1500]] },
} };
c.hass = { ...c.hass, states: { ...c.hass.states,
'vacuum.robo': { state: 'cleaning', attributes: { friendly_name: 'Робот' } },
'camera.robo_map': { state: 'idle', attributes: { vacuum_position: { x: 950, y: 1150, a: 0 }, map_name: 'm1' } } } };
await c.updateComplete;
c.hass = { ...c.hass, states: { ...c.hass.states } }; await c.updateComplete;
const prevG = sr().querySelector('.vactrail g.prev');
o.srvPrevRunShown = !!prevG && prevG.querySelectorAll('polyline').length === 2
&& prevG.querySelector('.case').getAttribute('points').split(' ').length === 3;
const curLines = [...sr().querySelectorAll('.vactrail > polyline.case, .vactrail g:not(.prev) polyline.case')];
const cur = curLines.find((x) => !x.closest('g.prev'));
// 4 server points, the live tail trimmed while moving -> 3 rendered
o.srvCurTrimmed = !!cur && cur.getAttribute('points').split(' ').length === 3;
o.srvPrevFaded = !!prevG && getComputedStyle(prevG).opacity === '0.4';
// the growing tip is glued to the puck by the rAF sampler
const tip = sr().querySelector('line.tip');
o.tipExists = !!tip;
await new Promise((r) => setTimeout(r, 250));
const puckNow = sr().querySelector('.vacpuck').getBoundingClientRect();
const svgT = sr().querySelector('.vactrail');
const vbT = svgT.viewBox.baseVal; const sbT = svgT.getBoundingClientRect();
const tx = sbT.left + ((parseFloat(tip.getAttribute('x2')) - vbT.x) / vbT.width) * sbT.width;
const ty = sbT.top + ((parseFloat(tip.getAttribute('y2')) - vbT.y) / vbT.height) * sbT.height;
o.tipGluedToPuck = Math.hypot(tx - (puckNow.left + puckNow.width / 2), ty - (puckNow.top + puckNow.height / 2)) < 12;
// 'never' kills every line including the previous run
cfg.markers.find((m) => m.id === 'e_vacuum_robo').vacuum.trail_mode = 'never';
c._regSignature = ''; c.requestUpdate(); await c.updateComplete;
o.neverHidesAll = !sr().querySelector('.vactrail');
cfg.markers.find((m) => m.id === 'e_vacuum_robo').vacuum.trail_mode = null;
c._vacSrvTrails = {};
// ---- the fit panel (drag + corner-stretch) end to end ----
c._regSignature = ''; c.requestUpdate(); await c.updateComplete;
const dev = c._devices.find((x) => x.id === 'e_vacuum_robo');
o.fitDevFound = !!dev;
// map m2 with rooms (bboxes) and a parked robot
c.hass = { ...c.hass, states: { ...c.hass.states,
'camera.robo_map': { state: 'idle', attributes: { vacuum_position: { x: 500, y: 500, a: 0 }, map_name: 'm2',
rooms: { 1: { name: 'Кухня', x0: 0, y0: 0, x1: 1000, y1: 800 },
2: { name: 'Зал', x0: 0, y0: 800, x1: 1000, y1: 2000 } } } } } };
await c.updateComplete;
c._vacStartFit(dev); await c.updateComplete;
o.fitOverlay = !!sr().querySelector('.vacfit');
o.fitGhostRooms = sr().querySelectorAll('.vacfit polygon').length === 2;
o.fitLabels = [...sr().querySelectorAll('.vacfit text')].map((t) => t.textContent.trim()).join('|') === 'Кухня|Зал';
o.fitHandles = sr().querySelectorAll('.vacfithandle').length === 4;
// REAL hit-tests: what would an actual click land on? Synthetic dispatch
// bypasses pointer-events, and that let the untouchable overlay pass once.
const hitAt = (el) => {
const r = el.getBoundingClientRect();
let n = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2);
while (n && n.shadowRoot) {
const deeper = n.shadowRoot.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2);
if (!deeper || deeper === n) break;
n = deeper;
}
return n;
};
o.fitHandleHittable = hitAt(sr().querySelector('.vacfithandle'))?.classList?.contains('vacfithandle') === true;
o.fitGhostHittable = !!hitAt(sr().querySelector('.vacfit polygon'))?.closest?.('.vacfit');
o.fitBar = !!sr().querySelector('.vaccalbar');
o.fitMirrorDefault = c._vacFit.p.mir === true;
// drag: the ghost centre must follow; rotate: the centre must NOT move
const before = { ...c._vacFit.p };
const svgEl = sr().querySelector('.vacfit');
const sb2 = svgEl.getBoundingClientRect();
const fire = (type, x, y, target) => (target || svgEl).dispatchEvent(new PointerEvent(type, {
bubbles: true, composed: true, pointerId: 7, clientX: x, clientY: y }));
fire('pointerdown', sb2.left + sb2.width * 0.5, sb2.top + sb2.height * 0.5);
fire('pointermove', sb2.left + sb2.width * 0.6, sb2.top + sb2.height * 0.55);
fire('pointerup', sb2.left + sb2.width * 0.6, sb2.top + sb2.height * 0.55);
await c.updateComplete;
o.fitDragMoves = c._vacFit.p.ox !== before.ox && c._vacFit.p.oy !== before.oy;
const centreBefore = (() => { const t = c._vacFit.p; const m = [t.s * (t.mir ? -1 : 1), 0, t.ox, 0, t.s, t.oy];
return [m[0] * 500 + m[2], m[4] * 1000 + m[5]]; })();
c._vacFitTurn({ rot: 90 }); await c.updateComplete;
o.fitRotateKeepsCentre = c._vacFit.p.rot === 90;
// corner-stretch scales
const s0 = c._vacFit.p.s;
const handle = sr().querySelector('.vacfithandle');
const hb = handle.getBoundingClientRect();
fire('pointerdown', hb.left + hb.width / 2, hb.top + hb.height / 2, handle);
fire('pointermove', hb.left + hb.width / 2 + 60, hb.top + hb.height / 2 + 60);
fire('pointerup', hb.left + hb.width / 2 + 60, hb.top + hb.height / 2 + 60);
await c.updateComplete;
o.fitCornerScales = Math.abs(c._vacFit.p.s - s0) > 1e-6;
// save -> matrix stored for m2, panel closed, puck appears while cleaning
c._vacFitSave(); await c.updateComplete;
const savedM = c._serverCfg.markers.find((m) => m.id === 'e_vacuum_robo').vacuum.calibration.m2;
o.fitSavedMatrix = Array.isArray(savedM) && savedM.length === 6 && savedM.every(Number.isFinite);
o.fitClosed = !c._vacFit && !sr().querySelector('.vacfit');
o.oldWizardGone = typeof c._vacCalClick === 'undefined' && typeof c._vacStartWizard === 'undefined';
c.hass = { ...c.hass, states: { ...c.hass.states,
'camera.robo_map': { state: 'idle', attributes: { vacuum_position: { x: 501, y: 501, a: 0 }, map_name: 'm2',
rooms: {} } } } };
await c.updateComplete;
c.hass = { ...c.hass, states: { ...c.hass.states } }; await c.updateComplete;
o.puckAfterFit = !!sr().querySelector('.vacpuck');
return o;
});
checkAll(out, {
dockedNoPuck: true, baseMarkerThere: true, puckAppears: true, puckRound: true,
puckPlateMatchesDev: true, puck80pct: true,
noWedge: true, iconCentred: true, baseStaysDuringCleaning: true, trailDrawn: true,
trailScaledByMatrix: true, trailBehindPuck: true, zoomTeleports: true,
trailCasing: true, trailToggleOff: true,
puckGoneWhenDocked: true, trailHiddenAfterDock: true, hiddenNoPuck: true,
unknownMapNoPuck: true,
srvPrevRunShown: true, srvCurTrimmed: true, srvPrevFaded: true,
tipExists: true, tipGluedToPuck: true, neverHidesAll: true,
fitDevFound: true, fitOverlay: true, fitGhostRooms: true, fitLabels: true,
fitHandles: true, fitHandleHittable: true, fitGhostHittable: true, fitBar: true, fitMirrorDefault: true, fitDragMoves: true,
fitRotateKeepsCentre: true, fitCornerScales: true, fitSavedMatrix: true,
fitClosed: true, oldWizardGone: true, puckAfterFit: true,
});
await finish(browser);
+108
View File
@@ -0,0 +1,108 @@
// v1.54.0 audit regressions (HP-1540-01/-04/-06): the FIRST-USE path.
// An auto-discovered vacuum has NO marker in the config — cfg.markers stays
// empty here, unlike smoke_vacuum.mjs which always pushes one by hand (the
// audit called that gap out explicitly). Every vacuum action must materialise
// the marker and actually persist; plan rooms are legacy RECTANGLES.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const out = await page.evaluate(async () => {
const c = window.__card;
const o = {};
const sr = () => c.shadowRoot || c.renderRoot;
const cfg = c._serverCfg;
// legacy rectangle rooms (x/y/w/h, HP-1540-04) in the garden space where
// the auto-discovered vacuum.mower already lives
const g = cfg.spaces.find((s) => s.id === 'garden');
g.rooms = [
{ id: 'ga', name: 'Кухня', area: 'garden', x: 0.05, y: 0.10, w: 0.25, h: 0.30 },
{ id: 'gb', name: 'Зал', x: 0.40, y: 0.10, w: 0.25, h: 0.30 },
{ id: 'gc', name: 'Спальня', x: 0.05, y: 0.50, w: 0.25, h: 0.30 },
];
// robot rooms: same centres in "robot mm" (scale 4), map_index is a
// NUMERIC ZERO — the cross-runtime zero-map contract (HP-1540-02)
const robotRooms = {
1: { name: 'Кухня', x0: 300, y0: 600, x1: 1100, y1: 1400 },
2: { name: 'Зал', x0: 1700, y0: 600, x1: 2500, y1: 1400 },
3: { name: 'Спальня', x0: 300, y0: 2200, x1: 1100, y1: 3000 },
};
const camAttrs = { vacuum_position: { x: 700, y: 1000, a: 0 }, map_index: 0, rooms: robotRooms };
c.hass = { ...c.hass,
entities: { ...c.hass.entities,
'camera.mower_map': { entity_id: 'camera.mower_map', device_id: 'd_mower', platform: 'demo' } },
states: { ...c.hass.states,
'camera.mower_map': { state: 'idle', attributes: camAttrs } } };
c._regSignature = '';
c.requestUpdate(); await c.updateComplete;
// spy on config writes: a success toast is only honest after one of these
const writes = [];
const realWS = c.hass.callWS;
c.hass.callWS = async (m) => { if (m.type === 'houseplan/config/set') writes.push(m); return realWS(m); };
const dev = c._devices.find((x) => x.id === 'd_mower');
o.devFound = !!dev;
o.freshNoMarker = (cfg.markers || []).length === 0 && !dev.marker;
// ---- auto-calibration from scratch (HP-1540-01 + HP-1540-04) ----
c._vacAutoCalibrate(dev);
await c.updateComplete;
const m1 = (cfg.markers || []).find((x) => x.id === 'd_mower');
o.markerMaterialised = !!m1 && m1.binding === 'device:d_mower';
const cal0 = m1?.vacuum?.calibration?.['0'];
o.rectRoomsCalibrated = Array.isArray(cal0) && cal0.length === 6 && cal0.every(Number.isFinite);
o.sourceStored = m1?.vacuum?.source === 'camera.mower_map';
// the matrix really maps robot mm → canvas: centre of «Кухня» → (175, 250)
const ap = (mm, x, y) => [mm[0] * x + mm[1] * y + mm[2], mm[3] * x + mm[4] * y + mm[5]];
const hit = cal0 ? ap(cal0, 700, 1000) : [0, 0];
o.matrixMapsRooms = Math.hypot(hit[0] - 175, hit[1] - 250) < 2;
o.successToastShown = (c._toast || '').startsWith('Done: bound via 3 rooms');
await new Promise((r) => setTimeout(r, 700)); // debounced _saveConfig
o.configWritePersisted = writes.length >= 1
&& !!writes[writes.length - 1].config.markers.find((x) => x.id === 'd_mower')?.vacuum?.calibration?.['0'];
// ---- manual fit from scratch (HP-1540-01) ----
cfg.markers = [];
c._regSignature = ''; c.requestUpdate(); await c.updateComplete;
const dev2 = c._devices.find((x) => x.id === 'd_mower');
o.fitFreshNoMarker = !dev2.marker;
c._vacStartFit(dev2); await c.updateComplete;
c._vacFitSave(); await c.updateComplete;
const m2 = (cfg.markers || []).find((x) => x.id === 'd_mower');
const calFit = m2?.vacuum?.calibration?.['0'];
o.fitMaterialises = !!m2 && Array.isArray(calFit) && calFit.length === 6;
o.fitToastAfterSave = c._toast === c._t('vac.cal_done');
// ---- live checkbox + trail select from scratch (HP-1540-01) ----
cfg.markers = [];
c._regSignature = ''; c.requestUpdate(); await c.updateComplete;
c._setMode('devices'); await c.updateComplete;
c._openMarkerDialog(c._devices.find((x) => x.id === 'd_mower')); await c.updateComplete;
const liveBox = sr().querySelector('.vacbox input[type=checkbox]');
o.vacSectionShown = !!liveBox;
if (liveBox) { liveBox.click(); await c.updateComplete; }
const m3 = (cfg.markers || []).find((x) => x.id === 'd_mower');
o.liveToggleMaterialises = !!m3 && m3.vacuum?.live === false;
const sel = sr().querySelector('.vacbox select');
if (sel) { sel.value = 'always'; sel.dispatchEvent(new Event('change')); await c.updateComplete; }
o.trailModePersists = m3?.vacuum?.trail_mode === 'always';
c._markerDialog = null; c._setMode('view'); await c.updateComplete;
// ---- error copy never points at the removed point calibration (HP-1540-06) ----
const toasts = ['vac.autocal_no_rooms', 'vac.autocal_no_match', 'vac.autocal_res_warn']
.map((k) => c._t(k)).join(' | ');
o.noPointCalibrationCopy = !/point|точк/i.test(toasts);
// and the real no-rooms path shows the reworded toast
c.hass = { ...c.hass, states: { ...c.hass.states,
'camera.mower_map': { state: 'idle', attributes: { vacuum_position: { x: 1, y: 2, a: 0 }, map_index: 0 } } } };
await c.updateComplete;
c._vacAutoCalibrate(c._devices.find((x) => x.id === 'd_mower'));
o.noRoomsToastReworded = c._toast === c._t('vac.autocal_no_rooms') && !/point|точк/i.test(c._toast);
c.hass.callWS = realWS;
return o;
});
checkAll(out);
await finish(browser, out);
+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 };

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