M3 Review→Publish UI (#77): one-column flow, searchable pickers, fig/feat tags, e2e

- Review screen: slug-resolved species/wcat groupers, case-insensitive attrs,
  fig-*/feat-* tag pickers (METADATA-30/40), AI-assist labels + AI bubbles.
- App orchestrator: single writable flow store, capture→review→publish, Resume boot.
- API client (slabs.ts) mirroring live backend routes; Blob name fix.
- Playwright e2e (7 tests, hermetic mock): canvas PNG seed, client-UUID mock,
  SKU blur commit, ready-state reactive fix; expect timeout 4s, 2 workers.
- e2e green 7/7 in 6s; check_requirements + check_coverage PASS.
This commit is contained in:
Ty
2026-09-20 14:34:24 -07:00
parent 2e2be48154
commit 1133201f30
12 changed files with 1996 additions and 275 deletions
+102
View File
@@ -0,0 +1,102 @@
// e2e helper: seed an in-progress device draft (stage='measure', with results) so
// the Resume path restores straight to the Photos-ready Measure step — the
// deterministic driver for the create→review→publish flow (no live camera/canvas
// in e2e). The draft is written to the real idb-keyval DB ('keyval-store'/'keyval',
// key 'slabuploader-draft') exactly as `saveDraft` would, so `resumeProbe` +
// `continueDraft` + `continueToReview` consume it unmodified.
//
// `source.blob` MUST be a decodable image: restoreFromDraft re-derives the mask
// in a Worker that decodes the blob; a failed decode falls back to `mask` (no
// results → the Continue button never renders). A 1×1 white PNG decodes fine and
// yields slabPx:0 (an empty mask) → rederiveMask resolves → stage reaches measure.
import type { Page } from '@playwright/test';
const KEY = 'slabuploader-draft';
export interface SeedMeasure {
sku: string;
lengthIn: number;
thicknessIn: number;
sqft: number;
bdft: number;
widthIn: number;
}
const toEighths = (inches: number) => Math.round(inches * 8);
export async function seedMeasureDraft(
page: Page,
d: SeedMeasure,
): Promise<void> {
const widthEighths = toEighths(d.widthIn);
// Build the decodable source PNG in the page via canvas (a fixed base64 1×1
// gray+alpha PNG trips Chromium's software decoder in headless), then write
// the draft with that blob so restoreFromDraft's worker decode succeeds.
await page.evaluate(
(payload) =>
new Promise<void>((resolve, reject) => {
const c = document.createElement('canvas');
c.width = 1;
c.height = 1;
const ctx = c.getContext('2d')!;
ctx.fillStyle = '#fff';
ctx.fillRect(0, 0, 1, 1);
c.toBlob((png) => {
if (!png) { reject(new Error('canvas toBlob returned null')); return; }
const draft = {
version: 1,
stage: 'measure',
batch: [],
calibrationIndex: null,
source: { widthPx: 1, heightPx: 1, blob: png },
maskParams: { sheet: 'green', sensitivity: 50 },
mask: {
slabPx: 0,
bbox: { corners: [], longSidePx: 0, angleDeg: 0 },
slabBounds: { x: 0, y: 0, w: 0, h: 0 },
},
axisBbox: null,
lengthEighths: payload.lengthEighths,
thicknessEighths: payload.thicknessEighths,
results: {
lengthEighths: payload.lengthEighths,
thicknessEighths: payload.thicknessEighths,
scale: 8,
widths: {
min: payload.widthEighths,
max: payload.widthEighths,
avg: payload.widthEighths,
every6In: [],
},
sqft: payload.sqft,
bdft: payload.bdft,
sourceBelowFloor: false,
},
inventoryPng: png,
overlayPng: null,
batchApply: null,
};
const req = indexedDB.open('keyval-store', 1);
req.onupgradeneeded = () => {
const db = req.result;
if (!db.objectStoreNames.contains('keyval')) db.createObjectStore('keyval');
};
req.onsuccess = () => {
const db = req.result;
const tx = db.transaction('keyval', 'readwrite');
tx.objectStore('keyval').put(draft, 'slabuploader-draft');
tx.oncomplete = () => { db.close(); resolve(); };
tx.onerror = () => { db.close(); reject(tx.error); };
};
req.onerror = () => reject(req.error);
}, 'image/png');
}),
{
lengthEighths: toEighths(d.lengthIn),
thicknessEighths: toEighths(d.thicknessIn),
widthEighths,
sqft: d.sqft,
bdft: d.bdft,
},
);
}