diff --git a/.github/workflows/release-review.yml b/.github/workflows/release-review.yml index aa909468..85ea0255 100644 --- a/.github/workflows/release-review.yml +++ b/.github/workflows/release-review.yml @@ -207,7 +207,7 @@ jobs: (стенд `demo/`, смоки `demo/smoke_*.mjs`), вводи текст посимвольно, закрывай диалоги настоящим Escape и крестиком. Где есть диалоги HA — пиннутая фикстура `ha-dialog` (#505, - demo/helpers/README-ha-dialog-505.md, + demo/helpers/README-ha-dialog.md, `node demo/verify_ha_dialog_discard_recovery.mjs`). - Для каждой поверхности: обычный сценарий и самый рискованный соседний, шесть классов риска (async, данные/права, геометрия, diff --git a/demo/capture_design_pairs_600.mjs b/demo/capture_design_pairs_600.mjs index a60e11f0..90d4dfb6 100644 --- a/demo/capture_design_pairs_600.mjs +++ b/demo/capture_design_pairs_600.mjs @@ -1,8 +1,9 @@ // #600 AC1: парные кадры продукта для сравнения с референсом // docs/design/600-settings-dialogs/ (ACCEPTANCE.md). Диагностическая съёмка, не // golden: кадры не принимаются golden:accept и не участвуют в golden:verify. +// Кадры — артефакт проверки, в репозиторий не коммитятся (#681). // -// node demo/capture_design_pairs_600.mjs [--out=docs/design/600-settings-dialogs/pairs] +// node demo/capture_design_pairs_600.mjs [--out=artifacts/design-pairs-600] // // Четыре диалога × две темы, ширина поверхности 560, скроллер раскрыт на всю // высоту, чтобы кадр показывал форму целиком, как макет референса. Состояния — @@ -14,7 +15,7 @@ import { mkdirSync, writeFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { launch } from './serve.mjs'; -const out = resolve(process.argv.find((a) => a.startsWith('--out='))?.slice(6) || 'docs/design/600-settings-dialogs/pairs'); +const out = resolve(process.argv.find((a) => a.startsWith('--out='))?.slice(6) || 'artifacts/design-pairs-600'); mkdirSync(out, { recursive: true }); const THEMES = { diff --git a/demo/capture_summary_panel_505.mjs b/demo/capture_summary_panel_505.mjs deleted file mode 100644 index 694798e7..00000000 --- a/demo/capture_summary_panel_505.mjs +++ /dev/null @@ -1,317 +0,0 @@ -/** Explicit #505 diagnostic evidence, not a golden-baseline producer or smoke. */ -import assert from 'node:assert/strict'; -import { execFileSync } from 'node:child_process'; -import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; -import { dirname, join, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { sourceFingerprint } from '../scripts/source-fingerprint.mjs'; -import { assertFreshDemoBundle } from './bundle-freshness.mjs'; -import { HA_DIALOG_PIN, prepareHaDialogAssets } from './helpers/ha-dialog-assets.mjs'; -import { launchHaDialogFixture } from './helpers/ha-dialog-fixture.mjs'; - -const repo = resolve(dirname(fileURLToPath(import.meta.url)), '..'); -const options = { output: join(repo, 'artifacts/summary-panel-505') }; -for (let index = 2; index < process.argv.length; index++) { - const arg = process.argv[index]; - if (arg === '--probe-only') options.probeOnly = true; - else if (['--output', '--ha-cache', '--ha-wheel', '--only'].includes(arg)) { - const value = process.argv[++index]; - if (!value || value.startsWith('--')) throw new Error(`Missing value for ${arg}`); - if (arg === '--only') options.only = value.split(','); - else options[{ '--output': 'output', '--ha-cache': 'cacheDir', '--ha-wheel': 'wheelPath' }[arg]] = resolve(value); - } else throw new Error(`Unknown option ${arg}`); -} -// Fail before the optional 124 MB download. Captures never bypass freshness. -const fingerprint = sourceFingerprint(repo); -if (!options.probeOnly) { - const manifest = JSON.parse(readFileSync(join(repo, 'demo/srv/assets/houseplan-assets.json'))); - assert.equal(manifest.fingerprint, fingerprint, 'Stale demo bundle: run npm run bundle:sync before capturing'); -} -mkdirSync(options.output, { recursive: true }); -const report = { - issue: 505, diagnosticOnly: true, platform: process.platform, - sourceSha: execFileSync('git', ['rev-parse', 'HEAD'], { cwd: repo, encoding: 'utf8' }).trim(), - sourceFingerprint: fingerprint, probeOnly: Boolean(options.probeOnly), - selectedCases: options.only || 'full matrix', - haFrontend: HA_DIALOG_PIN.version, wheelSha256: HA_DIALOG_PIN.sha256, - boundaries: 'Loopback synthetic demo only. No core/auth/HA server. Official app loader exposed at runtime; component factories/CSS unmodified.', - comparison: 'Equivalent one block, three default system rows and one entity row. Reference row text is normalized to observed product values; reference CSS/files are unchanged. Floor-plan/fake HA chrome pixels are not parity targets.', - captures: [], -}; -const saveReport = () => writeFileSync(join(options.output, 'report.json'), JSON.stringify(report, null, 2)); -const assets = await prepareHaDialogAssets(options); - -async function probe() { - const fixture = await launchHaDialogFixture({ assets }); - try { - const { page } = fixture; - report.chromium = fixture.browser.version(); - await page.goto(`${fixture.url}/ha505-probe.html`); - await page.waitForFunction(() => document.querySelector('ha-dialog')?.shadowRoot?.querySelector('wa-dialog')?.shadowRoot?.querySelector('dialog')?.matches(':modal')); - await page.waitForTimeout(300); - const read = () => page.evaluate(() => { - const ha = document.querySelector('ha-dialog'); - const wa = ha.shadowRoot.querySelector('wa-dialog'); - const surface = wa.shadowRoot.querySelector('dialog'); - return { width: surface.getBoundingClientRect().width, modal: surface.matches(':modal'), - radius: getComputedStyle(surface).borderRadius, preset: ha.width, - inheritedMd: getComputedStyle(ha).getPropertyValue('--ha-dialog-width-md') }; - }); - const small = await read(); - await page.evaluate(async () => { const ha = document.querySelector('ha-dialog'); ha.width = 'medium'; await ha.updateComplete; }); - await page.waitForTimeout(50); - const medium = await read(); - await page.evaluate(() => document.querySelector('#parent').style.setProperty('--ha-dialog-width-md', '920px')); - await page.waitForTimeout(50); - const inherited = await read(); - await page.screenshot({ path: join(options.output, 'authentic-ha-width-920.png') }); - await page.setViewportSize({ width: 390, height: 844 }); - await page.waitForTimeout(100); - const mobile = await read(); - await page.screenshot({ path: join(options.output, 'authentic-ha-mobile-390.png') }); - assert.equal(small.width, 320); assert.equal(medium.width, 580); - assert.equal(inherited.width, 920); assert.equal(mobile.width, 390); - await fixture.assertClean(); - report.probe = { small, medium, inherited, mobile, provenance: fixture.provenance, - pageErrors: fixture.errors, externalRequests: fixture.externalRequests, websocketAttempts: fixture.websocketAttempts }; - saveReport(); - } finally { await fixture.close(); } -} - -const rowLabels = ['Количество устройств', 'Общая площадь комнат', 'Текущие дата и время', 'Температура кухни']; -const productConfig = { - version: 1, title: 'Сводная информация', show_on_mobile: true, - blocks: [{ id: 'block-1', title: 'Общее', visible: true, scope: { type: 'all' }, - values: rowLabels.map((label, index) => ({ id: `value-${index + 1}`, label, - source: index < 3 ? { type: 'system', key: ['device_count', 'total_area', 'datetime'][index] } - : { type: 'entity', entity_id: 'sensor.kitchen_temperature' } })) }], -}; -const referenceConfig = { title: productConfig.title, showOnMobile: true, blocks: [{ - ...productConfig.blocks[0], scope: { type: 'all', spaceId: null }, - values: rowLabels.map((label, index) => ({ id: `value-${index + 1}`, label, - source: index < 3 ? { type: 'system', key: ['device_count', 'room_area', 'date_time'][index], name: label } - : { type: 'entity', entityId: 'sensor.kitchen_temperature' } })), -}] }; -const panelCases = [ - { name: 'desktop-light', width: 1600, height: 1000, theme: 'light' }, - { name: 'desktop-dark', width: 1600, height: 1000, theme: 'dark' }, - { name: 'bottom-portrait', width: 800, height: 1100, theme: 'light' }, - { name: 'kiosk', width: 1600, height: 1000, theme: 'light', kiosk: true }, - { name: 'mobile-view', width: 390, height: 844, theme: 'light' }, -]; -const settingCases = ['native', 'real-ha'].flatMap((shell) => [ - { name: 'desktop', width: 1600, height: 1000 }, - { name: 'narrow-320', width: 320, height: 844 }, - { name: 'narrow-390', width: 390, height: 844 }, -].flatMap((size) => ['light', 'dark'].map((theme) => ({ ...size, - name: `settings-${shell}-${size.name}-${theme}`, theme, shell, settings: true })))); -const edgeCases = ['native', 'real-ha'].flatMap((shell) => [ - { name: `edge-${shell}-effective-zoom-200`, width: 800, height: 500, - adaptation: '800×500 effective viewport proxy for 1600×1000 at 200%; not actual browser zoom' }, - { name: `edge-${shell}-text-200-long-de`, width: 1600, height: 1000, textSize: 32, language: 'de', - adaptation: 'Root font-size 32px enlarged-text stress, long German entity name/id; not browser zoom' }, - { name: `edge-${shell}-text-200-long-de-390`, width: 390, height: 844, textSize: 32, language: 'de', - adaptation: '390px plus root font-size32px and long German source' }, -].map((scenario) => ({ ...scenario, shell, settings: true, theme: 'light' }))); - -function scenarioConfig(scenario, reference = false) { - const config = structuredClone(reference ? referenceConfig : productConfig); - if (scenario.language === 'de') { - config.title = 'Zusammenfassung'; config.blocks[0].title = 'Allgemein'; - const row = config.blocks[0].values[3]; - row.label = 'Küchentemperatur und langfristiger Messwert'; - row.source = { type: 'entity', [reference ? 'entityId' : 'entity_id']: - 'sensor.kueche_erdgeschoss_langfristige_temperaturmessung_mit_besonders_langer_quellenkennung_505' }; - } - return config; -} - -async function configureProduct(page, scenario) { - await page.waitForFunction(() => window.__card?._summary && window.__card?._model?.length > 0); - await assertFreshDemoBundle(page, repo); - await page.waitForFunction(() => window.__card._booting === false && !window.__card._bootFading); - await page.evaluate(async ({ scenario, config }) => { - const card = window.__card; - Object.assign(document.querySelector('#host').style, { width: '100%', height: '100vh', padding: '0', margin: '0', maxWidth: 'none' }); - Object.assign(document.body.style, { margin: '0', padding: '0' }); - card.panelHost = true; - card.setAttribute('panel-host', ''); - Object.assign(card.style, { width: '100%', height: '100%' }); - card.setConfig({ ...card._config, language: scenario.language || 'ru', kiosk: Boolean(scenario.kiosk) }); - card._serverCfg = { ...card._serverCfg, settings: { ...card._serverCfg.settings, summary_panel: config } }; - card._cfgEpoch++; - card._haSummaryPanelApi = 1; - card._serverCanWrite = true; - card.narrow = scenario.width <= 450; - const dark = scenario.theme === 'dark'; - const colors = dark ? ['#3ea6ff', '#e6e7eb', '#9aa4ad', '#202126', '#3a3d45', '#11151b', '#2b2d33'] - : ['#0b73b8', '#202124', '#5f6368', '#ffffff', '#d7d9de', '#eef1f4', '#e7eaee']; - ['primary-color', 'primary-text-color', 'secondary-text-color', 'card-background-color', 'divider-color', 'primary-background-color', 'secondary-background-color'] - .forEach((key, index) => document.documentElement.style.setProperty(`--${key}`, colors[index])); - document.documentElement.style.setProperty('--ha-card-background', colors[3]); - document.documentElement.style.setProperty('--app-header-background-color', colors[3]); - document.documentElement.style.setProperty('--app-header-text-color', colors[1]); - document.documentElement.style.setProperty('--text-primary-color', '#ffffff'); - document.documentElement.style.colorScheme = scenario.theme; - if (scenario.textSize) document.documentElement.style.fontSize = `${scenario.textSize}px`; - document.body.style.background = colors[5]; - const entityId = config.blocks[0].values[3].source.entity_id; - card.hass = { ...card.hass, language: scenario.language || 'ru', locale: { ...card.hass.locale, language: scenario.language || 'ru' }, - config: { ...card.hass.config, time_zone: 'Europe/Moscow', unit_system: { ...card.hass.config?.unit_system, length: 'm' } }, - themes: { ...card.hass.themes, darkMode: dark }, - states: { ...card.hass.states, [entityId]: { entity_id: entityId, state: '20.6', attributes: { - friendly_name: scenario.language === 'de' ? 'Küchentemperatur im Erdgeschoss mit langfristiger Messwertüberwachung und besonders langer Quellenbezeichnung' : 'Kitchen temperature', unit_of_measurement: '°C' } } }, - }; - card._summary.updated(); - card._summary.saveLocal({ show: true }); - card.requestUpdate(); - await card.updateComplete; - card._summary.resized(); - }, { scenario, config: scenarioConfig(scenario) }); - await page.waitForFunction(() => window.__card._booting === false && !window.__card._bootFading && window.__card._devices.length > 0); - if (!scenario.settings) await page.locator('.summary-overlay').waitFor({ state: 'visible' }); - await page.waitForTimeout(350); - if (scenario.settings) { - await page.evaluate(async () => { - const card = window.__card; - if (card._ensureEditorRuntime) assertRuntime(await card._ensureEditorRuntime()); - function assertRuntime(ready) { if (!ready) throw new Error('Editor runtime failed to load'); } - await card._summary.openDialog(); - await card.updateComplete; - }); - await page.locator('hp-dialog[data-kind="summary"] .summary-editor').waitFor({ state: 'visible' }); - await page.waitForTimeout(300); - } -} - -async function capture(scenario) { - console.log(`Capturing ${scenario.name}`); - const fixture = await launchHaDialogFixture({ assets, authentic: scenario.shell === 'real-ha', - viewport: { width: scenario.width, height: scenario.height }, colorScheme: scenario.theme }); - const record = { ...scenario, files: {} }; - try { - const { page } = fixture; - // Let Date.now advance: the real card boot-veil quiescence uses it. - await page.clock.setSystemTime(new Date('2026-09-09T09:00:00Z')); - await page.goto(`${fixture.url}/product.html`); - await configureProduct(page, scenario); - const values = await page.evaluate((config) => config.blocks[0].values.map((value) => window.__card._summary.value(value)), scenarioConfig(scenario)); - assert.equal(values.length, 4, 'Equivalent four-row product fixture'); - record.product = await page.evaluate((settings) => { - const root = window.__card.shadowRoot; - const box = (node) => node ? { ...node.getBoundingClientRect().toJSON(), scrollWidth: node.scrollWidth, clientWidth: node.clientWidth } : null; - const panel = root.querySelector('.summary-overlay'); - const hp = root.querySelector('hp-dialog[data-kind="summary"]'); - const ha = hp?.shadowRoot.querySelector('ha-dialog'); - const wa = ha?.shadowRoot.querySelector('wa-dialog'); - const surface = wa?.shadowRoot.querySelector('dialog') || hp?.shadowRoot.querySelector('.surface'); - const footer = hp?.querySelector('[slot="footer"]'); - const editor = hp?.querySelector('.summary-editor'); - const insideViewport = (node) => { const r = node?.getBoundingClientRect(); return !!r && r.left >= -1 && r.right <= innerWidth + 1 && r.top >= -1 && r.bottom <= innerHeight + 1; }; - return { panel: box(panel), control: box(root.querySelector('.summary-control')), stage: box(root.querySelector('.stage')), - side: panel?.classList.contains('bottom') ? 'bottom' : 'right', rows: panel?.querySelectorAll('.summary-value').length, - settings: settings ? { surface: box(surface), editor: box(editor), footer: box(footer), - actualHA: Boolean(wa), modal: surface?.matches(':modal'), radius: getComputedStyle(surface).borderRadius, - border: getComputedStyle(surface).border, shadow: getComputedStyle(surface).boxShadow, - rootFont: getComputedStyle(document.documentElement).fontSize, - titleFont: getComputedStyle(hp.shadowRoot.querySelector('.title')).fontSize, - sourceRows: [...editor.querySelectorAll('.summary-source')].map((node) => ({ box: box(node), text: node.textContent.trim() })), - checks: { surfaceInsideViewport: insideViewport(surface), footerVisible: insideViewport(footer), - editorNoHorizontalOverflow: editor.scrollWidth <= editor.clientWidth + 1, - surfaceNoHorizontalOverflow: surface.scrollWidth <= surface.clientWidth + 1, - footerNoHorizontalOverflow: footer.scrollWidth <= footer.clientWidth + 1 }, - close: box(ha?.shadowRoot.querySelector('ha-icon-button') || hp.shadowRoot.querySelector('.close')) } : null }; - }, Boolean(scenario.settings)); - const shot = async (side, area, locator) => { - const name = `${scenario.name}-${side}-${area}.png`; - if (locator) await locator.screenshot({ path: join(options.output, name) }); - else await page.screenshot({ path: join(options.output, name) }); - record.files[`${side}-${area}`] = name; - }; - await shot('product', 'viewport'); - if (scenario.settings) await shot('product', 'dialog', page.locator(scenario.shell === 'real-ha' - ? 'hp-dialog[data-kind="summary"] ha-dialog wa-dialog dialog' : 'hp-dialog[data-kind="summary"] .surface')); - else { - await shot('product', 'panel', page.locator('.summary-overlay')); - await shot('product', 'control', page.locator('.summary-control')); - } - if (scenario.language === 'de') { - // The genuine HA owns scrolling in its shadow .body; the native wrapper - // owns it in the editor. Follow the composed tree instead of assuming. - const measureSource = async (align) => page.locator('.summary-source').last().evaluate((source, align) => { - const label = source.querySelector('strong'); - (align === 'start' ? label : source).scrollIntoView({ block: align, inline: 'nearest', behavior: 'instant' }); - const hp = source.closest('hp-dialog'); - const ha = hp.shadowRoot.querySelector('ha-dialog'); - const header = ha?.shadowRoot.querySelector('ha-dialog-header') || hp.shadowRoot.querySelector('.header'); - const footer = hp.querySelector('[slot="footer"]'); - let bandTop = Math.max(0, header?.getBoundingClientRect().bottom || 0); - let bandBottom = Math.min(innerHeight, footer.getBoundingClientRect().top); - const visible = (node) => { const box = node.getBoundingClientRect().toJSON(); - const visibleHeight = Math.max(0, Math.min(box.bottom, bandBottom) - Math.max(box.top, bandTop)); - return { box, visibleHeight, visibleFraction: visibleHeight / box.height }; }; - const scrollOwners = []; - for (let node = source; node; node = node.assignedSlot || node.parentElement || node.getRootNode()?.host) { - if (node.scrollHeight > node.clientHeight + 1 && /auto|scroll/.test(getComputedStyle(node).overflowY)) { - const clip = node.getBoundingClientRect(); - bandTop = Math.max(bandTop, clip.top); bandBottom = Math.min(bandBottom, clip.bottom); - scrollOwners.push({ tag: node.localName, class: node.className, scrollTop: node.scrollTop, - scrollHeight: node.scrollHeight, clientHeight: node.clientHeight }); - } - } - return { align, bandTop, bandBottom, label: visible(label), source: visible(source), scrollOwners }; - }, align); - record.product.longSourceStart = await measureSource('start'); - assert.ok(record.product.longSourceStart.label.visibleHeight > 0, 'Long source label must actually appear between header and footer'); - await shot('product', 'long-source-scrolled'); - record.product.longSourceEnd = await measureSource('end'); - assert.ok(record.product.longSourceEnd.source.visibleHeight > 0, 'Long source end must actually be visible'); - await shot('product', 'long-source-end'); - } - await fixture.assertClean(); - await page.addInitScript(({ config, theme, kiosk, textSize }) => { - localStorage.setItem('houseplan-dashboard5-config', JSON.stringify(config)); - localStorage.setItem('houseplan-dashboard5-enabled', 'true'); - localStorage.setItem('houseplan-dashboard5-theme', theme); - localStorage.setItem('houseplan-dashboard5-kiosk', String(Boolean(kiosk))); - if (textSize) document.addEventListener('DOMContentLoaded', () => { document.documentElement.style.fontSize = `${textSize}px`; }); - }, { config: scenarioConfig(scenario, true), theme: scenario.theme, kiosk: scenario.kiosk, textSize: scenario.textSize }); - await page.goto(`${fixture.url}/reference/index.html`); - await page.locator('#dashboard3-panel').waitFor({ state: 'visible' }); - await page.waitForTimeout(350); - await page.locator('.dashboard3-value dd').evaluateAll((nodes, text) => { - if (nodes.length !== text.length) throw new Error('Equivalent reference row count mismatch'); - nodes.forEach((node, index) => { node.textContent = text[index]; }); - }, values); - if (scenario.settings) { - await page.locator('.dashboard3-settings-toggle').click(); - await page.locator('.dashboard3-settings-dialog__window').waitFor({ state: 'visible' }); - } - record.reference = await page.evaluate(() => { - const box = (selector) => document.querySelector(selector)?.getBoundingClientRect().toJSON(); - return { panel: box('#dashboard3-panel'), control: box('.dashboard5-summary-control'), - dialog: box('.dashboard3-settings-dialog__window'), normalizedValueText: [...document.querySelectorAll('.dashboard3-value dd')].map((node) => node.textContent) }; - }); - await shot('reference', 'viewport'); - if (scenario.settings) await shot('reference', 'dialog', page.locator('.dashboard3-settings-dialog__window')); - else { - await shot('reference', 'panel', page.locator('#dashboard3-panel')); - await shot('reference', 'control', page.locator('.dashboard5-summary-control')); - } - await fixture.assertClean(); - record.pageErrors = fixture.errors; record.externalRequests = fixture.externalRequests; - record.websocketAttempts = fixture.websocketAttempts; - report.captures.push(record); saveReport(); - } finally { await fixture.close(); } -} - -try { - await probe(); - const cases = [...panelCases, ...settingCases, ...edgeCases]; - if (options.only) for (const name of options.only) assert.ok(cases.some((scenario) => scenario.name === name), `Unknown case ${name}`); - if (!options.probeOnly) for (const scenario of cases.filter((scenario) => !options.only || options.only.includes(scenario.name))) await capture(scenario); - report.status = 'complete'; saveReport(); - console.log(`Diagnostic evidence: ${join(options.output, 'report.json')}`); -} catch (error) { - report.status = 'failed'; report.error = error.stack || String(error); saveReport(); throw error; -} diff --git a/demo/golden/matrix.mjs b/demo/golden/matrix.mjs index 30c1b8f4..f6d08d4b 100644 --- a/demo/golden/matrix.mjs +++ b/demo/golden/matrix.mjs @@ -107,9 +107,13 @@ const stage3VacuumTrail = { const stage3RequiredOverlays = ['device', 'room-label', 'opening-lock']; -/** Exact existing baseline impact measured against origin/dev with the same - * Chromium build. Keep this list reviewable: shared visual fixtures make an - * opening-only change visible in editor/dialog captures too. */ +/** Exact existing baseline impact of the opening-symbol change (#242/#250, matrix + * v37): measured by comparing `actualSha256` for HEAD and origin/dev under the + * same Chromium build (baseline status alone was not used — dev already had + * unrelated pending candidates). Every listed frame uses a shared fixture with + * an affected opening or keeps that plan behind an editor/dialog; no other + * existing frame changed. Keep this list reviewable: this is its only copy + * since the testing-notes checklists were removed (#681). */ export const OPENING_SYMBOL_EXISTING_GOLDEN_IMPACT = Object.freeze([ 'isometric-geometry-view-dark', 'isometric-geometry-view-light', diff --git a/demo/guard/README.md b/demo/guard/README.md index 989ec97d..87f81a66 100644 --- a/demo/guard/README.md +++ b/demo/guard/README.md @@ -24,6 +24,7 @@ одно исключение внутри страницы и обязан выйти с кодом 1. Новый benchmark, открывающий страницу Playwright, обязан получить такой же -режим и запись здесь: этого требует `test/smoke-harness-contract.test.mjs`, и +режим и запись в `verify-guard.mjs` (`file: '../<имя>.mjs', args: +['--guard-probe']`): этого требует `test/smoke-harness-contract.test.mjs`, и требует статически — потому что динамическое доказательство возможно только -там, где есть браузер. +там, где есть браузер. Этот README записи не заменяет. diff --git a/demo/helpers/README-ha-dialog-505.md b/demo/helpers/README-ha-dialog-505.md deleted file mode 100644 index c1b25cf3..00000000 --- a/demo/helpers/README-ha-dialog-505.md +++ /dev/null @@ -1,124 +0,0 @@ -# Offline authentic HA dialog evidence (#505) - -This is an explicit diagnostic, **not** a `smoke_*` entry, a golden-baseline -producer, or a Home Assistant server. It never connects to a real HA instance. -Issue/spec: [#505](https://github.com/Matysh/houseplan-card/issues/505), -[`505-summary-panel-design-parity.md`](../../docs/specs/505-summary-panel-design-parity.md). - -The same pinned fixture also verifies #607 against the real HA close button: - -```sh -npm run bundle:sync -node demo/verify_ha_dialog_discard_recovery.mjs -``` - -That diagnostic opens Device, Room, Space and General settings in turn. For -each form it edits a field, presses HA's own close button, chooses Continue, -proves that the same modal and draft are interactive again, then covers -Discard and a clean close. It also fails on duplicate `hp-close` events, -external requests, WebSockets or browser errors. The ordinary native-fallback -contract remains in `demo/smoke_dialog_modal_recovery.mjs`. - -Issue #609 uses that same unmodified pinned `ha-dialog` to verify the shared -settings-form shell rather than the close lifecycle: - -```sh -npm run bundle:sync -node demo/verify_ha_form_shell_609.mjs --capture -``` - -It opens the real Room settings form and checks the settled WebAwesome surface: -560 px desktop width, the 940 px/viewport height cap, zero duplicate HA body -padding, one HA-owned scroller, a distinct form canvas, footer containment and -edge-to-edge layouts at 480×800 and 390×844. It also keeps HA's own fullscreen -behaviour for a 1280×480 viewport. `--capture` writes the three reviewed diagnostic -images under `docs/design/600-settings-dialogs/pairs/`; without it the command -is read-only. The authentic diagnostic explicitly applies the light palette, -then repeats desktop geometry in the dark palette with a 32 px root font and -proves that canvas contrast, the single HA scroller and the footer survive. -The fast Validate witness is -`demo/smoke_ha_form_shell_parity.mjs`; neither diagnostic is a golden producer. - -From a checkout with the usual `npm ci` dependencies and Playwright Chromium: - -```sh -node demo/capture_summary_panel_505.mjs --probe-only -node demo/capture_summary_panel_505.mjs --output artifacts/summary-panel-505 -``` - -The second command requires an already fresh `npm run bundle:sync` result. -It never builds or changes product/reference files, and refuses stale source -fingerprints even when `HP_ALLOW_STALE_BUNDLE` is set. For reviewer work, use -`--output "$RUNNER_TEMP/summary-panel-505"` to keep all evidence outside the checkout. -Use `--only edge-real-ha-text-200-long-de-390` for a named focused recapture; -comma-separated names are accepted and recorded as a partial selection. - -The first explicit run downloads the official 124,294,469-byte -`home-assistant-frontend==20260729.7` wheel from the exact allowlisted PyPI file -URL; version, size and SHA-256 are pinned in `ha-dialog-assets.mjs`, checked -before extraction, and checked against `tests_backend/requirements.txt`. -The wheel is **not installed**. Only allowlisted modern JS/static assets are -extracted to the OS temporary cache. The deterministic cache manifest has its -own pinned hash; existing extracted bytes are then checked against its per-file -hashes each run, so edited cache metadata is not trusted. Use a new cache directory -after corruption; the tool does not delete existing data. - -To reuse an already downloaded official wheel or select a disposable cache: - -```sh -node demo/capture_summary_panel_505.mjs --probe-only --ha-wheel /path/to/home_assistant_frontend-20260729.7-py3-none-any.whl --ha-cache /path/to/cache --output /path/to/evidence -``` - -`HP_HA_DIALOG_WHEEL` also supplies a local wheel. All paths are caller-selected; -there are no author-machine paths in the helper. Cache contents and screenshots -are not committed. - -## Authenticity and safety boundary - -The official `app.d53ce8172fc8c85d.js` is served with one explicit runtime -instrumentation: its final `o(91535);` becomes -`window.__ha505Require=o;o(91535);`, exposing its otherwise private module -loader. The downloaded wheel and the `ha-dialog`, WebAwesome, Lit and other -component factories/styles remain unmodified. Numbered official ESM chunks -register their original factories with that loader; there is no dialog stub. -This is a genuine-component diagnostic, **not** a complete production HA boot. - -No `core.*` entry, authentication root or HA server is loaded. The server binds -only `127.0.0.1` on an ephemeral port. A restrictive CSP, browser request -allowlist, blocked service workers and throwing WebSocket constructor prohibit -external/HA requests; any attempted external request, WebSocket or page error -fails the run. The synthetic demo HTML is adapted only in its served response -to await the authentic component bootstrap and avoid duplicate icon/card tag -registration. No production HA configuration, devices or services are touched. - -## Evidence and limits - -`report.json` records source SHA/fingerprint, Chromium/platform, wheel pin/hash, -measured surface/editor/footer/control geometry, actual shadow-component -presence, typography and network errors. The standalone probe proves the -small320/medium580/public inherited-width920 contract plus mobile390 fullscreen. -It does not load House Plan; the full settings cases exercise actual `hp-dialog`. - -The full command writes paired viewport/region PNGs for five panel contexts -(desktop light/dark, portrait bottom, kiosk, mobile View), native/real-HA -settings at desktop/320/390 in both themes, and enlarged-text/long-German -source edge cases. One default block has three system rows plus one entity row -on both sides. Reference displayed values are normalized to measured product -values **only as fixture text**, not CSS; original reference files stay intact. -The archived prototype UI is Russian; German stress changes fixture data and -product locale, not the archived prototype UI translation. -Long-source start/end images use `scrollIntoView` and record the actual composed -scroll owner plus source/label visibility between header and footer. This matters -because genuine HA scrolls its shadow body, not necessarily `.summary-editor`. - -The 800×500 case is explicitly an effective-viewport proxy for a 1600×1000 -display at 200%, **not actual browser zoom**. Separate 32px root-font cases -exercise enlarged text. HA normally uses a 14px root, so the report includes -actual computed font sizes. Different fake HA chrome/floor-plan pixels are not -parity targets. Fullscreen HA rules, HA typography/theme, larger touch targets -and the native disabled Save action remain the spec's allowed adaptations. - -`status: complete` means capture completed, not design acceptance. Inspect the -PNGs and the settings `checks`; these files are diagnostic evidence outside -canonical `demo/golden/baselines` and docs screenshot manifests. Regenerate on -the reviewer's pinned checkout and record the exact command/report paths. diff --git a/demo/helpers/README-ha-dialog.md b/demo/helpers/README-ha-dialog.md new file mode 100644 index 00000000..074434a5 --- /dev/null +++ b/demo/helpers/README-ha-dialog.md @@ -0,0 +1,76 @@ +# Offline authentic HA dialog fixture (#505, #607, #609) + +`demo/helpers/ha-dialog-fixture.mjs` serves the synthetic demo together with the +official, pinned Home Assistant `ha-dialog` so a diagnostic can exercise the +real component. It is **not** a `smoke_*` entry, a golden-baseline producer, or +a Home Assistant server, and it never connects to a real HA instance. It was +built for the #505 summary-panel parity work +([`505-summary-panel-design-parity.md`](../../docs/specs/505-summary-panel-design-parity.md)); +that one-off capture script and its designer prototype were removed by #681 and +remain in git history. Two diagnostics use the fixture today. + +#607 verifies the real HA close button: + +```sh +npm run bundle:sync +node demo/verify_ha_dialog_discard_recovery.mjs +``` + +That diagnostic opens Device, Room, Space and General settings in turn. For +each form it edits a field, presses HA's own close button, chooses Continue, +proves that the same modal and draft are interactive again, then covers +Discard and a clean close. It also fails on duplicate `hp-close` events, +external requests, WebSockets or browser errors. The ordinary native-fallback +contract remains in `demo/smoke_dialog_modal_recovery.mjs`. + +Issue #609 uses that same unmodified pinned `ha-dialog` to verify the shared +settings-form shell rather than the close lifecycle: + +```sh +npm run bundle:sync +node demo/verify_ha_form_shell_609.mjs --capture +``` + +It opens the real Room settings form and checks the settled WebAwesome surface: +560 px desktop width, the 940 px/viewport height cap, zero duplicate HA body +padding, one HA-owned scroller, a distinct form canvas, footer containment and +edge-to-edge layouts at 480×800 and 390×844. It also keeps HA's own fullscreen +behaviour for a 1280×480 viewport. `--capture` writes the diagnostic images into +the ignored `artifacts/ha-form-shell-609/`; without it the command is +read-only. The authentic diagnostic explicitly applies the light palette, +then repeats desktop geometry in the dark palette with a 32 px root font and +proves that canvas contrast, the single HA scroller and the footer survive. +The fast Validate witness is +`demo/smoke_ha_form_shell_parity.mjs`; neither diagnostic is a golden producer. + +The first explicit run downloads the official 124,294,469-byte +`home-assistant-frontend==20260729.7` wheel from the exact allowlisted PyPI file +URL; version, size and SHA-256 are pinned in `ha-dialog-assets.mjs`, checked +before extraction, and checked against `tests_backend/requirements.txt`. +The wheel is **not installed**. Only allowlisted modern JS/static assets are +extracted to the OS temporary cache. The deterministic cache manifest has its +own pinned hash; existing extracted bytes are then checked against its per-file +hashes each run, so edited cache metadata is not trusted. After corruption, +remove that temporary cache directory; the tool does not delete existing data. + +To reuse an already downloaded official wheel, point `HP_HA_DIALOG_WHEEL` at +it. There are no author-machine paths in the helper; cache contents and +screenshots are never committed. + +## Authenticity and safety boundary + +The official `app.d53ce8172fc8c85d.js` is served with one explicit runtime +instrumentation: its final `o(91535);` becomes +`window.__ha505Require=o;o(91535);`, exposing its otherwise private module +loader. The downloaded wheel and the `ha-dialog`, WebAwesome, Lit and other +component factories/styles remain unmodified. Numbered official ESM chunks +register their original factories with that loader; there is no dialog stub. +This is a genuine-component diagnostic, **not** a complete production HA boot. + +No `core.*` entry, authentication root or HA server is loaded. The server binds +only `127.0.0.1` on an ephemeral port. A restrictive CSP, browser request +allowlist, blocked service workers and throwing WebSocket constructor prohibit +external/HA requests; any attempted external request, WebSocket or page error +fails the run. The synthetic demo HTML is adapted only in its served response +to await the authentic component bootstrap and avoid duplicate icon/card tag +registration. No production HA configuration, devices or services are touched. diff --git a/demo/helpers/ha-dialog-fixture.mjs b/demo/helpers/ha-dialog-fixture.mjs index e579117e..a5b99712 100644 --- a/demo/helpers/ha-dialog-fixture.mjs +++ b/demo/helpers/ha-dialog-fixture.mjs @@ -70,10 +70,7 @@ export async function launchHaDialogFixture({ } else { let base = join(REPO, 'demo/srv'), relative = pathname; if (pathname === '/product.html') relative = '/demo.html'; - if (pathname.startsWith('/reference/')) { - base = join(REPO, 'docs/design/505-summary-panel/reference'); - relative = pathname.slice('/reference'.length); - } else if (ha && (pathname.startsWith('/frontend_latest/') || pathname.startsWith('/static/'))) base = ha.root; + if (ha && (pathname.startsWith('/frontend_latest/') || pathname.startsWith('/static/'))) base = ha.root; const file = resolve(base, '.' + relative); if (!file.startsWith(resolve(base) + sep) || !existsSync(file) || !statSync(file).isFile()) { response.writeHead(404).end('Not found'); return; diff --git a/demo/verify_ha_form_shell_609.mjs b/demo/verify_ha_form_shell_609.mjs index 7fd10ff6..2c795c47 100644 --- a/demo/verify_ha_form_shell_609.mjs +++ b/demo/verify_ha_form_shell_609.mjs @@ -3,13 +3,16 @@ // Room settings form, and verifies the public ha-dialog geometry on desktop // and both sides of HA's built-in 450 px mobile breakpoint. import assert from 'node:assert/strict'; +import { mkdirSync } from 'node:fs'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { assertFreshDemoBundle } from './bundle-freshness.mjs'; import { launchHaDialogFixture } from './helpers/ha-dialog-fixture.mjs'; const capture = process.argv.includes('--capture'); -const pairDir = fileURLToPath(new URL('../docs/design/600-settings-dialogs/pairs/', import.meta.url)); +// `--capture` writes diagnostic images into ignored artifacts/, never into docs (#681). +const pairDir = fileURLToPath(new URL('../artifacts/ha-form-shell-609/', import.meta.url)); +if (capture) mkdirSync(pairDir, { recursive: true }); const fixture = await launchHaDialogFixture({ authentic: true, viewport: { width: 1600, height: 1000 }, diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 320120ed..c154aada 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -744,6 +744,5 @@ mode before dispatching synthetic gestures, it must also wait for the observable mode-transition and viewport-refit state to settle, because the stage can finish its physical resize after the Lit update (#460). -When adding a checklist line marked `[auto: ...]` in docs/TESTING.md or its -appendices in docs/testing-notes/, add the failing check in the same commit — -that is what the marker now promises. +When adding a checklist line marked `[auto: ...]` in docs/TESTING.md, add the +failing check in the same commit — that is what the marker now promises. diff --git a/docs/TESTING.md b/docs/TESTING.md index a93b1af4..dfd81524 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -1,11 +1,11 @@ # Testing Действующая инструкция: правила для новых тестов, гейты, локальный набор и -матрицы релизной проверки. Ручные чек-листы по поверхностям и приложения по -отдельным issue перенесены дословно в [`testing-notes/`](testing-notes/README.md) -(#634) — индекс там же, по заголовку и номеру issue. Новый чек-лист по задаче -ложится в приложение своей подсистемы; сюда — только то, что действует для -любой задачи. Маркер `[auto: …]` у пункта в любом из этих файлов обещает +матрицы релизной проверки. Ручных чек-листов по поверхностям и приложений по +отдельным issue больше нет (#681, после #634): поведение держат тесты, смоки и +golden, а то, чего автоматика не видит, собрано в разделе +[«Чего не проверяет автоматика»](#чего-не-проверяет-автоматика). Сюда пишется +только то, что действует для любой задачи; маркер `[auto: …]` у пункта обещает падающую проверку в том же коммите (`docs/DEVELOPMENT.md`). ## Правила для новых тестов (issue #85) — обязательны @@ -90,7 +90,7 @@ ledger. Compile-time проверка допустима как самостоя явным полем определения `oracle: 'compile'`; прятать её в префиксе обычного browser/backend guard нельзя. Все недоказанные исходы завершают гейт кодом 2 и отдельно называются в ночном отчёте. Гвард с `--test-name-pattern`, не исполнивший -ни одного теста, — тоже `setup-failure` ([#650](testing-notes/infrastructure.md#пустое-совпадение---test-name-pattern-650)). +ни одного теста, — тоже `setup-failure` ([#650](#пустое-совпадение---test-name-pattern-650)). С #568 `setup-failure` в дифф-режиме **атрибутируется**: раннер прогоняет ОПРЕДЕЛЕНИЕ БАЗЫ диапазона на дереве базы и говорит, чей это отказ. @@ -143,6 +143,20 @@ mutant-jobs в доказательстве ревью (#541) не меняют что отменённый пуш или таймаут не пропадают даром. Полный прогон и `--id` журнал не читают; `--ledger` без `--changed` — ошибка. +## Пустое совпадение `--test-name-pattern` (#650) + +Зелёный `node --test` с `--test-name-pattern`, который не исполнил ни одного +теста: TAP называет только сам файл (`ok 1 - test/x.test.mjs`), ассертов не было +ни в чистом прогоне, ни на мутанте. Так бывает, когда задача переименовала тест, +а гвард реестра остался со старым именем. Шаблон — это RegExp: `(`, `+`, `?` в +имени экранируются (в команде гварда `\(отбор\)`, в JS-строке реестра +`\\(отбор\\)`). `mutation-gate --check` проверяет то же статически: шаблон +обязан совпасть хотя бы с одним литеральным именем `test(`/`it(`/`describe(` в +файлах гварда; если в файле есть имена `${…}`, несовпадение — `WARN`, иначе +`FAIL`. Это `setup-failure` (#550) и атрибутируется как любой другой (#568): +переименование теста в задаче даёт `introduced`, старый пустой шаблон — +`pre-existing`. Свидетель — `test/mutation-guard-outcome.test.mjs` (тесты `#650 …`). + ## Новый код не добавляет any (#342) ```bash @@ -503,79 +517,6 @@ documentation and test classification in the same change. “Best effort” cann be used to waive data corruption, unsafe service calls, permission failures, missing destructive confirmation or an editor exception that breaks View. -- [ ] Smoke harness itself (v1.43.2, audit T1/T2): every smoke asserts named - facts via `check`/`checkAll` and exits non-zero on any mismatch or - uncaught in-card exception; the suite runs in CI against a FRESHLY built - bundle. Sanity ritual: break one invariant on purpose (e.g. remove the - kiosk editor guard) and confirm the matching smoke goes red [auto: CI job "smoke"] - -- [ ] Room gear discoverability (v1.43.3, user feedback): in the Plan editor - every room card carries a pill button "⚙ Room" of a FIXED readable size - (independent of the card font) — including rooms without a name; it opens - Room settings [auto: smoke_feedback_v2] -- [ ] Metrics readability (v1.43.3): the metrics line is 0.75 of the room name - (was 0.62 — unreadable on tablets); per-room sliders still apply on top [auto: smoke_feedback_v2] -- [ ] Touch tooltips: touch/pen immediately clears hover even if the browser - claims hover support; compatibility mouse is ignored, while a later real - paired-mouse event restores desktop hover without reload - [auto: smoke_feedback_v2] - -- [ ] Light-source flag (v1.44.0, user feedback): a smart SWITCH driving dumb - fixtures creates a Glow pool only once "This device is a - light source" is ticked. External targets under "Controls" still feed - group state/statistics but never create a pool at the switch coordinates; - unticked devices without a light entity never glow [auto: smoke_glow] -- [ ] Device card controls (v1.44.0): the device card opens with its - controllable entities FIRST — toggles right there (≥30 px tap targets), - cover/lock/climate open HA more-info; model, links and manuals moved - below; config/diagnostic entities are not listed; locks never toggle from - the card [auto: smoke_card_controls] - -- [ ] Lock invariant, all paths (v1.44.2, review CR-1): icon tap, controls[], - device card and _cardToggle refuse locks/alarm panels entirely; the door - card's Unlock asks for confirmation, Lock does not [auto: smoke_lock_invariant] -- [ ] Attachment migration is transactional (v1.44.2, review CR-2/CR-3): - rebinding COPIES files, saves the config, and only then deletes the old - folder; a rejected save leaves the old files and urls intact; a name - collision in the destination gets a unique name (the pre-existing file is - never silently linked); urls are rewritten only for confirmed copies - [auto: unit logic.test + tests_backend] - -- [ ] Plans and PDFs load in a real browser (v1.44.3, B1 regression): open a - dashboard with an uploaded plan — the background renders and a manual link - opens; DevTools shows /api/houseplan/content/... returning 200 via a - signed url, while the same url without authSig returns 401 - [auto: tests_backend + manual] -- [ ] Auth policy is single-sourced (v1.44.4, B2): the HTTP upload and every WS - write use the same `may_write`, which denies non-admins when the config - entry is unavailable [auto: tests_backend] -- [ ] Coordinates and caps (v1.44.4, B5): NaN/Infinity are refused on room - rects, polygon vertices, view_box and openings — not only in layout; the - openings list honours MAX_OPENINGS [auto: tests_backend] -- [ ] Drag hardening (v1.44.4, L4 sub-item): every drag pipeline captures the - pointer through the tolerant helper; decor follows the infinite canvas - and stops only at the shared normalized ±5000 garbage bound - [auto: smoke_decor, smoke_drag_bounds] - -- [ ] Room climate counts hidden sensors (v1.44.5): a thermometer that is NOT - placed on the plan (hidden by filtering or by the user) still feeds the - room card, the tooltip and the temperature fill; fridges/TRVs still do - not; an explicit per-room source still wins [auto: unit devices.test] -- [ ] Room climate follows explicit House Plan placement (#317): a real - temperature/humidity sensor moved away from registry Area A votes exactly - once in its marker target, including an area-less `space + room_id` room; - hidden markers still vote, while removed/HA-disabled ones do not. Exact - entity placement wins over its parent device only for that entity, and - explicit room sources remain authoritative [auto: - smoke_room_climate_placement; units: test/devices.test.mjs; mutation: - room-climate-ignores-marker-placement] -- [ ] Room hover + tooltip: in View, hovering any room visibly highlights it - (filled, transparent and area-less alike) and shows its name plus clean- - floor area; temperature/signal follow when available. Thick walls reduce - the area to the inner contour. The wash/halo use plain SVG without CSS - filters, and hovering never replaces or flashes the Glow pool/gradient - DOM. Editors do neither [auto: smoke_ux_fixes + smoke_glow; manual visual] - ## Golden-image regression matrix `npm run golden:capture` records the data-only scenarios from @@ -591,88 +532,20 @@ whose hashes no longer match the reviewed manifest. `golden:accept -- error-free report captured from the current source fingerprint; the entire set is validated before any reference is copied. -The matrix covers thick wall junctions, the full #197 multi-room -virtual-junction resilience fixture in Plan and View (including the #261 -measured exterior-wedge fill probe), the #249 three-ray -unequal-thickness fixture with a semantic filled-node/empty-old-wedge gate, -the two #275 orthogonal-strip fixtures at `cell_cm: 5/1` with dense -`isPointInFill()` containment before raster comparison, -virtual/physical boundaries, -partitions/columns, axis-aligned and 45° door/window/gate tunnels, hidden -opening symbols, Glow and sun, live/manual Glow overlap and light through a doorway, -light/temperature/LQI fill splits on a wall axis, hover over Glow and nested rooms, all three editors, dark/light themes, -0.4×/fit/2.5×, warm remount and adaptive RU/EN dialogs including focus and the -decor colour popover. The two device-dialog scenes deliberately bind a real -light, select Always plus fixed colour/brightness, and scroll the complete -role/colour/brightness/radius block into the captured viewport; a screenshot -that contains only its heading is a scenario error, not an acceptable baseline. In the -canonical Linux CI profile Chromium, viewport, DPR, locale, timezone, colour -profile, font rendering, animations and caret are deterministic. The separate -CI job captures review candidates until the first baseline is accepted; after -that it automatically runs blocking verification. Review and accept the +What each scene covers — junctions, boundaries, openings, Glow and sun, fills, +hover, all three editors, themes, zoom levels, warm remount, dialogs — is +declared next to the scene in `demo/golden/matrix.mjs`; semantic assertions +(`enclosedHoles`, `isPointInFill()` containment, opening-symbol contracts) run +before the raster comparison, so a scene fails on meaning before pixels. A +dialog scene whose screenshot shows only a heading instead of the block it +exists for is a scenario error, not an acceptable baseline. In the canonical +Linux CI profile Chromium, viewport, DPR, locale, timezone, colour profile, +font rendering, animations and caret are deterministic. The separate CI job +captures review candidates until the first baseline is accepted; after that it +automatically runs blocking verification. Review and accept the `golden-images` CI artifact rather than treating a developer OS raster as the canonical set. See `demo/golden/README.md`. -For #249, `test/wall-thickness.test.mjs` additionally covers equal and unequal -three-/four-ray nodes (including literal 15/50/70 cm arms), reversed input, -winding/order changes, production `coordScale = 1000`, unchanged two-ray joins -and the anonymised regression fixture in -`test/fixtures/249-multiwall-junction.json`. The asymmetric corner-Split case -also proves that the union of clean-room floors equals the original room union -minus canonical bounded masonry and that every floor vertex remains inside the -source building. -`demo/smoke_multiwall_junction.mjs` checks Plan/View/kiosk/Static/hidden-Iso -parity, paper and clean-floor presence, shared Glow/sun masonry, cache reuse on -HA/theme ticks, no saved-config mutation, a filled node and the removed old -spike. Full golden/smoke/performance remain pre-beta gates. - -For #272 the same fixture and table-driven equal/mixed T/X fans inventory every -polygon hole fully enclosed in the local degree-3+ node window. The matrix runs -at `cell_cm: 5` and `cell_cm: 1`, reversed/permuted input and production scale; -`roomGeom`, final masonry and paper must all report zero local holes while the -existing discarded-wedge probe remains empty. The browser smoke repeats a -local flood-fill with real `SVGGeometryElement.isPointInFill()` in Plan, View, -kiosk and Static, and inspects hidden-Iso and light/sun source rings. The golden -scenario declares `enclosedHoles: 0`, so semantic failure happens before the -whole-frame pixel threshold. Mutation `multi-wall-exterior-corridor-disabled` -restores point-only contact and must be caught by the hole inventory even while -the legacy single-point probes remain green. - -For #275, `test/fixtures/275-orthogonal-strip-containment.json` contains only -the minimized coordinates and wall depths needed from both owner backups. The -unit oracle classifies perpendicular ray pairs independently, differences their -finite strip union against `roomGeom`/paper, covers adjacent overlapping repair -masks and keeps the non-orthogonal #249 discarded wedge empty. The production- -bundle smoke densely samples the same strips through Plan, View, kiosk, Static, -hidden Iso and light barriers. Golden scenes -`orthogonal-strip-cell-5-view-dark` and -`orthogonal-strip-cell-1-view-dark` repeat that semantic containment before -pixel comparison; `enclosedHoles: 0` remains a separate #272 assertion and can -no longer approve an exterior-connected notch. For private full-plan evidence, -`scripts/wall-strip-containment.mjs ` checks raw, Optimize preview, -applied canonical storage and JSON reload without printing or committing plan -contents. Mutation `multi-wall-orthogonal-strip-protection-disabled` restores -the release escape and must be killed by the containment tests. - -For #288, the node-map unit fixes the real topology class: `349 / 120 / 5` -steps, a 30 cm short ray and a perpendicular 20 cm shared wall beginning at its -far endpoint. It runs at scales corresponding to `cell_cm: 1/5/30`, reversed -endpoints and input permutations, while an outer continuation remains under -the established #271 contract. `demo/smoke_real_plan_masonry.mjs` loads both -tracked real-plan fixtures through the production bundle and densely samples -every undeclared room edge with `SVGGeometryElement.isPointInFill()`. Both -plans require exact `gapCount: 0` and `totalGapSteps: 0`. Mutation -`multi-wall-shared-continuation-protection-disabled` removes the endpoint -handoff and must be killed by the unit before the real-plan smoke. - -For #261, the anonymised #197 fixture also probes the real regression point -`(895.5, 556)`: `roomGeom`, final masonry and paper must fill it, while every -clean-floor contour must exclude it. The browser smoke repeats semantic point -coverage in Plan, View, kiosk, Static, hidden Iso and light/sun masonry; the two -existing #197 goldens require `SVGGeometryElement.isPointInFill()` at the same -point. Mutation `multi-wall-paper-full-origin-cut` restores the faulty -offset-origin cut and must make that regression test fail. - ## Large-house performance gate `npm run benchmark:large-house` runs a deterministic fictional three-floor @@ -792,6 +665,44 @@ See `demo/performance/README.md` for commands and the budget-review contract. installation section instead of flashing or entering a reload loop [auto: `smoke_version_recovery`]. +## Чего не проверяет автоматика + +Сводка того, что удалённые чек-листы `testing-notes/` (#681) помечали +`[manual]` без автоматического свидетеля. Проверяется на кандидате беты руками — +на демо-стенде (`demo/stand/README.md`, там же «чего на стенде нет») или у +владельца: + +- **Реальный Home Assistant.** Пользователь без прав администратора не видит «+» + пространства и шестерёнок (`_canEdit`); мастер импорта этажей на экземпляре с + настоящими этажами HA (порядок по уровню, Skip/Cancel); фильтрация мостов, + групп и сцен, нумерация дублей и свёртка групп света на настоящих реестрах; + лампа, чья сущность скрыта в реестре (свёрнута в группу), переключает и + показывает лампу; правило card-mod по `data-hp` действует в установленном + card-mod. +- **Сенсорный экран на устройстве.** Долгое нажатие 600 мс, `pointercancel` без + фантомной карточки, свайп пространств в киоске, панорама, начатая на значке в + View, — на планшете или телефоне, не в эмуляции. +- **Ресурсы сервера.** Файл ~50 МБ скачивается дважды параллельно без роста + памяти HA; перепривязка маркера переносит `/files//` с PDF; удаление + пространства снимает предупреждение Repairs о пропавшем плане. +- **Несколько клиентов и представлений.** Статическая карточка на том же + дашборде следует за перетаскиванием на полной без записи конфига; два + представления одного дашборда не делят viewport и диалоги; перезагрузка во + время записи не предлагает «Сохранить» повторно. +- **Визуальная оценка.** Центровка подложки и глифа значка (без сдвига на 1 px), + читаемость рамок и подписей на белой бумаге, цвета и градиенты заливок + «Свет», «Температура» и LQI, свет через туннель двери в толстой стене, + кромка солнечного луча на тёмной сцене (тонкий край, не контур), раскладка + карточки комнаты. +- **Пользовательское содержимое.** `javascript:` в поле ссылки маркера не + становится ссылкой. + +Остальные пункты с пометкой `[manual]` описывали обычное поведение режимов, +редакторов, диалогов, правил значков и `houseplan-space-card`. Для них нужен +смок, а не ручная проверка: задача, которая трогает такую поверхность без +смока, пишет его в том же коммите (правило `[auto: …]` выше). Пункты +`[manual]` разделов ниже остаются как есть. + ## Release regression quickies - [ ] Browser console has zero errors from houseplan-card.js on: dashboard load, markup, dialogs, zoom diff --git a/docs/design/505-summary-panel/ACCEPTANCE.md b/docs/design/505-summary-panel/ACCEPTANCE.md deleted file mode 100644 index 399eb380..00000000 --- a/docs/design/505-summary-panel/ACCEPTANCE.md +++ /dev/null @@ -1,128 +0,0 @@ -# #505 — author evidence and visual comparison - -Issue: [#505](https://github.com/Matysh/houseplan-card/issues/505). -Spec: [505-summary-panel-design-parity.md](../../specs/505-summary-panel-design-parity.md). -This is implementation evidence, **not** the independent S7 verdict. - -## Reproduce the paired visual check - -After `npm ci` and `npm run bundle:sync`: - -```sh -node demo/capture_summary_panel_505.mjs --probe-only -node demo/capture_summary_panel_505.mjs --output artifacts/summary-panel-505 -``` - -The [fixture instructions](../../../demo/helpers/README-ha-dialog-505.md) explain -the pinned official HA wheel, isolated loopback server, cache integrity and -optional offline wheel. No production HA instance is accessed. The genuine -`hp-dialog → ha-dialog → wa-dialog → dialog` chain is exercised; only the HA -app's private loader is exposed, not a replacement dialog implementation. - -Author run on Windows/Chromium, product commit `05778a47` (pre-rebase -`a038149c`, recorded in the original capture): -source fingerprint `23ed457d01a2622aae7bb6937116f20f0044b8216b723388ed3dcf85dacd4a1e`. -Rebase onto dev `2e387ac1` preserved identical frontend source and bundle bytes; -both unrelated #495 and #505 changelog entries were retained. `gate:small` was -rerun successfully after the rebase. -The full capture completed all five paired panel contexts and native/real-HA -settings in desktop, 320/390px, light/dark and enlarged-text cases. Every settings -surface/editor/footer had no horizontal overflow and a visible footer; no page -errors, external requests or WebSockets occurred. The separate authentic probe -measured small=320, medium=580, inherited medium=920, mobile=390 fullscreen. -The product's **first opening** was 920px in both native and genuine HA. - -Long German source fields were additionally re-captured using `--only` for -the `edge-*-text-200-long-de*` scenarios after replacing an incorrect assumption -about scroll ownership in the capture. Real HA scrolls its shadow `.body`, -native scrolls `.summary-editor`. The report records both scroll owners and -the actual visible band between header and footer; the source name and entity -ID were visually inspected in the corrected start/end frames. Product code -was not changed for this capture correction. - -These are diagnostic frames, not canonical golden/docs baselines. The main -matrix and corrected source frames were personally inspected, including -desktop light/dark, bottom portrait, kiosk, mobile View, native/HA settings, -and enlarged German text. The portable command produces the same comparisons -for the independent reviewer. A completed capture alone is not visual approval. - -## Comparison result and bounded adaptations - -| Surface | Result | -|---|---| -| Header/kiosk control | Designer outline gear on the left, sidebar on the right; only the right half is active. Normal control is last in View actions, kiosk control floats above the panel. | -| Panel | Separate header, muted scroller, white/theme block cards, header dividers and left-label/right-bold-value rows. Compact content height, 12px radius and stage insets; right/bottom anchoring preserved. | -| Settings | General/Blocks cards, block count, grip/arrows/name/eye header, scope row, label/source/remove value rows, dashed add actions and separate destructive block footer match the composition. | -| Responsive form | Wide 920px shell instead of the reference's 780px accommodates readable text and 44px controls. Narrow fields stack. HA retains its own fullscreen rule and close-button placement; native keeps its normal modal close position. | -| Themes and text | HA theme colors replace hard-coded cyan/white. Text is larger than the miniature reference and follows root font size; HA's 14px root yields a 19.25px title, native 16px root yields 22px. At root32px the title is 44px and wraps without clipping. | -| Data and actions | Real HA-formatted values, stable source picker, permissions and shared/local ownership retained. Save stays visible but disabled when unchanged, unlike the demo's omitted Save. No size controls in this dialog; stored scales preserved. | - -The 800×500 effective-viewport scenario is a layout proxy for 200% zoom, **not -an actual browser-zoom claim**. Separate root32px scenarios exercise enlarged -text. Different fake HA chrome, background plan and empty non-summary glyph -stubs are outside comparison scope. No pixel-identical rendering is claimed. - -## Executed gates - -- `npm run gate:small` — PASS: frontend units, typecheck/build, no-new-any, - bundle parity and budget. The initial View graph is 298558B gzip, only 1B - above the pre-task build; all added summary shell CSS is lazy. No budget or - baseline was increased. -- `node demo/smoke_summary_panel.mjs` — PASS, retaining live values, lost-ACK, - source identity, 10k-entity/200-row/multi-card scenarios. -- `node demo/smoke_summary_panel_polish.mjs` — PASS, actual frame sampling, - lifecycle boundaries, native geometry and persisted-setting combinations. -- `node demo/smoke_kiosk.mjs`, `node demo/smoke_houseplan_panel.mjs`, - `node demo/smoke_dialog_footer_width.mjs`, `node demo/smoke_nav_persist.mjs`, - `node demo/smoke_lazy_editor_chunk.mjs`, `node demo/smoke_warm_dialogs.mjs` - — PASS for neighbouring View/navigation/dialog contracts. -- `node scripts/check-docs.mjs --screenshots=warn` — PASS with the expected - stale canonical screenshot warning. Strict capture/acceptance belongs to - the beta candidate; no Windows baseline acceptance was performed. -- `git diff --check`, `node scripts/check-inputs.mjs --coverage`, - `node scripts/mutation-gate.mjs --check` — PASS. - -The broader smoke-select list includes unrelated symbols reached through the -large card renderer. Geometry, drawing, light, PDF, backend and public models -are unchanged; their full release suites and performance/golden captures were -not rerun for this task. This is not a release-quality assertion for a beta. - -## AC and failure witnesses - -All registered witnesses below were run with -`node scripts/mutation-gate.mjs --id=` on the implementation commit: -each clean guard passed, each mutated guard failed, and the runner reported -`поймано 1 из 1` with exit0. No stale-bundle bypass was used. - -| AC | Evidence | What detects a regression | -|---|---|---| -| AC1–2 | Focused smoke geometry/content + paired visual inspection above | Direct actual DOM/bounds/glyph/value comparisons; these are presentation AC, not guards. | -| AC3 | Focused smoke samples opacity, 18px offset, 190ms duration, one retained inert node, reversal and removal | `summary-hide-unmounts-before-animation` is caught. | -| AC4 | `test/summary-panel-presentation.test.mjs` plus focused browser lifecycle matrix | `summary-animation-stale-completion-unguarded`, `summary-animation-reset-keeps-presentation`, `summary-animation-ignores-reduced-motion` are caught. Actual browser checks include backgrounding, resize, eligibility, editors and identity changes. | -| AC5 | Native width/actual-control bounds smoke plus authentic HA measured and inspected frames | `summary-dialog-loses-wide-shell` is caught; it removes wide from the real form rather than changing a test fixture. | -| AC6 | Existing/focused summary smokes and paired settings frames | Actual CRUD/reorder/visibility/source/confirmation results and direct layout comparison. | -| AC7 | Summary runtime units and focused persisted-storage comparisons, admin and local-only | Read-through confirms existing scale load/save authority is untouched; the existing `summary-local-scale-authority-disabled` witness remains applicable (not rerun here). | -| AC8 | All local/mobile pairs through Save/Cancel/X/Escape/reopen/reload; unchanged and busy form | `summary-mobile-ignores-local-off` is caught by real disabled/focus/reopen/reload assertions; prior checkbox value remains saved. | -| AC9 | Existing summary runtime units + source picker/live/lost-ACK smoke | Existing #493 guard mechanisms read as unchanged; their registered witnesses remain applicable. This task does not claim a fresh negative run of every unchanged backend/permission guard. | -| AC10 | Four locales, narrow geometry, actual targets, keyboard, camera identity; theme/large-text frames | Actual bounds/focus/value comparisons and frame inspection. | -| AC11 | Gates/docs above; code scope inspection | No new eager implementation except header call order; stored schema and backend untouched. | - -## Code-review r1 follow-up - -The independent review accepted the implementation and recorded one Low L1: -`summary.block_visible` had no remaining consumers. The follow-up removes only -that unused key from all four dictionaries and rebuilds the bundle. No render, -style, event, model or visible string is changed. The paired visual evidence -above still describes the accepted implementation; it is not claimed as a new -capture of the cleanup commit. - -- `rg -n 'summary\.block_visible' src demo test scripts` — no matches. -- `npm run gate:small` after rebuilding, synchronizing and staging the complete - generated trees — PASS. The preliminary attempts caught unsynchronized bundle - manifests and untracked generated assets; both were corrected without changing - any test or gate. -- `node demo/smoke_summary_panel_polish.mjs` on the fresh cleanup bundle — PASS. - -Rollback is a revert and normal bundle rebuild. Nothing needs data migration. -Independent review and pipeline merge remain required before S8; publication -and issue closure belong to a later release request. diff --git a/docs/design/505-summary-panel/README.md b/docs/design/505-summary-panel/README.md deleted file mode 100644 index e4f6edb2..00000000 --- a/docs/design/505-summary-panel/README.md +++ /dev/null @@ -1,17 +0,0 @@ -# #505 designer reference - -Issue: https://github.com/Matysh/houseplan-card/issues/505 -Specification: [505-summary-panel-design-parity.md](../../specs/505-summary-panel-design-parity.md). - -Original files supplied by the owner on 2026-09-09 in `макет.zip` are preserved -under [reference](reference/index.html). ZIP SHA-256: -`bc754c16c9d9fc79dbbf093fcf8e14b96f8826fa01d1f0c8eccf68ab95357e15`. - -Open index.html and use the rightmost split control (gear, then sidebar toggle). -The demo uses local state; its JS is design reference, not production or process -instructions. No backend dependencies are needed. Demo dates may vary. -Do not bundle these files into the product. The numbered spec documents the -allowed adaptation for HA themes, accessibility and production data ownership. - -[Author comparison and executable evidence](ACCEPTANCE.md) documents the -prototype/product pairs and accepted adaptations; it does not replace S7 review. diff --git a/docs/design/505-summary-panel/reference/DEVELOPER-HANDOFF.md b/docs/design/505-summary-panel/reference/DEVELOPER-HANDOFF.md deleted file mode 100644 index 7300488a..00000000 --- a/docs/design/505-summary-panel/reference/DEVELOPER-HANDOFF.md +++ /dev/null @@ -1,143 +0,0 @@ -# Dashboard 5 — передача в разработку - -Дата пакета: 8 сентября 2026 года. -Основание: [houseplan-card issue #437](https://github.com/Matysh/houseplan-card/issues/437) и согласованные уточнения встречи от 8 сентября 2026 года. - -## Назначение - -Dashboard 5 — интерактивный HTML-прототип конфигурируемой информационной панели рядом с планом House Plan. Пакет предназначен как визуальная и поведенческая основа для реализации внутри существующей Lovelace-карточки. - -## Зафиксированное поведение - -- Информационная панель плавает поверх плана и не участвует в геометрии, `content bbox` или автоматическом `fit`. -- В горизонтальной ориентации панель размещается справа. -- В портретной ориентации панель размещается снизу, центрируется и подстраивает ширину под содержимое с ограничением шириной экрана. -- Панель имеет собственный пользовательский заголовок. -- В шапке House Plan используется составной контрол: слева настройка, справа показ или скрытие панели. -- Правая кнопка использует `svg/layout-sidebar-right.svg`. -- Отдельного состояния сворачивания всей панели и сворачивания блоков нет. -- Значения в строках выровнены по правому краю. -- Панель и её содержимое ограничены по высоте; при переполнении прокручиваются данные, а не план. -- В режиме киоска панель располагается ниже плавающего блока управления. - -## Структура данных - -```text -информационная панель -└── блок - └── значение: пользовательское название — источник/состояние -``` - -Предлагаемая модель: - -```js -{ - title: "Сводная информация", - showOnMobile: true, - blocks: [ - { - id: "stable-block-id", - title: "Общее", - visible: true, - scope: { type: "all" }, - values: [ - { - id: "stable-value-id", - label: "Количество устройств", - source: { type: "system", key: "device_count" } - } - ] - } - ] -} -``` - -Для блока, привязанного к пространству: - -```js -scope: { type: "space", spaceId: "stable-space-id" } -``` - -Для сущности Home Assistant: - -```js -source: { type: "entity", entityId: "sensor.example" } -``` - -## Начальная конфигурация - -- Название панели: «Сводная информация». -- Один включённый блок «Общее» для всех пространств. -- Системные строки в порядке: - 1. «Количество устройств»; - 2. «Общая площадь комнат»; - 3. «Текущие дата и время». - -## Диалог настроек - -Диалог работает с единым черновиком. «Сохранить» применяет все изменения атомарно. «Отмена», крестик и Escape отбрасывают весь черновик. - -Поддерживаются: - -- название панели; -- общий показ панели; -- показ в нативном узком режиме Home Assistant; -- создание, переименование, включение, выключение и удаление блоков; -- выбор области блока: все пространства или одно определённое; -- создание, переименование, сортировка и удаление строк; -- сортировка блоков и строк перетаскиванием и квадратными кнопками «выше/ниже»; -- выбор сущности по имени и `entity_id`; -- состояния загрузки, устаревания и недоступности; -- проверка обязательных полей и блокировка сохранения при ошибках; -- подтверждение удаления непустого блока. - -Ограничения структуры: не более 10 блоков, не более 20 значений в блоке, заголовок панели или блока до 48 символов, название значения до 64 символов. - -## Интеграция с Home Assistant - -Прототип содержит демонстрационный список сущностей. В продукте необходимо: - -1. Получать полный доступный пользователю список из `hass.states`. -2. Использовать стабильный `entity_id` и форматирование состояния средствами Home Assistant. -3. Показывать системные показатели в том же селекторе источников, что и сущности. -4. Автоматически включать в список новые Template-сущности. -5. Сохранять ранее выбранную недоступную сущность и отмечать её как требующую внимания. -6. Получать мобильный/узкий режим из нативного контекста Home Assistant, а не из собственного числового breakpoint. -7. Интегрировать конфигурацию с постоянным хранилищем House Plan, совместной работой и экспортом/импортом, не добавляя данные панели в геометрию плана. -8. Следовать действующей модели прав House Plan для доступа к структурным настройкам. - -## Файлы прототипа - -- `index.html` — экран House Plan, панель и диалоги. -- `dashboard-5.js` — состояние, черновик настроек, фильтрация по пространству и адаптивная позиция. -- `dashboard-5.css` — компоновка, темы, киоск и анимации. -- `styles.css`, `components.css`, `components.html` — базовая демонстрационная оболочка. -- `svg/` — локальные иконки интерфейса. - -## Проверки перед реализацией - -- Обычный и нативный узкий режим Home Assistant. -- Альбомная и портретная ориентации телефона и планшета. -- Панель справа и снизу, короткий и максимальный состав. -- Светлая и тёмная темы, 200% zoom и safe area. -- Клавиатурная навигация, Escape, фокус после сортировки. -- Pan/pinch/scroll плана при открытой панели. -- Переход между пространствами без `fit/refit` и скачка масштаба. -- Загрузка, отсутствие данных, `unknown`, `unavailable`, восстановленная и удалённая сущность. -- Удалённое пространство, сохранённая проблемная привязка и смена активного пространства. -- Перезагрузка страницы, совместное редактирование, импорт и экспорт конфигурации. - -## Запуск прототипа - -Из папки `Dashboard 5`: - -```text -python -m http.server 8768 -``` - -Открыть `http://127.0.0.1:8768/`. - -## Граница ответственности прототипа - -HTML-прототип демонстрирует интерфейс и взаимодействия. Статические системные числа, демонстрационный массив сущностей и `localStorage` не являются готовой продуктовой реализацией. Источники данных, постоянное хранение, права, i18n и интеграционные тесты реализуются в кодовой базе House Plan. - diff --git a/docs/design/505-summary-panel/reference/IMPLEMENTATION-NOTES.md b/docs/design/505-summary-panel/reference/IMPLEMENTATION-NOTES.md deleted file mode 100644 index 049bdfb8..00000000 --- a/docs/design/505-summary-panel/reference/IMPLEMENTATION-NOTES.md +++ /dev/null @@ -1,32 +0,0 @@ -# Dashboard 5 — заметки по реализации - -Версия реализует изменения из документа «ТЗ Dashboard 4 — финальные изменения по итогам встречи 08.09.2026.md» как отдельный прототип и не изменяет Dashboard 4. - -## Реализовано - -- автоматический выбор позиции по ориентации viewport через `matchMedia("(orientation: portrait)")`; -- `ResizeObserver` и обработчик `resize` для реакции на изменение размеров и поворот устройства; -- плавающее размещение поверх плана; -- компактная ширина по содержимому в правом и нижнем положении с ограничением доступным экраном; -- направленная анимация показа и скрытия; -- составной контрол из двух доступных кнопок; -- единое состояние показа в шапке и диалоге; -- собственный заголовок информационной панели; -- отсутствие элементов и состояния сворачивания блоков; -- внутренний скролл при переполнении; -- безопасное расположение под управлением киоска; -- отключение переходов при `prefers-reduced-motion`. - -## Прототипные ограничения - -- сущности заданы демонстрационным массивом; -- сохранение основано на `localStorage`; -- реальные контексты и форматирование Home Assistant требуют интеграции; -- touch/pan/pinch, safe area и 200% zoom требуют проверки в реальной карточке. - -## Последние визуальные уточнения - -- кнопка показа использует `svg/layout-sidebar-right.svg`; -- значения строк выровнены по правому краю блока; -- в портретной ориентации панель находится снизу, центрируется и не растягивается на всю ширину без необходимости. - diff --git a/docs/design/505-summary-panel/reference/ISSUE-HANDOFF.md b/docs/design/505-summary-panel/reference/ISSUE-HANDOFF.md deleted file mode 100644 index b2a1fe74..00000000 --- a/docs/design/505-summary-panel/reference/ISSUE-HANDOFF.md +++ /dev/null @@ -1,22 +0,0 @@ -# Текст для публикации в issue #437 - -Подготовлен архив **Dashboard-5-Issue-437-handoff-2026-09-08.zip** с актуальной версией HTML-прототипа информационной панели. - -В Dashboard 5 зафиксированы последние согласованные решения: - -- плавающая панель поверх плана без изменения масштаба и геометрии; -- автоматическое расположение справа в горизонтальной и снизу в портретной ориентации; -- компактный размер по содержимому с ограничением доступным экраном; -- составной контрол «настройки + показать/скрыть» в шапке House Plan; -- собственный пользовательский заголовок панели; -- единый диалог настройки и атомарные «Сохранить/Отмена»; -- блоки всегда развёрнуты; -- сортировка блоков и строк, включая доступные кнопки «выше/ниже»; -- области отображения блоков по пространствам; -- начальный блок «Общее» с тремя системными значениями; -- состояния сущностей, темы, киоск и локальные размеры экрана. - -В архив включены исходники прототипа, локальные SVG, инструкция запуска и документ `DEVELOPER-HANDOFF.md` с моделью данных, интеграционными требованиями и перечнем проверок для продуктовой реализации. - -Прототип использует демонстрационные сущности и `localStorage`. В продукте список должен формироваться из `hass.states`, а конфигурация — сохраняться штатными средствами House Plan. - diff --git a/docs/design/505-summary-panel/reference/README.md b/docs/design/505-summary-panel/reference/README.md deleted file mode 100644 index f10b03c1..00000000 --- a/docs/design/505-summary-panel/reference/README.md +++ /dev/null @@ -1,46 +0,0 @@ -# Dashboard 5 - -Интерактивный HTML-прототип финальной компоновки информационной панели House Plan по итогам встречи 8 сентября 2026 года и требованиям [issue #437](https://github.com/Matysh/houseplan-card/issues/437). - -## Запуск - -Откройте папку через локальный HTTP-сервер: - -```text -python -m http.server 8768 -``` - -После запуска страница доступна по адресу `http://127.0.0.1:8768/`. - -## Главное отличие версии 5 - -- панель плавает поверх плана и не изменяет его рабочую область; -- в горизонтальной ориентации экрана располагается справа; -- в вертикальной ориентации экрана автоматически перемещается вниз; -- положение пересчитывается при изменении размера окна или повороте устройства; -- габариты панели зависят от содержимого и ограничиваются доступным экраном; -- в нижнем положении панель центрируется и сохраняет компактную ширину по содержимому; -- собственный заголовок возвращён в панель; -- в шапке House Plan находится компактный составной контрол без текста; -- левая половина контрола открывает общий диалог настройки; -- правая половина показывает и скрывает панель; -- переключатель видимости в диалоге управляет тем же состоянием; -- отдельное состояние сворачивания панели отсутствует; -- блоки не сворачиваются ни в просмотре, ни в настройках; -- появление и скрытие анимируется в направлении текущего края; -- в режиме киоска учитываются плавающие элементы управления. - -## Основные файлы - -- `index.html` — интерфейс House Plan и разметка панели/диалогов; -- `dashboard-5.js` — модель, адаптивная позиция, настройки и взаимодействия; -- `dashboard-5.css` — компоновка, анимации, темы и киоск; -- `styles.css`, `components.css` — базовое оформление плана; -- `svg/` — локальные иконки. - -## Ограничение прототипа - -Список сущностей остаётся демонстрационным, а конфигурация сохраняется в `localStorage`. В продуктовой реализации источником сущностей должен быть `hass.states`, а хранение необходимо интегрировать со штатной конфигурацией House Plan. - -Полный пакет передачи разработчикам описан в `DEVELOPER-HANDOFF.md`. Текст, который можно использовать при прикреплении архива к issue, находится в `ISSUE-HANDOFF.md`. - diff --git a/docs/design/505-summary-panel/reference/components.css b/docs/design/505-summary-panel/reference/components.css deleted file mode 100644 index e90eccef..00000000 --- a/docs/design/505-summary-panel/reference/components.css +++ /dev/null @@ -1,425 +0,0 @@ -:root { - --ha-blue: #03a4cf; - --ha-blue-dark: #0497bd; - --ha-text: #202124; - --ha-muted: #686b6e; - --ha-border: #dedede; - --ha-soft: #f1f1f1; - --ha-danger: #ff725d; -} - -* { box-sizing: border-box; } - -html, body { margin: 0; min-width: 1120px; min-height: 720px; font-family: Arial, Helvetica, sans-serif; color: var(--ha-text); } -button, input { font: inherit; } -button { cursor: default; } - -.components-page { min-height: 100vh; overflow: auto; background: #dceaf0; } - -.ha-component-header { position: relative; z-index: 5; background: #fff; } -.ha-view-tabs { - display: flex; - align-items: flex-end; - height: 82px; - padding-left: 21px; - gap: 25px; - border-bottom: 1px solid var(--ha-border); -} -.ha-view-tab { - position: relative; - display: grid; - place-items: center; - width: 56px; - height: 62px; - color: #48494a; - font-size: 29px; - font-weight: 700; -} -.ha-view-tab.active::after { position: absolute; right: 0; bottom: 0; left: 0; height: 3px; content: ""; background: #222; } - -.ha-context-row { - display: flex; - align-items: center; - height: 86px; - padding: 0 20px; - gap: 14px; - border-bottom: 1px solid var(--ha-border); - white-space: nowrap; -} -.ha-plan-label { display: flex; align-items: center; gap: 8px; font-size: 21px; } -.ha-home-icon { color: var(--ha-blue); font-size: 28px; } -.ha-segmented { display: flex; align-items: center; height: 57px; padding: 5px; border-radius: 17px; background: var(--ha-soft); } -.ha-segment { display: flex; align-items: center; height: 47px; padding: 0 21px; gap: 8px; border-radius: 13px; color: #555; font-size: 18px; font-weight: 700; } -.ha-segment small { color: #bbb; font-size: 15px; } -.ha-segment.active { color: #fff; background: var(--ha-blue); } -.ha-segment.active small { color: #8ed5e8; } -.ha-segment b { margin-left: 9px; font-size: 23px; font-weight: 300; } -.ha-add { padding: 0 13px; color: #666; font-size: 24px; } -.ha-editor-switcher { margin-left: 0; } -.ha-editor-switcher .ha-segment { padding: 0 18px; } -.ha-device-count { color: #555; font-size: 17px; } - -.ha-tools-row { - display: flex; - align-items: center; - min-height: 85px; - padding: 14px 22px; - gap: 15px; - border-bottom: 1px solid var(--ha-border); - background: #fff; - white-space: nowrap; -} -.ha-tool-mode { color: #ffb52e; font-size: 34px; } -.ha-tool { - display: inline-flex; - align-items: center; - justify-content: center; - height: 47px; - padding: 0 18px; - gap: 11px; - border: 1px solid #d7d7d7; - border-radius: 14px; - color: #111; - background: #fff; - font-size: 19px; -} -.ha-tool.active { border-color: var(--ha-blue); color: #fff; background: var(--ha-blue); } -.ha-tool-icon { font-size: 22px; font-weight: 700; } -.ha-chevron { margin-left: 3px; } -.ha-history-actions { display: flex; margin-left: auto; gap: 21px; } -.ha-history-actions button { border: 0; color: #929292; background: transparent; font-size: 27px; } - -.ha-component-stage { position: relative; min-height: 1090px; overflow: hidden; background: #dbeaf0; } -.ha-stage-grid { - position: absolute; - inset: 0; - opacity: .6; - background-image: radial-gradient(#4da2bb 1px, transparent 1.5px); - background-size: 10px 10px; -} -.ha-plan-fragment { position: absolute; inset: 100px 10% 0 15%; color: #383d40; font-weight: 700; opacity: .45; } -.ha-plan-room { position: absolute; display: grid; place-items: center; border: 12px solid #596266; background: #a0a9ae; } -.room-a { left: 0; top: 0; width: 70%; height: 340px; } -.room-b { left: 28%; top: 328px; width: 31%; height: 390px; } -.room-c { left: 58%; top: 328px; width: 28%; height: 390px; } -.ha-modal-backdrop { position: absolute; inset: 0; background: rgba(40, 40, 40, .48); } - -.ha-device-modal { - position: absolute; - top: 9px; - left: 50%; - display: flex; - flex-direction: column; - width: 870px; - height: 1080px; - transform: translateX(-50%); - overflow: hidden; - border-radius: 21px; - background: #fff; - box-shadow: 0 7px 24px rgba(0,0,0,.18); -} -.ha-modal-header { display: flex; align-items: center; flex: 0 0 119px; padding: 0 37px; gap: 18px; } -.ha-modal-header h1 { margin: 0; font-size: 31px; line-height: 1; } -.ha-modal-close { display: grid; place-items: center; width: 34px; height: 34px; font-size: 40px; font-weight: 200; line-height: 1; } -.ha-device-shapes { position: relative; display: block; width: 31px; height: 34px; } -.ha-device-shapes i { position: absolute; display: block; background: var(--ha-blue); } -.ha-device-shapes i:nth-child(1) { left: 0; top: 4px; width: 13px; height: 13px; } -.ha-device-shapes i:nth-child(2) { right: 0; top: 4px; width: 13px; height: 13px; border-radius: 50%; } -.ha-device-shapes i:nth-child(3) { left: 1px; bottom: 1px; width: 0; height: 0; border-right: 7px solid transparent; border-bottom: 0; border-left: 7px solid transparent; border-top: 13px solid var(--ha-blue); background: transparent; transform: rotate(180deg); } -.ha-device-shapes i:nth-child(4) { right: 1px; bottom: 4px; width: 13px; height: 3px; } -.ha-device-shapes i:nth-child(4)::after { position: absolute; left: 5px; top: -5px; width: 3px; height: 13px; content: ""; background: var(--ha-blue); } - -.ha-modal-body { flex: 1 1 auto; min-height: 0; padding: 8px 46px 32px 37px; overflow-y: scroll; scrollbar-color: #b7b7b7 transparent; scrollbar-width: auto; } -.ha-modal-body::-webkit-scrollbar { width: 14px; } -.ha-modal-body::-webkit-scrollbar-track { background: transparent; } -.ha-modal-body::-webkit-scrollbar-thumb { border: 3px solid #fff; border-radius: 9px; background: #b7b7b7; } -.ha-field { margin-bottom: 20px; } -.ha-field label, .ha-field-label { display: block; margin-bottom: 12px; color: var(--ha-muted); font-size: 18px; } -.ha-field input[type="text"], .ha-light-sources input[type="text"] { - width: 100%; - height: 45px; - padding: 0 14px; - border: 1px solid #d6d6d6; - border-radius: 10px; - color: #242424; - background: #fff; - outline: none; - font-size: 19px; -} -.ha-binding-card { padding: 20px 13px 12px; border: 1px solid #d7d7d7; border-radius: 12px; } -.ha-choice { display: flex !important; align-items: center; margin: 0 0 21px !important; gap: 13px; color: var(--ha-muted); font-size: 18px; } -.ha-choice input { width: 19px; height: 19px; margin: 0; accent-color: #078cff; } -.ha-choice-row { display: flex; align-items: center; justify-content: flex-start; gap: 29px; } -.ha-switch-label { display: flex; align-items: center; gap: 12px; color: #727272; font-size: 18px; } -.ha-switch { position: relative; display: inline-block; width: 71px; height: 37px; border: 1px solid #a9a9a9; border-radius: 20px; background: #f7f7f7; } -.ha-switch i { position: absolute; left: 4px; top: 3px; width: 29px; height: 29px; border-radius: 50%; background: #737373; } -.ha-select-box { display: flex; align-items: center; width: 100%; height: 47px; padding: 0 14px; border: 1px solid #d6d6d6; border-radius: 10px; color: #222; background: #fff; font-size: 19px; } -.strong-select { height: 54px; border-color: #555; } -.ha-entity { margin-left: auto; color: #696969; font-weight: 400; } -.ha-select-arrow { margin-left: 18px; font-size: 23px; font-weight: 700; } -.action-field { margin-bottom: 30px; } -.action-field p { margin: 9px 0 0; color: var(--ha-muted); font-size: 18px; } -.confirmation { margin: 0 0 25px; } -.ha-light-sources h2 { margin: 0 0 18px; color: var(--ha-muted); font-size: 18px; font-weight: 400; } -.ha-light-sources p { margin: 0 0 8px; color: var(--ha-muted); font-size: 17px; line-height: 1.45; } -.ha-light-sources input { margin-top: 14px; } -.ha-light-sources input::placeholder { color: #777; opacity: 1; } -.ha-source-type { margin: 41px 0 0; padding: 26px 18px 13px; border: 1px solid #d8d8d8; border-radius: 12px; } -.ha-source-type legend { padding: 0 8px 0 0; font-size: 20px; font-weight: 700; } -.ha-help { display: inline-grid; place-items: center; width: 23px; height: 23px; margin-left: 13px; border: 2px solid #707070; border-radius: 50%; color: #707070; font-size: 15px; } -.ha-source-type .ha-choice { margin-bottom: 19px !important; } -.ha-modal-spacer { height: 130px; } - -.ha-modal-footer { display: flex; align-items: center; justify-content: space-between; flex: 0 0 107px; padding: 0 47px; border-top: 1px solid #e0e0e0; background: #fff; } -.ha-footer-left, .ha-footer-right { display: flex; align-items: center; gap: 13px; } -.ha-action-button, .ha-text-button, .ha-save-button { display: inline-flex; align-items: center; justify-content: center; height: 48px; border-radius: 11px; background: #fff; font-size: 18px; } -.ha-action-button { padding: 0 17px; gap: 10px; border: 1px solid #d5d5d5; } -.ha-action-button.danger { border-color: var(--ha-danger); color: var(--ha-danger); } -.ha-text-button { padding: 0 22px; border: 0; } -.ha-save-button { padding: 0 22px; gap: 12px; border: 1px solid var(--ha-blue); color: #fff; background: var(--ha-blue); font-weight: 700; } - -@media (max-width: 1500px) { - .ha-context-row { gap: 8px; padding-inline: 12px; } - .ha-segment { padding-inline: 13px; font-size: 15px; } - .ha-plan-label { font-size: 18px; } - .ha-tools-row { gap: 8px; padding-inline: 12px; } - .ha-tool { padding-inline: 11px; font-size: 15px; } -} - -@media (max-height: 950px) { - .ha-device-modal { height: calc(100vh - 18px); } - .ha-component-stage { min-height: calc(100vh - 253px); } -} - -/* Reusable home information panel */ -.ha-home-panel { - position: relative; - z-index: 4; - display: flex; - flex: 0 0 390px; - width: 390px; - min-width: 0; - height: 100%; - flex-direction: column; - border-left: 1px solid #d9dcde; - background: #f5f7f8; - box-shadow: -5px 0 20px rgba(46, 58, 64, .1); - transition: flex-basis .22s ease, width .22s ease; -} -.ha-home-panel.is-collapsed { flex-basis: 48px; width: 48px; } -.ha-home-panel__header { - position: relative; - display: flex; - align-items: center; - flex: 0 0 62px; - min-height: 62px; - padding: 0 13px 0 9px; - gap: 10px; - border-bottom: 1px solid #dfe2e4; - background: #fff; -} -.ha-icon-button { - display: inline-grid; - place-items: center; - width: 34px; - height: 34px; - flex: 0 0 34px; - padding: 0; - border: 0; - border-radius: 9px; - color: #555b5e; - background: transparent; - font-size: 18px; -} -.ha-icon-button:hover { background: #edf1f2; } -.ha-home-panel__size-toggle { border: 1px solid #dce0e2; font-size: 27px; line-height: 1; } -.ha-home-panel__size-toggle .when-collapsed { display: none; } -.ha-home-panel.is-collapsed .ha-home-panel__size-toggle .when-expanded { display: none; } -.ha-home-panel.is-collapsed .ha-home-panel__size-toggle .when-collapsed { display: inline; } -.ha-home-panel__title-wrap { display: flex; align-items: center; min-width: 0; gap: 9px; } -.ha-home-panel__mark { color: var(--ha-blue); font-size: 21px; } -.ha-home-panel__title-wrap h2 { margin: 0; font-size: 16px; line-height: 1.2; } -.ha-home-panel__title-wrap small { display: block; margin-top: 2px; color: #818588; font-size: 10px; } -.ha-panel-settings-toggle { margin-left: auto; } -.ha-panel-settings-toggle.is-active { color: #fff; background: var(--ha-blue); } -.ha-home-panel__rail-title { display: none; position: absolute; top: 56px; left: 16px; color: #72777a; font-size: 11px; font-weight: 700; letter-spacing: .04em; writing-mode: vertical-rl; } -.ha-home-panel.is-collapsed .ha-home-panel__header { flex: 1 1 auto; align-items: flex-start; padding: 10px 7px; } -.ha-home-panel.is-collapsed .ha-home-panel__title-wrap, -.ha-home-panel.is-collapsed .ha-panel-settings-toggle, -.ha-home-panel.is-collapsed .ha-home-panel__content { display: none; } -.ha-home-panel.is-collapsed .ha-home-panel__rail-title { display: block; } - -.ha-home-panel__content { display: flex; flex: 1 1 auto; min-height: 0; flex-direction: column; padding: 10px; overflow-y: auto; scrollbar-width: thin; scrollbar-color: #b8bec1 transparent; } -.ha-summary-card, -.ha-info-block, -.ha-panel-manager { margin-bottom: 9px; border: 1px solid #dde1e3; border-radius: 11px; background: #fff; box-shadow: 0 1px 2px rgba(34, 45, 50, .04); } -.ha-summary-card { padding: 13px; } -.ha-panel-manager { order: 0; } -.ha-summary-card { order: 1; } -.ha-panel-blocks { order: 2; } -.ha-summary-card__heading, -.ha-panel-manager__heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; } -.ha-summary-actions { display: flex; align-items: center; gap: 5px; } -.ha-eyebrow { display: block; margin-bottom: 3px; color: #878b8e; font-size: 9px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; } -.ha-summary-card h3, -.ha-panel-manager h3 { margin: 0; font-size: 15px; } -.ha-status-badge, -.room-state { display: inline-flex; align-items: center; min-height: 21px; padding: 0 7px; border-radius: 10px; color: #5e6467; background: #edf0f1; font-size: 9px; font-weight: 700; white-space: nowrap; } -.ha-status-badge.good, -.room-state.good { color: #168848; background: #e1f5e8; } -.room-state.warning { color: #9b6500; background: #fff0ca; } -.ha-temperature-grid { display: grid; grid-template-columns: repeat(2, 1fr); margin-top: 12px; gap: 7px; } -.ha-temperature-metric { display: flex; align-items: center; min-width: 0; padding: 9px; gap: 9px; border-radius: 9px; background: linear-gradient(135deg, #eef8fb, #f6fafb); } -.ha-temperature-metric.outside { background: linear-gradient(135deg, #fff6dc, #fffaf0); } -.ha-temperature-metric .metric-icon { display: grid; place-items: center; width: 29px; height: 29px; flex: 0 0 29px; border-radius: 9px; color: #fff; background: #3995ae; font-size: 13px; } -.ha-temperature-metric.outside .metric-icon { background: #edae2e; } -.ha-temperature-metric span:last-child { min-width: 0; } -.ha-temperature-metric small { display: block; margin-bottom: 2px; color: #777d80; font-size: 8px; } -.ha-temperature-metric strong { display: block; font-size: 14px; white-space: nowrap; } -.ha-summary-grid { display: grid; grid-template-columns: repeat(3, 1fr); margin-top: 7px; gap: 6px; } -.ha-summary-metric { display: flex; align-items: center; min-width: 0; flex-direction: column; padding: 8px 3px; border-radius: 8px; background: #f4f6f7; text-align: center; } -.ha-summary-metric .metric-icon { display: grid; place-items: center; width: 21px; height: 21px; margin-bottom: 4px; border-radius: 50%; color: #fff; background: #768085; font-size: 10px; } -.ha-summary-metric strong { font-size: 13px; } -.ha-summary-metric small { margin-top: 2px; overflow: hidden; max-width: 100%; color: #85898c; font-size: 8px; text-overflow: ellipsis; white-space: nowrap; } -.ha-summary-note { display: flex; align-items: center; margin-top: 10px; padding-top: 9px; gap: 7px; border-top: 1px solid #eceeef; color: #6b7073; font-size: 10px; } -.ha-warning-dot { width: 8px; height: 8px; flex: 0 0 8px; border-radius: 50%; background: #ffae00; box-shadow: 0 0 0 3px #fff1c9; } - -.ha-panel-blocks { min-height: 40px; } -.ha-info-block { overflow: clip; transition: opacity .15s ease, border-color .15s ease; } -.ha-info-block.is-dragging { border-color: var(--ha-blue); opacity: .55; } -.ha-info-block__header { display: flex; align-items: center; min-height: 43px; padding: 0 8px; gap: 7px; } -.ha-drag-handle, -.ha-manager-drag { color: #a0a5a8; cursor: grab; font-size: 17px; line-height: 1; user-select: none; } -.ha-drag-handle { visibility: hidden; width: 0; overflow: hidden; opacity: 0; transition: width .15s ease, opacity .15s ease; } -.ha-home-panel.is-editing .ha-drag-handle { visibility: visible; width: auto; overflow: visible; opacity: 1; } -.ha-home-panel.is-editing .ha-info-block { border-style: dashed; } -.ha-home-panel.is-editing .ha-info-block__header { background: #fbfdfe; } -.ha-block-settings-button { display: none; place-items: center; width: 27px; height: 27px; flex: 0 0 27px; padding: 0; border: 1px solid #cfe4ea; border-radius: 7px; color: #087f9d; background: #eef9fb; font-size: 12px; } -.ha-home-panel.is-editing .ha-block-settings-button { display: grid; } -.ha-block-settings-button:hover { color: #fff; background: var(--ha-blue); } -.ha-drag-handle:active, -.ha-manager-drag:active { cursor: grabbing; } -.ha-block-icon { display: grid; place-items: center; width: 24px; height: 24px; flex: 0 0 24px; border-radius: 7px; color: #31798e; background: #e5f3f7; font-size: 11px; font-weight: 800; } -.ha-info-block__toggle { display: grid; grid-template-columns: 1fr auto 16px; align-items: center; width: 100%; height: 42px; padding: 0; gap: 7px; border: 0; color: #292d2f; background: transparent; text-align: left; } -.ha-info-block__toggle > span { font-size: 13px; font-weight: 700; } -.ha-info-block__toggle small { color: #8a8e90; font-size: 9px; font-weight: 400; } -.ha-info-block__toggle b { color: #74797c; font-size: 12px; text-align: center; transition: transform .18s ease; } -.ha-info-block.is-collapsed .ha-info-block__toggle b { transform: rotate(180deg); } -.ha-info-block.is-collapsed .ha-info-block__body { display: none; } -.ha-info-block__body { padding: 0 11px 11px 38px; border-top: 1px solid #eef0f1; } -.ha-status-list, -.ha-compact-list, -.ha-timeline { margin: 0; padding: 0; list-style: none; } -.ha-status-list li { display: flex; align-items: center; justify-content: space-between; min-height: 42px; padding: 7px 0; gap: 8px; border-bottom: 1px solid #eef0f1; } -.ha-status-list li:last-child { border-bottom: 0; } -.ha-status-list li > span:first-child { min-width: 0; } -.ha-status-list strong { display: block; overflow: hidden; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; } -.ha-status-list small { display: block; margin-top: 3px; overflow: hidden; color: #858a8c; font-size: 9px; text-overflow: ellipsis; white-space: nowrap; } -.ha-block-link { display: flex; align-items: center; justify-content: space-between; width: 100%; margin-top: 6px; padding: 7px 0 0; border: 0; color: #078eb1; background: transparent; font-size: 10px; font-weight: 700; } -.ha-inline-stats { display: grid; grid-template-columns: repeat(3, 1fr); padding: 10px 0; gap: 5px; } -.ha-inline-stats span { display: flex; align-items: center; flex-direction: column; padding: 7px 3px; border-radius: 7px; background: #f5f7f7; } -.ha-inline-stats strong { font-size: 13px; } -.ha-inline-stats small { margin-top: 2px; color: #858a8c; font-size: 8px; } -.ha-compact-list li { display: flex; align-items: center; justify-content: space-between; min-height: 31px; gap: 8px; border-top: 1px solid #eef0f1; font-size: 10px; } -.ha-compact-list li span { color: #73787a; font-size: 9px; white-space: nowrap; } -.good-text { color: #168848 !important; font-weight: 700; } -.ha-timeline { padding-top: 7px; } -.ha-timeline li { display: flex; min-height: 43px; gap: 9px; } -.ha-timeline strong { display: block; font-size: 10px; } -.ha-timeline small { display: block; margin-top: 3px; color: #7d8284; font-size: 9px; line-height: 1.3; } -.event-dot { width: 9px; height: 9px; flex: 0 0 9px; margin-top: 3px; border-radius: 50%; background: #92989b; } -.event-dot.warning { background: #ffae00; } -.event-dot.good { background: #38c86e; } - -.ha-panel-manager { padding: 12px; border-color: #b9dce5; background: #fafdfe; } -.ha-panel-manager__heading .ha-icon-button { width: 27px; height: 27px; flex-basis: 27px; } -.ha-panel-manager > p { margin: 8px 0 10px; color: #74797c; font-size: 9px; line-height: 1.4; } -.ha-panel-manager__list { display: flex; flex-direction: column; gap: 5px; } -.ha-panel-manager__item { display: grid; grid-template-columns: 20px 1fr auto; align-items: center; min-height: 36px; padding: 0 6px; border: 1px solid #dfe4e6; border-radius: 8px; background: #fff; } -.ha-panel-manager__item.is-dragging { opacity: .5; } -.ha-panel-manager__item.is-pinned { border-style: dashed; background: #f4f8f9; } -.ha-panel-manager__item.is-pinned > small { color: #8a9092; font-size: 8px; } -.ha-manager-pin { color: var(--ha-blue); font-size: 8px; text-align: center; } -.ha-panel-manager__item label { display: flex; align-items: center; gap: 7px; font-size: 10px; font-weight: 700; } -.ha-panel-manager__item input { width: 15px; height: 15px; margin: 0; accent-color: var(--ha-blue); } -.ha-manager-order { display: flex; gap: 3px; } -.ha-manager-order button { display: grid; place-items: center; width: 25px; height: 25px; padding: 0; border: 1px solid #dce0e2; border-radius: 6px; color: #5f6568; background: #fff; } -.ha-manager-order button:disabled { opacity: .3; } -.ha-reset-layout { width: 100%; height: 30px; margin-top: 9px; border: 1px solid #d4dade; border-radius: 7px; color: #555d60; background: #fff; font-size: 9px; font-weight: 700; } - -.ha-device-categories { margin-top: 3px; } -.ha-device-category { border-top: 1px solid #e9edef; } -.ha-device-category__toggle { display: grid; grid-template-columns: 1fr auto 17px; align-items: center; width: 100%; min-height: 37px; padding: 0; gap: 7px; border: 0; color: #303537; background: transparent; text-align: left; } -.ha-device-category__toggle span { font-size: 10px; font-weight: 700; } -.ha-device-category__toggle small { color: #858a8c; font-size: 8px; } -.ha-device-category__toggle b { color: #747a7d; font-size: 12px; text-align: center; transition: transform .16s ease; } -.ha-device-category.is-open .ha-device-category__toggle b { transform: rotate(180deg); } -.ha-device-category__list { margin: 0; padding: 0 0 5px; list-style: none; } -.ha-device-category__list li { display: flex; align-items: center; justify-content: space-between; min-height: 43px; padding: 6px 0 6px 8px; gap: 8px; border-top: 1px solid #eef0f1; } -.ha-device-category__list li > span { min-width: 0; } -.ha-device-category__list strong { display: block; overflow: hidden; font-size: 10px; text-overflow: ellipsis; white-space: nowrap; } -.ha-device-category__list small { display: block; margin-top: 3px; overflow: hidden; color: #858a8c; font-size: 8px; text-overflow: ellipsis; white-space: nowrap; } -.ha-device-category__list li.is-device-hidden { opacity: .48; } -.ha-device-settings-button { display: inline-flex; align-items: center; min-height: 27px; padding: 0 7px; gap: 4px; border: 1px solid #d9dfe1; border-radius: 7px; color: #4e575a; background: #fff; font-size: 9px; white-space: nowrap; } -.ha-device-settings-button:hover { border-color: #8ecddd; color: #087f9d; background: #f1fafc; } - -.ha-device-editor { display: flex; flex: 1 1 auto; min-height: 0; flex-direction: column; background: #fff; } -.ha-device-editor[hidden] { display: none; } -.ha-home-panel.is-collapsed .ha-device-editor { display: none; } -.ha-home-panel.is-device-editor-open .ha-home-panel__content { display: none; } -.ha-device-editor__header { display: flex; align-items: center; min-height: 63px; padding: 0 14px; gap: 10px; border-bottom: 1px solid #e2e5e7; } -.ha-device-editor__header h3 { margin: 0; overflow: hidden; max-width: 270px; font-size: 15px; text-overflow: ellipsis; white-space: nowrap; } -.ha-device-editor__body { flex: 1 1 auto; min-height: 0; padding: 16px; overflow-y: auto; } -.ha-device-editor__body .ha-field { margin-bottom: 16px; } -.ha-device-editor__body .ha-field label { margin-bottom: 7px; font-size: 11px; } -.ha-device-editor__body input[type="text"], -.ha-device-editor__body select { width: 100%; height: 39px; padding: 0 10px; border: 1px solid #d4dadd; border-radius: 8px; color: #2c3133; background: #fff; font-size: 12px; outline: none; } -.ha-device-editor__body input:focus, -.ha-device-editor__body select:focus { border-color: var(--ha-blue); box-shadow: 0 0 0 2px rgba(3,164,207,.12); } -.ha-device-editor__body input[readonly] { color: #777e81; background: #f5f7f8; } -.ha-device-enabled { display: flex; align-items: center; margin-top: 22px; gap: 9px; color: #52595c; font-size: 11px; } -.ha-device-enabled input { width: 17px; height: 17px; margin: 0; accent-color: var(--ha-blue); } -.ha-device-editor__footer { display: flex; justify-content: flex-end; min-height: 61px; padding: 12px 14px; gap: 8px; border-top: 1px solid #e2e5e7; } -.ha-device-editor__footer button { height: 35px; padding: 0 13px; border-radius: 8px; font-size: 11px; font-weight: 700; } -.ha-device-editor__cancel { border: 1px solid #d6dcde; color: #555d60; background: #fff; } -.ha-device-editor__save { border: 1px solid var(--ha-blue); color: #fff; background: var(--ha-blue); } - -.ha-block-editor { display: flex; flex: 1 1 auto; min-height: 0; flex-direction: column; background: #fff; } -.ha-block-editor[hidden] { display: none; } -.ha-home-panel.is-collapsed .ha-block-editor { display: none; } -.ha-home-panel.is-block-editor-open .ha-home-panel__content { display: none; } -.ha-block-editor__header { display: flex; align-items: center; min-height: 63px; padding: 0 14px; gap: 10px; border-bottom: 1px solid #e2e5e7; } -.ha-block-editor__header h3 { margin: 0; overflow: hidden; max-width: 270px; font-size: 15px; text-overflow: ellipsis; white-space: nowrap; } -.ha-block-editor__body { flex: 1 1 auto; min-height: 0; padding: 15px; overflow-y: auto; } -.ha-block-editor__body > p { margin: 0 0 13px; color: #747a7d; font-size: 10px; line-height: 1.45; } -.ha-block-editor__title-field { margin-bottom: 17px; padding-bottom: 15px; border-bottom: 1px solid #e6e9ea; } -.ha-block-editor__title-field label { display: block; margin-bottom: 7px; color: #5e6568; font-size: 10px; font-weight: 700; } -.ha-block-editor__title-field input { width: 100%; height: 38px; padding: 0 10px; border: 1px solid #d5dbdd; border-radius: 8px; color: #303638; background: #fff; font-size: 12px; outline: none; } -.ha-block-editor__title-field input:focus { border-color: var(--ha-blue); box-shadow: 0 0 0 2px rgba(3,164,207,.12); } -.ha-block-editor__list { display: flex; flex-direction: column; gap: 7px; } -.ha-block-editor__item { display: grid; grid-template-columns: 1fr auto; align-items: center; min-height: 43px; padding: 0 8px 0 10px; gap: 8px; border: 1px solid #dfe4e6; border-radius: 9px; background: #fafcfc; } -.ha-block-editor__item label { display: flex; align-items: center; min-width: 0; gap: 8px; color: #34393b; font-size: 10px; font-weight: 700; } -.ha-block-editor__item label span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.ha-block-editor__item input { width: 16px; height: 16px; margin: 0; accent-color: var(--ha-blue); } -.ha-block-editor__item button { height: 27px; padding: 0 8px; border: 1px solid #ffb6a9; border-radius: 7px; color: #d75643; background: #fff; font-size: 8px; font-weight: 700; } -.ha-block-editor__empty { margin: 0; padding: 18px 10px; border: 1px dashed #ced5d8; border-radius: 9px; color: #858b8e; background: #fafcfc; font-size: 10px; text-align: center; } -.ha-block-editor__add { margin-top: 20px; padding-top: 14px; border-top: 1px solid #e6e9ea; } -.ha-block-editor__add > label { display: block; margin-bottom: 7px; color: #5e6568; font-size: 10px; font-weight: 700; } -.ha-block-editor__add > div { display: flex; gap: 7px; } -.ha-block-editor__add select { min-width: 0; height: 35px; flex: 1 1 auto; padding: 0 8px; border: 1px solid #d5dbdd; border-radius: 8px; color: #3e4447; background: #fff; font-size: 9px; } -.ha-block-editor__add-button { height: 35px; padding: 0 10px; border: 1px solid var(--ha-blue); border-radius: 8px; color: #fff; background: var(--ha-blue); font-size: 9px; font-weight: 700; white-space: nowrap; } -.ha-block-editor__add-button:disabled { border-color: #d6dcde; color: #969b9d; background: #eef1f2; } -.ha-block-editor__footer { display: flex; justify-content: space-between; min-height: 61px; padding: 12px 14px; gap: 8px; border-top: 1px solid #e2e5e7; } -.ha-block-editor__footer button { height: 35px; padding: 0 13px; border-radius: 8px; font-size: 10px; font-weight: 700; } -.ha-block-editor__reset { border: 1px solid #d6dcde; color: #596063; background: #fff; } -.ha-block-editor__done { border: 1px solid var(--ha-blue); color: #fff; background: var(--ha-blue); } - -@media (max-width: 1450px) { - .ha-home-panel { flex-basis: 350px; width: 350px; } - .ha-summary-grid { grid-template-columns: repeat(3, 1fr); } -} - -@media (max-width: 1220px) { - .ha-home-panel { flex-basis: 320px; width: 320px; } -} - diff --git a/docs/design/505-summary-panel/reference/components.html b/docs/design/505-summary-panel/reference/components.html deleted file mode 100644 index ce2427ac..00000000 --- a/docs/design/505-summary-panel/reference/components.html +++ /dev/null @@ -1,135 +0,0 @@ - - - - - - Home Assistant — Компоненты панели - - - - -
-
- ⌗ - ▣ - ▧ -
- -
-
▥План дома
- -
- Ground Floor ⚙ - First Floor ⚙ - Yard ⚙ - + -
- -
- ⌗ Редактор плана × - ⌘ Редактор устройств - ⌁ Редактор подложки -
- - 50 устр. -
- -
- - - - - - - - - - - - -
-
- -
- - -
- - -
- - - diff --git a/docs/design/505-summary-panel/reference/dashboard-5.css b/docs/design/505-summary-panel/reference/dashboard-5.css deleted file mode 100644 index 7688c28b..00000000 --- a/docs/design/505-summary-panel/reference/dashboard-5.css +++ /dev/null @@ -1,767 +0,0 @@ -.dashboard3-panel[hidden], -#legacy-home-panel[hidden], -.dashboard3-settings-dialog[hidden], -.dashboard3-entity-picker[hidden] { display: none !important; } - -/* Dashboard 3 header — compact Home Assistant chrome from the supplied reference. */ -.dashboard3-app-header { - padding: 0 14px 0 10px; -} - -.dashboard3-view-tabs, -.dashboard3-header-actions, -.dashboard3-context-actions, -.dashboard3-zoom-group { - display: flex; - align-items: center; -} - -.dashboard3-view-tabs { - align-self: stretch; - gap: 14px; -} - -.dashboard3-header-actions { - gap: 18px; -} - -.dashboard3-header-icon { - position: relative; - display: grid; - width: 26px; - height: 100%; - padding: 0; - place-items: center; - border: 0; - color: #242729; - background: transparent; - cursor: pointer; -} - -.dashboard3-header-icon svg, -.dashboard3-context-icon svg, -.dashboard3-zoom-group svg { - display: block; - width: 17px; - height: 17px; - fill: none; - stroke: currentColor; - stroke-width: 1.9; - stroke-linecap: round; - stroke-linejoin: round; -} - -.dashboard3-view-tab.active::after { - position: absolute; - right: -3px; - bottom: -1px; - left: -3px; - height: 2px; - content: ""; - background: #242729; -} - -.dashboard3-header-icon:hover, -.dashboard3-context-icon:hover, -.dashboard3-zoom-group button:hover { - color: #059bc1; - background: #f2f7f8; -} - -.dashboard3-context-bar { - overflow: visible; - background: #fff; -} - -.dashboard3-context-bar .ha-plan-label { - flex: 0 0 auto; - color: #17191a; -} - -.ha-plan-symbol { - width: 17px; - height: 17px; - fill: #03a4cf; - stroke: #03a4cf; - stroke-width: 1.5; - stroke-linecap: round; - stroke-linejoin: round; -} - -.dashboard3-context-bar .ha-segmented { - background: #f0f0f0; -} - -.dashboard3-context-bar .ha-floor-switcher { - flex: 0 0 auto; -} - -.dashboard3-context-bar .ha-editor-switcher { - flex: 0 1 auto; - min-width: 0; -} - -.dashboard3-context-bar .ha-editor-switcher .ha-segment { - background: transparent; -} - -.dashboard3-context-actions { - flex: 0 0 auto; - margin-left: auto; - gap: 7px; -} - -.dashboard3-zoom-group { - height: 31px; - padding: 0 3px; - overflow: hidden; - border-radius: 9px; - background: #f1f1f1; -} - -.dashboard3-zoom-group button, -.dashboard3-context-icon { - display: grid; - width: 30px; - height: 30px; - padding: 0; - place-items: center; - border: 0; - color: #242729; - background: transparent; - font: inherit; - font-size: 17px; - font-weight: 600; - cursor: pointer; -} - -.dashboard3-context-icon { - border: 1px solid #e1e4e5; - border-radius: 8px; - background: #fff; -} - -.dashboard3-help-icon { - border-radius: 9px; - font-size: 13px; -} - -.dashboard3-help-icon::before { - display: grid; - width: 15px; - height: 15px; - place-items: center; - border: 1.5px solid #242729; - border-radius: 50%; - content: ""; -} - -.dashboard3-help-icon { - position: relative; - color: transparent; -} - -.dashboard3-help-icon::after { - position: absolute; - color: #242729; - content: "?"; - font-size: 10px; - font-weight: 800; -} - -.dashboard3-space-tab { - border: 0; - background: transparent; - font-family: inherit; - cursor: pointer; -} -.dashboard3-space-tab.active { background: var(--ha-blue); } - -.dashboard3-summary-switcher { - flex: 0 0 auto; - overflow: hidden; -} - -.dashboard3-summary-switcher .dashboard3-global-toggle { - width: auto; - height: 25px; - padding: 0 5px 0 10px; - border: 0; - border-radius: 7px 0 0 7px; - color: #555; - background: transparent; - font: inherit; - font-size: 11px; - font-weight: 700; - cursor: pointer; -} -.dashboard3-settings-toggle { - display: grid; - width: 25px; - height: 25px; - padding: 0 6px 0 2px; - place-items: center; - border: 0; - border-radius: 0 7px 7px 0; - color: #777; - background: transparent; - cursor: pointer; -} -.dashboard3-settings-toggle img { display: block; width: 13px; height: 13px; opacity: .55; } -.dashboard3-summary-switcher .dashboard3-global-toggle[aria-pressed="true"], -.dashboard3-summary-switcher .dashboard3-global-toggle[aria-pressed="true"] + .dashboard3-settings-toggle { - color: #fff; - background: var(--ha-blue); -} -.dashboard3-summary-switcher .dashboard3-global-toggle[aria-pressed="true"] + .dashboard3-settings-toggle img { filter: brightness(0) invert(1); opacity: .72; } -.dashboard3-summary-switcher .dashboard3-global-toggle:hover, -.dashboard3-settings-toggle:hover { background: #e5e5e5; } -.dashboard3-summary-switcher .dashboard3-global-toggle[aria-pressed="true"]:hover, -.dashboard3-summary-switcher .dashboard3-global-toggle[aria-pressed="true"] + .dashboard3-settings-toggle:hover { background: var(--ha-blue-dark); } - -@media (max-width: 1500px) { - .dashboard3-header-actions { gap: 14px; } - .dashboard3-context-actions { gap: 5px; } - .dashboard3-context-bar .ha-editor-switcher .ha-segment { padding-inline: 7px; } -} - -@media (max-width: 1280px) { - .dashboard3-context-bar .ha-editor-switcher .ha-segment span { display: none; } - .dashboard3-context-bar .ha-editor-switcher .ha-segment { padding-inline: 6px; font-size: 9px; } - .dashboard3-context-icon, - .dashboard3-zoom-group button { width: 27px; } -} - -.dashboard-body { - position: relative; -} - -.dashboard3-panel { - position: absolute; - z-index: 6; - top: 12px; - right: 12px; - display: block; - height: fit-content; - min-height: 0; - max-height: calc(100vh - var(--global-bar-height) - 68px); - border-left: 0; - background: transparent; - box-shadow: none; -} - -.dashboard3-panel__content { - display: flex; - width: 100%; - min-height: 0; - max-height: calc(100vh - var(--global-bar-height) - 68px); - flex: 0 1 auto; - flex-direction: column; - padding: 10px; - overflow: hidden; - border: 1px solid #d9dcde; - border-radius: 12px; - background: #f5f7f8; - box-shadow: -5px 0 20px rgba(46, 58, 64, .1); -} -.dashboard3-space-context { - display: flex; - align-items: center; - justify-content: space-between; - min-height: 29px; - margin-bottom: 8px; - padding: 0 9px; - border: 1px solid #dce7ea; - border-radius: 8px; - color: #7a8285; - background: #f8fbfc; - font-size: 9px; -} -.dashboard3-space-context strong { color: #3f494c; font-size: 9px; } -.dashboard3-blocks { display: flex; min-height: 0; flex: 0 1 auto; flex-direction: column; gap: 9px; overflow-y: auto; padding-right: 2px; scrollbar-color: #b8bec1 transparent; scrollbar-width: thin; } -.dashboard3-block { - flex: 0 0 auto; - overflow: hidden; - border: 1px solid #dde3e5; - border-radius: 11px; - background: #fff; - box-shadow: 0 1px 2px rgba(34, 45, 50, .04); -} -.dashboard3-block__header { display: flex; align-items: center; min-height: 43px; padding: 0 14px; gap: 8px; border-bottom: 1px solid #eef1f2; } -.dashboard3-block__mark { display: grid; width: 25px; height: 25px; flex: 0 0 25px; place-items: center; border-radius: 7px; color: #157d98; background: #e7f5f8; font-size: 11px; font-weight: 800; } -.dashboard3-block__header h3 { margin: 0; overflow: hidden; min-width: 0; flex: 1 1 auto; color: #343b3d; font-size: 13px; text-overflow: ellipsis; white-space: nowrap; } -.dashboard3-block__scope { max-width: 120px; overflow: hidden; padding: 4px 7px; border-radius: 10px; color: #677174; background: #f0f3f4; font-size: 8px; font-weight: 700; text-overflow: ellipsis; white-space: nowrap; } -.dashboard3-block__values { margin: 0; padding: 4px 11px 7px; } -.dashboard3-value { - display: grid; - min-height: 36px; - grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); - align-items: center; - gap: 12px; - border-bottom: 1px solid #edf0f1; -} -.dashboard3-value:last-child { border-bottom: 0; } -.dashboard3-value dt, -.dashboard3-value dd { margin: 0; min-width: 0; } -.dashboard3-value dt { overflow: hidden; color: #697174; font-size: 9px; text-overflow: ellipsis; white-space: nowrap; } -.dashboard3-value dd { overflow: hidden; color: #30383a; font-size: 10px; font-weight: 800; text-align: right; text-overflow: ellipsis; white-space: nowrap; } -.dashboard3-empty { - display: grid; - min-height: 120px; - padding: 18px; - place-items: center; - border: 1px dashed #cfd9dc; - border-radius: 11px; - color: #899194; - background: #fafcfc; - font-size: 10px; - line-height: 1.45; - text-align: center; -} - -.dashboard3-settings-dialog { - position: fixed; - z-index: 1000; - inset: 0; - display: grid; - padding: 16px; - place-items: center; - background: rgba(42, 49, 52, .42); - backdrop-filter: blur(2px); -} -.dashboard3-settings-dialog__window { - display: flex; - width: min(780px, calc(100vw - 32px)); - max-height: calc(100vh - 32px); - flex-direction: column; - overflow: hidden; - border: 1px solid #cfdadd; - border-radius: 15px; - background: #f8fbfc; - box-shadow: 0 18px 60px rgba(25, 34, 38, .26); -} -.dashboard3-settings-dialog__header { - display: flex; - min-height: 70px; - align-items: center; - justify-content: space-between; - padding: 15px 17px 12px; - gap: 12px; - border-bottom: 1px solid #dfe7e9; - background: #fff; -} -.dashboard3-settings-dialog__header h2 { margin: 0; color: #303638; font-size: 22px; line-height: 1.2; } -.dashboard3-settings-dialog__close { width: 31px; height: 31px; flex: 0 0 31px; } -.dashboard3-settings-dialog__body { min-height: 0; padding: 14px 17px 18px; overflow-y: auto; scrollbar-width: thin; scrollbar-color: #b8bec1 transparent; } -.dashboard3-general-settings, -.dashboard3-block-editor { border: 1px solid #dce5e7; border-radius: 11px; background: #fff; } -.dashboard3-general-settings { padding: 13px; } -.dashboard3-general-settings > header h3, -.dashboard3-block-editor__heading h3 { margin: 0; color: #343b3d; font-size: 13px; } -.dashboard3-general-settings__grid { display: grid; margin-top: 11px; grid-template-columns: 1fr 1fr; gap: 12px; } -.dashboard3-field > span, -.dashboard3-editor-field > span { display: block; margin-bottom: 5px; color: #667073; font-size: 9px; font-weight: 700; } -.dashboard3-field input, -.dashboard3-editor-field input, -.dashboard3-editor-field select, -.dashboard3-value-label { - width: 100%; - height: 36px; - padding: 0 9px; - border: 1px solid #d5dddf; - border-radius: 7px; - color: #303638; - background: #fff; - font: inherit; - font-size: 10px; - outline: none; -} -.dashboard3-field input:focus, -.dashboard3-editor-field input:focus, -.dashboard3-editor-field select:focus, -.dashboard3-value-label:focus { border-color: var(--ha-blue); box-shadow: 0 0 0 2px rgba(3, 164, 207, .12); } -.dashboard3-field input.is-invalid, -.dashboard3-editor-field input.is-invalid, -.dashboard3-editor-field select.is-invalid, -.dashboard3-value-label.is-invalid, -.dashboard3-entity-trigger.is-invalid { border-color: #dc675a; box-shadow: 0 0 0 2px rgba(220, 103, 90, .10); } -.dashboard3-field__error { display: block; min-height: 12px; margin-top: 3px; color: #c84f43; font-size: 8px; } - -.dashboard3-switch-field { - display: flex; - min-height: 54px; - align-items: center; - justify-content: space-between; - padding: 4px 0; - gap: 12px; - border: 0; - border-radius: 0; - cursor: pointer; -} -.dashboard3-switch-field > span:first-child { display: flex; min-width: 0; flex-direction: column; gap: 3px; } -.dashboard3-switch-field strong { color: #434b4e; font-size: 9px; } -.dashboard3-switch-field small { color: #7d8588; font-size: 8px; line-height: 1.35; } -.dashboard3-switch-field input { position: absolute; width: 1px; height: 1px; overflow: hidden; opacity: 0; } -.dashboard3-switch { position: relative; width: 36px; height: 21px; flex: 0 0 36px; border: 1px solid #b7bec1; border-radius: 12px; background: #d8dddf; transition: .16s ease; } -.dashboard3-switch::after { position: absolute; top: 2px; left: 2px; width: 15px; height: 15px; border-radius: 50%; background: #fff; box-shadow: 0 1px 2px rgba(0, 0, 0, .2); content: ""; transition: transform .16s ease; } -.dashboard3-switch-field input:checked + .dashboard3-switch { border-color: var(--ha-blue); background: var(--ha-blue); } -.dashboard3-switch-field input:checked + .dashboard3-switch::after { transform: translateX(15px); } -.dashboard3-switch-field input:focus-visible + .dashboard3-switch { outline: 2px solid rgba(3, 164, 207, .28); outline-offset: 2px; } - -.dashboard3-block-editor { margin-top: 12px; padding: 13px; } -.dashboard3-block-editor__heading { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 10px; gap: 12px; } -.dashboard3-block-editor__heading p { margin: 3px 0 0; color: #7b8386; font-size: 8px; } -.dashboard3-block-count { padding: 4px 7px; border-radius: 10px; color: #597176; background: #edf6f8; font-size: 8px; font-weight: 700; } -.dashboard3-block-editor__list { display: flex; flex-direction: column; gap: 10px; } -.dashboard3-editor-block { overflow: visible; border: 1px solid #dce3e5; border-radius: 10px; background: #fbfdfd; } -.dashboard3-editor-block__header { display: grid; min-height: 50px; padding: 7px 8px; grid-template-columns: 61px minmax(120px, 1fr) 160px 31px 31px; align-items: center; gap: 7px; border-bottom: 1px solid #e5eaec; background: #fff; border-radius: 10px 10px 0 0; } -.dashboard3-order-buttons { display: grid; grid-template-columns: repeat(2, 29px); align-items: center; gap: 3px; } -.dashboard3-order-button { display: grid; width: 29px; height: 29px; padding: 0; place-items: center; border: 1px solid #d8dcde; border-radius: 6px; color: #586164; background: #fff; font: inherit; font-size: 13px; line-height: 1; cursor: pointer; } -.dashboard3-order-button:hover:not(:disabled) { border-color: #83c9da; color: #087f9d; background: #f2fafc; } -.dashboard3-order-button:disabled { opacity: .32; cursor: default; } -.dashboard3-block-title { width: 100%; height: 34px; min-width: 0; padding: 0 8px; border: 1px solid #d5dddf; border-radius: 7px; color: #343b3d; background: #fff; font: inherit; font-size: 11px; font-weight: 800; outline: none; } -.dashboard3-block-title:hover { border-color: #dce4e6; background: #fafcfc; } -.dashboard3-block-title:focus { border-color: var(--ha-blue); background: #fff; box-shadow: 0 0 0 2px rgba(3, 164, 207, .12); } -.dashboard3-block-title.is-invalid { border-color: #dc675a; background: #fff7f6; } -.dashboard3-scope-select { width: 100%; height: 34px; min-width: 0; padding: 0 26px 0 8px; border: 1px solid #d5dddf; border-radius: 7px; color: #4e575a; background: #fff; font: inherit; font-size: 9px; outline: none; cursor: pointer; } -.dashboard3-scope-select:focus { border-color: var(--ha-blue); box-shadow: 0 0 0 2px rgba(3, 164, 207, .12); } -.dashboard3-scope-select.is-invalid { border-color: #dc675a; box-shadow: 0 0 0 2px rgba(220, 103, 90, .10); } -.dashboard3-visibility { display: grid; width: 31px; height: 31px; padding: 0; place-items: center; border: 1px solid #acd9e4; border-radius: 7px; background: #f1fafc; cursor: pointer; } -.dashboard3-visibility.is-off { border-color: #d8dcde; background: #fff; } -.dashboard3-visibility img { width: 16px; height: 16px; opacity: .72; } -.dashboard3-editor-block__body { padding: 10px; } -.dashboard3-editor-values { display: flex; flex-direction: column; gap: 6px; } -.dashboard3-editor-value { display: grid; min-height: 43px; padding: 4px; grid-template-columns: 61px minmax(135px, .9fr) minmax(190px, 1.25fr) 29px; align-items: start; gap: 6px; border: 1px solid #e2e7e9; border-radius: 8px; background: #fff; } -.dashboard3-value-label { height: 34px; min-width: 0; } -.dashboard3-entity-trigger { display: flex; width: 100%; min-width: 0; min-height: 34px; padding: 5px 8px; align-items: center; justify-content: space-between; gap: 8px; border: 1px solid #d5dddf; border-radius: 7px; color: #3b4345; background: #fff; font: inherit; text-align: left; cursor: pointer; } -.dashboard3-entity-trigger__text { display: flex; min-width: 0; flex-direction: column; gap: 1px; } -.dashboard3-entity-trigger strong, -.dashboard3-entity-trigger small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.dashboard3-entity-trigger strong { font-size: 9px; } -.dashboard3-entity-trigger small { color: #828a8d; font-size: 8px; font-weight: 400; } -.dashboard3-entity-trigger > b { color: #7c8588; font-size: 10px; } -.dashboard3-editor-value__remove { width: 29px; height: 29px; margin-top: 2px; padding: 0; border: 0; border-radius: 7px; color: #ca5548; background: #fff3f1; font-size: 15px; cursor: pointer; } -.dashboard3-editor-value__error { display: none; grid-column: 2 / 4; margin: -2px 2px 2px; color: #c84f43; font-size: 8px; } -.dashboard3-editor-value.has-error .dashboard3-editor-value__error { display: block; } -.dashboard3-editor-block__footer { display: flex; justify-content: flex-end; margin-top: 10px; padding-top: 9px; border-top: 1px solid #e8edef; } -.dashboard3-delete-block { min-height: 30px; padding: 0 11px; border: 1px solid #e2968c; border-radius: 7px; color: #c84f43; background: #fff; font: inherit; font-size: 9px; font-weight: 700; cursor: pointer; } -.dashboard3-delete-block:hover { border-color: #d96759; background: #fff5f3; } -.dashboard3-add-value, -.dashboard3-add-block { display: flex; align-items: center; justify-content: center; gap: 6px; border: 1px dashed #78c4d6; color: #087f9d; background: #f2fafc; font: inherit; font-size: 9px; font-weight: 700; cursor: pointer; } -.dashboard3-add-value { width: 100%; min-height: 32px; margin-top: 7px; border-radius: 8px; } -.dashboard3-add-block { display: inline-flex; width: auto; min-height: 35px; margin-top: 10px; padding: 0 12px; border-radius: 9px; } -.dashboard3-add-value img, -.dashboard3-add-block img { width: 14px; height: 14px; opacity: .7; } -.dashboard3-add-value:disabled, -.dashboard3-add-block:disabled { border-color: #d9dfe1; color: #9ba1a4; background: #f7f8f8; cursor: not-allowed; } -.dashboard3-add-value:hover:not(:disabled), -.dashboard3-add-block:hover:not(:disabled) { color: #fff; background: var(--ha-blue); } -.dashboard3-add-value:hover:not(:disabled) img, -.dashboard3-add-block:hover:not(:disabled) img { filter: brightness(0) invert(1); opacity: 1; } - -.dashboard3-settings-dialog__footer { display: flex; min-height: 61px; align-items: center; justify-content: flex-end; padding: 11px 17px; gap: 8px; border-top: 1px solid #dfe7e9; background: #fff; } -.dashboard3-settings-status { margin-right: auto; color: #c25246; font-size: 9px; } -.dashboard3-settings-status.is-ready { color: #5f777c; } -.dashboard3-button { height: 35px; padding: 0 14px; border-radius: 8px; font: inherit; font-size: 10px; font-weight: 700; cursor: pointer; } -.dashboard3-button--secondary { border: 1px solid #d5dcde; color: #555e61; background: #fff; } -.dashboard3-button--primary { border: 1px solid var(--ha-blue); color: #fff; background: var(--ha-blue); } -.dashboard3-button--primary:disabled { border-color: #b9d4da; color: #eef7f8; background: #91c7d4; cursor: not-allowed; } - -.dashboard3-entity-picker { - position: fixed; - z-index: 1100; - display: flex; - width: min(420px, calc(100vw - 24px)); - max-height: min(430px, calc(100vh - 24px)); - flex-direction: column; - overflow: hidden; - border: 1px solid #cfdadd; - border-radius: 11px; - background: #fff; - box-shadow: 0 14px 38px rgba(25, 34, 38, .25); -} -.dashboard3-entity-picker__search { display: flex; min-height: 47px; align-items: center; padding: 7px 9px; gap: 7px; border-bottom: 1px solid #e3e8ea; } -.dashboard3-entity-picker__search > span { color: #657074; font-size: 15px; } -.dashboard3-entity-picker__search input { width: 100%; height: 33px; padding: 0 8px; border: 1px solid #d5dddf; border-radius: 7px; color: #303638; font: inherit; font-size: 10px; outline: none; } -.dashboard3-entity-picker__search input:focus { border-color: var(--ha-blue); box-shadow: 0 0 0 2px rgba(3, 164, 207, .12); } -.dashboard3-entity-picker__list { min-height: 80px; overflow-y: auto; padding: 5px; scrollbar-width: thin; } -.dashboard3-entity-option { display: flex; width: 100%; min-height: 43px; padding: 6px 8px; align-items: center; justify-content: space-between; gap: 10px; border: 0; border-radius: 7px; color: #343b3d; background: #fff; font: inherit; text-align: left; cursor: pointer; } -.dashboard3-entity-option:hover, -.dashboard3-entity-option:focus-visible { background: #edf8fa; outline: none; } -.dashboard3-entity-option__name { display: flex; min-width: 0; flex-direction: column; gap: 2px; } -.dashboard3-entity-option__name strong, -.dashboard3-entity-option__name small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.dashboard3-entity-option__name strong { font-size: 10px; } -.dashboard3-entity-option__name small { color: #7d8588; font-size: 8px; } -.dashboard3-entity-option__state { flex: 0 0 auto; color: #3e616a; font-size: 9px; font-weight: 700; } -.dashboard3-entity-option.is-selected { background: #e5f5f8; } -.dashboard3-entity-picker__empty { padding: 18px; color: #858d90; font-size: 9px; text-align: center; } -.dashboard3-entity-picker__hint { margin: 0; padding: 8px 10px; border-top: 1px solid #e3e8ea; color: #717a7d; background: #f7fafb; font-size: 8px; line-height: 1.4; } - -button:focus-visible, -.dashboard3-space-tab:focus-visible { outline: 2px solid rgba(3, 164, 207, .35); outline-offset: 2px; } - -@media (max-width: 1450px) { - .dashboard3-panel { flex-basis: 350px; width: 350px; } -} - -@media (max-width: 1220px) { - .dashboard3-panel { flex-basis: 320px; width: 320px; } - .dashboard3-block__scope { display: none; } -} - -@media (max-width: 760px) { - .dashboard3-settings-dialog { padding: 8px; } - .dashboard3-settings-dialog__window { width: calc(100vw - 16px); max-height: calc(100vh - 16px); } - .dashboard3-general-settings__grid, - .dashboard3-block-options { grid-template-columns: 1fr; } - .dashboard3-editor-block__header { grid-template-columns: 61px minmax(0, 1fr) 31px 31px; } - .dashboard3-scope-select { grid-column: 1 / -1; grid-row: 2; } - .dashboard3-editor-value { grid-template-columns: 61px minmax(0, 1fr) 29px; } - .dashboard3-entity-trigger { grid-column: 2 / 3; } - .dashboard3-editor-value__remove { grid-column: 3; grid-row: 1; } - .dashboard3-editor-value__error { grid-column: 2 / 4; } -} - -/* Dashboard 5: sorting controls, explicit scope, data states and local view settings. */ -.dashboard4-size-dialog[hidden] { display: none !important; } -.dashboard3-editor-block.is-dragging, -.dashboard3-editor-value.is-dragging { opacity: .5; } -.dashboard3-editor-block.is-drop-target, -.dashboard3-editor-value.is-drop-target { border-color: var(--ha-blue); box-shadow: 0 0 0 2px rgba(3, 164, 207, .15); } -.dashboard4-drag-handle { display: grid; width: 26px; height: 30px; place-items: center; border: 0; color: #7f8b8f; background: transparent; cursor: grab; font-size: 17px; user-select: none; } -.dashboard4-drag-handle:active { cursor: grabbing; } -.dashboard3-editor-block__header { grid-template-columns: 26px 61px minmax(120px, 1fr) 31px 31px; } -.dashboard4-block-drag { grid-column: 1; grid-row: 1; } -.dashboard3-editor-block__header > .dashboard3-order-buttons { grid-column: 2; grid-row: 1; } -.dashboard3-editor-block__header > .dashboard3-block-title { grid-column: 3; grid-row: 1; } -.dashboard3-editor-block__header > .dashboard3-visibility { grid-column: 4; grid-row: 1; } -.dashboard4-scope-controls { display: grid; min-width: 0; grid-column: 3 / -1; grid-row: 2; grid-template-columns: minmax(150px, .9fr) minmax(160px, 1fr); gap: 5px; } -.dashboard4-scope-controls > select[hidden] { display: none; } -.dashboard4-scope-controls:has(.dashboard4-space-select[hidden]) { grid-template-columns: 1fr; } -.dashboard4-scope-warning { margin: 0 0 9px; padding: 7px 9px; border: 1px solid #e4b569; border-radius: 7px; color: #805514; background: #fff9eb; font-size: 9px; } -.dashboard3-editor-value { grid-template-columns: 27px 61px minmax(125px, .9fr) minmax(180px, 1.2fr) 29px; } -.dashboard3-editor-value__error { grid-column: 3 / 5; } -.dashboard4-value--loading dd { color: #738084; } -.dashboard4-value--stale dd { color: #9a650d; } -.dashboard4-value--unavailable dd { color: #b2443b; } -.dashboard3-value dd small { display: block; margin-top: 1px; color: inherit; font-size: 7px; font-weight: 600; } -.dashboard3-entity-option__state { display: flex; flex-direction: column; align-items: flex-end; } -.dashboard3-entity-option__state small { color: #a55a21; font-size: 7px; } -.dashboard4-entity--unavailable .dashboard3-entity-option__state { color: #b2443b; } -.dashboard4-entity--stale .dashboard3-entity-option__state { color: #9a650d; } - -.dashboard4-kiosk-toggle { min-width: 44px; height: 27px; padding: 0 8px; border: 1px solid #d9dfe1; border-radius: 7px; color: #566064; background: #fff; font: inherit; font-size: 9px; font-weight: 700; cursor: pointer; } -.dashboard4-kiosk-toggle[aria-pressed="true"] { color: #fff; border-color: var(--ha-blue); background: var(--ha-blue); } -.dashboard4-size-dialog { position: fixed; z-index: 1200; inset: 0; display: grid; padding: 16px; place-items: center; background: rgba(42, 49, 52, .42); backdrop-filter: blur(2px); } -.dashboard4-size-dialog__window { width: min(430px, calc(100vw - 32px)); overflow: hidden; border: 1px solid #d6dfe1; border-radius: 14px; background: #fff; box-shadow: 0 18px 55px rgba(25, 34, 38, .25); } -.dashboard4-size-dialog__window > header { display: flex; min-height: 58px; align-items: center; justify-content: space-between; padding: 12px 16px; border-bottom: 1px solid #e3e8ea; } -.dashboard4-size-dialog h2 { margin: 0; font-size: 17px; } -.dashboard4-size-close { width: 31px; height: 31px; border: 0; border-radius: 7px; background: transparent; font-size: 21px; cursor: pointer; } -.dashboard4-size-dialog label { display: grid; padding: 14px 16px 3px; grid-template-columns: 1fr auto; gap: 9px 12px; color: #4e585b; font-size: 12px; } -.dashboard4-size-dialog output { font-weight: 800; } -.dashboard4-size-dialog input[type="range"] { grid-column: 1 / -1; width: 100%; accent-color: var(--ha-blue); } -.dashboard4-size-dialog footer { display: flex; justify-content: flex-end; padding: 14px 16px; gap: 8px; } -.dashboard4-size-reset { min-height: 35px; padding: 0 12px; border: 1px solid #d5dcde; border-radius: 8px; background: #fff; cursor: pointer; } -.floor-plan .device { transform: scale(var(--dashboard4-device-scale, 1)); } -.floor-plan .room-name { font-size: calc(11px * var(--dashboard4-room-text-scale, 1)); } - -html[data-theme="dark"] body { color: #e8edef; background: #14191b; } -html[data-theme="dark"] .sidebar, -html[data-theme="dark"] .global-bar, -html[data-theme="dark"] .dashboard3-context-bar, -html[data-theme="dark"] .dashboard3-panel__content, -html[data-theme="dark"] .dashboard3-block, -html[data-theme="dark"] .dashboard3-settings-dialog__window, -html[data-theme="dark"] .dashboard3-settings-dialog__header, -html[data-theme="dark"] .dashboard3-settings-dialog__footer, -html[data-theme="dark"] .dashboard3-general-settings, -html[data-theme="dark"] .dashboard3-block-editor, -html[data-theme="dark"] .dashboard3-editor-block, -html[data-theme="dark"] .dashboard3-editor-block__header, -html[data-theme="dark"] .dashboard3-editor-value, -html[data-theme="dark"] .dashboard4-size-dialog__window { color: #e8edef; border-color: #3a4549; background: #22292c; } -html[data-theme="dark"] input, -html[data-theme="dark"] select, -html[data-theme="dark"] .dashboard3-entity-trigger, -html[data-theme="dark"] .dashboard3-order-button, -html[data-theme="dark"] .dashboard4-kiosk-toggle, -html[data-theme="dark"] .dashboard4-size-reset { color: #e8edef; border-color: #48555a; background: #1b2124; } -html[data-theme="dark"] .dashboard3-block__header h3, -html[data-theme="dark"] .dashboard3-value dd, -html[data-theme="dark"] .dashboard3-settings-dialog h2, -html[data-theme="dark"] .dashboard3-settings-dialog h3 { color: #f2f5f6; } -html[data-theme="dark"] .dashboard3-value { border-color: #333e42; } -html[data-theme="dark"] .dashboard3-value dt, -html[data-theme="dark"] .dashboard3-block-editor__heading p { color: #aeb8bb; } - -body.dashboard5-kiosk .app-shell { grid-template-columns: 1fr; } -body.dashboard5-kiosk .sidebar, -body.dashboard5-kiosk .global-bar, -body.dashboard5-kiosk .dashboard3-context-bar > :not(.dashboard3-context-actions) { display: none; } -body.dashboard5-kiosk .workspace { grid-column: 1; } -body.dashboard5-kiosk .dashboard3-context-bar { position: fixed; z-index: 30; top: 10px; right: 10px; width: auto; min-height: 42px; padding: 6px; border: 1px solid #d8dfe1; border-radius: 10px; background: rgba(255,255,255,.94); box-shadow: 0 5px 18px rgba(25,34,38,.16); } -body.dashboard5-kiosk .dashboard3-context-actions { display: flex; } -body.dashboard5-kiosk .dashboard-body { height: 100vh; } -html[data-theme="dark"] body.dashboard5-kiosk .dashboard3-context-bar { border-color: #48555a; background: rgba(34, 41, 44, .96); } - -@media (max-width: 900px) { - .dashboard3-editor-block__header { grid-template-columns: 26px 61px minmax(0, 1fr) 31px 31px; } - .dashboard4-scope-controls { grid-column: 3 / -1; grid-row: 2; grid-template-columns: 1fr; } - .dashboard3-editor-value { grid-template-columns: 27px 61px minmax(0, 1fr) 29px; } - .dashboard3-entity-trigger { grid-column: 3 / 4; } - .dashboard3-editor-value__remove { grid-column: 4; grid-row: 1; } - .dashboard3-editor-value__error { grid-column: 3 / 5; } -} - -/* Dashboard 5: one floating adaptive information panel. */ -.dashboard5-summary-control { - display: flex; - height: 29px; - flex: 0 0 auto; - overflow: hidden; - border: 1px solid #d8dfe1; - border-radius: 8px; - background: #fff; - box-shadow: 0 1px 2px rgba(30, 44, 49, .05); -} -.dashboard5-summary-control .dashboard3-settings-toggle, -.dashboard5-summary-control .dashboard3-global-toggle { - display: grid; - width: 31px; - height: 27px; - padding: 0; - place-items: center; - border: 0; - border-radius: 0; - color: #586367; - background: transparent; - cursor: pointer; -} -.dashboard5-summary-control .dashboard3-settings-toggle { - border-right: 1px solid #d8dfe1; -} -.dashboard5-summary-control .dashboard3-settings-toggle img { - width: 15px; - height: 15px; - opacity: .7; -} -.dashboard5-summary-control .dashboard3-global-toggle img { - display: block; - width: 17px; - height: 17px; - opacity: .74; -} -.dashboard5-summary-control .dashboard3-settings-toggle:hover, -.dashboard5-summary-control .dashboard3-global-toggle:hover { background: #edf2f3; } -.dashboard5-summary-control .dashboard3-global-toggle[aria-pressed="true"] { color: #fff; background: var(--ha-blue); } -.dashboard5-summary-control .dashboard3-global-toggle[aria-pressed="true"] img { filter: brightness(0) invert(1); opacity: .92; } -.dashboard5-summary-control .dashboard3-global-toggle[aria-pressed="true"]:hover { background: var(--ha-blue-dark); } -.dashboard5-summary-control button:focus-visible { position: relative; z-index: 1; outline: 2px solid rgba(3, 164, 207, .42); outline-offset: -2px; } - -.dashboard3-panel { - top: 12px; - right: 12px; - bottom: auto; - left: auto; - width: max-content; - min-width: 280px; - max-width: min(420px, calc(100% - 24px)); - height: fit-content; - max-height: calc(100% - 24px); - flex-basis: auto; - opacity: 0; - visibility: hidden; - pointer-events: none; - transform: translateX(18px); - transition: opacity .19s ease, transform .19s ease, visibility 0s linear .19s; -} -.dashboard3-panel.is-visible { - opacity: 1; - visibility: visible; - pointer-events: auto; - transform: translateX(0); - transition-delay: 0s; -} -.dashboard3-panel__content { - width: max-content; - min-width: 280px; - max-width: min(420px, calc(100vw - 24px)); - max-height: calc(100% - 24px); - padding: 0; - overflow: hidden; - box-shadow: 0 8px 26px rgba(40, 54, 60, .16); -} -.dashboard5-panel-header { - display: flex; - min-height: 48px; - flex: 0 0 auto; - align-items: center; - padding: 0 14px; - border-bottom: 1px solid #e1e7e9; - background: #fff; -} -.dashboard5-panel-header h2 { - max-width: 100%; - margin: 0; - overflow: hidden; - color: #30383b; - font-size: 14px; - line-height: 1.25; - text-overflow: ellipsis; - white-space: nowrap; -} -.dashboard3-blocks { - width: 100%; - padding: 10px; -} -.dashboard3-value { - grid-template-columns: minmax(0, 1fr) max-content; -} -.dashboard3-panel.dashboard5-panel-bottom { - top: auto; - right: auto; - bottom: 12px; - left: 50%; - width: max-content; - min-width: min(360px, calc(100% - 24px)); - max-width: calc(100% - 24px); - max-height: min(60%, calc(100% - 24px)); - transform: translate(-50%, 18px); -} -.dashboard3-panel.dashboard5-panel-bottom.is-visible { transform: translate(-50%, 0); } -.dashboard3-panel.dashboard5-panel-bottom .dashboard3-panel__content { - width: max-content; - min-width: min(360px, calc(100vw - 24px)); - max-width: calc(100vw - 24px); - max-height: min(60vh, calc(100vh - 24px)); -} - -/* Blocks are always expanded, including in the settings dialog. */ -.dashboard3-editor-block__header { grid-template-columns: 26px 61px minmax(120px, 1fr) 31px; } -.dashboard3-editor-block__header > .dashboard3-visibility { grid-column: 4; grid-row: 1; } -.dashboard4-scope-controls { grid-column: 3 / -1; } - -body.dashboard5-kiosk .dashboard3-panel:not(.dashboard5-panel-bottom) { - top: 72px; - max-height: calc(100vh - 84px); -} -body.dashboard5-kiosk .dashboard3-panel:not(.dashboard5-panel-bottom) .dashboard3-panel__content { max-height: calc(100vh - 84px); } -body.dashboard5-kiosk .dashboard3-panel.dashboard5-panel-bottom { top: auto; bottom: 12px; } - -html[data-theme="dark"] .dashboard5-summary-control { border-color: #48555a; background: #1b2124; } -html[data-theme="dark"] .dashboard5-summary-control .dashboard3-settings-toggle { border-color: #48555a; } -html[data-theme="dark"] .dashboard5-summary-control .dashboard3-settings-toggle:hover, -html[data-theme="dark"] .dashboard5-summary-control .dashboard3-global-toggle:hover { background: #303b3f; } -html[data-theme="dark"] .dashboard5-panel-header { border-color: #3a4549; background: #22292c; } -html[data-theme="dark"] .dashboard5-panel-header h2 { color: #f2f5f6; } - -@media (max-width: 900px) { - .dashboard3-editor-block__header { grid-template-columns: 26px 61px minmax(0, 1fr) 31px; } - .dashboard4-scope-controls { grid-column: 3 / -1; } -} - -@media (prefers-reduced-motion: reduce) { - .dashboard3-panel { transition: none; } -} - diff --git a/docs/design/505-summary-panel/reference/dashboard-5.js b/docs/design/505-summary-panel/reference/dashboard-5.js deleted file mode 100644 index 8d89306d..00000000 --- a/docs/design/505-summary-panel/reference/dashboard-5.js +++ /dev/null @@ -1,756 +0,0 @@ -(() => { - const panel = document.querySelector("#dashboard3-panel"); - const blocksContainer = document.querySelector("#dashboard3-blocks"); - const settingsToggle = document.querySelector(".dashboard3-settings-toggle"); - const globalToggle = document.querySelector(".dashboard3-global-toggle"); - const panelTitleHeading = document.querySelector("#dashboard5-panel-title"); - const dashboardBody = document.querySelector(".dashboard-body"); - const spaceTabs = [...document.querySelectorAll(".dashboard3-space-tab")]; - const settingsDialog = document.querySelector("#dashboard3-settings-dialog"); - const settingsForm = settingsDialog?.querySelector(".dashboard3-settings-dialog__window"); - const settingsClose = settingsDialog?.querySelector(".dashboard3-settings-dialog__close"); - const settingsCancel = settingsDialog?.querySelector(".dashboard3-settings-cancel"); - const settingsSave = settingsDialog?.querySelector(".dashboard3-settings-save"); - const settingsStatus = document.querySelector("#dashboard3-settings-status"); - const panelTitleInput = document.querySelector("#dashboard3-panel-title"); - const panelTitleError = panelTitleInput?.parentElement.querySelector(".dashboard3-field__error"); - const showMobileInput = document.querySelector("#dashboard3-show-mobile"); - const showPanelInput = document.querySelector("#dashboard5-show-panel"); - const editorList = document.querySelector("#dashboard3-block-editor-list"); - const blockCount = document.querySelector("#dashboard3-block-count"); - const addBlockButton = document.querySelector(".dashboard3-add-block"); - const entityPicker = document.querySelector("#dashboard3-entity-picker"); - const entitySearch = document.querySelector("#dashboard3-entity-search"); - const entityList = document.querySelector("#dashboard3-entity-list"); - const sizeToggle = document.querySelector(".dashboard4-size-toggle"); - const themeToggle = document.querySelector(".dashboard4-theme-toggle"); - const kioskToggle = document.querySelector(".dashboard4-kiosk-toggle"); - const sizeDialog = document.querySelector("#dashboard4-size-dialog"); - const sizeForm = sizeDialog?.querySelector(".dashboard4-size-dialog__window"); - const sizeClose = sizeDialog?.querySelector(".dashboard4-size-close"); - const sizeReset = sizeDialog?.querySelector(".dashboard4-size-reset"); - const iconSizeInput = document.querySelector("#dashboard4-icon-size"); - const textSizeInput = document.querySelector("#dashboard4-text-size"); - const iconSizeOutput = document.querySelector("#dashboard4-icon-size-output"); - const textSizeOutput = document.querySelector("#dashboard4-text-size-output"); - - if (!panel || !blocksContainer || !settingsToggle || !globalToggle || !panelTitleHeading || !dashboardBody || !spaceTabs.length || !settingsDialog || !settingsForm || !settingsClose || !settingsCancel || !settingsSave || !settingsStatus || !panelTitleInput || !panelTitleError || !showMobileInput || !showPanelInput || !editorList || !blockCount || !addBlockButton || !entityPicker || !entitySearch || !entityList || !sizeToggle || !themeToggle || !kioskToggle || !sizeDialog || !sizeForm || !sizeClose || !sizeReset || !iconSizeInput || !textSizeInput || !iconSizeOutput || !textSizeOutput) return; - - const limits = { blocks: 10, values: 20 }; - const storageKeys = { - config: "houseplan-dashboard5-config", - enabled: "houseplan-dashboard5-enabled", - theme: "houseplan-dashboard5-theme", - kiosk: "houseplan-dashboard5-kiosk", - sizes: "houseplan-dashboard5-local-sizes" - }; - const spaces = [ - { id: "ground-floor", name: "Ground Floor" }, - { id: "first-floor", name: "First Floor" }, - { id: "yard", name: "Yard" } - ]; - const entities = [ - { entityId: "sensor.kitchen_temperature", name: "Kitchen temperature", state: "20,6 °C" }, - { entityId: "sensor.living_room_humidity", name: "Living room humidity", state: "42 %" }, - { entityId: "light.wall_lamp_3", name: "Wall Lamp 3", state: "Выключено" }, - { entityId: "light.terrace_lights", name: "Terrace lights", state: "Включено" }, - { entityId: "climate.ground_floor", name: "Ground floor climate", state: "Нагрев · 21 °C" }, - { entityId: "sensor.ground_floor_average_temperature", name: "Средняя температура первого этажа", state: "20,9 °C", template: true }, - { entityId: "sensor.outdoor_temperature", name: "Outdoor temperature", state: "12,4 °C" }, - { entityId: "sensor.outdoor_humidity", name: "Outdoor humidity", state: "Загрузка…", status: "loading" }, - { entityId: "binary_sensor.front_door", name: "Front door", state: "Закрыто" }, - { entityId: "binary_sensor.smoke_kitchen", name: "Kitchen smoke detector", state: "Норма" }, - { entityId: "switch.sauna_heater", name: "Sauna heater", state: "Выключено" }, - { entityId: "sensor.sauna_temperature", name: "Sauna temperature", state: "24,1 °C" }, - { entityId: "sensor.boiler_temperature", name: "Boiler temperature", state: "54,0 °C" }, - { entityId: "binary_sensor.boiler_water_leak", name: "Boiler water leak", state: "Норма" }, - { entityId: "sensor.washing_machine_progress", name: "Washing machine progress", state: "66 %" }, - { entityId: "sensor.washing_machine_state", name: "Washing machine state", state: "Rinsing" }, - { entityId: "media_player.kitchen_display", name: "Kitchen display", state: "Ожидание" }, - { entityId: "camera.entrance", name: "Entrance camera", state: "Недоступно", status: "unavailable" }, - { entityId: "fan.kitchen_hood", name: "Kitchen hood", state: "Выключено" }, - { entityId: "cover.guest_bedroom_blinds", name: "Guest bedroom blinds", state: "Открыто" }, - { entityId: "sensor.house_energy_today", name: "House energy today", state: "8,7 kWh" }, - { entityId: "vacuum.ground_floor", name: "Ground floor vacuum", state: "На базе" }, - { entityId: "person.demo", name: "Demo", state: "Дома" }, - { entityId: "weather.home", name: "Home weather", state: "Облачно · 13 °C", status: "stale" } - ]; - - let blockSequence = 1; - let valueSequence = 3; - let activeSpaceId = "ground-floor"; - let globalEnabled = false; - let config = { - title: "Сводная информация", - showOnMobile: true, - blocks: [ - { - id: "block-1", - title: "Общее", - visible: true, - scope: { type: "all", spaceId: null }, - values: [ - { id: "value-1", label: "Количество устройств", source: { type: "system", key: "device_count", name: "Количество устройств" } }, - { id: "value-2", label: "Общая площадь комнат", source: { type: "system", key: "room_area", name: "Общая площадь комнат" } }, - { id: "value-3", label: "Текущие дата и время", source: { type: "system", key: "date_time", name: "Текущие дата и время" } } - ] - } - ] - }; - let draft = null; - let baseline = null; - let draftEnabled = false; - let baselineEnabled = false; - let dragContext = null; - let pickerContext = null; - let currentTheme = "light"; - let kioskEnabled = false; - let localSizes = { icons: 100, text: 100 }; - - try { - const storedConfig = window.localStorage.getItem(storageKeys.config); - if (storedConfig) config = JSON.parse(storedConfig); - globalEnabled = window.localStorage.getItem(storageKeys.enabled) === "true"; - currentTheme = window.localStorage.getItem(storageKeys.theme) || "light"; - kioskEnabled = window.localStorage.getItem(storageKeys.kiosk) === "true"; - const storedSizes = window.localStorage.getItem(storageKeys.sizes); - if (storedSizes) localSizes = { ...localSizes, ...JSON.parse(storedSizes) }; - } catch (error) { - console.warn("Dashboard 5: сохранённая конфигурация недоступна", error); - } - blockSequence = Math.max(1, config.blocks.length); - valueSequence = Math.max(3, ...config.blocks.flatMap((block) => block.values.map((value) => Number(String(value.id).match(/\d+/)?.[0]) || 0))); - - const persist = (key, value) => { - try { - window.localStorage.setItem(key, typeof value === "string" ? value : JSON.stringify(value)); - } catch (error) { - console.warn("Dashboard 5: не удалось сохранить настройку", error); - } - }; - - const applyTheme = () => { - document.documentElement.dataset.theme = currentTheme; - themeToggle.setAttribute("aria-label", currentTheme === "dark" ? "Включить светлую тему" : "Включить тёмную тему"); - }; - - const applyKiosk = () => { - document.body.classList.toggle("dashboard5-kiosk", kioskEnabled); - kioskToggle.setAttribute("aria-pressed", String(kioskEnabled)); - kioskToggle.textContent = kioskEnabled ? "Выйти" : "Киоск"; - }; - - const applyLocalSizes = () => { - document.documentElement.style.setProperty("--dashboard4-device-scale", String(localSizes.icons / 100)); - document.documentElement.style.setProperty("--dashboard4-room-text-scale", String(localSizes.text / 100)); - iconSizeInput.value = String(localSizes.icons); - textSizeInput.value = String(localSizes.text); - iconSizeOutput.textContent = `${localSizes.icons}%`; - textSizeOutput.textContent = `${localSizes.text}%`; - }; - - const clone = (value) => JSON.parse(JSON.stringify(value)); - const fingerprint = (value) => JSON.stringify(value); - const escapeHtml = (value) => String(value).replace(/[&<>"']/g, (character) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[character]); - const currentSpace = () => spaces.find((space) => space.id === activeSpaceId) || spaces[0]; - const findDraftBlock = (blockId) => draft?.blocks.find((block) => block.id === blockId); - const findDraftValue = (blockId, valueId) => findDraftBlock(blockId)?.values.find((value) => value.id === valueId); - - const isNarrowViewport = () => { - if (typeof window.hass?.narrow === "boolean") return window.hass.narrow; - return document.documentElement.dataset.haNarrow === "true"; - }; - - const formatDateTime = () => new Intl.DateTimeFormat("ru-RU", { - day: "2-digit", - month: "short", - hour: "2-digit", - minute: "2-digit" - }).format(new Date()).replace(".", ""); - - const entitySnapshot = (entityId) => { - const hassState = window.hass?.states?.[entityId]; - if (hassState) { - const rawState = hassState.state; - const status = rawState === "unavailable" || rawState === "unknown" ? "unavailable" : hassState.attributes?.restored ? "stale" : "ready"; - let text; - try { - text = typeof window.hass.formatEntityState === "function" ? window.hass.formatEntityState(hassState) : rawState; - } catch { - text = rawState; - } - return { text: text || "Нет данных", status }; - } - const demoEntity = entities.find((item) => item.entityId === entityId); - if (!demoEntity) return { text: "Недоступно", status: "unavailable" }; - return { text: demoEntity.state || "Нет данных", status: demoEntity.status || "ready" }; - }; - - const resolveValue = (value) => { - if (value.source.type === "system") { - if (value.source.key === "device_count") return { text: "50", status: "ready" }; - if (value.source.key === "room_area") return { text: "156 м²", status: "ready" }; - if (value.source.key === "date_time") return { text: formatDateTime(), status: "ready" }; - } - return entitySnapshot(value.source.entityId); - }; - - const sourcePresentation = (source) => { - if (!source) return { name: "Выберите сущность", detail: "Поиск по всем сущностям HA" }; - if (source.type === "system") return { name: source.name, detail: "Системное значение" }; - const entity = entities.find((item) => item.entityId === source.entityId); - if (!entity) return { name: "Недоступная сущность", detail: `${source.entityId || "Не выбрано"} · Требует внимания` }; - const status = entity.status === "unavailable" ? " · Недоступно" : entity.status === "stale" ? " · Данные устарели" : entity.status === "loading" ? " · Загрузка" : ""; - return { name: entity.name, detail: `${entity.entityId}${status}` }; - }; - - const blockMatchesSpace = (block) => block.scope.type === "all" || block.scope.spaceId === activeSpaceId; - - const positionPanel = () => { - const bottom = window.matchMedia("(orientation: portrait)").matches; - panel.classList.toggle("dashboard5-panel-bottom", bottom); - panel.dataset.position = bottom ? "bottom" : "right"; - }; - - const renderGlobalState = () => { - const hiddenByMobile = isNarrowViewport() && !config.showOnMobile; - const visible = globalEnabled && !hiddenByMobile; - panel.hidden = false; - panel.classList.toggle("is-visible", visible); - panel.setAttribute("aria-hidden", String(!visible)); - globalToggle.setAttribute("aria-pressed", String(globalEnabled)); - globalToggle.title = globalEnabled ? "Скрыть информационную панель" : "Показать информационную панель"; - globalToggle.setAttribute("aria-label", globalToggle.title); - }; - - const renderPanel = () => { - const title = config.title.trim() || "Сводная информация"; - panelTitleHeading.textContent = title; - panel.setAttribute("aria-label", title); - positionPanel(); - renderGlobalState(); - - blocksContainer.replaceChildren(); - const displayedBlocks = config.blocks.filter((block) => block.visible && blockMatchesSpace(block)); - if (!displayedBlocks.length) { - const empty = document.createElement("div"); - empty.className = "dashboard3-empty"; - empty.textContent = `Для пространства «${currentSpace().name}» нет настроенных блоков.`; - blocksContainer.append(empty); - return; - } - - displayedBlocks.forEach((block) => { - const valuesHtml = block.values.map((value) => { - const resolved = resolveValue(value); - const statusLabel = resolved.status === "loading" ? "Загрузка" : resolved.status === "stale" ? "Данные устарели" : resolved.status === "unavailable" ? "Источник недоступен" : ""; - return ` -
-
${escapeHtml(value.label)}
-
${escapeHtml(resolved.text)}${statusLabel ? `${escapeHtml(statusLabel)}` : ""}
-
`; - }).join(""); - const card = document.createElement("section"); - card.className = "dashboard3-block"; - card.innerHTML = ` -
-

${escapeHtml(block.title)}

-
-
${valuesHtml || '
В блоке пока нет значений.
'}
`; - blocksContainer.append(card); - }); - }; - - const validateDraft = () => { - const errors = []; - if (!draft?.title.trim()) errors.push({ type: "panel-title" }); - draft?.blocks.forEach((block) => { - if (!block.title.trim()) errors.push({ type: "block-title", blockId: block.id }); - if (block.scope.type === "space" && !block.scope.spaceId) errors.push({ type: "space", blockId: block.id }); - block.values.forEach((value) => { - const missing = []; - if (!value.label.trim()) missing.push("название"); - if (!value.source) missing.push("сущность"); - if (missing.length) errors.push({ type: "value", blockId: block.id, valueId: value.id, missing }); - }); - }); - return errors; - }; - - const updateDialogState = () => { - if (!draft || !baseline) return; - const errors = validateDraft(); - const dirty = fingerprint(draft) !== fingerprint(baseline) || draftEnabled !== baselineEnabled; - settingsSave.hidden = !dirty; - settingsSave.disabled = errors.length > 0; - panelTitleInput.classList.toggle("is-invalid", !draft.title.trim()); - panelTitleError.textContent = draft.title.trim() ? "" : "Введите название панели"; - - editorList.querySelectorAll(".dashboard3-editor-block").forEach((element) => { - const blockId = element.dataset.blockId; - const block = findDraftBlock(blockId); - if (!block) return; - element.querySelector(".dashboard3-block-title").classList.toggle("is-invalid", !block.title.trim()); - const scopeSelect = element.querySelector(".dashboard4-space-select"); - const invalidScope = block.scope.type === "space" && !block.scope.spaceId; - if (scopeSelect) scopeSelect.classList.toggle("is-invalid", invalidScope); - element.querySelectorAll(".dashboard3-editor-value").forEach((rowElement) => { - const value = findDraftValue(blockId, rowElement.dataset.valueId); - if (!value) return; - const missing = []; - if (!value.label.trim()) missing.push("название"); - if (!value.source) missing.push("сущность"); - rowElement.classList.toggle("has-error", missing.length > 0); - rowElement.querySelector(".dashboard3-value-label").classList.toggle("is-invalid", !value.label.trim()); - rowElement.querySelector(".dashboard3-entity-trigger").classList.toggle("is-invalid", !value.source); - rowElement.querySelector(".dashboard3-editor-value__error").textContent = missing.length ? `Заполните: ${missing.join(" и ")}` : ""; - }); - }); - - settingsStatus.textContent = errors.length - ? `Нельзя сохранить: незаполненных полей — ${errors.length}` - : dirty ? "Есть несохранённые изменения" : ""; - settingsStatus.classList.toggle("is-ready", dirty && !errors.length); - }; - - const moveItemByOffset = (items, itemId, offset) => { - const sourceIndex = items.findIndex((item) => item.id === itemId); - const targetIndex = sourceIndex + offset; - if (sourceIndex < 0 || targetIndex < 0 || targetIndex >= items.length) return false; - [items[sourceIndex], items[targetIndex]] = [items[targetIndex], items[sourceIndex]]; - return true; - }; - - const moveItem = (items, sourceId, targetId, placeAfter) => { - const sourceIndex = items.findIndex((item) => item.id === sourceId); - if (sourceIndex < 0) return; - const [moved] = items.splice(sourceIndex, 1); - const targetIndex = items.findIndex((item) => item.id === targetId); - if (targetIndex < 0) items.push(moved); - else items.splice(targetIndex + (placeAfter ? 1 : 0), 0, moved); - }; - - const restoreOrderFocus = (selector) => { - window.requestAnimationFrame(() => editorList.querySelector(selector)?.focus()); - }; - - const renderEditor = () => { - editorList.replaceChildren(); - blockCount.textContent = `${draft.blocks.length} из ${limits.blocks}`; - addBlockButton.disabled = draft.blocks.length >= limits.blocks; - addBlockButton.title = addBlockButton.disabled ? `Можно добавить не более ${limits.blocks} блоков` : "Добавить блок"; - - if (!draft.blocks.length) { - const empty = document.createElement("div"); - empty.className = "dashboard3-empty"; - empty.textContent = "Блоков пока нет. Добавьте блок, чтобы вывести данные в панели."; - editorList.append(empty); - } - - draft.blocks.forEach((block, blockIndex) => { - const article = document.createElement("article"); - article.className = "dashboard3-editor-block"; - article.dataset.blockId = block.id; - const knownSpace = spaces.some((space) => space.id === block.scope.spaceId); - const spaceOptions = [ - '', - block.scope.spaceId && !knownSpace ? `` : "", - ...spaces.map((space) => ``) - ].join(""); - const valuesHtml = block.values.map((value, index) => { - const source = sourcePresentation(value.source); - return ` -
- ⠿ - - - - - - - - -
`; - }).join(""); - article.innerHTML = ` -
- ⠿ - - - - - - - - - - -
-
- ${block.scope.type === "space" && block.scope.spaceId && !knownSpace ? '

Выбранное пространство недоступно. Привязка сохранена и требует внимания.

' : ""} -
${valuesHtml || '
В блоке пока нет значений.
'}
- -
-
`; - - const blockTitle = article.querySelector(".dashboard3-block-title"); - const blockDrag = article.querySelector(".dashboard4-block-drag"); - const blockMoveUp = article.querySelector(".dashboard3-block-up"); - const blockMoveDown = article.querySelector(".dashboard3-block-down"); - const visibility = article.querySelector(".dashboard3-visibility"); - const deleteBlock = article.querySelector(".dashboard3-delete-block"); - const scopeSelect = article.querySelector(".dashboard4-scope-type"); - const spaceSelect = article.querySelector(".dashboard4-space-select"); - const addValue = article.querySelector(".dashboard3-add-value"); - - blockTitle.addEventListener("input", (event) => { - block.title = event.target.value; - updateDialogState(); - }); - blockMoveUp.addEventListener("click", () => { - if (!moveItemByOffset(draft.blocks, block.id, -1)) return; - renderEditor(); - restoreOrderFocus(`[data-block-id="${block.id}"] .dashboard3-block-up`); - }); - blockMoveDown.addEventListener("click", () => { - if (!moveItemByOffset(draft.blocks, block.id, 1)) return; - renderEditor(); - restoreOrderFocus(`[data-block-id="${block.id}"] .dashboard3-block-down`); - }); - visibility.addEventListener("click", () => { - block.visible = !block.visible; - renderEditor(); - }); - deleteBlock.addEventListener("click", () => { - if (block.values.length && !window.confirm(`Удалить блок «${block.title || "Без названия"}» и все его значения?`)) return; - draft.blocks = draft.blocks.filter((item) => item.id !== block.id); - closeEntityPicker(); - renderEditor(); - }); - scopeSelect.addEventListener("change", (event) => { - block.scope.type = event.target.value; - if (block.scope.type === "all") block.scope.spaceId = null; - else block.scope.spaceId = null; - renderEditor(); - }); - spaceSelect.addEventListener("change", (event) => { - block.scope.spaceId = event.target.value || null; - renderEditor(); - }); - addValue.addEventListener("click", () => { - if (block.values.length >= limits.values) return; - block.values.push({ id: `value-${++valueSequence}`, label: "", source: null }); - renderEditor(); - article.ownerDocument.querySelector(`[data-block-id="${block.id}"] .dashboard3-editor-value:last-of-type .dashboard3-value-label`)?.focus(); - }); - - blockDrag.addEventListener("dragstart", (event) => { - dragContext = { type: "block", id: block.id }; - article.classList.add("is-dragging"); - event.dataTransfer.effectAllowed = "move"; - }); - article.addEventListener("dragover", (event) => { - if (dragContext?.type !== "block" || dragContext.id === block.id) return; - event.preventDefault(); - article.classList.add("is-drop-target"); - }); - article.addEventListener("dragleave", () => article.classList.remove("is-drop-target")); - article.addEventListener("drop", (event) => { - if (dragContext?.type !== "block" || dragContext.id === block.id) return; - event.preventDefault(); - const rect = article.getBoundingClientRect(); - moveItem(draft.blocks, dragContext.id, block.id, event.clientY > rect.top + rect.height / 2); - dragContext = null; - renderEditor(); - }); - blockDrag.addEventListener("dragend", () => { - dragContext = null; - editorList.querySelectorAll(".is-dragging, .is-drop-target").forEach((element) => element.classList.remove("is-dragging", "is-drop-target")); - }); - - article.querySelectorAll(".dashboard3-editor-value").forEach((rowElement) => { - const value = block.values.find((item) => item.id === rowElement.dataset.valueId); - if (!value) return; - const labelInput = rowElement.querySelector(".dashboard3-value-label"); - const entityTrigger = rowElement.querySelector(".dashboard3-entity-trigger"); - const removeValue = rowElement.querySelector(".dashboard3-editor-value__remove"); - const valueMoveUp = rowElement.querySelector(".dashboard3-value-up"); - const valueMoveDown = rowElement.querySelector(".dashboard3-value-down"); - const valueDrag = rowElement.querySelector(".dashboard4-value-drag"); - labelInput.addEventListener("input", (event) => { - value.label = event.target.value; - updateDialogState(); - }); - entityTrigger.addEventListener("click", () => openEntityPicker(block.id, value.id, entityTrigger)); - valueMoveUp.addEventListener("click", () => { - if (!moveItemByOffset(block.values, value.id, -1)) return; - renderEditor(); - restoreOrderFocus(`[data-block-id="${block.id}"] [data-value-id="${value.id}"] .dashboard3-value-up`); - }); - valueMoveDown.addEventListener("click", () => { - if (!moveItemByOffset(block.values, value.id, 1)) return; - renderEditor(); - restoreOrderFocus(`[data-block-id="${block.id}"] [data-value-id="${value.id}"] .dashboard3-value-down`); - }); - removeValue.addEventListener("click", () => { - block.values = block.values.filter((item) => item.id !== value.id); - closeEntityPicker(); - renderEditor(); - }); - valueDrag.addEventListener("dragstart", (event) => { - dragContext = { type: "value", id: value.id, blockId: block.id }; - rowElement.classList.add("is-dragging"); - event.stopPropagation(); - event.dataTransfer.effectAllowed = "move"; - }); - rowElement.addEventListener("dragover", (event) => { - if (dragContext?.type !== "value" || dragContext.blockId !== block.id || dragContext.id === value.id) return; - event.preventDefault(); - event.stopPropagation(); - rowElement.classList.add("is-drop-target"); - }); - rowElement.addEventListener("drop", (event) => { - if (dragContext?.type !== "value" || dragContext.blockId !== block.id || dragContext.id === value.id) return; - event.preventDefault(); - event.stopPropagation(); - const rect = rowElement.getBoundingClientRect(); - moveItem(block.values, dragContext.id, value.id, event.clientY > rect.top + rect.height / 2); - dragContext = null; - renderEditor(); - }); - valueDrag.addEventListener("dragend", (event) => { - event.stopPropagation(); - dragContext = null; - editorList.querySelectorAll(".is-dragging, .is-drop-target").forEach((element) => element.classList.remove("is-dragging", "is-drop-target")); - }); - }); - - editorList.append(article); - }); - updateDialogState(); - }; - - const renderEntityOptions = () => { - const query = entitySearch.value.trim().toLocaleLowerCase("ru-RU"); - const filtered = entities.filter((entity) => `${entity.name} ${entity.entityId}`.toLocaleLowerCase("ru-RU").includes(query)); - entityList.replaceChildren(); - if (!filtered.length) { - const empty = document.createElement("div"); - empty.className = "dashboard3-entity-picker__empty"; - empty.textContent = "Сущности не найдены"; - entityList.append(empty); - return; - } - const selected = pickerContext ? findDraftValue(pickerContext.blockId, pickerContext.valueId)?.source?.entityId : null; - filtered.forEach((entity) => { - const statusLabel = entity.status === "loading" ? "Загрузка" : entity.status === "stale" ? "Устарело" : entity.status === "unavailable" ? "Недоступно" : ""; - const option = document.createElement("button"); - option.className = `dashboard3-entity-option dashboard4-entity--${entity.status || "ready"}${selected === entity.entityId ? " is-selected" : ""}`; - option.type = "button"; - option.setAttribute("role", "option"); - option.setAttribute("aria-selected", String(selected === entity.entityId)); - option.innerHTML = `${escapeHtml(entity.name)}${entity.template ? " · Template" : ""}${escapeHtml(entity.entityId)}${escapeHtml(entity.state)}${statusLabel ? `${escapeHtml(statusLabel)}` : ""}`; - option.addEventListener("click", () => { - if (!pickerContext) return; - const value = findDraftValue(pickerContext.blockId, pickerContext.valueId); - if (!value) return; - value.source = { type: "entity", entityId: entity.entityId }; - closeEntityPicker(); - renderEditor(); - }); - entityList.append(option); - }); - }; - - const positionEntityPicker = (trigger) => { - const rect = trigger.getBoundingClientRect(); - const width = Math.min(420, window.innerWidth - 24); - const estimatedHeight = Math.min(430, window.innerHeight - 24); - const left = Math.min(Math.max(12, rect.right - width), window.innerWidth - width - 12); - const below = rect.bottom + 5; - const top = below + estimatedHeight <= window.innerHeight - 12 ? below : Math.max(12, rect.top - estimatedHeight - 5); - entityPicker.style.left = `${left}px`; - entityPicker.style.top = `${top}px`; - }; - - const openEntityPicker = (blockId, valueId, trigger) => { - pickerContext = { blockId, valueId, trigger }; - entitySearch.value = ""; - entityPicker.hidden = false; - renderEntityOptions(); - positionEntityPicker(trigger); - window.requestAnimationFrame(() => entitySearch.focus()); - }; - - const closeEntityPicker = () => { - entityPicker.hidden = true; - pickerContext = null; - }; - - const openSettings = () => { - baseline = clone(config); - draft = clone(config); - baselineEnabled = globalEnabled; - draftEnabled = globalEnabled; - panelTitleInput.value = draft.title; - showPanelInput.checked = draftEnabled; - showMobileInput.checked = draft.showOnMobile; - settingsDialog.hidden = false; - document.body.classList.add("dashboard3-dialog-open"); - renderEditor(); - window.requestAnimationFrame(() => panelTitleInput.focus()); - }; - - const closeSettings = (restore = true) => { - closeEntityPicker(); - settingsDialog.hidden = true; - document.body.classList.remove("dashboard3-dialog-open"); - if (restore) { - draft = null; - baseline = null; - draftEnabled = false; - baselineEnabled = false; - } - }; - - const saveSettings = () => { - const errors = validateDraft(); - if (errors.length) { - updateDialogState(); - editorList.querySelector(".is-invalid")?.focus(); - return; - } - config = clone(draft); - globalEnabled = draftEnabled; - persist(storageKeys.config, config); - persist(storageKeys.enabled, String(globalEnabled)); - closeSettings(false); - draft = null; - baseline = null; - draftEnabled = false; - baselineEnabled = false; - renderPanel(); - }; - - globalToggle.addEventListener("click", () => { - globalEnabled = !globalEnabled; - persist(storageKeys.enabled, String(globalEnabled)); - renderGlobalState(); - }); - themeToggle.addEventListener("click", () => { - currentTheme = currentTheme === "dark" ? "light" : "dark"; - persist(storageKeys.theme, currentTheme); - applyTheme(); - }); - kioskToggle.addEventListener("click", () => { - kioskEnabled = !kioskEnabled; - persist(storageKeys.kiosk, String(kioskEnabled)); - applyKiosk(); - }); - sizeToggle.addEventListener("click", () => { - sizeDialog.hidden = false; - document.body.classList.add("dashboard3-dialog-open"); - applyLocalSizes(); - iconSizeInput.focus(); - }); - sizeClose.addEventListener("click", () => { - sizeDialog.hidden = true; - document.body.classList.remove("dashboard3-dialog-open"); - }); - sizeForm.addEventListener("submit", (event) => { - event.preventDefault(); - sizeDialog.hidden = true; - document.body.classList.remove("dashboard3-dialog-open"); - }); - sizeReset.addEventListener("click", () => { - localSizes = { icons: 100, text: 100 }; - persist(storageKeys.sizes, localSizes); - applyLocalSizes(); - }); - iconSizeInput.addEventListener("input", (event) => { - localSizes.icons = Number(event.target.value); - persist(storageKeys.sizes, localSizes); - applyLocalSizes(); - }); - textSizeInput.addEventListener("input", (event) => { - localSizes.text = Number(event.target.value); - persist(storageKeys.sizes, localSizes); - applyLocalSizes(); - }); - settingsToggle.addEventListener("click", openSettings); - settingsClose.addEventListener("click", () => closeSettings()); - settingsCancel.addEventListener("click", () => closeSettings()); - settingsDialog.addEventListener("click", (event) => { - if (event.target === settingsDialog) closeSettings(); - }); - settingsForm.addEventListener("submit", (event) => { - event.preventDefault(); - saveSettings(); - }); - panelTitleInput.addEventListener("input", (event) => { - draft.title = event.target.value; - updateDialogState(); - }); - showPanelInput.addEventListener("change", (event) => { - draftEnabled = event.target.checked; - updateDialogState(); - }); - showMobileInput.addEventListener("change", (event) => { - draft.showOnMobile = event.target.checked; - updateDialogState(); - }); - addBlockButton.addEventListener("click", () => { - if (draft.blocks.length >= limits.blocks) return; - const usedNumbers = draft.blocks.map((block) => Number(block.title.match(/\d+/)?.[0]) || 0); - const number = Math.max(0, ...usedNumbers, blockSequence) + 1; - blockSequence = number; - draft.blocks.push({ - id: `block-${Date.now()}-${number}`, - title: `Блок ${number}`, - visible: true, - scope: { type: "all", spaceId: null }, - values: [{ id: `value-${++valueSequence}`, label: "", source: null }] - }); - renderEditor(); - editorList.lastElementChild?.scrollIntoView({ behavior: "smooth", block: "nearest" }); - editorList.lastElementChild?.querySelector(".dashboard3-block-title")?.focus(); - }); - entitySearch.addEventListener("input", renderEntityOptions); - document.addEventListener("pointerdown", (event) => { - if (entityPicker.hidden || entityPicker.contains(event.target) || event.target.closest(".dashboard3-entity-trigger")) return; - closeEntityPicker(); - }); - document.addEventListener("keydown", (event) => { - if (event.key !== "Escape") return; - if (!entityPicker.hidden) closeEntityPicker(); - else if (!sizeDialog.hidden) { - sizeDialog.hidden = true; - document.body.classList.remove("dashboard3-dialog-open"); - } - else if (!settingsDialog.hidden) closeSettings(); - }); - window.addEventListener("resize", () => { - if (!entityPicker.hidden && pickerContext?.trigger) positionEntityPicker(pickerContext.trigger); - positionPanel(); - renderGlobalState(); - }); - spaceTabs.forEach((tab) => { - tab.addEventListener("click", () => { - activeSpaceId = tab.dataset.spaceId; - spaceTabs.forEach((item) => item.classList.toggle("active", item === tab)); - renderPanel(); - }); - }); - - applyTheme(); - applyKiosk(); - applyLocalSizes(); - renderPanel(); - positionPanel(); - if (typeof ResizeObserver === "function") new ResizeObserver(positionPanel).observe(dashboardBody); - window.addEventListener("resize", positionPanel, { passive: true }); - window.setInterval(renderPanel, 60000); -})(); - diff --git a/docs/design/505-summary-panel/reference/index.html b/docs/design/505-summary-panel/reference/index.html deleted file mode 100644 index 3d905ed7..00000000 --- a/docs/design/505-summary-panel/reference/index.html +++ /dev/null @@ -1,496 +0,0 @@ - - - - - - Home Assistant — Dashboard 5 - - - - - - -
- - -
-
-
- - - -
-
- - - - -
-
- -
-
- - План дома -
-
- - - - + -
-
- ⌗ Редактор плана - ⌘ Редактор устройств - ⌁ Редактор подложки -
- 50 устр. -
-
- - - -
- - - - - -
- - -
-
-
- -
-
-
-
-
-
-

Terrace

- -
- -
-
- Kitchen & Living ↗ - ♟ 20.6°   ● 42%   ◒ Выкл -
- -
- -
Sauna ↗
-
Guest WC ↗
-
- Outdoor Storage ↗◉ Выкл -
-
- Water · 66% - -
-
Boiler Room ↗
-
- Hallway ↗◉ Выкл - - -
-
- Under-Stairs Closet ↗ - -
-
Guest Bedroom ↗
-
-

Porch

- - -
- - - -
- ▣● -
- ● - ● - ◉ - ● - ● - ● - -
- ▬▣⌂ -
-
- ▣▦▦▣ -
-
✣✣
- - 42% - ♟ - ● - ● - ● - ● - ● - ● - -
- ↔☷↔ -
-
- ♜▦ -
- ◉ - 66% - ⌁ - Rinsing - ⌁ - ● - ● - ● - - ◉ - 43% - ♟ -
▣▬
-
-
- - - - - - - - - - - -
-
-
- - - diff --git a/docs/design/505-summary-panel/reference/styles.css b/docs/design/505-summary-panel/reference/styles.css deleted file mode 100644 index 593fd193..00000000 --- a/docs/design/505-summary-panel/reference/styles.css +++ /dev/null @@ -1,373 +0,0 @@ -:root { - --sidebar-width: 190px; - --global-bar-height: 41px; - --toolbar-height: 42px; - --blue: #00a5cf; - --ink: #303437; - --muted: #767b7e; - --line: #e7e7e7; - --room-fill: #8f979e; - --wall-dark: #53585b; -} - -* { box-sizing: border-box; } - -html, body { - width: 100%; - height: 100%; - margin: 0; - overflow: hidden; - font-family: Arial, Helvetica, sans-serif; - color: var(--ink); - background: #fff; -} - -body { min-width: 1160px; min-height: 680px; } -.components-page { min-height: 100vh; overflow: auto; } - -.app-shell { display: flex; width: 100vw; height: 100vh; } - -.sidebar { - position: relative; - z-index: 10; - flex: 0 0 var(--sidebar-width); - height: 100%; - border-right: 1px solid var(--line); - background: #fff; -} - -.brand-row { - display: flex; - align-items: center; - height: var(--global-bar-height); - padding: 0 12px; - border-bottom: 1px solid var(--line); - gap: 12px; -} - -.brand-name { font-size: 16px; font-weight: 500; color: #242424; } -.nav-icon { width: 19px; text-align: center; color: #7b7f80; font-size: 17px; font-weight: 700; } -.hamburger { font-size: 15px; transform: scaleX(1.1); } -.plan-glyph, .active .nav-icon { color: var(--blue); } -.lightning { font-size: 23px; } - -.side-nav { padding-top: 7px; } -.nav-item { - display: flex; - align-items: center; - height: 34px; - padding: 0 12px; - gap: 11px; - color: #2e3032; - text-decoration: none; - font-size: 12px; - font-weight: 600; -} -.nav-item.active { - margin: 0 3px; - padding-left: 9px; - border-radius: 3px; - color: var(--blue); - background: #d9f1f7; -} -.sidebar-footer { position: absolute; left: 0; right: 0; bottom: 8px; } -.sidebar-footer .nav-item { position: relative; } -.badge { - position: absolute; - right: 11px; - display: grid; - place-items: center; - width: 17px; - height: 17px; - border-radius: 50%; - color: #fff; - background: #ff9900; - font-size: 10px; -} -.profile { height: 38px; } -.avatar { - display: grid; - place-items: center; - width: 31px; - height: 31px; - margin-left: -5px; - border-radius: 50%; - color: #328496; - background: #d1f2fb; - font-weight: 400; -} - -.workspace { position: relative; display: flex; flex: 1 1 auto; flex-direction: column; min-width: 0; height: 100%; } -.global-bar { - display: flex; - align-items: center; - justify-content: space-between; - height: var(--global-bar-height); - padding: 0 14px 0 10px; - border-bottom: 1px solid var(--line); - background: #fff; - flex: 0 0 var(--global-bar-height); -} -.view-switcher { display: flex; align-self: stretch; gap: 14px; } -.view-icon { - position: relative; - display: grid; - place-items: center; - width: 28px; - color: #555; - font-size: 16px; -} -.view-icon.selected::after { - position: absolute; - right: 0; - bottom: -1px; - left: 0; - height: 2px; - content: ""; - background: #2d2d2d; -} -.global-actions { display: flex; align-items: center; gap: 24px; font-size: 20px; color: #222; } - -.ha-view-tabs--compact { - align-items: stretch; - align-self: stretch; - height: var(--global-bar-height); - padding-left: 0; - gap: 14px; - border-bottom: 0; -} -.ha-view-tabs--compact .ha-view-tab { width: 28px; height: var(--global-bar-height); font-size: 16px; } -.ha-view-tabs--compact .ha-view-tab.active::after { height: 2px; } - -.ha-context-row--compact { - flex: 0 0 44px; - height: 44px; - min-height: 44px; - padding: 0 10px; - gap: 8px; - overflow: hidden; - font-size: 11px; -} -.ha-context-row--compact .ha-plan-label { gap: 5px; font-size: 12px; } -.ha-context-row--compact .ha-home-icon { font-size: 17px; } -.ha-context-row--compact .ha-segmented { height: 31px; padding: 3px; border-radius: 10px; } -.ha-context-row--compact .ha-segment { height: 25px; padding: 0 11px; gap: 5px; border-radius: 7px; font-size: 11px; } -.ha-context-row--compact .ha-segment small { font-size: 10px; } -.ha-context-row--compact .ha-segment b { margin-left: 4px; font-size: 15px; } -.ha-context-row--compact .ha-add { padding: 0 7px; font-size: 16px; } -.ha-context-row--compact .ha-device-count { font-size: 11px; } - -.ha-tools-row--compact { - flex: 0 0 50px; - min-height: 50px; - height: 50px; - padding: 7px 10px; - gap: 7px; - overflow: hidden; -} -.ha-tools-row--compact .ha-tool-mode { font-size: 24px; } -.ha-tools-row--compact .ha-tool { height: 34px; padding: 0 10px; gap: 6px; border-radius: 9px; font-size: 12px; } -.ha-tools-row--compact .ha-tool-icon { font-size: 15px; } -.ha-tools-row--compact .ha-history-actions { gap: 8px; } -.ha-tools-row--compact .ha-history-actions button { font-size: 18px; } - -.dashboard-body { display: flex; flex: 1 1 auto; min-height: 0; overflow: hidden; } - -.plan-toolbar { - position: relative; - z-index: 4; - display: flex; - align-items: center; - height: var(--toolbar-height); - padding: 0 10px; - gap: 8px; - border-bottom: 1px solid #ededed; - background: #fff; - white-space: nowrap; - font-size: 11px; -} -.plan-title { display: flex; align-items: center; gap: 5px; font-size: 12px; } -.home-mark { color: var(--blue); font-size: 17px; } -.floor-tabs, .editor-tabs, .zoom-tools { - display: flex; - align-items: center; - height: 29px; - border-radius: 10px; - background: #f1f1f1; -} -.floor-tab { height: 29px; padding: 8px 12px; color: #606164; font-weight: 600; } -.floor-tab small { color: #999; } -.floor-tab.active { margin: 2px; height: 25px; padding-top: 6px; border-radius: 6px; color: #fff; background: var(--blue); } -.add-floor { padding: 0 9px; color: #777; font-size: 16px; } -.editor-tabs { gap: 18px; padding: 0 13px; color: #616164; font-weight: 600; } -.device-count { color: #777; } -.zoom-tools { margin-left: auto; border-radius: 8px; } -.zoom-tools span { display: grid; place-items: center; width: 29px; height: 29px; font-size: 16px; } -.settings-button { display: grid; place-items: center; width: 30px; height: 29px; border-radius: 8px; background: #f5f5f5; font-size: 15px; } - -.canvas { - position: relative; - flex: 1 1 auto; - min-width: 0; - height: auto; - overflow: hidden; - background: #d9ebf1; -} -.canvas::before { - position: absolute; - inset: 0; - content: ""; - background: - radial-gradient(circle at 50% 45%, rgba(255,255,255,.34), transparent 37%), - linear-gradient(90deg, rgba(255,255,255,.08), transparent 12%, transparent 88%, rgba(255,255,255,.08)); -} -.ambient-light { position: absolute; border-radius: 50%; filter: blur(20px); opacity: .6; } -.light-one { right: 5%; top: 53%; width: 155px; height: 155px; background: rgba(255, 245, 145, .42); } -.light-two { left: 39%; top: 5%; width: 70px; height: 70px; background: rgba(255, 236, 166, .22); } - -.floor-plan { - position: absolute; - top: 50%; - left: 50%; - width: 700px; - height: 850px; - transform: translate(-46%, -49%) scale(var(--plan-scale, .94)); - transform-origin: center; - color: #3d4143; -} - -.room { - position: absolute; - border: 10px solid transparent; - background: - linear-gradient(var(--room-fill), var(--room-fill)) padding-box, - repeating-linear-gradient(135deg, #4f5355 0 2px, #9da2a4 2px 7px) border-box; -} -.room-name { position: absolute; font-size: 12px; font-weight: 700; color: #414548; white-space: nowrap; } -.room-name sup { color: #666; font-size: 9px; } -.room-name small { display: block; margin-top: 8px; font-size: 9px; color: #555; } - -.terrace { - left: 185px; top: 9px; width: 420px; height: 190px; - border: 10px solid #5d7c8a; - border-top-style: dashed; - background: rgba(175, 195, 204, .55); -} -.terrace h1 { margin: 42px 0 0; text-align: center; font-size: 44px; line-height: 1; } -.kitchen { left: 12px; top: 170px; width: 590px; height: 285px; } -.kitchen-name { right: 76px; top: 46px; font-size: 15px; text-align: left; } -.kitchen-name small { margin-top: 8px; font-size: 11px; } -.sauna { left: 12px; top: 445px; width: 122px; height: 150px; } -.sauna .room-name { left: 7px; bottom: 17px; } -.guest-wc { left: 123px; top: 445px; width: 145px; height: 150px; } -.guest-wc .room-name { right: 9px; bottom: 17px; } -.storage { left: 12px; top: 585px; width: 112px; height: 165px; } -.storage-name { top: 7px; left: 6px; font-size: 9px; } -.washroom { left: 113px; top: 585px; width: 155px; height: 165px; } -.water-value { position: absolute; top: 57px; left: -17px; color: #4f879c; font-size: 12px; font-weight: 700; } -.boiler { left: 155px; top: 668px; width: 113px; height: 82px; } -.boiler .room-name { right: 4px; bottom: 17px; } -.hallway { left: 258px; top: 445px; width: 193px; height: 305px; } -.hallway-name { left: 0; top: 163px; } -.stairs-closet { left: 440px; top: 365px; width: 250px; height: 165px; } -.closet-name { right: 10px; bottom: 14px; font-size: 11px; } -.guest-bedroom { left: 440px; top: 520px; width: 160px; height: 230px; } -.bedroom-name { right: 7px; bottom: 17px; font-size: 11px; } -.porch { - left: 12px; top: 740px; width: 440px; height: 102px; - border-color: #5d7c8a; - background: #b8aa9a; -} -.porch h2 { margin: 24px 0 0; text-align: center; color: #595d5f; font-size: 26px; } - -.door { position: absolute; width: 56px; height: 56px; border: 2px solid #555; border-left: 0; border-bottom: 0; border-radius: 0 56px 0 0; } -.terrace-door { left: 41px; bottom: -11px; transform: rotate(-90deg); transform-origin: bottom left; } -.kitchen-door { left: 235px; bottom: -11px; transform: rotate(90deg); transform-origin: bottom left; } -.wc-door { right: -10px; top: 48px; transform: rotate(-90deg); } -.wash-door { right: -10px; top: 68px; transform: rotate(-90deg); } -.hall-door-left { left: -10px; top: 23px; transform: rotate(-90deg); } -.hall-door-right { right: -10px; top: 58px; transform: rotate(-90deg); } -.closet-door { left: 70px; bottom: -10px; transform: rotate(180deg); } -.porch-door-one { left: 8px; top: -10px; transform: rotate(90deg); } -.porch-door-two { right: 74px; top: -10px; transform: rotate(90deg); } -.dashed-zone { position: absolute; left: 273px; top: 450px; width: 223px; height: 84px; border: 2px dashed #555; border-bottom: 0; } - -.device { - display: grid; - place-items: center; - width: 24px; - height: 24px; - border: 2px solid #9da3a7; - border-radius: 50%; - box-shadow: 0 1px 2px rgba(0,0,0,.22); - color: #111; - background: #fff; - font-size: 11px; - font-weight: 800; - line-height: 1; -} -.bulb { font-size: 10px; } -.camera { font-size: 13px; } -.sensor { width: 29px; font-size: 10px; } -.thermometer { font-size: 13px; } -.small-indicator { width: 14px; height: 14px; border: 0; color: #fff; background: #36c86c; font-size: 7px; box-shadow: 0 0 5px rgba(55,202,108,.6); } -.device-row { position: absolute; display: flex; gap: 4px; } -.device-column { position: absolute; display: flex; flex-direction: column; gap: 4px; } -.top-left { left: 24px; top: 182px; } -.d1 { left: 188px; top: 182px; } -.d2 { left: 305px; top: 182px; } -.d3 { left: 298px; top: 204px; } -.d4 { left: 421px; top: 182px; } -.d5 { left: 540px; top: 182px; } -.online-1 { left: 271px; top: 184px; } -.left-devices { left: 23px; top: 340px; } -.right-devices { left: 565px; top: 202px; } -.fan-row { position: absolute; left: 536px; top: 302px; display: flex; gap: 7px; } -.temp-main { left: 138px; top: 411px; } -.temp-icon { left: 183px; top: 411px; } -.center-bulb-1 { left: 219px; top: 337px; } -.center-bulb-2 { left: 324px; top: 446px; } -.center-bulb-3 { left: 324px; top: 497px; } -.center-bulb-4 { left: 324px; top: 548px; } -.center-bulb-5 { left: 324px; top: 607px; } -.center-bulb-6 { left: 324px; top: 660px; } -.utility-devices { left: 23px; top: 611px; } -.wash-devices { left: 128px; top: 622px; } -.wash-camera { left: 158px; top: 592px; } -.wash-percent { left: 151px; top: 633px; } -.wash-warning { left: 187px; top: 634px; color: #fff; background: #ffae00; } -.status-pill { position: absolute; padding: 5px 7px; border: 2px solid #fff; border-radius: 11px; color: #fff; background: #ffad00; font-size: 9px; font-weight: 700; box-shadow: 0 1px 2px rgba(0,0,0,.2); } -.rinsing { left: 191px; top: 672px; } -.boiler-warning { left: 123px; top: 711px; color: #fff; background: #ffae00; } -.boiler-bulb { left: 143px; top: 692px; } -.online-2 { left: 53px; top: 718px; } -.online-3 { left: 325px; top: 718px; } -.closet-camera { left: 622px; top: 386px; } -.guest-temp { left: 514px; top: 525px; } -.guest-thermo { left: 550px; top: 525px; } -.guest-devices { left: 442px; top: 603px; } - -@media (max-height: 820px) { - .floor-plan { --plan-scale: .76; } -} - -@media (min-height: 821px) and (max-height: 940px) { - .floor-plan { --plan-scale: .88; } -} - -@media (min-height: 941px) { - .floor-plan { --plan-scale: 1; } -} - -@media (max-width: 1450px) { - :root { --sidebar-width: 178px; } - .ha-context-row--compact .ha-segment { padding-inline: 7px; font-size: 10px; } - .ha-tools-row--compact .ha-tool { padding-inline: 7px; font-size: 11px; } - .floor-plan { transform: translate(-44%, -49%) scale(var(--plan-scale, .85)); } -} - -@media (max-width: 1220px) { - .ha-context-row--compact .ha-editor-switcher .ha-segment { padding-inline: 5px; font-size: 9px; } - .ha-tools-row--compact .ha-tool { padding-inline: 5px; gap: 3px; font-size: 10px; } -} - diff --git a/docs/design/505-summary-panel/reference/svg/chevron-down.svg b/docs/design/505-summary-panel/reference/svg/chevron-down.svg deleted file mode 100644 index 37fe8e2d..00000000 --- a/docs/design/505-summary-panel/reference/svg/chevron-down.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/docs/design/505-summary-panel/reference/svg/eye-off.svg b/docs/design/505-summary-panel/reference/svg/eye-off.svg deleted file mode 100644 index 000f413e..00000000 --- a/docs/design/505-summary-panel/reference/svg/eye-off.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/docs/design/505-summary-panel/reference/svg/eye.svg b/docs/design/505-summary-panel/reference/svg/eye.svg deleted file mode 100644 index 41cf11ca..00000000 --- a/docs/design/505-summary-panel/reference/svg/eye.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/docs/design/505-summary-panel/reference/svg/layout-sidebar-right.svg b/docs/design/505-summary-panel/reference/svg/layout-sidebar-right.svg deleted file mode 100644 index d43c300d..00000000 --- a/docs/design/505-summary-panel/reference/svg/layout-sidebar-right.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/docs/design/505-summary-panel/reference/svg/plus.svg b/docs/design/505-summary-panel/reference/svg/plus.svg deleted file mode 100644 index d6aae34f..00000000 --- a/docs/design/505-summary-panel/reference/svg/plus.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/docs/design/505-summary-panel/reference/svg/settings.svg b/docs/design/505-summary-panel/reference/svg/settings.svg deleted file mode 100644 index 34c7acf2..00000000 --- a/docs/design/505-summary-panel/reference/svg/settings.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/docs/design/600-settings-dialogs/ACCEPTANCE.md b/docs/design/600-settings-dialogs/ACCEPTANCE.md index ba6ada8c..d8453365 100644 --- a/docs/design/600-settings-dialogs/ACCEPTANCE.md +++ b/docs/design/600-settings-dialogs/ACCEPTANCE.md @@ -1,5 +1,10 @@ # #600 · приёмка против референса +> Запись закрытой задачи. Кадры `pairs/`, `screenshots/` и прототип `reference/`, +> на которые она ссылается, удалены из дерева в #681 и лежат в истории git +> (`git checkout f989fbfe -- docs/design/600-settings-dialogs`); новая съёмка +> пишет в `artifacts/design-pairs-600/`. + AC1 в #600 требует не «эталоны изменились», а **парное сравнение с референсом**: для каждого диалога кадр продукта рядом с кадром референса, и каждое расхождение названо со ссылкой на пункт `SPEC.md` или узел `reference/`. @@ -14,7 +19,7 @@ AC1 в #600 требует не «эталоны изменились», а **п ``` npm run build && npm run bundle:sync -node demo/capture_design_pairs_600.mjs # → pairs/<диалог>-<тема>.png +node demo/capture_design_pairs_600.mjs # → artifacts/design-pairs-600/<диалог>-<тема>.png ``` Скрипт открывает четыре диалога в состояниях макетов (пространство с diff --git a/docs/design/600-settings-dialogs/ARCHIVE-README.md b/docs/design/600-settings-dialogs/ARCHIVE-README.md deleted file mode 100644 index 4b89fb96..00000000 --- a/docs/design/600-settings-dialogs/ARCHIVE-README.md +++ /dev/null @@ -1,33 +0,0 @@ -# Settings dialogs redesign: материалы для issue и реализации - -Дата: 18.09.2026. Сверено с `Matysh/houseplan-card` dev `9683a59` (v1.76.0). Изменение только UI. - -``` -ISSUE-FORM.md текст для трёх полей формы Feature request (Title, What problem, How it works) -SPEC.md подробное ТЗ на четыре диалога в структуре PROCESS.md §7.1 (сценарий, до/после, - скоуп, визуальная система, состав каждого диалога, i18n, AC, риски, предположения) -IMPLEMENTATION-GUIDE.md для Claude, который будет реализовывать: где что в коде, порядок работ, - маппинг каждого контрола на ключи состояния, что прототип не показывает, чек-лист PR -OPEN-POINTS.md места, где прототип задевает поведение: реализующая сессия Claude уточняет их у JB в чате -docs/FIELD-MAP-space.md полная карта полей Space settings (прототип → SpaceDialogState → i18n → контрол) -reference/ интерактивный прототип четырёх форм (дизайн-референс, не продуктовый код) - index.html, styles.css, ui.js (иконки, «?»-подсказки, toast), demo-shell.js (переключатель форм) - app.js (Space settings), general-settings.js, room-settings.js, device-settings.js - room-fill.js + room-fill.test.mjs (сверка семантики заливок), serve.mjs, START.cmd/.ps1, assets/ -screenshots/ четыре формы целиком, 560 px, 2x, состояние по умолчанию -``` - -## Как открыть прототип - -``` -cd reference -node serve.mjs # http://127.0.0.1:8135/ ; file:// не работает (ES-модули) -``` - -Кнопка «Open preview» или переключатель «Form» вверху: Space settings, General settings, Room settings, Device on the plan. Данные форм хранятся в localStorage браузера; для чистого состояния открывать в приватном окне. - -## Как использовать - -1. Создать issue из `ISSUE-FORM.md`, приложить этот архив и четыре PNG из `screenshots/`. -2. Когда владелец примет задачу и дойдёт до этапа ТЗ, `SPEC.md` вставляется в тело issue под `## ТЗ` (там уже нужная структура). -3. Реализация по `IMPLEMENTATION-GUIDE.md` только после метки `S5-ready` (Rule #1 в AGENTS.md). Меняется только UI форм; спорные места из `OPEN-POINTS.md` реализующая сессия Claude уточняет у JB в чате, не у владельца. diff --git a/docs/design/600-settings-dialogs/ISSUE-FORM.md b/docs/design/600-settings-dialogs/ISSUE-FORM.md deleted file mode 100644 index da7ff19f..00000000 --- a/docs/design/600-settings-dialogs/ISSUE-FORM.md +++ /dev/null @@ -1,19 +0,0 @@ -# Текст для полей issue (шаблон Feature request) - -**Title** - -Settings dialogs: new compact card layout (Space, General, Room, Device on the plan) - -В форме заполняются только три поля ниже; метки и статус ставит владелец. Архив с прототипом и четыре PNG прикрепить через «Paste, drop, or click to add files». - ---- - -**What problem would this solve?** - -The four settings dialogs (Space, General settings, Room settings, Device on the plan) are flat lists of fields: toggles, selects, radio lists, colour swatches and hint paragraphs follow each other in one column with no grouping. Related settings sit far apart, some options are worded as "hide" next to others worded as "show", explanations take whole paragraphs, and the colour picker opens a separate HSB panel with sliders and an OK button. Editing one setting means scanning the whole dialog. - -**How do you imagine it working?** - -UI-only redesign, no changes to state, storage, resolvers or behaviour. Each dialog becomes a 560 px form with a fixed header and footer and one scrolling body of white cards (Space: Basics, Appearance, Room cards, Sun & light; General: Display, Zigbee links, Room fill colors, Light-source glow, Plan, Sun, Data; Room: Basics, Fill, Sensor sources, Font sizes; Device: Basics, Tap action, Light and glow, Appearance, Details). Shared controls: segmented control instead of selects/radio lists with 2–5 options, one-line rows for uniform toggle lists, icon tiles for the room-card values, inline colour field (swatch with the system picker, hex, opacity, Reset) instead of the HSB popover, colour tiles for colour groups, `hp-help` "?" buttons next to every heading instead of hint paragraphs. Save is enabled only with changes and without errors; closing with unsaved changes asks to keep or discard. - -**Scope: UI of the four dialogs only.** No changes to dialog state, config, resolvers, saving, permissions or any product logic; every new control writes the same value to the same key as the control it replaces. Interactive prototype of all four dialogs, full-height screenshots, the detailed spec (`SPEC.md`, in the `## ТЗ` structure from PROCESS.md §7.1) and the implementation guide (`IMPLEMENTATION-GUIDE.md`) are in the attached archive. I can implement it myself once the issue is admitted. diff --git a/docs/design/600-settings-dialogs/OPEN-POINTS.md b/docs/design/600-settings-dialogs/OPEN-POINTS.md deleted file mode 100644 index f1e34133..00000000 --- a/docs/design/600-settings-dialogs/OPEN-POINTS.md +++ /dev/null @@ -1,35 +0,0 @@ -# Открытые точки для реализующей сессии Claude - -Это не вопросы владельцу репозитория. Это места, где прототип задевает не только разметку, и где реализующая сессия Claude должна **уточнить у JB в чате**, когда дойдёт до соответствующего диалога, а не решать сама и не спрашивать в issue. Каждый пункт: что неясно · что изменится от ответа · вариант по умолчанию, который предлагается JB. Пока ответа нет, держится текущее поведение продукта, а визуал адаптируется. Главное правило неизменно: меняем только UI форм, код продукта глобально не трогаем. - ---- - -**Q1. Save только при изменениях и подтверждение при закрытии.** Сейчас Save активен всегда (кроме пустого имени / невалидных полей), а X, Cancel и Escape закрывают диалог без вопроса. Прототип делает Save неактивным без изменений, показывает «Unsaved changes» и спрашивает Keep editing / Discard changes. Это новое поведение, а не только разметка. -Предлагаемый вариант: принять для всех четырёх диалогов; dirty считается сравнением черновика с исходным снимком в памяти, ничего не сохраняется. Если нет, оставляем текущую логику Save и закрытия, футер только перерисовывается. - -**Q2. Образцы карточки комнаты.** В Space settings (под слайдером размера шрифта) и в Room settings (внизу) сейчас есть образец карточки `_renderCardPreview`. В прототипе его нет. -Предлагаемый вариант: убрать оба образца. Если оставить, они встают в конец соответствующей карточки Font sizes / Room cards в стиле Display preview. - -**Q3. Display preview в диалоге устройства.** Прототип показывает упрощённый блок (макет маркера + четыре факта). В продукте `hp-device-preview` богаче: Now / Example, демонстрация активности, Source details, примечание о масштабе. -Предлагаемый вариант: оставить `hp-device-preview` целиком, только обернуть в блок на тинте; ничего не урезать. - -**Q4. Room: «As the space» тумблером.** Сейчас это первый пункт из шести радио. В прототипе тумблер «As the space», при выключении сегмент из пяти режимов. Значение то же (`_roomFill` = '' или режим), но при выключении тумблера нужно какое-то стартовое значение сегмента. -Предлагаемый вариант: стартовое значение = эффективный режим пространства (если у пространства Custom, выбирается Custom), пользователь меняет дальше. Альтернатива: один сегмент из шести с первым пунктом «As the space». - -**Q5. Поле цвета с системным пикером.** Прототип заменяет `hp-color-opacity` (панель HSB со слайдерами и OK) на inline-плашку с ``, hex и числом прозрачности. В компаньоне HA на iOS/Android это нативный лист ОС; в старых WebView может деградировать до текста (предусмотрен fallback hex-поле). -Предлагаемый вариант: новое поле только в четырёх диалогах настроек; редактор декора, summary и радар остаются на `hp-color-opacity`. Если нативный пикер неприемлем, то же inline-поле, но клик по свотчу открывает существующую панель `hp-color-opacity`. - -**Q6. Переименования подписей.** Прототип меняет тексты существующих ключей: `space.show_lqi` → «Zigbee signal next to devices», `space.show_names` без «(drag to move)», `room.area_label` без «(unassigned)», `marker.name_label` → «Name» (пояснение «Shown on the plan» уходит в подсказку), «Hide the decorative layer / Hide openings» → «Decorative layer / Doors, windows & gates» с логикой «включено = видно» только в UI. Заголовки карточек новые. -Предлагаемый вариант: принять; ключи `hide_decor` / `hide_openings` в конфиге не меняются. Переводы de/fr для новых и изменённых строк пишет исполнитель, владелец проверяет. - -**Q7. Ширина диалогов 560 px.** Сейчас `wide` = 500 px (в реальном HA `--ha-dialog-width-md`). Прототип рассчитан на 560. -Предлагаемый вариант: scoped-переменная 560 для четырёх `data-kind`, generic `wide` не трогается; проверка на настоящем `ha-dialog` по методике #505. - -**Q8. Мелкие условные скрытия в диалоге устройства.** Прототип прячет «Ask for confirmation» при действии «Do nothing» и переносит чекбокс «Show entities» внутрь панели выбора. -Предлагаемый вариант: принять оба (значения ключей не меняются). Если нет, тумблер показывается всегда, чекбокс остаётся рядом с сегментом. - -**Q9. Один issue или четыре.** Все четыре диалога на одной библиотеке примитивов; можно вести одним issue с четырьмя коммит-сериями или разбить на umbrella + 4 дочерних. -Предлагаемый вариант: один issue, порядок реализации Space → General → Room → Device, каждый диалог отдельным PR/коммит-серией с собственными парными скриншотами. - -**Q10. Место референса в репозитории.** По образцу #505: `docs/design/-settings-dialogs/reference/` + `README.md` с SHA-256 и `ACCEPTANCE.md`. -Предлагаемый вариант: так и сделать; в бандл ничего из референса не попадает. diff --git a/docs/design/600-settings-dialogs/README.md b/docs/design/600-settings-dialogs/README.md index df45f9ca..db396bb7 100644 --- a/docs/design/600-settings-dialogs/README.md +++ b/docs/design/600-settings-dialogs/README.md @@ -26,27 +26,19 @@ SHA-256 06f07fc212947ee9da1a39aaab2d14cbd41154cc9effe39bc48e1c2e1517255b |---|---| | `SPEC.md` | ТЗ автора на четыре диалога в структуре `PROCESS.md` §7.1: визуальная система §3.1, состав каждой карточки §4–§7, i18n §8, AC §11 | | `IMPLEMENTATION-GUIDE.md` | где что в `src/`, порядок работ, маппинг контрол → ключ состояния, ветки, которых прототип не показывает | -| `OPEN-POINTS.md` | десять мест, где прототип задевает поведение; решения владельца по каждому — в #600 | | `field-maps/FIELD-MAP-space.md` | карта полей Space settings: прототип → `SpaceDialogState` → ключ i18n → контрол | -| `ARCHIVE-README.md` | README автора из архива, как есть | -| `ISSUE-FORM.md` | текст, из которого заведён #591 | -| `reference/` | свёрстанный прототип всех четырёх форм | -| `screenshots/` | четыре формы целиком, 560 px, 2×, состояние по умолчанию | -| `pairs/` | кадры **продукта** для парного сравнения: четыре диалога × светлая и тёмная тема, 560 px; снимаются `node demo/capture_design_pairs_600.mjs` (см. `ACCEPTANCE.md`) | +| `ACCEPTANCE.md` | запись парной приёмки #600 против референса | -## Как открыть прототип - -``` -cd docs/design/600-settings-dialogs/reference -node serve.mjs # http://127.0.0.1:8135/ -``` - -`file://` не работает: прототип собран на ES-модулях. Переключатель «Form» в -верхней панели показывает четыре формы. Данные форм лежат в `localStorage` -браузера; для чистого состояния открывать в приватном окне. - -Windows-лаунчеры `START.cmd` и `START.ps1` из архива в репозиторий не -переносились — они ничего не добавляют к строке выше. +Код ссылается на пункты `SPEC.md` («§N SPEC.md»), поэтому ТЗ, руководство и +карта полей остаются. Свёрстанный прототип `reference/`, кадры референса +`screenshots/`, кадры продукта `pairs/`, а также `OPEN-POINTS.md` (решения +владельца — в #600), `ISSUE-FORM.md` (текст #591) и `ARCHIVE-README.md` удалены +из дерева в #681 после выпуска задачи; `SPEC.md`, `IMPLEMENTATION-GUIDE.md` и +`ACCEPTANCE.md` упоминают их как есть. Восстановить — из истории git: +`git checkout f989fbfe -- docs/design/600-settings-dialogs/reference` (прототип +открывался `node serve.mjs` → http://127.0.0.1:8135/, не через `file://`). +Новые парные кадры `node demo/capture_design_pairs_600.mjs` пишет в +игнорируемый `artifacts/design-pairs-600/`. ## Границы diff --git a/docs/design/600-settings-dialogs/pairs/device-dark.png b/docs/design/600-settings-dialogs/pairs/device-dark.png deleted file mode 100644 index 56b2a485..00000000 Binary files a/docs/design/600-settings-dialogs/pairs/device-dark.png and /dev/null differ diff --git a/docs/design/600-settings-dialogs/pairs/device-light.png b/docs/design/600-settings-dialogs/pairs/device-light.png deleted file mode 100644 index c8b6fd53..00000000 Binary files a/docs/design/600-settings-dialogs/pairs/device-light.png and /dev/null differ diff --git a/docs/design/600-settings-dialogs/pairs/general-dark.png b/docs/design/600-settings-dialogs/pairs/general-dark.png deleted file mode 100644 index 7e4901f5..00000000 Binary files a/docs/design/600-settings-dialogs/pairs/general-dark.png and /dev/null differ diff --git a/docs/design/600-settings-dialogs/pairs/general-light.png b/docs/design/600-settings-dialogs/pairs/general-light.png deleted file mode 100644 index b93b7db2..00000000 Binary files a/docs/design/600-settings-dialogs/pairs/general-light.png and /dev/null differ diff --git a/docs/design/600-settings-dialogs/pairs/room-dark.png b/docs/design/600-settings-dialogs/pairs/room-dark.png deleted file mode 100644 index 3c239c0a..00000000 Binary files a/docs/design/600-settings-dialogs/pairs/room-dark.png and /dev/null differ diff --git a/docs/design/600-settings-dialogs/pairs/room-ha-dark-text-200.png b/docs/design/600-settings-dialogs/pairs/room-ha-dark-text-200.png deleted file mode 100644 index 85580a12..00000000 Binary files a/docs/design/600-settings-dialogs/pairs/room-ha-dark-text-200.png and /dev/null differ diff --git a/docs/design/600-settings-dialogs/pairs/room-ha-light.png b/docs/design/600-settings-dialogs/pairs/room-ha-light.png deleted file mode 100644 index eefe69cc..00000000 Binary files a/docs/design/600-settings-dialogs/pairs/room-ha-light.png and /dev/null differ diff --git a/docs/design/600-settings-dialogs/pairs/room-ha-mobile-light.png b/docs/design/600-settings-dialogs/pairs/room-ha-mobile-light.png deleted file mode 100644 index 659c2c5f..00000000 Binary files a/docs/design/600-settings-dialogs/pairs/room-ha-mobile-light.png and /dev/null differ diff --git a/docs/design/600-settings-dialogs/pairs/room-light.png b/docs/design/600-settings-dialogs/pairs/room-light.png deleted file mode 100644 index 85348ed9..00000000 Binary files a/docs/design/600-settings-dialogs/pairs/room-light.png and /dev/null differ diff --git a/docs/design/600-settings-dialogs/pairs/space-dark.png b/docs/design/600-settings-dialogs/pairs/space-dark.png deleted file mode 100644 index 20c39f12..00000000 Binary files a/docs/design/600-settings-dialogs/pairs/space-dark.png and /dev/null differ diff --git a/docs/design/600-settings-dialogs/pairs/space-light.png b/docs/design/600-settings-dialogs/pairs/space-light.png deleted file mode 100644 index 8c8c446b..00000000 Binary files a/docs/design/600-settings-dialogs/pairs/space-light.png and /dev/null differ diff --git a/docs/design/600-settings-dialogs/reference/ROOM-FILL.md b/docs/design/600-settings-dialogs/reference/ROOM-FILL.md deleted file mode 100644 index 2c2b16eb..00000000 --- a/docs/design/600-settings-dialogs/reference/ROOM-FILL.md +++ /dev/null @@ -1,71 +0,0 @@ -# Проверка Room fill — 15.09.2026 - -Сверено с пользовательскими Custom color.png, Temperature.png и Lights.png, -а затем с исходным кодом House Plan, commit -`c22c04d6476d0d387c237b427d0b83e7c1e11f70`: - -- `src/logic.ts`: DEFAULT_FILL_COLORS, roomFillStyle, DEFAULT_CUSTOM_FILL. -- `src/space-render.ts`: resolvedRoomFills и glowBaseShapes. -- `docs/USER-GUIDE.ru.md`, раздел 13: заливки, наследование и Glow. - -## Исправления - -Раньше в прототипе были фиксированные пастельные цвета и opacity 85% у всех -режимов данных. Теперь `room-fill.js` повторяет выбор цвета и непрозрачности -оригинала; функция принимает палитру отдельно от настроек пространства. - -| Режим | Поведение | -| --- | --- | -| Custom | Фиксированный цвет и opacity. Смена данных датчиков его не меняет. Reset снимает локальный цвет и показывает DEFAULT_CUSTOM_FILL: #607D8B / 18%. | -| Zigbee | Средний LQI комнаты: линейное смешивание цвета и opacity от low при ≤40 до high при ≥180. Нет LQI — нет заливки данных. | -| Lights | Хотя бы один источник включён → light_on; все выключены → light_off; источников нет → light_none (по умолчанию opacity 0). | -| Temperature | Ниже min → cold; min ≤ температура ≤ max → comfort; выше max → hot. Нет температуры — нет заливки данных. | - -При отсутствии видимой заливки (включая Custom с opacity 0) включённый Glow -добавляет базовое затемнение `glow_base`. При видимой заливке дополнительного -затемнения нет. Световое пятно появляется только при включённом Glow и наличии -включённого источника. Солнечный свет независим. - -Температурные границы по умолчанию в исходнике — 20–25 °C. Уже сохранённые -границы прототипа не перезаписываются. Исходный согласованный Custom -Ground Floor (#EDEDED / 100%) сохранён. - -## Что можно установить по скриншотам - -- Серый фон в Custom не является обязательным цветом режима: его задаёт пользователь; - при прозрачной заливке тот же серый вид может давать Glow. -- Голубая Kitchen & Living в Temperature соответствует холодному состоянию - относительно настроенного диапазона. Цвет также можно переопределить в общей палитре. -- Off в Lights выбирает отдельный цвет выключенных источников; отсутствие источников - является другим состоянием. У комнаты может быть собственный режим заливки. - -Точную общую палитру, температурные границы, локальные переопределения комнат и -значение Glow нельзя восстановить из растровых скриншотов. Прототип использует -стандартную палитру исходника, поэтому оттенки могут отличаться от конкретной -конфигурации пользователя. При интеграции необходимо передавать результат -`fillColorsOf(settings)` и вычислять данные комнаты существующими функциями продукта. - -## Данные демо и проверка - -В верхней панели Sample data выбираются Typical room, Lights off, Cold room, -Hot room, Weak Zigbee, Strong Zigbee и No room data. Меняются одновременно -данные заливки, значения комнатной карточки, LQI устройства и наличие светового -пятна. Эти примеры не сохраняются в настройках и не делают форму изменённой. -Контрол виден только у Space settings и при видимом правом Preview. - -Проверки без зависимостей: - -```powershell -node room-fill.test.mjs -``` - -Дополнительная сверка с локальным исходником: - -```powershell -node room-fill.test.mjs ../houseplan-dev-source-review/src/logic.ts -``` - -Вторая команда извлекает чистую функцию оригинала и сравнивает 1008 сочетаний: -4 режима, 7 LQI, 3 состояния света, 6 температур, 2 палитры. Проверка custom -использует уже валидные цвета, как поля input type=color прототипа. Это проверка -выбора заливки, а не сбора HA-данных или полного рендера плана. diff --git a/docs/design/600-settings-dialogs/reference/app.js b/docs/design/600-settings-dialogs/reference/app.js deleted file mode 100644 index c2e31d04..00000000 --- a/docs/design/600-settings-dialogs/reference/app.js +++ /dev/null @@ -1,316 +0,0 @@ -/* Standalone design prototype. All data stays in this origin's localStorage. - The field map for the product (SpaceDialogState, i18n keys, controls) is docs/FIELD-MAP.md in the handoff archive. */ -import {registerDemoForm, openDemoForm, notifyDemoFormClosed} from './demo-shell.js'; -import {DEFAULT_CUSTOM_FILL} from './room-fill.js'; -import {$, $$, escape, clone, icon, hydrateIcons, showToast, attachHelp, closeHelp, helpOpen} from './ui.js'; -const STORAGE_KEY = 'houseplan-space-settings-design-v1'; -const base = { - title:'Ground Floor', source:'draw', planName:'', planData:'', cellCm:5, - showBorders:true, zeroWallStyle:'dashed', showNames:true, showLqi:false, - hideDecor:false, hideOpenings:false, labelTemp:true, labelHum:true, labelLqi:true, labelLight:true, - cardFontScale:100, roomColor:'#52636b', roomOpacity:85, - fillMode:'custom', fillColor:DEFAULT_CUSTOM_FILL.c, fillOpacity:DEFAULT_CUSTOM_FILL.a*100, customFillSet:false, - tempMin:20, tempMax:25, bgMode:'daynight', bgColor:'#edf1f2', bgColorSet:false, - northDeg:null, sunRays:'inherit', glowEnabled:true, -}; -const initialSpaces = [ - // Keep the approved visual example reproducible in a fresh browser. - {id:'ground-floor', devices:50, settings:{...clone(base),zeroWallStyle:'solid',showLqi:true,roomColor:'#3b4245',fillColor:'#ededed',fillOpacity:100,customFillSet:true}}, - {id:'first-floor', devices:0, settings:{...clone(base),title:'First Floor',source:'file',planName:'first-floor.svg',planData:'assets/sample-plan.svg'}}, -]; -function loadSpaces() { - try { - const data = JSON.parse(localStorage.getItem(STORAGE_KEY)); - if (!Array.isArray(data) || !data.length) return clone(initialSpaces); - const valid = data.every(s => typeof s.id === 'string' && Number.isInteger(s.devices) && s.settings && - Object.entries(base).every(([k,v]) => k === 'northDeg' ? s.settings[k] === null || Number.isFinite(s.settings[k]) : typeof s.settings[k] === typeof v)); - return valid ? data : clone(initialSpaces); - } catch { return clone(initialSpaces); } -} -let spaces = loadSpaces(); -let activeId = spaces[0].id; -let saved = clone(spaces[0].settings); -let draft = clone(saved); -const customFill = () => draft.customFillSet?{c:draft.fillColor,a:Number(draft.fillOpacity)/100}:DEFAULT_CUSTOM_FILL; -const currentScroller = () => $('#dialog-scroll'); -let lastSubTrigger; -let closeDecision; -const dialog = $('#settings'); -const content = $('#settings-content'); -const subDialog = $('#sub-dialog'); -const dirty = () => JSON.stringify(draft) !== JSON.stringify(saved); -const activeSpace = () => spaces.find(s => s.id === activeId); -function errorFor(key) { - if(key === 'title' && !draft.title.trim()) return 'Enter a name for this space.'; - if(key === 'cellCm' && (!Number.isFinite(Number(draft.cellCm)) || draft.cellCm === '' || Number(draft.cellCm)<.1 || Number(draft.cellCm)>1000)) return 'Enter a grid size between 0.1 and 1000 cm.'; - if(key === 'source' && draft.source === 'file' && !draft.planData) return 'Choose a floor-plan image to continue.'; - if(key === 'tempMax' && draft.fillMode === 'temp' && (draft.tempMin === '' || draft.tempMax === '' || !Number.isFinite(Number(draft.tempMin)) || !Number.isFinite(Number(draft.tempMax)) || Number(draft.tempMin)>=Number(draft.tempMax))) return 'The maximum must be higher than the minimum.'; - if(key === 'northDeg' && draft.northDeg !== null && (draft.northDeg === '' || !Number.isInteger(Number(draft.northDeg)) || Number(draft.northDeg)<0 || Number(draft.northDeg)>359)) return 'Enter a whole number between 0° and 359°.'; - if(key === 'cardFontScale' && (draft.cardFontScale === '' || draft.cardFontScale<50 || draft.cardFontScale>300)) return 'Choose a size between 50% and 300%.'; - if((key==='roomOpacity' || (key==='fillOpacity' && draft.fillMode==='custom')) && (draft[key] === '' || draft[key]<0 || draft[key]>100)) return 'Enter an opacity between 0% and 100%.'; - return ''; -} -const errorFields = ['title','cellCm','source','tempMax','northDeg','cardFontScale','roomOpacity','fillOpacity']; -const allErrors = () => errorFields.map(key => [key,errorFor(key)]).filter(([,message]) => message); -const errorSlot = key => `
${errorFor(key)}
`; -function textField(key, label, options = {}) { - return `
${options.hint ? `

${options.hint}

` : ''}${errorSlot(key)}
`; -} -const card = (id, title, body, extra='') => `

${title}

${extra}
${body}
`; -const hint = text => `

${text}

`; -const info = (text, warning=false) => `
${icon('info')}

${text}

`; -const helpCopy = { - scale:['Grid cell size','The grid sets real-world lengths, areas, wall thickness and opening sizes. Changing it recalculates dimensions for the whole space without moving points on the plan.'], - floorPlan:['Floor plan','Use an image as a background to draw over. Rooms and devices stay editable on top of it. Switching to drawing by hand keeps the uploaded image available to use again.'], - borders:['Always show room borders','When off, patterned wall outlines and zero-thickness wall lines are hidden. Plain, light wall bands stay visible, so rooms remain separated. Doors, windows, room fills and lighting keep working.'], - walls:['Zero-thickness walls','Dashed zero-thickness walls let lamp glow and sunlight pass through. Solid ones block both, even when their lines are hidden on the plan.'], - fill:['Room fill','Custom uses a fixed color and opacity. Zigbee uses average signal; Lights distinguishes any on, all off and no sources; Temperature uses the comfort range. State colors and opacity come from general settings. Room overrides take priority. If no visible fill is available, enabled light-source glow adds base shading. Sunlight and glow remain separate effects.'], - layers:['Visible layers','Hiding changes only what you see on the plan. Decorative elements remain available in the background editor. Doors and windows stay in the plan editor, while their sensors, sunlight and lamp glow keep working.'], - names:['Show room names','Hiding room names also hides their values without clearing your selections. Room-card positions stay where you placed them.'], - cardSize:['Room-card font size','100% is the default size. This scales every room card in this space while keeping individual card sizes and positions. Changes apply when you save.'], - north:['North on the plan','North is measured clockwise from the top of the plan: 0° is up, 90° is right. Use general settings follows the home-wide value; a custom direction overrides it only for this space. Your plan does not rotate.'], - light:['Light-source glow','Lamp glow and sunlight are independent. Hiding opening symbols does not stop either effect. Window glass still lets sunlight in when the window is closed.'], -}; -const help = attachHelp($('#settings-content'), $('#dialog-scroll'), helpCopy); -const helpButton = key => help.button(key); -const fieldHeading = (text,help,forId='') => `
${helpButton(help)}
`; -const subsectionHeading = (text,help) => `

${text}

${helpButton(help)}
`; -const northNeedle = ''; -function toggle(key, title, description, name, invert=false, disabled=false, help='') { - const checked = invert ? !draft[key] : draft[key]; - return `
${icon(name)}
${help?helpButton(help):''}
${description?`${description}`:''}
`; -} -/* Compact density: one-line rows for uniform on/off lists. The group heading carries the shared explanation. */ -function compactToggle(key, title, name, invert=false, disabled=false) { - const checked = invert ? !draft[key] : draft[key]; - return `
${icon(name)}
`; -} -const compactList = rows => `
${rows.join('')}
`; -/* Icon tiles: a multi-select panel for the values shown on a room card. Pressed tiles use the segmented-control tint. */ -function valueTiles(items, label, disabled=false) { - return `
${items.map(([key,title,name,full])=>``).join('')}
`; -} -function select(key, label, values, note='') { - return `
${note?hint(note):''}
`; -} -function segmented(key, values, label) { - return `
${values.map(([value,title,decoration])=>``).join('')}
`; -} -function colorField(key, opacityKey, label, reset='') { - const color=key==='fillColor'?customFill().c:draft[key]; - const opacity=key==='fillColor'?customFill().a*100:draft[opacityKey]; - return `
${color.toUpperCase()}${opacityKey?``:''}${reset?``:''}
${opacityKey?errorSlot(opacityKey):''}
`; -} -function generalSection() { - const upload = draft.source === 'file' ? `
${draft.planData ? `
Selected floor-plan image
${escape(draft.planName)}Ready to use
` : `
${icon('upload')}

Drop your floor plan here

SVG, PNG, JPG or WebP · up to 2 MB in this preview
`}
${errorSlot('source')}` : ''; - const scale = `
${fieldHeading('Grid cell size','scale','field-cellCm')}
cm / cell
${errorSlot('cellCm')}

Scale of the plan.

`; - return card('general','Basics', - `
${textField('title','Space name',{attributes:'maxlength="80" autocomplete="off"',hint:'Shown in the space tabs.'})}${scale}
`+ - `${draft.cellCm!==saved.cellCm?info('The scale has changed. Calculated dimensions for this whole space will change when you save.',true):''}`+ - `
${subsectionHeading('Floor plan','floorPlan')}
${upload}${draft.source==='draw'&&draft.planData?info('Your uploaded image is kept. Switch back to use it again.'):''}
` - ); -} -function appearanceSection() { - const fillOptions = [['custom','Custom'],['lqi','Zigbee'],['light','Lights'],['temp','Temperature']]; - const fillDetail = draft.fillMode==='custom' ? colorField('fillColor','fillOpacity','Fill color','reset-fill') : draft.fillMode==='temp' ? `
Comfort range
${errorSlot('tempMax')}
ColdComfortHot
${hint('Below the minimum: cold. Within the range: comfort. Above the maximum: hot. Without a temperature reading, light-source glow can still add base shading.')}
` : hint(draft.fillMode==='lqi'?'Uses average room LQI: low at 40 or below, high at 180 or above. Colors and opacity follow the general palette.':'Uses separate colors for any light on, all lights off, and no light sources. Colors and opacity follow the general palette.'); - return card('appearance','Appearance', - toggle('showBorders','Always show room borders','Show patterned outlines around the walls.','borders',false,false,'borders')+ - `
${fieldHeading('Zero-thickness walls','walls')}${segmented('zeroWallStyle',[['dashed','Dashed',''],['solid','Solid','']],'Zero-thickness walls')}
`+ - colorField('roomColor','roomOpacity','Border & name color','reset-room')+ - `
${subsectionHeading('Room fill','fill')}${segmented('fillMode',fillOptions,'Room fill')}${fillDetail}`+`
`+ - `
${subsectionHeading('Visible layers','layers')}${compactList([compactToggle('hideDecor','Decorative layer','sofa',true),compactToggle('hideOpenings','Doors, windows & gates','door',true),compactToggle('showLqi','Zigbee signal next to devices','zigbee')])}`+`
` - ); -} -function cardsSection() { - return card('cards','Room cards', - toggle('showNames','Show room names','Display room names and their selected values.','type',false,false,'names')+ - `
Values on the card${valueTiles([['labelTemp','Temperature','temperature'],['labelHum','Humidity','humidity'],['labelLqi','Zigbee signal','zigbee','Average Zigbee signal'],['labelLight','Lights on / off','bulb']],'Values on the card',!draft.showNames)}${!draft.showNames?`
${icon('eyeOff')}Values are saved but hidden. to display them.
`:''}
`+ - `
${helpButton('cardSize')}
50%300%
${errorSlot('cardFontScale')}${hint('Scales every room card in this space.')}`+`
`, - '' - ); -} -function lightSection() { - return card('light','Sun & light', - `
Plan background${segmented('bgMode',[['inherit','General settings'],['static','Static color'],['daynight','Follows the Sun']],'Plan background')}
`+ - (draft.bgMode==='static' ? colorField('bgColor',null,'Background color','reset-background') : '')+ - `
Sunlight through windows
${[['inherit','Use general settings'],['on','On'],['off','Off']].map(([v,t])=>``).join('')}
`+ - `
${fieldHeading('North on the plan','north','north-mode')}
${draft.northDeg!==null?`
°
${errorSlot('northDeg')}
`:''}`+`
`+ - `
${toggle('glowEnabled','Light-source glow','Show a soft glow around lights that are on.','bulb',false,false,'light')}`+`
` - ); -} -function renderMain() { - closeHelp(); - $('#settings-content').innerHTML = generalSection()+appearanceSection()+cardsSection()+lightSection(); -} -function render() { renderMain(); updateChrome(); } - -function updateChrome() { - $('#space-crumb').textContent = draft.title.trim() || 'Untitled space'; - $('#save-settings').disabled = !dirty() || allErrors().length > 0; - $('#save-status').classList.toggle('dirty',dirty()); - $('#save-status').textContent = dirty() ? 'Unsaved changes' : ''; - const errors=allErrors(); - $('#save-status').hidden = !dirty() && !errors.length; - if(errors.length)$('#save-status').innerHTML=``; - errorFields.forEach(key => { - const el = $(`#error-${key}`), message = errorFor(key); - if(el){el.hidden = !message;el.textContent = message;} - $$(`[data-field="${key}"]`,content).forEach(input => input.setAttribute('aria-invalid',String(!!message))); - }); - $$('[data-hex]',content).forEach(el => {el.textContent=(el.dataset.hex==='fillColor'?customFill().c:draft[el.dataset.hex]).toUpperCase();}); - if($('#dial-arrow'))$('#dial-arrow').style.transform=`rotate(${draft.northDeg??165}deg)`; -} -function updateField(el) { - const key=el.dataset.field; - if(!key)return; - let value=el.type==='checkbox'?(el.dataset.invert?!el.checked:el.checked):el.type==='number'||el.type==='range'?(el.value===''?'':Number(el.value)):el.value; - if(['fillColor','fillOpacity'].includes(key)&&!draft.customFillSet){draft.fillColor=DEFAULT_CUSTOM_FILL.c;draft.fillOpacity=DEFAULT_CUSTOM_FILL.a*100;} - draft[key]=value; - if(['fillColor','fillOpacity'].includes(key))draft.customFillSet=true; - if(key==='bgColor')draft.bgColorSet=true; - if(key==='cardFontScale')$$('[data-field="cardFontScale"]',content).filter(x=>x!==el).forEach(x=>{x.value=value;}); - if(['source','fillMode','bgMode','showNames','zeroWallStyle'].includes(key)) { - const scroll=currentScroller().scrollTop; - renderMain();currentScroller().scrollTop=scroll; - const replacement=$(`[data-field="${key}"]${el.type==='radio'?`:checked`:''}`,content);replacement?.focus({preventScroll:true}); - } - updateChrome(); -} -function renderPicker() { - $('#space-picker').innerHTML=spaces.map(s=>``).join(''); -} -function openSettings() { - saved=clone(activeSpace().settings);draft=clone(saved);render(); - if(!dialog.open)dialog.show(); - currentScroller().scrollTop=0; - $('#dialog-title').focus({preventScroll:true}); -} -function closeSettings() { - dialog.close();renderPicker();notifyDemoFormClosed('space-settings'); -} -function showSub(title, content, actions) { - lastSubTrigger=document.activeElement; - subDialog.innerHTML=`

${title}

${content}
${actions}
`; - subDialog.showModal(); -} -function closeSub() { - subDialog.close();lastSubTrigger?.focus({preventScroll:true}); - if(closeDecision){const resolve=closeDecision;closeDecision=undefined;resolve(false);} -} -function requestClose() { - if(!dirty()){closeSettings();return Promise.resolve(true);} - showSub('Discard your changes?',`

Your changes to ${escape(saved.title)} haven’t been saved.

`,``); - $('[data-sub="close"].primary',subDialog).focus(); - return new Promise(resolve=>{closeDecision=resolve;}); -} -function persist(nextSpaces) { - try {localStorage.setItem(STORAGE_KEY,JSON.stringify(nextSpaces));return true;} - catch {showToast('Could not save locally. Try a smaller image or enable browser storage.');return false;} -} -function saveSettings() { - if(!dirty()||allErrors().length)return; - const normalized={...clone(draft),title:draft.title.trim()}; - const next=spaces.map(s=>s.id===activeId?{...s,settings:normalized}:s); - if(!persist(next))return; - spaces=next;saved=clone(normalized);draft=clone(saved);closeSettings(); - showToast('Space settings saved in this browser.'); -} -function openGallery() { - showSub('Uploaded floor plans','

Choose an image for this space.

',''); -} -function readFile(file) { - if(!file)return; - if(!/\.(svg|png|jpe?g|webp)$/i.test(file.name)||!['image/svg+xml','image/png','image/jpeg','image/webp'].includes(file.type)){showToast('Choose an SVG, PNG, JPG or WebP image.');return;} - if(file.size>2*1024*1024){showToast('Choose an image smaller than 2 MB for this preview.');return;} - const reader=new FileReader(); - reader.onload=()=>{ - const image=new Image(); - image.onload=()=>{draft.planData=String(reader.result);draft.planName=file.name;renderMain();updateChrome();}; - image.onerror=()=>showToast('This image could not be opened. Choose another file.'); - image.src=String(reader.result); - }; - reader.onerror=()=>showToast('Could not read the image. Please try again.');reader.readAsDataURL(file); -} -function openCopy() { - let n=2;while(spaces.some(s=>s.settings.title===`${saved.title} (${n})`))n++; - showSub('Copy space',`

Start a new space with the saved layout and display settings. Rooms and device placements are not copied.

This preview creates a local settings copy. The production action also copies the plan geometry.

${dirty()?info('The copy will use saved settings. Your unsaved changes stay in the original draft.'):''}`,``); - $('#copy-name').focus();$('#copy-name').select(); -} -function openDelete() { - if(activeSpace().devices>0&&spaces.length>1){ - showSub('This space still has devices',`

${activeSpace().devices} devices are placed in ${escape(saved.title)}. Move or remove them before deleting this space.

`,``);return; - } - showSub('Delete this space?',`

Delete ${escape(saved.title)} from this design preview? This removes its locally saved settings.

`,``); - $('[data-sub="close"].button',subDialog).focus(); -} -function handleAction(action) { - if(action==='review-errors'){ - const first=allErrors()[0];if(!first)return; - const field=$(`[data-field="${first[0]}"]`,content);field?.scrollIntoView({block:'center'});field?.focus({preventScroll:true});return; - } - if(action==='upload'){$('#plan-upload').click();return;} - if(action==='gallery'){openGallery();return;} - if(action==='reset-fill'){draft.fillColor=base.fillColor;draft.fillOpacity=base.fillOpacity;draft.customFillSet=false;} - if(action==='reset-background'){draft.bgColor=base.bgColor;draft.bgColorSet=false;} - if(action==='reset-room'){draft.roomColor=base.roomColor;draft.roomOpacity=base.roomOpacity;} - if(action==='reset-font')draft.cardFontScale=100; - if(action==='show-cards')draft.showNames=true; - if(action==='inherit-north')draft.northDeg=null; - const scroll=currentScroller().scrollTop;renderMain();updateChrome();currentScroller().scrollTop=scroll; -} -document.addEventListener('click',e=>{ - const target=e.target.closest('button'); - if(target?.dataset.space){activeId=target.dataset.space;renderPicker();} - if(target?.dataset.action)handleAction(target.dataset.action); -}); -$('#settings-content').addEventListener('input',e=>{ - if(['checkbox','radio','file'].includes(e.target.type)||e.target.tagName==='SELECT')return; - updateField(e.target); -}); -$('#settings-content').addEventListener('change',e=>{ - if(e.target.id==='plan-upload'){readFile(e.target.files[0]);return;} - if(e.target.id==='north-mode'){draft.northDeg=e.target.value==='inherit'?null:165;renderMain();updateChrome();return;} - if(['checkbox','radio'].includes(e.target.type)||e.target.tagName==='SELECT')updateField(e.target); - if(e.target.dataset.field==='cellCm'){const scroll=currentScroller().scrollTop;renderMain();updateChrome();currentScroller().scrollTop=scroll;} -}); -$('#settings-content').addEventListener('dragover',e=>{const zone=e.target.closest('#upload-panel');if(zone){e.preventDefault();zone.classList.add('dragging');}}); -$('#settings-content').addEventListener('dragleave',e=>{e.target.closest('#upload-panel')?.classList.remove('dragging');}); -$('#settings-content').addEventListener('drop',e=>{const zone=e.target.closest('#upload-panel');if(zone){e.preventDefault();zone.classList.remove('dragging');readFile(e.dataTransfer.files[0]);}}); -$('#close-settings').addEventListener('click',requestClose); -$('#cancel-settings').addEventListener('click',requestClose); -$('#save-settings').addEventListener('click',saveSettings); -dialog.addEventListener('cancel',e=>{e.preventDefault();requestClose();}); -dialog.addEventListener('keydown',e=>{ - if(e.key==='Escape'&&helpOpen()){e.preventDefault();e.stopPropagation();closeHelp();return;} - if(e.key==='Escape'&&!subDialog.open){e.preventDefault();e.stopPropagation();requestClose();} -}); -$('#copy-space').addEventListener('click',openCopy); -$('#delete-space').addEventListener('click',openDelete); -subDialog.addEventListener('cancel',e=>{e.preventDefault();closeSub();}); -subDialog.addEventListener('input',e=>{if(e.target.id==='copy-name')$('[data-sub="confirm-copy"]').disabled=!e.target.value.trim();}); -subDialog.addEventListener('click',e=>{ - const button=e.target.closest('button[data-sub]');if(!button)return; - const action=button.dataset.sub; - if(action==='close'){closeSub();return;} - if(action==='discard'){ - const resolve=closeDecision;closeDecision=undefined; - draft=clone(saved);closeSub();closeSettings();resolve?.(true);return; - } - if(action==='choose-image'){draft.source='file';draft.planName=button.dataset.name;draft.planData='assets/sample-plan.svg';closeSub();renderMain();updateChrome();return;} - if(action==='confirm-copy'){ - const title=$('#copy-name').value.trim();if(!title)return; - const copy={id:crypto.randomUUID(),devices:0,settings:{...clone(saved),title}}; - const next=[...spaces,copy];if(!persist(next))return; - spaces=next;closeSub();renderPicker();showToast(`“${title}” created. Open it from the space picker.`);return; - } - if(action==='confirm-delete'){ - let next=spaces.filter(s=>s.id!==activeId); - if(!next.length)next=[{id:'new-space',devices:0,settings:{...clone(base),title:'New space'}}]; - if(!persist(next))return; - spaces=next;activeId=next[0].id;closeSub();closeSettings();showToast('Space deleted from this preview.'); - } -}); -window.addEventListener('beforeunload',e=>{if(dialog.open&&dirty()){e.preventDefault();e.returnValue='';}}); -hydrateIcons();renderPicker(); -registerDemoForm({id:'space-settings',title:'Space settings',description:'Layout, appearance, room cards and lighting for a space.',galleryControls:$('#space-examples'),open:openSettings,requestClose}); -openDemoForm('space-settings'); diff --git a/docs/design/600-settings-dialogs/reference/assets/mark.svg b/docs/design/600-settings-dialogs/reference/assets/mark.svg deleted file mode 100644 index 201f1c12..00000000 --- a/docs/design/600-settings-dialogs/reference/assets/mark.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/docs/design/600-settings-dialogs/reference/assets/sample-plan.svg b/docs/design/600-settings-dialogs/reference/assets/sample-plan.svg deleted file mode 100644 index ff24a5b3..00000000 --- a/docs/design/600-settings-dialogs/reference/assets/sample-plan.svg +++ /dev/null @@ -1 +0,0 @@ -BedroomBathroomStudyHallBedroomLiving room diff --git a/docs/design/600-settings-dialogs/reference/assets/select-chevron.svg b/docs/design/600-settings-dialogs/reference/assets/select-chevron.svg deleted file mode 100644 index 35fe3308..00000000 --- a/docs/design/600-settings-dialogs/reference/assets/select-chevron.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/docs/design/600-settings-dialogs/reference/assets/zigbee-glyph.svg b/docs/design/600-settings-dialogs/reference/assets/zigbee-glyph.svg deleted file mode 100644 index 14a97640..00000000 --- a/docs/design/600-settings-dialogs/reference/assets/zigbee-glyph.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/docs/design/600-settings-dialogs/reference/assets/zigbee-lqi-active.svg b/docs/design/600-settings-dialogs/reference/assets/zigbee-lqi-active.svg deleted file mode 100644 index a4cfcfe6..00000000 --- a/docs/design/600-settings-dialogs/reference/assets/zigbee-lqi-active.svg +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/docs/design/600-settings-dialogs/reference/assets/zigbee-lqi-default.svg b/docs/design/600-settings-dialogs/reference/assets/zigbee-lqi-default.svg deleted file mode 100644 index a587de21..00000000 --- a/docs/design/600-settings-dialogs/reference/assets/zigbee-lqi-default.svg +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/docs/design/600-settings-dialogs/reference/assets/zigbee-lqi-hover.svg b/docs/design/600-settings-dialogs/reference/assets/zigbee-lqi-hover.svg deleted file mode 100644 index 84cc3ace..00000000 --- a/docs/design/600-settings-dialogs/reference/assets/zigbee-lqi-hover.svg +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/docs/design/600-settings-dialogs/reference/demo-shell.js b/docs/design/600-settings-dialogs/reference/demo-shell.js deleted file mode 100644 index 2d7c0132..00000000 --- a/docs/design/600-settings-dialogs/reference/demo-shell.js +++ /dev/null @@ -1,66 +0,0 @@ -/* Demo navigation belongs to the gallery, not to the production settings form. */ -const forms = new Map(); -const selector = document.querySelector('#demo-form-select'); -const gallery = document.querySelector('#launch-screen'); -const list = document.querySelector('#demo-form-list'); -let activeForm = null; -let switching = false; - -export function registerDemoForm(form) { - if (forms.has(form.id)) throw new Error(`Duplicate demo form: ${form.id}`); - forms.set(form.id, form); - const option = new Option(form.title, form.id); - selector.add(option); - - const card = document.createElement('article'); - card.className = 'demo-form-card'; - const heading = document.createElement('h2'); - heading.textContent = form.title; - const description = document.createElement('p'); - description.textContent = form.description; - const button = document.createElement('button'); - button.className = 'button primary'; - button.type = 'button'; - button.textContent = 'Open preview'; - button.setAttribute('aria-label', `Open ${form.title} preview`); - button.addEventListener('click', () => openDemoForm(form.id)); - card.append(heading, description); - if (form.galleryControls) { - form.galleryControls.hidden = false; - card.append(form.galleryControls); - } - card.append(button); - list.append(card); -} - -export async function openDemoForm(id) { - if (switching || id === activeForm) return; - if (id && !forms.has(id)) throw new Error(`Unknown demo form: ${id}`); - // Keep showing the current selection until the form agrees to close. - selector.value = activeForm || ''; - switching = true; - try { - if (activeForm && !(await forms.get(activeForm).requestClose())) return; - if (!id) return; - activeForm = id; - selector.value = id; - gallery.hidden = true; - document.body.dataset.demoForm = id; - document.title = `${forms.get(id).title} · House Plan previews`; - forms.get(id).open(); - } finally { - switching = false; - } -} - -export function notifyDemoFormClosed(id) { - if (activeForm !== id) return; - activeForm = null; - selector.value = ''; - gallery.hidden = false; - delete document.body.dataset.demoForm; - document.title = 'Form previews · House Plan'; - selector.focus({preventScroll:true}); -} - -selector.addEventListener('change', () => openDemoForm(selector.value)); diff --git a/docs/design/600-settings-dialogs/reference/device-settings.js b/docs/design/600-settings-dialogs/reference/device-settings.js deleted file mode 100644 index 9ffeb585..00000000 --- a/docs/design/600-settings-dialogs/reference/device-settings.js +++ /dev/null @@ -1,253 +0,0 @@ -/* Device on the plan: design prototype of the marker dialog (edit mode). - Same visual system as the other forms. Field keys mirror the product's marker dialog state - (name, bindingMode, binding, showEntities, room, tap, tapConfirm, controls, lightRole, glowMode, glowColor, - glowBrightness, glowRadius, icon, iconPinned, display, badge*, pulse*, iconScale, rotate, model, link, description, pdfs, isRadar). */ -import {registerDemoForm, notifyDemoFormClosed} from './demo-shell.js'; -import {$, $$, escape, clone, icon, hydrateIcons, showToast, attachHelp, closeHelp, helpOpen} from './ui.js'; - -const STORAGE_KEY = 'houseplan-device-settings-design-v1'; -const HA_LIST = [ - {value:'device:wall-lamp-3', label:'Wall Lamp 3', sub:'Philips Hue · device', entity:false}, - {value:'entity:light.wall_lamp_3', label:'Wall Lamp 3', sub:'entity:light.wall_lamp_3', entity:true}, - {value:'device:ceiling-spots', label:'Ceiling spots', sub:'Philips Hue · device', entity:false}, - {value:'entity:light.ceiling_spots', label:'Ceiling spots', sub:'entity:light.ceiling_spots', entity:true}, - {value:'device:aqara-th-1', label:'Kitchen climate sensor', sub:'Aqara TH · device', entity:false}, - {value:'entity:sensor.kitchen_temperature', label:'Kitchen temperature', sub:'entity:sensor.kitchen_temperature', entity:true}, -]; -const ROOMS = [['', '— by device area (auto) —'],['ground:kitchen','Ground Floor · Kitchen & Living'],['ground:hallway','Ground Floor · Hallway'],['first:bedroom','First Floor · Bedroom']]; -const LIGHTS = [{id:'light.ceiling_spots',label:'Ceiling spots',sub:'light.ceiling_spots'},{id:'light.floor_lamp',label:'Floor lamp',sub:'light.floor_lamp'},{id:'switch.hallway',label:'Hallway switch',sub:'switch.hallway'},{id:'light.led_strip',label:'LED strip',sub:'light.led_strip'}]; -const TAPS = [['info','Device card'],['more_info','HA more-info dialog'],['toggle','Toggle state'],['run','Run automation/script/scene'],['none','Do nothing']]; -const DISPLAYS = [['badge','Icon + state','The icon and dynamic plate show device state without the ordinary activity pulse. Red alarms remain visible.'],['icon_ripple','Icon + state and activity','The icon, dynamic plate and pulse show a short pulse for events and a continuous pulse for work, motion or presence. Red alarms have priority.'],['value','Value + state','A selected or automatically resolved Home Assistant value replaces the icon while the plate continues to show state. Red alarms remain visible.'],['static_icon','Always static icon','The theme-aware shell and icon always stay the same. State, activity, unavailability and alarms do not change the face.'],['value_static','Value + static icon','A selected value replaces the icon on a static shell.']]; -const BADGE_SOURCES = [['state','State · Wall Lamp 3 · Off','light.wall_lamp_3'],['brightness','Brightness · Wall Lamp 3','light.wall_lamp_3 · brightness'],['lqi','Average Zigbee signal quality','']]; -const GENERAL_GLOW_RADIUS = 3; - -const defaults = { - name:'Wall Lamp 3', bindingMode:'ha', binding:'entity:light.wall_lamp_3', showEntities:true, room:'ground:kitchen', - tap:'toggle', tapConfirm:false, controls:[], - lightRole:'auto', glowMode:'auto', glowColor:'#ffd9a0', glowBrightness:100, glowRadius:'1.5', - icon:'', iconPinned:false, display:'badge', badgeEnabled:false, badgeSource:'state', badgePosition:'top', - pulseColor:'#ffd45c', pulseSize:100, iconScale:1, rotate:0, - model:'', link:'', description:'', pdfs:[], isRadar:false, -}; -function load() { - try { const d = JSON.parse(localStorage.getItem(STORAGE_KEY)); return d && Object.keys(defaults).every(k => k in d) ? d : clone(defaults); } - catch { return clone(defaults); } -} -let saved = load(), draft = clone(saved); -let pickOpen = false, pickFilter = '', ctrlFilter = '', ctrlOpen = false; -const dirty = () => JSON.stringify(draft) !== JSON.stringify(saved); - -/* Dialog shell. */ -const dialog = document.createElement('dialog'); -dialog.id = 'device-settings'; dialog.className = 'settings-dialog'; dialog.setAttribute('aria-labelledby', 'ds-title'); -dialog.innerHTML = ` -
- -

Device on the plan

- Kitchen & Living -
-
-
- - - -
`; -$('#demo-stage').append(dialog); -const subDialog = document.createElement('dialog'); -subDialog.id = 'ds-sub-dialog'; subDialog.className = 'sub-dialog'; subDialog.setAttribute('aria-labelledby', 'ds-sub-title'); -document.body.append(subDialog); -const content = $('#ds-content'), scroller = $('#ds-scroll'); - -const helpCopy = { - binding:['Bind to an HA device','A bound marker shows the live state of a Home Assistant device or entity and can act on it. A virtual device is a plain marker with no binding.'], - controls:['Controls other light sources','With the “Toggle state” action, the listed sources toggle together with this marker. Their glow stays at their own markers, and the room counts them as usual.'], - lightRole:['Is this device a light source?','“Auto” uses the bound device’s resolved role, “Always” forces its own source and “Never” excludes it; linked lights above remain independent.'], - glowMode:['Glow colour and brightness','Use live source values, override only its colour, or fix both colour and brightness. The minimum is 1%; to disable the source, choose “Never a light source”.'], - glowRadius:['Glow radius','Sets the glow radius in metres or feet; an empty value uses the radius from general settings.'], - icon:['Icon','Empty means automatic: the icon comes from the icon rules for this device. Pick one to override, or pin the automatic choice.'], - display:['Display','How the marker face reacts to state and activity. The value modes replace the icon with a Home Assistant value.'], - badge:['Value badge','Shows one selected value next to the icon. It does not affect room metrics, light, Glow or the tap action.'], - badgeSource:['Value','Choose a specific state, supported attribute, or derived value of this device.'], - badgePosition:['Position','The chosen side stays fixed and does not flip automatically at a plan edge.'], - size:['Icon size and rotation','Scales and rotates only this marker on the plan. The tap area grows with the icon.'], -}; -const help = attachHelp(content, scroller, helpCopy); - -/* Rendering helpers. */ -const card = (id, title, body, extra='') => `

${title}

${extra}
${body}
`; -const hint = text => `

${text}

`; -const callout = (text, warning=false) => `
${icon('info')}

${text}

`; -const fieldHeading = (text, key, forId='') => `
${key?help.button(key):''}
`; -const subsectionHeading = (text, key='') => `

${text}

${key?help.button(key):''}
`; -function segmented(key, values, label, current, disabled=false) { - return `
${values.map(([value,title])=>``).join('')}
`; -} -function toggle(key, title, description, name, checked, compact=false, disabled=false) { - return `
${icon(name)}
${description&&!compact?`${description}`:''}
`; -} -const selectField = (key, label, options, current, helpKey='', id='') => `
${fieldHeading(label, helpKey, id||`ds-${key}`)}
`; -const rangeField = (key, label, min, max, step, value, unit, helpKey='') => `
${fieldHeading(label, helpKey, `ds-${key}-range`)}
`; - -const errorFor = key => { - if (key==='name' && !draft.name.trim()) return 'Enter a name for this device.'; - if (key==='binding' && draft.bindingMode==='ha' && !draft.binding) return 'Choose a device or entity to bind.'; - if (key==='glowRadius' && draft.glowRadius!=='' && !(Number(draft.glowRadius.replace(',','.'))>0)) return 'Enter a radius greater than 0, or leave it empty.'; - if (key==='link' && draft.link.trim() && !/^https?:\/\//i.test(draft.link.trim())) return 'Enter a link that starts with http:// or https://.'; - return ''; -}; -const errorFields = ['name','binding','glowRadius','link']; -const allErrors = () => errorFields.map(k=>[k,errorFor(k)]).filter(([,m])=>m); -const errorSlot = key => `
${errorFor(key)}
`; -const bound = () => HA_LIST.find(x => x.value===draft.binding); -const boundEntity = () => draft.bindingMode==='ha' && draft.binding ? (draft.binding.startsWith('entity:') ? draft.binding.slice(7) : 'light.wall_lamp_3') : ''; - -function basicsCard() { - const b = bound(); - const list = HA_LIST.filter(x => (draft.showEntities || !x.entity) && (!pickFilter || (x.label+' '+x.sub).toLowerCase().includes(pickFilter.toLowerCase()))); - const picker = draft.bindingMode==='ha' ? `
${pickOpen?`
${list.map(x=>``).join('')||'

Nothing found.

'}
`:''}${errorSlot('binding')}
` : hint('A virtual device is a plain marker on the plan without live state.'); - return card('basics','Basics', - `
${errorSlot('name')}${hint('Shown on the plan.')}
`+ - `
${fieldHeading('Bind to an HA device','binding')}${segmented('bindingMode',[['virtual','Virtual device'],['ha','Pick from the HA list']],'Bind to an HA device',draft.bindingMode)}${picker}
`+ - `
${hint(draft.room?'Overrides the placement by device area.':'Placed by the device’s Home Assistant area.')}
` - ); -} -function tapCard() { - const ent = boundEntity(); - const tapNote = draft.tap==='toggle' ? (ent ? hint(`Target: ${escape(draft.name)} (${ent}). Now: Off → will turn on.`) : callout('A virtual device has nothing to toggle. Bind it to a Home Assistant device first.',true)) - : draft.tap==='run' ? `
` : ''; - const ctrl = draft.controls.map(id => LIGHTS.find(l=>l.id===id)).filter(Boolean); - const cands = LIGHTS.filter(l => !draft.controls.includes(l.id) && (!ctrlFilter || (l.label+' '+l.sub).toLowerCase().includes(ctrlFilter.toLowerCase()))); - return card('tap','Tap action', - selectField('tap','Tap action for this device',TAPS,draft.tap)+tapNote+ - (draft.tap==='none' ? '' : `
${toggle('tapConfirm','Ask for confirmation','Show a confirmation dialog before acting, a guard against accidental taps.','check',draft.tapConfirm)}
`)+ - `
${fieldHeading('Controls other light sources','controls','ds-ctrl-filter')}${ctrl.length?`
${ctrl.map(l=>`${icon('bulb')}${escape(l.label)}`).join('')}
`:''}
${ctrlOpen?`
${cands.map(l=>``).join('')||'

Nothing found.

'}
`:''}
` - ); -} -function lightCard() { - const never = draft.lightRole==='never'; - const roleHint = draft.lightRole==='auto' ? hint(draft.bindingMode==='ha' ? 'Auto: resolved as a light source.' : 'Auto: a virtual device is not a light source.') : ''; - const glowDetail = draft.glowMode==='auto' ? '' : `
${draft.glowColor.toUpperCase()}
`+ - (draft.glowMode==='fixed' ? rangeField('glowBrightness','Brightness',1,100,1,draft.glowBrightness,'%') : ''); - return card('light','Light and glow', - `
${fieldHeading('Is this device a light source?','lightRole')}${segmented('lightRole',[['auto','Auto'],['always','Always'],['never','Never']],'Is this device a light source?',draft.lightRole)}${roleHint}
`+ - `
${fieldHeading('Glow colour and brightness','glowMode')}${segmented('glowMode',[['auto','From source'],['color','Set colour'],['fixed','Colour and brightness']],'Glow colour and brightness',draft.glowMode,never)}${never?'':glowDetail}`+ - `
${fieldHeading('Glow radius','glowRadius','ds-glow-radius')}
m
${errorSlot('glowRadius')}${draft.glowRadius===''?hint(`Empty: uses the general radius (${GENERAL_GLOW_RADIUS} m).`):''}
`+ - (never?callout('Glow settings are unavailable because this marker is explicitly not a light source.'):'')+`
` - ); -} -function appearanceCard() { - const autoIcon = 'mdi:lightbulb'; - const iconField = `
${fieldHeading('Icon','icon','ds-icon')}
${icon('bulb')}${draft.icon?``:''}
${draft.icon?hint('Custom icon for this marker.'):`

Auto: ${autoIcon} (by icon rules). ${draft.iconPinned?'Pinned.':``}

`}
`; - const mode = DISPLAYS.find(d=>d[0]===draft.display); - const displayField = `
${fieldHeading('Display','display','ds-display')}${hint(mode[2])}
`+ - (draft.display==='icon_ripple' ? `
${draft.pulseColor.toUpperCase()}
`+rangeField('pulseSize','Activity pulse size',50,200,5,draft.pulseSize,'%') : ''); - const isStatic = draft.display==='static_icon'; - const badgeSrc = BADGE_SOURCES.find(s=>s[0]===draft.badgeSource); - const badge = `
${subsectionHeading('Value badge','badge')}${toggle('badgeEnabled','Show a value badge','',isStatic?'eyeOff':'card',draft.badgeEnabled,true)}${draft.badgeEnabled ? (isStatic ? callout('A static icon does not show live values. The setting is preserved.') : (draft.bindingMode!=='ha' ? callout('A virtual device has no Home Assistant value to show.',true) : `
${fieldHeading('Value','badgeSource','ds-badge-source')}${badgeSrc[2]?`

${badgeSrc[2]}

`:''}
${fieldHeading('Position','badgePosition')}${segmented('badgePosition',[['top','Above'],['right','Right'],['bottom','Below'],['left','Left']],'Position',draft.badgePosition)}
`)) : ''}
`; - const preview = `

Display preview

Now
${icon('bulb')}${draft.badgeEnabled&&!isStatic&&draft.bindingMode==='ha'?'Off':''}
Provided by
${draft.bindingMode==='ha'?'template':'House Plan · virtual device'}
Display source
${draft.bindingMode==='ha'?`${escape(draft.name)} · ${boundEntity()}`:'No active source'}
Current state
${draft.bindingMode==='ha'?'Off':'No current state'}
On the plan
${isStatic?'Static mode: state does not change the icon':'Neutral dark plate'}
`; - const size = `
${subsectionHeading('Icon size and rotation','size')}
${rangeField('iconScale','Size',0.5,3,0.1,draft.iconScale,'×')}${rangeField('rotate','Rotation',0,359,1,draft.rotate,'°')}
`; - return card('appearance','Appearance', iconField+displayField+badge+preview+size); -} -function detailsCard() { - return card('details','Details', - `
${errorSlot('link')}
`+ - `
`+ - `
Manuals (PDF etc.)${draft.pdfs.length?`
${draft.pdfs.map((p,i)=>`${icon('folder')}${escape(p)}`).join('')}
`:''}
`+ - `
${subsectionHeading('Additional actions')}${toggle('isRadar','This is a presence radar','Declares this device as a presence radar and opens its calibration.','radar',draft.isRadar)}
` - ); -} - -function renderMain() { - closeHelp(); - content.innerHTML = basicsCard()+tapCard()+lightCard()+appearanceCard()+detailsCard(); - $$('.panel-scroll>.field-stack:first-child', content).forEach(el => { el.style.marginTop = '0'; }); -} -function updateChrome() { - const errors = allErrors(); - $('#ds-save').disabled = !dirty() || errors.length>0; - const status = $('#ds-status'); - status.classList.toggle('dirty', dirty()); status.hidden = !dirty() && !errors.length; status.textContent = dirty() ? 'Unsaved changes' : ''; - if (errors.length) status.innerHTML = ``; - errorFields.forEach(key => { const el = $(`#ds-error-${key}`), m = errorFor(key); if (el) { el.hidden = !m; el.textContent = m; } }); - $$('[data-hex]', content).forEach(el => { el.textContent = (el.dataset.hex==='glow'?draft.glowColor:draft.pulseColor).toUpperCase(); }); - const mock = $('.marker-mock', content); if (mock) { mock.style.setProperty('--scale', draft.iconScale); mock.style.setProperty('--rot', `${draft.rotate}deg`); } -} -const render = () => { renderMain(); updateChrome(); }; -function rerender(focusSelector) { const top = scroller.scrollTop; renderMain(); updateChrome(); scroller.scrollTop = top; if (focusSelector) $(focusSelector, content)?.focus({preventScroll:true}); } -const RERENDER_KEYS = new Set(['bindingMode','room','tap','lightRole','glowMode','display','badgeEnabled','badgeSource','badgePosition','showEntities','glowRadius','icon']); -function updateField(el) { - const key = el.dataset.field; if (!key) return; - if (['iconScale','rotate','glowBrightness','pulseSize'].includes(key)) { const n = Number(el.value); if (Number.isFinite(n)) draft[key] = n; $$(`[data-field="${key}"]`, content).filter(x=>x!==el).forEach(x=>{ x.value = el.value; }); updateChrome(); return; } - if (el.type==='checkbox') draft[key] = el.checked; else draft[key] = el.value; - if (key==='bindingMode' && el.value==='virtual') pickOpen = false; - if (key==='glowRadius' || key==='icon') { updateChrome(); const wasEmpty = el.value===''; if ((key==='glowRadius' && (wasEmpty || el.value.length===1)) || (key==='icon' && (wasEmpty || el.value.length===1))) rerender(`[data-field="${key}"]`); return; } - if (RERENDER_KEYS.has(key)) rerender(el.type==='radio'?`[data-field="${key}"]:checked`:`[data-field="${key}"]`); else updateChrome(); -} -function handleAction(button) { - const a = button.dataset.action; - if (a==='review-errors') { const first = allErrors()[0]; if (!first) return; const field = $(`[data-field="${first[0]}"], [data-action="pick-toggle"]`, content); field?.scrollIntoView({block:'center'}); field?.focus({preventScroll:true}); return; } - if (a==='pick-toggle') { pickOpen = !pickOpen; pickFilter = ''; rerender(pickOpen?'[data-pick-filter]':'[data-action="pick-toggle"]'); return; } - if (a==='pick') { draft.binding = button.dataset.value; pickOpen = false; rerender('[data-action="pick-toggle"]'); return; } - if (a==='ctrl-add') { draft.controls.push(button.dataset.id); ctrlFilter = ''; ctrlOpen = true; rerender('#ds-ctrl-filter'); return; } - if (a==='ctrl-remove') { draft.controls = draft.controls.filter(id => id!==button.dataset.id); rerender('#ds-ctrl-filter'); return; } - if (a==='icon-clear') { draft.icon = ''; rerender('#ds-icon'); return; } - if (a==='icon-pin') { draft.iconPinned = true; draft.icon = 'mdi:lightbulb'; rerender('#ds-icon'); return; } - if (a==='attach') { draft.pdfs.push(draft.pdfs.length ? `datasheet-${draft.pdfs.length+1}.pdf` : 'manual.pdf'); rerender(); showToast('The product opens a file picker here; a sample file was attached.'); return; } - if (a==='pdf-remove') { draft.pdfs.splice(Number(button.dataset.index), 1); rerender(); return; } -} - -/* Open, close, save. */ -let closeDecision, lastSubTrigger; -function openSettings() { saved = load(); draft = clone(saved); pickOpen = false; ctrlOpen = false; render(); if (!dialog.open) dialog.show(); scroller.scrollTop = 0; $('#ds-title').focus({preventScroll:true}); } -function closeSettings() { dialog.close(); notifyDemoFormClosed('device-settings'); } -function showSub(title, body, actions) { - lastSubTrigger = document.activeElement; - subDialog.innerHTML = `

${title}

${body}
${actions}
`; - subDialog.showModal(); -} -function closeSub() { subDialog.close(); lastSubTrigger?.focus({preventScroll:true}); if (closeDecision) { const r = closeDecision; closeDecision = undefined; r(false); } } -function requestClose() { - if (!dirty()) { closeSettings(); return Promise.resolve(true); } - showSub('Discard your changes?', `

Your changes to ${escape(saved.name)} haven’t been saved.

`, ''); - $('[data-sub="close"].primary', subDialog).focus(); - return new Promise(resolve => { closeDecision = resolve; }); -} -function saveSettings() { - if (!dirty() || allErrors().length) return; - const normalized = {...clone(draft), name:draft.name.trim(), link:draft.link.trim()}; - try { localStorage.setItem(STORAGE_KEY, JSON.stringify(normalized)); } catch { showToast('Could not save locally. Enable browser storage and try again.'); return; } - saved = clone(normalized); draft = clone(saved); closeSettings(); showToast('Device settings saved in this browser.'); -} - -content.addEventListener('input', e => { - if (e.target.dataset.pickFilter!==undefined) { pickFilter = e.target.value; const pos = e.target.selectionStart; rerender('[data-pick-filter]'); $('[data-pick-filter]', content)?.setSelectionRange(pos, pos); return; } - if (e.target.dataset.ctrlFilter!==undefined) { ctrlFilter = e.target.value; ctrlOpen = true; const pos = e.target.selectionStart; rerender('#ds-ctrl-filter'); $('#ds-ctrl-filter', content)?.setSelectionRange(pos, pos); return; } - if (['checkbox','radio'].includes(e.target.type) || e.target.tagName==='SELECT') return; - updateField(e.target); -}); -content.addEventListener('focusin', e => { if (e.target.id==='ds-ctrl-filter' && !ctrlOpen) { ctrlOpen = true; rerender('#ds-ctrl-filter'); } }); -content.addEventListener('change', e => { if (['checkbox','radio'].includes(e.target.type) || e.target.tagName==='SELECT') updateField(e.target); }); -content.addEventListener('click', e => { const b = e.target.closest('button[data-action]'); if (b) handleAction(b); }); -document.addEventListener('click', e => { if (ctrlOpen && dialog.open && !e.target.closest('#ds-section-tap .source-picker')) { ctrlOpen = false; ctrlFilter = ''; $('#ds-section-tap .source-panel', content)?.remove(); } }); -$('#ds-status').addEventListener('click', e => { const b = e.target.closest('button[data-action]'); if (b) handleAction(b); }); -$('#ds-close').addEventListener('click', requestClose); -$('#ds-cancel').addEventListener('click', requestClose); -$('#ds-save').addEventListener('click', saveSettings); -$('#ds-hide').addEventListener('click', () => showToast('The product hides the marker on the plan and keeps its settings.')); -$('#ds-delete').addEventListener('click', () => { showSub('Delete this device?', `

Remove ${escape(saved.name)} from the plan? The Home Assistant device itself is not affected.

`, ''); $('[data-sub="close"]', subDialog).focus(); }); -dialog.addEventListener('cancel', e => { e.preventDefault(); requestClose(); }); -dialog.addEventListener('keydown', e => { - if (e.key==='Escape' && helpOpen()) { e.preventDefault(); e.stopPropagation(); closeHelp(); return; } - if (e.key==='Escape' && (pickOpen || ctrlOpen)) { e.preventDefault(); e.stopPropagation(); pickOpen = false; ctrlOpen = false; rerender(); return; } - if (e.key==='Escape' && !subDialog.open) { e.preventDefault(); e.stopPropagation(); requestClose(); } -}); -subDialog.addEventListener('cancel', e => { e.preventDefault(); closeSub(); }); -subDialog.addEventListener('click', e => { - const b = e.target.closest('button[data-sub]'); if (!b) return; - if (b.dataset.sub==='close') { closeSub(); return; } - if (b.dataset.sub==='discard') { const r = closeDecision; closeDecision = undefined; draft = clone(saved); closeSub(); closeSettings(); r?.(true); return; } - if (b.dataset.sub==='delete') { closeSub(); closeSettings(); showToast('Device removed from the plan in this preview.'); } -}); -window.addEventListener('beforeunload', e => { if (dialog.open && dirty()) { e.preventDefault(); e.returnValue=''; } }); -hydrateIcons(dialog); -registerDemoForm({id:'device-settings', title:'Device on the plan', description:'Binding, tap action, light and glow, appearance and details of one device marker.', open:openSettings, requestClose}); diff --git a/docs/design/600-settings-dialogs/reference/general-settings.js b/docs/design/600-settings-dialogs/reference/general-settings.js deleted file mode 100644 index 3e7a3314..00000000 --- a/docs/design/600-settings-dialogs/reference/general-settings.js +++ /dev/null @@ -1,258 +0,0 @@ -/* General settings: design prototype of the house-wide settings dialog. - Same visual system as Space settings (cards in one flow, "?" help, inline color fields). - Field keys mirror the product's SettingsDialogState; data stays in this origin's localStorage. */ -import {registerDemoForm, notifyDemoFormClosed} from './demo-shell.js'; -import {$, $$, escape, clone, icon, hydrateIcons, showToast, attachHelp, closeHelp, helpOpen} from './ui.js'; - -const STORAGE_KEY = 'houseplan-general-settings-design-v1'; -/* DEFAULT_FILL_COLORS from src/logic.ts; opacity kept as a fraction like the product. */ -const DEFAULT_COLORS = { - light_on:{c:'#ffd45c',a:0.18}, light_off:{c:'#9aa0a6',a:0.14}, light_none:{c:'#6b7480',a:0}, - temp_cold:{c:'#4fc3f7',a:0.18}, temp_ok:{c:'#66d17a',a:0.18}, temp_hot:{c:'#ffd45c',a:0.18}, - lqi_low:{c:'#f25a4a',a:0.18}, lqi_high:{c:'#4bd28f',a:0.18}, - glow_base:{c:'#0d1b2a',a:0.5}, glow_light:{c:'#ffd9a0',a:0.85}, - wall_fill:{c:'#ffffff',a:1}, -}; -const defaults = { - showRoomTooltip:true, radarShowLive:true, - zigbeeTopology:{enabled:false, z2mTopics:'zigbee2mqtt'}, - colors:clone(DEFAULT_COLORS), glowRadius:3, - bgMode:'daynight', bgColor:null, northDeg:null, sunRays:true, sunRayOrigin:'inner', -}; -/* Visual example for a fresh browser: matches the current stage (north 165°, Zigbee links on). */ -const example = {...clone(defaults), northDeg:165, zigbeeTopology:{enabled:true, z2mTopics:'zigbee2mqtt'}}; -const THEME_BG = '#edf1f2'; - -function load() { - try { - const data = JSON.parse(localStorage.getItem(STORAGE_KEY)); - if (!data || typeof data !== 'object') return clone(example); - const ok = Object.keys(defaults).every(k => k in data) && Object.keys(DEFAULT_COLORS).every(k => data.colors?.[k]?.c); - return ok ? data : clone(example); - } catch { return clone(example); } -} -let saved = load(); -let draft = clone(saved); -const dirty = () => JSON.stringify(draft) !== JSON.stringify(saved); - -/* Dialog shell: same structure and classes as the Space settings dialog. */ -const dialog = document.createElement('dialog'); -dialog.id = 'general-settings'; dialog.className = 'settings-dialog'; dialog.setAttribute('aria-labelledby', 'gs-title'); -dialog.innerHTML = ` -
- -

General settings

-
-
-
- - - -
`; -$('#demo-stage').append(dialog); -const subDialog = document.createElement('dialog'); -subDialog.id = 'gs-sub-dialog'; subDialog.className = 'sub-dialog'; subDialog.setAttribute('aria-labelledby', 'gs-sub-title'); -document.body.append(subDialog); -const content = $('#gs-content'); -const scroller = $('#gs-scroll'); - -/* Help texts: product keys where they exist (gs.*.help, topology help). */ -const helpCopy = { - zigbee:['Zigbee links','Shows which Zigbee devices each device talks to directly. Line colour is link quality (LQI) on the same scale as the device’s LQI reading: red at 40 and below, green at 180 and above. A dashed line means quality was not reported. An arrow points to the next device on the way to the coordinator; a line without an arrow is a spare neighbour. Data comes from the last load and goes stale; links are shown with a mouse only.'], - fills:['Room fill colors','Fill colors apply to every space; each color has its own opacity. Which fill mode a space uses is set in that space’s dialog.'], - glowRadius:['Glow radius','Sets the default glow radius in metres or feet; a device-specific radius overrides it.'], - bgMode:['Plan background','“Follows the Sun” uses sun.sun, falling back to the browser’s local clock; Static always shows the selected colour.'], - north:['North on the plan','The angle is measured clockwise from the plan’s upward vertical; north is required for window rays, not for the “Follows the Sun” background.'], -}; -const help = attachHelp(content, scroller, helpCopy); - -/* Rendering helpers (same markup as Space settings). */ -const card = (id, title, body, extra='') => `

${title}

${extra}
${body}
`; -const hint = text => `

${text}

`; -const callout = (text, warning=false) => `
${icon('info')}

${text}

`; -const subsectionHeading = (text, key='') => `

${text}

${key?help.button(key):''}
`; -function toggle(key, title, description, name, disabled=false) { - return `
${icon(name)}
${description?`${description}`:''}
`; -} -function segmented(key, values, label) { - return `
${values.map(([value,title])=>``).join('')}
`; -} -/* Color tiles: one card per color with a large swatch (system picker), name, hex and opacity. Groups of colors sit in one row. */ -const onDark = hex => { const n = parseInt(hex.slice(1), 16); const r = n >> 16, g = (n >> 8) & 255, b = n & 255; return (0.299*r + 0.587*g + 0.114*b) < 150; }; -function colorTile(key, label) { - const {c, a} = draft.colors[key]; - return `
${label}
${c.toUpperCase()}
`; -} -const colorTiles = items => `
${items.map(([k,l])=>colorTile(k,l)).join('')}
`; -/* Single color: same plate as the Space settings color fields (label above, swatch, hex, optional opacity and Reset). */ -function colorPlate(key, label, {opacity=true, reset=''}={}) { - const {c, a} = draft.colors[key]; - return `
${c.toUpperCase()}${opacity?``:''}${reset?``:''}
`; -} -const northNeedle = ''; - -function generalCard() { - return card('general','Display', - toggle('showRoomTooltip','Show the room information window on hover','Hover a room on the plan to see its summary.','tooltip')+ - toggle('radarShowLive','Show live presence on the plan','Hides or shows all live radar targets. This does not start or stop observation recording.','radar') - ); -} -function zigbeeCard() { - const z = draft.zigbeeTopology, savedEnabled = saved.zigbeeTopology.enabled; - const providers = z.enabled ? ` - ${!savedEnabled?callout('Save this setting before loading topology data.'):''} -
${subsectionHeading('ZHA')}${hint('Reads ZHA’s cached topology; it does not start a radio scan.')}
Data not loaded
-
${subsectionHeading('Zigbee2MQTT')}
${callout('A network-map scan can take 10 seconds to 2 minutes and may temporarily reduce Zigbee responsiveness.',true)}
Data not loaded
` : ''; - return card('zigbee','Zigbee links', - `
${icon('zigbee')}
Shows observed direct neighbours only. Nothing is fetched while you hover.
`+providers, - help.button('zigbee') - ); -} -function fillsCard() { - return card('fills','Room fill colors', - `
${subsectionHeading('Lights')}${colorTiles([['light_on','Lights on'],['light_off','All lights off'],['light_none','No light sources']])}
`+ - `
${subsectionHeading('Temperature')}${colorTiles([['temp_cold','Cold'],['temp_ok','Comfortable'],['temp_hot','Hot']])}
`+ - `
${subsectionHeading('Zigbee signal')}${colorTiles([['lqi_low','Weak signal'],['lqi_high','Strong signal']])}
`, - help.button('fills') - ); -} -function glowCard() { - return card('glow','Light-source glow', - colorTiles([['glow_base','House darkness'],['glow_light','Default light color / intensity']])+ - `
${help.button('glowRadius')}
m
${errorSlot('glowRadius')}
` - ); -} -function planCard() { - const bgDetail = draft.bgMode==='static' - ? `
${(draft.bgColor||THEME_BG).toUpperCase()}${draft.bgColor?``:''}
${hint(draft.bgColor?'Custom background color.':'Using the theme background.')}
` - : ''; - return card('plan','Plan', - colorPlate('wall_fill','Wall fill')+ - `
Plan background${help.button('bgMode')}
${segmented('bgMode',[['static','Static color'],['daynight','Follows the Sun']],'Plan background')}${bgDetail}
` - ); -} -function sunCard() { - return card('sun','Sun', - `
${help.button('north')}
°${draft.northDeg!==null?``:''}
${errorSlot('northDeg')}`+ - `
${toggle('sunRays','Sunlight through windows','Draw sun rays through window openings.','sun')}
`+ - `
Sun rays${segmented('sunRayOrigin',[['inner','From the inner window corners'],['outer','From the outer window corners']],'Sun rays')}
` - ); -} -function dataCard() { - return card('data','Data', - `
${subsectionHeading('Backup and transfer')}${hint('Download a portable JSON backup or preview and import a backup made by House Plan.')}
`+ - `
${subsectionHeading('Plan maintenance')}${hint('Updates data models, aligns plan elements to the grid and merges redundant wall fragments. An exact report is shown before anything is stored.')}
` - ); -} - -/* Validation, chrome and state. */ -function errorFor(key) { - if (key==='glowRadius' && (draft.glowRadius==='' || !Number.isFinite(Number(draft.glowRadius)) || Number(draft.glowRadius)<=0)) return 'Enter a radius greater than 0.'; - if (key==='northDeg' && draft.northDeg!==null && (draft.northDeg==='' || !Number.isInteger(Number(draft.northDeg)) || draft.northDeg<0 || draft.northDeg>359)) return 'Enter a whole number between 0° and 359°.'; - return ''; -} -const errorFields = ['glowRadius','northDeg']; -const allErrors = () => errorFields.map(k=>[k,errorFor(k)]).filter(([,m])=>m); -const errorSlot = key => `
${errorFor(key)}
`; - -function renderMain() { - closeHelp(); - content.innerHTML = generalCard()+zigbeeCard()+fillsCard()+glowCard()+planCard()+sunCard()+dataCard(); -} -function updateChrome() { - const errors = allErrors(); - $('#gs-save').disabled = !dirty() || errors.length>0; - const status = $('#gs-status'); - status.classList.toggle('dirty', dirty()); - status.hidden = !dirty() && !errors.length; - status.textContent = dirty() ? 'Unsaved changes' : ''; - if (errors.length) status.innerHTML = ``; - errorFields.forEach(key => { - const el = $(`#gs-error-${key}`), message = errorFor(key); - if (el) { el.hidden = !message; el.textContent = message; } - $$(`[data-field="${key}"]`, content).forEach(input => input.setAttribute('aria-invalid', String(!!message))); - }); - $$('[data-hex]', content).forEach(el => { const k = el.dataset.hex; el.textContent = (k==='bg' ? (draft.bgColor||THEME_BG) : draft.colors[k].c).toUpperCase(); }); - const arrow = $('#gs-dial-arrow'); if (arrow) arrow.style.transform = `rotate(${draft.northDeg??0}deg)`; - $('#gs-dial-arrow')?.parentElement.classList.toggle('unset', draft.northDeg===null); -} -const render = () => { renderMain(); updateChrome(); }; -function rerenderKeepingScroll() { const top = scroller.scrollTop; renderMain(); updateChrome(); scroller.scrollTop = top; } - -function updateField(el) { - const key = el.dataset.field; - if (el.dataset.color) { draft.colors[el.dataset.color].c = el.value; el.parentElement.classList.toggle('on-dark', onDark(el.value)); updateChrome(); return; } - if (el.dataset.opacity) { const n = Math.min(100, Math.max(0, Number(el.value))); if (Number.isFinite(n)) draft.colors[el.dataset.opacity].a = n/100; updateChrome(); return; } - if (!key) return; - if (key==='zigbeeEnabled') { draft.zigbeeTopology.enabled = el.checked; rerenderKeepingScroll(); return; } - if (key==='z2mTopics') { draft.zigbeeTopology.z2mTopics = el.value; $('[data-action="z2m"]', content).lastChild.textContent = `Update map · ${el.value.split('\n')[0].trim()||'zigbee2mqtt'}`; updateChrome(); return; } - if (key==='bgColor') { draft.bgColor = el.value; rerenderKeepingScroll(); return; } - if (key==='northDeg') { draft.northDeg = el.value==='' ? null : Number(el.value); updateChrome(); if ((el.value==='')!==!$('[data-action="clear-north"]', content)) rerenderKeepingScroll(); return; } - if (el.type==='checkbox') draft[key] = el.checked; - else if (el.type==='number') draft[key] = el.value==='' ? '' : Number(el.value); - else draft[key] = el.value; - if (key==='bgMode') rerenderKeepingScroll(); else updateChrome(); -} -function handleAction(action) { - if (action==='review-errors') { const first = allErrors()[0]; if (!first) return; const field = $(`[data-field="${first[0]}"]`, content); field?.scrollIntoView({block:'center'}); field?.focus({preventScroll:true}); return; } - if (action==='bg-theme') draft.bgColor = null; - if (action==='clear-north') draft.northDeg = null; - if (action==='zha' || action==='z2m') { showToast('Topology data is loaded from Home Assistant in the product; this preview only shows the controls.'); return; } - if (action==='export') { showToast('The product downloads a JSON backup here.'); return; } - if (action==='import') { showToast('The product opens a file picker and previews the backup here.'); return; } - if (action==='optimize') { showToast('The product shows an exact optimization report before storing anything.'); return; } - rerenderKeepingScroll(); -} - -/* Open, close, save. */ -let closeDecision, lastSubTrigger; -function openSettings() { - saved = load(); draft = clone(saved); render(); - if (!dialog.open) dialog.show(); - scroller.scrollTop = 0; $('#gs-title').focus({preventScroll:true}); -} -function closeSettings() { dialog.close(); notifyDemoFormClosed('general-settings'); } -function showSub(title, body, actions) { - lastSubTrigger = document.activeElement; - subDialog.innerHTML = `

${title}

${body}
${actions}
`; - subDialog.showModal(); -} -function closeSub() { subDialog.close(); lastSubTrigger?.focus({preventScroll:true}); if (closeDecision) { const r = closeDecision; closeDecision = undefined; r(false); } } -function requestClose() { - if (!dirty()) { closeSettings(); return Promise.resolve(true); } - showSub('Discard your changes?', '

Your changes to the general settings haven’t been saved.

', ''); - $('[data-sub="close"].primary', subDialog).focus(); - return new Promise(resolve => { closeDecision = resolve; }); -} -function saveSettings() { - if (!dirty() || allErrors().length) return; - const normalized = clone(draft); - normalized.zigbeeTopology.z2mTopics = normalized.zigbeeTopology.z2mTopics.split('\n').map(s=>s.trim()).filter(Boolean).join('\n') || 'zigbee2mqtt'; - try { localStorage.setItem(STORAGE_KEY, JSON.stringify(normalized)); } - catch { showToast('Could not save locally. Enable browser storage and try again.'); return; } - saved = clone(normalized); draft = clone(saved); closeSettings(); - showToast('General settings saved in this browser.'); -} - -content.addEventListener('input', e => { if (['checkbox','radio'].includes(e.target.type) || e.target.tagName==='SELECT') return; updateField(e.target); }); -content.addEventListener('change', e => { if (['checkbox','radio'].includes(e.target.type) || e.target.tagName==='SELECT') updateField(e.target); }); -content.addEventListener('click', e => { const b = e.target.closest('button[data-action]'); if (b) handleAction(b.dataset.action); }); -$('#gs-status').addEventListener('click', e => { const b = e.target.closest('button[data-action]'); if (b) handleAction(b.dataset.action); }); -$('#gs-close').addEventListener('click', requestClose); -$('#gs-cancel').addEventListener('click', requestClose); -$('#gs-save').addEventListener('click', saveSettings); -$('#gs-reset').addEventListener('click', () => { draft = clone(defaults); draft.zigbeeTopology.enabled = saved.zigbeeTopology.enabled; rerenderKeepingScroll(); showToast('Defaults restored. Save to keep them.'); }); -dialog.addEventListener('cancel', e => { e.preventDefault(); requestClose(); }); -dialog.addEventListener('keydown', e => { - if (e.key==='Escape' && helpOpen()) { e.preventDefault(); e.stopPropagation(); closeHelp(); return; } - if (e.key==='Escape' && !subDialog.open) { e.preventDefault(); e.stopPropagation(); requestClose(); } -}); -subDialog.addEventListener('cancel', e => { e.preventDefault(); closeSub(); }); -subDialog.addEventListener('click', e => { - const b = e.target.closest('button[data-sub]'); if (!b) return; - if (b.dataset.sub==='close') { closeSub(); return; } - if (b.dataset.sub==='discard') { const r = closeDecision; closeDecision = undefined; draft = clone(saved); closeSub(); closeSettings(); r?.(true); } -}); -window.addEventListener('beforeunload', e => { if (dialog.open && dirty()) { e.preventDefault(); e.returnValue=''; } }); -hydrateIcons(dialog); -registerDemoForm({id:'general-settings', title:'General settings', description:'House-wide display, fill colors, glow, background, sun and maintenance.', open:openSettings, requestClose}); diff --git a/docs/design/600-settings-dialogs/reference/index.html b/docs/design/600-settings-dialogs/reference/index.html deleted file mode 100644 index 833890e5..00000000 --- a/docs/design/600-settings-dialogs/reference/index.html +++ /dev/null @@ -1,53 +0,0 @@ - - - - - - - Form previews · House Plan - - - - -
-
-
House PlanForm previews
- -
-
-
-
- -
- -
- -
- -

Space settings

- Ground Floor -
-
-
-
- -
-
-
- - - -
-
-
- - - - - - - - diff --git a/docs/design/600-settings-dialogs/reference/room-fill.js b/docs/design/600-settings-dialogs/reference/room-fill.js deleted file mode 100644 index 658e51e6..00000000 --- a/docs/design/600-settings-dialogs/reference/room-fill.js +++ /dev/null @@ -1,48 +0,0 @@ -/* House Plan roomFillStyle + Glow fallback, adapted for this isolated demo. - Source: c22c04d6476d0d387c237b427d0b83e7c1e11f70, src/logic.ts and - src/space-render.ts. See ROOM-FILL.md for palette and data limitations. */ -export const DEFAULT_CUSTOM_FILL = {c:'#607d8b', a:.18}; -export const DEFAULT_FILL_COLORS = { - light_on:{c:'#ffd45c',a:.18}, light_off:{c:'#9aa0a6',a:.14}, - light_none:{c:'#6b7480',a:0}, - temp_cold:{c:'#4fc3f7',a:.18}, temp_ok:{c:'#66d17a',a:.18}, - temp_hot:{c:'#ffd45c',a:.18}, - lqi_low:{c:'#f25a4a',a:.18}, lqi_high:{c:'#4bd28f',a:.18}, - glow_base:{c:'#0d1b2a',a:.5}, -}; -const clamp = value => Math.min(1,Math.max(0,value)); -function mixColor(a,b,t) { - return '#' + [1,3,5].map(i=>{ - const start=parseInt(a.slice(i,i+2),16),end=parseInt(b.slice(i,i+2),16); - return Math.round(start+(end-start)*t).toString(16).padStart(2,'0'); - }).join(''); -} -export function roomFillStyle({mode,lqi,lights,temp,min,max,custom=DEFAULT_CUSTOM_FILL},colors=DEFAULT_FILL_COLORS) { - if(mode==='custom')return custom; - if(mode==='lqi') { - if(lqi==null)return null; - const t=clamp((lqi-40)/140); - return {c:mixColor(colors.lqi_low.c,colors.lqi_high.c,t),a:colors.lqi_low.a+(colors.lqi_high.a-colors.lqi_low.a)*t}; - } - if(mode==='light')return lights==='none' ? (colors.light_none.a>0?colors.light_none:null) : colors[lights==='on'?'light_on':'light_off']; - if(mode==='temp') { - if(temp==null)return null; - return colors[tempMath.max(min,max)?'temp_hot':'temp_ok']; - } - return null; -} -export function previewFill(settings,data,colors=DEFAULT_FILL_COLORS) { - const fill=roomFillStyle({mode:settings.fillMode,lqi:data.lqi,lights:data.totalLights===0?'none':data.onLights>0?'on':'off',temp:data.temp,min:settings.tempMin,max:settings.tempMax, - custom:settings.customFillSet?{c:settings.fillColor,a:clamp(Number(settings.fillOpacity)/100)}:DEFAULT_CUSTOM_FILL},colors); - return {fill,glowBase:settings.glowEnabled&&(!fill||fill.a<=0)?colors.glow_base:null}; -} -const typical={temp:22.4,humidity:45,lqi:92,onLights:1,totalLights:3}; -export const SAMPLE_SCENARIOS = { - typical:{label:'Typical room',...typical}, - off:{label:'Lights off',...typical,onLights:0}, - cold:{label:'Cold room',...typical,temp:16}, - hot:{label:'Hot room',...typical,temp:30}, - low:{label:'Weak Zigbee',...typical,lqi:30}, - high:{label:'Strong Zigbee',...typical,lqi:200}, - empty:{label:'No room data',temp:null,humidity:null,lqi:null,onLights:0,totalLights:0}, -}; diff --git a/docs/design/600-settings-dialogs/reference/room-fill.test.mjs b/docs/design/600-settings-dialogs/reference/room-fill.test.mjs deleted file mode 100644 index 0f3cd873..00000000 --- a/docs/design/600-settings-dialogs/reference/room-fill.test.mjs +++ /dev/null @@ -1,55 +0,0 @@ -import assert from 'node:assert/strict'; -import {readFileSync} from 'node:fs'; -import {stripTypeScriptTypes} from 'node:module'; -import {runInNewContext} from 'node:vm'; -import {roomFillStyle,previewFill,DEFAULT_FILL_COLORS as palette,DEFAULT_CUSTOM_FILL,SAMPLE_SCENARIOS} from './room-fill.js'; - -const input={mode:'temp',min:20,max:25,temp:22.4,lqi:92,lights:'on',custom:{c:'#ededed',a:1}}; -for(const [temp,state] of [[19.9,'cold'],[20,'ok'],[22.4,'ok'],[25,'ok'],[25.1,'hot']]) { - assert.deepEqual(roomFillStyle({...input,temp}),palette[`temp_${state}`]); - assert.deepEqual(roomFillStyle({...input,temp,min:25,max:20}),palette[`temp_${state}`]); -} -assert.equal(roomFillStyle({...input,temp:null}),null); -assert.equal(roomFillStyle({...input,mode:'lqi',lqi:null}),null); -assert.deepEqual(roomFillStyle({...input,mode:'lqi',lqi:0}),palette.lqi_low); -assert.deepEqual(roomFillStyle({...input,mode:'lqi',lqi:255}),palette.lqi_high); -assert.deepEqual(roomFillStyle({...input,mode:'lqi',lqi:110}),{c:'#9f966d',a:.18}); -for(const lights of ['on','off','none'])assert.deepEqual(roomFillStyle({...input,mode:'light',lights}),lights==='none'?null:palette[`light_${lights}`]); -const customPalette={...palette,light_off:{c:'#444444',a:.8},light_none:{c:'#dddddd',a:1},temp_cold:{c:'#aabbcc',a:.4}}; -assert.deepEqual(roomFillStyle({...input,mode:'light',lights:'off'},customPalette),customPalette.light_off); -assert.deepEqual(roomFillStyle({...input,mode:'light',lights:'none'},customPalette),customPalette.light_none); -assert.deepEqual(roomFillStyle({...input,temp:16},customPalette),customPalette.temp_cold); -const settings={fillMode:'custom',customFillSet:true,fillColor:'#ededed',fillOpacity:100,tempMin:20,tempMax:25,glowEnabled:true}; -for(const data of Object.values(SAMPLE_SCENARIOS))assert.deepEqual(previewFill(settings,data),{fill:{c:'#ededed',a:1},glowBase:null}); -assert.deepEqual(previewFill({...settings,customFillSet:false},SAMPLE_SCENARIOS.typical).fill,DEFAULT_CUSTOM_FILL); -assert.deepEqual(previewFill({...settings,fillOpacity:0},SAMPLE_SCENARIOS.typical).glowBase,palette.glow_base); -for(const fillMode of ['temp','light','lqi']) { - assert.deepEqual(previewFill({...settings,fillMode},SAMPLE_SCENARIOS.empty),{fill:null,glowBase:palette.glow_base}); - assert.deepEqual(previewFill({...settings,fillMode,glowEnabled:false},SAMPLE_SCENARIOS.empty),{fill:null,glowBase:null}); -} -assert.deepEqual(previewFill({...settings,fillMode:'light'},SAMPLE_SCENARIOS.off),{fill:palette.light_off,glowBase:null}); -assert.deepEqual(previewFill({...settings,fillMode:'light'},SAMPLE_SCENARIOS.typical),{fill:palette.light_on,glowBase:null}); - -// Optional parity audit against the actual, read-only House Plan source snapshot. -// Run: node room-fill.test.mjs ../houseplan-dev-source-review/src/logic.ts -if(process.argv[2]) { - const source=readFileSync(process.argv[2],'utf8'); - const extract=name=>{ - const start=source.indexOf(`export function ${name}(`); - const end=source.indexOf('\n}',start)+2; - assert.ok(start>=0&&end>start,`Missing source function ${name}`); - return source.slice(start,end).replace('export ',''); - }; - const paletteSource=source.slice(source.indexOf('export const DEFAULT_FILL_COLORS:'),source.indexOf('\n};',source.indexOf('export const DEFAULT_FILL_COLORS:'))+3).replace('export ',''); - const reference=runInNewContext(stripTypeScriptTypes(`${paletteSource}\nconst DEFAULT_CUSTOM_FILL={c:'#607d8b',a:.18}; const customFillOf=value=>value;\n${extract('lerpColor')}\n${extract('roomFillStyle')}\n({roomFillStyle,DEFAULT_FILL_COLORS});`)); - for(const [key,value] of Object.entries(palette))assert.deepEqual(value,JSON.parse(JSON.stringify(reference.DEFAULT_FILL_COLORS[key]))); - let count=0; - for(const mode of ['custom','temp','light','lqi'])for(const lqi of [null,0,40,92,110,180,255])for(const lights of ['none','off','on'])for(const temp of [null,16,20,22.4,25,30])for(const colors of [palette,customPalette]) { - const args={...input,mode,lqi,lights,temp}; - const expected=reference.roomFillStyle(mode,lqi,lights,temp,input.min,input.max,colors,input.custom); - assert.deepEqual(roomFillStyle(args,colors),JSON.parse(JSON.stringify(expected))); - count++; - } - console.log(`Source parity: ${count} combinations passed.`); -} -console.log('Room-fill behavior checks passed.'); diff --git a/docs/design/600-settings-dialogs/reference/room-settings.js b/docs/design/600-settings-dialogs/reference/room-settings.js deleted file mode 100644 index 15c5dc11..00000000 --- a/docs/design/600-settings-dialogs/reference/room-settings.js +++ /dev/null @@ -1,229 +0,0 @@ -/* Room settings: design prototype of the per-room dialog (edit mode). - Same visual system as Space and General settings. Field keys mirror the product's room dialog state - (_nameSel, _areaSel, _roomFill, _roomCustomFill, _roomTempMin/Max, _roomTempSrc, _roomHumSrc, _roomNameScale, _roomLabelScale). */ -import {registerDemoForm, notifyDemoFormClosed} from './demo-shell.js'; -import {$, $$, escape, clone, icon, hydrateIcons, showToast, attachHelp, closeHelp, helpOpen} from './ui.js'; - -const STORAGE_KEY = 'houseplan-room-settings-design-v1'; -/* Demo context: the room lives in Ground Floor; the space fill is Custom #EDEDED, comfort range 20–25 °C. */ -const SPACE = {title:'Ground Floor', fill:'custom', customFill:{c:'#ededed',a:1}, tempMin:20, tempMax:25, cardFontScale:1}; -const AREAS = [['area_kitchen','Kitchen & Living'],['area_hallway','Hallway'],['area_garage','Garage']]; -const SOURCES = [ - {value:'device:aqara-th-1', label:'Kitchen climate sensor', sub:'Aqara TH'}, - {value:'device:sonoff-snzb-2', label:'Living room sensor', sub:'SONOFF SNZB-02'}, - {value:'entity:sensor.kitchen_temperature', label:'Kitchen temperature', sub:'sensor.kitchen_temperature'}, - {value:'entity:sensor.kitchen_humidity', label:'Kitchen humidity', sub:'sensor.kitchen_humidity'}, - {value:'entity:sensor.thermostat_current', label:'Thermostat current temperature', sub:'sensor.thermostat_current'}, -]; -const defaults = { - nameSel:'Kitchen & Living', areaSel:'area_kitchen', - roomFill:'', roomCustomFill:null, roomTempMin:'', roomTempMax:'', - roomTempSrc:'', roomHumSrc:'', roomNameScale:100, roomLabelScale:100, -}; -function load() { - try { - const data = JSON.parse(localStorage.getItem(STORAGE_KEY)); - return data && Object.keys(defaults).every(k => k in data) ? data : clone(defaults); - } catch { return clone(defaults); } -} -let saved = load(); -let draft = clone(saved); -let srcOpen = null, srcFilter = ''; -const dirty = () => JSON.stringify(draft) !== JSON.stringify(saved); - -/* Dialog shell. */ -const dialog = document.createElement('dialog'); -dialog.id = 'room-settings'; dialog.className = 'settings-dialog'; dialog.setAttribute('aria-labelledby', 'rs-title'); -dialog.innerHTML = ` -
- -

Room settings

- ${escape(SPACE.title)} -
-
-
- - - -
`; -$('#demo-stage').append(dialog); -const subDialog = document.createElement('dialog'); -subDialog.id = 'rs-sub-dialog'; subDialog.className = 'sub-dialog'; subDialog.setAttribute('aria-labelledby', 'rs-sub-title'); -document.body.append(subDialog); -const content = $('#rs-content'); -const scroller = $('#rs-scroll'); - -const helpCopy = { - area:['Home Assistant area','Links the room to a Home Assistant area: its sensors feed the room card and its lights count as the room’s lights. Only areas not used by another room are listed.'], - fill:['Fill in this room','Overrides the space fill for this room only. “As the space” follows the space setting; Custom uses a fixed color that can also be inherited from the space until you change it.'], - tempRange:['Comfort range','This range applies only to this room. Empty fields inherit the corresponding space bounds; if the bounds are reversed, House Plan uses the smaller value as the minimum.'], - sources:['Sensor sources','By default the room card averages every temperature or humidity sensor in the room’s area. Pick a specific device or entity to show its reading instead.'], - sizes:['Font sizes','Scales the name and the metrics of this room card on top of the space-wide card size. 100% is the default.'], -}; -const help = attachHelp(content, scroller, helpCopy); - -/* Rendering helpers. */ -const card = (id, title, body, extra='') => `

${title}

${extra}
${body}
`; -const hint = text => `

${text}

`; -const fieldHeading = (text, key, forId='') => `
${key?help.button(key):''}
`; -function segmented(key, values, label, current) { - return `
${values.map(([value,title])=>``).join('')}
`; -} -function toggle(key, title, description, name, checked) { - return `
${icon(name)}
${description?`${description}`:''}
`; -} -const errorFor = key => { - if (key==='nameSel' && !draft.nameSel.trim()) return 'Enter a name for this room.'; - if (key==='tempRange') { - const bad = v => v.trim()!=='' && !Number.isFinite(Number(v.replace(',','.'))); - if (bad(draft.roomTempMin) || bad(draft.roomTempMax)) return 'Enter numbers, or leave a field empty to inherit the space bound.'; - } - if ((key==='roomNameScale'||key==='roomLabelScale') && (draft[key]===''||draft[key]<50||draft[key]>300)) return 'Choose a size between 50% and 300%.'; - return ''; -}; -const errorFields = ['nameSel','tempRange','roomNameScale','roomLabelScale']; -const allErrors = () => errorFields.map(k=>[k,errorFor(k)]).filter(([,m])=>m); -const errorSlot = key => `
${errorFor(key)}
`; - -function basicsCard() { - return card('basics','Basics', - `
${errorSlot('nameSel')}
`+ - `
${fieldHeading('Home Assistant area','area','rs-area')}
` - ); -} -function fillCard() { - const inherit = draft.roomFill === ''; - const effective = inherit ? SPACE.fill : draft.roomFill; - const custom = draft.roomCustomFill || SPACE.customFill; - const own = !!draft.roomCustomFill; - const modeDetail = inherit ? hint(`Following the space: ${({none:'no fill',lqi:'Zigbee signal',light:'Lights',temp:'Temperature',custom:'Custom color'})[SPACE.fill]}.`) - : `
${segmented('roomFill',[['none','None'],['lqi','Zigbee'],['light','Lights'],['temp','Temperature'],['custom','Custom']],'Fill in this room',draft.roomFill)}
`; - const colorDetail = effective==='custom' && !inherit - ? `
${custom.c.toUpperCase()}${own?``:''}
${hint(own?'Custom color for this room.':'Using the space color until you change it.')}
` - : ''; - const tempDetail = effective==='temp' - ? `
${fieldHeading('Comfort range','tempRange')}
${errorSlot('tempRange')}
ColdComfortHot
${draft.roomTempMin.trim()||draft.roomTempMax.trim()?`

`:hint(`Empty fields inherit the space bounds (${SPACE.tempMin}–${SPACE.tempMax} °C).`)}
` - : ''; - return card('fill','Fill', - toggle('fillInherit','As the space','Use the fill mode set for the whole space.','layers',inherit)+modeDetail+colorDetail+tempDetail, - help.button('fill') - ); -} -function sourceBlock(kind, label) { - const key = kind==='temp' ? 'roomTempSrc' : 'roomHumSrc'; - const val = draft[key], open = srcOpen===kind; - const picked = SOURCES.find(s=>s.value===val); - const picker = val || open ? `
${open?`
${SOURCES.filter(s=>!srcFilter||(s.label+' '+s.sub).toLowerCase().includes(srcFilter.toLowerCase())).map(s=>``).join('')||'

Nothing matches.

'}
`:''}
` : ''; - return `
${label}${segmented(key+'Mode',[['average','Average of the room’s sensors'],['pick','Specific device or entity']],label,val||open?'pick':'average')}${picker}
`; -} -function sourcesCard() { - return card('sources','Sensor sources', sourceBlock('temp','Temperature')+sourceBlock('hum','Humidity'), help.button('sources')); -} -function sizeRow(key, label) { - return `
50%300%
${errorSlot(key)}
`; -} -function sizesCard() { - return card('sizes','Font sizes', sizeRow('roomNameScale','Room name size')+sizeRow('roomLabelScale','Metrics size')+hint(`On top of the space-wide card size (${Math.round(SPACE.cardFontScale*100)}%).`), help.button('sizes')); -} - -function renderMain() { - closeHelp(); - content.innerHTML = basicsCard()+fillCard()+sourcesCard()+sizesCard(); - $$('.panel-scroll>.field-stack:first-child', content).forEach(el => { el.style.marginTop = '0'; }); -} -function updateChrome() { - const errors = allErrors(); - $('#rs-save').disabled = !dirty() || errors.length>0; - const status = $('#rs-status'); - status.classList.toggle('dirty', dirty()); - status.hidden = !dirty() && !errors.length; - status.textContent = dirty() ? 'Unsaved changes' : ''; - if (errors.length) status.innerHTML = ``; - errorFields.forEach(key => { const el = $(`#rs-error-${key}`), m = errorFor(key); if (el) { el.hidden = !m; el.textContent = m; } }); - $$('[data-field="nameSel"]', content).forEach(i => i.setAttribute('aria-invalid', String(!!errorFor('nameSel')))); - const hex = $('[data-hex="fill"]', content); if (hex) hex.textContent = (draft.roomCustomFill||SPACE.customFill).c.toUpperCase(); -} -const render = () => { renderMain(); updateChrome(); }; -function rerender(focusSelector) { - const top = scroller.scrollTop; renderMain(); updateChrome(); scroller.scrollTop = top; - if (focusSelector) $(focusSelector, content)?.focus({preventScroll:true}); -} -function updateField(el) { - const key = el.dataset.field; if (!key) return; - if (key==='fillInherit') { draft.roomFill = el.checked ? '' : (SPACE.fill==='custom'?'custom':SPACE.fill); if (el.checked) draft.roomCustomFill = null; rerender(); return; } - if (key==='roomFill') { draft.roomFill = el.value; if (el.value!=='custom') draft.roomCustomFill = null; rerender(`[data-field="roomFill"]:checked`); return; } - if (key==='fillColor' || key==='fillOpacity') { - const cur = draft.roomCustomFill || clone(SPACE.customFill); - if (key==='fillColor') cur.c = el.value; else { const n = Math.min(100, Math.max(0, Number(el.value))); if (Number.isFinite(n)) cur.a = n/100; } - const wasOwn = !!draft.roomCustomFill; draft.roomCustomFill = cur; - if (!wasOwn) rerender(`[data-field="${key}"]`); else updateChrome(); return; - } - if (key==='roomTempSrcMode' || key==='roomHumSrcMode') { - const kind = key==='roomTempSrcMode' ? 'temp' : 'hum', field = kind==='temp' ? 'roomTempSrc' : 'roomHumSrc'; - if (el.value==='average') { draft[field] = ''; srcOpen = null; } else { srcOpen = kind; srcFilter = ''; } - rerender(); return; - } - if (key==='roomNameScale' || key==='roomLabelScale') { draft[key] = el.value==='' ? '' : Number(el.value); $$(`[data-field="${key}"]`, content).filter(x=>x!==el).forEach(x=>{ x.value = el.value; }); updateChrome(); return; } - draft[key] = el.value; updateChrome(); -} -function handleAction(button) { - const action = button.dataset.action; - if (action==='review-errors') { const first = allErrors()[0]; if (!first) return; const field = $(`[data-field="${first[0]==='tempRange'?'roomTempMin':first[0]}"]`, content); field?.scrollIntoView({block:'center'}); field?.focus({preventScroll:true}); return; } - if (action==='reset-fill') draft.roomCustomFill = null; - if (action==='reset-temp') { draft.roomTempMin = ''; draft.roomTempMax = ''; } - if (action==='reset-roomNameScale') draft.roomNameScale = 100; - if (action==='reset-roomLabelScale') draft.roomLabelScale = 100; - if (action==='src-toggle') { srcOpen = srcOpen===button.dataset.kind ? null : button.dataset.kind; srcFilter = ''; rerender(srcOpen?`[data-src-filter="${srcOpen}"]`:`[data-action="src-toggle"][data-kind="${button.dataset.kind}"]`); return; } - if (action==='src-pick') { draft[button.dataset.kind==='temp'?'roomTempSrc':'roomHumSrc'] = button.dataset.value; srcOpen = null; rerender(`[data-action="src-toggle"][data-kind="${button.dataset.kind}"]`); return; } - rerender(); -} - -/* Open, close, save. */ -let closeDecision, lastSubTrigger; -function openSettings() { saved = load(); draft = clone(saved); srcOpen = null; render(); if (!dialog.open) dialog.show(); scroller.scrollTop = 0; $('#rs-title').focus({preventScroll:true}); } -function closeSettings() { dialog.close(); notifyDemoFormClosed('room-settings'); } -function showSub(title, body, actions) { - lastSubTrigger = document.activeElement; - subDialog.innerHTML = `

${title}

${body}
${actions}
`; - subDialog.showModal(); -} -function closeSub() { subDialog.close(); lastSubTrigger?.focus({preventScroll:true}); if (closeDecision) { const r = closeDecision; closeDecision = undefined; r(false); } } -function requestClose() { - if (!dirty()) { closeSettings(); return Promise.resolve(true); } - showSub('Discard your changes?', `

Your changes to ${escape(saved.nameSel)} haven’t been saved.

`, ''); - $('[data-sub="close"].primary', subDialog).focus(); - return new Promise(resolve => { closeDecision = resolve; }); -} -function saveSettings() { - if (!dirty() || allErrors().length) return; - const normalized = {...clone(draft), nameSel:draft.nameSel.trim()}; - try { localStorage.setItem(STORAGE_KEY, JSON.stringify(normalized)); } catch { showToast('Could not save locally. Enable browser storage and try again.'); return; } - saved = clone(normalized); draft = clone(saved); closeSettings(); showToast('Room settings saved in this browser.'); -} - -content.addEventListener('input', e => { - if (e.target.dataset.srcFilter) { srcFilter = e.target.value; const pos = e.target.selectionStart; rerender(`[data-src-filter="${e.target.dataset.srcFilter}"]`); const f = $(`[data-src-filter="${srcOpen}"]`, content); if (f) f.setSelectionRange(pos, pos); return; } - if (['checkbox','radio'].includes(e.target.type) || e.target.tagName==='SELECT') return; - updateField(e.target); -}); -content.addEventListener('change', e => { if (['checkbox','radio'].includes(e.target.type) || e.target.tagName==='SELECT') updateField(e.target); }); -content.addEventListener('click', e => { const b = e.target.closest('button[data-action]'); if (b) handleAction(b); }); -$('#rs-status').addEventListener('click', e => { const b = e.target.closest('button[data-action]'); if (b) handleAction(b); }); -$('#rs-close').addEventListener('click', requestClose); -$('#rs-cancel').addEventListener('click', requestClose); -$('#rs-save').addEventListener('click', saveSettings); -dialog.addEventListener('cancel', e => { e.preventDefault(); requestClose(); }); -dialog.addEventListener('keydown', e => { - if (e.key==='Escape' && helpOpen()) { e.preventDefault(); e.stopPropagation(); closeHelp(); return; } - if (e.key==='Escape' && srcOpen) { e.preventDefault(); e.stopPropagation(); const k = srcOpen; srcOpen = null; rerender(`[data-action="src-toggle"][data-kind="${k}"]`); return; } - if (e.key==='Escape' && !subDialog.open) { e.preventDefault(); e.stopPropagation(); requestClose(); } -}); -subDialog.addEventListener('cancel', e => { e.preventDefault(); closeSub(); }); -subDialog.addEventListener('click', e => { - const b = e.target.closest('button[data-sub]'); if (!b) return; - if (b.dataset.sub==='close') { closeSub(); return; } - if (b.dataset.sub==='discard') { const r = closeDecision; closeDecision = undefined; draft = clone(saved); closeSub(); closeSettings(); r?.(true); } -}); -window.addEventListener('beforeunload', e => { if (dialog.open && dirty()) { e.preventDefault(); e.returnValue=''; } }); -hydrateIcons(dialog); -registerDemoForm({id:'room-settings', title:'Room settings', description:'Name, area, fill override, sensor sources and font sizes of one room.', open:openSettings, requestClose}); diff --git a/docs/design/600-settings-dialogs/reference/serve.mjs b/docs/design/600-settings-dialogs/reference/serve.mjs deleted file mode 100644 index 02568daf..00000000 --- a/docs/design/600-settings-dialogs/reference/serve.mjs +++ /dev/null @@ -1,22 +0,0 @@ -import http from 'node:http'; -import {readFile, stat, realpath} from 'node:fs/promises'; -import path from 'node:path'; -import {fileURLToPath} from 'node:url'; -const root = await realpath(path.dirname(fileURLToPath(import.meta.url))); -const port = Number(process.env.HOUSEPLAN_SETTINGS_PORT || 8135); -const types = {'.html':'text/html; charset=utf-8','.css':'text/css; charset=utf-8','.js':'text/javascript; charset=utf-8','.svg':'image/svg+xml','.png':'image/png','.md':'text/plain; charset=utf-8','.json':'application/json; charset=utf-8'}; -const server=http.createServer(async(req,res)=>{ - try { - if(!['GET','HEAD'].includes(req.method)){res.writeHead(405);res.end();return;} - const pathname=decodeURIComponent(new URL(req.url,'http://127.0.0.1').pathname); - const requested=path.resolve(root,'.'+(pathname==='/'?'/index.html':pathname)); - const file=await realpath(requested); - const relative=path.relative(root,file); - if(relative.startsWith('..')||path.isAbsolute(relative)||!(await stat(file)).isFile()){res.writeHead(403);res.end();return;} - const data=await readFile(file); - res.writeHead(200,{'Content-Type':types[path.extname(file)]||'application/octet-stream','Cache-Control':'no-store','X-Content-Type-Options':'nosniff'}); - res.end(req.method==='HEAD'?undefined:data); - } catch {res.writeHead(404);res.end('Not found');} -}); -server.on('error',error=>{console.error(error.code==='EADDRINUSE'?`Port ${port} is already in use.`:error.message);process.exitCode=1;}); -server.listen(port,'127.0.0.1',()=>console.log(`Space settings design preview: http://127.0.0.1:${port}`)); diff --git a/docs/design/600-settings-dialogs/reference/styles.css b/docs/design/600-settings-dialogs/reference/styles.css deleted file mode 100644 index 99a11bf9..00000000 --- a/docs/design/600-settings-dialogs/reference/styles.css +++ /dev/null @@ -1,388 +0,0 @@ -/* Space settings: compact form, four cards in one flow. Visual system follows House Plan Summary (#437 / #505). */ -:root{font-family:Roboto,"Segoe UI",Arial,sans-serif;font-synthesis:none;color:#202124;background:#eef0f2;--ink:#202124;--muted:#666;--line:#ddd;--surface:#fff;--canvas:#f6f6f6;--accent:#4a9ec6;--accent-hover:#368db6;--tint:#f3f9fc;--danger:#db543d;--demo-toolbar-height:64px} -*{box-sizing:border-box} -body{margin:0;font-size:14px;line-height:1.4;height:100dvh;overflow:hidden;background:#eef0f2} -button,input,select{font:inherit} -button{cursor:pointer} -button:disabled{cursor:default} -svg{display:block} -button,input,select,[tabindex]{outline-offset:2px} -button:focus-visible,input:focus-visible,select:focus-visible,[tabindex]:focus-visible{outline:2px solid var(--accent)} -[hidden]{display:none!important} -.icon{width:20px;height:20px;fill:none;stroke:currentColor;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round;flex-shrink:0} -[data-icon]{display:inline-flex;align-items:center;justify-content:center;flex-shrink:0} -.demo-toolbar{height:var(--demo-toolbar-height);background:#fff;border-bottom:1px solid #dfe3e6} -.demo-toolbar-inner{height:100%;max-width:1200px;margin:auto;padding:10px 24px;display:flex;align-items:center;justify-content:space-between;gap:20px} -.demo-brand{display:flex;align-items:center;gap:16px;white-space:nowrap} -.demo-brand strong{font-size:16px;font-weight:600;color:#38434a} -.demo-brand>span{font-size:14px;line-height:20px;color:#7b858c;border-left:1px solid #e0e4e6;padding-left:16px} -.demo-form-switch{display:flex;align-items:center;gap:10px;font-size:14px;color:#6d777e} -.demo-form-switch .select-input{width:220px;min-height:42px;background-color:#fafbfc} -#demo-stage{height:calc(100dvh - var(--demo-toolbar-height));min-height:0;padding:24px;display:flex;flex-direction:column;align-items:center;justify-content:center;overflow:auto} -body[data-demo-form] #demo-stage{overflow:hidden} -#launch-screen{width:min(920px,100%);margin:24px auto auto} -.demo-gallery-heading{margin-bottom:24px} -.demo-gallery-heading h1{font-size:24px;line-height:1.3;margin:0 0 8px;font-weight:600} -.demo-gallery-heading p{margin:0;color:#768088;font-size:14px} -.demo-form-list{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:20px} -.demo-form-card{padding:24px;background:white;border:1px solid #dfe3e6;border-radius:12px} -.demo-form-card h2{font-size:20px;line-height:1.3;margin:0 0 8px;font-weight:600} -.demo-form-card>p{font-size:14px;color:#747f86;line-height:1.6;margin:0 0 20px} -.demo-examples{margin-bottom:20px} -.space-picker{display:flex;gap:8px;flex-wrap:wrap} -.space-picker button{padding:8px 12px;border:1px solid var(--line);background:white;border-radius:8px;color:var(--muted)} -.space-picker button.selected{border-color:var(--accent);color:var(--accent);background:var(--tint)} -/* Dialog shell: 560 CSS px wide, height bounded by the viewport; header and footer stay fixed, only the body scrolls. */ -dialog{border:1px solid var(--line);color:var(--ink);padding:0;background:#fff;box-shadow:0 18px 60px #19222642;overscroll-behavior:contain} -dialog::backdrop{background:#39464c75;backdrop-filter:blur(3px)} -.settings-dialog{position:relative;inset:auto;width:min(560px,100%);height:min(940px,calc(100dvh - var(--demo-toolbar-height) - 48px));max-width:none;max-height:none;border-radius:15px;margin:auto;flex-shrink:0;overflow:hidden;box-shadow:0 12px 36px #24324010} -.settings-dialog[open]{display:flex;flex-direction:column} -.dialog-header{min-height:70px;flex-shrink:0;display:flex;align-items:center;gap:12px;padding:12px 17px;background:#fff} -.dialog-header h1{font-size:20px;font-weight:600;line-height:1.3;margin:0;min-width:0} -.dialog-header h1:focus{outline:none} -.icon-button{display:inline-flex;align-items:center;justify-content:center;width:44px;height:44px;flex-shrink:0;border:0;border-radius:50%;background:#f1f1f1;color:var(--ink)} -.icon-button:hover{background:#e5e5e5} -.icon-button .icon{width:24px;height:24px} -.space-badge{margin-left:auto;padding:5px 8px;border-radius:7px;color:#777;background:#f6f6f6;font-size:14px;white-space:nowrap;max-width:180px;text-overflow:ellipsis;overflow:hidden} -.dialog-body{min-height:0;background:var(--canvas);padding:16px} -.settings-content{min-width:0;display:flex;flex-direction:column;gap:16px;padding:0} -.settings-content:focus{outline:none} -.settings-card{display:flex;flex-direction:column;padding:0;background:white;border:1px solid var(--line);border-radius:11px;margin:0;min-width:0;overflow:hidden;flex-shrink:0} -.settings-card:last-child{margin-bottom:0} -.card-heading{display:flex;align-items:center;justify-content:space-between;gap:10px;flex-shrink:0;margin:0;padding:16px 16px 12px} -.card-heading h2{font-size:20px;line-height:1.3;font-weight:600;margin:0} -.subsection{margin-top:22px} -.subsection h3{font-size:16px;line-height:1.4;font-weight:600;margin:0 0 12px} -.field-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px} -.general-grid{grid-template-columns:minmax(0,1fr) 175px} -.field{min-width:0;margin:0 0 15px} -.field:last-child{margin-bottom:0} -.field-grid>.field{margin-bottom:0} -.field-label{display:block;font-size:14px;font-weight:600;min-height:28px;line-height:28px;color:#666;margin:0 0 7px} -.field-hint{font-size:14px;line-height:1.5;color:#707070;margin:7px 0 0} -.text-input,.select-input{border:1px solid var(--line);border-radius:7px;background:#fff;color:var(--ink);min-height:44px;padding:8px 10px;min-width:0;max-width:100%;width:100%;font-size:14px;line-height:1.4} -.select-input{--select-inset:10px;cursor:pointer;appearance:none;padding-left:var(--select-inset);padding-right:calc(var(--select-inset) + 22px);background-image:url('assets/select-chevron.svg');background-position:right var(--select-inset) center;background-repeat:no-repeat;background-size:12px 7px} -.text-input:hover,.select-input:hover{border-color:#b3b3b3} -.text-input:focus,.select-input:focus{border-color:var(--accent)} -[aria-invalid=true]{border-color:var(--danger)!important} -.error{font-size:14px;line-height:1.5;color:#c34e3f;margin-top:6px} -.field-unit{display:flex;gap:9px;align-items:center;color:#777;font-size:14px} -.field-unit>.text-input{width:90px} -.unit-inside{display:inline-flex;height:44px;align-items:center;border:1px solid var(--line);border-radius:7px;background:#fff;flex-shrink:0;overflow:visible} -.unit-inside input{background:none;border:0;border-radius:7px;padding:8px 9px;min-width:5.5em;width:5.5em;height:100%;color:var(--ink);font-size:14px;line-height:1.4;font-variant-numeric:tabular-nums} -.unit-inside>span{padding:0 11px 0 3px;white-space:nowrap;flex-shrink:0;color:#888;font-size:14px} -.unit-inside:focus-within{outline:2px solid var(--accent);outline-offset:2px} -.unit-inside input:focus{outline:0} -.source-options{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px} -.choice-card{position:relative;padding:13px 35px 13px 12px;border:1px solid var(--line);border-radius:8px;cursor:pointer;display:flex;flex-direction:column;min-width:0;gap:5px;min-height:80px;justify-content:center} -.choice-card input{position:absolute;right:12px;top:16px;margin:0;width:16px;height:16px;accent-color:var(--accent)} -.choice-card:has(input:checked){background:var(--tint);border-color:#92c5dd} -.choice-card:hover{border-color:#92c5dd} -.choice-card strong{font-size:14px;font-weight:600} -.choice-card small{font-size:14px;color:#777;line-height:1.5} -.callout{display:flex;gap:8px;background:var(--tint);border-radius:8px;padding:11px 12px;color:#677f8c;font-size:14px;line-height:1.6;margin-top:12px} -.callout .icon{width:16px;height:16px;margin-top:2px} -.callout p{margin:0} -.callout.warning{background:#fff8ec;color:#917343} -.field-heading-with-help,.subsection-heading,.row-title{display:flex;align-items:center;gap:4px;min-width:0} -.field-heading-with-help{margin-bottom:7px} -.field-heading-with-help .field-label,.field-heading-inline .field-heading-with-help{margin:0} -.subsection-heading{margin-bottom:12px} -.subsection-heading h3{margin:0} -.row-title label{cursor:pointer} -/* Help: "?" trigger and floating tooltip, mirrors the product's hp-help. */ -.help-button{display:inline-flex;align-items:center;justify-content:center;border:0;background:transparent;color:#8a979f;border-radius:50%;width:32px;height:32px;padding:0;flex-shrink:0;cursor:help;-webkit-tap-highlight-color:transparent} -.help-button .icon{width:18px;height:18px;stroke:none;fill:currentColor} -.help-button:hover,.help-button:focus-visible,.help-button[aria-expanded=true]{background:rgba(127,127,127,.18);color:var(--ink)} -.help-button:focus-visible{outline:0;box-shadow:0 0 0 2px var(--accent)} -.help-popover{position:fixed;z-index:1000;top:0;left:0;width:max-content;max-width:min(320px,calc(100vw - 16px));max-height:calc(100vh - 16px);overflow:auto;padding:10px 12px;background:#fff;border:1px solid var(--line);border-radius:9px;box-shadow:0 8px 24px rgba(0,0,0,.18);color:#3d474d;font-size:14px;line-height:1.5;text-align:start;overflow-wrap:anywhere} -.upload-panel{border:1px dashed #a6c7d7;border-radius:8px;padding:14px;margin-top:12px;background:#f8fbfc;text-align:center} -.upload-panel.dragging{background:#e9f5fc;border-color:var(--accent)} -.upload-icon{color:#8babbc;display:flex;justify-content:center;margin-bottom:8px} -.upload-panel p{font-size:14px;margin:0 0 4px} -.upload-panel small{display:block;font-size:14px;color:#7a858b;margin-bottom:12px} -.upload-buttons{display:flex;align-items:center;justify-content:center;gap:8px;flex-wrap:wrap} -.upload-panel .button{font-size:14px} -.uploaded-file{display:flex;align-items:center;gap:10px;text-align:left;min-width:0} -.uploaded-file img{width:64px;height:54px;border:1px solid var(--line);background:white;border-radius:5px;object-fit:contain} -.uploaded-file>div{flex:1;min-width:0} -.uploaded-file strong{display:block;font-size:14px;white-space:nowrap;text-overflow:ellipsis;overflow:hidden} -.uploaded-file small{margin:4px 0 0} -.uploaded-file+.upload-buttons{justify-content:flex-start;margin-top:10px} -.setting-item{border-bottom:0} -.setting-item:last-child{border-bottom:0} -.setting-row{display:grid;grid-template-columns:28px minmax(0,1fr) 44px;grid-template-rows:auto auto;align-items:center;column-gap:10px;row-gap:4px;min-height:58px;padding:11px 0} -.row-icon{display:flex;align-items:center;justify-content:center;color:#7f8a91;width:28px;grid-column:1;grid-row:1;align-self:center} -.row-icon .icon{width:19px;height:19px} -.row-copy{display:contents} -.row-copy strong{display:block;font-size:14px;font-weight:600;line-height:1.4} -.row-copy small{display:block;grid-column:2;grid-row:2;font-size:14px;line-height:1.5;color:#777;margin:0} -.switch{width:44px;height:44px;flex-shrink:0;position:relative;display:flex;align-items:center;justify-content:center} -.switch input{position:absolute;inset:0;opacity:0;width:100%;height:100%;margin:0;cursor:pointer;z-index:1} -.switch-track{display:block;width:36px;height:22px;border:1px solid #bbb;border-radius:12px;background:#ccc} -.switch-track:before{content:"";position:absolute;left:7px;top:14px;width:16px;height:16px;border-radius:50%;background:white;box-shadow:0 1px 2px #0003;transition:transform .15s} -.switch input:checked+.switch-track{background:var(--accent);border-color:var(--accent)} -.switch input:checked+.switch-track:before{transform:translateX(14px)} -.switch input:focus-visible+.switch-track{outline:2px solid var(--accent);outline-offset:4px} -.switch input:disabled{cursor:default} -.switch input:disabled+.switch-track{opacity:.4} -.setting-row:has(input:disabled){color:#aaa;cursor:default} -.setting-row:has(input:disabled) .row-copy{opacity:.65} -.field-stack{margin-top:15px} -.segmented{display:flex;background:#f6f6f6;border:1px solid #e5e5e5;padding:3px;border-radius:7px;gap:3px;min-width:0} -.segment{position:relative;cursor:pointer;display:flex;align-items:center;justify-content:center;gap:7px;flex:1;min-width:0;color:#777;font-size:14px;font-weight:500;padding:9px 5px;border:1px solid transparent;border-radius:5px} -.segment input{position:absolute;opacity:0;inset:0;pointer-events:none} -.segment:has(input:checked){background:var(--tint);border-color:#b7d5e4;color:#398fb8} -.segment:has(input:focus-visible){outline:2px solid var(--accent);outline-offset:1px} -.segment .icon{width:16px;height:16px} -.line-sample{width:25px;border-top:2px solid currentColor} -.line-sample.dashed{border-top-style:dashed} -.color-field{display:flex;flex-wrap:wrap;align-items:center;gap:10px;min-height:44px;width:fit-content;max-width:100%;padding:8px 10px;border:1px solid #e5e5e5;border-radius:9px;background:#fafafa} -.color-input{width:38px;height:38px;border:3px solid white;outline:1px solid #d8d8d8;border-radius:6px;overflow:hidden;cursor:pointer;background:#fff;padding:0;flex-shrink:0} -.color-input::-webkit-color-swatch-wrapper{padding:0} -.color-input::-webkit-color-swatch{border:0;border-radius:3px} -.hex-code{font-size:14px;color:#888;font-variant-numeric:tabular-nums} -.color-opacity{display:flex;align-items:center;gap:8px;margin-left:8px;font-size:14px;color:#666} -.color-opacity .unit-inside{height:42px} -.color-opacity .unit-inside input{min-width:5em;width:5em} -.color-field .text-link{margin-left:3px} -.text-link{background:none;border:0;padding:5px 0;color:var(--accent);font-size:14px;font-weight:500;cursor:pointer} -.field-heading-inline{display:flex;align-items:center;justify-content:space-between;gap:10px} -.field-heading-inline .field-label{margin:0} -.range-line{display:flex;gap:15px;align-items:center;margin-top:13px} -.range-line input[type=range]{flex:1;min-width:0;accent-color:var(--accent);height:5px;cursor:pointer} -.range-line .unit-inside{flex:0 0 auto} -.range-line .unit-inside input{width:5.8em;min-width:5.8em} -.range-endpoints{display:flex;justify-content:space-between;padding-right:122px;margin-top:8px;font-size:14px;color:#999} -/* Compact density: one-line rows for uniform on/off lists (no description, no dividers). */ -.compact-list{display:flex;flex-direction:column;gap:2px} -.setting-item.compact{border-bottom:0} -.setting-row.compact{grid-template-rows:auto;min-height:40px;padding:2px 0} -.setting-row.compact .row-title{min-height:24px;line-height:24px} -.setting-row.compact .row-copy strong{font-weight:500} -.setting-row.compact .row-icon .icon{width:18px;height:18px} -.setting-item:has(+.value-block){border-bottom:0} -/* Icon tiles: multi-select panel for the values shown on a room card. */ -.value-block{margin-top:2px} -.value-block .field-label{margin-bottom:6px} -.value-tiles{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:8px} -.value-tile{position:relative;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:6px;min-height:72px;padding:10px 6px 9px;border:1px solid var(--line);border-radius:8px;background:#fff;color:#777;cursor:pointer;text-align:center;user-select:none;transition:background .12s,border-color .12s,color .12s} -.value-tile input{position:absolute;inset:0;width:100%;height:100%;opacity:0;margin:0;cursor:pointer} -.tile-icon{display:flex;align-items:center;justify-content:center;width:24px;height:24px;color:#8a949b} -.tile-icon .icon{width:22px;height:22px} -.tile-label{font-size:14px;font-weight:500;line-height:1.25;text-wrap:balance} -.value-tile:hover{border-color:#bbb} -.value-tile:has(input:checked){background:var(--tint);border-color:#b7d5e4;color:#398fb8} -.value-tile:has(input:checked) .tile-icon{color:var(--accent)} -.value-tile:has(input:focus-visible){outline:2px solid var(--accent);outline-offset:2px} -.value-tile:has(input:disabled){opacity:.45;cursor:default;border-color:var(--line);background:#fff;color:#777} -.value-tile:has(input:disabled) input{cursor:default} -.value-block .disabled-note{margin:10px 0 0} -.disabled-note{display:flex;align-items:flex-start;gap:8px;border-radius:7px;background:var(--tint);color:#71838d;padding:10px 12px;font-size:14px;line-height:1.5;margin-bottom:10px} -.disabled-note .icon{width:16px;height:16px} -.disabled-note button{background:none;border:0;padding:0;font-weight:600;color:var(--accent)} -.temperature-range{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px} -.temperature-range .unit-inside{display:flex} -.temperature-range .unit-inside input{width:100%;min-width:0} -.temp-legend{display:flex;gap:5px;margin-top:12px} -.temp-legend span{flex:1;color:var(--muted);font-size:14px;padding-top:7px;border-top:4px solid #4fc3f7} -.temp-legend span:nth-child(2){border-color:#66d17a;text-align:center} -.temp-legend span:nth-child(3){border-color:#ffd45c;text-align:right} -.north-setting{display:grid;grid-template-columns:minmax(0,1fr) 44px;gap:7px 16px;align-items:center} -.north-controls,.north-indicator{display:contents} -.north-controls>.field-heading-with-help{grid-area:1/1;margin:0} -.north-controls>.select-input{grid-area:2/1} -.north-indicator{color:#7a858b} -.north-indicator>span{grid-area:1/2;text-align:center;font-size:14px;line-height:28px} -.north-dial{grid-area:2/2;width:44px;height:44px;border:1px solid #ddd;border-radius:50%;background:#fafafa;display:flex;align-items:center;justify-content:center} -.compass-needle{width:24px;height:24px;stroke:currentColor;stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round;fill:none;color:#559fbe;transform-origin:50% 50%;transform-box:view-box} -.dialog-footer{display:flex;align-items:center;gap:10px;min-height:70px;padding:12px 17px;border-top:1px solid var(--line);background:#fff;flex-shrink:0} -.footer-tools,.footer-commit{display:flex;align-items:center;gap:8px;flex-shrink:0} -.footer-commit{margin-left:auto} -.button{min-height:44px;min-width:44px;display:inline-flex;align-items:center;justify-content:center;gap:7px;padding:9px 14px;border:1px solid var(--line);border-radius:8px;background:#fff;color:var(--ink);font-size:14px;font-weight:400;line-height:1.4;white-space:nowrap;transition:background .15s} -.button .icon{width:18px;height:18px} -.button:hover{background:#f6f6f6} -.button.primary{background:var(--accent);border-color:var(--accent);color:#fff;font-weight:500} -.button.primary:hover{background:var(--accent-hover)} -.button.primary:disabled{background:#b7d8e7;border-color:#b7d8e7;color:#fff} -.button.danger-outline{color:var(--danger);border-color:#db8d80;background:#fff} -.button.danger-outline:hover{background:#fdf4f2} -.button.danger{color:#fff;background:var(--danger);border-color:var(--danger)} -.save-status{display:flex;align-items:center;gap:6px;font-size:14px;color:#777;margin-left:8px;min-width:0} -.save-status .text-link{font-size:14px;color:#b87940} -.sub-dialog{width:470px;max-width:calc(100vw - 32px);border-radius:15px;padding:22px} -.sub-dialog::backdrop{background:#34464e45;backdrop-filter:blur(1px)} -.sub-heading{display:flex;align-items:center;justify-content:space-between;gap:15px;margin-bottom:12px} -.sub-heading h2{font-size:20px;font-weight:600;margin:0} -.sub-heading .icon-button{margin:-8px -8px 0 0} -.sub-dialog p{font-size:14px;line-height:1.6;color:#777;margin:0 0 18px} -.sub-actions{display:flex;gap:8px;justify-content:flex-end;margin-top:22px} -.sub-dialog .field-hint{font-size:14px} -.gallery-grid{display:grid;grid-template-columns:1fr 1fr;gap:12px} -.gallery-item{border:1px solid var(--line);border-radius:9px;background:white;padding:12px;font-size:14px;color:var(--ink);text-align:left} -.gallery-item:hover{border-color:var(--accent);background:var(--tint)} -.gallery-item img{width:100%;height:100px;object-fit:contain;margin-bottom:10px;border-radius:4px} -.gallery-item small{color:#888;font-size:14px;display:block;margin-top:3px} -.toast{position:fixed;z-index:10000;bottom:24px;left:50%;transform:translateX(-50%);border:1px solid #dde7e4;background:#fff;color:#4b6860;box-shadow:0 8px 35px #2b444329;padding:13px 19px;border-radius:10px;font-size:14px;max-width:calc(100vw - 32px)} -.dialog-scroll{flex:1;min-height:0;overflow-y:auto;overflow-x:hidden;background:var(--canvas);scrollbar-width:thin;scrollbar-color:#b9c0c4 transparent;overscroll-behavior:contain;border-top:1px solid var(--line)} -.settings-card{margin-bottom:0} -.settings-card:focus{outline:none} -.choice-card{min-height:104px} - -/* Each card owns its height; only the form body scrolls during normal editing. */ -.panel-scroll{padding:0 16px 16px} -.panel-scroll:focus{outline:none} -.panel-scroll:focus-visible{outline:2px solid var(--accent);outline-offset:-3px} -.setting-item:has(+.subsection){border-bottom:0} -.row-title{grid-column:2;grid-row:1;min-height:28px;line-height:28px} -.setting-row>.switch{grid-column:3;grid-row:1/3;align-self:center} -.subsection-heading{min-height:28px;line-height:28px} -.subsection-heading h3{line-height:28px} - -@media(max-width:820px){ - .settings-dialog{width:min(560px,100%)} -.demo-form-list{grid-template-columns:minmax(0,1fr)} -.dialog-body{grid-template-columns:minmax(0,1fr);padding:16px} -.settings-content{padding:0} -.dialog-header{padding:10px 14px} -.dialog-footer{padding:12px 14px} -.save-status{margin-left:0} -.space-badge{max-width:150px} -} -@media(max-width:480px){ -.color-tiles{grid-template-columns:repeat(2,minmax(0,1fr))} -.gs-color-row{grid-template-columns:minmax(0,1fr)} -.gs-color-row .color-field{justify-self:start} -.inline-field{flex-direction:column;align-items:stretch;gap:7px} -.radio-row{margin-left:0} -.inline-field .select-input{width:100%} -.value-tiles{grid-template-columns:repeat(2,minmax(0,1fr))} - .settings-dialog{width:100%;height:calc(100dvh - var(--demo-toolbar-height));border:0;border-radius:0} -.demo-toolbar-inner{padding:10px 12px;gap:12px} -.demo-brand strong{font-size:14px} -.demo-brand>span,.demo-form-switch>span{display:none} -.demo-form-switch .select-input{width:min(220px,56vw)} -#demo-stage{padding:0} -#launch-screen{padding:24px 16px} -.demo-form-card{padding:20px} -.dialog-header{gap:5px;min-height:66px;padding:10px 8px} -.dialog-header h1{font-size:20px} -.space-badge{max-width:105px;margin-right:7px;font-size:14px} -.dialog-body{padding:12px} -.card-heading{padding:14px 14px 12px} -.panel-scroll{padding:0 14px 14px} -.general-grid{grid-template-columns:minmax(0,1fr)} -.field-grid{gap:14px} -.source-options{grid-template-columns:minmax(0,1fr)} -.choice-card{min-height:80px;padding:12px 36px 12px 13px} -.color-opacity{margin-left:0} -.color-field{gap:8px} -.color-field .text-link{margin-left:0} - -.field-heading-inline{align-items:flex-start} -.range-endpoints{padding-right:125px} -.dialog-footer{display:grid;grid-template-columns:auto auto;gap:6px;padding:10px 12px} -.footer-tools{gap:6px;grid-column:1} -.footer-commit{gap:6px;grid-column:2;grid-row:1;margin-left:auto} -.dialog-footer .button{font-size:14px;padding:9px 10px} -.dialog-footer .button .icon{width:16px;height:16px} -.save-status{grid-column:1/-1;grid-row:2;justify-content:center;font-size:14px;min-height:20px} -.dialog-footer .save-status .text-link{padding:0} -.field-unit>.text-input{width:100px} -.sub-actions .button{font-size:14px;padding-left:10px;padding-right:10px} -.uploaded-file{flex-wrap:wrap} -.uploaded-file .button{margin-left:auto} -.segmented{flex-wrap:wrap} -.segment{flex-basis:42%} -} -@media(max-width:360px){.footer-tools .icon{display:none} -.dialog-footer .button{padding:9px} -.space-badge{max-width:80px} -} -@media(prefers-reduced-motion:reduce){*{transition:none!important;scroll-behavior:auto!important} -} -@media(forced-colors:active){.select-input{appearance:auto;background-image:none;padding-right:var(--select-inset)}} -.panel-scroll>.field-stack:first-child{margin-top:0} -.inline-field{display:flex;align-items:center;justify-content:space-between;gap:12px} -.inline-field .field-label{margin:0;flex:1 1 auto} -.inline-field .select-input{width:auto;flex:0 0 auto;min-width:200px} -.radio-field{flex-wrap:wrap;row-gap:6px} -.radio-row{display:flex;flex-wrap:wrap;gap:4px 18px;margin-left:auto} -.radio-option{display:inline-flex;align-items:center;gap:7px;min-height:28px;font-size:14px;color:#444;cursor:pointer;white-space:nowrap} -.radio-option input{margin:0;width:16px;height:16px;accent-color:var(--accent);cursor:pointer} -.radio-option:has(input:checked){color:#398fb8} -.subsection.divided{border-bottom:1px solid #e8e8e8;padding-bottom:18px} -/* General settings form (shares the Space settings system). */ -.card-title{display:flex;align-items:center;gap:4px;min-width:0} -.subsection.first{margin-top:0} -.gs-color-row{display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:8px 16px;min-height:56px} -.gs-color-row>.field-label,.gs-color-row>.field-heading-with-help{margin:0} -.gs-color-row .color-field{justify-self:end} -.gs-color-row+.gs-color-row{margin-top:4px} -.hex-note{font-size:14px;color:#8a949b} -textarea.text-input{min-height:72px;resize:vertical;font-family:inherit;line-height:1.5} -.action-line{display:flex;flex-wrap:wrap;align-items:center;gap:10px 14px;margin-top:12px} -.action-status{font-size:14px;color:#8a949b} -.north-controls>.north-input{grid-area:2/1} -.north-dial.unset .compass-needle{color:#b9c2c7} -.color-tiles{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px} -.color-tile{display:flex;flex-direction:column;gap:6px;min-width:0;padding:6px 6px 7px;border:1px solid var(--line);border-radius:8px;background:#fff} -.tile-swatch-wrap{position:relative;display:block;min-height:48px} -.color-tile .tile-swatch{position:absolute;inset:0;width:100%;height:100%;border:1px solid #d8d8d8;outline:0;border-radius:6px} -.color-tile .tile-swatch::-webkit-color-swatch{border-radius:4px} -.tile-name{position:absolute;inset:0;display:flex;align-items:center;padding:0 10px;pointer-events:none;font-size:14px;font-weight:500;line-height:1.25;color:#1f2a30;text-shadow:0 0 6px rgba(255,255,255,.35)} -.tile-swatch-wrap.on-dark .tile-name{color:#fff;text-shadow:0 0 6px rgba(0,0,0,.35)} -.tile-meta{display:flex;align-items:center;justify-content:space-between;gap:6px} -.tile-meta .hex-code{font-size:13px} -.tile-opacity{height:32px} -.tile-opacity input{min-width:3em;width:3.2em;padding:4px 4px 4px 7px;font-size:13px} -.tile-opacity>span{padding:0 7px 0 0;font-size:13px} -/* Room settings form. */ -.rs-grid{grid-template-columns:repeat(2,minmax(0,1fr))} -.source-picker{margin-top:10px} -.source-button{display:flex;align-items:center;gap:8px;text-align:left;cursor:pointer;padding-right:calc(var(--select-inset) + 22px)} -.source-button b{font-weight:500;color:var(--ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} -.source-ref{font-size:13px;color:#8a949b;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0} -.source-placeholder{color:#8a949b} -.source-panel{margin-top:6px;background:#fafafa;border:1px solid var(--line);border-radius:9px;padding:8px} -.source-list{max-height:220px;overflow:auto;margin-top:6px;display:flex;flex-direction:column;gap:2px} -.source-candidate{display:flex;flex-direction:column;align-items:flex-start;gap:2px;width:100%;padding:8px 10px;border:0;border-radius:7px;background:transparent;color:var(--ink);font-size:14px;text-align:left;cursor:pointer} -.source-candidate small{font-size:13px;color:#8a949b} -.source-candidate:hover{background:#f6f6f6} -.source-candidate.selected{background:var(--tint);color:#398fb8} -.rs-grid .field>.field-label{min-height:32px;line-height:32px} -/* Device on the plan form. */ -.source-toolbar{display:flex;flex-wrap:wrap;align-items:center;gap:8px 14px} -.source-toolbar .text-input{flex:1 1 200px} -.inline-check{display:inline-flex;align-items:center;gap:7px;font-size:14px;color:#555;cursor:pointer;white-space:nowrap} -.inline-check input{margin:0;width:16px;height:16px;accent-color:var(--accent)} -.segmented.disabled{opacity:.5} -.subsection.is-disabled .field-label,.subsection.is-disabled .field-heading-with-help{color:#9aa4ab} -.chip-row{display:flex;flex-wrap:wrap;gap:8px;margin:2px 0 10px} -.chip{display:inline-flex;align-items:center;gap:6px;min-height:36px;padding:0 4px 0 10px;border:1px solid #b7d5e4;border-radius:18px;background:var(--tint);color:#398fb8;font-size:14px;font-weight:500} -.chip .icon{width:16px;height:16px} -.chip-remove{display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;border:0;border-radius:50%;background:transparent;color:#398fb8;cursor:pointer} -.chip-remove .icon{width:14px;height:14px} -.chip-remove:hover{background:#dbeaf2} -.icon-field{display:flex;align-items:center;gap:8px} -.icon-preview{display:inline-flex;align-items:center;justify-content:center;width:44px;height:44px;flex-shrink:0;border:1px solid var(--line);border-radius:7px;background:#fafafa;color:#4a5560} -.icon-button.small{width:44px;height:44px;background:transparent;border:1px solid var(--line);border-radius:7px} -.icon-button.small .icon{width:20px;height:20px} -.field-hint.mono{font-family:ui-monospace,Menlo,Consolas,monospace;font-size:13px} -.preview-block{border:1px solid #d5e5ec;border-radius:9px;background:var(--tint);padding:14px} -.preview-head{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:12px} -.preview-head h3{margin:0;font-size:16px;font-weight:600} -.preview-tag{font-size:13px;color:#6b7b85;background:#dfeaf0;border-radius:12px;padding:3px 10px} -.preview-body{display:grid;grid-template-columns:150px minmax(0,1fr);gap:14px;align-items:start} -.preview-stage{display:flex;align-items:center;justify-content:center;height:150px;border-radius:8px;background:#e6e8ea} -.marker-mock{position:relative;display:flex;align-items:center;justify-content:center;transform:scale(var(--scale,1))} -.marker-plate{display:flex;align-items:center;justify-content:center;width:44px;height:44px;border-radius:50%;background:#fff;box-shadow:0 2px 6px rgba(0,0,0,.18);color:#1f2a30;transform:rotate(var(--rot,0deg))} -.marker-plate .icon{width:22px;height:22px} -.marker-badge{position:absolute;padding:3px 9px;border-radius:12px;background:#fff;box-shadow:0 2px 6px rgba(0,0,0,.18);font-size:13px;font-weight:600;color:#1f2a30;white-space:nowrap} -.marker-mock.pos-top .marker-badge{bottom:calc(100% + 4px)} -.marker-mock.pos-bottom .marker-badge{top:calc(100% + 4px)} -.marker-mock.pos-right .marker-badge{left:calc(100% + 4px)} -.marker-mock.pos-left .marker-badge{right:calc(100% + 4px)} -.preview-facts{display:grid;grid-template-columns:auto minmax(0,1fr);gap:8px 14px;margin:0;font-size:14px} -.preview-facts dt{color:#6b7b85} -.preview-facts dd{margin:0;font-weight:600;color:var(--ink);overflow-wrap:anywhere} diff --git a/docs/design/600-settings-dialogs/reference/ui.js b/docs/design/600-settings-dialogs/reference/ui.js deleted file mode 100644 index f21a58ed..00000000 --- a/docs/design/600-settings-dialogs/reference/ui.js +++ /dev/null @@ -1,111 +0,0 @@ -/* Shared UI primitives for the demo forms: icons, escaping, the "?" help tooltip and the toast. - Form-specific state stays in each form module. */ -export const $ = (selector, scope = document) => scope.querySelector(selector); -export const $$ = (selector, scope = document) => [...scope.querySelectorAll(selector)]; -export const escape = value => String(value ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); -export const clone = value => JSON.parse(JSON.stringify(value)); - -export const icons = { - floor:'', - sliders:'', - palette:'', - card:'', - sun:'', - close:'', - check:'', - layers:'', - info:'', - help:'', - image:'', - draw:'', - grid:'', - upload:'', - download:'', - folder:'', - borders:'', - type:'', - zigbee:'', - sofa:'', - door:'', - temperature:'', - humidity:'', - bulb:'', - eyeOff:'', - eye:'', - more:'', - copy:'', - trash:'', - compass:'', - reset:'', - chevron:'', - link:'', - radar:'', - tooltip:'', - wall:'', - broom:'', - wifi:'', - refresh:'', -}; -export const icon = name => ``; -export const hydrateIcons = (scope = document) => $$('[data-icon]', scope).forEach(el => { el.innerHTML = icon(el.dataset.icon); }); - -/* Toast: one shared status line at the bottom of the page. */ -let toastTimer; -export function showToast(message) { - const toast = $('#toast'); - clearTimeout(toastTimer); toast.textContent = message; toast.hidden = false; - toastTimer = setTimeout(() => { toast.hidden = true; }, 4200); -} - -/* Help: "?" trigger with a floating tooltip, mirrors the product's hp-help - (hover, click or focus opens; Escape, outside click or leaving closes). One surface serves every form. */ -const helpPopover = document.createElement('div'); -helpPopover.id = 'help-popover'; helpPopover.className = 'help-popover'; helpPopover.setAttribute('role', 'tooltip'); helpPopover.hidden = true; -document.body.append(helpPopover); -let helpTrigger = null, helpPinned = false, helpText = () => ''; -function positionHelp() { - if (!helpTrigger) return; - const margin = 8, gap = 6, a = helpTrigger.getBoundingClientRect(); - helpPopover.style.maxWidth = `${Math.max(0, Math.min(320, innerWidth - margin * 2))}px`; - const r = helpPopover.getBoundingClientRect(); - const left = Math.min(Math.max(margin, a.left), innerWidth - margin - r.width); - let top = a.bottom + gap, side = 'bottom'; - if (top + r.height > innerHeight - margin && a.top - gap - r.height >= margin) { top = a.top - gap - r.height; side = 'top'; } - helpPopover.style.left = `${Math.round(left)}px`; helpPopover.style.top = `${Math.round(top)}px`; helpPopover.dataset.side = side; -} -function openHelp(button, pinned) { - if (helpTrigger && helpTrigger !== button) helpTrigger.setAttribute('aria-expanded', 'false'); - helpTrigger = button; helpPinned = pinned || (helpPinned && helpTrigger === button); - helpPopover.textContent = helpText(button.dataset.help); - helpPopover.hidden = false; button.setAttribute('aria-expanded', 'true'); - positionHelp(); -} -export function closeHelp() { - if (!helpTrigger) return; - helpTrigger.setAttribute('aria-expanded', 'false'); helpTrigger = null; helpPinned = false; helpPopover.hidden = true; -} -export const helpOpen = () => !!helpTrigger; -document.addEventListener('pointerdown', e => { if (helpTrigger && !e.target.closest('button[data-help]') && !helpPopover.contains(e.target)) closeHelp(); }, true); -addEventListener('resize', () => { if (helpTrigger) positionHelp(); }); -/* Bind the triggers inside `scope` to a dictionary { key: [title, text] }; `scroller` keeps the tooltip anchored while the form scrolls. */ -export function attachHelp(scope, scroller, copy) { - const text = key => copy[key][1]; - scope.addEventListener('click', e => { - const button = e.target.closest('button[data-help]'); if (!button) return; - helpText = text; - if (helpTrigger === button && helpPinned) closeHelp(); else openHelp(button, true); - }); - scope.addEventListener('pointerover', e => { - const button = e.target.closest('button[data-help]'); - if (!button || e.pointerType === 'touch' || helpPinned) return; - helpText = text; openHelp(button, false); - }); - scope.addEventListener('pointerout', e => { - const button = e.target.closest('button[data-help]'); - if (button && helpTrigger === button && !helpPinned && !button.contains(e.relatedTarget)) closeHelp(); - }); - scope.addEventListener('focusin', e => { const b = e.target.closest('button[data-help]'); if (b && !helpPinned) { helpText = text; openHelp(b, false); } }); - scope.addEventListener('focusout', e => { const b = e.target.closest('button[data-help]'); if (b && helpTrigger === b && !helpPinned) closeHelp(); }); - scroller.addEventListener('scroll', () => { if (helpTrigger) positionHelp(); }, { passive: true }); - return { button: key => `` }; -} diff --git a/docs/design/600-settings-dialogs/screenshots/device-on-the-plan-2026-09-18.png b/docs/design/600-settings-dialogs/screenshots/device-on-the-plan-2026-09-18.png deleted file mode 100644 index 42bda4fa..00000000 Binary files a/docs/design/600-settings-dialogs/screenshots/device-on-the-plan-2026-09-18.png and /dev/null differ diff --git a/docs/design/600-settings-dialogs/screenshots/general-settings-2026-09-18.png b/docs/design/600-settings-dialogs/screenshots/general-settings-2026-09-18.png deleted file mode 100644 index 0cbfac85..00000000 Binary files a/docs/design/600-settings-dialogs/screenshots/general-settings-2026-09-18.png and /dev/null differ diff --git a/docs/design/600-settings-dialogs/screenshots/room-settings-2026-09-18.png b/docs/design/600-settings-dialogs/screenshots/room-settings-2026-09-18.png deleted file mode 100644 index f9dcb000..00000000 Binary files a/docs/design/600-settings-dialogs/screenshots/room-settings-2026-09-18.png and /dev/null differ diff --git a/docs/design/600-settings-dialogs/screenshots/space-settings-2026-09-18.png b/docs/design/600-settings-dialogs/screenshots/space-settings-2026-09-18.png deleted file mode 100644 index a65d1b98..00000000 Binary files a/docs/design/600-settings-dialogs/screenshots/space-settings-2026-09-18.png and /dev/null differ diff --git a/docs/specs/505-summary-panel-design-parity.md b/docs/specs/505-summary-panel-design-parity.md index fed71b61..ea9caae8 100644 --- a/docs/specs/505-summary-panel-design-parity.md +++ b/docs/specs/505-summary-panel-design-parity.md @@ -41,8 +41,9 @@ camera/layout changes, new panel-placement setting or modification of HA chrome. ## 2. Design authority and reproducible reference -[Designer reference](../design/505-summary-panel/README.md), including original -[index.html](../design/505-summary-panel/reference/index.html), CSS, JS and SVG. +Designer reference `docs/design/505-summary-panel/` (README, original +`reference/index.html`, CSS, JS and SVG) — removed from the tree by #681 after +the task shipped; recover it from git history (`git show f989fbfe:docs/design/505-summary-panel/README.md`). Archive received from owner: `макет.zip`, SHA-256 `bc754c16c9d9fc79dbbf093fcf8e14b96f8826fa01d1f0c8eccf68ab95357e15`. Open index.html in a browser; press the last header button, then its adjacent gear. diff --git a/docs/testing-notes/README.md b/docs/testing-notes/README.md deleted file mode 100644 index 5022001a..00000000 --- a/docs/testing-notes/README.md +++ /dev/null @@ -1,124 +0,0 @@ -# Приложения к TESTING.md — индекс - -Ручные чек-листы по поверхностям и приложения по отдельным issue, перенесённые -из [`docs/TESTING.md`](../TESTING.md) дословно (#634). Действующие правила и -гейты — в самом `TESTING.md`. Искать по номеру issue или теме: -`grep -n "#293" docs/testing-notes/README.md`. Полноту индекса и каждую ссылку -сверяет `test/testing-notes-index.test.mjs`: раздел без строки здесь — красный -тест. - -## [Ручной чек-лист по поверхностям](core-checklist.md) - -- [Modes (v1.25.0) ★](core-checklist.md#modes-v1250-) -- [Onboarding ★](core-checklist.md#onboarding-) -- [Spaces ★](core-checklist.md#spaces-) -- [Room markup editor ★](core-checklist.md#room-markup-editor-) -- [Devices on the plan ★](core-checklist.md#devices-on-the-plan-) -- [Device dialog (markers) ★](core-checklist.md#device-dialog-markers-) -- [Icon rules ★](core-checklist.md#icon-rules-) -- [Tap actions & gestures ★](core-checklist.md#tap-actions--gestures-) -- [Zoom / pan / labels](core-checklist.md#zoom--pan--labels) -- [Multi-client & concurrency ★](core-checklist.md#multi-client--concurrency-) -- [houseplan-space-card (read-only embedded)](core-checklist.md#houseplan-space-card-read-only-embedded) -- [Doors, windows & gates (v1.23.0+)](core-checklist.md#doors-windows--gates-v1230) - -## [Диалоги, формы и сводная панель](dialogs-and-forms.md) - -- [Вопрос о несохранённых настройках (#610)](dialogs-and-forms.md#вопрос-о-несохранённых-настройках-610) -- [Числовые поля со слайдером (#608)](dialogs-and-forms.md#числовые-поля-со-слайдером-608) -- [Toggle confirmation state (#103)](dialogs-and-forms.md#toggle-confirmation-state-103) -- [Unified color and opacity picker (#57)](dialogs-and-forms.md#unified-color-and-opacity-picker-57) -- [Unified picker coverage for every color field (#180)](dialogs-and-forms.md#unified-picker-coverage-for-every-color-field-180) -- [Contextual help (issues #68 and #86)](dialogs-and-forms.md#contextual-help-issues-68-and-86) -- [Help & private feedback (#43)](dialogs-and-forms.md#help--private-feedback-43) -- [v1.71 audit polish (#434)](dialogs-and-forms.md#v171-audit-polish-434) -- [Надёжность сводной панели (#493)](dialogs-and-forms.md#надёжность-сводной-панели-493) -- [Идентичность сводной панели в Masonry (#561)](dialogs-and-forms.md#идентичность-сводной-панели-в-masonry-561) -- [Доступность основного View (#565)](dialogs-and-forms.md#доступность-основного-view-565) - -## [Устройства и маркеры](devices.md) - -- [Device icon design package (#179)](devices.md#device-icon-design-package-179) -- [Dense device-marker hit ownership (#564)](devices.md#dense-device-marker-hit-ownership-564) -- [Device marker polish and pointer modality (#212)](devices.md#device-marker-polish-and-pointer-modality-212) -- [Device value badge (#90)](devices.md#device-value-badge-90) -- [HA-disabled binding gate](devices.md#ha-disabled-binding-gate) -- [HA Area marker relocation (#126)](devices.md#ha-area-marker-relocation-126) -- [Device display preview and face parity](devices.md#device-display-preview-and-face-parity) -- [Device icon package parity (#211)](devices.md#device-icon-package-parity-211) -- [Device marker geometry and input polish (#213)](devices.md#device-marker-geometry-and-input-polish-213) -- [Text marker shell shape (#217)](devices.md#text-marker-shell-shape-217) -- [Device lock and orange foreground palette (#219)](devices.md#device-lock-and-orange-foreground-palette-219) -- [Unified device status and pulse activity (#98)](devices.md#unified-device-status-and-pulse-activity-98) -- [Climate temperature opt-in (dev)](devices.md#climate-temperature-opt-in-dev) -- [Styling hooks and HA-formatted values (docs/STYLING-HOOKS.md, dev, unreleased)](devices.md#styling-hooks-and-ha-formatted-values-docsstyling-hooksmd-dev-unreleased) - -## [Геометрия: стены, проёмы, комнаты, холст](geometry.md) - -- [Stable wall-segment identity (#282)](geometry.md#stable-wall-segment-identity-282) -- [Legacy draft migration and atomic wall-chain writes (#314, #478)](geometry.md#legacy-draft-migration-and-atomic-wall-chain-writes-314-478) -- [Current-writer fixed point (#477)](geometry.md#current-writer-fixed-point-477) -- [Resize: реальный pointer pipeline (#293)](geometry.md#resize-реальный-pointer-pipeline-293) -- [Opening symbol centreline (#242, #250)](geometry.md#opening-symbol-centreline-242-250) -- [Empty-space lifecycle (#113)](geometry.md#empty-space-lifecycle-113) -- [Fixed card space (#210)](geometry.md#fixed-card-space-210) -- [Open passage (#157)](geometry.md#open-passage-157) -- [Independent-wall openings and structural axes (#132, #185)](geometry.md#independent-wall-openings-and-structural-axes-132-185) -- [Independent-wall opening jamb margin (#186)](geometry.md#independent-wall-opening-jamb-margin-186) -- [Room resize (docs/RESIZE.md)](geometry.md#room-resize-docsresizemd) -- [Infinite canvas (docs/CANVAS.md, dev)](geometry.md#infinite-canvas-docscanvasmd-dev) -- [«+» adds a space from anywhere (dev, unreleased)](geometry.md#-adds-a-space-from-anywhere-dev-unreleased) -- [Honest new-space display defaults (#204, dev, unreleased)](geometry.md#honest-new-space-display-defaults-204-dev-unreleased) -- [Wall thickness (docs/WALL-THICKNESS.md, Unreleased)](geometry.md#wall-thickness-docswall-thicknessmd-unreleased) -- [Wall chains, partitions and columns](geometry.md#wall-chains-partitions-and-columns) -- [Вписывание выбранной комнаты (#152)](geometry.md#вписывание-выбранной-комнаты-152) - -## [Декор, подложка, мебель, слои](decor-and-backdrop.md) - -- [Decor composition order (#231)](decor-and-backdrop.md#decor-composition-order-231) -- [Custom decor images (#51)](decor-and-backdrop.md#custom-decor-images-51) -- [Backdrop picture: move & scale (docs/DECOR-EDITOR.md §3, dev)](decor-and-backdrop.md#backdrop-picture-move--scale-docsdecor-editormd-3-dev) -- [«Already uploaded» plan picker (dev, unreleased)](decor-and-backdrop.md#already-uploaded-plan-picker-dev-unreleased) -- [The furniture library (docs/FURNITURE.md, dev, unreleased)](decor-and-backdrop.md#the-furniture-library-docsfurnituremd-dev-unreleased) -- [The furniture library (docs/FURNITURE.md, dev, unreleased)](decor-and-backdrop.md#the-furniture-library-docsfurnituremd-dev-unreleased-1) -- [Large backdrops (#39, docs/specs/039-large-backdrops.md)](decor-and-backdrop.md#large-backdrops-39-docsspecs039-large-backdropsmd) -- [Plan upload over HTTP and the 8 MB limit (#617)](decor-and-backdrop.md#plan-upload-over-http-and-the-8-mb-limit-617) -- [Hiding layers: decor, openings, zero-thickness walls (docs/UX-MODES.md)](decor-and-backdrop.md#hiding-layers-decor-openings-zero-thickness-walls-docsux-modesmd) - -## [Живые слои и интеграции](live-and-integrations.md) - -- [PDF export polish (#482)](live-and-integrations.md#pdf-export-polish-482) -- [Многоэтажный робот: карты и пространства (#162)](live-and-integrations.md#многоэтажный-робот-карты-и-пространства-162) -- [Vacuum trail smoothing (#209)](live-and-integrations.md#vacuum-trail-smoothing-209) -- [Live vacuums (docs/VACUUM.md)](live-and-integrations.md#live-vacuums-docsvacuummd) -- [Sun on the plan (docs/SUN.md)](live-and-integrations.md#sun-on-the-plan-docssunmd) -- [The text block on the plan (docs/DECOR-EDITOR.md §5, dev, unreleased)](live-and-integrations.md#the-text-block-on-the-plan-docsdecor-editormd-5-dev-unreleased) -- [Sun ray rim (docs/SUN.md «The rim», dev, unreleased)](live-and-integrations.md#sun-ray-rim-docssunmd-the-rim-dev-unreleased) -- [Coming back to the tab (docs/WARM-REMOUNT.md, dev, unreleased)](live-and-integrations.md#coming-back-to-the-tab-docswarm-remountmd-dev-unreleased) -- [Реальная raw-карта Zigbee2MQTT (#450)](live-and-integrations.md#реальная-raw-карта-zigbee2mqtt-450) -- [Порядок слоя Zigbee-топологии (#464)](live-and-integrations.md#порядок-слоя-zigbee-топологии-464) -- [Полнота источников радара (#545)](live-and-integrations.md#полнота-источников-радара-545) - -## [Инфраструктура тестов и съёмки](infrastructure.md) - - - [Issue #73 baseline and implementation (2026-08-11)](infrastructure.md#issue-73-baseline-and-implementation-2026-08-11) -- [Lazy editor runtime and frontend asset tree (#337)](infrastructure.md#lazy-editor-runtime-and-frontend-asset-tree-337) -- [Съёмка документации запускается с флагами детерминизма (#424)](infrastructure.md#съёмка-документации-запускается-с-флагами-детерминизма-424) -- [Воспроизводимость съёмки документации (#410, #422)](infrastructure.md#воспроизводимость-съёмки-документации-410-422) -- [Пустое совпадение `--test-name-pattern` (#650)](infrastructure.md#пустое-совпадение---test-name-pattern-650) - -## [Browser guards mutation registry (#659)](mutation-browser-guards.md) - -- [Measured effect](mutation-browser-guards.md#measured-effect) -- [Reviewed per-mutant inventory](mutation-browser-guards.md#reviewed-per-mutant-inventory) -- [Performance threshold](mutation-browser-guards.md#performance-threshold) -- [Browser harness integrity](mutation-browser-guards.md#browser-harness-integrity) -- [Paint, cascade and layer composition](mutation-browser-guards.md#paint-cascade-and-layer-composition) -- [Pointer geometry and trusted interaction](mutation-browser-guards.md#pointer-geometry-and-trusted-interaction) -- [Responsive DOM layout](mutation-browser-guards.md#responsive-dom-layout) -- [Custom-element and HA browser lifecycle](mutation-browser-guards.md#custom-element-and-ha-browser-lifecycle) - -## [История прогонов и партий](history.md) - -- [Last self-run](history.md#last-self-run) -- [Batch 2026-08-04 (dev, unreleased)](history.md#batch-2026-08-04-dev-unreleased) diff --git a/docs/testing-notes/core-checklist.md b/docs/testing-notes/core-checklist.md deleted file mode 100644 index 2cbf3100..00000000 --- a/docs/testing-notes/core-checklist.md +++ /dev/null @@ -1,1156 +0,0 @@ -# Ручной чек-лист по поверхностям - -> Приложение к [`docs/TESTING.md`](../TESTING.md): перенесено оттуда дословно (#634). -> Индекс всех приложений — [`README.md`](README.md). - -## Modes (v1.25.0) ★ - -- [ ] The card always loads in **View**; edit modes are never restored [manual] -- [ ] View: pan/zoom/space-switch/tap/long-press/tooltips only — dragging an icon, - label or opening does nothing; panning may start on top of an icon [manual] -- [ ] View header: space tabs + count + zoom + editor tabs, the general-settings - cog and the per-space gears (visible in EVERY mode since v1.30.1/v1.30.3 - for users who may edit); no editor toolbars [auto: smoke_modes] -- [ ] Space-tab reorder (#243): in an editor with at least three spaces, use a - real mouse drag while browser pointer capture remains on the held tab; - moving left resolves the tab under the cursor and paints its left divider, - moving right paints the right divider, and each valid drop saves exactly - once [auto: smoke_space_tab_reorder; golden: space-tab-drop-before-light, - space-tab-drop-after-dark] -- [ ] Move a held space from a valid target out over the plan: the divider - clears immediately and release does not save. `pointercancel` and removing - the card mid-drag also end the gesture without changing order - [auto: smoke_space_tab_reorder] -- [ ] A sub-threshold mouse gesture remains a tab click; the next click after an - outside release still works. Touch, View and a card fixed to one `floor` - never expose reordering [auto: smoke_space_tab_reorder] -- [ ] Plan: markup toolbar, space gears, +space, ⚙ palette; device icons hidden, - labels/openings draggable; orange stage frame [manual] -- [ ] Devices: icon drag works, click opens the marker editor directly; +/👁/↺/⬡ - buttons; accent stage frame [manual] -- [ ] Mode tabs hidden for non-admin users; segmented control highlights the active mode -- [ ] Openings in View (v1.28.1+): the door/window/gate itself is a pure drawing — no - cursor change, no hover outline, no hit target, no click, regardless of - bindings [manual] -- [ ] The LOCK BADGE is the one exception: when a lock is bound it is shown and - clickable in View (pointer cursor, click → door/lock info card); inert in - Plan so it does not fight editing [manual] -- [ ] Device icons in View show a pointer cursor (no grab); grab only in the - Devices mode [manual] -- [ ] In Plan an opening is interactive: grab cursor, hover outline, drag along - walls, click (any tool) opens its properties — with a 3 px drag threshold - since v1.43.1, so a tap is never swallowed [auto: smoke_inert_openings] -- [ ] Opening drag rulers (2026-08-03): while an opening is dragged, a measure - badge on EACH shoulder shows the along-the-wall distance from the wall end - to the nearest opening edge, live; the wall is ONE room's edge — the edge - the opening is snapped to — so a neighbouring room's collinear edge is - never merged in; at that edge's center (±half a grid step) a - perpendicular dashed tick appears and the center magnet-snaps; there is - no modifier that disables the magnet; badges and tick vanish on release - [auto: smoke_opening_measure + unit openingShoulders] -- [ ] Physical rulers while PLACING a new opening (#238): one room shows two - lines/four endpoint ticks from the preview jambs to the physical inner - face endpoints. A shared wall shows four independently resolved lines/ - eight ticks, two on each room face. A finished independent wall stops per - direction at the nearest physical face of a connected wall/partition and - falls back to its own endpoint where none exists. Lines, ticks and labels - update in the same frame as the preview and are pointer/ARIA inert; saved - opening drag retains the legacy two badges and no new lines - [unit: opening-dimensions; auto: smoke_opening_inner_distances + - smoke_opening_measure] -- [ ] While PLACING a new opening: pressing **Opening** only - opens the shared secondary tray; choosing Window / Door / Gate arms the - 120 / 90 / 300 cm session preset. Moving over a physical wall shows the - complete architectural symbol at 50% opacity, above the masonry, together - with the physical dimension badges and the existing centre tick/magnet. The - preview accepts pointer hits anywhere inside a thick wall body, is absent - on virtual spans and existing openings, and never carries an interactive - or persistent identity. A direct click without prior hover resolves the - same candidate authoritatively and opens its dialog. Save and Cancel keep - the selected preset for repeated placement; tool/mode/space exit and Esc - clear it [unit: opening-placement; auto: smoke_opening_preview + - smoke_opening_measure; golden: opening-placement-door-thick-wall-dark] - -## Onboarding ★ - -- [ ] Empty config, HA has floors → floors-import wizard offers them sorted by level [manual] -- [ ] Wizard: uncheck all → "Create" disabled; "Start from scratch" → classic dialog -- [ ] Wizard: N floors → space dialog per floor with prefilled name and progress "i of N"; Skip skips one; Cancel aborts the whole queue [manual] -- [ ] After the last wizard space (or first manual space) → markup mode auto-opens with a toast -- [ ] Empty config, no floors → classic "New space" dialog auto-opens once per session -- [ ] All floors skipped, nothing created → empty state with "Add space" button remains usable - [auto: smoke_optional_space_model] - -## Spaces ★ - -- [ ] Create with an image (SVG, PNG, JPG, WebP) → correct aspect, crisp at zoom (SVG) -- [ ] Oversized plan (>8 MB) → readable error toast, dialog stays open -- [ ] Create with "No image — I'll outline rooms by hand": orientation landscape/portrait/square respected [manual]; borders+names default ON [manual] -- [ ] Draw-space (no background) renders a WHITE canvas (paper-like), markup works on it; room borders/names stay legible on white [manual] -- [ ] Edit: rename; replace image; **switch image→draw detaches the plan** [manual] -- [ ] Delete space with rooms/devices → tab disappears, layout of other spaces untouched -- [ ] Delete the last space → empty state without console errors; active editor - gestures and drafts are aborted, and creating the first space remains available - [auto: smoke_optional_space_model] -- [ ] Display settings: borders toggle, names toggle, color picker + opacity slider live-preview after save, fill selector [manual] -- [ ] Fill "zigbee": rooms tint red→green by average LQI; rooms without zigbee stay unfilled [manual] -- [ ] Fill "lights": yellow when any light on, grey when all off, unfilled when the room has no lights [manual]; toggling a light from the plan recolors the room -- [ ] Fill "temperature": blue below the comfort range, green inside, yellow above [manual]; comfort bounds editable inline on the radio row (swapped bounds tolerated [manual], clearing a field cannot zero a bound [manual]); rooms without a temperature reading stay unfilled [manual] -- [ ] Fill mode is a radio group (no dropdown); labels carry no color legend -- [ ] Room hover adds a subtle accent wash and double contour without changing - the underlying room fill or Glow brightness -- [ ] Room tooltip shows average room temperature and humidity after the area line and before LQI; missing values are omitted [auto: smoke_ux_fixes] -- [ ] General settings can hide only the room tooltip: default/Cancel/save/reopen - semantics, skipped area work, persistent room highlight, unaffected - device tooltip and restoration on the next mouse move - [auto: smoke_room_tooltip_toggle] -- [ ] Average room temperature counts ONLY thermometer/air-monitor devices — fridges, TRV heads, - smart-plug chip temperatures (`*_device_temperature`) and diagnostic-category temps are excluded [manual] -- [ ] Space dialog is 500 px wide; the comfort-bounds inputs are compact (56 px) -- [ ] The scale input is compact (72 px), not full-width; it shows cm in metric - HA and inches in imperial HA [manual; auto: smoke_space_scale_defaults] -- [ ] A new manual space and every floor-import draft start at 1 cm in metric HA - or exactly 1 inch/2.54 canonical cm in imperial HA. Opening and saving an - existing 5 cm, fractional or missing legacy value without editing the - field is lossless; changing language does not rewrite the canonical draft - [auto: smoke_space_scale_defaults] -- [ ] Physically equivalent rich fixtures at 1 cm and 5 cm have equal View, - Plan-with-grid-masked and static-card pixels/critical bounds. The grid has - five times the intervals only; openings retain their edge hit target, and - physical/screen-fixed layers are not double-scaled - [auto: smoke_grid_scale_invariance; unit: grid-scale.test.mjs, - opening-symbol.test.mjs, canvas.test.mjs] -- [ ] General settings (⚙ in the header): fill colors grouped by mode (lights on/off/none, - temp cold/comfy/hot, LQI weak/strong), each with its own opacity slider [manual]; - Reset restores defaults; saving defaults stores nothing [manual] -- [ ] Custom fill colors apply to the full card AND the static space-card -- [ ] **Room colour follows the room's own mode (#581):** a room colour counts - only with the room's own «Свой цвет»; «Как у пространства» clears the - colour draft at once, the saved room keeps neither `fill_mode` nor - `custom_fill`, and the plan paints the space colour. A colour stored - without the mode (an orphan from an older editor) paints the space colour, - opens as «Как у пространства» without a colour row and is dropped by a - plain save; `name_scale`/`label_scale` survive [unit: `logic.test.mjs` - `#581 AC1`; auto: `smoke_room_settings` step 7, `smoke_space_settings` - `customRoomOrphanInheritsSpace`; mutation: `room-orphan-colour-wins-again`] -- [ ] LQI gradient interpolates between the configured weak/strong colors [manual] -- [ ] Per-space "Show zigbee signal (LQI)" toggle hides/shows the badges next to - devices and the signal line in room tooltips for that space only [manual] -- [ ] Device icon badge is centred exactly on its point (no 1 px down-right drift) [manual] -- [ ] Device glyph is centred within its badge (no vertical drift — real ha-icon is block+line-height) [manual] -- [ ] Room hover highlight still works when custom borders/fills are on -- [ ] Settings persist across reload and other browsers (server-side) - -## Room markup editor ★ - -- [ ] The toolbar has one **Walls** tool and no separate Room outline or - Partition drawing button; Split remains available [auto: - smoke_unified_wall_tool + unified-wall-tool-source.test] -- [ ] Grid appears; dots snap; the wall chain draws pair-by-pair; shared walls reused -- [ ] Ruler: while drawing, the length of the current segment follows the cursor - (metres, or feet+inches on an imperial HA); scale = canonical per-space - `cell_cm` (new-space default 1 cm or 1 inch; missing legacy fallback 5 cm) -- [ ] Every completed segment is saved immediately as one ordinary partition. - Changing tool, editor or floor clears only the session-local chain state; - accepted walls stay unchanged and are not resumed after reload/remount - [auto: smoke_unified_wall_tool + smoke_free_walls + - smoke_plan_snap_overlay] -- [ ] A legacy warm-viewport token `partition` still opens the unified Walls - tool, but `partition` is not a runtime tool-state, dispatch branch or - golden-matrix option [auto: wall-face-graph.test + - unified-wall-tool-source.test + golden-matrix.test] -- [ ] Re-selecting Walls, Reset, pan, pinch, a second pointer, `pointercancel` - and a suppressed synthetic click never finish a chain or save an extra - segment [auto: smoke_unified_wall_tool] -- [ ] The active segment keeps a visible thin axis and endpoint above a thick - preview. Distinct nodes inside the ambiguity radius fail closed with a - zoom prompt. Shift constrains the actual endpoint to the nearest exact - 45° ray, including exact ray/wall intersections; the angle colour follows - the stored vector, not pointer intent [unit: plan-snap-overlay.test.mjs; - auto: smoke_plan_snap_overlay + smoke_plan_drawing_repairs]. -- [ ] With no active chain, a click strictly inside the smallest unoccupied - exact wall face offers a room; boundary/snap hits and Shift bypass it. - Keep/Cancel is a true no-op. If one endpoint→endpoint or - endpoint→solid-line repair closes the face within 2 physical cm, the red - diagnostic is offered and moves geometry only together with Create. - A larger gap, hosted-opening mover or multiple possible repairs fails - closed [unit: wall-face-graph.test.mjs + wall-face-repair.test.mjs; auto: - smoke_plan_drawing_repairs]. -- [ ] Deleting a room uses an accessible Keep walls / Delete walls / Cancel - dialog. Keep materialises only exclusive positive solid intervals as - partitions and rehosts their openings; Delete cascades only openings on - those exclusive walls. Shared walls/openings, explicit partitions and - partition-hosted openings survive. Either accepted choice is one - Undo/Redo/save transaction [unit: room-deletion.test.mjs; auto: - smoke_plan_drawing_repairs]. -- [ ] There is no "Erase" tool in the markup toolbar (removed in v1.19.0) -- [ ] Rooms never overlap (v1.20.0): a click strictly inside an existing room is refused with a - toast; a click ON a shared wall (including mid-span of a longer neighbour wall) still works -- [ ] Closing an outline drawn AROUND an existing room is refused; the outline stays open -- [ ] Merge (v1.21.0): two rooms sharing a wall merge into one; the dialog picks the surviving - name/area; rooms touching only at a corner or apart are refused with a toast -- [ ] Split (v1.21.0): click a room, then two points on its walls — the bigger part keeps the - name/area/devices, the smaller opens the new-room dialog; Cancel leaves the room whole -- [ ] Split: a cut with an end off the wall, or along a wall, is refused with a toast -- [ ] Split: the click snaps to the nearest wall, so it works on non-grid-aligned rooms - (imported/legacy polygons), not only on rooms drawn on the current grid [manual] -- [ ] Split: a click far from any wall (middle of the room) is a miss with a toast — - the wall-snap pull is capped, accidental clicks do not pick a wall [manual] -- [ ] Esc / Ctrl+Z removes the last dot (and its line); Reset clears the active path -- [ ] A latest segment that creates bounded endpoint, T or X faces opens the - room queue in area/key order. Existing exact/partial rooms and physical - gaps, including opening cuts, are excluded; nested rooms remain eligible - [unit: wall-face-graph; auto: smoke_unified_wall_tool + - smoke_room_autoclose] -- [ ] Create and Keep as walls answers are buffered. The last answer applies all - rooms and unconsumed wall atoms as one Undo/Redo transaction; Cancel/Esc - restores the terminal draft without partial rooms [auto: - smoke_unified_wall_tool] -- [ ] A clean divider across one existing room offers only the smaller child; - the larger child keeps the original room id, name, area binding, settings - and device placement [auto: smoke_unified_wall_tool] -- [ ] Room dialog: area list shows only unassigned areas; picking an area prefills the name -- [ ] Room dialog uses the medium width and its body has no horizontal overflow; - long options stay inside it at desktop and narrow widths [auto: - smoke_editor_tabs; manual: narrow viewport] -- [ ] "No area" room (decorative) requires a name; saves with `area: null` -- [ ] Cancel in a Walls face queue restores the persisted terminal draft -- [ ] Saving a room with an area: area devices appear with icons; positions are fixed into the layout [manual] -- [ ] Delete-room consequences are chosen explicitly as described above; there - is no Erase tool -- [ ] Device icons hidden during markup; visible again on exit - -## Devices on the plan ★ - -- [ ] Auto devices appear only in rooms bound to their area [manual] -- [ ] **Entity/parent ownership (#226):** placing `entity:X` removes X from its - automatic parent. A visible unclaimed sibling keeps one residual parent; - an empty or HA-hidden-only residual removes it. State, primary/action, - `allEntities`, light/Glow and LQI cannot see X twice - [auto: unit `devices.test.mjs`; browser `smoke_entity_parent_dedup.mjs`]. -- [ ] Two explicit markers `entity:X` + `device:D` coexist and the device stays - complete. A user-hidden live entity marker still owns X, while an entity - tombstone returns X to the parent; disabled entity ownership follows the - known full-registry relation [auto: unit `devices.test.mjs`]. -- [ ] The #94 curtain boundary is deliberate: untouched hidden `cover.*` stays - cover-first, but after placing the only visible auxiliary switch the - hidden-only automatic remainder disappears. An explicit `device:D` - restores the complete curtain beside that entity marker - [auto: unit `devices.test.mjs`]. -- [ ] Renderer and seeder cannot drift back to exact-binding-only ownership - [mutation: `entity-marker-kept-in-parent-device`, - `entity-marker-parent-seeded`]. -- [ ] Filtering hides bridges/groups/scenes/excluded integrations; 👁 "show all" reveals [manual] -- [ ] Duplicate "name|area" numbered ("Lamp", "Lamp 2") [manual] -- [ ] Light groups fold their single lamps; `group_lights=false` unfolds [manual] -- [ ] Drag anywhere (no edit mode), snaps to grid, persists after reload, per space -- [ ] ↺ reset restores auto layout after confirm -- [ ] Temperature badge on thermometers; LQI value under zigbee icons with red→green color -- [ ] Unified live states (dev, owner 2026-08-05; lock palette #219): actual - work is yellow; open door/window and open valve are orange; unlocked lock - is red and locked lock is green; covers stay neutral and morph their icon; - unavailable is faded. The plate and the activity effect come from the same - semantic resolver -- [ ] State icons (v1.26.0): auto icons morph with state — door/window/garage open↔closed, - lock locked↔unlocked, bulb on; custom icons and unavailable states never morph [manual] -- [ ] display "Value instead of an icon": the marker shows the measurement (°/%/unit) - as its body, small badges hidden; non-numeric fallback keeps the icon [manual] -- [ ] RGB lights (v1.27.0, contract changed in v1.52.0): a lamp's colour lives - in its glow spot and the activity-effect fallback ONLY — the icon/badge/border get - no RGB tint; explicit activity color still wins; off lights unchanged - [auto: smoke_light_badges + smoke_rgb_alarm] -- [ ] Alarm pulse (v1.27.0, unified dev): leak/smoke/gas/CO/siren in `on` - and an alarm control panel in `triggered` - get a red plate and red pulse over every dynamic display mode and even - with ordinary live-state dressing off; `static_icon` is the deliberate - neutral exception after an editor warning; clears on 'off'; unavailable - never alarms [manual]; reduced-motion is static -- [ ] Render cost (v1.43.1, audit L1): geometry (space model, open pairs) is - computed once per config change, not per HA state push — smoke asserts - zero recomputations across 10 state pushes and recomputation after an - edit; the plan still renders dashes/islands correctly [auto: smoke_render_perf] -- [ ] Opening tap vs drag (v1.43.1, audit L4): a tap on a door in the Plan - editor opens its properties (3 px threshold like the other pipelines) and - writes nothing; a real drag that ends where it started also writes nothing [auto: smoke_render_perf] -- [ ] Concave containment (v1.43.1, audit G2): an island room inside a U- or - L-shaped parent is accepted and punches the evenodd hole; a traced - duplicate outline is still NOT containment [auto: smoke_inert_openings] -- [ ] Backend hardening (v1.43.1, audit B2-B5): the admin check fails closed - when the entry is unavailable; layout/set and config/set without - expected_rev may bootstrap revision zero, but over a non-empty store each - returns `conflict` without changing its document/rev/backups or firing an - event (including a no-op body); explicit stale revisions are rejected; a - production config-writer inventory requires expected_rev; - NaN/Infinity coordinates and oversized collections are rejected - [auto: tests_backend/test_ha_websocket.py + coordinate-write-barrier-guard.test] -- [ ] Save race (v1.43.0, audit L2): make a markup edit, then press Save in any - dialog within 500 ms (or let another client save) — the markup edit must - survive and reach the server; a failed reload now shows a toast [auto: unit: tests_backend] -- [ ] Niche split (v1.43.0, audit G1): a cut that starts AND ends on the same - wall carves a niche; the two parts' areas must sum to the original (the - invariant is enforced in code and asserted for every split test) [auto: smoke_save_race] -- [ ] Authenticated content (v1.43.0, audit B1): plan images and marker files - are only reachable through /api/houseplan/content/… with a session; the - old /houseplan_files/plans|files paths return 404 after a restart; old - stored URLs keep working (rewritten on read) [auto+manual] -- [ ] Every editor option is storable (v1.45.3, issue #3): set a sensor to - "value instead of an icon" and save — no validation error, the value shows - on the plan after a reload. Same for each tap action and each fill mode - [auto: backend test_every_display_mode_the_editor_offers_is_accepted and - neighbours, test_a_marker_showing_its_value_can_be_saved] -- [ ] Room settings button (dev): detached from the (movable) name label — - always at the room's geometric centre, one button-height below it; sized - at 70% of a device icon and zooming WITH the plan; the small metric rows - under the room name now show in the plan editor too - [auto: smoke_room_cards gearDetached/plainInPlan] -- [ ] Working plate remains universal: in a source-glow space a lit lamp or - other actually working device stays yellow in View and in every editor; - the glow pool is an additional spatial indicator, not a replacement - [auto: smoke_light_badges] -- [ ] Size/angle parity (v1.52.1, HP-1513-01): a marker with size 3 / angle 37 - scales x3 and rotates on BOTH cards [auto: smoke_size_angle_parity] -- [ ] Tap runs an automation (dev, owner's spec 2026-07-29): the tap-action - list has "Run automation/script/scene" with a searchable picker; saving - without a target is refused; the confirm checkbox guards toggle AND run - (our dialog, Esc/cancel = no call); automation.trigger / script.turn_on / - scene.turn_on per domain; a deleted target toasts and calls nothing - [auto: smoke_tap_run + unit resolveToggleIntent/runServiceFor + backend - test_run_target_is_bounded_to_runnable_domains] -- [ ] Universal Toggle state (#94): the option is visible for device, entity - and virtual markers; the separate Open/close option is absent. The hint - names the exact target(s), current state, next effect and skipped refs. - Exact entity never retargets to a sibling; explicit controls never fall - back to the controller; partial groups call exactly the shown available - subset; a no-target tap is a quiet no-op. Locks, alarm panels and - garage/door/gate covers are explained secure no-ops. Cover/valve use - closed→open, open→close, moving→stop when supported, otherwise their - domain toggle. Confirmation re-resolves current state but cancels if the - target set changed. Opening and saving an untouched legacy `cover` or an - absent light default preserves the original token/absence; an intentional - selector edit writes `toggle`. Feature-gated climate/water-heater/siren/ - camera/media-player/legacy-vacuum entities require their exact HA bits; - an empty service catalog is unsupported. If #73 retains an older visual - device while live controls change, click calls only the current controls - [auto: test/device-toggle.test.mjs + smoke_cover_tap + - smoke_cover_not_primary + smoke_controls + backend action-schema parity] - The synthetic HA fixture publishes its service catalog explicitly, so - browser checks exercise the same fail-closed resolver as production -- [ ] A cover is NEVER painted (dev, owner 2026-08-04): «у штор не должно быть - жёлтой подложки никогда, индикация открыто/закрыто за счёт морфинга - иконки». Walk one curtain through closed / open / ajar / opening / - closing: the plate is the plain neutral badge every time — never the - yellow «включено» one, never the orange «открыто» frame it used to wear - while open — the icon is the only open/closed signal, and the breathing - `.activity-transition` ring appears in the two travelling states and - nowhere else when «Icon + activity» is selected. - The morph is exhaustive: every device class gives two DIFFERENT glyphs - (awning included), a cover with no device_class morphs within its own - auto-icon family (mdi:roller-shade, mdi:garage-variant), a hand-picked - icon morphs only inside the pair it was picked from, and an - unknown/unavailable state morphs nothing. NOT touched: an open door / - window binary sensor and an open valve still wear the orange «открыто» - frame (a valve has no icon pair, so the frame is all it has); lock keeps - its separate red-unlocked/green-locked palette [auto: - smoke_cover_no_plate + unit stateIcon «every class, both ways»] -- [ ] Cover target and indication parity: on a device where a hidden functional - `cover.*` competes with an auxiliary option switch, the shared resolver - selects the cover, calls only it and supplies the same entity to icon - morph/activity. A mixed lamp+cover remains a lamp unless the universal - toggle result actually selects the cover. Cover presentation remains - neutral in every state and cannot be painted yellow by its controls; - secure cover classes call nothing and never fall back to info - [auto: smoke_cover_not_primary + smoke_cover_plate_precedence + - test/device-toggle.test.mjs] -- [ ] Light-source badges (current contract): in glow fill a lit lamp's badge - stays yellow just like a lit socket; the pool keeps the source's RGB while - the marker keeps semantic yellow. Other fills behave identically; - morphing and the activity-colour fallback survive [auto: smoke_light_badges + - smoke_rgb_alarm] -- [ ] Icon size multiplier scales the glyph (dev): set a marker's size to 3 — - the icon inside grows with the badge instead of staying default - [auto: smoke_icon_scale] -- [ ] Auto-grid parity (v1.51.2, HP-1511-01): with an empty layout, a visible - device among hidden ones sits at the same spot on both cards - [auto: smoke_hidden_flag autoGridParity] -- [ ] Activity ghost (v1.51.2, unified dev): a hidden icon+activity marker - shows its base icon and no effect [auto: smoke_hidden_flag rippleGhost*] -- [ ] Hidden LQI parity (v1.51.1, HP-1510-01): a room whose only Zigbee - devices are hidden paints the same lqi fill on the full and the static - card [auto: smoke_hidden_flag lqiParity] -- [ ] Ghost shows no numbers (v1.51.1, HP-1510-02): a hidden value-display - device renders as a plain ghost — no value/temp/hum/LQI, no icon morph - [auto: smoke_hidden_flag ghostHidesValue] -- [ ] Hide-from-plan flag (dev, docs/FILTERING.md): every existing device - dialog has a bottom-left "Hide" / "Show" action, incl. virtual; the - change is applied by Save; hidden devices vanish from every mode and - the count, still count toward room LQI, cast no glow/light fill; the - device editor's "Show hidden" (local, per tab) shows them as BLUE - dashed ghosts — distinct from a grey unavailable icon — with NO live - state paint (no yellow, no alarm, no activity); - showing and saving keeps a hidden:false marker (re-seed protection); an old - config materialises on first load by an editing client and legacy - clients keep the old behaviour until then - [auto: smoke_hidden_flag + unit seedHiddenBindings/seeded/legacy] -- [ ] Yellow means working (dev): a TRV whose hvac_action is heating glows - yellow; one that is merely enabled (idle) or has a service switch on - (anti-scaling, child lock) stays dark; a lit light turns its state on by - the same condition that lights the glow pool and shows the yellow badge - even where that pool is drawn - [auto: smoke_yellow_principle + smoke_light_badges] -- [ ] Activity baseline (beta.10 audit): rebuilding the device registry seeds - the current snapshot immediately, so the very first later motion/event - transition is detected. Rebinding a marker's effective source clears the - old source's finite flash in the same update [auto: smoke_motion_sense] -- [ ] Touch gesture ownership (dev): in the plan editor on a phone, pinch zooms - and a moving finger pans; releasing after a gesture does not draw a point - and a clean tap still does. In View, both marker-first and stage-first - pinches change zoom; the latter holds its second contact on the marker for - more than 600 ms. Neither order opens the local card, HA more-info, - confirmation/service or a compatibility click/contextmenu action. Reverse - release, a third contact, pointer cancel and lost capture stay blocked, - while the next deliberate single-touch tap and long press work once - without waiting; real mouse and keyboard context menus remain available - [unit: touch-gesture-click-guard; auto: smoke_editor_gestures; mutations: - touch-pinch-click-block-cleared-on-terminal, - touch-pinch-marker-hold-rearmed, - touch-pinch-contextmenu-guard-removed] -- [ ] Legacy geometry parity (v1.50.4, HP-1503-01): a store with a zero - viewport and a negative rect renders identically sane in BOTH cards — - full canvas fallback, normalised rectangle [auto: smoke_legacy_geometry] -- [ ] Sizes are positive (v1.50.3, HP-1502-01): view_box or room w/h of zero - or below is refused; a store that already holds one opens on the full - canvas, not a blank screen [auto: test_sizes_are_not_coordinates + unit - safeViewBox fallback] -- [ ] Room card layout (v1.50.3): the settings button is the bottom row of the - card and the room name sits in the same spot in view and plan modes - [manual; verified by vb-coordinate measurement] -- [ ] Geometry bounds (v1.50.2, HP-1501-01): a config with a 1e100 room - vertex is refused by the server; one already stored still renders with a - sane frame [auto: test_geometry_magnitudes_are_bounded + unit - contentBounds legacy case] -- [ ] No-op repair (v1.50.2, HP-1501-02): geometry/repair with a typo'd space - id errors, moves no revision and keeps the previous backup undoable - [auto: test_a_noop_repair_does_not_eat_the_backup] -- [ ] Card below other dashboard content (v1.50.1, HP-1500-02): place the card - after a tall card in a normal dashboard — the plan still gets most of the - viewport instead of a zero-height stage [auto: smoke_zoom_out] -- [ ] Frame never degenerate (v1.50.1, HP-1500-03): a space with one lone - marker opens with canvas around it, not an empty scene; an absurd stored - coordinate neither hides the plan nor is accepted by the server - [auto: unit contentBounds + backend test_layout_coordinates_are_bounded] -- [ ] Stranded migration repair (v1.50.1, HP-1500-01): geometry/repair with - dry_run previews, applies with a backup, undo restores; wrong space is - recoverable [auto: test_geometry_repair_is_explicit_previewable_and_undoable] -- [ ] Editors see the whole canvas (v1.50.0, HP-1490-03): a hand-drawn space - with one small room opens content-fit in View; switching to the plan - editor shows the full square with room to draw a second room far away; - back to View restores the content fit [auto: smoke_audit_1490] -- [ ] Save waits for a picked plan's proportions (v1.50.0, HP-1490-04): pick a - saved plan and hit Save before the thumbnail loads — the stored aspect is - the real one, never the previous file's [auto: smoke_audit_1490] -- [ ] Zoom goes below the fit (v1.50.0): minus past 100% floats the plan - centred, floor at 1/3 (`MIN_ZOOM`, `src/space-geometry.ts`); entering an editor keeps the stage inside the - viewport [auto: smoke_zoom_out] -- [ ] Migration crash recovery (v1.50.0, HP-1490-01): kill HA between the two - store writes of the square migration — the next start finishes the layout - half from the saved intent - [auto: test_square_migration_finishes_after_a_crash_between_the_writes] -- [ ] Parallel upload quota (v1.50.0, HP-1490-02): two simultaneous uploads - with one slot left — exactly one succeeds - [auto: test_parallel_uploads_cannot_slip_past_the_quota_together] -- [ ] Zoom opens on the content (v1.49.0): a space with no background and one - small room opens with that room filling the screen, with a small margin. - With a background it still fits the whole image - [auto: unit: contentBounds] -- [ ] Deleting a picked plan is refused (v1.49.0, HP-1470-02): pick a saved - plan, reopen the list — its delete button is disabled. Ask the server to - store a plan url whose file is gone: `missing_plan`, and the revision does - not move [auto: smoke_saved_plans + backend - test_config_set_refuses_a_plan_that_no_longer_exists] -- [ ] Portable import rechecks local content under its paired-write lock: a - plan or marker PDF deleted after preview fails with `missing_plan` or - `missing_content`, and neither store advances - [auto: backend test_apply_rechecks_plan_file_under_the_write_lock + - test_apply_rechecks_attachment_under_the_write_lock] -- [ ] Uploads are bounded (v1.49.0, HP-1470-01): past the store quota an upload - is refused with a clear error and the disk does not grow; the plan list - returns the newest 60 with a total - [auto: unit: test_check_quota_counts_the_whole_store_not_one_request, - backend test_uploads_are_bounded_by_a_store_quota] -- [ ] An attachment already written to staging is not reserved twice: with the - real 512 MiB disk reserve intact its same-filesystem promotion succeeds, - while one byte below the reserve still fails. Uploads checked before any - bytes are written continue to reserve their full incoming size - [auto: backend test_issue_554_low_disk_reserve_distinguishes_staged_and_unwritten_bytes - + test_issue_554_upload_uses_actual_free_space_after_staging; mutation: - quota-reserves-staged-bytes-twice-on-disk] -- [ ] Square canvas migration (v1.48.0): after the upgrade every existing plan - looks exactly as before, just with margins where the canvas was extended. - Measure a wall in the plan editor — the length in cm is unchanged. Marker - positions, doors, decor and the saved zoom are all where they were - [auto: unit: test_a_wide_plan_gains_margins_above_and_below and neighbours, - test_migration_preserves_real_lengths_and_shapes] -- [ ] A plan image is centred (v1.48.0): a wide image sits in the middle with - empty bands above and below, a tall one with bands at the sides, and it is - never stretched [auto: unit: fitInSquare + smoke_space_settings] -- [ ] Re-attaching a detached plan (v1.47.0): detach a plan, save, RELOAD THE - PAGE, open space settings → "Already uploaded" → the image is listed with - its size and no "in use" note → attach it → it renders. The one a space - uses shows that space and cannot be deleted; a free one can, with a - confirm, and disappears from the list - [auto: smoke_saved_plans + backend test_stored_plans_can_be_listed_and_deleted_on_request] -- [ ] Detaching a plan keeps the file (v1.46.6): switch a space to "draw" and - SAVE — the image is still in `config/houseplan/plans/` right afterwards, - and after a restart, and can be re-attached. Deleting the space keeps it - too. Replacing a plan still removes the one it replaced, immediately. - Check straight after the save: the earlier bug deleted the file at that - moment, while every scheduled-pass test passed - [auto: unit: test_plan_collection_matrix, test_attachment_collection_matrix, - backend test_detaching_a_plan_keeps_the_file] -- [ ] Rebinding a device does not eat its manuals (v1.46.5): attach two files to - a device, rebind it to another HA device — both are readable afterwards. - If a copy failed, the file it failed on is still there rather than deleted - with the folder [auto: backend test_files_cleanup_keeps_referenced_files] -- [ ] Nothing accumulates on an idle instance (v1.46.2/v1.46.3, HP-1461-01, - HP-1462-01): attach a file, cancel the dialog, and do not save anything - else — the file is gone after a restart AND after the daily pass, while - every file the configuration still references is untouched. Seed the - strays AFTER the last save, or `config/set` collects them and the check - proves nothing - [auto: backend test_startup_sweep_collects_what_no_commit_will, - test_daily_sweep_callback_collects_too, test_sweep_and_a_config_write_do_not_race] -- [ ] A drag wins over a concurrent remote move (v1.46.2, HP-1461-02): drag an - icon and, while the save is still in flight, have another window move a - different icon — your icon stays where you put it and the other one - updates [auto: smoke_layout_sync] -- [ ] Concurrent uploads of one name (v1.46.1, HP-1460-01): attach the same - file from two browser tabs at once — two attachments, two sets of bytes, - neither lost. A file whose name is at the length limit still downloads - [auto: unit: test_reserve_filename_is_safe_under_concurrency and neighbours] -- [ ] No temporary files survive (v1.46.1, HP-1460-02): abort a large upload - mid-transfer, send two files in one request, make promotion fail — in each - case the files folder holds no `.upload-*`. An old one is swept at startup - [auto: backend test_upload_leaves_no_temporary_behind + unit: sweep_upload_temps] -- [ ] Two full cards agree on positions (v1.46.1, HP-1460-03): open the plan in - two windows, drag an icon in one — it moves in the other without a reload; - a drag in progress in the second window is not thrown away - [auto: smoke_layout_sync] -- [ ] Uploaded SVG is inert as a document (v1.46.0, HP-1454-01): open a plan's - signed url directly in a tab — a `