mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-28 19:01:34 +00:00
docs(hygiene): снять чек-листы testing-notes и дизайн-материалы выпущенных задач (#681)
Волна 4 эпика #674. docs/testing-notes/: восемь ручных чек-листов по поверхностям и индекс удалены — ни одного отмеченного пункта, ручной фазы в PROCESS.md §2 нет. Правило #650 (пустое совпадение --test-name-pattern) перенесено в TESTING.md; пункты [manual] без автоматического свидетеля сведены в раздел «Чего не проверяет автоматика» (реальный HA, сенсорное устройство, ресурсы сервера, несколько клиентов, визуальная оценка, пользовательское содержимое). В TESTING.md снят блок чек-листов v1.43–1.44 и приложения по issue в разделе golden (#197/#249/#272/#275/#288/#261) — сцены объявлены в demo/golden/matrix.mjs. Остался реестр браузерных гвардов #659 (mutation-browser-guards.md). test/testing-notes-index.test.mjs → test/testing-doc.test.mjs: лимит 800 строк, правила #85, раздел ручных проверок и живые ссылки TESTING.md; каталог testing-notes содержит только реестр. Мутант testing-notes-index-drops-section (удалял строку индекса) → testing-doc-drops-manual-section. golden-matrix: копия 67 id сцен #242/#250 в чек-листе снята, список и способ его измерения — в demo/golden/matrix.mjs. docs/design/505-summary-panel удалён вместе с demo/capture_summary_panel_505.mjs и маршрутом /reference/ фикстуры диалога. docs/design/600-settings-dialogs: reference/, screenshots/, pairs/, ARCHIVE-README, ISSUE-FORM, OPEN-POINTS удалены; SPEC, IMPLEMENTATION-GUIDE, field-maps, ACCEPTANCE остаются; вывод capture_design_pairs_600.mjs и verify_ha_form_shell_609.mjs --capture — в artifacts/. docs/design: 68 файлов / 4,08 МБ → 13 / 0,70 МБ. README-ha-dialog-505.md → README-ha-dialog.md (путь в release-review.yml); demo/guard/README.md: запись гварда — в verify-guard.mjs, не в README. docs/design/649-25d-stage6 не тронут — пункт после стабильного v1.78.0. Issue: #681 User-Visible: no Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
This commit is contained in:
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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 записи не заменяет.
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
|
||||
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user