refactor: extract the base styles and finish the aggregator (#266 slice 5/5)

52 блока host/переменных/кросс-поверхностных групп → src/styles/base.styles.ts;
styles.ts — 19-строчный сборщик [base, plan, devices, chrome, dialogs] с
задокументированным контрактом порядка каскада. Два оставшихся мутантных
якоря (:host-гейт ховера устройств) переадресованы на base.styles.ts.

Юниты инвариантов (test/styles-split.test.mjs): состав и порядок сборщика;
непересечение (scope+селектор) между файлами — единственное именованное
исключение: кросс-поверхностная группа «:host(...) .dev:hover, .dev:focus-
visible» живёт в base по §1.1; выживание медиа-обёрток — 2 forced-colors и
10 prefers-reduced-motion (в ТЗ и ревью фигурировали 8 — фактический счёт по
исходнику 10, юнит держит точное число). fix-test-build научился точке в
имени модуля (styles/base.styles → .js). ARCHITECTURE.md — раздел Styles.

Refactor-proof diff (scope-ключ) пуст; golden 129/129 без переприёмки; смоки
plan_snap_overlay/preloader OK; npm test 1318/0; бандл 1 291 440 → 1 291 458
(+18 байт).

Issue: #266
User-Visible: no
This commit is contained in:
Codex
2026-08-26 01:34:20 +03:00
parent 55f7126afd
commit 0d9320fe1d
10 changed files with 370 additions and 267 deletions
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+14
View File
@@ -3,6 +3,20 @@
Updated: 2026-08-19 (#113 optional space-model lifecycle). The repository = a HACS integration (category **Integration**)
that contains both the backend (`custom_components/houseplan`) and the Lovelace card (`src/` → `dist/`).
## Styles (#266)
`src/styles.ts` is a 19-line aggregator: `cardStyles = [baseStyles, planStyles,
devicesStyles, chromeStyles, dialogsStyles]` from `src/styles/*.styles.ts`.
The array ORDER is part of the cascade contract — rules of equal specificity
resolve by position, and the golden set is accepted against exactly this
order. Surface ownership: `base` (host, variables, cross-surface groups),
`plan` (stage scene, walls/axes/snap/decor/iso/resize ink), `devices`
(markers, shells, vacuums), `chrome` (toolbars, tabs, menus), `dialogs`
(dialogs, forms, pickers). A rule serving two surfaces lives in `base`; the
invariants (no duplicate selectors across files, aggregator composition,
media-wrapper survival) are pinned by `test/styles-split.test.mjs`, and
`scripts/dev/styles-diff.mjs` proves any restyle-move refactor-only.
## Layout
```
+11 -11
View File
@@ -2,7 +2,7 @@
"version": 1,
"fixture": "synthetic-only",
"chromium": "151.0.7922.34",
"sourceFingerprint": "dc22dc9e6166e8b09eee216838fb26e2b48623185162d4654ec7f949e3bcdf15",
"sourceFingerprint": "da2136b0e110b8b4173c465625648cc368e54c34b33cf17bb2ded0cc82c36b2e",
"captureScriptSha256": "ce2e9542fed9dade3085be87d16f69adb2ac8262893ad78ad966b1b9673f2983",
"command": "npm run build && node demo/docs/capture.mjs",
"scenarios": {
@@ -14,7 +14,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "dc22dc9e6166e8b09eee216838fb26e2b48623185162d4654ec7f949e3bcdf15",
"sourceSha256": "da2136b0e110b8b4173c465625648cc368e54c34b33cf17bb2ded0cc82c36b2e",
"imageSha256": "0ca982495a7749907c48ebbddfd089c54385a4347f79f64e94af2c5a8d5c03eb"
},
"view-touch": {
@@ -25,7 +25,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "dc22dc9e6166e8b09eee216838fb26e2b48623185162d4654ec7f949e3bcdf15",
"sourceSha256": "da2136b0e110b8b4173c465625648cc368e54c34b33cf17bb2ded0cc82c36b2e",
"imageSha256": "1c8e28ff34736b936ba7c9ec047d1da27aaf6fbb01411155f2d5c05282c02c41"
},
"space-create": {
@@ -36,7 +36,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "dc22dc9e6166e8b09eee216838fb26e2b48623185162d4654ec7f949e3bcdf15",
"sourceSha256": "da2136b0e110b8b4173c465625648cc368e54c34b33cf17bb2ded0cc82c36b2e",
"imageSha256": "329750c81a7a958c4fd105b2c80f85dce55ed87df9ddf0ea08084e4428e85f04"
},
"room-contour-close": {
@@ -47,7 +47,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "dc22dc9e6166e8b09eee216838fb26e2b48623185162d4654ec7f949e3bcdf15",
"sourceSha256": "da2136b0e110b8b4173c465625648cc368e54c34b33cf17bb2ded0cc82c36b2e",
"imageSha256": "ff2e903e548423aa0aa52680e20b8ad951c704e01130cb4b180222015a241276"
},
"plan-context-tray": {
@@ -58,7 +58,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "dc22dc9e6166e8b09eee216838fb26e2b48623185162d4654ec7f949e3bcdf15",
"sourceSha256": "da2136b0e110b8b4173c465625648cc368e54c34b33cf17bb2ded0cc82c36b2e",
"imageSha256": "ff55925f079411023442e7e7dd5ed528c331aaa3fd3fc2fbbf3b1aaf34a85718"
},
"device-editor": {
@@ -69,7 +69,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "dc22dc9e6166e8b09eee216838fb26e2b48623185162d4654ec7f949e3bcdf15",
"sourceSha256": "da2136b0e110b8b4173c465625648cc368e54c34b33cf17bb2ded0cc82c36b2e",
"imageSha256": "6b9cd281450e7782cff292f11efc58d227bedd0df86afc3be987feb2edec9dc3"
},
"device-display-preview": {
@@ -80,7 +80,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "dc22dc9e6166e8b09eee216838fb26e2b48623185162d4654ec7f949e3bcdf15",
"sourceSha256": "da2136b0e110b8b4173c465625648cc368e54c34b33cf17bb2ded0cc82c36b2e",
"imageSha256": "6f628abe6b89d7c4b352e6d73ac0b126526f6fdba5927bb774c5b884e484ca19"
},
"background-editor": {
@@ -91,7 +91,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "dc22dc9e6166e8b09eee216838fb26e2b48623185162d4654ec7f949e3bcdf15",
"sourceSha256": "da2136b0e110b8b4173c465625648cc368e54c34b33cf17bb2ded0cc82c36b2e",
"imageSha256": "a1d1a905f1410f73231d2ea5d1dfbd8ae79c2fe8b0a79cff87ebb977ac7a3103"
},
"room-card": {
@@ -102,7 +102,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "dc22dc9e6166e8b09eee216838fb26e2b48623185162d4654ec7f949e3bcdf15",
"sourceSha256": "da2136b0e110b8b4173c465625648cc368e54c34b33cf17bb2ded0cc82c36b2e",
"imageSha256": "48a5685039a5e0de2a28190d857b193c86e2e63606e3c7a8c40fc9d2b190da4e"
},
"device-info": {
@@ -113,7 +113,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "dc22dc9e6166e8b09eee216838fb26e2b48623185162d4654ec7f949e3bcdf15",
"sourceSha256": "da2136b0e110b8b4173c465625648cc368e54c34b33cf17bb2ded0cc82c36b2e",
"imageSha256": "29a342811aaedaebc54ba337792d60cb454a7cf4c3063b210f6c4bafe0a4d87b"
}
}
+4 -1
View File
@@ -15,7 +15,10 @@ const patchImports = (dir) => {
const source = readFileSync(path, 'utf8').replace(
/(from\s+['"]|import\s*\(\s*['"])(\.\.?\/[^'"]+)(['"])/g,
(match, prefix, specifier, suffix) => (
extname(specifier) ? match : `${prefix}${specifier}.js${suffix}`
// A dot inside the module NAME is not an extension: `./styles/base.styles`
// must still get `.js` (#266). Only real loader extensions pass through.
['.js', '.mjs', '.json'].includes(extname(specifier))
? match : `${prefix}${specifier}.js${suffix}`
),
);
writeFileSync(path, source);
+2 -2
View File
@@ -1999,7 +1999,7 @@ export const MUTANTS = [
because: 'unavailable keeps click/keyboard access but must never regain the blue visual hover '
+ 'which makes an offline device look live',
patches: [{
file: 'src/styles.ts',
file: 'src/styles/base.styles.ts',
find: '.dev:not(.unavail):hover {',
replace: '.dev.unavail:hover {',
}],
@@ -2165,7 +2165,7 @@ export const MUTANTS = [
because: 'touch must not leave a browser-matched device hover painted after the JS tooltip '
+ 'has already been cleared',
patches: [{
file: 'src/styles.ts',
file: 'src/styles/base.styles.ts',
find: ' :host([data-pointer-hover]) .dev:not(.unavail):hover {',
replace: ' .dev:not(.unavail):hover {',
}],
+10 -250
View File
@@ -1,259 +1,19 @@
/** Styles of the House Plan card — being split by surface (#266). */
import { css } from 'lit';
/**
* Styles of the House Plan card — assembled from the surface files (#266).
*
* The ORDER of this array is part of the cascade contract: rules of equal
* specificity resolve by position, and the golden set was accepted against
* exactly this order. Do not reorder without re-reviewing the golden set.
*/
import type { CSSResultGroup } from 'lit';
import { baseStyles } from './styles/base.styles';
import { planStyles } from './styles/plan.styles';
import { devicesStyles } from './styles/devices.styles';
import { chromeStyles } from './styles/chrome.styles';
import { dialogsStyles } from './styles/dialogs.styles';
export { planStyles, devicesStyles, chromeStyles, dialogsStyles };
const inlineStyles = css`
:host {
--hp-bg: var(--card-background-color, #16212e);
--hp-line: var(--divider-color, #2b3d4f);
--hp-txt: var(--primary-text-color, #e6edf3);
--hp-muted: var(--secondary-text-color, #8aa0b3);
--hp-accent: var(--primary-color, #3ea6ff);
--hp-on: #ffd45c;
--hp-open: #ff9f43;
/* design tokens (UI chrome only). The icon/plan scale math stays on
--icon-size/--dev-size cqw units and never uses these. */
/* spacing scale, fact-based; stray 3/5/7/9/13/14px values are unified
onto the nearest step (max +-2px, the whole point of the pass) */
--sp-1: 2px;
--sp-2: 4px;
--sp-3: 6px;
--sp-4: 8px;
--sp-5: 12px;
--sp-6: 16px;
/* px radii of dialogs/buttons/plates (the 22% badge radius is scale math) */
--rad-s: 6px;
--rad-m: 8px;
--rad-l: 12px;
/* font tiers: fine print+labels / body+buttons / title */
--fs-s: 12px;
--fs-m: 13px;
--fs-l: 15px;
/* elevation: badge / floating panel (menu, tip, toast) / dialog */
--shadow-1: 0 1px 3px rgba(0, 0, 0, 0.45);
--shadow-2: 0 6px 22px rgba(0, 0, 0, 0.45);
--shadow-3: 0 8px 30px rgba(0, 0, 0, 0.5);
}
ha-card {
overflow: visible; /* overflow:hidden breaks position:sticky on the header */
}
.empty {
padding: 40px 24px;
color: var(--hp-txt);
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
gap: var(--sp-4);
}
.empty .big {
--mdc-icon-size: 56px;
color: var(--hp-accent);
opacity: 0.7;
}
.empty .muted {
color: var(--hp-muted);
font-size: var(--fs-m);
margin: 0;
}
.empty .btn {
margin-top: var(--sp-4);
}
@keyframes fixedfloor-spin {
to { transform: rotate(360deg); }
}
:host([data-pointer-hover]) .tab:hover {
color: var(--hp-txt);
}
.spacer {
flex: 1;
}
:host([data-pointer-hover]) .btn:hover {
border-color: var(--hp-accent);
}
.bootveil {
position: absolute;
inset: 0;
z-index: 40;
display: flex;
align-items: center;
justify-content: center;
background: var(--hp-bg, #16212e);
opacity: 1;
transition: opacity 0.15s ease;
pointer-events: none;
}
.bootveil.off {
opacity: 0;
}
.bootveil .boothouse {
position: static; /* the main .zoomwrap SVG is pinned to inset:0 — not this icon */
width: 56px;
height: 56px;
fill: var(--hp-accent);
opacity: 0.85;
animation: hp-boot-pulse 1.3s ease-in-out infinite;
}
@keyframes hp-boot-pulse {
0%, 100% { opacity: 0.3; transform: scale(0.94); }
50% { opacity: 0.9; transform: scale(1); }
}
@media (prefers-reduced-motion: reduce) {
.bootveil .boothouse {
animation: none;
opacity: 0.7;
}
.recoveryoverlay {
transition: none;
}
.stage.hpsettle {
transition: none;
}
}
:host([data-pointer-hover]) .stage.mode-view .room.overlay:not(.styled):hover {
stroke: var(--hp-accent);
stroke-opacity: 1;
}
:host([data-pointer-hover]) .stage.mode-view .room.yard:not(.styled):hover {
stroke: var(--hp-accent);
stroke-opacity: 1;
}
/* 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;
}
:host([data-pointer-hover]) .stage.markup g.opening:hover .op-outline {
opacity: 0.9;
}
@keyframes hp-pulse-short {
0% { transform: scale(1); opacity: 0.55; }
70% { opacity: 0.22; }
100% { transform: scale(var(--ripple-scale, 1.5)); opacity: 0; }
}
/* Alternate identity: a rapid retrigger restarts the browser timeline. */
@keyframes hp-pulse-short-b {
0% { transform: scale(1); opacity: 0.55; }
70% { opacity: 0.22; }
100% { transform: scale(var(--ripple-scale, 1.5)); opacity: 0; }
}
@keyframes hp-pulse-continuous {
0% { transform: scale(1); opacity: 0.55; }
65% { opacity: 0.18; }
100% { transform: scale(var(--ripple-scale, 1.5)); opacity: 0; }
}
@keyframes hp-pulse-alarm {
0% { transform: scale(1); opacity: 0.72; }
100% { transform: scale(1.5); opacity: 0; }
}
/* Space changes keep their direction, but travel only a few percent: the
motion explains what changed without making the whole plan fly around. */
@keyframes hp-slide-left {
0% { transform: translateX(4%); opacity: 0.72; }
100% { transform: translateX(0); opacity: 1; }
}
@keyframes hp-slide-right {
0% { transform: translateX(-4%); opacity: 0.72; }
100% { transform: translateX(0); opacity: 1; }
}
:host([data-pointer-hover]) .rlgearbtn:hover { opacity: 1; filter: brightness(1.18); }
:host([data-pointer-hover]) .rlgear:hover { opacity: 1; }
:host([data-pointer-hover]) .stage.mode-view .rlgo:hover { opacity: 1; }
:host([data-pointer-hover]) .ctrlopt:hover {
background: var(--secondary-background-color, rgba(128,128,128,0.15));
}
:host([data-pointer-hover]) .stage.markup .roomlabel:hover .rlhandle { display: block; }
:host([data-pointer-hover]) .bdframe .bdhandle:hover + .bdknob {
fill: #fff;
stroke: var(--hp-accent);
}
:host([data-pointer-hover]) .dtframe .dthandle:hover + .dtknob {
fill: #fff;
stroke: var(--hp-accent);
}
:host([data-pointer-hover]) .modetab .closex:hover { opacity: 1; }
@keyframes hp-sunfade-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes hp-sunfade-out {
from { opacity: 1; }
to { opacity: 0; }
}
/* Interaction wins ordinary state colours. Alarm keeps priority through
the more-specific rule below. Unavailable has no visual hover. */
:host([data-pointer-hover]) .dev:not(.unavail):hover {
--device-face-bg: #0C82F0;
--device-face-fg: light-dark(#fff, #252525);
--device-shell-stroke: var(--device-shell-base-stroke);
}
:host([data-pointer-hover]) .dev.theme-light:not(.unavail):hover { --device-face-fg: #fff; }
:host([data-pointer-hover]) .dev.theme-dark:not(.unavail):hover { --device-face-fg: #252525; }
:host([data-pointer-hover]) .dev:hover,
.dev:focus-visible { z-index: 5; }
/* Alert stays above focus, selection, hover and ordinary semantic paint. */
.dev.alarm,
:host([data-pointer-hover]) .dev.alarm:hover,
.dev.alarm:focus-visible {
--device-face-bg: #F0410C;
--device-face-fg: light-dark(#fff, #252525);
--device-shell-stroke: #F0410C;
}
:host([data-pointer-hover]) .tab:hover .tabedit {
opacity: 0.9;
}
:host([data-pointer-hover]) .rszhandle:hover + .rszicon .rszink { stroke-width: 3; }
:host([data-pointer-hover]) .rszhandle.disabled:hover + .rszicon .rszink { stroke-width: 2; }
:host([data-pointer-hover]) .furnitem:hover { background: rgba(127, 127, 127, 0.18); }
:host([data-pointer-hover]) .cand:hover {
background: rgba(127, 127, 127, 0.15);
}
:host([data-pointer-hover]) .pdftag .x:hover {
color: #ff7a5c;
}
.sr-only {
position: absolute !important;
width: 1px !important;
height: 1px !important;
padding: 0 !important;
margin: -1px !important;
overflow: hidden !important;
clip: rect(0, 0, 0, 0) !important;
white-space: nowrap !important;
border: 0 !important;
}
:host([data-pointer-hover]) .rrow .ract:hover { color: var(--hp-txt); }
:host([data-pointer-hover]) .rrow .ract.del:hover { color: #ff7a5c; }
:host([data-pointer-hover]) .aboutlink:hover { text-decoration: underline; }
:host([data-pointer-hover]) .menu .it:hover {
background: rgba(127, 127, 127, 0.15);
}
.toast {
position: fixed;
pointer-events: none;
left: 50%;
bottom: 22px;
transform: translateX(-50%);
background: var(--hp-bg);
border: 1px solid var(--hp-accent);
color: var(--hp-txt);
padding: var(--sp-4) var(--sp-6);
border-radius: var(--rad-l);
font-size: var(--fs-m);
box-shadow: var(--shadow-2);
z-index: 120;
max-width: 90vw;
}
`;
export { baseStyles, planStyles, devicesStyles, chromeStyles, dialogsStyles };
export const cardStyles: CSSResultGroup = [
inlineStyles, planStyles, devicesStyles, chromeStyles, dialogsStyles,
baseStyles, planStyles, devicesStyles, chromeStyles, dialogsStyles,
];
+248
View File
@@ -0,0 +1,248 @@
/** Host, variables, resets and cross-surface rules (#266, split from styles.ts). */
import { css } from 'lit';
export const baseStyles = css`
:host {
--hp-bg: var(--card-background-color, #16212e);
--hp-line: var(--divider-color, #2b3d4f);
--hp-txt: var(--primary-text-color, #e6edf3);
--hp-muted: var(--secondary-text-color, #8aa0b3);
--hp-accent: var(--primary-color, #3ea6ff);
--hp-on: #ffd45c;
--hp-open: #ff9f43;
/* design tokens (UI chrome only). The icon/plan scale math stays on
--icon-size/--dev-size cqw units and never uses these. */
/* spacing scale, fact-based; stray 3/5/7/9/13/14px values are unified
onto the nearest step (max +-2px, the whole point of the pass) */
--sp-1: 2px;
--sp-2: 4px;
--sp-3: 6px;
--sp-4: 8px;
--sp-5: 12px;
--sp-6: 16px;
/* px radii of dialogs/buttons/plates (the 22% badge radius is scale math) */
--rad-s: 6px;
--rad-m: 8px;
--rad-l: 12px;
/* font tiers: fine print+labels / body+buttons / title */
--fs-s: 12px;
--fs-m: 13px;
--fs-l: 15px;
/* elevation: badge / floating panel (menu, tip, toast) / dialog */
--shadow-1: 0 1px 3px rgba(0, 0, 0, 0.45);
--shadow-2: 0 6px 22px rgba(0, 0, 0, 0.45);
--shadow-3: 0 8px 30px rgba(0, 0, 0, 0.5);
}
ha-card {
overflow: visible; /* overflow:hidden breaks position:sticky on the header */
}
.empty {
padding: 40px 24px;
color: var(--hp-txt);
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
gap: var(--sp-4);
}
.empty .big {
--mdc-icon-size: 56px;
color: var(--hp-accent);
opacity: 0.7;
}
.empty .muted {
color: var(--hp-muted);
font-size: var(--fs-m);
margin: 0;
}
.empty .btn {
margin-top: var(--sp-4);
}
@keyframes fixedfloor-spin {
to { transform: rotate(360deg); }
}
:host([data-pointer-hover]) .tab:hover {
color: var(--hp-txt);
}
.spacer {
flex: 1;
}
:host([data-pointer-hover]) .btn:hover {
border-color: var(--hp-accent);
}
.bootveil {
position: absolute;
inset: 0;
z-index: 40;
display: flex;
align-items: center;
justify-content: center;
background: var(--hp-bg, #16212e);
opacity: 1;
transition: opacity 0.15s ease;
pointer-events: none;
}
.bootveil.off {
opacity: 0;
}
.bootveil .boothouse {
position: static; /* the main .zoomwrap SVG is pinned to inset:0 — not this icon */
width: 56px;
height: 56px;
fill: var(--hp-accent);
opacity: 0.85;
animation: hp-boot-pulse 1.3s ease-in-out infinite;
}
@keyframes hp-boot-pulse {
0%, 100% { opacity: 0.3; transform: scale(0.94); }
50% { opacity: 0.9; transform: scale(1); }
}
@media (prefers-reduced-motion: reduce) {
.bootveil .boothouse {
animation: none;
opacity: 0.7;
}
.recoveryoverlay {
transition: none;
}
.stage.hpsettle {
transition: none;
}
}
:host([data-pointer-hover]) .stage.mode-view .room.overlay:not(.styled):hover {
stroke: var(--hp-accent);
stroke-opacity: 1;
}
:host([data-pointer-hover]) .stage.mode-view .room.yard:not(.styled):hover {
stroke: var(--hp-accent);
stroke-opacity: 1;
}
/* 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;
}
:host([data-pointer-hover]) .stage.markup g.opening:hover .op-outline {
opacity: 0.9;
}
@keyframes hp-pulse-short {
0% { transform: scale(1); opacity: 0.55; }
70% { opacity: 0.22; }
100% { transform: scale(var(--ripple-scale, 1.5)); opacity: 0; }
}
/* Alternate identity: a rapid retrigger restarts the browser timeline. */
@keyframes hp-pulse-short-b {
0% { transform: scale(1); opacity: 0.55; }
70% { opacity: 0.22; }
100% { transform: scale(var(--ripple-scale, 1.5)); opacity: 0; }
}
@keyframes hp-pulse-continuous {
0% { transform: scale(1); opacity: 0.55; }
65% { opacity: 0.18; }
100% { transform: scale(var(--ripple-scale, 1.5)); opacity: 0; }
}
@keyframes hp-pulse-alarm {
0% { transform: scale(1); opacity: 0.72; }
100% { transform: scale(1.5); opacity: 0; }
}
/* Space changes keep their direction, but travel only a few percent: the
motion explains what changed without making the whole plan fly around. */
@keyframes hp-slide-left {
0% { transform: translateX(4%); opacity: 0.72; }
100% { transform: translateX(0); opacity: 1; }
}
@keyframes hp-slide-right {
0% { transform: translateX(-4%); opacity: 0.72; }
100% { transform: translateX(0); opacity: 1; }
}
:host([data-pointer-hover]) .rlgearbtn:hover { opacity: 1; filter: brightness(1.18); }
:host([data-pointer-hover]) .rlgear:hover { opacity: 1; }
:host([data-pointer-hover]) .stage.mode-view .rlgo:hover { opacity: 1; }
:host([data-pointer-hover]) .ctrlopt:hover {
background: var(--secondary-background-color, rgba(128,128,128,0.15));
}
:host([data-pointer-hover]) .stage.markup .roomlabel:hover .rlhandle { display: block; }
:host([data-pointer-hover]) .bdframe .bdhandle:hover + .bdknob {
fill: #fff;
stroke: var(--hp-accent);
}
:host([data-pointer-hover]) .dtframe .dthandle:hover + .dtknob {
fill: #fff;
stroke: var(--hp-accent);
}
:host([data-pointer-hover]) .modetab .closex:hover { opacity: 1; }
@keyframes hp-sunfade-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes hp-sunfade-out {
from { opacity: 1; }
to { opacity: 0; }
}
/* Interaction wins ordinary state colours. Alarm keeps priority through
the more-specific rule below. Unavailable has no visual hover. */
:host([data-pointer-hover]) .dev:not(.unavail):hover {
--device-face-bg: #0C82F0;
--device-face-fg: light-dark(#fff, #252525);
--device-shell-stroke: var(--device-shell-base-stroke);
}
:host([data-pointer-hover]) .dev.theme-light:not(.unavail):hover { --device-face-fg: #fff; }
:host([data-pointer-hover]) .dev.theme-dark:not(.unavail):hover { --device-face-fg: #252525; }
:host([data-pointer-hover]) .dev:hover,
.dev:focus-visible { z-index: 5; }
/* Alert stays above focus, selection, hover and ordinary semantic paint. */
.dev.alarm,
:host([data-pointer-hover]) .dev.alarm:hover,
.dev.alarm:focus-visible {
--device-face-bg: #F0410C;
--device-face-fg: light-dark(#fff, #252525);
--device-shell-stroke: #F0410C;
}
:host([data-pointer-hover]) .tab:hover .tabedit {
opacity: 0.9;
}
:host([data-pointer-hover]) .rszhandle:hover + .rszicon .rszink { stroke-width: 3; }
:host([data-pointer-hover]) .rszhandle.disabled:hover + .rszicon .rszink { stroke-width: 2; }
:host([data-pointer-hover]) .furnitem:hover { background: rgba(127, 127, 127, 0.18); }
:host([data-pointer-hover]) .cand:hover {
background: rgba(127, 127, 127, 0.15);
}
:host([data-pointer-hover]) .pdftag .x:hover {
color: #ff7a5c;
}
.sr-only {
position: absolute !important;
width: 1px !important;
height: 1px !important;
padding: 0 !important;
margin: -1px !important;
overflow: hidden !important;
clip: rect(0, 0, 0, 0) !important;
white-space: nowrap !important;
border: 0 !important;
}
:host([data-pointer-hover]) .rrow .ract:hover { color: var(--hp-txt); }
:host([data-pointer-hover]) .rrow .ract.del:hover { color: #ff7a5c; }
:host([data-pointer-hover]) .aboutlink:hover { text-decoration: underline; }
:host([data-pointer-hover]) .menu .it:hover {
background: rgba(127, 127, 127, 0.15);
}
.toast {
position: fixed;
pointer-events: none;
left: 50%;
bottom: 22px;
transform: translateX(-50%);
background: var(--hp-bg);
border: 1px solid var(--hp-accent);
color: var(--hp-txt);
padding: var(--sp-4) var(--sp-6);
border-radius: var(--rad-l);
font-size: var(--fs-m);
box-shadow: var(--shadow-2);
z-index: 120;
max-width: 90vw;
}
`;
+78
View File
@@ -0,0 +1,78 @@
/** #266: invariants of the styles split. */
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { cardStyles, baseStyles, planStyles, devicesStyles, chromeStyles, dialogsStyles }
from '../test-build/styles.js';
const FILES = ['base', 'plan', 'devices', 'chrome', 'dialogs'];
const sourceOf = (name) =>
readFileSync(new URL(`../src/styles/${name}.styles.ts`, import.meta.url), 'utf8');
const selectorsOf = (tsSource) => {
// Only the css`` payload: the TS import/export scaffolding is not CSS.
const css = [...tsSource.matchAll(/css`([\s\S]*?)`/g)].map((m) => m[1]).join('\n');
const text = css.replace(/\/\*[\s\S]*?\*\//g, '');
const out = new Set();
const walk = (chunk, scope) => {
let i = 0;
while (i < chunk.length) {
const open = chunk.indexOf('{', i);
if (open === -1) break;
const header = chunk.slice(i, open).replace(/\s+/g, ' ').trim();
let depth = 1, j = open + 1;
while (j < chunk.length && depth > 0) {
if (chunk[j] === '{') depth++;
else if (chunk[j] === '}') depth--;
j++;
}
if (header.startsWith('@media') || header.startsWith('@supports')) {
walk(chunk.slice(open + 1, j - 1), `${scope}${header} :: `);
} else if (!header.startsWith('@')) {
// The key includes the media scope: the SAME selector inside and
// outside a wrapper are different cascade entries by construction
// (a multi-zone @media block lands in base as a whole).
for (const sel of header.split(',')) out.add(`${scope}${sel.trim()}`);
}
i = j;
}
};
walk(text, '');
return out;
};
test('issue 266 the aggregator is exactly the five surface files in the cascade order', () => {
assert.deepEqual(cardStyles, [baseStyles, planStyles, devicesStyles, chromeStyles, dialogsStyles],
'the cascade order is a contract — the golden set was accepted against it');
});
test('issue 266 surface files do not share a single selector', () => {
const sets = FILES.map((name) => ({ name, set: selectorsOf(sourceOf(name)) }));
const clashes = [];
for (let i = 0; i < sets.length; i++) {
for (let j = i + 1; j < sets.length; j++) {
for (const sel of sets[i].set) {
if (sets[j].set.has(sel)) clashes.push(`${sel} (${sets[i].name} ∩ ${sets[j].name})`);
}
}
}
// Spec §1.3.2: every allowed exception is named with its reason.
const allowed = [
// The `:host([data-pointer-hover]) .dev:hover, .dev:focus-visible` GROUP
// is cross-surface (host gate + device selector) and therefore lives in
// base per spec §1.1, while the plain `.dev:focus-visible` outline rule
// stays with its surface. Their source order (group first, outline
// second) is preserved by the [base, …, devices] assembly order — golden
// holds the cascade.
'.dev:focus-visible (base ∩ devices)',
];
assert.deepEqual(clashes, allowed);
});
test('issue 266 the media wrappers survived the move', () => {
const all = FILES.map((name) => sourceOf(name)).join('\n');
assert.equal((all.match(/@media \(forced-colors: active\)/g) || []).length, 2,
'both forced-colors blocks must survive — golden never emulates them');
assert.equal((all.match(/@media \(prefers-reduced-motion: reduce\)/g) || []).length, 10,
'all ten reduced-motion blocks must survive — golden always shoots reduced');
});
+1 -1
View File
@@ -8,7 +8,7 @@
"resolveJsonModule": true
},
"include": [
"src/color.ts", "src/logic.ts", "src/glow-blend.ts", "src/grid-scale.ts", "src/device-visual.ts", "src/device-pulse.ts", "src/device-presentation.ts", "src/device-marker-geometry.ts",
"src/color.ts", "src/styles.ts", "src/styles/*.styles.ts", "src/logic.ts", "src/glow-blend.ts", "src/grid-scale.ts", "src/device-visual.ts", "src/device-pulse.ts", "src/device-presentation.ts", "src/device-marker-geometry.ts",
"src/device-face.ts", "src/device-toggle.ts", "src/marker-toggle-entity.ts", "src/activity-runtime.ts",
"src/ha-binding-status.ts",
"src/integration-provider.ts", "src/vacuum.ts",