Шапка на телефоне: одна строка — вкладки, масштаб и шестерёнка с меню остального (#616)

На окне не шире 480 px шапка карточки и страницы бокового меню занимает одну
строку ≤ 56 px: вкладки пространств (без шестерёнок и «+», прокрутка вбок,
текущая вкладка докручивается в видимую область), масштаб, одна шестерёнка
«Действия и настройки» и у админа слот крестика #647. Заголовок скрыт.

Меню шестерёнки (src/header-menu.ts): редакторы, настройки и добавление
пространства, общие настройки, PDF, помощь, сводная панель и объёмный вид
(alpha) — в фиксированном порядке и с теми же условиями и действиями, что у
прежних кнопок. Пункт закрывает меню; Escape возвращает фокус на шестерёнку;
касание вне меню поглощается прозрачной подложкой. На > 480 px шапка прежняя,
в киоске меню нет.

- три кнопки шапки вынесены в renderHeaderActions — шапка карточки не растёт;
- сводная панель отдаёт свои пункты методом menuItems();
- i18n: title.header_menu (en/ru/de/fr);
- потолок initial View 291 000 → 292 600 (замер 291 635), база bundleBytes;
- смок smoke_mobile_view_header, юнит header-menu, 10 мутантов; смоки
  support_feedback, gear_tabs, modes, toolbar_stable_width переведены на
  меню на ≤ 480 px; подпись вкладки в span.tabtitle без pointer-events;
- USER-GUIDE.ru, UX-MODES, TOUCH-SUPPORT, STYLING-HOOKS, data-hp-contract,
  оба changelog.

Issue: #616
User-Visible: yes
This commit is contained in:
Claude
2026-09-25 14:22:36 +03:00
parent dc6018ed5a
commit 255330b9bc
97 changed files with 1640 additions and 742 deletions
+13 -6
View File
@@ -81,7 +81,8 @@ const kio = await page.evaluate(() => {
});
for (const [k, v] of Object.entries(kio)) check(k, v);
// мобильный вид: ряд вкладок переносится, а не выезжает за карточку
// мобильный вид (#616): ряд вкладок не выезжает за карточку, а «+» на ≤ 480 px
// живёт в меню шестерёнки — пункт есть, внутри карточки и попадает под палец.
await page.setViewportSize({ width: 390, height: 760 });
const narrow = await page.evaluate(async () => {
const out = {};
@@ -90,19 +91,25 @@ const narrow = await page.evaluate(async () => {
c.requestUpdate(); await c.updateComplete;
const sr = c.shadowRoot || c.renderRoot;
const head = sr.querySelector('.head');
const btn = sr.querySelector('.tab.tabadd');
out.narrowAddPresent = !!btn;
out.narrowNoHOverflow = head.scrollWidth <= head.clientWidth + 1
? true : `scrollW=${head.scrollWidth} > clientW=${head.clientWidth}`;
out.narrowInlineAddHidden = !sr.querySelector('.tab.tabadd')?.getClientRects().length;
sr.querySelector('[data-hp="header-menu"]')?.click();
await c.updateComplete;
const btn = sr.querySelector('[data-hp="header-menu-item"][data-id="space-add"]');
out.narrowAddPresent = !!btn;
btn?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
const cr = sr.querySelector('ha-card').getBoundingClientRect();
const br = btn ? btn.getBoundingClientRect() : null;
out.narrowAddInsideCard = !br ? 'нет кнопки'
out.narrowAddInsideCard = !br ? 'нет пункта'
: br.right <= cr.right + 1 && br.left >= cr.left - 1 ? true
: `btn ${Math.round(br.left)}..${Math.round(br.right)} vs card ${Math.round(cr.left)}..${Math.round(cr.right)}`;
out.narrowAddHittable = !br ? 'нет кнопки' : (() => {
out.narrowAddHittable = !br ? 'нет пункта' : (() => {
const hit = sr.elementFromPoint(br.left + br.width / 2, br.top + br.height / 2);
return hit && hit.closest && hit.closest('.tab.tabadd') === btn ? true : 'перекрыта';
return hit && hit.closest && hit.closest('[data-hp="header-menu-item"]') === btn ? true : 'перекрыта';
})();
sr.querySelector('[data-hp="header-menu"]')?.click();
await c.updateComplete;
return out;
});
for (const [k, v] of Object.entries(narrow)) check(k, v);
+297
View File
@@ -0,0 +1,297 @@
// #616: компактная шапка на телефоне (≤ 480 px). В одной строке ≤ 56 px —
// вкладки пространств, зум и одна шестерёнка; всё, что строка убрала, — пункты
// её меню с теми же действиями, что у прежних кнопок. Тап вне меню поглощается,
// Escape возвращает фокус на шестерёнку, активная вкладка видна в прокрутке.
// На > 480 px шапка прежняя, в киоске меню нет. Клики — настоящие (Playwright
// по координатам), приватные поля карточки только читаются.
import { launch, check, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 390, height: 844 }, 1);
// Как в golden: карточка во всю ширину окна, а не фиксированные 780 px стенда.
await page.addStyleTag({ content: '#host{width:min(100%,1120px)!important;margin:0 auto!important;padding:0!important}' });
const out = {};
const settle = () => page.evaluate(() => window.__hpTest.settled());
const size = async (width) => { await page.setViewportSize({ width, height: 844 }); await settle(); };
// Снимок шапки: что видно, одна ли строка, высота.
const header = () => page.evaluate(() => {
const sr = window.__card.shadowRoot;
const head = sr.querySelector('.hdr > .head');
const shown = (el) => !!el && el.getClientRects().length > 0 && getComputedStyle(el).visibility !== 'hidden';
const kids = [...head.children].filter((el) => shown(el) && el.getBoundingClientRect().width > 0);
const mids = kids.map((el) => { const r = el.getBoundingClientRect(); return r.top + r.height / 2; });
const gear = sr.querySelector('[data-hp="header-menu"]');
const g = gear?.getBoundingClientRect();
return {
height: head.getBoundingClientRect().height,
oneRow: mids.length > 0 && Math.max(...mids) - Math.min(...mids) <= 6,
title: shown(head.querySelector(':scope > .title')),
tabs: shown(head.querySelector(':scope > .tabs')),
zoom: shown(head.querySelector(':scope > .zoomctl')),
gear: shown(gear),
gear44: !!g && g.width >= 44 && g.height >= 44,
hiddenInline: ['.modes', '.header-action', '.summary-control', '.projection-toggle', '.tabedit', '.tabadd']
.filter((selector) => [...head.querySelectorAll(selector)].some(shown)),
tabEditInDom: !!head.querySelector('.tabedit, .tabadd'),
inlineVisible: ['.header-action', '.summary-control'].filter((selector) => [...head.querySelectorAll(selector)].some(shown)),
};
});
const menuState = () => page.evaluate(() => {
const sr = window.__card.shadowRoot;
const menu = sr.querySelector('#hp-header-menu');
const gear = sr.querySelector('[data-hp="header-menu"]');
const items = [...sr.querySelectorAll('[data-hp="header-menu-item"]')];
return {
open: !!menu, expanded: gear?.getAttribute('aria-expanded'),
ids: items.map((el) => el.dataset.id),
pressed: Object.fromEntries(items.filter((el) => el.hasAttribute('aria-pressed')).map((el) => [el.dataset.id, el.getAttribute('aria-pressed')])),
current: items.filter((el) => el.getAttribute('aria-current') === 'true').map((el) => el.dataset.id),
items44: items.every((el) => el.getBoundingClientRect().height >= 44),
inViewport: !menu || (() => { const r = menu.getBoundingClientRect(); return r.left >= 0 && r.right <= innerWidth + 0.5 && r.bottom <= innerHeight + 0.5; })(),
};
});
const clickAt = async (selector) => {
const box = await page.evaluate((s) => {
const el = window.__card.shadowRoot.querySelector(s);
if (!el) return null;
el.scrollIntoView({ block: 'nearest', inline: 'nearest' });
const r = el.getBoundingClientRect();
return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
}, selector);
if (!box) throw new Error(`нет ${selector}`);
await page.mouse.click(box.x, box.y);
await settle();
};
const openMenu = async () => { if (!(await menuState()).open) await clickAt('[data-hp="header-menu"]'); };
const dialogKinds = () => page.evaluate(() => {
const kinds = [];
const walk = (root) => {
for (const d of root.querySelectorAll('hp-dialog[data-kind]')) kinds.push(d.getAttribute('data-kind'));
for (const el of root.querySelectorAll('*')) if (el.shadowRoot) walk(el.shadowRoot);
};
walk(window.__card.shadowRoot);
return kinds.sort();
});
const closeDialogs = async () => {
for (let i = 0; i < 4 && (await dialogKinds()).length; i++) {
await page.evaluate(() => window.__hpTest.close());
}
};
const mode = () => page.evaluate(() => window.__card.shadowRoot.querySelector('ha-card[data-hp-mode]')?.getAttribute('data-hp-mode'));
// ---- администратор (стенд) --------------------------------------------------
const ADMIN_VIEW = ['mode-plan', 'mode-devices', 'mode-decor', 'space-settings', 'space-add', 'settings', 'pdf', 'support', 'summary-settings', 'summary-toggle'];
for (const width of [390, 320]) {
await size(width);
const h = await header();
out[`admin${width}_oneRow56`] = h.oneRow && h.height <= 56;
out[`admin${width}_rowContent`] = !h.title && h.tabs && h.zoom && h.gear && h.gear44;
out[`admin${width}_inlineHidden`] = h.hiddenInline.length === 0 ? true : h.hiddenInline.join(',');
await openMenu();
const m = await menuState();
out[`admin${width}_menuItems`] = m.ids.join(',') === ADMIN_VIEW.join(',') ? true : m.ids.join(',');
out[`admin${width}_menuTargets`] = m.items44 && m.inViewport && m.expanded === 'true';
await clickAt('[data-hp="header-menu"]');
out[`admin${width}_gearToggles`] = !(await menuState()).open;
}
// Каждый пункт делает то же, что прежняя кнопка: сначала прежняя кнопка на
// 1400 px (эталон эффекта), потом пункт меню на 390 px.
const same = {};
for (const [id, inline] of [['settings', '[data-hp="settings"]'], ['pdf', '[data-hp="pdf"]'], ['support', '[data-hp="support"]'],
['space-settings', '[data-hp="space-settings"]'], ['space-add', '[data-hp="space-add"]'], ['summary-settings', '.summary-control > button:first-child']]) {
await size(1400);
await clickAt(inline);
await page.waitForTimeout(250);
const expected = await dialogKinds();
await closeDialogs();
await size(390);
await openMenu();
await clickAt(`[data-hp="header-menu-item"][data-id="${id}"]`);
await page.waitForTimeout(250);
const actual = await dialogKinds();
const closed = !(await menuState()).open;
// Открытое меню перехватывает Escape — диалог тогда не закрыть; это провал пункта, а не смока.
const closeError = await closeDialogs().then(() => '', (error) => String(error.message || error).slice(0, 80));
if ((await menuState()).open) await clickAt('[data-hp="header-menu"]');
if (closeError) await closeDialogs().catch(() => {});
same[id] = expected.length > 0 && expected.join() === actual.join() && closed && !closeError
? true : `${expected}|${actual}|closed=${closed}|${closeError}`;
}
out.itemsSameEffect = Object.values(same).every((v) => v === true) ? true : JSON.stringify(same);
// Сводная панель: переключатель меняет то же локальное состояние, что и кнопка.
await openMenu();
const before = (await menuState()).pressed['summary-toggle'];
await clickAt('[data-hp="header-menu-item"][data-id="summary-toggle"]');
await openMenu();
const after = (await menuState()).pressed['summary-toggle'];
await clickAt('[data-hp="header-menu-item"][data-id="summary-toggle"]');
out.summaryToggleFlips = before !== after && ['true', 'false'].includes(after);
// Редактор с телефона: вход из меню, строка одна, × виден и закрывает.
await openMenu();
await clickAt('[data-hp="header-menu-item"][data-id="mode-plan"]');
await page.waitForTimeout(300);
out.editorFromMenu = (await mode()) === 'plan';
const inEditor = await header();
out.editorOneRow56 = inEditor.oneRow && inEditor.height <= 56 && inEditor.hiddenInline.length === 0;
await openMenu();
const editorMenu = await menuState();
out.editorMenuMarksCurrent = editorMenu.current.join() === 'mode-plan' && !editorMenu.ids.includes('summary-toggle');
await clickAt('[data-hp="header-menu"]');
await clickAt('[data-hp="editor-close"]');
await page.waitForTimeout(300);
out.crossClosesOnPhone = (await mode()) === 'view';
// Escape закрывает меню и возвращает фокус на шестерёнку.
await openMenu();
await page.keyboard.press('Escape');
await settle();
out.escapeClosesAndFocuses = await page.evaluate(() => {
const sr = window.__card.shadowRoot;
return !sr.querySelector('#hp-header-menu') && sr.activeElement === sr.querySelector('[data-hp="header-menu"]');
});
// Тап вне меню поглощается: под пальцем маркер устройства, но до него тап не доходит.
const target = await page.evaluate(() => {
const sr = window.__card.shadowRoot;
const top = sr.querySelector('.hdr').getBoundingClientRect().bottom + 40;
for (const el of sr.querySelectorAll('[data-hp="device"]')) {
const r = el.getBoundingClientRect();
const x = r.left + r.width / 2; const y = r.top + r.height / 2;
if (r.width > 0 && y > top && y < innerHeight - 20 && x > 20 && x < innerWidth - 20) {
const hit = sr.elementFromPoint(x, y);
if (hit && (hit === el || el.contains(hit))) return { x, y, id: el.getAttribute('data-id') };
}
}
return null;
});
if (target) {
await openMenu();
const shield = await page.evaluate(({ x, y }) => window.__card.shadowRoot.elementFromPoint(x, y)?.getAttribute('data-hp'), target);
const states = () => page.evaluate(() => JSON.stringify(window.__card.hass.states));
const dialogsBefore = await dialogKinds();
const statesBefore = await states();
await page.mouse.click(target.x, target.y);
await settle();
await page.waitForTimeout(250);
out.outsideTapClosesMenu = !(await menuState()).open;
out.outsideTapSwallowed = shield === 'header-menu-scrim' && (await states()) === statesBefore
&& (await dialogKinds()).join() === dialogsBefore.join();
} else {
out.outsideTapClosesMenu = 'нет маркера под шапкой';
out.outsideTapSwallowed = 'нет маркера под шапкой';
}
// Много пространств с длинными названиями: строка одна, активная вкладка видна.
await page.evaluate(() => window.__hpTest.setServerConfig((cfg) => {
const base = cfg.spaces[0];
for (let i = 1; i <= 4; i++) {
cfg.spaces.push({ ...structuredClone(base), id: `smoke-long-${i}`, title: `Очень длинное пространство ${i}`, floor: undefined });
}
return cfg;
}));
await size(390);
const lastId = await page.evaluate(() => [...window.__card.shadowRoot.querySelectorAll('[data-hp="space-tab"]')].at(-1)?.dataset.id);
await page.evaluate((id) => window.__hpTest.switchSpace(id), lastId);
await page.waitForTimeout(200);
const tabVisible = () => page.evaluate(() => {
const sr = window.__card.shadowRoot;
const nav = sr.querySelector('.head .tabs').getBoundingClientRect();
const tab = sr.querySelector('.head .tab.active').getBoundingClientRect();
return tab.left >= nav.left - 0.5 && tab.right <= nav.right + 0.5;
});
const many = await header();
out.manySpacesOneRow = many.oneRow && many.height <= 56;
out.activeTabRevealedOnSwitch = await tabVisible();
const firstId = await page.evaluate(() => window.__card.shadowRoot.querySelector('[data-hp="space-tab"]').dataset.id);
await page.evaluate((id) => window.__hpTest.switchSpace(id), firstId);
await page.waitForTimeout(200);
out.activeTabRevealedBack = await tabVisible();
// > 480 px: прежняя шапка, кнопки меню не видно.
for (const width of [481, 768, 1400]) {
await size(width);
const w = await header();
out[`admin${width}_unchanged`] = !w.gear && w.inlineVisible.length === 2 && w.title ? true : JSON.stringify(w);
}
// ---- не-админ ---------------------------------------------------------------
await page.evaluate(async () => {
const base = window.__mkHass();
const hass = {
...base, user: { id: 'household', name: 'Household', is_admin: false },
callWS: async (m) => {
const r = await base.callWS(m);
return m.type === 'houseplan/config/get' ? { ...r, can_write: false } : r;
},
};
const host = document.getElementById('host');
host.replaceChildren();
const card = document.createElement('houseplan-card');
card.setConfig({ type: 'custom:houseplan-card', title: 'House Plan' });
host.append(card);
card.hass = hass;
window.__card = card;
const until = Date.now() + 8000;
while (Date.now() < until && !card.shadowRoot?.querySelector('[data-hp="space-tab"]')) await new Promise((r) => setTimeout(r, 50));
await new Promise((r) => setTimeout(r, 600));
});
for (const width of [390, 320]) {
await size(width);
const h = await header();
out[`household${width}_oneRow56`] = h.oneRow && h.height <= 56;
out[`household${width}_rowContent`] = !h.title && h.tabs && h.zoom && h.gear && !h.tabEditInDom;
await openMenu();
const m = await menuState();
out[`household${width}_menuItems`] = m.ids.join(',') === 'summary-settings,summary-toggle' ? true : m.ids.join(',');
await page.keyboard.press('Escape');
await settle();
}
// «После загрузки на последнем»: вкладка запомнена, новая карточка открывает
// её же — и докручивает в видимую область без участия человека.
await size(390);
const lastHouseholdId = await page.evaluate(() => [...window.__card.shadowRoot.querySelectorAll('[data-hp="space-tab"]')].at(-1)?.dataset.id);
await page.evaluate((id) => window.__hpTest.switchSpace(id), lastHouseholdId);
await page.evaluate(async () => {
const old = window.__card;
const hass = old.hass;
const host = document.getElementById('host');
host.replaceChildren();
const card = document.createElement('houseplan-card');
card.setConfig({ type: 'custom:houseplan-card', title: 'House Plan' });
host.append(card);
card.hass = hass;
window.__card = card;
const until = Date.now() + 8000;
while (Date.now() < until && !card.shadowRoot?.querySelector('.head .tab.active')) await new Promise((r) => setTimeout(r, 50));
await new Promise((r) => setTimeout(r, 800));
});
out.reloadOpensLastSpace = await page.evaluate((id) => window.__card.shadowRoot.querySelector('.head .tab.active')?.dataset.id === id, lastHouseholdId);
out.activeTabRevealedOnLoad = await tabVisible();
await size(1400);
const wide = await header();
out.household1400_noTabEditing = !wide.tabEditInDom && !wide.gear;
// ---- киоск: шапки нет, меню не рендерится ------------------------------------
await size(390);
await page.evaluate(async () => {
const host = document.getElementById('host');
host.replaceChildren();
const card = document.createElement('houseplan-card');
card.setConfig({ type: 'custom:houseplan-card', kiosk: true });
host.append(card);
card.hass = window.__mkHass();
window.__card = card;
const until = Date.now() + 8000;
while (Date.now() < until && !card.shadowRoot?.querySelector('.hdr')) await new Promise((r) => setTimeout(r, 50));
await new Promise((r) => setTimeout(r, 400));
});
out.kioskNoMenu = await page.evaluate(() => {
const sr = window.__card.shadowRoot;
return getComputedStyle(sr.querySelector('.hdr')).display === 'none' && !sr.querySelector('[data-hp="header-menu"]');
});
for (const [name, value] of Object.entries(out)) check(name, value);
await finish(browser, out);
+2 -1
View File
@@ -10,7 +10,8 @@ const st = () => page.evaluate(() => {
mode: c._mode,
modeTabs: sr.querySelectorAll('.modetab').length,
active: sr.querySelector('.modetab.active')?.textContent.trim(),
editBtns: sr.querySelectorAll('.head .btn:not(.zb)').length,
// #616: the phone gear (.header-menu-button) is in the DOM but hidden above 480 px.
editBtns: sr.querySelectorAll('.head .btn:not(.zb):not(.header-menu-button)').length,
gears: sr.querySelectorAll('.tabedit').length,
// The last bar stays mounted for the exit animation; only an open chrome
// is an active/visible editor bar.
+11 -2
View File
@@ -426,7 +426,16 @@ const responsive = async (viewport) => {
if (card._supportDialog) await card._editorRuntime._closeSupportDialog();
card._setMode('view');
await card.updateComplete;
const button = card.renderRoot.querySelector('.support-button');
// #616: at ≤ 480 px the entry point is the header menu item, not the header
// button (hidden there); its hit target is what the phone user taps.
let button = card.renderRoot.querySelector('.support-button');
let entryRect = null;
if (innerWidth <= 480) {
card.renderRoot.querySelector('[data-hp="header-menu"]')?.click();
await card.updateComplete;
button = card.renderRoot.querySelector('[data-hp="header-menu-item"][data-id="support"]');
entryRect = button?.getBoundingClientRect();
}
button?.click();
await card.updateComplete;
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
@@ -434,7 +443,7 @@ const responsive = async (viewport) => {
const body = dialog?.querySelector('.supportbody');
const footer = dialog?.querySelector('.supportfooter');
const dialogRect = dialog?.getBoundingClientRect();
const buttonRect = button?.getBoundingClientRect();
const buttonRect = entryRect ?? button?.getBoundingClientRect();
return !!dialog && !!body && !!footer && !!dialogRect && !!buttonRect
&& dialogRect.left >= -1 && dialogRect.right <= innerWidth + 1
&& dialogRect.top >= -1 && dialogRect.bottom <= innerHeight + 1
+9 -3
View File
@@ -21,13 +21,18 @@ for (const width of WIDTHS) {
const head = () => sr().querySelector('.head');
const slot = () => sr().querySelector('.editor-close-slot');
const cross = () => sr().querySelector('.editor-close-slot .closex');
const tabs = () => [...sr().querySelectorAll('[data-hp="mode-tab"]')];
// #616: на ≤ 480 px кнопки режимов живут в меню шестерёнки; соседи × в строке —
// вкладки пространств, зум и шестерёнка, их положение и меряется.
const phone = width <= 480;
const tabs = () => [...sr().querySelectorAll(phone
? '.head > .tabs, .head > .zoomctl, [data-hp="header-menu"]' : '[data-hp="mode-tab"]')];
await hp.setMode('view');
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
// Позиции — относительно левого края шапки: прокрутка страницы их не меняет.
const measure = () => {
const origin = head().getBoundingClientRect().left;
const nextEl = slot()?.nextElementSibling;
let nextEl = slot()?.nextElementSibling;
while (nextEl && !nextEl.getClientRects().length) nextEl = nextEl.nextElementSibling;
return {
head: head().getBoundingClientRect().width,
tabs: tabs().map((t) => t.getBoundingClientRect().left - origin),
@@ -35,7 +40,8 @@ for (const width of WIDTHS) {
next: nextEl ? nextEl.getBoundingClientRect().left - origin : null,
};
};
const visibleTabs = tabs().filter((t) => t.getBoundingClientRect().width > 0).length === 3;
const visibleTabs = tabs().filter((t) => t.getBoundingClientRect().width > 0).length === 3
&& (!phone || [...sr().querySelectorAll('[data-hp="mode-tab"]')].every((t) => !t.getClientRects().length));
o.tabsVisible = visibleTabs;
// AC1: числа нет — ни узла, ни текста вида «N dev.».