/** 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; } /* #532 + #582: an untouched card preserves the historical single-SVG composition. On the first camera movement the card switches for its remaining lifetime to the stage-sized sibling: the inner 4700×4200 group is demoted before HA Companion can lose compositor tiles. */ .stage.daycycle .hp-paperg, .stage.daycycle.hp-safe-daycycle-outline .hp-paper-outline-svg, .hp-static-stage.daycycle .hp-paper-outline-svg { 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)); will-change: filter; transition: filter 1100ms cubic-bezier(.22, .61, .36, 1); } .stage.daycycle.hp-safe-daycycle-outline .hp-paperg { filter: none; will-change: auto; transition: none; } .hp-static-stage.daycycle .hp-paperg { filter: none; will-change: auto; transition: none; } .stage.daycycle.hp-safe-daycycle-outline .hp-paper-outline-svg { transition: none; } .stage.daycycle.hp-safe-daycycle-outline .plan-svg { will-change: transform; } @media (prefers-reduced-motion: reduce) { .hp-day-cycle-bg, .hp-day-cycle-sun, .stage.daycycle .hp-paperg, .stage.daycycle.hp-safe-daycycle-outline .hp-paper-outline-svg, .hp-static-stage.daycycle .hp-paper-outline-svg { transition: none; } } .sunlayer { pointer-events: none; } /* #600: компас — индикатор 44 px набора (класс hpf-compass), геометрия и цвета живут в листе набора; здесь только поведение перетаскивания. */ .compass { touch-action: none; cursor: grab; user-select: none; } .compass:active { cursor: grabbing; } /* HP-1552: first-open boot veil — the plan hides until the stage height settles */ .stage.hpboot .zoomwrap, .stage.hpboot .zoombadge, .stage.hpiso-pending .zoomwrap, .stage.hpiso-pending .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; } .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; } .hp-paper-outline-svg { z-index: 0; overflow: visible; pointer-events: none; visibility: hidden; } .stage.hp-safe-daycycle-outline .hp-paper-outline-svg { visibility: visible; } .hp-static-stage .hp-paper-outline-svg { visibility: visible; } .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; } /* #649 3b: the user's wall colour (fill_colors.wall_fill), stage vars from src/iso-materials.ts; the fallbacks are the default white. */ .iso-side-hi { stop-color: var(--iso-side-hi, #c4c4c4); } .iso-side-mid { stop-color: var(--iso-side-mid, #adadad); } .iso-side-lo { stop-color: var(--iso-side-lo, #999999); } .iso-top-hi { stop-color: var(--iso-top-hi, #ffffff); } .iso-top-lo { stop-color: var(--iso-top-lo, #ededed); } .iso-wall-side { fill: url(#hp-iso-wall-side) var(--iso-side-mid, #adadad); stroke: #92989b; stroke-width: 0.7; vector-effect: non-scaling-stroke; } .iso-wall-top { fill: url(#hp-iso-wall-top) var(--iso-top-hi, #ffffff); 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: none; } .iso-opening-panel.iso-opening-leaf-edge, .iso-opening-panel.iso-opening-leaf-top { fill: #8d9497; } .iso-opening-panel.iso-material-light-frame, .iso-opening-panel.iso-material-light-sill { fill: #fffef8; fill-opacity: 0.94; stroke: #b3b5ae; } .iso-opening-panel.iso-material-light-window, .iso-opening-panel.iso-material-glass-side { fill: #c9e4f3; fill-opacity: 0.88; stroke: #8fb4c7; } .iso-opening-panel.iso-material-glass-top { fill: #e3f2fa; fill-opacity: 0.96; stroke: #9fc4d5; } .iso-ambient-shadow { fill: rgba(15, 21, 25, 0.22); filter: url(#hp-iso-ambient-shadow); } /* #649 3b: walls, openings, floor edge, wall texture and room labels do not follow the HA theme in 2.5D — only the building's ambient shadow on the card background may. */ .stage.theme-dark .iso-ambient-shadow { fill: rgba(0, 0, 0, 0.34); } @media (prefers-color-scheme: dark) { .stage:not(.theme-light) .iso-ambient-shadow { fill: rgba(0, 0, 0, 0.34); } } @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-material-texture { display: none; } } @supports not (filter: blur(1px)) { .iso-ambient-shadow, .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. #534: the shape is now keyed(space.id, repeat(items, (o) => o.id, ...)), and BOTH halves are load-bearing. The outer key does the space switch: there the key sets are disjoint, so repeat would build two key maps and walk both lists only to throw everything away — 80 ms of it on a plan with two hundred markers. The inner key does everything inside one space, where the list composition still changes underneath: an opening with an unresolved host is listed ONLY in plan mode (_openingsR), so entering and leaving the editor shifts the positions of the rest. Removing either half brings the phantom animation back through a different door. Witnesses: 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; } .rlgearbtn { transition: none !important; } } /* 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: grab; pointer-events: auto; touch-action: none; opacity: 0.92; box-shadow: var(--shadow-1); z-index: 2; } .rlgearbtn { transition: opacity 0.15s, filter 0.15s, box-shadow 0.15s; } .rlgearbtn.dragging { cursor: grabbing; opacity: 1; filter: brightness(1.1); box-shadow: var(--shadow-3, 0 8px 20px rgb(0 0 0 / 0.28)); } :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; text-shadow: none; filter: none; -webkit-text-stroke: 0 transparent; /* #649 3b: the configured room label colour (inline, as in Flat) is kept in both themes; 2.5D only drops stroke, shadow and halo. */ } /* #665: the 44 x 44 px touch floor of a raised room label is an invisible pseudo-element, as on door locks: sizing the label box itself centred the name in 44 px and pushed the absolutely placed metrics row away from it by (44 - name height) / 2, so the metrics drifted as the zoom changed the font. z-index -1 paints the floor below the label content, inside the label's own stacking context (z-index 1). */ .stage.projection-iso.mode-view .roomlabel::before { content: ''; position: absolute; left: 50%; top: 50%; width: max(44px, 100%); height: max(44px, 100%); transform: translate(-50%, -50%); z-index: -1; pointer-events: auto; } /* Interactive raised overlays stay above passive room names. Collision placement never needs to move or promote a label to make a device usable. */ .stage.projection-iso.mode-view .dev, .stage.projection-iso.mode-view .oplock { z-index: 2; } .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/DECOR-EDITOR.md §3.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, .hp-stair-frame .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, .hp-stair-frame .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; } /* #687: devices are landmarks in the Plan editor too, exactly as in Background (#362): the same markers as View, at the same 35% and fully pointer-inert, so every Plan tool receives the point below them. The fade sits on each marker, not on .devlayer: room labels share that layer and stay opaque and interactive here. filter: opacity() multiplies the marker's own opacity (.unavail), as the Background layer opacity does. */ .stage.markup .devlayer .dev { filter: opacity(0.35); } .stage.markup .devlayer .dev, .stage.markup .devlayer .dev *, .stage.markup .devlayer .dev::before { pointer-events: none; } /* 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 , 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); } .stage.markup.tool-stairs { cursor: crosshair; } .hp-stair-outline { fill: var(--hp-stair-fill, #607d8b); fill-opacity: var(--hp-stair-fill-opacity, 0); stroke: var(--hp-stair-line, #607d8b); stroke-opacity: var(--hp-stair-line-opacity, 1); stroke-width: calc(2px / var(--hp-plan-screen-scale, 1)); vector-effect: non-scaling-stroke; } .hp-stair-trapezoid, .hp-stair-tread, .hp-stair-arrow { fill: none; stroke: var(--hp-stair-line, #607d8b); stroke-opacity: var(--hp-stair-line-opacity, 1); stroke-width: calc(2px / var(--hp-plan-screen-scale, 1)); stroke-linecap: round; stroke-linejoin: round; vector-effect: non-scaling-stroke; pointer-events: none; } .hp-stair-arrow { stroke-width: calc(2.5px / var(--hp-plan-screen-scale, 1)); } .hp-stair-hit { fill: transparent; stroke: transparent; stroke-width: 14px; pointer-events: none; } .hp-stair.input-enabled .hp-stair-hit { pointer-events: all; } .hp-stair.navigable { cursor: pointer; } /* #686 (owner, 2026-09-28): a stair link shows no focus indicator at all — neither the browser's ring nor a replacement. It stays focusable, and Enter/Space still follow it (#676). */ .hp-stair:focus, .hp-stair:focus-visible { outline: none; } :host([data-pointer-hover]) .hp-stair.navigable:hover .hp-stair-outline { stroke: var(--hp-accent); stroke-opacity: 1; stroke-width: calc(2.5px / var(--hp-plan-screen-scale, 1)); } .hp-stair.selected .hp-stair-outline { stroke: var(--hp-accent); stroke-opacity: 1; stroke-width: calc(3px / var(--hp-plan-screen-scale, 1)); } /* #676: the frame and its handles are the decor frame's chrome (.dtframe, .dthandle, .dtknob, dt-* cursors) painted in the top overlay; the draft being drawn is inert until it is placed */ .hp-stair.draft .hp-stair-hit { pointer-events: none; } .hp-stair.input-enabled .hp-stair-hit { cursor: move; } .alignmsg { margin: 0 0 8px; font-size: 13px; line-height: 1.45; } `;