Files
houseplan-card/src/styles/plan.styles.ts
T
Claudeandclaude[bot] 9edef041a9 fix: openings and markers keep their identity across a space switch (#525)
Lit reuses list nodes by position. The opening list and the device markers had
no keys, so on a space switch the leaf that held a slot kept its DOM node and
only changed values — and `.op-leaf` (transform) and `.op-arc`
(stroke-dashoffset) carry a 0.6 s transition, so the browser animated a door
that never moved: the new floor's leaf drove in from the previous floor's
opening angle. Measured on two spaces with a door in the same place and
opposite contact states: the node is reused, transform goes
`rotate(-90deg) → rotate(0deg)`, dash offset `0 → 125.66`, both transitions
`running`. The marker shell adds two more with its `box-shadow`.

Both lists are now rendered through `repeat(…, (item) => item.id, …)`, the
same lesson `glow-scene.ts` already learned for the Glow spots. The trap is
written where it starts — above the two transitions in `plan.styles.ts` —
because that is the file someone edits when adding the next animated property.
`houseplan-card.ts` is at its line ceiling, and the note would have cost the
budget a dozen lines for nothing: the swap itself is line-for-line.

The witness walks the shadow tree per element. `document.getAnimations()` is
empty here EVEN ON THE BROKEN CODE — the card lives in a shadow root and the
document-level call does not reach into it, and the issue proposed exactly
that call. The smoke also builds its own fixture: the demo home has no
openings at all, so two doors in two spaces are prepared in the smoke, and it
asserts the other half of the contract as well — a real contact change inside
one space still animates the leaf.

On `origin/dev` the smoke fails on five facts, naming the offenders:
`op-arc:stroke-dashoffset`, `op-leaf:transform`, `device-shell-frame:box-shadow`
twice. Mutants `openings-rendered-without-keys` and
`device-markers-rendered-without-keys` put each `map` back.

Perf, 7 samples against `19e421b3`: spaceSwitchMs 524.8 (limit 769.35, base
512.9), switchCycleMs 1293.9 (1696.28, 1256.5), firstStableRenderMs 2533.8
(3000, 2529.2), modelReadyMs 732.7 (944.97, 726.9), longTask.maxSingleMs 663
(910, 660) — `benchmark:compare` green in full.

The initial View graph grows 241 B gzip: `repeat` enters it for the first
time. The #438 ceiling is recentred 300 400 → 300 700 with the usual dated
note; measured 300 059 B keeps 641 B above and 1 359 B below the band. The
301 066 B budget is untouched, but only 366 B now separate the ceiling from
it — the #367 headroom debt has stopped being theoretical.

Issue: #525
User-Visible: yes
2026-09-11 01:42:46 +00:00

1593 lines
54 KiB
TypeScript

/** The plan scene: stage, walls, axes, snap, decor, iso and resize ink (#266, split from styles.ts). */
import { css } from 'lit';
export const planStyles = css`
.fixedfloor-loading {
animation: fixedfloor-spin 1.1s linear infinite;
}
.fixedfloor-error p {
max-width: 42rem;
margin: 0;
overflow-wrap: anywhere;
}
@media (prefers-reduced-motion: reduce) {
.fixedfloor-loading { animation: none; }
.radar-target { transition: none; }
}
.stage.noplan {
background: #ffffff;
}
/* Editors always keep the white drawing sheet — even when a backdrop
image is loaded. Without this, a picture plan painted the theme card
colour under the grid (owner 2026-08-05). View keeps the historical
theme / .noplan split. */
.stage.mode-plan,
.stage.mode-devices,
.stage.mode-decor {
background: #ffffff;
}
.stage {
position: relative;
width: 100%;
container-type: inline-size;
overflow: hidden;
touch-action: none; /* custom pinch/pan gestures */
background: var(--ha-card-background, var(--card-background-color, #111));
}
.zoomwrap {
position: absolute;
inset: 0;
z-index: 1;
}
.stage.mode-transition,
.stage.mode-transition .zoomwrap {
transition: none !important;
}
.stage.mode-transition .hp-view-only-layer,
.stage.mode-transition .hp-editor-only-layer {
pointer-events: none;
will-change: opacity;
}
.stage.mode-transition .hp-paper {
fill: var(--hp-mode-paper) !important;
}
/* Four-phase environment (#146): constant layers cross-fade because CSS
gradients themselves are not reliably interpolated. Everything stays
behind the plan; the plan tree is never dimmed, tinted or faded. */
.hp-day-cycle-env {
position: absolute;
inset: 0;
z-index: 0;
overflow: hidden;
pointer-events: none;
isolation: isolate;
}
.hp-day-cycle-bg {
position: absolute;
inset: 0;
opacity: 0;
overflow: hidden;
transition: opacity 1100ms cubic-bezier(.22, .61, .36, 1);
}
.hp-day-cycle-bg.active { opacity: 1; }
.hp-day-cycle-sun {
position: absolute;
left: var(--hp-day-cycle-sun-x);
top: var(--hp-day-cycle-sun-y);
width: clamp(140px, 25cqw, 250px);
height: clamp(140px, 25cqw, 250px);
transform: translate(-50%, -50%);
border-radius: 50%;
filter: blur(10px);
opacity: var(--hp-day-cycle-sun-opacity);
transition:
left 1100ms cubic-bezier(.22, .61, .36, 1),
top 1100ms cubic-bezier(.22, .61, .36, 1),
opacity 1100ms cubic-bezier(.22, .61, .36, 1);
}
.stage.mode-transition .hp-day-cycle-env { transition: none; }
.stage.daycycle .hp-paperg,
.hp-static-stage.daycycle .hp-paperg {
filter:
drop-shadow(0 0 1px var(--hp-day-cycle-outline-near))
drop-shadow(0 0 5px var(--hp-day-cycle-outline-mid))
drop-shadow(0 0 10px var(--hp-day-cycle-outline-far));
transition: filter 1100ms cubic-bezier(.22, .61, .36, 1);
}
@media (prefers-reduced-motion: reduce) {
.hp-day-cycle-bg,
.hp-day-cycle-sun,
.stage.daycycle .hp-paperg,
.hp-static-stage.daycycle .hp-paperg {
transition: none;
}
}
.sunlayer {
pointer-events: none;
}
.compass {
width: 120px;
height: 120px;
flex: none;
touch-action: none;
cursor: grab;
user-select: none;
}
.compass:active {
cursor: grabbing;
}
.compass .cring {
fill: rgba(255, 255, 255, 0.04);
stroke: var(--divider-color, #444);
stroke-width: 2;
}
.compass .ctick {
stroke: var(--secondary-text-color, #9aa4ad);
stroke-width: 2;
}
.compass .ctick.minor {
stroke-width: 1;
opacity: 0.6;
}
.compass .cneedle line {
stroke: var(--primary-color, #3ea6ff);
stroke-width: 2.5;
stroke-linecap: round;
}
.compass .cneedle path {
fill: var(--primary-color, #3ea6ff);
}
.compass .cneedle text {
fill: var(--text-primary-color, #fff);
font-size: 11px;
font-weight: 700;
}
.compass .cdeg {
fill: var(--secondary-text-color, #9aa4ad);
font-size: 13px;
}
.compass.unset .cneedle {
opacity: 0.35;
}
/* HP-1552: first-open boot veil — the plan hides until the stage height settles */
.stage.hpboot .zoomwrap,
.stage.hpboot .zoombadge {
visibility: hidden;
}
/* AUD-1552-02: post-veil grace — HA chrome landing after the cap moves
the stage height smoothly; the viewport ResizeObserver refits the plan
along the transition, so a late panel glides instead of jumping. */
.stage.hpsettle {
transition: height 0.25s ease;
}
@media (prefers-reduced-motion: reduce) {
.stage.hpsettle {
transition: none;
}
}
.zoomctl {
display: inline-flex;
gap: var(--sp-1);
background: rgba(127, 127, 127, 0.12);
border-radius: var(--rad-m);
padding: var(--sp-1);
}
.zoomctl .zb {
border: none;
padding: var(--sp-3) var(--sp-4);
}
.zoomctl .zb[disabled] {
opacity: 0.4;
pointer-events: none;
}
.projection-toggle {
min-width: 44px;
min-height: 44px;
justify-content: center;
padding: var(--sp-3);
}
.header-action {
min-width: 44px;
min-height: 44px;
justify-content: center;
padding: var(--sp-3);
}
/* docs/CANVAS.md §5: the plane has no edges, so you can pan until nothing
is on screen. One pointer home, one click back. */
.homearrow {
position: absolute;
z-index: 12;
transform: translate(-50%, -50%);
width: 40px;
height: 40px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
background: var(--card-background-color, var(--hp-bg));
color: var(--hp-accent);
border: 1px solid var(--hp-accent);
opacity: 0.9;
padding: 0;
}
.homearrow ha-icon {
--mdc-icon-size: 22px;
line-height: 1;
}
.zoombadge {
position: absolute;
z-index: 12;
left: var(--sp-4);
bottom: var(--sp-4);
background: var(--card-background-color, var(--hp-bg));
opacity: 0.92;
color: var(--hp-txt);
border: 1px solid var(--hp-accent);
border-radius: var(--rad-m);
padding: var(--sp-1) var(--sp-4);
font-size: var(--fs-s);
font-weight: 600;
pointer-events: none;
}
/* Only the plan canvas owns the whole zoom wrapper. Context trays now live
inside .stage as well, and may contain small SVG previews (furniture in
particular); the old descendant-wide stage-SVG selector stretched every
preview over the tray and stacked them into one blocking artefact. The
vacuum trail/fit overlays have their own explicit geometry below. */
.zoomwrap > svg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
display: block;
}
.plan-svg { z-index: 1; }
.iso-underlay-svg { z-index: 0; overflow: visible; }
.iso-shadows-svg { z-index: 3; overflow: visible; }
.iso-walls-svg {
z-index: 4;
overflow: visible;
}
.iso-overlays-svg { z-index: 5; overflow: visible; }
.iso-underlay-svg,
.iso-shadows-svg,
.iso-walls-svg,
.iso-overlays-svg,
.iso-underlay,
.iso-shadows,
.iso-walls,
.iso-openings {
pointer-events: none;
}
.iso-side-hi { stop-color: #b9bdbe; }
.iso-side-lo { stop-color: #969c9f; }
.iso-top-hi { stop-color: #fafaf7; }
.iso-top-lo { stop-color: #e2e4e2; }
.iso-wall-side {
fill: url(#hp-iso-wall-side) #a8acae;
stroke: #92989b;
stroke-width: 0.7;
vector-effect: non-scaling-stroke;
}
.iso-wall-top {
fill: url(#hp-iso-wall-top) #f3f3f1;
stroke: #d7d9d8;
stroke-width: 0.8;
vector-effect: non-scaling-stroke;
}
.iso-material-texture {
fill: url(#hp-iso-wall-texture);
stroke: none;
opacity: 0.12;
pointer-events: none;
}
.iso-floor-texture { fill: url(#hp-iso-floor-texture); opacity: 0.1; }
.iso-texture-mark { fill: rgba(36, 43, 47, 0.34); }
.iso-texture-line {
fill: none;
stroke: rgba(36, 43, 47, 0.24);
stroke-width: 0.55;
}
.iso-floor-side {
fill: #858b8d;
stroke: #71787b;
stroke-width: 0.7;
vector-effect: non-scaling-stroke;
}
.iso-opening-panel {
fill: #d7d9d7;
fill-opacity: 0.96;
stroke: #7f878b;
stroke-width: 0.9;
vector-effect: non-scaling-stroke;
}
.iso-opening-panel.iso-window {
fill: #dfeff4;
fill-opacity: 0.72;
stroke: #8aa7b1;
}
.iso-opening-panel.iso-material-reveal {
fill: #c7cbca;
stroke: #8b9294;
}
.iso-opening-panel.iso-material-matte-leaf {
fill: #a6abad;
stroke: #737b7f;
}
.iso-opening-panel.iso-opening-leaf-edge,
.iso-opening-panel.iso-opening-leaf-top { fill: #8d9497; }
.iso-opening-panel.iso-material-light-window,
.iso-opening-panel.iso-material-light-frame,
.iso-opening-panel.iso-material-light-sill {
fill: #edf7f9;
fill-opacity: 0.94;
stroke: #94adb4;
}
.iso-overlay-ground {
fill: rgba(20, 26, 29, 0.3);
filter: url(#hp-iso-overlay-ground);
pointer-events: none;
}
.iso-overlay-tether {
stroke: rgba(54, 65, 71, 0.48);
stroke-width: 1;
stroke-dasharray: 3 2;
vector-effect: non-scaling-stroke;
pointer-events: none;
}
.iso-ambient-shadow {
fill: rgba(15, 21, 25, 0.22);
filter: url(#hp-iso-ambient-shadow);
}
.iso-contact-shadow {
fill: none;
stroke: rgba(22, 28, 31, 0.25);
stroke-width: 3;
filter: url(#hp-iso-contact-shadow);
vector-effect: non-scaling-stroke;
}
.iso-leaf-shadow {
fill: none;
stroke: rgba(18, 23, 27, 0.24);
stroke-width: 4;
filter: url(#hp-iso-leaf-shadow);
vector-effect: non-scaling-stroke;
}
.stage.theme-dark .iso-side-hi { stop-color: #4c555a; }
.stage.theme-dark .iso-side-lo { stop-color: #343c40; }
.stage.theme-dark .iso-top-hi { stop-color: #687176; }
.stage.theme-dark .iso-top-lo { stop-color: #50585d; }
.stage.theme-dark .iso-wall-side { stroke: #30373b; }
.stage.theme-dark .iso-wall-top { stroke: #7b858a; }
.stage.theme-dark .iso-floor-side { fill: #2d3438; stroke: #20272a; }
.stage.theme-dark .iso-opening-panel { fill: #626b70; stroke: #899399; }
.stage.theme-dark .iso-opening-panel.iso-window { fill: #75919b; stroke: #abc6ce; }
.stage.theme-dark .iso-opening-panel.iso-material-reveal { fill: #454e52; stroke: #727d82; }
.stage.theme-dark .iso-opening-panel.iso-material-matte-leaf { fill: #626a6e; stroke: #899398; }
.stage.theme-dark .iso-opening-panel.iso-opening-leaf-edge,
.stage.theme-dark .iso-opening-panel.iso-opening-leaf-top { fill: #4b5458; }
.stage.theme-dark .iso-opening-panel.iso-material-light-window,
.stage.theme-dark .iso-opening-panel.iso-material-light-frame,
.stage.theme-dark .iso-opening-panel.iso-material-light-sill {
fill: #d8e8eb;
stroke: #9eb8bf;
}
.stage.theme-dark .iso-ambient-shadow { fill: rgba(0, 0, 0, 0.34); }
.stage.theme-dark .iso-contact-shadow,
.stage.theme-dark .iso-leaf-shadow { stroke: rgba(0, 0, 0, 0.38); }
.stage.theme-dark .iso-texture-mark { fill: rgba(255, 255, 255, 0.24); }
.stage.theme-dark .iso-texture-line { stroke: rgba(255, 255, 255, 0.18); }
.stage.theme-dark .iso-overlay-ground { fill: rgba(0, 0, 0, 0.42); }
.stage.theme-dark .iso-overlay-tether { stroke: rgba(224, 232, 235, 0.5); }
@media (prefers-color-scheme: dark) {
/* HA's explicit theme wins over the operating-system preference. */
.stage:not(.theme-light) .iso-side-hi { stop-color: #4c555a; }
.stage:not(.theme-light) .iso-side-lo { stop-color: #343c40; }
.stage:not(.theme-light) .iso-top-hi { stop-color: #687176; }
.stage:not(.theme-light) .iso-top-lo { stop-color: #50585d; }
.stage:not(.theme-light) .iso-wall-side { stroke: #30373b; }
.stage:not(.theme-light) .iso-wall-top { stroke: #7b858a; }
.stage:not(.theme-light) .iso-floor-side { fill: #2d3438; stroke: #20272a; }
.stage:not(.theme-light) .iso-opening-panel { fill: #626b70; stroke: #899399; }
.stage:not(.theme-light) .iso-opening-panel.iso-window { fill: #75919b; stroke: #abc6ce; }
.stage:not(.theme-light) .iso-opening-panel.iso-material-reveal { fill: #454e52; stroke: #727d82; }
.stage:not(.theme-light) .iso-opening-panel.iso-material-matte-leaf { fill: #626a6e; stroke: #899398; }
.stage:not(.theme-light) .iso-opening-panel.iso-opening-leaf-edge,
.stage:not(.theme-light) .iso-opening-panel.iso-opening-leaf-top { fill: #4b5458; }
.stage:not(.theme-light) .iso-opening-panel.iso-material-light-window,
.stage:not(.theme-light) .iso-opening-panel.iso-material-light-frame,
.stage:not(.theme-light) .iso-opening-panel.iso-material-light-sill { fill: #d8e8eb; stroke: #9eb8bf; }
.stage:not(.theme-light) .iso-ambient-shadow { fill: rgba(0, 0, 0, 0.34); }
.stage:not(.theme-light) .iso-contact-shadow,
.stage:not(.theme-light) .iso-leaf-shadow { stroke: rgba(0, 0, 0, 0.38); }
.stage:not(.theme-light) .iso-texture-mark { fill: rgba(255, 255, 255, 0.24); }
.stage:not(.theme-light) .iso-texture-line { stroke: rgba(255, 255, 255, 0.18); }
.stage:not(.theme-light) .iso-overlay-ground { fill: rgba(0, 0, 0, 0.42); }
.stage:not(.theme-light) .iso-overlay-tether { stroke: rgba(224, 232, 235, 0.5); }
}
@media (forced-colors: active) {
.iso-wall-side, .iso-wall-top, .iso-floor-side, .iso-opening-panel {
fill: Canvas;
stroke: CanvasText;
forced-color-adjust: auto;
}
.iso-ambient-shadow, .iso-contact-shadow, .iso-leaf-shadow,
.iso-overlay-ground, .iso-material-texture { display: none; }
.iso-overlay-tether { stroke: CanvasText; }
}
@supports not (filter: blur(1px)) {
.iso-ambient-shadow, .iso-contact-shadow, .iso-leaf-shadow,
.iso-overlay-ground, .iso-material-texture { display: none; }
}
/* Opaque plan paper (owner 2026-08-03): the scene bg_color / daynight sky
shows ONLY around the plan, never through it. The colour is the
pre-bg_color canvas — the theme card background under an image plan,
plain white for a hand-drawn one (.stage.noplan). On drawn plans the
paper is per-room shapes following the room contours — fill only,
never a stroke, so the paper cannot poke past a wall. Night dimming
comes from the .zoomwrap brightness filter, never from alpha
(docs/SUN.md). */
.hp-paper {
fill: var(--ha-card-background, var(--card-background-color, #111));
stroke: none;
}
.stage.noplan .hp-paper,
.stage.mode-plan .hp-paper,
.stage.mode-devices .hp-paper,
.stage.mode-decor .hp-paper {
fill: #ffffff;
}
/* Owner 2026-08-04: «углы границ комнат всё ещё с зубцами». A miter join
on a 30-45° corner shoots a spike far past the wall (and flips to an
ugly bevel once past the miter limit) — the same defect the decor lines
had before they got round caps. Every room border, in EVERY renderer
that reuses these styles (plan view, plan editor, static space-card),
joins its walls with a ROUND join instead: the corner reads as the
stroke's own radius, never as a tooth. The linecap matters only for the
open outlines below, but it costs nothing to state it here. */
.room {
transition: 0.12s;
cursor: default;
stroke-linejoin: round;
stroke-linecap: round;
}
.stage.mode-view .room { cursor: pointer; }
.room.overlay {
fill: transparent;
stroke: transparent;
stroke-width: calc(2px * var(--hp-cell-visual-scale, 1));
}
:host([data-pointer-hover]) .stage.mode-view .room.overlay:not(.styled):hover {
stroke: var(--hp-accent);
stroke-opacity: 1;
}
.room.yard {
fill: rgba(75, 140, 90, 0.14);
stroke: #4b8c5a;
stroke-width: calc(2px * var(--hp-cell-visual-scale, 1));
}
:host([data-pointer-hover]) .stage.mode-view .room.yard:not(.styled):hover {
stroke: var(--hp-accent);
stroke-opacity: 1;
}
.room.styled {
stroke: var(--room-stroke, transparent);
stroke-opacity: var(--room-stroke-op, 0);
stroke-width: calc(2.5px * var(--hp-cell-visual-scale, 1));
fill: var(--room-fill, transparent);
fill-opacity: var(--room-fill-op, 0);
}
.glow-base-layer,
.glow-base-tunnels,
.glow-pools-frame,
.glow-pools,
.glow-spot {
pointer-events: none;
}
/* The parent isolates all source spots from the room data fill, Glow base,
paper and backdrop. A spot is one circle clipped to the floor its lamp
can see, so the whole spot screen-blends as a single primitive — there is
no mask left to be dropped on a promoted layer. Per-stop alpha already
contains the shared 0.7 ceiling; the spot opacity below only animates
between 0 and 1 and is never another persistent alpha ceiling. */
.glow-pools-frame,
.glow-pools,
.glow-spot {
isolation: isolate;
}
.glow-pools.blend-screen .glow-spot {
mix-blend-mode: screen;
}
.glow-spot {
opacity: 1;
transition: opacity 500ms ease;
}
.glow-spot.is-entering,
.glow-spot.is-leaving {
opacity: 0;
}
@media (prefers-reduced-motion: reduce) {
.glow-spot {
transition: none;
}
}
/* The explicit late room-hover layer owns the wash and halo. Keeping CSS
filters off room paths prevents Chromium from recompositing the sibling
screen-blended Glow layer for one bright frame on every hover. */
:host([data-pointer-hover]) .stage.mode-view .room.styled:hover {
stroke: var(--hp-accent);
stroke-opacity: 1;
}
/* doors, windows & gates
#525: these two transitions are the reason the opening list is rendered
with repeat(items, (o) => o.id, ...) and not a bare map(). Lit reuses
list nodes by POSITION, so on a space switch the leaf that held a slot
kept its DOM node and only changed values — and the browser dutifully
animated a door that never moved: the new floor's leaf drove in from the
previous floor's opening angle, 0.6 s of an event that did not happen.
Anything animated that is rendered from a list needs a key. The Glow
spots learned it first (glow-scene.ts, opacity), the openings and the
device markers (.device-shell-frame, box-shadow) with this issue.
Witness: demo/smoke_space_switch_transitions.mjs. */
.op-leaf {
transition: transform 0.6s ease;
}
.op-arc {
stroke-width: calc(1.5px * var(--hp-cell-visual-scale, 1));
transition: stroke-dashoffset 0.6s ease;
}
/* hover affordance: a rounded outline hugging the wall strip + a grab cursor */
.op-outline {
fill: none;
stroke: var(--hp-accent);
stroke-width: calc(1.5px * var(--hp-cell-visual-scale, 1));
opacity: 0;
transition: opacity 0.15s;
pointer-events: none;
}
:host([data-pointer-hover]) .stage.markup g.opening:hover .op-outline {
opacity: 0.9;
}
/* openings are pure status graphics outside Plan mode: no cursor, no hover,
no hit target — View must not interact with them at all */
.op-hit {
fill: transparent;
pointer-events: none;
cursor: default;
}
.stage.markup .op-hit {
pointer-events: auto;
cursor: grab;
touch-action: none; /* drags, not scrolls, on touch */
}
.stage.markup .op-hit:active {
cursor: grabbing;
}
.stage.markup .opening.orphan {
pointer-events: auto;
cursor: pointer;
color: var(--error-color, #db4437);
}
.stage.markup .opening.orphan circle {
fill: var(--hp-bg);
stroke: currentColor;
stroke-width: calc(2px * var(--hp-cell-visual-scale, 1));
}
.stage.markup .opening.orphan text {
fill: currentColor;
font-weight: 800;
font-size: calc(12px * var(--hp-cell-visual-scale, 1));
pointer-events: none;
}
/* HP-1550-04: in the resize tool the wall handles own the hit test — the
transparent .op-hit of a door at the midpoint of a wall used to sit ON
TOP of the handle and made that wall ungrabbable for both rooms.
Openings are not editable in this tool (they ride along with the wall),
so their hit area goes fully inert; every other Plan tool is untouched. */
.stage.markup.tool-resize .op-hit {
pointer-events: none;
cursor: default;
}
.stage.mode-view .oplock {
pointer-events: auto;
cursor: pointer;
}
.stage.projection-iso.mode-view .oplock::before {
content: '';
position: absolute;
left: 50%;
top: 50%;
width: max(44px, 100%);
height: max(44px, 100%);
transform: translate(-50%, -50%);
border-radius: 50%;
pointer-events: auto;
}
@media (prefers-reduced-motion: reduce) {
.op-leaf, .op-arc { transition: none; }
}
.roomlabel {
pointer-events: none; /* draggable only in plan mode (rule below) */
position: absolute;
transform: translate(-50%, -50%);
font-size: calc(var(--rl-icon-size, var(--icon-size, 2.5cqw)) * 0.5 * var(--rl-scale, 1) * var(--rl-font, 1) * var(--rl-space, 1));
font-weight: 700;
letter-spacing: 0.04em;
white-space: nowrap;
cursor: grab;
user-select: none;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.15em;
text-align: center;
}
.rlname {
display: inline-flex;
align-items: center;
gap: 0.25em;
font-size: calc(1em * var(--rl-name, 1));
}
.zoomwrap.slide-left { animation: hp-slide-left 0.18s cubic-bezier(0.2, 0.7, 0.2, 1); }
.zoomwrap.slide-right { animation: hp-slide-right 0.18s cubic-bezier(0.2, 0.7, 0.2, 1); }
@media (prefers-reduced-motion: reduce) {
.zoomwrap.slide-left,
.zoomwrap.slide-right { animation: none; }
}
/* The name is the anchor: the label box is centred on the room point, so
anything that takes part in its layout SHIFTS THE NAME. The gear button
and the metrics hang below as absolutes — the name renders in exactly
the same place in view mode and in the plan editor (owner's request),
and the button sits at the very bottom of the card. */
/* Standalone, centred on the room, sized from the device icon: icon-size
already rescales with the view, so the button zooms WITH the plan
instead of keeping a constant screen size (owner's spec). */
.rlgearbtn {
--gear-h: calc(var(--icon-size, 2.5cqw) * 0.77); /* owner: half the previous size */
position: absolute;
/* dead-centred on the room, both axes (owner's spec) */
transform: translate(-50%, -50%);
display: inline-flex;
align-items: center;
gap: 0.35em;
height: var(--gear-h);
padding: 0 calc(var(--gear-h) * 0.38);
border: 0;
border-radius: 999px;
background: var(--hp-accent);
color: var(--text-primary-color, #fff);
font: inherit;
font-size: calc(var(--gear-h) * 0.42);
font-weight: 600;
line-height: 1;
white-space: nowrap;
cursor: pointer;
pointer-events: auto;
opacity: 0.92;
box-shadow: var(--shadow-1);
z-index: 2;
}
.rlgearbtn { transition: opacity 0.15s, filter 0.15s; }
:host([data-pointer-hover]) .rlgearbtn:hover { opacity: 1; filter: brightness(1.18); }
.rlgearbtn ha-icon { --mdc-icon-size: calc(var(--gear-h) * 0.55); display: inline-flex; }
.rlgear {
--mdc-icon-size: 0.9em;
display: inline-flex;
margin-right: 0.2em;
opacity: 0.6;
cursor: pointer;
pointer-events: auto;
}
:host([data-pointer-hover]) .rlgear:hover { opacity: 1; }
.rlgo {
--mdc-icon-size: 0.85em;
display: inline-flex;
opacity: 0.55;
}
.stage.mode-view .rlgo {
pointer-events: auto;
cursor: pointer;
}
:host([data-pointer-hover]) .stage.mode-view .rlgo:hover { opacity: 1; }
.roomlabel .rlmetrics {
position: absolute; /* below the name, outside the centring math */
top: calc(100% + 0.15em);
left: 50%;
transform: translateX(-50%);
white-space: nowrap;
display: flex;
align-items: center;
gap: 0.55em;
font-size: calc(0.75em * var(--rl-meta, 1)); /* feedback: 0.62 was unreadable on a tablet */
font-weight: 600;
letter-spacing: 0.02em;
opacity: 0.9;
}
.roomlabel .rlm {
display: inline-flex;
align-items: center;
gap: 0.12em;
}
.roomlabel .rlm ha-icon {
--mdc-icon-size: 1.05em;
display: inline-flex;
}
.roomlabel .rlm.lit { opacity: 1; }
.rlhandle {
display: none;
position: absolute;
width: 9px;
height: 9px;
border-radius: 2px;
background: var(--hp-accent);
border: 1px solid var(--card-background-color, #fff);
z-index: 2;
}
.rlhandle.tl { left: -6px; top: -6px; cursor: nwse-resize; }
.rlhandle.br { right: -6px; bottom: -6px; cursor: nwse-resize; }
.rlhandle.tr { right: -6px; top: -6px; cursor: nesw-resize; }
.rlhandle.bl { left: -6px; bottom: -6px; cursor: nesw-resize; }
:host([data-pointer-hover]) .stage.markup .roomlabel:hover .rlhandle { display: block; }
.stage.markup .roomlabel { pointer-events: auto; }
.stage.mode-view .roomlabel {
pointer-events: auto;
cursor: pointer;
border-radius: 0.2em;
}
.stage.projection-iso.mode-view .roomlabel {
box-sizing: border-box;
min-width: 44px;
min-height: 44px;
justify-content: center;
}
.stage.mode-view .roomlabel:focus-visible {
outline: 2px solid var(--hp-accent);
outline-offset: 4px;
}
.roomlabel:active { cursor: grabbing; }
.measurelayer {
position: absolute;
inset: 0;
pointer-events: none;
}
.alignline {
stroke: var(--hp-accent);
stroke-width: calc(1.2px * var(--hp-cell-visual-scale, 1));
stroke-dasharray:
calc(4px * var(--hp-cell-visual-scale, 1))
calc(4px * var(--hp-cell-visual-scale, 1));
pointer-events: none;
opacity: 0.9;
}
.aligndot {
fill: var(--hp-accent);
pointer-events: none;
}
.measurelabel.on45 {
color: #4bd28f;
border-color: #4bd28f;
}
.measurelabel {
position: absolute;
transform: translate(12px, -150%);
font-size: var(--fs-s);
font-weight: 600;
padding: 1px var(--sp-3);
border-radius: var(--rad-s);
background: rgba(0, 0, 0, 0.72);
color: #fff;
white-space: nowrap;
pointer-events: none;
user-select: none;
z-index: 3;
}
.measurelabel.opdimension {
transform: translate(
calc(-50% + var(--op-label-shift-x, 0px)),
calc(-50% + var(--op-label-shift-y, -12px))
);
border: 1px solid var(--hp-open, #ff9800);
}
/* decor (background) layer */
.decorlayer .dshape { pointer-events: none; }
.stage.mode-decor .decorlayer .dshape {
pointer-events: visiblePainted;
cursor: pointer;
}
.stage.mode-decor.dtool-select .decorlayer .dshape { cursor: move; }
/* …but a DRAWING tool owns the canvas: existing shapes stop being targets
entirely, so a new line can start exactly on the end of an old one
instead of grabbing it (owner, 2026-08-04). Same for the picture tool —
a shape lying over the plan must not block the picture's own drag.
Only select (move) and erase (delete) keep the shapes clickable. */
.stage.mode-decor.dtool-line .decorlayer .dshape,
.stage.mode-decor.dtool-rect .decorlayer .dshape,
.stage.mode-decor.dtool-ellipse .decorlayer .dshape,
.stage.mode-decor.dtool-text .decorlayer .dshape,
.stage.mode-decor.dtool-furniture .decorlayer .dshape,
.stage.mode-decor.dtool-image .decorlayer .dshape,
.stage.mode-decor.dtool-backdrop .decorlayer .dshape { pointer-events: none; }
/* the furniture tool is a stamp: the press must reach the stage even when
it lands on a sofa that is already there (docs/FURNITURE.md §4) */
.stage.mode-decor.dtool-furniture,
.stage.mode-decor.dtool-image { cursor: copy; }
.stage.mode-decor.dtool-select .decorlayer .dimage,
.stage.mode-decor.dtool-erase .decorlayer .dimage,
.stage.mode-decor.dtool-select .decorlayer .dimage-missing,
.stage.mode-decor.dtool-erase .decorlayer .dimage-missing {
pointer-events: bounding-box;
}
.decorlayer .dimage-missing rect {
fill: rgba(127, 127, 127, 0.12);
stroke: var(--hp-accent);
stroke-dasharray: 8 5;
vector-effect: non-scaling-stroke;
}
.decorlayer .dimage-missing path {
fill: none;
stroke: var(--hp-accent);
vector-effect: non-scaling-stroke;
}
.decorlayer .decor-image-placement-preview { opacity: 0.65; }
/* ONE exception (owner, 2026-08-04): under the TEXT tool an existing LABEL
is a target again — pressing it opens its editor instead of starting a
new label on top of the old one. Only labels: a line or a rectangle
under the text tool stays inert, so the press reaches the stage and a
new label is created there. */
.stage.mode-decor.dtool-text .decorlayer .dshape.dtext,
.stage.mode-decor.dtool-select .decorlayer .dshape.dtext,
.stage.mode-decor.dtool-erase .decorlayer .dshape.dtext {
/* A label is one logical decor object. SVG's visiblePainted would hit
only the ink of individual glyphs, so spaces, counters and the area
inside its selection glow behaved like empty canvas. */
pointer-events: visiblePainted;
pointer-events: bounding-box;
}
.stage.mode-decor.dtool-text .decorlayer .dshape.dtext {
cursor: text;
}
/* Erasing a hairline must not require pixel-perfect aim. The duplicate
geometry is invisible and exists only while Erase is active. A
non-scaling stroke keeps the target comfortably wide at every zoom. */
.decorlayer .derasehit {
fill: none;
stroke: transparent;
stroke-width: 16px;
stroke-linecap: round;
stroke-linejoin: round;
vector-effect: non-scaling-stroke;
pointer-events: none;
}
.stage.mode-decor.dtool-erase .decorlayer .derasehit {
pointer-events: stroke;
}
/* A dashed line must remain selectable across its gaps. This proxy also
makes the only entry to line-style properties — double click in Select —
practical for hairlines at every zoom. */
.decorlayer .dselecthit {
fill: none;
stroke: transparent;
stroke-width: 16px;
stroke-linecap: round;
vector-effect: non-scaling-stroke;
pointer-events: none;
}
.stage.mode-decor.dtool-select .decorlayer .dselecthit {
pointer-events: stroke;
cursor: move;
}
/* #383: furniture keeps a path-shaped target. Its per-object physical
width is supplied by the renderer; no empty bounding-box area is hit. */
.stage.mode-decor .decorlayer .dshape.dfurniturehit {
fill: none;
stroke: transparent;
pointer-events: none;
}
.stage.mode-decor.dtool-select .decorlayer .dshape.dfurniturehit {
pointer-events: stroke;
cursor: move;
}
.decorlayer .dsel {
filter: drop-shadow(0 0 3px var(--hp-accent));
}
.decorlayer .ddraft {
stroke-dasharray:
calc(6px * var(--hp-cell-visual-scale, 1))
calc(5px * var(--hp-cell-visual-scale, 1));
pointer-events: none;
}
.decorlayer .furniture-placement-preview {
opacity: 0.55;
pointer-events: none;
}
.decorlayer text {
font-weight: 600;
user-select: none;
dominant-baseline: middle;
text-anchor: middle;
}
.stage.mode-decor {
outline: 2px solid #26a69a;
outline-offset: -2px;
}
/* backdrop transform frame (docs/BACKDROP.md §2). Editor chrome: the
outline never takes a pointer, the four corner handles do — and they are
finger-sized (r = 2 % of the visible view), because this is dragged on a
tablet as often as with a mouse. */
.bdframe .bdbox {
fill: none;
stroke: var(--hp-accent);
stroke-width: 2;
stroke-dasharray: 10 7;
vector-effect: non-scaling-stroke;
pointer-events: none;
opacity: 0.9;
}
/* the HIT circle: invisible, finger-sized, the only thing that takes a
pointer. The visible bead is .bdknob, a quarter of its radius — the same
visual/hit split .dthandle + .dtknob and .rszhandle + .rszicon use. */
.bdframe .bdhandle {
fill: transparent;
stroke: none;
pointer-events: all;
touch-action: none;
}
.bdframe .bdknob {
fill: var(--hp-accent);
stroke: #fff;
stroke-width: 1;
vector-effect: non-scaling-stroke;
pointer-events: none;
}
:host([data-pointer-hover]) .bdframe .bdhandle:hover + .bdknob {
fill: #fff;
stroke: var(--hp-accent);
}
.bdframe .bd-nwse { cursor: nwse-resize; }
.bdframe .bd-nesw { cursor: nesw-resize; }
/* the picture itself is the drag target for a move (grab, then grabbing) */
.stage.mode-decor.bdgrab { cursor: grab; }
.stage.mode-decor.bdgrabbing,
.stage.mode-decor.bdgrabbing .bdframe .bdhandle { cursor: grabbing; }
.measurelabel.bdmeasure {
transform: translate(-50%, -50%);
border: 1px solid var(--hp-accent);
}
/* the selected decor object's frame — same chrome rules as the backdrop's:
the outline never takes a pointer, the handles always do, and they are
finger-sized because this is dragged on a tablet too */
.dtframe .dtbox {
fill: none;
stroke: var(--hp-accent);
stroke-width: 1.5;
stroke-dasharray: 7 5;
vector-effect: non-scaling-stroke;
pointer-events: none;
opacity: 0.85;
}
.dtframe .dtstem,
.bdframe .dtstem {
stroke: var(--hp-accent);
stroke-width: 1.5;
vector-effect: non-scaling-stroke;
pointer-events: none;
opacity: 0.85;
}
/* the HIT circle: invisible, finger-sized, the only thing that takes a
pointer. The visible bead is .dtknob, a quarter of its radius — the
same visual/hit split .rszhandle + .rszicon use. */
.dtframe .dthandle {
fill: transparent;
stroke: none;
pointer-events: all;
touch-action: none;
}
.dtframe .dtknob {
fill: var(--hp-accent);
stroke: #fff;
stroke-width: 1;
vector-effect: non-scaling-stroke;
pointer-events: none;
}
:host([data-pointer-hover]) .dtframe .dthandle:hover + .dtknob {
fill: #fff;
stroke: var(--hp-accent);
}
.dtframe .dt-nwse { cursor: nwse-resize; }
.dtframe .dt-nesw { cursor: nesw-resize; }
.dtframe .dtrot { cursor: grab; }
.dtframe .dt-ew { cursor: ew-resize; }
.dtframe .dt-ns { cursor: ns-resize; }
.dtfurnitureframe .dtrot {
cursor: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24'%3E%3Cpath d='M18.4 7.2A8 8 0 1 0 20 12' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round'/%3E%3Cpath d='m15.5 3.8 3.2 3.5-4.6.8' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") 12 12, grab;
}
.dtfurnitureframe .dtrot:active { cursor: grabbing; }
.dtframe .dtendpoint { cursor: crosshair; }
.bdframe .dtrot { cursor: grab; }
.dtarea {
resize: vertical;
min-height: 3.4em;
font: inherit;
line-height: 1.35;
}
.stage.mode-decor.dtool-line, .stage.mode-decor.dtool-rect,
.stage.mode-decor.dtool-ellipse, .stage.mode-decor.dtool-text {
cursor: crosshair;
}
.stage.mode-decor.dtool-erase,
.stage.mode-decor.dtool-erase .decorlayer .dshape {
cursor: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24'%3E%3Cg transform='rotate(-45 12 12)'%3E%3Crect x='7' y='2' width='10' height='18' rx='2' fill='%23fff' stroke='%23111' stroke-width='1.5'/%3E%3Cpath d='M7 13h10v5a2 2 0 0 1-2 2H9a2 2 0 0 1-2-2z' fill='%23ff9f43' stroke='%23111' stroke-width='1.5'/%3E%3C/g%3E%3C/svg%3E") 5 22, pointer;
}
.stage.mode-decor .room { pointer-events: none; }
/* Devices are landmarks in Background, never editing targets. The marker
package deliberately re-enables pointer events on the core, its 44 px
pseudo hit area and the visible capsule for View/Devices. A none on
devlayer alone therefore does not make its HTML descendants inert.
Scope the boundary to the whole subtree (including the pseudo element)
so a Background tool receives the exact point below every visible part. */
.stage.mode-decor .devlayer,
.stage.mode-decor .devlayer *,
.stage.mode-decor .dev::before {
pointer-events: none;
}
/* Backdrop-editor de-emphasis is a shared mode-transition coordinate.
It multiplies whole presentation groups and never changes Glow source
alpha, additive blending, or the underlying resolved state. */
.stage .room,
.stage .devlayer,
.stage .opening,
.stage .room-outline,
.stage .wallbodies,
.stage .opening-tunnels,
.stage .glow-base-layer,
.stage .glow-pools-frame,
.stage .zero-walls {
opacity: var(--hp-mode-architecture-opacity, 1);
}
.opening-preview {
opacity: 0.5;
pointer-events: none;
}
.opening-preview[data-kind="passage"] {
opacity: 1;
}
.passage-preview-cut {
fill: var(--wall-fill, #ffffff);
fill-opacity: 0.35;
stroke: none;
pointer-events: none;
}
.passage-preview-boundary {
stroke: var(--hp-open, #ff9800);
stroke-width: calc(2.5px * var(--hp-cell-visual-scale, 1));
pointer-events: none;
}
.opening-preview .op-leaf,
.opening-preview .op-arc {
transition: none;
}
.opening-preview-dot {
fill: var(--hp-open, #ff9800);
pointer-events: none;
}
.opening-dimensions,
.opening-dimension {
pointer-events: none;
}
.opening-dimension-line,
.opening-dimension-tick {
fill: none;
stroke: var(--hp-open, #ff9800);
stroke-width: 1.5;
vector-effect: non-scaling-stroke;
pointer-events: none;
}
.opening-dimension-tick {
stroke-width: 2;
}
.stage.edit .room {
pointer-events: none;
}
.stage.markup {
cursor: crosshair;
}
/* room-picking stages: merge (both clicks) and split before a room is chosen */
.stage.markup.tool-merge,
.stage.markup.tool-split.pickstage,
.stage.markup.tool-delroom {
cursor: pointer;
}
.stage.markup.tool-wallthick { cursor: default; }
.stage.markup.tool-wallthick.wallhot { cursor: pointer; }
/* Solid wall colour sits under the hatch (owner: both, not either/or). */
.wallbody-fill {
fill: var(--wall-fill, #ffffff);
fill-opacity: var(--wall-fill-op, 1);
fill-rule: evenodd;
stroke: none;
pointer-events: none;
}
.wallbody {
fill: url(#hp-wall-hatch);
fill-rule: evenodd;
stroke: var(--room-stroke, var(--hp-muted));
stroke-width: calc(0.6px * var(--hp-cell-visual-scale, 1));
pointer-events: none;
}
/* Thin-on-screen: hatch collapses into noise — fill alone, stroke from fill path's sibling. */
.wallbody.solid {
fill: none;
}
.wallthick-hover {
fill: var(--accent-color, #03a9f4);
fill-opacity: 0.38;
stroke: var(--accent-color, #03a9f4);
stroke-width: calc(2.5px * var(--hp-cell-visual-scale, 1));
stroke-linejoin: round;
stroke-linecap: round;
pointer-events: none;
}
.wallthick-hover.isopen {
fill: var(--error-color, #f44336);
stroke: var(--error-color, #f44336);
}
.zero-wall {
stroke: var(--zero-wall-stroke, var(--hp-muted));
stroke-width: calc(2.5px * var(--hp-cell-visual-scale, 1));
stroke-dasharray:
calc(7px * var(--hp-cell-visual-scale, 1))
calc(7px * var(--hp-cell-visual-scale, 1));
stroke-linecap: butt;
pointer-events: none;
opacity: 0.9;
}
.zero-walls.solid .zero-wall {
stroke-dasharray: none;
}
/* Rooms with zero/thick stretches: the polygon's own stroke is fully off.
The trimmed .room-outline draws normal walls; View hover gets its own
top overlay after the wall bodies. */
.room.noedge {
stroke-opacity: 0 !important;
}
/* rooms with open boundaries draw their walls as separate M..L subpaths,
so a corner between two of them is two stroke ENDS meeting: round caps
fill it in the same way a round join fills a closed contour's corner
(owner 2026-08-04 — no teeth anywhere on a room border). */
.room-outline {
fill: none;
stroke-width: calc(2.5px * var(--hp-cell-visual-scale, 1));
stroke-linejoin: round;
stroke-linecap: round;
pointer-events: none;
}
/* Plan editor: trimmed outlines use the markup blue */
.room-outline.outlined {
stroke: rgba(62, 166, 255, 0.55);
stroke-opacity: 1;
}
/* The wash is rendered directly above the resolved room fill but below
tunnels, Glow, sun and walls. Black at 22% reproduces the old
brightness(.78) contract without filtering/promoting an SVG ancestor or
changing the room's hue. The halo/outline are rendered again late, above
the wall bodies. */
.room-hover-fill {
fill: #000;
fill-opacity: 0.22;
pointer-events: none;
}
.room-hover-halo {
fill: none;
stroke: var(--hp-accent);
stroke-opacity: 0.28;
stroke-width: calc(8px * var(--hp-cell-visual-scale, 1));
stroke-linejoin: round;
stroke-linecap: round;
pointer-events: none;
}
.room-hover-outline {
fill: none;
stroke: var(--hp-accent);
stroke-width: calc(3px * var(--hp-cell-visual-scale, 1));
stroke-linejoin: round;
stroke-linecap: round;
pointer-events: none;
}
.stage.markup .room {
pointer-events: none;
}
.stage.markup .devlayer .dev {
display: none; /* in plan mode the icons do not get in the way; labels stay */
}
/* mode frames: the edit modes are visible at a glance */
.stage.mode-plan {
outline: 2px solid #ffc14d;
outline-offset: -2px;
}
.stage.mode-devices {
outline: 2px solid var(--hp-accent);
outline-offset: -2px;
}
.room.outlined {
stroke: rgba(62, 166, 255, 0.55);
fill: rgba(62, 166, 255, 0.06);
}
/* AFTER .outlined: same specificity — source order decides (gotcha x4) */
.room.picked {
stroke: #ffc14d;
stroke-width: calc(3px * var(--hp-cell-visual-scale, 1));
fill: rgba(255, 193, 77, 0.25);
}
/* Owner 2026-08-04: the grid is a HINT, not content — at full strength the
dots argued with the plan on white paper. Both levels are muted, the
hierarchy is kept (major still denser than fine). */
.griddot {
fill: var(--hp-accent);
opacity: 0.35;
stroke: rgba(0, 0, 0, 0.35);
stroke-width: 0.4;
}
/* docs/CANVAS.md §7: every coarse node (5x/10x the live step) keeps a
bigger, more opaque dot, so zoomed far out the grid still reads as a
grid instead of a grey wash. */
.griddot.major {
opacity: 0.5;
stroke-width: 0;
}
/* the contour being drawn in the Plan editor: each wall is its own <line>,
so the round cap IS the corner (matches the finished .room border) */
.seg {
stroke: var(--hp-accent);
stroke-width: calc(2.5px * var(--hp-cell-visual-scale, 1));
stroke-linecap: round;
stroke-linejoin: round;
}
.pathline {
stroke: #ffc14d;
stroke-width: calc(3px * var(--hp-cell-visual-scale, 1));
fill: none;
stroke-linecap: round;
stroke-linejoin: round;
}
.preview {
stroke: #ffc14d;
stroke-width: calc(2px * var(--hp-cell-visual-scale, 1));
stroke-dasharray:
calc(6px * var(--hp-cell-visual-scale, 1))
calc(5px * var(--hp-cell-visual-scale, 1));
opacity: 0.7;
}
.active-axis {
stroke: #ffc14d;
stroke-width: 2;
stroke-linecap: round;
vector-effect: non-scaling-stroke;
pointer-events: none;
}
.active-vertex {
fill: #ffc14d;
stroke: #171006;
stroke-width: 2;
vector-effect: non-scaling-stroke;
pointer-events: none;
}
.wall-repair-preview {
stroke: #d93025;
stroke-width: 4;
stroke-linecap: round;
stroke-dasharray: 5 4;
vector-effect: non-scaling-stroke;
pointer-events: none;
}
.drawwall-preview-fill {
fill: var(--wall-fill, #ffffff);
fill-opacity: calc(var(--wall-fill-op, 1) * 0.55);
fill-rule: evenodd;
stroke: none;
pointer-events: none;
}
.drawwall-preview {
fill: url(#hp-wall-hatch);
fill-opacity: 0.55;
fill-rule: evenodd;
stroke: var(--accent-color, #03a9f4);
stroke-width: calc(0.5px * var(--hp-cell-visual-scale, 1));
stroke-opacity: 0.7;
pointer-events: none;
}
.drawwall-zero-preview {
fill: none;
stroke: var(--accent-color, #03a9f4);
stroke-width: calc(3px * var(--hp-cell-visual-scale, 1));
stroke-linecap: butt;
stroke-linejoin: round;
opacity: 0.72;
pointer-events: none;
}
.drawwall-zero-preview.dashed {
stroke-dasharray:
calc(7px * var(--hp-cell-visual-scale, 1))
calc(7px * var(--hp-cell-visual-scale, 1));
}
.vertex {
fill: #ffc14d;
stroke: #4a2800;
stroke-width: calc(1px * var(--hp-cell-visual-scale, 1));
}
.vertex.first {
fill: #4bd28f;
stroke: #04121f;
}
.plan-snap-overlay,
.plan-snap-overlay *,
.hidden-wall-diagnostic,
.hidden-wall-diagnostic * {
pointer-events: none;
}
.plan-snap-line,
.hidden-wall-line {
fill: none;
stroke: color-mix(in srgb, var(--hp-accent) 82%, white 18%);
/* Explicit non-scaling-stroke in the SVG keeps this one screen pixel. */
stroke-width: 1;
stroke-linecap: round;
opacity: 0.92;
}
.plan-snap-node,
.hidden-wall-node {
fill: var(--ha-card-background, var(--card-background-color, #fff));
stroke: color-mix(in srgb, var(--hp-accent) 88%, #07131c 12%);
stroke-width: 1;
vector-effect: non-scaling-stroke;
}
.plan-snap-node.active {
fill: #ffc14d;
stroke: #171006;
stroke-width: 2;
}
.plan-snap-node.active.dynamic {
fill: #4bd28f;
stroke: #04121f;
}
.plan-snap-node.conflict {
fill: #fff;
stroke: #d93025;
stroke-width: 3;
}
@media (prefers-color-scheme: dark) {
.plan-snap-line {
stroke: color-mix(in srgb, var(--hp-accent) 72%, white 28%);
}
.hidden-wall-line {
stroke: color-mix(in srgb, var(--hp-accent) 72%, white 28%);
}
.plan-snap-node,
.hidden-wall-node {
fill: #17242c;
stroke: #9bdcf5;
}
.plan-snap-node.active {
fill: #ffc14d;
stroke: #fff4d6;
}
.plan-snap-node.active.dynamic {
fill: #4bd28f;
stroke: #eafff4;
}
}
@media (forced-colors: active) {
.plan-snap-line,
.hidden-wall-line {
stroke: CanvasText;
opacity: 1;
forced-color-adjust: auto;
}
.plan-snap-node,
.hidden-wall-node {
fill: Canvas;
stroke: CanvasText;
forced-color-adjust: auto;
}
.plan-snap-node.active,
.plan-snap-node.active.dynamic {
fill: Highlight;
stroke: HighlightText;
forced-color-adjust: auto;
}
}
/* RGB lights: the bulb takes the light's actual color */
/* v1.52.0: the RGB tint of the icon/border is gone — a lamp's colour
lives ONLY in its glow spot (owner's rule). The ripple-color fallback
keeps using the light colour; that is set inline via --ripple-color. */
/* Sun wedges (docs/SUN.md). The layer is present ONLY above the 3°
threshold; crossing it fades the whole layer in or out over EXACTLY
2 s (owner 2026-08-03 — RAY_FADE_MS in src/sun.ts must match). The
geometry is untouched: this is a plain opacity animation on the group,
so overlapping wedges keep their own blending while it plays. */
.sunlayer {
animation: hp-sunfade-in 2s linear both;
}
.sunlayer.out {
animation: hp-sunfade-out 2s linear both;
}
@media (prefers-reduced-motion: reduce) {
/* no fade at all: the rays are simply there or simply gone */
.sunlayer, .sunlayer.out { animation: none; }
.sunlayer.out { opacity: 0; }
}
.devlayer {
position: absolute;
inset: 0;
pointer-events: none;
z-index: 6;
}
.radar-target {
position: absolute;
width: 8px;
height: 8px;
box-sizing: border-box;
transform: translate(-50%, -50%);
border-radius: 50%;
background: var(--success-color, #43a047);
border: 2px solid var(--primary-text-color, #1f1f1f);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--card-background-color, #fff) 80%, transparent);
pointer-events: none;
z-index: 0;
}
.radar-target.smooth { transition: left 200ms linear, top 200ms linear; }
.radar-ranges {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
overflow: visible;
pointer-events: none;
z-index: 0;
}
.radar-ranges path,
.radar-ranges polyline {
fill: none;
stroke: var(--success-color, #43a047);
stroke-width: 2px;
stroke-dasharray: 5 4;
vector-effect: non-scaling-stroke;
opacity: 0.72;
}
.radar-ranges .radar-zone {
fill: color-mix(in srgb, var(--success-color, #43a047) 8%, transparent);
stroke: var(--success-color, #43a047);
stroke-width: 2px;
vector-effect: non-scaling-stroke;
opacity: 0.8;
}
.stage.mode-devices .dev { cursor: grab; }
.stage.mode-devices .dev:active { cursor: grabbing; }
.physical-hit {
fill: transparent;
stroke: transparent;
pointer-events: none;
cursor: grab;
}
.stage.tool-select .physical-hit { pointer-events: all; }
.physical-hit:active { cursor: grabbing; }
line.physical-hit { cursor: pointer; }
.physical-hit.selected {
fill: rgba(255, 193, 77, 0.24);
stroke: transparent;
}
line.physical-hit.selected {
stroke: transparent;
}
.physical-drag {
fill: rgba(255, 193, 77, 0.38);
stroke: #ffc14d;
stroke-width: 2.5;
vector-effect: non-scaling-stroke;
pointer-events: none;
}
.physical-chrome { pointer-events: none; }
.physical-chrome .frame,
.physical-chrome .stem {
fill: none;
stroke: #ffc14d;
stroke-width: 2;
vector-effect: non-scaling-stroke;
}
.physical-chrome .frame { fill: rgba(255, 193, 77, 0.22); }
.physical-chrome polyline.frame { fill: none; }
.physical-chrome .move-dot {
fill: #ffc14d;
stroke: #24262d;
stroke-width: 1.5;
vector-effect: non-scaling-stroke;
}
.physical-chrome .rotate-handle {
fill: #24262d;
stroke: #ffc14d;
stroke-width: 2.5;
vector-effect: non-scaling-stroke;
pointer-events: all;
cursor: crosshair;
}
.drawwall.invalid input { border-color: var(--error-color, #db4437); }
.drawwall .rangehint { margin-inline-start: 4px; font-size: 0.78em; opacity: 0.72; }
.drawwall.invalid .rangehint { color: var(--error-color, #db4437); opacity: 1; }
.stage.markup.tool-partition,
.stage.markup.tool-column { cursor: crosshair; }
/* room resize tool (docs/RESIZE.md) */
/* wall handle: invisible finger-sized hit circle (HP-1550-04 hit priority
kept), the visible glyph lives in the sibling .rszicon */
.rszhandle {
fill: transparent;
stroke: none;
pointer-events: all;
cursor: grab;
touch-action: none;
}
.rszhandle:active { cursor: grabbing; }
.rszhandle.disabled,
.rszhandle.disabled:active {
cursor: not-allowed;
}
/* wall-with-arrows glyph: accent ink over a bg halo, readable on any plan */
.rszicon { pointer-events: none; }
.rszicon path {
fill: none;
stroke-linecap: round;
stroke-linejoin: round;
vector-effect: non-scaling-stroke;
}
.rszhalo { stroke: var(--hp-bg); stroke-width: 6; }
.rszink { stroke: var(--hp-accent); stroke-width: 2; }
:host([data-pointer-hover]) .rszhandle:hover + .rszicon .rszink { stroke-width: 3; }
.rszicon.disabled { opacity: 0.38; }
:host([data-pointer-hover]) .rszhandle.disabled:hover + .rszicon .rszink { stroke-width: 2; }
.rszmeasurelayer,
.rszmeasurelayer * { pointer-events: none; }
.rszmeasurehalo,
.rszmeasureink,
.rszleader {
fill: none;
stroke-linecap: round;
vector-effect: non-scaling-stroke;
}
.rszmeasurehalo {
stroke: var(--hp-bg);
stroke-width: 7px;
opacity: 0.9;
}
.rszmeasureink {
stroke: var(--hp-accent);
stroke-width: 3px;
}
.rszleader {
stroke: var(--hp-accent);
stroke-width: 2px;
opacity: 0.95;
}
/* the decor draft badge rides the MIDDLE of the shape, so it is centred
horizontally and lifted clear of the line instead of trailing the
cursor the way a wall badge does (owner 2026-08-04) */
.measurelabel.dmeasure {
transform: translate(-50%, -160%);
}
.measurelabel.rszarea {
transform: translate(
calc(-50% + var(--rsz-label-x, 0px)),
calc(-50% + var(--rsz-label-y, 0px))
);
background: rgba(0, 0, 0, 0.6);
border: 1px solid var(--hp-accent);
}
/* width and depth of a piece of furniture while its corner is dragged —
centred on the edge they measure (docs/FURNITURE.md §6) */
.measurelabel.furnmeasure {
transform: translate(-50%, -50%);
border: 1px solid var(--hp-accent);
}
.alignmsg { margin: 0 0 8px; font-size: 13px; line-height: 1.45; }
`;