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
+204 -77
View File
@@ -2,19 +2,24 @@
import { onMount, onDestroy } from 'svelte';
import { supported } from './browser';
import { createCaptureSession, type CaptureSession } from './capture/session';
import type { Capture } from './capture/capture.types';
import Resume from './capture/Resume.svelte';
import BatchPhotos from './capture/BatchPhotos.svelte';
import PickCalibration from './capture/PickCalibration.svelte';
import MaskPanel from './capture/MaskPanel.svelte';
import AxisPanel from './capture/AxisPanel.svelte';
import MeasurePanel from './capture/MeasurePanel.svelte';
import Settings from './create/settings/Settings.svelte';
import SlabList from './create/slabs/SlabList.svelte';
import PhotoAttach from './create/photos/PhotoAttach.svelte';
import Review from './create/review/Review.svelte';
import Publish from './create/publish/Publish.svelte';
import GenerateText from './create/inference/GenerateText.svelte';
import type { Capture, Measures } from './capture/capture.types';
import { eighthsToIn } from './capture/capture.types';
import { buildPhotosReadyLineup } from './capture/photosReadyLineup';
import Resume from './capture/Resume.svelte';
import BatchPhotos from './capture/BatchPhotos.svelte';
import PickCalibration from './capture/PickCalibration.svelte';
import MaskPanel from './capture/MaskPanel.svelte';
import AxisPanel from './capture/AxisPanel.svelte';
import MeasurePanel from './capture/MeasurePanel.svelte';
import Settings from './create/settings/Settings.svelte';
import SlabList from './create/slabs/SlabList.svelte';
import Review from './create/review/Review.svelte';
import Publish from './create/publish/Publish.svelte';
import {
flow, startSaving, enterReview, enterPublish, markDone, backToCapture, backToReview, type CalibratedCapture,
} from './create/flow';
import { createSlab, uploadPhoto, getSettings, ApiError } from './api/slabs';
import { clearDraft } from './draft/idb';
const gate = typeof window !== 'undefined' ? supported() : 'window unavailable';
const session: CaptureSession = createCaptureSession();
@@ -24,27 +29,104 @@ import GenerateText from './create/inference/GenerateText.svelte';
let pngUrl: string | null = null;
let showSettings = false;
let showSlabs = false;
let showPhotos = false;
let showReview = false;
let showPublish = false;
let showGenerate = false;
let curFlow: import('./create/flow').Flow = { phase: 'capture' };
$: curFlow = $flow;
let saving = false;
let saveError = '';
const unsubCap = session.capture.subscribe((c) => {
cap = c;
pngUrl = c.k === 'measure' ? session.getInventoryUrl() : null;
});
const unsubBusy = session.busy.subscribe((b) => {
busy = b;
});
const unsubBusy = session.busy.subscribe((b) => (busy = b));
onMount(() => {
void session.resumeProbe();
// e2e boot guard: `?noload` skips the resume probe so a test can seed the
// device draft into IndexedDB after the origin exists, then reload without
// ?noload to trigger the Resume prompt deterministically.
const boot = new URLSearchParams(window.location.search);
if (!boot.get('noload')) void session.resumeProbe();
});
onDestroy(() => {
unsubCap();
unsubBusy();
session.dispose();
});
// APP-15: the device draft is cleared only on publish success / "new slab".
function startNewSlab(): void {
void clearDraft().then(() => {
session.startFresh();
backToCapture();
});
}
// C2: Photos-ready → build the calibrated slab, upload the lineup PNGs, then
// hand off to Review. The device draft is retained here (APP-10); it is
// cleared only on publish success (APP-15).
async function continueToReview(): Promise<void> {
const m = session.currentMeasure();
if (!m || !m.results) return;
const r: Measures = m.results;
const capture: CalibratedCapture = {
lengthIn: eighthsToIn(m.lengthEighths),
thicknessIn: eighthsToIn(m.thicknessEighths),
sqft: r.sqft,
bdft: r.bdft,
widthMinIn: eighthsToIn(r.widths.min),
widthMaxIn: eighthsToIn(r.widths.max),
widthAvgIn: eighthsToIn(r.widths.avg),
sourceBelowFloor: r.sourceBelowFloor,
lineup: buildPhotosReadyLineup({
inventoryPng: r.inventoryPng,
overlayPng: r.overlayPng,
batchApply: m.batchApply,
sourceBelowFloor: r.sourceBelowFloor,
}),
};
saving = true;
saveError = '';
startSaving();
try {
const id = crypto.randomUUID();
await createSlab({
id,
sku: '',
length_in: capture.lengthIn,
thickness_in: capture.thicknessIn,
sqft: capture.sqft,
bdft: capture.bdft,
width_min_in: capture.widthMinIn,
width_max_in: capture.widthMaxIn,
width_avg_in: capture.widthAvgIn,
price: null,
price_source: 'recommendation',
content_source: 'manual',
client_rev: 0,
});
const pngItems = capture.lineup.filter(
(it) => it.kind === 'png' && it.role !== 'overlay',
);
for (let i = 0; i < pngItems.length; i++) {
const it = pngItems[i];
await uploadPhoto(id, {
photoId: crypto.randomUUID(),
seq: i + 1,
isCalibration: it.role === 'calibration',
original: it.blob,
processed: it.blob,
});
}
enterReview(id, capture);
} catch (e) {
saveError =
e instanceof ApiError ? e.message : 'Could not start the slab. Check your connection and try again.';
backToCapture();
} finally {
saving = false;
}
}
</script>
{#if gate}
@@ -58,55 +140,103 @@ import GenerateText from './create/inference/GenerateText.svelte';
</main>
{:else}
<main class="app">
{#if cap.k === 'resume'}
<Resume onContinue={() => session.continueDraft()} onFresh={() => void session.startFresh()} />
{:else if cap.k === 'batch'}
<BatchPhotos
photos={cap.photos}
onAdd={(f) => void session.addToBatch(f)}
onRemove={session.removeFromBatch}
onContinue={session.continueBatch}
{#if curFlow.phase === 'review'}
<Review
slabId={curFlow.slabId}
capture={curFlow.capture}
onPublish={() => {
const f = curFlow;
if (f.phase === 'review') enterPublish(f.slabId, f.capture);
}}
onBack={() => backToCapture()}
/>
{:else if cap.k === 'pickCalibration'}
<PickCalibration
photos={cap.photos}
onPick={session.pickCalibration}
onBack={session.back}
/>
{:else if cap.k === 'mask'}
<MaskPanel
state={cap}
{busy}
setSensitivity={session.setSensitivity}
setSheet={session.setSheet}
approveMask={session.approveMask}
retakes={session.retakes}
back={session.back}
/>
{:else if cap.k === 'axis'}
<AxisPanel
state={cap}
setAxisAngle={session.setAxisAngle}
approveAxis={session.approveAxis}
back={session.back}
{:else if curFlow.phase === 'publish'}
<Publish
slabId={curFlow.slabId}
onPublished={(wooId, draft) => markDone(curFlow.slabId, wooId, draft)}
onBack={() => {
const f = curFlow;
if (f.phase === 'publish') backToReview(f.slabId, f.capture);
}}
/>
{:else if curFlow.phase === 'done'}
<section class="panel done" aria-label="Published">
<h1>Published</h1>
<p class="ok">
{#if curFlow.draft}
Created as a UAT draft in the store.
{:else}
Created in the store.
{/if}
</p>
<p>Woo product #{curFlow.wooProductId} · photos and device draft cleared.</p>
<div class="row">
<button type="button" class="primary" data-testid="new-slab" on:click={startNewSlab}>
Start a new slab
</button>
</div>
</section>
{:else if curFlow.phase === 'saving'}
<section class="panel" aria-busy="true">
<h1>Saving slab…</h1>
</section>
{:else}
<MeasurePanel
state={cap}
setLength={session.setLength}
setThickness={session.setThickness}
calculate={session.calculate}
back={session.back}
{pngUrl}
/>
<!-- capture column (single slab, CAPTURE-25/30) -->
{#if cap.k === 'resume'}
<Resume onContinue={() => session.continueDraft()} onFresh={() => void session.startFresh()} />
{:else if cap.k === 'batch'}
<BatchPhotos
photos={cap.photos}
onAdd={(f) => void session.addToBatch(f)}
onRemove={session.removeFromBatch}
onContinue={session.continueBatch}
/>
{:else if cap.k === 'pickCalibration'}
<PickCalibration
photos={cap.photos}
onPick={session.pickCalibration}
onBack={session.back}
/>
{:else if cap.k === 'mask'}
<MaskPanel
state={cap}
{busy}
setSensitivity={session.setSensitivity}
setSheet={session.setSheet}
approveMask={session.approveMask}
retakes={session.retakes}
back={session.back}
/>
{:else if cap.k === 'axis'}
<AxisPanel
state={cap}
setAxisAngle={session.setAxisAngle}
approveAxis={session.approveAxis}
back={session.back}
/>
{:else}
<MeasurePanel
state={cap}
setLength={session.setLength}
setThickness={session.setThickness}
calculate={session.calculate}
back={session.back}
{pngUrl}
onContinue={saving ? undefined : () => void continueToReview()}
/>
{/if}
{#if saving}
<div class="row"><span class="busy" aria-live="polite">Saving slab…</span></div>
{/if}
{#if saveError}
<div class="save-err" role="alert" data-testid="save-error">{saveError}</div>
{/if}
{/if}
<!-- Separate pages, never in the flow (UX.md §1). -->
<div style="margin-top:1rem;display:flex;gap:8px">
<button on:click={() => (showSettings = !showSettings)}>Settings (dev)</button>
<button on:click={() => (showSlabs = !showSlabs)}>Slabs (dev)</button>
<button on:click={() => (showPhotos = !showPhotos)}>Photos (dev)</button>
<button on:click={() => (showReview = !showReview)}>Review (dev)</button>
<button on:click={() => (showPublish = !showPublish)}>Publish (dev)</button>
<button on:click={() => (showGenerate = !showGenerate)}>Generate (dev)</button>
<button on:click={() => (showSettings = !showSettings)} data-testid="open-settings">Settings</button>
<button on:click={() => (showSlabs = !showSlabs)} data-testid="open-slabs">Slabs</button>
</div>
{#if showSettings}
<Settings />
@@ -114,18 +244,6 @@ import GenerateText from './create/inference/GenerateText.svelte';
{#if showSlabs}
<SlabList />
{/if}
{#if showPhotos}
<PhotoAttach />
{/if}
{#if showReview}
<Review />
{/if}
{#if showPublish}
<Publish />
{/if}
{#if showGenerate}
<GenerateText />
{/if}
</main>
{/if}
@@ -139,4 +257,13 @@ import GenerateText from './create/inference/GenerateText.svelte';
.hardstop { padding: 48px 24px; color: #eee; text-align: center; }
.hardstop h1 { font-size: 1.6rem; }
.detail { color: #888; font-size: 0.85rem; }
.panel { padding: 16px; color: #eee; }
.done .ok { color: #7fd37f; font-weight: 600; }
.row { display: flex; gap: 10px; margin-top: 16px; }
.primary {
flex: 1; padding: 14px; font-size: 1.05rem; border-radius: 10px; border: 0;
background: #00e5ff; color: #002; font-weight: 700;
}
.busy { color: #aaa; }
.save-err { margin-top: 12px; color: #ffb4a2; font-weight: 600; }
</style>