mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-30 19:58:50 +00:00
Record summary panel visual and negative-witness evidence (#505)
Issue: #505 User-Visible: no
This commit is contained in:
@@ -14,10 +14,11 @@ 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'].includes(arg)) {
|
||||
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}`);
|
||||
options[{ '--output': 'output', '--ha-cache': 'cacheDir', '--ha-wheel': 'wheelPath' }[arg]] = resolve(value);
|
||||
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.
|
||||
@@ -31,6 +32,7 @@ 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.',
|
||||
@@ -234,8 +236,37 @@ async function capture(scenario) {
|
||||
await shot('product', 'control', page.locator('.summary-control'));
|
||||
}
|
||||
if (scenario.language === 'de') {
|
||||
await page.locator('.summary-editor').evaluate((node) => { node.scrollTop = node.scrollHeight; });
|
||||
// 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 }) => {
|
||||
@@ -276,7 +307,9 @@ async function capture(scenario) {
|
||||
|
||||
try {
|
||||
await probe();
|
||||
if (!options.probeOnly) for (const scenario of [...panelCases, ...settingCases, ...edgeCases]) await capture(scenario);
|
||||
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) {
|
||||
|
||||
@@ -16,14 +16,17 @@ 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. Existing extracted bytes are hash-checked
|
||||
against the generated local cache manifest each run. Use a new cache directory
|
||||
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:
|
||||
@@ -70,6 +73,9 @@ 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
|
||||
|
||||
@@ -11,6 +11,9 @@ import { parseAst } from 'rollup/parseAst';
|
||||
export const HA_DIALOG_PIN = Object.freeze({
|
||||
version: '20260729.7', bytes: 124294469,
|
||||
sha256: 'ba01782297e9506d3185f99a74d829f3811df322623848af21bcc0b8ea834c12',
|
||||
// Deterministic manifest generated from that authenticated wheel, including
|
||||
// extracted-file hashes and the module index. Cache metadata is not trusted.
|
||||
cacheManifestSha256: '6fd75eb27f8a660c57d173d7b9b496d17f96e404c48adba7faeef1a3d391d831',
|
||||
url: 'https://files.pythonhosted.org/packages/22/84/f117626ac7db42d34341aa2795cd2bd2f84e7cc89ecdf0117bf4cfa2f1e6/home_assistant_frontend-20260729.7-py3-none-any.whl',
|
||||
app: 'app.d53ce8172fc8c85d.js', module: '25395', entry: 'o(91535);',
|
||||
});
|
||||
@@ -67,7 +70,9 @@ export async function prepareHaDialogAssets({
|
||||
mkdirSync(cache, { recursive: true });
|
||||
const manifestPath = join(cache, 'manifest.json');
|
||||
if (existsSync(manifestPath)) {
|
||||
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
||||
const manifestBytes = readFileSync(manifestPath);
|
||||
if (digest(manifestBytes) !== HA_DIALOG_PIN.cacheManifestSha256) throw new Error('HA fixture cache manifest hash mismatch');
|
||||
const manifest = JSON.parse(manifestBytes);
|
||||
if (manifest.version !== pin || manifest.sha256 !== HA_DIALOG_PIN.sha256
|
||||
|| !manifest.files?.length || !manifest.modules?.[HA_DIALOG_PIN.module]) throw new Error('Invalid HA fixture cache manifest');
|
||||
for (const file of manifest.files) {
|
||||
@@ -123,6 +128,7 @@ export async function prepareHaDialogAssets({
|
||||
const root = join(cache, 'hass_frontend');
|
||||
const manifest = { version: pin, sha256: HA_DIALOG_PIN.sha256, url: HA_DIALOG_PIN.url,
|
||||
files, modules: moduleIndex(join(root, 'frontend_latest')) };
|
||||
if (digest(JSON.stringify(manifest)) !== HA_DIALOG_PIN.cacheManifestSha256) throw new Error('Rebuilt HA manifest differs from verified pin; refusing this cache');
|
||||
writeFileSync(manifestPath, JSON.stringify(manifest));
|
||||
return { root, manifest };
|
||||
}
|
||||
|
||||
@@ -99,7 +99,7 @@ export async function launchHaDialogFixture({
|
||||
.replace(/<script type="module">\r?\nconst CFG/, '<script type="module">\nawait import("/ha505-bootstrap.mjs");\nconst CFG');
|
||||
}
|
||||
}
|
||||
response.writeHead(200, { 'content-type': type, 'cache-control': 'no-store',
|
||||
response.writeHead(200, { 'content-type': type.startsWith('text/') ? `${type}; charset=utf-8` : type, 'cache-control': 'no-store',
|
||||
'content-security-policy': "default-src 'self' data: blob:; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; connect-src 'self'; font-src 'self' data:; img-src 'self' data: blob:; worker-src 'none'" }).end(body);
|
||||
} catch (error) { errors.push(String(error)); response.writeHead(500).end('Diagnostic fixture error'); }
|
||||
});
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
# #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 `a038149c`:
|
||||
source fingerprint `23ed457d01a2622aae7bb6937116f20f0044b8216b723388ed3dcf85dacd4a1e`.
|
||||
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=<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. |
|
||||
|
||||
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.
|
||||
@@ -13,3 +13,5 @@ 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.
|
||||
|
||||
Reference in New Issue
Block a user