v1.50.0: the v1.49.0 review (HP-1490-01..04) and the owner's zoom batch

Owner's batch (committed to dev earlier today, released here):
- devices count as content for the default zoom;
- the editor no longer shifts the plan — the stage measures its own top
  instead of assuming 118px of header;
- zoom goes out to 0.4x, centred.

From the review:
- HP-1490-01: the square-canvas migration wrote two stores in sequence, and
  the first write deleted the aspects the second needed — a crash between
  them stranded the layout in the old coordinates with nothing able to
  finish it. The intent {space: old aspect} is durable now: saved to the
  layout store before anything moves, cleared by the same write that stores
  the migrated layout, each half idempotent behind its own trigger. The
  update event fires only after both halves are on disk. Proven at the exact
  crash boundary by a harness test that fails the layout write once.
- HP-1490-02: check_quota and the file write were two executor jobs with
  nothing between them, so N parallel uploads all measured the store before
  any of them wrote. One job under a dedicated upload_lock now — narrower
  than write_lock on purpose, a directory scan must not stall config saves.
  A failed write reserves nothing.
- HP-1490-03: the content frame fed pan, zoom, clamp AND pointer maths, so
  the editors were boxed into yesterday's drawing. Edit modes measure from
  the full square; mode switches refit rather than carry a view clamped
  against the wrong base.
- HP-1490-04: Save could outrun the proportions read and ship the previous
  file's ratio. Picking a plan clears it immediately; Save awaits the
  bounded read and stores 'unknown' over a lie.
- §5: package-lock version synced, duplicated comment removed.

New: smoke_audit_1490.mjs, migration crash-recovery pure + harness tests,
parallel-quota harness test. Inventory: 138 unit / 49 pure / 40 harness / 64
smokes.
This commit is contained in:
Matysh
2026-07-28 23:50:59 +03:00
parent 6c90e03427
commit 8c5d5ba5c5
22 changed files with 494 additions and 70 deletions
+41 -9
View File
@@ -36,7 +36,7 @@ import { cardStyles } from './styles';
import { fitInSquare, contentBounds } from './space-geometry';
import { langOf, t, type I18nKey } from './i18n';
const CARD_VERSION = '1.49.0';
const CARD_VERSION = '1.50.0';
const LS_KEY = 'houseplan_card_layout_v1';
const LS_CFG = 'houseplan_card_cfg_v1'; // cache of the server config+layout for instant rendering
const LS_ZOOM = 'houseplan_card_zoom_v1';
@@ -1258,6 +1258,10 @@ class HouseplanCard extends LitElement {
private _baseVb(): number[] {
const m = this._spaceModel();
if (m.bg) return m.vb;
// The EDITORS get the whole square: the content frame also bounds pan,
// zoom and pointer maths, and inside it there is nowhere to draw the next
// room or drag a marker away from the first one (HP-1490-03).
if (this._mode !== 'view') return m.vb;
// devices are content too — they may stand outside every room
const pts = this._devices
.filter((d) => d.space === m.id)
@@ -1676,7 +1680,15 @@ class HouseplanCard extends LitElement {
this._showToast(this._t('toast.markup_needs_server'));
return;
}
const baseChanges = !this._spaceModel().bg && (mode === 'view') !== (this._mode === 'view');
this._mode = mode;
if (baseChanges) {
// refit against the new base: the editors measure from the full square,
// the view from the content frame — a view clamped to one is nonsense
// against the other (HP-1490-03)
this._zoom = 1;
this._view = null; // updated() refits on the next frame
}
this._path = [];
this._cursorPt = null;
this._tool = 'draw';
@@ -3164,12 +3176,18 @@ class HouseplanCard extends LitElement {
}
};
/** The in-flight proportions read for the last picked saved plan. Save
* awaits it rather than shipping whatever was there before (HP-1490-04). */
private _aspectJob: Promise<number> | null = null;
private _useServerPlan(url: string): void {
const d = this._spaceDialog;
if (!d) return;
// Attach immediately — the click should not wait for anything.
this._spaceDialog = { ...d, planUrl: url, planFile: null, pickSaved: false };
void this._readPlanAspect(url);
// Attach immediately — the click should not wait for anything. The OLD
// file's proportions go right away: they describe the previous image, and
// a Save racing the read must get "unknown", never "the wrong shape".
this._spaceDialog = { ...d, planUrl: url, planFile: null, pickSaved: false, savedAspect: undefined };
this._aspectJob = this._readPlanAspect(url);
}
/**
@@ -3182,7 +3200,7 @@ class HouseplanCard extends LitElement {
* signature, and bind the result to THIS dialog and THIS url, or a late
* answer would reshape whatever the user opened next.
*/
private async _readPlanAspect(url: string): Promise<void> {
private async _readPlanAspect(url: string): Promise<number> {
for (let i = 0; i < 40; i++) { // ~6 s, then give up quietly
const src = this._display(url);
if (src) {
@@ -3196,12 +3214,14 @@ class HouseplanCard extends LitElement {
const cur = this._spaceDialog;
if (cur && cur.planUrl === url && Number.isFinite(ratio) && ratio > 0) {
this._spaceDialog = { ...cur, savedAspect: ratio };
return ratio;
}
return;
return 0;
}
await new Promise((r) => setTimeout(r, 150));
if (this._spaceDialog?.planUrl !== url) return; // the user moved on
if (this._spaceDialog?.planUrl !== url) return 0; // the user moved on
}
return 0;
}
private async _deleteServerPlan(name: string): Promise<void> {
@@ -3269,6 +3289,17 @@ class HouseplanCard extends LitElement {
uploaded = { url: resp.url, aspect: d.planFile.aspect };
}
// A plan picked from the server list reads its proportions from the
// image, and Save used to outrun that read: the snapshot still carried
// the PREVIOUS file's ratio and a wide plan came out at the old shape
// for good (HP-1490-04). Wait for the read — it is bounded (~6 s) and
// the dialog is already busy. Unknown stays unknown, never the old
// value: a square fallback is honest, an inherited ratio is not.
let pickedAspect: number | null = d.savedAspect || null;
if (!uploaded && d.source === 'file' && d.planUrl && !pickedAspect && this._aspectJob) {
pickedAspect = (await this._aspectJob) || null;
}
// from here on: no awaits until the save, so `sp` cannot be orphaned
const cfg = this._serverCfg!;
let sp: any;
@@ -3292,9 +3323,10 @@ class HouseplanCard extends LitElement {
// the image's own proportions, so it can be centred before it loads
sp.plan_aspect = uploaded.aspect;
} else if (d.source === 'file' && d.planUrl && d.planUrl !== sp.plan_url) {
// picked from the server list: no upload, just a reference
// picked from the server list: no upload, just a reference — and the
// previous image's proportions never survive the switch
sp.plan_url = d.planUrl;
if (d.savedAspect) sp.plan_aspect = d.savedAspect;
sp.plan_aspect = pickedAspect;
}
// switching an existing space to "draw" detaches its background image
// (the uploaded file stays on disk; only the reference is cleared)