feat: отполировать главную панель редакторов (#660)

Issue: #660
User-Visible: yes
This commit is contained in:
Sergey Matyunin
2026-09-26 08:51:15 +00:00
committed by claude[bot]
parent cff83e43ec
commit d3c88f06f9
14 changed files with 225 additions and 63 deletions
+34 -4
View File
@@ -15,8 +15,8 @@ const res = await page.evaluate(async () => {
// 1) две вкладки, Просмотра нет, крестиков в неактивных нет // 1) две вкладки, Просмотра нет, крестиков в неактивных нет
out.twoTabs = tabs().length === 3; // третья — Редактор подложки (v1.33.0) out.twoTabs = tabs().length === 3; // третья — Редактор подложки (v1.33.0)
out.labels = tabs().map((t) => t.textContent.trim()); out.labels = tabs().map((t) => t.textContent.trim());
// #647: X no longer lives inside a mode tab; its own slot after the tabs is // #660: X does not live inside a mode button; its slot follows the active
// empty (but keeps its size) outside an editor. // button and moves to the group end (empty but same size) in View.
out.noCrossIdle = sr().querySelectorAll('.modetab .closex').length === 0 out.noCrossIdle = sr().querySelectorAll('.modetab .closex').length === 0
&& !sr().querySelector('.editor-close-slot .closex'); && !sr().querySelector('.editor-close-slot .closex');
out.startView = c._mode === 'view'; out.startView = c._mode === 'view';
@@ -51,6 +51,14 @@ const res = await page.evaluate(async () => {
&& Math.abs((barCloseIconRect.top + barCloseIconRect.height / 2) && Math.abs((barCloseIconRect.top + barCloseIconRect.height / 2)
- (barCloseRect.top + barCloseRect.height / 2)) <= 1; - (barCloseRect.top + barCloseRect.height / 2)) <= 1;
out.tabCross = !!headerCross() && !tabs()[0].querySelector('.closex'); out.tabCross = !!headerCross() && !tabs()[0].querySelector('.closex');
const planSummary = sr().querySelector('.summary-control');
const summaryToggle = planSummary?.querySelector('button:last-child');
const summaryBefore = summaryToggle?.getAttribute('aria-pressed');
summaryToggle?.click(); await c.updateComplete;
out.summaryControlsStayInPlan = planSummary?.querySelectorAll('button').length === 2
&& summaryToggle?.getAttribute('aria-pressed') !== summaryBefore
&& !sr().querySelector('.summary-overlay');
summaryToggle?.click(); await c.updateComplete;
// 3) повторный клик по активной вкладке — ничего // 3) повторный клик по активной вкладке — ничего
tabs()[0].click(); await c.updateComplete; tabs()[0].click(); await c.updateComplete;
out.reclickNoop = c._mode === 'plan'; out.reclickNoop = c._mode === 'plan';
@@ -198,8 +206,8 @@ const res = await page.evaluate(async () => {
out.barCloseWorks = c._mode === 'view' out.barCloseWorks = c._mode === 'view'
&& getComputedStyle(chrome).visibility === 'hidden' && getComputedStyle(chrome).visibility === 'hidden'
&& chrome.getBoundingClientRect().height < 1; && chrome.getBoundingClientRect().height < 1;
// 7) #647 (+#195): the header X sits in a fixed 24 x 24 slot after the mode // 7) #660 (+#195): the header X sits in a fixed 24 x 24 slot after the active
// tabs; the slot is its hit target around a 13 px glyph and has the same size // mode; the slot is its hit target around a 13 px glyph and has the same size
// outside an editor, so the header never changes width. Exercise the slot's // outside an editor, so the header never changes width. Exercise the slot's
// edge rather than calling the element's centre programmatically. // edge rather than calling the element's centre programmatically.
const tabCrossChecks = []; const tabCrossChecks = [];
@@ -210,6 +218,8 @@ const res = await page.evaluate(async () => {
const slot = sr().querySelector('.editor-close-slot').getBoundingClientRect(); const slot = sr().querySelector('.editor-close-slot').getBoundingClientRect();
const glyph = cross.querySelector('ha-icon'); const glyph = cross.querySelector('ha-icon');
const hit = sr().elementFromPoint(rect.left + 1, rect.top + rect.height / 2); const hit = sr().elementFromPoint(rect.left + 1, rect.top + rect.height / 2);
const editorSummaryVisible = sr().querySelectorAll('.summary-control button').length === 2
&& !sr().querySelector('.summary-overlay');
const glyphStays13 = getComputedStyle(glyph).getPropertyValue('--mdc-icon-size').trim() === '13px'; const glyphStays13 = getComputedStyle(glyph).getPropertyValue('--mdc-icon-size').trim() === '13px';
hit?.dispatchEvent(new MouseEvent('click', { hit?.dispatchEvent(new MouseEvent('click', {
bubbles: true, bubbles: true,
@@ -226,6 +236,7 @@ const res = await page.evaluate(async () => {
footprint: Math.abs(slot.width - idleSlot.width) <= 0.5 footprint: Math.abs(slot.width - idleSlot.width) <= 0.5
&& Math.abs(slot.height - idleSlot.height) <= 0.5 && Math.abs(slot.width - 24) <= 0.5, && Math.abs(slot.height - idleSlot.height) <= 0.5 && Math.abs(slot.width - 24) <= 0.5,
edgeHit: !!hit && (hit === cross || cross.contains(hit)), edgeHit: !!hit && (hit === cross || cross.contains(hit)),
summary: editorSummaryVisible,
closed: c._mode === 'view', closed: c._mode === 'view',
}); });
} }
@@ -234,6 +245,7 @@ const res = await page.evaluate(async () => {
out.tabCrossKeepsLayoutFootprint = tabCrossChecks.every((check) => check.footprint); out.tabCrossKeepsLayoutFootprint = tabCrossChecks.every((check) => check.footprint);
out.tabCrossExpandedEdgeWorks = tabCrossChecks.every((check) => check.edgeHit && check.closed); out.tabCrossExpandedEdgeWorks = tabCrossChecks.every((check) => check.edgeHit && check.closed);
out.tabCrossWorks = tabCrossChecks.every((check) => check.closed); out.tabCrossWorks = tabCrossChecks.every((check) => check.closed);
out.summaryControlsStayInEveryEditor = tabCrossChecks.every((check) => check.summary);
// Closing the editor only drops session state: every accepted segment was // Closing the editor only drops session state: every accepted segment was
// already persisted as an ordinary partition by its own click. // already persisted as an ordinary partition by its own click.
@@ -358,6 +370,24 @@ const res = await page.evaluate(async () => {
c._roomDialog = savedRoomDialog; c._roomDialog = savedRoomDialog;
c._nameSel = savedNameSel; c._nameSel = savedNameSel;
c._areaSel = savedAreaSel; c._areaSel = savedAreaSel;
// #660: an internal Plan/Background action consumes the first Escape; the
// next neutral Escape leaves the editor. Device editor exits immediately.
c._setMode('plan', false); await settleMode();
c._tool = 'opening'; c.requestUpdate(); await c.updateComplete;
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); await c.updateComplete;
out.planEscapeKeepsInternalPriority = c._mode === 'plan' && c._tool === 'draw';
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); await settleMode();
out.planNeutralEscapeExits = c._mode === 'view';
c._setMode('devices', false); await settleMode();
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); await settleMode();
out.deviceNeutralEscapeExits = c._mode === 'view';
c._setMode('decor', false); await settleMode();
c._decorTool = 'line'; c.requestUpdate(); await c.updateComplete;
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); await c.updateComplete;
out.decorEscapeKeepsInternalPriority = c._mode === 'decor' && c._decorTool === 'select';
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); await settleMode();
out.decorNeutralEscapeExits = c._mode === 'view';
return out; return out;
}); });
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1) // значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
+7 -3
View File
@@ -28,9 +28,12 @@ const header = () => page.evaluate(() => {
title: shown(head.querySelector(':scope > .title')), title: shown(head.querySelector(':scope > .title')),
tabs: shown(head.querySelector(':scope > .tabs')), tabs: shown(head.querySelector(':scope > .tabs')),
zoom: shown(head.querySelector(':scope > .zoomctl')), zoom: shown(head.querySelector(':scope > .zoomctl')),
modeSlot: shown(head.querySelector(':scope > .modes'))
&& Math.abs(head.querySelector(':scope > .modes').getBoundingClientRect().width - 24) <= 0.5,
cross: shown(head.querySelector('[data-hp="editor-close"]')),
gear: shown(gear), gear: shown(gear),
gear44: !!g && g.width >= 44 && g.height >= 44, gear44: !!g && g.width >= 44 && g.height >= 44,
hiddenInline: ['.modes', '.header-action', '.summary-control', '.tabedit', '.tabadd'] hiddenInline: ['.modes .modetab', '.header-action', '.summary-control', '.tabedit', '.tabadd']
.filter((selector) => [...head.querySelectorAll(selector)].some(shown)), .filter((selector) => [...head.querySelectorAll(selector)].some(shown)),
tabEditInDom: !!head.querySelector('.tabedit, .tabadd'), tabEditInDom: !!head.querySelector('.tabedit, .tabadd'),
inlineVisible: ['.header-action', '.summary-control'].filter((selector) => [...head.querySelectorAll(selector)].some(shown)), inlineVisible: ['.header-action', '.summary-control'].filter((selector) => [...head.querySelectorAll(selector)].some(shown)),
@@ -85,7 +88,7 @@ for (const width of [390, 320]) {
await size(width); await size(width);
const h = await header(); const h = await header();
out[`admin${width}_oneRow56`] = h.oneRow && h.height <= 56; 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}_rowContent`] = !h.title && h.tabs && h.modeSlot && h.zoom && h.gear && h.gear44;
out[`admin${width}_inlineHidden`] = h.hiddenInline.length === 0 ? true : h.hiddenInline.join(','); out[`admin${width}_inlineHidden`] = h.hiddenInline.length === 0 ? true : h.hiddenInline.join(',');
await openMenu(); await openMenu();
const m = await menuState(); const m = await menuState();
@@ -135,7 +138,8 @@ await clickAt('[data-hp="header-menu-item"][data-id="mode-plan"]');
await page.waitForTimeout(300); await page.waitForTimeout(300);
out.editorFromMenu = (await mode()) === 'plan'; out.editorFromMenu = (await mode()) === 'plan';
const inEditor = await header(); const inEditor = await header();
out.editorOneRow56 = inEditor.oneRow && inEditor.height <= 56 && inEditor.hiddenInline.length === 0; out.editorOneRow56 = inEditor.oneRow && inEditor.height <= 56 && inEditor.modeSlot
&& inEditor.cross && inEditor.hiddenInline.length === 0;
await openMenu(); await openMenu();
const editorMenu = await menuState(); const editorMenu = await menuState();
out.editorMenuMarksCurrent = editorMenu.current.join() === 'mode-plan' && !editorMenu.ids.includes('summary-toggle'); out.editorMenuMarksCurrent = editorMenu.current.join() === 'mode-plan' && !editorMenu.ids.includes('summary-toggle');
+27 -11
View File
@@ -1,5 +1,5 @@
// #647: основная панель — нет счётчика устройств, крестик редактора в своём // #647/#660: основная панель — нет счётчика устройств, крестик редактора в
// слоте, ширина шапки и положение кнопок режимов не меняются при входе, // постоянном слоте внутри группы режимов, ширина шапки и группы не меняются,
// выходе и переключении редакторов; кликабельная зона × не меньше 24 × 24 // выходе и переключении редакторов; кликабельная зона × не меньше 24 × 24
// (#195); × виден на обычных ширинах (721–1100 px — там раньше скрывался // (#195); × виден на обычных ширинах (721–1100 px — там раньше скрывался
// счётчик). Режимы переключаются настоящими кнопками через фасад #629; // счётчик). Режимы переключаются настоящими кнопками через фасад #629;
@@ -8,7 +8,7 @@ import { launch, check, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 1400, height: 820 }, 1); const { page, browser } = await launch({ width: 1400, height: 820 }, 1);
const WIDTHS = [1400, 1000, 768, 390]; const WIDTHS = [1400, 1200, 1000, 768, 620, 481, 390];
const out = {}; const out = {};
for (const width of WIDTHS) { for (const width of WIDTHS) {
@@ -31,13 +31,22 @@ for (const width of WIDTHS) {
// Позиции — относительно левого края шапки: прокрутка страницы их не меняет. // Позиции — относительно левого края шапки: прокрутка страницы их не меняет.
const measure = () => { const measure = () => {
const origin = head().getBoundingClientRect().left; const origin = head().getBoundingClientRect().left;
let nextEl = slot()?.nextElementSibling; const modes = sr().querySelector('.modes');
while (nextEl && !nextEl.getClientRects().length) nextEl = nextEl.nextElementSibling; const zoom = sr().querySelector('.zoomctl');
const modesBox = modes?.getBoundingClientRect();
const zoomBox = zoom?.getBoundingClientRect();
const active = modes?.querySelector('.modetab.active');
return { return {
head: head().getBoundingClientRect().width, head: head().getBoundingClientRect().width,
tabs: tabs().map((t) => t.getBoundingClientRect().left - origin), modesLeft: modesBox ? modesBox.left - origin : null,
modesWidth: modesBox?.width ?? null,
zoomLeft: zoomBox ? zoomBox.left - origin : null,
gap: modesBox && zoomBox ? zoomBox.left - modesBox.right : null,
slot: slot()?.getBoundingClientRect(), slot: slot()?.getBoundingClientRect(),
next: nextEl ? nextEl.getBoundingClientRect().left - origin : null, slotInside: slot()?.parentElement === modes,
slotOrder: c._mode === 'view'
? slot() === modes?.lastElementChild
: slot()?.previousElementSibling === active,
}; };
}; };
const visibleTabs = tabs().filter((t) => t.getBoundingClientRect().width > 0).length === 3 const visibleTabs = tabs().filter((t) => t.getBoundingClientRect().width > 0).length === 3
@@ -56,8 +65,9 @@ for (const width of WIDTHS) {
o.idleSlotKeepsSize = !!idle.slot && Math.abs(idle.slot.width - 24) <= 0.5 && Math.abs(idle.slot.height - 24) <= 0.5; o.idleSlotKeepsSize = !!idle.slot && Math.abs(idle.slot.width - 24) <= 0.5 && Math.abs(idle.slot.height - 24) <= 0.5;
const same = (a, b) => Math.abs(a.head - b.head) <= 1 const same = (a, b) => Math.abs(a.head - b.head) <= 1
&& a.tabs.every((left, i) => Math.abs(left - b.tabs[i]) <= 1) && Math.abs(a.modesLeft - b.modesLeft) <= 1
&& (a.next === null || Math.abs(a.next - b.next) <= 1); && Math.abs(a.modesWidth - b.modesWidth) <= 1
&& Math.abs(a.zoomLeft - b.zoomLeft) <= 1;
const steps = []; const steps = [];
const hits = []; const hits = [];
// View → каждый редактор → View, и прямые переключения между редакторами. // View → каждый редактор → View, и прямые переключения между редакторами.
@@ -66,7 +76,9 @@ for (const width of WIDTHS) {
await hp.setMode(next); await hp.setMode(next);
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
const now = measure(); const now = measure();
steps.push({ next, stable: same(idle, now) }); const expectedGap = width <= 620 ? 40.5 : 50;
steps.push({ next, stable: same(idle, now), inside: now.slotInside,
order: now.slotOrder, gap: phone || Math.abs(now.gap - expectedGap) <= 1 });
if (next !== 'view' && cross()) { if (next !== 'view' && cross()) {
// На 390 px карточка демо-стенда шире окна (так и на dev) — докрутить × в видимую область. // На 390 px карточка демо-стенда шире окна (так и на dev) — докрутить × в видимую область.
cross().scrollIntoView({ block: 'nearest', inline: 'nearest' }); cross().scrollIntoView({ block: 'nearest', inline: 'nearest' });
@@ -81,8 +93,12 @@ for (const width of WIDTHS) {
} }
} }
} }
// AC2/AC3: ширина шапки и левые границы кнопок режимов и контрола справа стабильны. // AC2/AC3: ширина шапки, группы режимов и контрола справа стабильны.
o.widthAndTabsStable = steps.every((s) => s.stable); o.widthAndTabsStable = steps.every((s) => s.stable);
o.closeSlotLivesInsideModes = idle.slotInside && idle.slotOrder
&& steps.every((s) => s.inside && s.order);
o.modeToZoomGapMatchesSpec = (phone || Math.abs(idle.gap - (width <= 620 ? 40.5 : 50)) <= 1)
&& steps.every((s) => s.gap);
// AC5/AC6: × виден, ≥ 24 × 24, глиф 13 px, центр слота — это ×, кнопки режимов без ×. // AC5/AC6: × виден, ≥ 24 × 24, глиф 13 px, центр слота — это ×, кнопки режимов без ×.
o.crossVisibleAndSized = hits.length === 7 && hits.every((h) => h.visible && h.glyph13); o.crossVisibleAndSized = hits.length === 7 && hits.every((h) => h.visible && h.glyph13);
o.crossHitTarget = hits.every((h) => h.centreHitsCross); o.crossHitTarget = hits.every((h) => h.centreHitsCross);
+8
View File
@@ -2,6 +2,14 @@
## Unreleased ## Unreleased
- The main toolbar is steadier and more compact: its editor close **×** now
occupies the reserved place immediately after the active editor button, the
gap before zoom is halved, and the header width stays fixed while switching
modes. On desktop the summary-panel settings and show/hide buttons remain
available in every editor (the panel itself still appears only in View),
while the phone menu remains View-only. Escape now leaves any editor after
first dismissing its active tool, selection, drag, popover or dialog
([#660](https://github.com/Matysh/houseplan-card/issues/660)).
- A cold dashboard load with 2.5D enabled no longer briefly shows the flat plan - A cold dashboard load with 2.5D enabled no longer briefly shows the flat plan
or classifies every floor as dark on its first frame. The neutral House Plan or classifies every floor as dark on its first frame. The neutral House Plan
loading surface now stays in place until the 2.5D renderer and the actual plan loading surface now stays in place until the 2.5D renderer and the actual plan
+9
View File
@@ -8,6 +8,15 @@
## Не выпущено ## Не выпущено
- Основная панель стала компактнее и перестала дёргаться: крестик **×**
редактора теперь занимает зарезервированное место сразу после активной
кнопки редактора, расстояние до масштаба уменьшено вдвое, а общая ширина не
меняется при переключении режимов. На широком экране настройки и показ
сводной панели доступны во всех редакторах (сама панель появляется только в
Просмотре), а меню телефона остаётся прежним и показывает их только в
Просмотре. Escape теперь выходит из любого редактора, предварительно закрыв
активный инструмент, выбор, перетаскивание, меню или диалог
([#660](https://github.com/Matysh/houseplan-card/issues/660)).
- При холодной загрузке дашборда включённый объёмный план (2.5D) больше не - При холодной загрузке дашборда включённый объёмный план (2.5D) больше не
успевает показать обычный плоский вид и не считает все полы тёмными на первом успевает показать обычный плоский вид и не считает все полы тёмными на первом
кадре. Нейтральная заставка House Plan остаётся до готовности 2.5D-рендера и кадре. Нейтральная заставка House Plan остаётся до готовности 2.5D-рендера и
+7 -2
View File
@@ -240,6 +240,8 @@ product surfaces after setup.
## 5. Interface modes ## 5. Interface modes
View is the state with no editor open. Close the active editor to return to it. View is the state with no editor open. Close the active editor to return to it.
Escape first cancels the current dialog, popover, drag, selection or tool; press
it again after the editor is neutral to return to View.
| Mode | Devices | Geometry | Background/openings | | Mode | Devices | Geometry | Background/openings |
|---|---|---|---| |---|---|---|---|
@@ -298,8 +300,11 @@ bottom otherwise. It temporarily hides when the card cannot fit a readable
panel, and restores itself after the card grows. Turning off **Display on panel, and restores itself after the card grows. Turning off **Display on
mobile devices** also hides it whenever Home Assistant reports a narrow view; mobile devices** also hides it whenever Home Assistant reports a narrow view;
the show/hide button remains pressed because the local choice was not erased. the show/hide button remains pressed because the local choice was not erased.
The panel and its controls are absent from all three editors and from the The panel itself is absent from all three editors and from the static space
static space card. card. On a screen wider than 480 px, its two header buttons stay available in
the editors: you can open settings or change the local show/hide choice there,
but the panel appears only after you return to View. On a phone those two gear
menu items remain available only in View.
The room highlight remains available in View and kiosk. To keep that highlight The room highlight remains available in View and kiosk. To keep that highlight
but hide the floating room summary, turn off **General settings → Show the room but hide the floating room summary, turn off **General settings → Show the room
+9 -3
View File
@@ -252,6 +252,8 @@ title: План дома
Редакторы переключаются напрямую. Новая панель инструментов коротко проявляется, Редакторы переключаются напрямую. Новая панель инструментов коротко проявляется,
а её фактическая высота плавно подстраивается даже при переносе кнопок на несколько строк; а её фактическая высота плавно подстраивается даже при переносе кнопок на несколько строк;
при системной настройке уменьшения движения переход отключается. при системной настройке уменьшения движения переход отключается.
Escape сначала отменяет активный диалог, меню, перетаскивание, выбор или
инструмент; следующее нажатие в нейтральном состоянии возвращает в Просмотр.
| Режим | Основное назначение | Устройства | Комнаты/стены | Декор и проёмы | | Режим | Основное назначение | Устройства | Комнаты/стены | Декор и проёмы |
|---|---|---|---|---| |---|---|---|---|---|
@@ -274,7 +276,8 @@ title: План дома
просмотре любому пользователю — **Настройки сводной панели** и показ или просмотре любому пользователю — **Настройки сводной панели** и показ или
скрытие сводной панели. Пункт делает то же, что прежняя кнопка, и закрывает скрытие сводной панели. Пункт делает то же, что прежняя кнопка, и закрывает
меню; Escape или касание вне меню просто закрывают его — касание при этом не меню; Escape или касание вне меню просто закрывают его — касание при этом не
нажимает ничего на плане. В редакторе крестик закрытия остаётся в строке. На нажимает ничего на плане. В редакторе крестик закрытия остаётся в строке, хотя
сами кнопки редакторов находятся только в меню. На
более широком экране шапка прежняя, в киоске её нет. более широком экране шапка прежняя, в киоске её нет.
### Сводная панель ### Сводная панель
@@ -327,8 +330,11 @@ Escape и закрытие диалога отбрасывают изменен
снизу. В слишком маленькой карточке она временно скрывается и возвращается при снизу. В слишком маленькой карточке она временно скрывается и возвращается при
увеличении. Выключенная общая опция **Отображать на мобильных устройствах** увеличении. Выключенная общая опция **Отображать на мобильных устройствах**
также скрывает панель, когда Home Assistant сообщает об узком режиме; кнопка также скрывает панель, когда Home Assistant сообщает об узком режиме; кнопка
остаётся включённой, потому что локальный выбор не сброшен. В трёх редакторах остаётся включённой, потому что локальный выбор не сброшен. Сама панель в трёх
и статической карточке панели и её кнопок нет. редакторах и статической карточке не показывается. На экране шире 480 px обе её
кнопки в шапке остаются доступны и в редакторах: можно открыть настройки или
изменить локальный выбор показа, но панель появится только после возврата в
Просмотр. В телефонном меню эти два пункта по-прежнему есть только в Просмотре.
Подсветка комнаты сохраняется в Просмотре и киоске. Чтобы оставить подсветку, Подсветка комнаты сохраняется в Просмотре и киоске. Чтобы оставить подсветку,
но убрать всплывающее описание комнаты, выключите **Общие настройки → но убрать всплывающее описание комнаты, выключите **Общие настройки →
+17 -9
View File
@@ -53,12 +53,14 @@ inert everywhere outside its editor).
background and editor-specific layers move on one short timeline, so opening background and editor-specific layers move on one short timeline, so opening
an editor never flashes a default scale or a mixed dark/white frame. A rapid an editor never flashes a default scale or a mixed dark/white frame. A rapid
second choice retargets from the visible intermediate frame. Reduced-motion second choice retargets from the visible intermediate frame. Reduced-motion
preferences apply the same final state immediately. The header X sits in preferences apply the same final state immediately. The header X sits in a
its own fixed 24 × 24 px slot right after the mode tabs (#647): the slot keeps fixed 24 × 24 px slot inside the segmented control, immediately after the
its size, empty and hidden from assistive technology, outside the editors, active editor (#660); in View the same empty, assistive-technology-hidden
so entering, leaving or switching editors never changes the header width or reserve sits at the end. The group width therefore stays fixed while buttons
moves a tab; the X keeps its compact 13 px glyph and the whole slot is its to the right of the active editor yield the reserved place. The X keeps its
hit target (≥ 24 × 24 px, #195). compact 13 px glyph and the whole slot is its hit target (≥ 24 × 24 px,
#195). On a phone the mode buttons remain in the gear menu, while this one
slot stays visible in the header so the editor still has a direct exit.
- An editor's primary toolbar contains only persistent tools. Close is pinned - An editor's primary toolbar contains only persistent tools. Close is pinned
in its own end cap. Selection actions, active-tool parameters, operation in its own end cap. Selection actions, active-tool parameters, operation
hints and palettes appear in one translucent context tray over the top of the hints and palettes appear in one translucent context tray over the top of the
@@ -121,8 +123,11 @@ local show and View-size controls only. In a landscape/square House Plan stage
the panel is on the right; in a portrait stage it is centred at the bottom. the panel is on the right; in a portrait stage it is centred at the bottom.
Native HA `narrow` controls the independent mobile permission. Insufficient Native HA `narrow` controls the independent mobile permission. Insufficient
measured stage space temporarily hides the overlay without changing the saved measured stage space temporarily hides the overlay without changing the saved
local choice. All summary surfaces disappear in Plan, Devices and Background; local choice. The overlay remains View-only. On windows wider than 480 px its
`houseplan-space-card` never renders them. settings and local show/hide buttons stay in the header in all three editors:
settings can be opened there and the local choice can be changed, but the
overlay appears only after returning to View. The phone gear keeps those two
entries View-only. `houseplan-space-card` never renders any summary surface.
The admin-only **General settings → Show Zigbee links on device hover** option The admin-only **General settings → Show Zigbee links on device hover** option
is off by default. Once an admin explicitly reads cached ZHA data or updates a is off by default. Once an admin explicitly reads cached ZHA data or updates a
@@ -142,7 +147,10 @@ Removed from this mode (they move, not die):
- every edit button in the header (+device, 👁 show-all, ↺ reset, ⬡ rules, ⚙ general, - every edit button in the header (+device, 👁 show-all, ↺ reset, ⬡ rules, ⚙ general,
per-space gear, markup toggle). per-space gear, markup toggle).
Header in View: space tabs, summary control and zoom cluster; on a phone (≤ 480 px) space tabs, zoom and one gear whose menu holds the rest (#616). Header in View: space tabs, summary control and zoom cluster. On wider screens
the summary control also remains available in editors; the fixed distance from
the editor group to zoom is half the former gap. On a phone (≤ 480 px): space
tabs, the editor-close reserve, zoom and one gear whose menu holds the rest.
## Plan — geometry and appearance of the space ## Plan — geometry and appearance of the space
+64 -8
View File
@@ -7568,8 +7568,8 @@ const MUTANT_DEFINITIONS = [
because: '#647 AC1: the header must not show a device count in any mode or locale', because: '#647 AC1: the header must not show a device count in any mode or locale',
patches: [{ patches: [{
file: 'src/houseplan-card.ts', file: 'src/houseplan-card.ts',
find: " <span class=\"editor-close-slot\" aria-hidden=", find: " const editorClose = html`<span class=\"editor-close-slot\" aria-hidden=",
replace: " <span class=\"count\">${this._devices.length} dev.</span><span class=\"editor-close-slot\" aria-hidden=", replace: " const editorClose = html`<span class=\"count\">${this._devices.length} dev.</span><span class=\"editor-close-slot\" aria-hidden=",
}], }],
}, },
{ {
@@ -7588,8 +7588,8 @@ const MUTANT_DEFINITIONS = [
because: '#647 AC3: an X inside the active mode tab widens that tab and moves its neighbours', because: '#647 AC3: an X inside the active mode tab widens that tab and moves its neighbours',
patches: [{ patches: [{
file: 'src/houseplan-card.ts', file: 'src/houseplan-card.ts',
find: " <ha-icon icon=${ic}></ha-icon><span class=\"ml\">${this._t(('mode.' + m) as any)}</span>\n </button>`,", find: " <ha-icon icon=${ic}></ha-icon><span class=\"ml\">${this._t(('mode.' + m) as any)}</span>\n </button>${this._mode === m ? editorClose : nothing}`",
replace: " <ha-icon icon=${ic}></ha-icon><span class=\"ml\">${this._t(('mode.' + m) as any)}</span>${this._mode === m ? html`<ha-icon class=\"closex\" icon=\"mdi:close\"></ha-icon>` : nothing}\n </button>`,", replace: " <ha-icon icon=${ic}></ha-icon><span class=\"ml\">${this._t(('mode.' + m) as any)}</span>${this._mode === m ? html`<ha-icon class=\"closex\" icon=\"mdi:close\"></ha-icon>` : nothing}\n </button>${this._mode === m ? editorClose : nothing}`",
}], }],
}, },
{ {
@@ -7598,8 +7598,8 @@ const MUTANT_DEFINITIONS = [
because: '#647 AC4: outside an editor the reserve is inert and hidden from assistive technology', because: '#647 AC4: outside an editor the reserve is inert and hidden from assistive technology',
patches: [{ patches: [{
file: 'src/houseplan-card.ts', file: 'src/houseplan-card.ts',
find: " <span class=\"editor-close-slot\" aria-hidden=${this._mode === 'view' ? 'true' : nothing}>", find: "<span class=\"editor-close-slot\" aria-hidden=${this._mode === 'view' ? 'true' : nothing}>",
replace: " <span class=\"editor-close-slot\" tabindex=\"0\">", replace: "<span class=\"editor-close-slot\" tabindex=\"0\">",
}], }],
}, },
{ {
@@ -7622,14 +7622,70 @@ const MUTANT_DEFINITIONS = [
replace: " .editor-close-slot .closex ha-icon { --mdc-icon-size: 13px; }\n @media (max-width: 1100px) { .editor-close-slot .closex { display: none; } }", replace: " .editor-close-slot .closex ha-icon { --mdc-icon-size: 13px; }\n @media (max-width: 1100px) { .editor-close-slot .closex { display: none; } }",
}], }],
}, },
// #660: compact stable header, desktop summary controls and neutral Escape.
{
id: 'toolbar-close-slot-leaves-mode-group',
guard: 'node demo/smoke_toolbar_stable_width.mjs',
because: '#660 AC2: the one fixed X reserve belongs inside the segmented mode group, after the active button',
patches: [{
file: 'src/houseplan-card.ts',
find: ' </button>${this._mode === m ? editorClose : nothing}`',
replace: ' </button>`',
}, {
file: 'src/houseplan-card.ts',
find: " ${this._mode === 'view' ? editorClose : nothing}\n </div>`",
replace: ' </div>${editorClose}`',
}],
},
{
id: 'toolbar-mode-zoom-gap-regresses',
guard: 'node demo/smoke_toolbar_stable_width.mjs',
because: '#660 AC3: the modes-to-zoom distance is the specified compact 50/40.5 px, not the old flexible gap',
patches: [{
file: 'src/styles/chrome.styles.ts',
find: ' .head > .spacer { flex: 0 0 30px; }',
replace: ' .head > .spacer { flex: 0 0 80px; }',
}],
},
{
id: 'editor-summary-controls-return-to-view-only',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test test/summary-panel-runtime.test.mjs',
because: '#660 AC1: wide-screen inline summary actions remain usable in all three editors',
patches: [{
file: 'src/summary-panel-runtime-loaded.ts',
find: " if (kiosk && this.host._mode !== 'view') return nothing;",
replace: " if (this.host._mode !== 'view') return nothing;",
}],
},
{
id: 'phone-editor-close-hidden-with-mode-buttons',
guard: 'node demo/smoke_mobile_view_header.mjs',
because: '#660 AC4: hiding desktop mode buttons on a phone must leave the editor X slot visible',
patches: [{
file: 'src/styles/chrome.styles.ts',
find: ' .head > .title, .head > .spacer, .head > .header-action,',
replace: ' .head > .title, .head > .modes, .head > .spacer, .head > .header-action,',
}],
},
{
id: 'editor-neutral-escape-does-not-exit',
guard: 'node demo/smoke_editor_tabs.mjs',
because: '#660 AC5: after inner actions have had priority, a neutral Escape returns every editor to View',
patches: [{
file: 'src/houseplan-card.ts',
find: " e.preventDefault();\n this._setMode('view');\n }\n /** Remove the last transient point",
replace: " e.preventDefault();\n }\n /** Remove the last transient point",
}],
},
{ {
id: 'phone-header-shows-title', id: 'phone-header-shows-title',
guard: 'node demo/smoke_mobile_view_header.mjs', guard: 'node demo/smoke_mobile_view_header.mjs',
because: '#616 AC1: on a phone the card title alone takes a row; the one-row header must hide it', because: '#616 AC1: on a phone the card title alone takes a row; the one-row header must hide it',
patches: [{ patches: [{
file: 'src/styles/chrome.styles.ts', file: 'src/styles/chrome.styles.ts',
find: ' .head > .title, .head > .modes, .head > .spacer, .head > .header-action,', find: ' .head > .title, .head > .spacer, .head > .header-action,',
replace: ' .head > .modes, .head > .spacer, .head > .header-action,', replace: ' .head > .spacer, .head > .header-action,',
}], }],
}, },
{ {
+1 -1
View File
@@ -41,7 +41,7 @@ export type HeaderMenuInput = {
kiosk: boolean; kiosk: boolean;
mode: HeaderMode; mode: HeaderMode;
hasFixedFloor: boolean; hasFixedFloor: boolean;
/** Пункты сводной панели (её рантайм отдаёт их только в просмотре). */ /** Пункты сводной панели; мобильное меню добавляет их только в просмотре. */
summary: HeaderMenuItem[]; summary: HeaderMenuItem[];
t: (key: I18nKey) => string; t: (key: I18nKey) => string;
actions: HeaderMenuActions; actions: HeaderMenuActions;
+13 -13
View File
@@ -2764,7 +2764,6 @@ export class HouseplanCard extends LitElement {
return; return;
} }
if (e.key === 'Escape') { if (e.key === 'Escape') {
// close the topmost open dialog; info popups first, then editors
if (this._tapConfirm) { this._tapConfirm = null; return; } if (this._tapConfirm) { this._tapConfirm = null; return; }
if (this._vacCalConfirm) { if (this._vacCalConfirm) {
if (!this._vacCalConfirm.busy) this._vacCalConfirm = null; if (!this._vacCalConfirm.busy) this._vacCalConfirm = null;
@@ -2858,8 +2857,6 @@ export class HouseplanCard extends LitElement {
e.preventDefault(); e.preventDefault();
if (this._decorDraft) this._decorDraft = null; if (this._decorDraft) this._decorDraft = null;
else if (this._decorMove || this._dtDrag || this._bdDrag) this._cancelDecorGesture(); else if (this._decorMove || this._dtDrag || this._bdDrag) this._cancelDecorGesture();
// The palette is one explicit surface: Escape closes it and returns
// to Select in one step, regardless of whether a symbol was armed.
else if (this._decorTool === 'furniture') { else if (this._decorTool === 'furniture') {
this._editorRuntime?._clearFurniturePreview(); this._editorRuntime?._clearFurniturePreview();
this._editorRuntime?._furnShiftDetach(); // #369(д) r2-H1 this._editorRuntime?._furnShiftDetach(); // #369(д) r2-H1
@@ -2885,9 +2882,10 @@ export class HouseplanCard extends LitElement {
this._undoDevicePosition(); this._undoDevicePosition();
return; return;
} }
if (e.key === 'Escape' && this._deviceDrag) { if (e.key === 'Escape') {
e.preventDefault(); e.preventDefault();
this._cancelDeviceDrag(); if (this._deviceDrag) this._cancelDeviceDrag();
else this._setMode('view');
} }
return; return;
} }
@@ -2998,9 +2996,11 @@ export class HouseplanCard extends LitElement {
e.preventDefault(); e.preventDefault();
if (this._tool === 'opening') this._clearOpeningPlacement(true); if (this._tool === 'opening') this._clearOpeningPlacement(true);
this._tool = 'draw'; this._tool = 'draw';
return;
} }
e.preventDefault();
this._setMode('view');
} }
/** Remove the last transient point or undo the terminal persisted wall. */ /** Remove the last transient point or undo the terminal persisted wall. */
private _undoPoint(): void { private _undoPoint(): void {
if (!this._path.length) return; if (!this._path.length) return;
@@ -10743,6 +10743,10 @@ export class HouseplanCard extends LitElement {
); );
const transitionStageBg = modeVisual?.stageColor || stageBg; const transitionStageBg = modeVisual?.stageColor || stageBg;
const transitionBrightness = modeVisual?.sceneBrightness ?? 1; const transitionBrightness = modeVisual?.sceneBrightness ?? 1;
const editorClose = html`<span class="editor-close-slot" aria-hidden=${this._mode === 'view' ? 'true' : nothing}>${this._mode !== 'view' ? html`<button
class="closex" title=${this._t('title.close_editor')} aria-label=${this._t('title.close_editor')} data-hp="editor-close" data-editor-navigation="view"
@click=${(e: Event) => { e.stopPropagation(); this._setMode('view'); }}><ha-icon icon="mdi:close"></ha-icon></button>`
: nothing}</span>`;
return html` return html`
<ha-card <ha-card
data-hp-state=${this._booting ? 'booting' : 'ready'} data-hp-mode=${this._mode} data-hp-state=${this._booting ? 'booting' : 'ready'} data-hp-mode=${this._mode}
@@ -10816,14 +10820,10 @@ export class HouseplanCard extends LitElement {
title=${this._t(('mode.' + m + '_tip') as any)} title=${this._t(('mode.' + m + '_tip') as any)}
@click=${() => this._setMode(m)}> @click=${() => this._setMode(m)}>
<ha-icon icon=${ic}></ha-icon><span class="ml">${this._t(('mode.' + m) as any)}</span> <ha-icon icon=${ic}></ha-icon><span class="ml">${this._t(('mode.' + m) as any)}</span>
</button>`, </button>${this._mode === m ? editorClose : nothing}`,
)} )}
</div>${''/* #647: X owns a fixed slot (the old device-count place): header width never changes */} ${this._mode === 'view' ? editorClose : nothing}
<span class="editor-close-slot" aria-hidden=${this._mode === 'view' ? 'true' : nothing}>${this._mode !== 'view' </div>`
? html`<button class="closex" title=${this._t('title.close_editor')} aria-label=${this._t('title.close_editor')}
data-hp="editor-close" data-editor-navigation="view"
@click=${(e: Event) => { e.stopPropagation(); this._setMode('view'); }}><ha-icon icon="mdi:close"></ha-icon></button>`
: nothing}</span>`
: nothing} : nothing}
<span class="spacer"></span> <span class="spacer"></span>
<div class="zoomctl"> <div class="zoomctl">
+16 -6
View File
@@ -147,10 +147,22 @@ const chromeCoreStyles = css`
.header-menu-item.on { color: var(--hp-accent); font-weight: 600; } .header-menu-item.on { color: var(--hp-accent); font-weight: 600; }
:host([data-pointer-hover]) .header-menu-item:hover { background: rgba(127, 127, 127, 0.14); } :host([data-pointer-hover]) .header-menu-item:hover { background: rgba(127, 127, 127, 0.14); }
.header-menu-item:focus-visible { outline: 2px solid var(--hp-accent); outline-offset: -2px; } .header-menu-item:focus-visible { outline: 2px solid var(--hp-accent); outline-offset: -2px; }
.head > .spacer { flex: 0 0 30px; }
@media (max-width: 620px) and (min-width: 481px) {
.head > .spacer { flex-basis: 28.5px; }
}
@media (max-width: 480px) { @media (max-width: 480px) {
.hdr > .head { flex-wrap: nowrap; padding: 5px 8px; gap: 6px; } .hdr > .head { flex-wrap: nowrap; padding: 5px 8px; gap: 6px; }
.head > .title, .head > .modes, .head > .spacer, .head > .header-action, .head > .title, .head > .spacer, .head > .header-action,
.head > .summary-control { display: none; } .head > .summary-control { display: none; }
.head > .modes {
flex: 0 0 24px;
width: 24px;
padding: 0;
gap: 0;
background: transparent;
}
.head > .modes .modetab { display: none; }
.head > .tabs { .head > .tabs {
flex: 1 1 auto; flex: 1 1 auto;
min-width: 0; min-width: 0;
@@ -163,7 +175,7 @@ const chromeCoreStyles = css`
.head > .tabs .tab { flex: none; white-space: nowrap; max-width: 100%; } .head > .tabs .tab { flex: none; white-space: nowrap; max-width: 100%; }
.head > .tabs .tabtitle { min-width: 0; overflow: hidden; text-overflow: ellipsis; } .head > .tabs .tabtitle { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
.head > .tabs .tabedit, .head > .tabs .tabadd { display: none; } .head > .tabs .tabedit, .head > .tabs .tabadd { display: none; }
.head > .zoomctl, .head > .editor-close-slot { flex: none; } .head > .zoomctl, .head > .modes > .editor-close-slot { flex: none; }
.header-menu-wrap { display: inline-flex; } .header-menu-wrap { display: inline-flex; }
} }
.decorbar .dcolor { .decorbar .dcolor {
@@ -221,10 +233,8 @@ const chromeCoreStyles = css`
.tab.dragging { cursor: grabbing; opacity: 0.55; } .tab.dragging { cursor: grabbing; opacity: 0.55; }
.tab.drop-before { box-shadow: inset 2px 0 0 0 var(--primary-color, #03a9f4); } .tab.drop-before { box-shadow: inset 2px 0 0 0 var(--primary-color, #03a9f4); }
.tab.drop-after { box-shadow: inset -2px 0 0 0 var(--primary-color, #03a9f4); } .tab.drop-after { box-shadow: inset -2px 0 0 0 var(--primary-color, #03a9f4); }
/* #647: the editor X owns a fixed slot right after the mode tabs (where /* #660: the fixed X slot lives inside the mode group, immediately after
the device count used to be). One size for both states, so entering, the active editor; in View the same reserve sits at the group end. */
leaving or switching editors never changes the header width; the slot is
the X's pointer target (>= 24 x 24, #195) around a 13 px glyph. */
.editor-close-slot { .editor-close-slot {
--hp-editor-close-size: 24px; --hp-editor-close-size: 24px;
box-sizing: border-box; box-sizing: border-box;
+2 -3
View File
@@ -234,8 +234,7 @@ export class LoadedSummaryPanelRuntime {
</aside>`; </aside>`;
} }
/** #616: the same two actions as `renderControls` — items of the phone header menu /** #616: the same two actions as `renderControls`; the phone menu offers them only in View. */
* (the menu itself offers them only in View, as `renderControls` does). */
public menuItems(): HeaderMenuItem[] { public menuItems(): HeaderMenuItem[] {
const toggleTitle = this.toggleTitle(); const toggleTitle = this.toggleTitle();
return [ return [
@@ -260,7 +259,7 @@ export class LoadedSummaryPanelRuntime {
} }
public renderControls(kiosk = false): TemplateResult | typeof nothing { public renderControls(kiosk = false): TemplateResult | typeof nothing {
if (this.host._mode !== 'view') return nothing; if (kiosk && this.host._mode !== 'view') return nothing;
const toggleTitle = this.toggleTitle(); const toggleTitle = this.toggleTitle();
const stop = (event: Event) => event.stopPropagation(); const stop = (event: Event) => event.stopPropagation();
return html`<div class="summary-control ${kiosk ? 'kiosk' : ''}" role="group" return html`<div class="summary-control ${kiosk ? 'kiosk' : ''}" role="group"
+11
View File
@@ -1,5 +1,6 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import { nothing } from 'lit';
import { SUMMARY_PANEL_API_VERSION } from '../test-build/summary-panel-api.js'; import { SUMMARY_PANEL_API_VERSION } from '../test-build/summary-panel-api.js';
import { SUMMARY_PANEL_LEGACY_SCALE_KEY, summaryLocalKey } from '../test-build/summary-panel.js'; import { SUMMARY_PANEL_LEGACY_SCALE_KEY, summaryLocalKey } from '../test-build/summary-panel.js';
@@ -72,6 +73,16 @@ const hostFixture = () => {
}; };
}; };
test('#660 desktop summary controls stay available in editors while kiosk controls stay View-only', () => {
const host = hostFixture();
const runtime = new LoadedSummaryPanelRuntime(host);
for (const mode of ['plan', 'devices', 'decor']) {
host._mode = mode;
assert.notEqual(runtime.renderControls(false), nothing, `${mode}: desktop controls`);
assert.equal(runtime.renderControls(true), nothing, `${mode}: kiosk controls`);
}
});
test('#561 unresolved Masonry stays session-only and never imports an old DOM-path preference', () => { test('#561 unresolved Masonry stays session-only and never imports an old DOM-path preference', () => {
const browser = installBrowserGlobals(); const browser = installBrowserGlobals();
try { try {