Files
houseplan-card/demo/smoke_dialog_help_clipping.mjs
T
Claude 8dc60aec6e fix(dialogs): находки код-ревью #600 r1 — сегмент без разрыва слов, радиус без блокировки Save, свидетель дефекта №5
M1. Подпись сегмента больше не рвётся посреди слова: колонка `.hpf-seg label`
не сжимается ниже самого длинного слова (`min-width: fit-content`, для
браузеров без него — прежний `0` строкой выше), слова переносятся только по
пробелам (`overflow-wrap: normal`), не поместившийся сегмент переносится на
вторую строку целыми кнопками (`flex-wrap`). Новый свидетель
demo/smoke_dialog_segments_i18n.mjs — четыре диалога × en/ru/de/fr: у каждой
подписи число строк (по Range текстового узла: span — flex-элемент, у блока
один прямоугольник даже при переносе) не больше числа слов, текст внутри
кнопки. На разметке r1 краснеет на en/ru/fr комнаты («Temperature» в 2
строки, «По температуре» в 3). Мутант form-kit-segment-breaks-words
возвращает прежние две строки CSS. ACCEPTANCE.md — строка про подписи
сегмента переписана.

M2. Glow radius — не условие Save: как на dev, нечисло или неположительное
значение при сохранении молча становится общим радиусом. Удалены
glowRadiusValid, MarkerProblem marker.error_glow_radius, ключ в четырёх
локалях словаря settings, aria-invalid у поля; смок device_settings_form
утверждает обратное прежнему (badRadiusDoesNotBlockSave,
zeroRadiusKeepsSaveEnabled) и проверяет «Review N fields» на привязке в
create-режиме. Changelog EN/RU и USER-GUIDE ru/en больше не называют радиус
среди ошибок; таблица устройства говорит про молчаливый общий радиус.

M3. Свидетель дефекта №5 (поповер «?» обрезается границей диалога комнаты):
demo/smoke_dialog_help_clipping.mjs — короткое окно 900×620, диалог комнаты
скроллится, «?» нижней карточки прижат к краю; подсказка выходит за край
скроллера и остаётся hit-test'ируемой во всех пяти точках (elementsFromPoint
сквозь теневые корни — обрезанная часть не hit-test'ится). Четыре ветки:
нативный <dialog> и ha-dialog × Popover API и портальный fallback; ветка
ha-dialog — заглушка с ловушками настоящей mwc-поверхности (transform +
overflow: hidden + собственный скроллер), portal вне поверхности проверен
явно. Настоящий ha-dialog (#505, загрузка колеса HA-frontend) — отдельная
диагностическая приёмка владельца; так и записано в ACCEPTANCE.md, где
появилась таблица AC8 «девять дефектов — свидетель на каждый».

M4. ACCEPTANCE.md: эталонов 13, а не 11 — одиннадцать из ТЗ (список AC10
унаследован от #598 без комнаты) плюс две room-temperature-dialog-*.

L1. USER-GUIDE.ru.md: испорченная строка таблицы настроек пространства
(хвост удалённой строки про декоративный слой) — убран; пояснение «в
редакторе подложки остаются видимыми» вернулось в строку «Видимые слои»;
строка про солнце говорит «слой Двери, окна и ворота выключен» вместо
«Скрыть проёмы включено».

Фикстуры листа диалогов набора пересняты (панель — байт в байт).

Бюджет: initial 289 852 / 290 900; lazy editor 242 989 / 243 900.

Гейты: typecheck, build, bundle:sync (три копии), npm test 2794 pass по
четырём частям, no-new-any, mutation-gate --check, check-inputs --coverage,
смоки: segments_i18n, help_clipping, четыре формы, config_parity,
help_affordance, room_settings, settings_dialog_cards.

Issue: #600
User-Visible: yes
2026-09-20 16:37:31 +03:00

135 lines
8.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// #600 AC8, дефект №5 («поповер «?» обрезается границей диалога комнаты»),
// ревью r1 M3: свидетель, что подсказка «?» в диалоге комнаты не режется ни
// скроллером диалога, ни поверхностью ha-dialog.
//
// Проверяется исполнением, а не чтением: подсказка открывается у карточки,
// прижатой к нижнему краю короткого окна (диалог скроллится), и каждый угол её
// прямоугольника hit-test'ом (elementsFromPoint сквозь теневые корни) попадает
// в саму подсказку — обрезанная часть элемента не hit-test'ится. Четыре
// комбинации: нативный <dialog> и ветка ha-dialog × Popover API и портальный
// fallback. Ветка ha-dialog берётся заглушкой с теми же ловушками, что у
// настоящей mwc-поверхности: transform на поверхности (ловит position: fixed) и
// overflow: hidden с собственным скроллером содержимого. Настоящий ha-dialog
// (#505) требует загрузки колеса HA-frontend — это отдельная приёмка владельца.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 900, height: 620 });
const out = await page.evaluate(async () => {
const o = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const frame = () => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
const upd = async () => { c.requestUpdate(); await c.updateComplete; await frame(); await new Promise((r) => setTimeout(r, 40)); };
const dlg = () => sr().querySelector('hp-dialog[data-kind="room"]');
const helpOf = (key) => dlg()?.querySelector(`hp-help[data-help-key="${key}"]`);
const surfaceOf = (key) => helpOf(key)?.shadowRoot?.querySelector('.tooltip')
|| dlg()?.shadowRoot?.querySelector('[data-hp-overlay="help"]')?.shadowRoot?.querySelector('.tooltip');
// hit-test сквозь теневые корни: есть ли в точке именно .tooltip
const tooltipAt = (x, y) => {
const seen = new Set();
const visit = (root) => {
for (const el of root.elementsFromPoint(x, y)) {
if (seen.has(el)) continue; seen.add(el);
if (el.classList?.contains('tooltip')) return true;
if (el.shadowRoot && visit(el.shadowRoot)) return true;
}
return false;
};
return visit(document);
};
const scroller = () => dlg()?.shadowRoot?.querySelector('.content')
|| dlg()?.shadowRoot?.querySelector('ha-dialog')?.shadowRoot?.querySelector('.content');
const probe = async (label, key) => {
const help = helpOf(key);
// прижать «?» к нижнему краю окна — там подсказка обязана уйти вверх, а не под срез
help.scrollIntoView({ block: 'end' });
await frame();
const trigger = help.shadowRoot.querySelector('.trigger');
trigger.click(); await frame(); await new Promise((r) => setTimeout(r, 60));
const surface = surfaceOf(key);
const r = surface?.getBoundingClientRect();
const inViewport = !!r && r.width > 40 && r.height > 20 && r.left >= 0 && r.top >= 0
&& r.right <= innerWidth && r.bottom <= innerHeight;
// 12 px от края: у подсказки скруглённые углы (radius 9), точка в 2 px от угла — за скруглением
const corners = r ? [[r.left + 12, r.top + 12], [r.right - 12, r.top + 12], [r.left + 12, r.bottom - 12], [r.right - 12, r.bottom - 12], [(r.left + r.right) / 2, (r.top + r.bottom) / 2]] : [];
const hits = corners.map(([x, y]) => tooltipAt(x, y));
o[`${label}_open`] = trigger.getAttribute('aria-expanded') === 'true' && !!surface;
o[`${label}_insideViewport`] = inViewport;
o[`${label}_notClipped`] = hits.length === 5 && hits.every(Boolean);
// подсказка стоит ВНЕ скроллера содержимого: её низ ниже или верх выше «?» так, что часть
// прямоугольника лежит за пределами видимой области скроллера — и всё равно hit-test'ится
const sc = scroller()?.getBoundingClientRect();
o[`${label}_crossesScrollerEdge`] = !!r && !!sc && (r.top < sc.top || r.bottom > sc.bottom);
trigger.click(); await frame();
};
const openRoom = async () => {
if (c._roomDialog) { c._roomDialogCancel(); await upd(); }
c._setMode('plan'); await upd();
c._openRoomEdit(c._curSpaceCfg.rooms[0]); await upd();
c._roomFill = 'temp'; await upd();
};
const withFallback = async (fn) => {
const show = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'showPopover');
const hide = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'hidePopover');
Object.defineProperty(HTMLElement.prototype, 'showPopover', { configurable: true, value: undefined });
Object.defineProperty(HTMLElement.prototype, 'hidePopover', { configurable: true, value: undefined });
try { await fn(); } finally {
if (show) Object.defineProperty(HTMLElement.prototype, 'showPopover', show); else delete HTMLElement.prototype.showPopover;
if (hide) Object.defineProperty(HTMLElement.prototype, 'hidePopover', hide); else delete HTMLElement.prototype.hidePopover;
}
};
// --- нативный <dialog> ------------------------------------------------------
await openRoom();
o.nativeBranch = !!dlg().shadowRoot.querySelector('dialog') && !dlg().shadowRoot.querySelector('ha-dialog');
o.dialogScrolls = scroller().scrollHeight > scroller().clientHeight + 10;
await probe('native_popover_sizes', 'room.sizes_section.help');
await probe('native_popover_fill', 'room.group_fill.help');
await withFallback(async () => {
await probe('native_fallback_sizes', 'room.sizes_section.help');
});
// доказательство содержательное только если подсказка реально вышла за край скроллера
o.native_tooltipLeavesScrollerAndStaysVisible = o.native_popover_sizes_crossesScrollerEdge === true
&& o.native_fallback_sizes_crossesScrollerEdge === true;
// --- ветка ha-dialog: заглушка с ловушками настоящей mwc-поверхности --------
customElements.define('ha-dialog', class extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' }).innerHTML = `<style>
:host { position: fixed; inset: 0; z-index: 7; display: flex; align-items: center; justify-content: center; }
.scrim { position: absolute; inset: 0; background: rgba(0,0,0,.32); }
.surface { position: relative; display: flex; flex-direction: column; width: min(560px, 94vw); max-height: calc(100vh - 72px);
overflow: hidden; transform: scale(1); border-radius: 12px; background: var(--card-background-color, #fff); }
.head { flex: 0 0 auto; padding: 12px 16px; }
.content { flex: 1 1 auto; min-height: 0; overflow: auto; }
.foot { flex: 0 0 auto; }
</style><div class="scrim"></div><div class="surface"><div class="head"><slot name="headerTitle"></slot></div>
<div class="content"><slot></slot></div><div class="foot"><slot name="footer"></slot></div></div>`;
}
connectedCallback() { queueMicrotask(() => this.dispatchEvent(new CustomEvent('opened'))); }
set open(v) { this._open = v; } get open() { return this._open; }
});
c._roomDialogCancel(); await upd();
await openRoom();
o.haDialogBranch = !!dlg().shadowRoot.querySelector('ha-dialog') && !dlg().shadowRoot.querySelector('dialog');
o.haDialogPortalOutsideSurface = (() => {
const portal = dlg().shadowRoot.querySelector('.overlay-portal');
return !!portal && !dlg().shadowRoot.querySelector('ha-dialog').contains(portal);
})();
o.haDialogScrolls = scroller().scrollHeight > scroller().clientHeight + 10;
await probe('ha_popover_sizes', 'room.sizes_section.help');
await withFallback(async () => {
await probe('ha_fallback_sizes', 'room.sizes_section.help');
await probe('ha_fallback_fill', 'room.group_fill.help');
});
o.ha_tooltipLeavesScrollerAndStaysVisible = o.ha_popover_sizes_crossesScrollerEdge === true
&& o.ha_fallback_sizes_crossesScrollerEdge === true;
c._roomDialogCancel(); await upd();
return o;
});
checkAll(Object.fromEntries(Object.entries(out).filter(([k]) => !k.endsWith('_crossesScrollerEdge'))));
await finish(browser, out);