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:
+204
-77
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user