fix: host-gated groups belong to their owner surface; split mixed media wrappers (#266)

CI-шард смока поймал сдвиг каскада, невидимый golden-набору:
smoke_device_icon_design — alert-shell стал серым, dark-unavailable core
светлым. Причина: классификатор считал ведущий :host(...) владельцем
селектора и уносил гейтнутые группы устройств в base — ВПЕРЁД их поверхности,
меняя победителя при равной специфичности. Теперь :host-префикс — гейт, а не
владелец: владелец — первый значимый токен после него; смешанные @media
режутся на последовательные по-зонные копии обёртки (reduced-motion обёрток
стало 12 поверх тех же правил 10 исходных — юнит заякорен на факт, сверка
scope-ключом доказывает, что ни одно правило обёртку не потеряло). Два
мутантных якоря вернулись в devices.styles.ts; исключение юнита
непересечения опустело.

Гейты: refactor-proof diff пуст · golden 129/129 без переприёмки · смоки
device_icon_design, plan_snap_overlay, preloader OK · npm test 1318/0.

Issue: #266
User-Visible: no
This commit is contained in:
Codex
2026-08-26 01:55:00 +03:00
parent 0d62a48b1e
commit 0b782ee714
10 changed files with 333 additions and 273 deletions
File diff suppressed because one or more lines are too long
+88 -84
View File
File diff suppressed because one or more lines are too long
+58 -7
View File
@@ -93,13 +93,20 @@ while (i < cssBody.length) {
}
const zoneOfSelector = (header) => {
const first = (sel) => {
const t = sel.trim().match(/^[.:#\[]?[\w-]+/);
// A leading :host(...) compound GATES a rule, it does not own it: the owner
// is the first meaningful token after the gate. Classifying such groups
// into base moved them AHEAD of their surface in the cascade and flipped
// equal-specificity winners (smoke_device_icon_design caught alert shells
// and dark-unavailable cores) — the owner surface keeps their position.
const owner = (sel) => {
let rest = sel.trim().replace(/^:host(\([^)]*\))?\s*/, '');
if (!rest) return ':host';
const t = rest.match(/^[.:#\[]?[\w-]+/);
return t ? t[0] : null;
};
const zones = new Set();
for (const sel of header.split(',')) {
const token = first(sel);
const token = owner(sel);
if (!token) continue;
const zone = T[token];
if (!zone) throw new Error(`unclassified token «${token}» in «${header.slice(0, 80)}»`);
@@ -118,10 +125,54 @@ for (const block of blocks) {
zone = /^(hp-dev|dev|pulse|vac)/.test(name) ? 'devices'
: /^(hp-spin|spin|fade|toast)/.test(name) ? 'base' : 'base';
} else if (/^@(media|supports)/.test(block.header)) {
// classify by the first inner selector
const inner = block.text.slice(block.text.indexOf('{') + 1);
const sel = inner.slice(0, inner.indexOf('{')).replace(/\/\*[\s\S]*?\*\//g, '').trim();
zone = zoneOfSelector(sel);
// Classify by ALL inner rules. A single-zone wrapper moves whole; a mixed
// one is split into consecutive per-zone copies of the wrapper so every
// rule stays with its owner surface without crossing zone order.
const openAt = block.text.indexOf('{');
const inner = block.text.slice(openAt + 1, block.text.lastIndexOf('}'));
const innerBlocks = [];
{
let k = 0, pend = '';
while (k < inner.length) {
const rest2 = inner.slice(k);
const cm2 = rest2.match(/^\s*\/\*[\s\S]*?\*\//);
if (cm2 && !rest2.slice(0, rest2.indexOf('/*')).includes('{')) {
pend += cm2[0]; k += cm2[0].length; continue;
}
const o2 = inner.indexOf('{', k);
if (o2 === -1) break;
const h2 = inner.slice(k, o2);
let d2 = 1, j2 = o2 + 1;
while (j2 < inner.length && d2 > 0) {
if (inner[j2] === '{') d2++;
else if (inner[j2] === '}') d2--;
j2++;
}
innerBlocks.push({ text: pend + h2 + inner.slice(o2, j2), header: h2.trim() });
pend = '';
k = j2;
}
}
const innerZones = innerBlocks.map((b) => zoneOfSelector(b.header));
const uniq = [...new Set(innerZones)];
if (uniq.length <= 1) {
zone = uniq[0] ?? 'base';
} else {
const wrapperHeader = block.text.slice(0, openAt).trimEnd();
let g = 0;
while (g < innerBlocks.length) {
const zg = innerZones[g];
let h = g;
while (h < innerBlocks.length && innerZones[h] === zg) h++;
const bodyPart = innerBlocks.slice(g, h).map((b) => b.text).join('');
const copy = `${wrapperHeader} {${bodyPart}\n }`;
ZONES[zg].push(copy);
g = h;
}
// mark handled: skip the shared push below
block.zone = 'SPLIT';
continue;
}
} else {
zone = zoneOfSelector(block.header);
}
+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/base.styles.ts',
file: 'src/styles/devices.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/base.styles.ts',
file: 'src/styles/devices.styles.ts',
find: ' :host([data-pointer-hover]) .dev:not(.unavail):hover {',
replace: ' .dev:not(.unavail):hover {',
}],
-83
View File
@@ -61,15 +61,9 @@ export const baseStyles = css`
@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;
@@ -102,30 +96,6 @@ export const baseStyles = css`
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; }
@@ -157,22 +127,6 @@ export const baseStyles = css`
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; }
@@ -181,37 +135,6 @@ export const baseStyles = css`
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;
@@ -223,12 +146,6 @@ export const baseStyles = css`
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;
+10
View File
@@ -31,6 +31,9 @@ export const chromeStyles = css`
transition: 0.15s;
font-family: inherit;
}
:host([data-pointer-hover]) .tab:hover {
color: var(--hp-txt);
}
.tab.active {
background: var(--hp-accent);
color: var(--text-primary-color, #fff);
@@ -166,6 +169,7 @@ export const chromeStyles = css`
cursor: pointer;
border-radius: var(--rad-s);
}
:host([data-pointer-hover]) .modetab .closex:hover { opacity: 1; }
.editbar .barclose {
box-sizing: border-box;
width: 40px;
@@ -218,6 +222,9 @@ export const chromeStyles = css`
margin-left: var(--sp-3);
opacity: 0.4;
}
:host([data-pointer-hover]) .tab:hover .tabedit {
opacity: 0.9;
}
.tab.tabadd {
padding: var(--sp-3) var(--sp-4);
}
@@ -356,6 +363,9 @@ export const chromeStyles = css`
--mdc-icon-size: 16px;
color: var(--hp-muted);
}
:host([data-pointer-hover]) .menu .it:hover {
background: rgba(127, 127, 127, 0.15);
}
.menu .it.all {
color: var(--hp-accent);
font-weight: 600;
+19
View File
@@ -324,6 +324,17 @@ export const devicesStyles = css`
--device-face-fg: #252525;
--device-shell-stroke-ratio: .025;
}
/* 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; }
.dev.unavail {
opacity: 0.35;
--device-face-bg: #B5BAC1;
@@ -391,6 +402,14 @@ export const devicesStyles = css`
.dev:not(.on):not(.open):not(.alarm):not(.lock-locked):not(.lock-unlocked):not(.unavail):focus-visible {
--device-face-fg: #0C82F0;
}
/* 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;
}
.dev.theme-light.alarm { --device-face-fg: #fff; }
.dev.theme-dark.alarm {
--device-face-fg: #252525;
+21
View File
@@ -48,6 +48,9 @@ export const dialogsStyles = css`
.btn ha-icon {
--mdc-icon-size: 17px;
}
:host([data-pointer-hover]) .btn:hover {
border-color: var(--hp-accent);
}
.btn.on {
background: var(--hp-accent);
color: var(--text-primary-color, #fff);
@@ -111,6 +114,11 @@ export const dialogsStyles = css`
--mdc-icon-size: 44px;
color: var(--hp-accent);
}
@media (prefers-reduced-motion: reduce) {
.recoveryoverlay {
transition: none;
}
}
.oplock {
--oplock-size: calc(var(--icon-size, 2.5cqw) * 0.62);
--oplock-core-size: calc(var(--oplock-size) / 1.26875);
@@ -239,6 +247,9 @@ export const dialogsStyles = css`
border: 0; background: transparent; color: var(--hp-txt);
padding: var(--sp-3) var(--sp-4); border-radius: var(--rad-s); cursor: pointer; font-family: inherit; font-size: var(--fs-m);
}
:host([data-pointer-hover]) .ctrlopt:hover {
background: var(--secondary-background-color, rgba(128,128,128,0.15));
}
.ctrlopt .sub { color: var(--hp-muted); font-size: var(--fs-s); margin-left: auto; }
.ctrlopt ha-icon { --mdc-icon-size: 16px; }
.ctrlstates { display: flex; flex-direction: column; gap: var(--sp-2); }
@@ -669,6 +680,7 @@ export const dialogsStyles = css`
text-align: center;
cursor: pointer;
}
:host([data-pointer-hover]) .furnitem:hover { background: rgba(127, 127, 127, 0.18); }
.furnitem.on {
border-color: var(--hp-accent);
background: rgba(38, 166, 154, 0.18);
@@ -749,6 +761,9 @@ export const dialogsStyles = css`
border-radius: var(--rad-s);
font-size: var(--fs-m);
}
:host([data-pointer-hover]) .cand:hover {
background: rgba(127, 127, 127, 0.15);
}
.cand.sel {
background: var(--hp-accent);
color: var(--text-primary-color, #fff);
@@ -807,6 +822,9 @@ export const dialogsStyles = css`
cursor: pointer;
color: var(--hp-muted);
}
:host([data-pointer-hover]) .pdftag .x:hover {
color: #ff7a5c;
}
.entlist {
display: flex;
flex-direction: column;
@@ -919,6 +937,8 @@ export const dialogsStyles = css`
color: var(--hp-muted);
cursor: pointer;
}
:host([data-pointer-hover]) .rrow .ract:hover { color: var(--hp-txt); }
:host([data-pointer-hover]) .rrow .ract.del:hover { color: #ff7a5c; }
.gsrow .gsl {
min-width: 150px;
font-size: var(--fs-m);
@@ -1037,6 +1057,7 @@ export const dialogsStyles = css`
font-size: var(--fs-m);
padding: var(--sp-1) 0;
}
:host([data-pointer-hover]) .aboutlink:hover { text-decoration: underline; }
.aboutlink ha-icon { --mdc-icon-size: 18px; line-height: 1; }
hp-dialog .body {
padding: var(--sp-5) var(--sp-6);
+37
View File
@@ -159,6 +159,11 @@ export const planStyles = css`
.stage.hpsettle {
transition: height 0.25s ease;
}
@media (prefers-reduced-motion: reduce) {
.stage.hpsettle {
transition: none;
}
}
.zoomctl {
display: inline-flex;
gap: var(--sp-1);
@@ -359,11 +364,19 @@ export const planStyles = css`
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);
@@ -405,6 +418,13 @@ export const planStyles = css`
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 */
.op-leaf {
transition: transform 0.6s ease;
@@ -422,6 +442,9 @@ export const planStyles = css`
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 {
@@ -532,6 +555,7 @@ export const planStyles = css`
z-index: 2;
}
.rlgearbtn { transition: opacity 0.15s, filter 0.15s; }
:host([data-pointer-hover]) .rlgearbtn:hover { opacity: 1; filter: brightness(1.18); }
.rlgearbtn ha-icon { --mdc-icon-size: calc(var(--gear-h) * 0.55); display: inline-flex; }
.rlgear {
--mdc-icon-size: 0.9em;
@@ -541,6 +565,7 @@ export const planStyles = css`
cursor: pointer;
pointer-events: auto;
}
:host([data-pointer-hover]) .rlgear:hover { opacity: 1; }
.rlgo {
--mdc-icon-size: 0.85em;
display: inline-flex;
@@ -550,6 +575,7 @@ export const planStyles = css`
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);
@@ -588,6 +614,7 @@ export const planStyles = css`
.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; }
.roomlabel:active { cursor: grabbing; }
.measurelayer {
@@ -749,6 +776,10 @@ export const planStyles = css`
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) */
@@ -795,6 +826,10 @@ export const planStyles = css`
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; }
@@ -1311,7 +1346,9 @@ export const planStyles = css`
}
.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,
+10 -13
View File
@@ -56,23 +56,20 @@ test('issue 266 surface files do not share a single selector', () => {
}
}
}
// 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);
// Spec §1.3.2: the exception list is empty — a leading :host(...) gate is
// not ownership, so gated device groups live with their surface and no
// selector is shared between files.
assert.deepEqual(clashes, []);
});
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');
// 10 source wrappers; two of them were MIXED-zone and are split into
// per-zone copies by the generator (cascade fix), hence 12 wrappers over
// the same 10 wrappers' worth of rules — the scope-keyed refactor diff
// proves no rule lost its wrapper.
assert.equal((all.match(/@media \(prefers-reduced-motion: reduce\)/g) || []).length, 12,
'reduced-motion wrappers must survive — golden always shoots reduced');
});