mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
The Plan editor hid every device marker (display: none), so walls, openings and stairs were placed against devices from memory. It now shows them exactly as the Background editor does (#362): the same markers as View, at the Background editor's effective opacity, fully pointer-inert. The fade is filter: opacity(0.35) on each marker, not on .devlayer, because room labels and room settings buttons share that layer and stay opaque and interactive; filter multiplies the marker's own opacity (.unavail -> 0.35 x 0.35), as the Background layer does. Marker handlers already fail closed outside View/Devices. demo/smoke_plan_device_landmarks.mjs proves AC1/AC2: same markers as View, per-marker alpha equal to Background, core and capsule fall through, a real click on a marker reaches the Walls tool and a room settings button below, cursor and target unchanged, handlers inert, no tabindex. Three mutants guard hiding, opacity override and the pointer boundary. UX-MODES, DECOR-EDITOR, USER-GUIDE.ru mode table, changelogs. Issue: #687 User-Visible: yes Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
1627 lines
56 KiB
TypeScript
1627 lines
56 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; }
|
||
/* #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 <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);
|
||
}
|
||
.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; }
|
||
`;
|