Files
houseplan-card/demo/smoke_help_affordance.mjs
T
Claude bf6602bbd8 feat(device): «Устройство на плане» по референсу — серия 4 из четырёх (#600)
§7 SPEC.md референса. Пять карточек #598 остались, менялось содержимое; все
ключи _markerDialog и все записи в них прежние (§7.2):

- Basics — баннеры привязки стали callout'ами; Name с подсказкой «Shown on
  the plan»; привязка — сегмент [Virtual | From the HA list] (те же радио
  bmode) и кнопка выбора #marker-binding с панелью в потоке (sourcePicker
  набора), чекбокс «Show entities» — внутри панели рядом с поиском (решение
  владельца Q8); Room селектом с подсказкой «по зоне / переопределяет»;
  секции радара и пылесоса — своими модулями без изменений;
- Tap action — селект с подсказкой Target/Now, «Ask for confirmation» —
  строка-тумблер только при toggle/run (Q8), Entity to toggle и What to run
  теми же условиями (список целей — панель набора), «Controls other light
  sources» — чипы набора и поиск с панелью в потоке;
- Light and glow — «Include the device temperature…» строкой-тумблером;
  роль — сегмент [Auto | Always | Never] с подсказкой результата Auto;
  Leading light entity по условиям; блок свечения (.hpf-block): подзаголовок
  с «?», сегмент [From source | Set colour | Colour and brightness] с
  отключёнными вариантами и aria-describedby на объяснение, плашка цвета
  вокруг прежнего hp-color-opacity (Q5), яркость и радиус строками с
  числом и единицей, при Never блок неактивен целиком (aria-disabled) с
  callout'ом причины;
- Appearance — поле иконки с превью, очисткой и ссылкой Pin в подсказке;
  Display селектом с подсказкой режима; пульс — плашка цвета и строка
  размера; Value source по условиям; подраздел Value badge — компактный
  тумблер, Value селектом с техническим идентификатором, Position сегментом
  [Above | Right | Below | Left]; предупреждения бейджа — заметки и
  callout'ы; Display preview — прежний hp-device-preview целиком в блоке на
  тинте с тегом Now (решение владельца Q3); «Icon size and rotation» —
  две строки range-line в сетке;
- Details — Model и Link в ряд, Description textarea набора, Manuals чипами
  и строкой действий Attach…, «Additional actions» подзаголовком набора.

Футер: Hide и Delete слева, статус, Cancel и Save справа. К10: снимок
черновика при _openMarkerDialog (marker-form-state.ts поверх
dialog-baseline; транзиентные ключи — раскрытие и фильтры списков, busy,
объявление подсказки, autoIcon, uploadId); в edit Save только при
изменениях, закрытие с изменениями спрашивает через _confirmDanger. Прежние
запреты Save (busy, калибровка радара, привязка не выбрана/не подтверждена)
остались; привязка и радиус свечения названы словами под полем
(marker.error_binding, marker.error_glow_radius), «Review N fields» ведёт к
полю. Тумблеры диалога — строки набора (нативный чекбокс, К7), а не
ha-switch; слайдеры — прежний _rangeInput (ha-slider в HA).

Набор: sourcePicker получил toolbar и disabled; сегмент — value у радио,
disabled и describedBy у варианта; callout — role note и id; unitInput —
describedBy; rangeLine — id; новые классы .hpf-block/.hpf-disabled,
.hpf-toolbar/.hpf-check, .hpf-iconfield/.hpf-iconpreview, .hpf-mono,
.hpf-note, .hpf-hint-line, textarea.hpf-input. settingsCopy вынесен в
editors/settings-copy.ts (общий для пространства, комнаты, устройства).

Смоки переписаны осознанно, с пометкой #600: binding_ui (#marker-binding,
.hpf-panel, #marker-show-entities), tap_run (панель набора, #marker-tap-
confirm), value_face_source (.hpf-cand.sel), static_icon (callout'ы),
climate_temp (#marker-use-climate-temp), help_affordance (#marker-name,
.hpf-block.hpf-disabled вместо fieldset[disabled]; второй Escape в грязном
диалоге спрашивает — К10), size_angle_parity (сетка Appearance),
color_picker_consumers (подписи полей), device_preview_parity (сегмент
стороны), ha_controls (тумблеры набора — нативные чекбоксы по К7, ha-slider
прежний). Golden-harness device — .hpf-field/.hpf-block, скроллер .content.
Новый фокусный свидетель demo/smoke_device_settings_form.mjs: пять карточек
без старой разметки, оболочка, привязка/панель/Show entities, Q8, сегменты и
блок свечения, плашки, иконка, бейдж, превью на тинте, размеры, Details,
цели 44, Save/dirty/вопрос при закрытии, create-режим, семь полос экранов.

Мутанты: marker-save-enabled-without-changes, marker-discard-without-asking,
marker-confirm-row-shown-for-do-nothing, marker-never-keeps-glow-block-live,
marker-badge-position-forgets-touch; якоря same-binding-click-resets-source,
state-callout-hidden-under-help, form-kit-segment-drops-radio-semantics
переставлены на новую разметку.

Контрактные тесты: color-picker (два поля набора вместо flex-строк),
native-select-contract (сторона бейджа — сегмент), i18n dead-keys (34 → 38
подсказок), German=English (marker.name_label). Словарь i18n/settings: 21
ключ marker.* на четыре локали; в основном каталоге marker.name_label
«Name», marker.size_label «Icon size and rotation», удалены мёртвые
marker.room_override, marker.light_role_always/never. Фикстуры листа
диалогов набора пересняты намеренно (панель — байт в байт, AC11).

Бюджет: стартовый граф 290 489 (потолок 291 600 без изменений); ленивый
редакторский 243 173 — потолок 240 100 → 243 900 с разбором в
bundle-budget.mjs; старые стили диалога устройства снимаются в финале.

Гейты: typecheck, build, bundle:sync (три копии байт в байт), npm test
2790+ pass по четырём частям, mutation-gate --check, 39 смоков диалога
устройства + новый + три формы серий 1–3.

Issue: #600
User-Visible: no
2026-09-20 12:18:12 +03:00

467 lines
22 KiB
JavaScript

// Issue #68: contextual help trigger parity, dialog overlay ownership and the
// real non-Popover fallback. Run by the prerelease smoke suite, not on edits.
import { launch, launchColdView, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 390, height: 780 }, 1);
await page.keyboard.press('Tab');
const keyboardFocus = await page.evaluate(async () => {
const frame = () => new Promise((resolve) => requestAnimationFrame(resolve));
const deepActive = () => {
let active = document.activeElement;
while (active?.shadowRoot?.activeElement) active = active.shadowRoot.activeElement;
return active;
};
const dialog = document.createElement('hp-dialog');
dialog.title = 'Keyboard help smoke';
const help = document.createElement('hp-help');
help.text = 'Keyboard description';
help.ariaLabel = 'Help: keyboard description';
dialog.append(help);
document.body.append(dialog);
await dialog.updateComplete;
await help.updateComplete;
dialog._focusInitial();
await frame();
const button = help.shadowRoot?.querySelector('.trigger');
const result = deepActive() === button
&& button?.matches(':focus-visible')
&& button?.getAttribute('aria-expanded') === 'true'
&& dialog._focusableElements().includes(button);
dialog.remove();
return result;
});
const res = await page.evaluate(async () => {
const out = {};
const card = window.__card;
const root = () => card.shadowRoot || card.renderRoot;
const wait = (ms = 0) => new Promise((resolve) => setTimeout(resolve, ms));
const frame = () => new Promise((resolve) => requestAnimationFrame(resolve));
const deepActive = () => {
let active = document.activeElement;
while (active?.shadowRoot?.activeElement) active = active.shadowRoot.activeElement;
return active;
};
const helpButton = (key) => root().querySelector(`hp-help[data-help-key="${key}"]`)?.shadowRoot?.querySelector('.trigger');
const helpSurface = (key) => {
const help = root().querySelector(`hp-help[data-help-key="${key}"]`);
return help?.shadowRoot?.querySelector('.tooltip')
|| root().querySelector('hp-dialog')?.shadowRoot?.querySelector('[data-hp-overlay="help"]')?.shadowRoot?.querySelector('.tooltip');
};
const enterWithRealMouse = (target) => {
target?.dispatchEvent(new PointerEvent('pointerover', {
pointerType: 'mouse', bubbles: true, composed: true,
}));
target?.dispatchEvent(new PointerEvent('pointerenter', {
pointerType: 'mouse', bubbles: false, composed: true,
}));
};
// Empty or incomplete content must not leave a dead focus target behind.
const incompleteHelp = document.createElement('hp-help');
incompleteHelp.text = ' ';
incompleteHelp.ariaLabel = 'Help: unavailable description';
document.body.append(incompleteHelp);
await incompleteHelp.updateComplete;
out.emptyTextHidesTrigger = !incompleteHelp.shadowRoot?.querySelector('.trigger')
&& getComputedStyle(incompleteHelp).display === 'none';
incompleteHelp.text = 'Available description';
incompleteHelp.ariaLabel = ' ';
await incompleteHelp.updateComplete;
out.emptyAriaHidesTrigger = !incompleteHelp.shadowRoot?.querySelector('.trigger')
&& getComputedStyle(incompleteHelp).display === 'none';
incompleteHelp.ariaLabel = 'Help: available description';
await incompleteHelp.updateComplete;
const restoredTrigger = incompleteHelp.shadowRoot?.querySelector('.trigger');
out.circledQuestionIcon = restoredTrigger?.querySelector('svg[viewBox="0 0 24 24"] path')
?.getAttribute('d')?.length > 0;
incompleteHelp.text = Array.from({ length: 320 }, (_, index) => `Line ${index + 1}`).join(' · ');
await incompleteHelp.updateComplete;
const overflowTrigger = incompleteHelp.shadowRoot?.querySelector('.trigger');
overflowTrigger?.click();
await incompleteHelp.updateComplete;
await frame();
const longSurface = incompleteHelp.shadowRoot?.querySelector('.tooltip');
overflowTrigger?.dispatchEvent(new KeyboardEvent('keydown', {
key: 'PageDown', bubbles: true, composed: true, cancelable: true,
}));
await frame();
out.overflowHelpScrollsFromTrigger = !!longSurface
&& longSurface.scrollHeight > longSurface.clientHeight && longSurface.scrollTop > 0;
overflowTrigger?.click();
incompleteHelp.remove();
card._setMode('devices');
const marker = card._devices[0];
card._openMarkerDialog(marker);
await card.updateComplete;
await frame();
const dialogBody = root().querySelector('hp-dialog .body');
const scrollGeometry = () => dialogBody ? {
clientHeight: dialogBody.clientHeight,
scrollHeight: dialogBody.scrollHeight,
scrollTop: dialogBody.scrollTop,
} : null;
const roleHelp = root().querySelector('hp-help[data-help-key="marker.light_role.help"]');
const roleButton = helpButton('marker.light_role.help');
const roleDescription = roleHelp?.shadowRoot?.querySelector('[role="tooltip"]');
out.realButton = roleButton?.tagName === 'BUTTON'
&& !!roleDescription?.id
&& !roleButton.hasAttribute('aria-describedby');
const beforeHoverGeometry = scrollGeometry();
enterWithRealMouse(roleButton);
await wait(330);
out.mouseHover = roleButton?.getAttribute('aria-expanded') === 'true'
&& roleButton.getAttribute('aria-describedby') === roleDescription?.id
&& !!helpSurface('marker.light_role.help');
out.hoverKeepsDialogScrollGeometry = JSON.stringify(scrollGeometry())
=== JSON.stringify(beforeHoverGeometry);
roleButton?.dispatchEvent(new PointerEvent('pointerleave', { pointerType: 'mouse', bubbles: true, composed: true }));
await wait(180);
out.mouseLeave = roleButton?.getAttribute('aria-expanded') === 'false';
// Escape dismisses a mouse-hovered bubble without moving keyboard focus
// away from the form control the user was editing.
const nameInput = root().querySelector('hp-dialog #marker-name'); // #600: поле набора
nameInput?.focus();
enterWithRealMouse(roleButton);
await wait(330);
nameInput?.dispatchEvent(new KeyboardEvent('keydown', {
key: 'Escape', bubbles: true, composed: true, cancelable: true,
}));
await frame();
out.hoverEscapeKeepsFocus = roleButton?.getAttribute('aria-expanded') === 'false'
&& deepActive() === nameInput;
roleButton?.click();
await frame();
document.body.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, composed: true }));
await frame();
out.outsidePointer = roleButton?.getAttribute('aria-expanded') === 'false';
roleButton?.click();
await frame();
out.touchClick = roleButton?.getAttribute('aria-expanded') === 'true';
roleButton?.dispatchEvent(new KeyboardEvent('keydown', {
key: 'Escape', bubbles: true, composed: true, cancelable: true,
}));
await card.updateComplete;
out.escapeHelpFirst = roleButton?.getAttribute('aria-expanded') === 'false' && !!card._markerDialog;
// The help in the subsection heading remains a real enabled target even
// while the glow block itself is disabled (#600: .hpf-block.hpf-disabled
// with aria-disabled replaced the fieldset[disabled]/legend pair).
card._markerDialog = { ...card._markerDialog, lightRole: 'never' };
await card.updateComplete;
const disabledHelpHost = root().querySelector('hp-help[data-help-key="marker.glow_mode.help"]');
const disabledHelp = helpButton('marker.glow_mode.help');
out.disabledExplanation = !!disabledHelp && !disabledHelp.disabled
&& disabledHelpHost?.closest('.hpf-block.hpf-disabled')?.getAttribute('aria-disabled') === 'true'
&& !!disabledHelpHost?.closest('.hpf-sub');
// Exclusive transient surfaces: opening help dismisses an open colour picker.
card._markerDialog = { ...card._markerDialog, lightRole: 'always', glowMode: 'fixed' };
await card.updateComplete;
const picker = root().querySelector('hp-color-opacity');
const pickerButton = picker?.shadowRoot?.querySelector('.trigger');
pickerButton?.click();
await frame();
const colorOnlySurface = picker?.shadowRoot?.querySelector('.picker');
out.glowColorOnlyHasNoAlpha = colorOnlySurface?.querySelectorAll('input[type="range"]').length === 3
&& !colorOnlySurface?.querySelector('input[type="number"]');
const glowHelpButton = helpButton('marker.glow_mode.help');
const focusables = root().querySelector('hp-dialog')?._focusableElements?.() || [];
out.shadowFocusables = focusables.includes(glowHelpButton) && focusables.includes(pickerButton);
glowHelpButton?.click();
await frame();
out.exclusive = pickerButton?.getAttribute('aria-expanded') === 'false'
&& glowHelpButton?.getAttribute('aria-expanded') === 'true';
const surface = helpSurface('marker.glow_mode.help');
const box = surface?.getBoundingClientRect();
const viewport = window.visualViewport;
out.insideViewport = !!box && box.left >= (viewport?.offsetLeft || 0)
&& box.top >= (viewport?.offsetTop || 0)
&& box.right <= (viewport?.offsetLeft || 0) + (viewport?.width || innerWidth)
&& box.bottom <= (viewport?.offsetTop || 0) + (viewport?.height || innerHeight);
pickerButton?.click();
await frame();
out.reverseExclusive = glowHelpButton?.getAttribute('aria-expanded') === 'false'
&& pickerButton?.getAttribute('aria-expanded') === 'true';
pickerButton?.click();
glowHelpButton?.click();
await frame();
card._showToast('overlay priority');
await frame();
out.toastClosesTransient = glowHelpButton?.getAttribute('aria-expanded') === 'false'
&& !!card._markerDialog;
glowHelpButton?.click();
await frame();
// Scrolling the owning dialog closes help instead of leaving a detached bubble.
const body = glowHelpButton?.getRootNode()?.host?.closest('hp-dialog')?.querySelector('.body');
body?.dispatchEvent(new Event('scroll', { bubbles: false }));
await frame();
out.ownScrollCloses = glowHelpButton?.getAttribute('aria-expanded') === 'false';
// Force feature detection down the true portal fallback branch.
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 });
const beforeFallbackGeometry = scrollGeometry();
glowHelpButton?.click();
await frame();
const dialog = root().querySelector('hp-dialog');
const fallback = dialog?.shadowRoot?.querySelector('[data-hp-overlay="help"]');
const fallbackBox = fallback?.shadowRoot?.querySelector('.tooltip')?.getBoundingClientRect();
out.realFallback = !!fallback && !!fallbackBox?.width && !!fallbackBox?.height;
out.fallbackKeepsDialogScrollGeometry = JSON.stringify(scrollGeometry())
=== JSON.stringify(beforeFallbackGeometry);
glowHelpButton?.click();
pickerButton?.click();
await frame();
const pickerFallback = dialog?.shadowRoot?.querySelector('[data-hp-overlay="color-opacity"]');
const pickerControl = pickerFallback?.shadowRoot?.querySelector('input');
const pickerConfirm = pickerFallback?.shadowRoot?.querySelector('.confirm');
out.fallbackPickerFocusable = !!pickerControl
&& dialog?._focusableElements?.().includes(pickerControl);
out.fallbackPickerHasConfirm = pickerConfirm?.textContent.trim() === 'OK'
&& pickerFallback?.shadowRoot?.querySelector('.picker')?.lastElementChild === pickerConfirm;
pickerConfirm?.click();
await frame();
out.fallbackPickerConfirmCloses = pickerButton?.getAttribute('aria-expanded') === 'false'
&& picker?.shadowRoot?.activeElement === pickerButton;
if (pickerButton?.getAttribute('aria-expanded') === 'true') pickerButton.click();
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;
// Explicit card language wins over HA locale and updates an existing component.
card._config = { ...card._config, language: 'ru' };
card.requestUpdate();
await card.updateComplete;
out.cardLanguage = /Определяет|источник/.test(root()
.querySelector('hp-help[data-help-key="marker.light_role.help"]')?.text || '');
const finalButton = helpButton('marker.light_role.help');
finalButton?.click();
await frame();
finalButton?.dispatchEvent(new KeyboardEvent('keydown', {
key: 'Escape', bubbles: true, composed: true, cancelable: true,
}));
await card.updateComplete;
finalButton?.dispatchEvent(new KeyboardEvent('keydown', {
key: 'Escape', bubbles: true, composed: true, cancelable: true,
}));
await card.updateComplete; await frame();
// #600 К10: черновик выше менялся (роль, режим свечения) — второй Escape
// просит подтвердить сброс изменений, и только «Discard» закрывает диалог.
const discardConfirm = root().querySelector('hp-confirm hp-dialog');
out.secondEscapeAsksToDiscardDirtyDraft = !!discardConfirm && !!card._markerDialog;
discardConfirm?.querySelector('[data-hp="dialog-confirm"]')?.click();
await card.updateComplete; await frame();
out.secondEscapeClosesDialog = !card._markerDialog;
// #86 Party 1: all general-settings placements are real help controls and
// opening one of them cannot mutate its associated setting.
card._openSettingsDialog();
await card.updateComplete;
const generalKeys = ['gs.glow_radius.help', 'gs.bg_mode.help', 'gs.north.help'];
out.party1GeneralInventory = generalKeys.every((key) => !!helpButton(key));
const generalBefore = JSON.stringify({
glowRadius: card._settingsDialog?.glowRadius,
bgMode: card._settingsDialog?.bgMode,
northDeg: card._settingsDialog?.northDeg,
});
helpButton('gs.bg_mode.help')?.click();
await frame();
out.party1GeneralHelpIsReadOnly = generalBefore === JSON.stringify({
glowRadius: card._settingsDialog?.glowRadius,
bgMode: card._settingsDialog?.bgMode,
northDeg: card._settingsDialog?.northDeg,
});
card._settingsDialog = null;
card.requestUpdate();
await card.updateComplete;
// The regular editor and cold-install onboarding render separate space
// dialog templates; this smoke covers the editor copy while the source/i18n
// contract covers both implementations.
card._openSpaceDialog('edit', card._space);
await card.updateComplete;
const spaceKeys = [
'space.cell_cm.help', 'space.zero_wall_style.help', 'space.bg_mode.help',
'space.north.help', 'space.fill_mode.help',
];
out.party1SpaceInventory = spaceKeys.every((key) => !!helpButton(key));
const spaceBefore = JSON.stringify({
cellCm: card._spaceDialog?.cellCm,
zeroWallStyle: card._spaceDialog?.zeroWallStyle,
bgMode: card._spaceDialog?.bgMode,
northDeg: card._spaceDialog?.northDeg,
fillMode: card._spaceDialog?.fillMode,
});
helpButton('space.zero_wall_style.help')?.click();
await frame();
out.party1SpaceHelpIsReadOnly = spaceBefore === JSON.stringify({
cellCm: card._spaceDialog?.cellCm,
zeroWallStyle: card._spaceDialog?.zeroWallStyle,
bgMode: card._spaceDialog?.bgMode,
northDeg: card._spaceDialog?.northDeg,
fillMode: card._spaceDialog?.fillMode,
});
card._spaceDialog = null;
card.requestUpdate();
await card.updateComplete;
card._openDeviceInbox();
await card.updateComplete;
const hiddenBefore = card._showHidden;
const hiddenHelpButton = helpButton('device_inbox.show_hidden.help');
hiddenHelpButton?.click();
await frame();
const hiddenSurface = helpSurface('device_inbox.show_hidden.help');
const hiddenBox = hiddenSurface?.getBoundingClientRect();
out.party1ShowHiddenHelpIsReadOnly = !!hiddenHelpButton
&& card._showHidden === hiddenBefore;
out.party1ShowHiddenHelpInsideNarrowViewport = !!hiddenBox
&& hiddenBox.left >= (visualViewport?.offsetLeft || 0)
&& hiddenBox.top >= (visualViewport?.offsetTop || 0)
&& hiddenBox.right <= (visualViewport?.offsetLeft || 0) + (visualViewport?.width || innerWidth)
&& hiddenBox.bottom <= (visualViewport?.offsetTop || 0) + (visualViewport?.height || innerHeight);
return out;
});
// Empty-install onboarding owns a separate lazy runtime and a second copy of
// the space dialog. Exercise that production chunk, not merely the regular
// editor template or its source inventory (#86 review r1 M1).
const onboarding = await launchColdView({ width: 390, height: 780 });
await onboarding.page.evaluate(async () => {
const card = window.__card;
card._onboardingShown = false;
card._serverCfg = { ...card._serverCfg, spaces: [] };
card._model = [];
card.hass = { ...card.hass, floors: {}, areas: {} };
card.requestUpdate();
await card.updateComplete;
});
await onboarding.page.waitForFunction(() => {
const card = window.__card;
return card._onboardingRuntime && !card._editorRuntime
&& card.renderRoot.querySelector('hp-dialog');
});
const onboardingHelp = await onboarding.page.evaluate(async () => {
const card = window.__card;
const root = card.renderRoot;
const keys = [
'space.cell_cm.help', 'space.zero_wall_style.help', 'space.bg_mode.help',
'space.north.help', 'space.fill_mode.help',
];
const help = (key) => root.querySelector(`hp-help[data-help-key="${key}"]`);
const before = JSON.stringify({
cellCm: card._spaceDialog?.cellCm,
zeroWallStyle: card._spaceDialog?.zeroWallStyle,
bgMode: card._spaceDialog?.bgMode,
northDeg: card._spaceDialog?.northDeg,
fillMode: card._spaceDialog?.fillMode,
});
const zeroWallHelp = help('space.zero_wall_style.help');
zeroWallHelp?.shadowRoot?.querySelector('.trigger')?.click();
await zeroWallHelp?.updateComplete;
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
const after = JSON.stringify({
cellCm: card._spaceDialog?.cellCm,
zeroWallStyle: card._spaceDialog?.zeroWallStyle,
bgMode: card._spaceDialog?.bgMode,
northDeg: card._spaceDialog?.northDeg,
fillMode: card._spaceDialog?.fillMode,
});
return {
inventory: keys.every((key) => !!help(key)?.shadowRoot?.querySelector('.trigger')),
selectedHelpOpened: zeroWallHelp?.shadowRoot
?.querySelector('.trigger')?.getAttribute('aria-expanded') === 'true',
readOnly: before === after,
editorStillLazy: !!card._onboardingRuntime && !card._editorRuntime,
};
});
res.party1ColdOnboardingInventory = onboardingHelp.inventory;
res.party1ColdOnboardingHelpOpens = onboardingHelp.selectedHelpOpened;
res.party1ColdOnboardingHelpIsReadOnly = onboardingHelp.readOnly;
res.party1ColdOnboardingKeepsEditorLazy = onboardingHelp.editorStillLazy;
await onboarding.browser.close();
// Browser zoom at 200% on a 780 px-wide display exposes a 390 CSS px viewport
// at DPR 2. Playwright cannot change Chromium's toolbar preference, so create
// that exact renderer contract directly; unlike CSS `zoom`, this preserves the
// coordinate space used by fixed/popover surfaces and catches real clipping.
const zoomed = await launch({ width: 390, height: 900 }, 2);
const zoomedLayout = await zoomed.page.evaluate(async () => {
const card = window.__card;
const root = card.renderRoot;
const frame = () => new Promise((resolve) => requestAnimationFrame(resolve));
card._openSettingsDialog();
await card.updateComplete;
await frame();
const help = root.querySelector('hp-help[data-help-key="gs.bg_mode.help"]');
const trigger = help?.shadowRoot?.querySelector('.trigger');
const stage = root.querySelector('.stage');
const dialogBody = root.querySelector('hp-dialog .body');
const stageRect = () => {
const box = stage?.getBoundingClientRect();
return box ? [box.left, box.top, box.width, box.height].map((value) => Math.round(value)) : null;
};
const settleStage = async () => {
let previous = null;
for (let attempt = 0; attempt < 8; attempt++) {
await frame();
const current = stageRect();
if (JSON.stringify(current) === JSON.stringify(previous)) return current;
previous = current;
}
return previous;
};
trigger?.scrollIntoView({ block: 'center', inline: 'nearest' });
await frame();
const before = await settleStage();
trigger?.click();
await help?.updateComplete;
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
const triggerBox = trigger?.getBoundingClientRect();
const surface = help?.shadowRoot?.querySelector('.tooltip')
|| root.querySelector('hp-dialog')?.shadowRoot
?.querySelector('[data-hp-overlay="help"]')?.shadowRoot?.querySelector('.tooltip');
const surfaceBox = surface?.getBoundingClientRect();
const viewport = window.visualViewport;
const left = viewport?.offsetLeft || 0;
const top = viewport?.offsetTop || 0;
const right = left + (viewport?.width || innerWidth);
const bottom = top + (viewport?.height || innerHeight);
const inside = (box) => !!box && box.width > 0 && box.height > 0
&& box.left >= left && box.top >= top && box.right <= right && box.bottom <= bottom;
return {
zoomApplied: devicePixelRatio === 2 && innerWidth === 390,
triggerInside: inside(triggerBox),
tooltipInside: inside(surfaceBox),
noHorizontalDialogOverflow: !!dialogBody
&& dialogBody.scrollWidth <= dialogBody.clientWidth + 1,
stageStable: JSON.stringify(await settleStage()) === JSON.stringify(before),
};
});
res.party1BrowserZoom200Applied = zoomedLayout.zoomApplied;
res.party1BrowserZoom200TriggerInside = zoomedLayout.triggerInside;
res.party1BrowserZoom200TooltipInside = zoomedLayout.tooltipInside;
res.party1BrowserZoom200NoHorizontalOverflow = zoomedLayout.noHorizontalDialogOverflow;
res.party1BrowserZoom200StageStable = zoomedLayout.stageStable;
await zoomed.browser.close();
res.keyboardFocus = keyboardFocus;
checkAll(res);
await finish(browser, res);