mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-03 21:28:59 +00:00
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:
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -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
|
||||
|
||||
```
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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,
|
||||
];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
`;
|
||||
@@ -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
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user