Files
houseplan-card/demo/smoke_help_affordance.mjs
T
2026-09-06 12:56:13 +00:00

460 lines
21 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 .namein');
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 first legend remains a real enabled target even while the
// glow fieldset itself is disabled.
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('fieldset[disabled]')
&& !!disabledHelpHost?.closest('legend');
// 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;
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);