fix: guard dismissal honesty, one threshold source, EXIF proven (#39 r1)

r1-M1: dismissal (Escape/scrim/Cancel) while the reduce or keep-original
flow is executing no longer races the decision — hp-close is ignored while
busy, and every flow re-checks it still owns the guard before applying, so
a force-cleared dialog can never silently install its stale result. The
smoke now drives both: hp-close during a hanging decode leaves the busy
dialog up, and a force-cleared guard ends with clean staging, no toast, no
planFile. A new registry mutant removes the busy gate and is killed.

r1-M2: the hard-dialog text takes its limit from the imported
HARD_DIMENSION instead of a literal — recalibration stays a one-file
change, as the spec promises.

r1-M3: AC8 is now proven end to end, not plausible: the smoke splices a
real EXIF APP1 (orientation 6) into a canvas-encoded 8200×4100 JPEG,
asserts the header probe reads the unrotated SOF, that the decode call
carries imageOrientation:'from-image' (captured on the hook), and that the
reduced copy comes out portrait 2048×4096. TESTING.md names the scenario.

Issue: #39
User-Visible: no
This commit is contained in:
Codex
2026-08-29 10:13:09 +03:00
parent e84af749aa
commit 7c45372c32
22 changed files with 252 additions and 142 deletions
+15 -4
View File
@@ -7,7 +7,7 @@
import { html, type TemplateResult } from 'lit';
import {
DOWNSCALE_JPEG_QUALITY, DOWNSCALE_TARGET_PX, DOWNSCALE_TIMEOUT_MS,
downscaleDimensions, probeBackdrop, type BackdropProbe,
HARD_DIMENSION, downscaleDimensions, probeBackdrop, type BackdropProbe,
} from './backdrop-probe';
import type { I18nKey } from './i18n';
@@ -166,7 +166,7 @@ export function renderBackdropGuard(
const hard = probe.kind === 'hard';
const body = hard
? host._t('backdrop.too_large_body', {
w: probe.width ?? 0, h: probe.height ?? 0, limit: 16384,
w: probe.width ?? 0, h: probe.height ?? 0, limit: HARD_DIMENSION,
})
: probe.kind === 'unknown'
? host._t('backdrop.unknown_body')
@@ -176,9 +176,18 @@ export function renderBackdropGuard(
fileMb: megabytes(guard.file.size),
decodedMb: megabytes(probe.decodedBytes ?? 0),
});
// r1-M1: while a decision is executing, dismissal must not race it — the
// dialog stays up (buttons are disabled), and even if the guard somehow
// vanished mid-flight, a stale flow must not apply its result silently.
const dismiss = (): void => {
if (host._backdropGuard?.busy) return;
close();
};
const stillCurrent = (): boolean => host._backdropGuard?.file === guard.file;
const original = async (): Promise<void> => {
if (host._backdropGuard?.busy) return;
const payload = await encodePlanFile(guard.file, guard.ext, guard.file.name);
if (!stillCurrent()) return;
apply(payload);
close();
};
@@ -189,21 +198,23 @@ export function renderBackdropGuard(
try {
const out = await downscaleBackdrop(guard);
const payload = await encodePlanFile(out.blob, out.ext, out.name);
if (!stillCurrent()) return;
apply(payload);
close();
} catch {
// Honest phase 2 (spec §UX): no silent fallback to the original the
// user just declined — staging stays clean, the toast says what happened.
if (!stillCurrent()) return;
close();
host._showToast(host._t('backdrop.downscale_failed'));
}
};
return html`<hp-dialog .hass=${hass}
.title=${host._t(hard ? 'backdrop.too_large_title' : 'backdrop.large_title')}
icon="mdi:image-size-select-large" dismiss-on-scrim @hp-close=${() => close()}>
icon="mdi:image-size-select-large" dismiss-on-scrim @hp-close=${() => dismiss()}>
<div class="body"><p>${body}</p></div>
<div class="row" slot="footer">
<button class="btn ghost" ?disabled=${guard.busy} @click=${() => close()}>
<button class="btn ghost" ?disabled=${guard.busy} @click=${() => dismiss()}>
${host._t('btn.cancel')}</button>
<span class="spacer"></span>
${hard ? null : html`