Files
SlabUploader/frontend/src/create/review/Review.svelte
T
Ty 1a50fad0a5 merge issue-89-metadata-lock → main (Create-string model, #89/#90)
Drop the metadata_locked column + "Save and lock" UI end-to-end. The
review endpoint now builds the deterministic title/short_description on
every call; the owner's Create title / Create short desc buttons fire
the build and write the result (force-overwrite contract). commitReview
auto-fills only empty fields (no owner-clobber) and folds the
deterministic copy into its single PUT (kills the client_rev 409
cascade).

Taken wholesale from the branch: photo strip, wood primary/secondary,
server-side thickness band (hidden from UI, passed to Woo on publish),
and fixes #80/#82/#83/#84.

- backend: remove metadata_locked from schema.sql, db sentinels,
  slabs.py client fields/exposure, review.py lock action; review
  always computes title/short_desc
- frontend: slabs.ts + Review.svelte drop the lock model, add
  Create-string workflow + wood primary + photo strip
- e2e: m3-create + review-photo-strip rewritten for the Create-string
  contract; thickness-band test asserts absence (per #90)
- deploy: recreate LAN binds (override-m3.yml was absent); verify
  metadata_locked gone, review builds title, PUT persists

Gates: backend 117/117, unit 88/88, tsc clean, e2e 6/6 (m3-create +
photo-strip). Live UAT verified at 192.168.1.202:10881.
2026-09-21 11:54:48 -07:00

964 lines
44 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import {
getSlab, updateSlab, reviewSlab, getTaxonomy, getSettings, inferTaxon, inferContent,
listPhotos, persistLineup, photoUrl, ApiError,
type Slab, type Taxonomy, type TaxCategory, type TaxTag, type SettingsView, type PhotoRow,
} from '../../api/slabs';
import type { CalibratedCapture } from '../flow';
import MultiSelect from './MultiSelect.svelte';
import {
keptInOrder, moveItem, missingMinimums, roleLabel, resolvePhotoUrl,
type LineupItem,
} from './lineup';
import { nextPrimary } from './woodPrimary';
// Props handed off by App (one-column flow, UX.md §1).
export let slabId: string;
export let capture: CalibratedCapture | null = null;
export let onPublish: () => void;
export let onBack: () => void;
let slab: Slab | null = null;
let tax: Taxonomy | null = null;
let settings: SettingsView | null = null;
let loadError = '';
let loading = true;
// Picker option types (id + name + whether the AI recommended it).
interface Opt { id: number; name: string; ai: boolean }
const byId = <T extends { id: number }>(arr: T[]) => {
const m = new Map<number, T>();
for (const x of arr) m.set(x.id, x);
return m;
};
// --- Derived taxonomy option lists (resolve by slug, never hardcoded ids) ---
let speciesOpts: Opt[] = [];
let woodCatGroups: import('./MultiSelect.svelte').Group[] = [];
let edgeOpts: Opt[] = [];
let figureOpts: Opt[] = [];
let gradeOpts: Opt[] = [];
let moistureOpts: Opt[] = [];
let figTagOpts: Opt[] = [];
let featTagOpts: Opt[] = [];
function deriveOptions() {
if (!tax) return;
const cats: TaxCategory[] = tax.categories;
// Resolve the two top-level groupers BY SLUG (QUALITY-105 / METADATA-5 — never
// hardcode live Woo term ids): `species` → species options; `wcat` → wood
// categories. Both are parent_id:null groupers; their children are the leaves.
const speciesGrouper = cats.find((c) => c.parent_id === null && c.slug === 'species');
const wcatGrouper = cats.find((c) => c.parent_id === null && c.slug === 'wcat');
const childrenOf = (g: TaxCategory | undefined): TaxCategory[] =>
g ? cats.filter((c) => c.parent_id === g.id) : [];
// Attribute names are matched case-insensitively (live Woo uses 'Edge type').
const termsOf = (name: string): Opt[] => {
const attr = tax!.attributes.find(
(a) => a.name.toLowerCase() === name.toLowerCase(),
);
return (attr?.terms ?? []).map((t) => ({ id: t.id, name: t.name, ai: false }));
};
speciesOpts = childrenOf(speciesGrouper).map((c) => ({ id: c.id, name: c.name, ai: false }));
// Wood categories: parent/child grouped (METADATA-10 — parents group only, never
// assigned). Each direct child of `wcat` that has its own children becomes a parent
// group with those children as leaves; a child with no children is a leaf under the
// (implicit) top group. This keeps the full tree visible with non-selectable parents.
woodCatGroups = (childrenOf(wcatGrouper)).map((parent) => {
const kids = cats.filter((c) => c.parent_id === parent.id);
const leaves: Opt[] = kids.length
? kids.map((c) => ({ id: c.id, name: c.name, ai: false }))
: [{ id: parent.id, name: parent.name, ai: false }];
return { parent: { id: parent.id, name: parent.name }, leaves };
});
edgeOpts = termsOf('edge type');
figureOpts = termsOf('figure');
gradeOpts = termsOf('grade');
// No thickness options: the band is derived server-side and never shown here
// (METADATA-15 / QUALITY-95).
moistureOpts = termsOf('moisture');
// fig-* / feat-* product tags (METADATA-30): pulled from the live synced set,
// filtered by the family the backend keeps. Never invent a prefix — if the
// store has none, the picker is legitimately empty.
const tags: TaxTag[] = tax.tags ?? [];
figTagOpts = tags.filter((t) => t.slug.startsWith('fig')).map((t) => ({ id: t.id, name: t.name, ai: false }));
featTagOpts = tags.filter((t) => t.slug.startsWith('feat')).map((t) => ({ id: t.id, name: t.name, ai: false }));
}
// --- Editable model (mirrors the live slab) ---
let speciesId: number | '' = '';
let woodCatIds: number[] = [];
let edgeTypeId: number | '' = '';
let figureIds: number[] = [];
let gradeId: number | '' = '';
let moistureId: number | '' = '';
let figTagIds: number[] = [];
let featTagIds: number[] = [];
let primaryCatId: number | '' = '';
let title = '';
let shortDescription = '';
let description = '';
let contentSource = '';
let price = '';
let priceSource = 'recommendation';
let sku = '';
// --- Review (server-derived ready/missing/title/price) ---
interface ReviewOut {
ready: boolean; missing: string[]; title: string | null;
short_description: string | null; price: number | null;
price_suggestion: number | null; primary_wood_category_id: number | null;
}
let review: ReviewOut | null = null;
let reviewBusy = false;
let reviewError = '';
// #89: explicit "Create" actions. `createBusy` covers the deterministic
// title/short-desc generation; `canCreateTitle` is its gate (species + ≥1 wood
// category). `canCreateDesc` gates the AI long-description on title+short-desc
// being populated AND the content LLM being enabled.
let createBusy = false;
let createError = '';
$: canCreateTitle =
speciesId !== '' && woodCatIds.length > 0;
$: canCreateDesc =
canGenerateContent && title.trim() !== '' && shortDescription.trim() !== '';
// Monotonic token: each commit captures the current value and ignores its
// result if a newer commit has since started. Without this, two concurrent
// commits (e.g. a select change racing the SKU blur) resolve out of order and
// a stale "not ready" review clobbers the final ready one — the
// continue-to-Publish button stays disabled even though the status reads ready.
let commitToken = 0;
// --- Inference ---
let aiBusy = false;
let aiNote = '';
let genBusy = false;
let genError = '';
let canGenerateContent = false;
let aiRecommendations: number[] = []; // union of AI-suggested ids (for row bubbles)
const num = (v: unknown) => (typeof v === 'number' ? v : null);
const fmt1 = (v: number) => { const s = v.toFixed(1); return s.endsWith('.0') ? s.slice(0, -2) : s; };
const fmt2 = (v: number) => v.toFixed(2);
// --- #90 Photo strip (REVIEW-5 / PUBLISH-45) ---
// The owner edits which photos stay/go and their order. Leftmost kept photo =
// primary (Woo cover). Kept in-memory; persisted via persistLineup() on
// "Continue to Publish". Sources are the STORED photos (listPhotos) so the
// strip survives re-entry into Review; in-memory lineup blobs (from capture)
// are preferred when present.
interface StripPhoto {
id: string;
role: 'calibration' | 'overlay' | 'batch';
isCalibration: boolean;
kept: boolean;
/** True for the mandatory photos (clean calibration + dimension-overlay
copy). Their keep checkbox is checked and disabled — the owner cannot
remove them (REVIEW-5 requires both). */
required: boolean;
original: Blob;
processed: Blob | null;
url: string;
}
let photos: StripPhoto[] = [];
let photosLoaded = false;
let lineupDirty = false;
let persistBusy = false;
let persistError = '';
// In-memory blobs from the capture lineup, keyed by lineup order. The stored
// photo id matches the capture's upload order only on the first entry; after
// an edit the STORED row (listPhotos) is authoritative and we re-fetch blobs.
const inMemByRole = (): Map<'calibration' | 'overlay' | 'batch', Blob[]> => {
const m = new Map<'calibration' | 'overlay' | 'batch', Blob[]>();
for (const it of capture?.lineup ?? []) {
if (it.kind !== 'png') continue;
const arr = m.get(it.role) ?? [];
arr.push(it.blob);
m.set(it.role, arr);
}
return m;
};
// Object URLs minted from in-memory blobs; revoked on unmount/reload so the
// <img> src stays valid. A raw Blob assigned to src renders broken — it must
// be a blob: URL (bug: the first/Primary photo was the one carrying a blob).
let photoUrls: string[] = [];
async function loadPhotos() {
// Revoke any previous object URLs before rebuilding the strip.
for (const u of photoUrls) URL.revokeObjectURL(u);
photoUrls = [];
try {
const rows: PhotoRow[] = await listPhotos(slabId);
const byRole = inMemByRole();
const seqByRole: Record<string, number> = { calibration: 0, overlay: 0, batch: 0 };
photos = rows.map((r) => {
const role = r.role ?? 'batch';
const n = seqByRole[role]++;
const blob = (byRole.get(role)?.[n]) ?? null;
// Prefer the in-memory blob (fresh capture), as an object URL. Otherwise
// fall back to the stored photo endpoint (a plain string URL).
const endpoint = photoUrl(slabId, r.id, (r.has_processed ? 'processed' : 'original') as 'original' | 'processed');
const url = resolvePhotoUrl(blob, endpoint);
if (blob && blob.size > 0) photoUrls.push(url);
return {
id: r.id,
role,
isCalibration: r.is_calibration === 1,
// Mandatory: the clean calibration photo and the dimension-overlay
// copy. REVIEW-5 requires both to ship, so their keep control is
// checked + disabled (the owner can't remove them).
required: role === 'calibration' || role === 'overlay',
kept: true,
original: blob ?? new Blob(),
processed: blob ?? null,
url,
};
});
photosLoaded = true;
lineupDirty = false;
} catch {
photos = [];
photosLoaded = false;
}
}
onDestroy(() => {
for (const u of photoUrls) URL.revokeObjectURL(u);
photoUrls = [];
});
// Whole-card tap = keep/remove toggle. A real drag (>6px) reorders and does NOT
// toggle keep/remove — the threshold lives in the pointer handlers below.
function toggleKeep(id: string) {
photos = photos.map((p) => {
if (p.id !== id) return p;
// Mandatory photos (clean cal + dimension-overlay) can't be removed.
if (p.required) return p;
return { ...p, kept: !p.kept };
});
lineupDirty = true;
}
function reorder(from: number, to: number) {
photos = moveItem(photos, from, to);
lineupDirty = true;
}
let dragFrom = -1;
function onDragStart(e: DragEvent, index: number) {
dragFrom = index;
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', String(index));
}
}
function onDragOver(e: DragEvent) {
if (e.preventDefault) e.preventDefault();
}
function onDrop(e: DragEvent, index: number) {
if (e.preventDefault) e.preventDefault();
if (dragFrom >= 0 && dragFrom !== index) reorder(dragFrom, index);
dragFrom = -1;
}
// REVIEW-5: the kept set must contain a clean photo and its overlay copy. Only
// enforced once photos have loaded (photosLoaded); a failed/absent photo list
// (e.g. legacy mock) leaves the lineup minimum neutral rather than blocking.
let lineupMissing: ('calibration' | 'overlay')[] = [];
$: lineupMissing = photosLoaded
? missingMinimums(keptInOrder(photos as LineupItem[]) as LineupItem[])
: [];
$: lineupValid = lineupMissing.length === 0;
// Leftmost kept photo id = primary (Woo cover). '' when nothing is kept.
let primaryId = '';
$: primaryId = photosLoaded
? ((keptInOrder(photos as LineupItem[]) as StripPhoto[])[0]?.id ?? '')
: '';
// Persist the edited lineup (re-seq kept + drop removed), then hand to Publish.
async function goPublish() {
if (!lineupValid) return;
persistBusy = true;
persistError = '';
try {
const kept = keptInOrder(photos as LineupItem[]) as StripPhoto[];
const removed = photos.filter((p) => !p.kept).map((p) => p.id);
await persistLineup(
slabId,
kept.map((p) => ({
id: p.id,
role: p.role,
isCalibration: p.isCalibration,
original: p.original,
processed: p.processed,
})),
removed,
);
onPublish();
} catch (e) {
persistError = e instanceof ApiError ? e.message : 'Could not save the photos. Try again.';
} finally {
persistBusy = false;
}
}
function hydrate(s: Slab, t: Taxonomy | null) {
speciesId = s.species_id ?? '';
woodCatIds = [...(s.wood_category_ids ?? [])];
edgeTypeId = s.edge_type_term_id ?? '';
figureIds = [...(s.figure_term_ids ?? [])];
gradeId = s.grade_term_id ?? '';
moistureId = s.moisture_term_id ?? '';
figTagIds = [...(s.fig_tag_ids ?? [])];
featTagIds = [...(s.feat_tag_ids ?? [])];
title = s.title ?? '';
shortDescription = s.short_description ?? '';
description = s.description ?? '';
contentSource = s.content_source ?? 'manual';
price = s.price != null ? String(s.price) : '';
priceSource = s.price_source ?? 'recommendation';
sku = s.sku ?? '';
// REVIEW-15: primary defaults to first selected wood cat; keep if still valid.
primaryCatId = nextPrimary(woodCatIds, woodCatIds[0] ?? '');
// Seed AI-recommended markers from stored confidences (>=0.7 means the AI put
// its weight behind the value the owner kept).
aiRecommendations = [];
if (num(s.species_confidence) != null && num(s.species_confidence)! >= 0.7 && s.species_id)
aiRecommendations.push(s.species_id);
if (num(s.figure_confidence) != null && num(s.figure_confidence)! >= 0.7)
aiRecommendations.push(...(s.figure_term_ids ?? []));
}
// AI-marked option views (badge on rows after Call 1 / stored confidences).
$: speciesOptsView = speciesOpts.map((o) => ({ ...o, ai: aiRecommendations.includes(o.id) }));
$: woodCatGroupsView = woodCatGroups.map((g) => ({
parent: g.parent,
leaves: g.leaves.map((o) => ({ ...o, ai: aiRecommendations.includes(o.id) })),
}));
$: figureOptsView = figureOpts.map((o) => ({ ...o, ai: aiRecommendations.includes(o.id) }));
$: featTagOptsView = featTagOpts.map((o) => ({ ...o, ai: aiRecommendations.includes(o.id) }));
// Flat leaf name lookup for the primary radio set (REVIEW-15).
$: woodLeafById = new Map(
woodCatGroups.flatMap((g) => g.leaves.map((l) => [l.id, l] as [number, Opt])),
);
$: primaryOpts = woodCatIds
.map((id) => woodLeafById.get(id))
.filter((o): o is Opt => !!o)
.map((o) => ({ ...o, ai: aiRecommendations.includes(o.id) }));
// METADATA-15 / QUALITY-95: the thickness band is derived server-side from the
// measured thickness against the live synced Woo bands. It is deliberately absent
// from this screen — no owner control, no read-only echo. The only thickness the
// owner sees here is the measured value in the summary above.
const aiIn = (ids: number[]) => (id: number) => ids.includes(id);
// Persist taxonomy/price/sku edits to the slab (PUT), then recompute ready.
// #89: the deterministic title/short_description are written here — the server's
// review returns the build from the current metadata, and if the field is empty
// we fold the deterministic value into THIS same PUT (no extra request, no rev
// race). Owner-edited text is never clobbered (only an empty field is filled).
async function commitReview(): Promise<void> {
if (!slab) return;
const myToken = ++commitToken;
reviewBusy = true;
reviewError = '';
try {
// Run the deterministic review FIRST (reads the current metadata) to get the
// title/short_description build before we persist.
const probe = await reviewSlab(slabId, {
primary_wood_category_id: primaryCatId === '' ? undefined : Number(primaryCatId),
price: price === '' ? undefined : Number(price),
sku: sku || undefined,
});
if (myToken !== commitToken) return;
// If the owner hasn't typed a title/short-desc, adopt the deterministic build
// so this PUT persists it (the Create buttons do the same, on demand).
if (probe.title && title === '') title = probe.title;
if (probe.short_description && shortDescription === '') shortDescription = probe.short_description;
const patch = {
species_id: speciesId === '' ? null : Number(speciesId),
wood_category_ids: woodCatIds,
edge_type_term_id: edgeTypeId === '' ? null : Number(edgeTypeId),
figure_term_ids: figureIds,
grade_term_id: gradeId === '' ? null : Number(gradeId),
moisture_term_id: moistureId === '' ? null : Number(moistureId),
fig_tag_ids: figTagIds,
feat_tag_ids: featTagIds,
title: title || null,
short_description: shortDescription || null,
content_source: contentSource || null,
price: price === '' ? null : Number(price),
price_source: priceSource,
sku: sku || null,
client_rev: slab.server_rev,
};
const fresh = await updateSlab(slabId, patch);
if (myToken !== commitToken) return; // superseded; don't clobber the latest
slab = fresh;
// Re-run the review against the just-persisted row (authoritative ready/missing).
review = await reviewSlab(slabId, {
primary_wood_category_id: primaryCatId === '' ? undefined : Number(primaryCatId),
price: price === '' ? undefined : Number(price),
sku: sku || undefined,
});
if (myToken !== commitToken) return;
if (review.primary_wood_category_id != null) primaryCatId = review.primary_wood_category_id;
} catch (e) {
if (myToken !== commitToken) return;
reviewError = e instanceof ApiError ? e.message : 'Could not review. Try again.';
review = null;
} finally {
if (myToken === commitToken) reviewBusy = false;
}
}
// #89: explicit "Create title" / "Create short desc" — fire the deterministic
// build from the current metadata and write the result into the fields,
// overwriting whatever was there (empty, owner-typed, or previously generated).
// That is the whole contract: the button fires the build. commitReview()
// auto-fills only empty fields; this is the explicit "force overwrite" action.
async function createTitle(): Promise<void> {
if (!slab || createBusy) return;
createBusy = true;
createError = '';
try {
// 1. Run the deterministic review to get title / short_description.
const res = await reviewSlab(slabId, {
primary_wood_category_id: primaryCatId === '' ? undefined : Number(primaryCatId),
});
if (res.primary_wood_category_id != null) primaryCatId = res.primary_wood_category_id;
// 2. Write the deterministic values into the bound fields — always overwrite.
if (res.title) title = res.title;
if (res.short_description) shortDescription = res.short_description;
// 3. Persist the generated prose (single PUT, fresh client_rev).
if ((title || shortDescription) && (title !== (slab as Slab).title || shortDescription !== (slab as Slab).short_description)) {
const prosePatch = {
title: title || null,
short_description: shortDescription || null,
client_rev: (slab as Slab).server_rev,
};
const fresh = await updateSlab(slabId, prosePatch);
slab = fresh;
review = await reviewSlab(slabId, {
primary_wood_category_id: primaryCatId === '' ? undefined : Number(primaryCatId),
});
}
} catch (e) {
createError = e instanceof ApiError ? e.message : 'Could not build the title. Try again.';
} finally {
createBusy = false;
}
}
// #89: the description textarea commits on blur. commitReview() no longer sends
// description (see #89), so this persists the owner-typed description explicitly.
async function commitDescription(): Promise<void> {
if (!slab) return;
const myToken = ++commitToken;
reviewBusy = true;
reviewError = '';
try {
const fresh = await updateSlab(slabId, {
description: description || null,
content_source: contentSource || null,
client_rev: slab.server_rev,
});
if (myToken !== commitToken) return;
slab = fresh;
review = await reviewSlab(slabId, {
primary_wood_category_id: primaryCatId === '' ? undefined : Number(primaryCatId),
});
} catch (e) {
if (myToken !== commitToken) return;
reviewError = e instanceof ApiError ? e.message : 'Could not review. Try again.';
review = null;
} finally {
if (myToken === commitToken) reviewBusy = false;
}
}
async function runInference() {
if (!slab) return;
aiBusy = true;
aiNote = '';
try {
const r = await inferTaxon(slabId);
const s = r.suggestions;
const recs: number[] = [];
if (s.species_id) { speciesId = s.species_id; recs.push(s.species_id); }
if (s.wood_category_ids) {
woodCatIds = [...s.wood_category_ids];
primaryCatId = nextPrimary(woodCatIds, primaryCatId);
recs.push(...s.wood_category_ids);
}
if (s.edge_type_term_id) edgeTypeId = s.edge_type_term_id;
if (s.figure_term_ids) { figureIds = [...s.figure_term_ids]; recs.push(...s.figure_term_ids); }
if (s.feature_tag_ids?.length) {
featTagIds = [...s.feature_tag_ids];
recs.push(...s.feature_tag_ids);
}
// Grade is not AI-suggested (METADATA-40) — owner judgment. Do not auto-fill.
aiRecommendations = recs;
aiNote = recs.length ? `AI suggested ${recs.length} field${recs.length === 1 ? '' : 's'} — review & edit below.` : 'No confident AI suggestions.';
await commitReview();
} catch (e) {
aiNote = e instanceof ApiError ? e.message : 'Inference unavailable.';
} finally {
aiBusy = false;
}
}
async function generateContent() {
if (!slab || !canCreateDesc) return;
genBusy = true;
genError = '';
try {
const r = await inferContent(slabId);
description = r.description;
contentSource = 'llm';
// commitReview() no longer sends description (see #89), so persist it explicitly.
const myToken = ++commitToken;
const fresh = await updateSlab(slabId, {
description: description || null,
content_source: contentSource || null,
client_rev: slab.server_rev,
});
if (myToken !== commitToken) return;
slab = fresh;
} catch (e) {
genError = e instanceof ApiError ? e.message : 'Could not generate text.';
} finally {
genBusy = false;
}
}
const missingCount = () => (review ? review.missing.length : null);
const ready = () => review?.ready === true;
// The button's enabled state must derive from the SAME reactive `review` object
// the status text reads. A separate `ready()` function call in the template
// evaluates in a different reactive flush, leaving `disabled` stale (false→the
// button stays disabled) even after `review.ready` flips true. Binding
// `publishDisabled` to `review` (a reactive object) forces the same dependency.
let publishDisabled = true;
$: publishDisabled = review ? !review.ready : true;
// --- Multi-select list helpers (searchable, squared) ---
function toggleMulti(arr: number[], id: number): number[] {
return arr.includes(id) ? arr.filter((x) => x !== id) : [...arr, id];
}
/** Wood categories: toggle leaf + keep primary valid (REVIEW-15 / wireframe #90). */
function toggleWoodCat(id: number) {
woodCatIds = toggleMulti(woodCatIds, id);
primaryCatId = nextPrimary(woodCatIds, primaryCatId);
}
function pickPrimary(id: number) {
if (!woodCatIds.includes(id)) return;
primaryCatId = id;
void commitReview();
}
onMount(async () => {
try {
const [s, t, set] = await Promise.all([getSlab(slabId), getTaxonomy(), getSettings()]);
slab = s; tax = t; settings = set;
deriveOptions();
hydrate(s, t);
canGenerateContent = !!set.content_llm_enabled;
await loadPhotos();
await commitReview();
} catch (e) {
loadError = e instanceof ApiError ? e.message : 'Could not load the slab.';
} finally {
loading = false;
}
});
function goBack() {
onBack();
}
</script>
{#if loading}
<section class="review" aria-busy="true" data-testid="review-loading">
<h1>Review to Ready</h1>
<p class="busy">Loading slab…</p>
</section>
{:else if loadError}
<section class="review">
<h1>Review to Ready</h1>
<p class="err" role="alert">{loadError}</p>
<button type="button" class="ghost" data-testid="back-capture" on:click={goBack}>Back to capture</button>
</section>
{:else}
<section class="review" data-testid="review-screen">
<h1>Review to Ready</h1>
<dl class="math">
<div><dt>Length</dt><dd>{fmt1(capture?.lengthIn ?? slab?.length_in ?? 0)}″</dd></div>
<div><dt>Thickness</dt><dd>{fmt1(capture?.thicknessIn ?? slab?.thickness_in ?? 0)}″</dd></div>
<div><dt>Sqft / Bdft</dt><dd>{fmt2(capture?.sqft ?? slab?.sqft ?? 0)} / {fmt2(capture?.bdft ?? slab?.bdft ?? 0)}</dd></div>
</dl>
<!-- #90 Photo strip: keep/remove + drag-reorder; leftmost kept = primary. -->
{#if photosLoaded && photos.length}
<div class="photos" data-testid="photo-strip">
<p class="ph-hint">Tap a photo to keep or remove it · drag to reorder · leftmost photo = primary</p>
<div class="ph-grid">
{#each photos as p, i (p.id)}
<div
class="pcard"
class:kept={p.kept}
class:primary={p.id === primaryId}
class:required={p.required}
data-testid="photo-card"
data-primary={p.id === primaryId ? 'true' : ''}
data-required={p.required ? 'true' : ''}
role="button"
tabindex="0"
aria-pressed={p.required ? true : p.kept}
aria-disabled={p.required ? 'true' : 'false'}
aria-label="{p.required ? 'Required: ' : ''}{roleLabel(p.role)}{p.required ? ' (cannot be removed)' : (p.kept ? ', kept' : ', removed')}"
draggable="true"
on:click={() => toggleKeep(p.id)}
on:keydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggleKeep(p.id); } }}
on:dragstart={(e) => onDragStart(e, i)}
on:dragover={onDragOver}
on:drop={(e) => onDrop(e, i)}
>
<img class="ph-thumb" src={p.url} alt={roleLabel(p.role)} draggable="false" />
{#if p.id === primaryId}
<span class="ph-pri">PRIMARY</span>
{/if}
{#if p.required}
<span class="ph-req">REQUIRED</span>
{/if}
<span class="ph-seq" aria-hidden="true">{i + 1}</span>
<!-- Keep checkbox: always checked for required photos; a disabled
glyph (no check) when a batch photo is removed. -->
<span class="ph-keep" class:required={p.required} aria-hidden="true"></span>
</div>
{/each}
</div>
{#if lineupMissing.length}
<p class="ph-nudge" data-testid="lineup-nudge" role="alert">
Keep at least {lineupMissing.map(roleLabel).join(' and ')} — required to publish.
</p>
{/if}
{#if persistError}<p class="nudge" role="alert">{persistError}</p>{/if}
</div>
{/if}
{#if settings?.inference_enabled}
<div class="aibar">
<button type="button" class="ai-btn" data-testid="run-inference" on:click={runInference} disabled={aiBusy}>
{aiBusy ? 'Reading slab…' : 'Suggest with AI'}
</button>
{#if aiNote}<p class="ai-note" data-testid="ai-note">{aiNote}</p>{/if}
</div>
{/if}
<!-- Species (single, AI-assist) -->
<div class="field">
<label class="lbl" for="species">Species <span class="ai">AI assist</span></label>
<select id="species" class="sel" data-testid="species" bind:value={speciesId} on:change={commitReview}>
<option value="">— select —</option>
{#each speciesOptsView as o (o.id)}
<option value={o.id} data-ai={o.ai ? 'true' : ''}>{o.name}{o.ai ? ' (AI)' : ''}</option>
{/each}
</select>
</div>
<!-- Wood categories (multi + inline primary radio set, REVIEW-15 / #90 wireframe) -->
<div class="field" data-testid="wood-cat-field">
<label class="lbl">Wood categories <span class="ai">AI assist</span></label>
<MultiSelect
label="Wood categories"
groups={woodCatGroupsView}
selected={woodCatIds}
placeholder="Choose wood categories"
testid="wood-cat"
onToggle={toggleWoodCat}
on:commit={commitReview}
/>
{#if primaryOpts.length}
<div class="primary-set" data-testid="primary-wood-set" role="radiogroup" aria-label="Primary wood category">
<div class="ps-lbl">Primary wood category <span class="ps-req">drives title</span></div>
{#each primaryOpts as o (o.id)}
<button
type="button"
class="ps-opt"
class:on={primaryCatId === o.id}
role="radio"
aria-checked={primaryCatId === o.id}
data-testid={`primary-wood-${o.id}`}
on:click={() => pickPrimary(o.id)}
>
<span class="ps-radio" aria-hidden="true"></span>
<span class="ps-name">{o.name}</span>
{#if o.ai}<span class="ps-tag">AI</span>{/if}
</button>
{/each}
<p class="ps-note">
Primary selects the title &amp; short-description pattern (METADATA-85).
Secondary categories feed the description. Default = first selected.
</p>
</div>
{/if}
</div>
<!-- Edge type (single, AI-assist) -->
<div class="field">
<label class="lbl" for="edge">Edge type <span class="ai">AI assist</span></label>
<select id="edge" class="sel" data-testid="edge-type" bind:value={edgeTypeId} on:change={commitReview}>
<option value="">— select —</option>
{#each edgeOpts as o (o.id)}
<option value={o.id}>{o.name}</option>
{/each}
</select>
</div>
<!-- Figure (multi, searchable list, AI-assist) -->
<div class="field">
<label class="lbl">Figure <span class="ai">AI assist</span></label>
<MultiSelect
label="Figure"
options={figureOptsView}
selected={figureIds}
placeholder="Choose figure"
testid="figure"
onToggle={(id) => (figureIds = toggleMulti(figureIds, id))}
on:commit={commitReview}
/>
</div>
<!-- Grade (single, owner judgment — NOT AI-assisted per METADATA-40) -->
<div class="field">
<label class="lbl" for="grade">Grade</label>
<select id="grade" class="sel" data-testid="grade" bind:value={gradeId} on:change={commitReview}>
<option value="">— select —</option>
{#each gradeOpts as o (o.id)}<option value={o.id}>{o.name}</option>{/each}
</select>
</div>
<!-- fig-* product tags (optional, owner-only, no AI) -->
<div class="field">
<label class="lbl">fig-* tags <span class="sug">optional</span></label>
<MultiSelect
label="fig-* tags"
options={figTagOpts}
selected={figTagIds}
placeholder="Choose fig-* tags"
testid="fig-tag"
onToggle={(id) => (figTagIds = toggleMulti(figTagIds, id))}
on:commit={commitReview}
/>
</div>
<!-- feat-* product tags (optional, AI-assisted by Call 1) -->
<div class="field">
<label class="lbl">feat-* tags <span class="sug">optional</span> <span class="ai">AI assist</span></label>
<MultiSelect
label="feat-* tags"
options={featTagOptsView}
selected={featTagIds}
placeholder="Choose feat-* tags"
testid="feat-tag"
onToggle={(id) => (featTagIds = toggleMulti(featTagIds, id))}
on:commit={commitReview}
/>
</div>
<div class="field">
<label class="lbl" for="moisture">Moisture</label>
<select id="moisture" class="sel" data-testid="moisture" bind:value={moistureId} on:change={commitReview}>
<option value="">— select —</option>
{#each moistureOpts as o (o.id)}<option value={o.id}>{o.name}</option>{/each}
</select>
</div>
<!-- Title / Short description + explicit "Create" build buttons (#89) -->
<div class="field">
<label class="lbl" for="title">Title</label>
<div class="create-row">
<input id="title" class="inp" data-testid="title" bind:value={title} />
<button type="button" class="create-btn" data-testid="create-title"
disabled={!canCreateTitle || createBusy} on:click={createTitle}>
{createBusy ? 'Building…' : 'Create title'}
</button>
</div>
</div>
<div class="field">
<label class="lbl" for="short-desc">Short description</label>
<div class="create-row">
<input id="short-desc" class="inp" data-testid="short-desc" bind:value={shortDescription} />
<button type="button" class="create-btn" data-testid="create-short-desc"
disabled={!canCreateTitle || createBusy} on:click={createTitle}>
{createBusy ? 'Building…' : 'Create short desc'}
</button>
</div>
</div>
<div class="field">
<label class="lbl" for="desc">Description</label>
<textarea id="desc" class="inp ta" data-testid="desc" rows="3" bind:value={description} on:blur={commitDescription}></textarea>
{#if canCreateDesc}
<div class="gen">
<button type="button" class="gen-btn" data-testid="create-desc" on:click={generateContent} disabled={genBusy}>
{genBusy ? 'Writing…' : 'Create desc'}
</button>
</div>
{/if}
{#if genError}<p class="nudge" role="alert">{genError}</p>{/if}
{#if createError}<p class="nudge" role="alert">{createError}</p>{/if}
</div>
<!-- Price / SKU (SKU last) -->
<div class="field">
<label class="lbl" for="price">Price ($)
{#if review?.price_suggestion != null}<span class="sug">suggested ${review.price_suggestion.toFixed(2)}</span>{/if}
</label>
<input id="price" class="inp" data-testid="price" type="number" step="0.01" bind:value={price} on:change={commitReview} />
</div>
<div class="field">
<label class="lbl" for="sku">SKU <span class="sug">last field, 3–24 chars</span></label>
<input id="sku" class="inp" data-testid="sku" placeholder="SLAB-…" bind:value={sku} on:blur={commitReview} />
{#if sku && !/^[A-Za-z0-9-]{3,24}$/.test(sku)}
<p class="nudge">SKU must be 3–24 alphanumerics or hyphens.</p>
{/if}
</div>
<!-- Ready status + actions -->
{#if reviewBusy}
<div class="status" data-testid="review-busy" aria-busy="true">Checking readiness…</div>
{:else if review}
<div class="status" class:ok={ready() && lineupValid} data-testid="ready-status">
{ready() && lineupValid
? 'Ready to publish'
: `Not ready — ${[
...review.missing,
...lineupMissing.map(roleLabel),
].length} field${[...review.missing, ...lineupMissing.map(roleLabel)].length === 1 ? '' : 's'} remaining`}
</div>
{#if !(ready() && lineupValid)}
<p class="missing" data-testid="missing-list">Missing: {[...review.missing, ...lineupMissing.map(roleLabel)].join(', ')}</p>
{/if}
{/if}
{#if reviewError}<p class="nudge" role="alert">{reviewError}</p>{/if}
<div class="row">
<button type="button" class="ghost" data-testid="back-capture" on:click={goBack}>Back</button>
<button
type="button"
class="primary"
data-testid="continue-publish"
disabled={publishDisabled || !lineupValid || persistBusy}
on:click={goPublish}
>
{persistBusy ? 'Saving photos…' : 'Continue to Publish'}
</button>
</div>
</section>
{/if}
<style>
.review { max-width: 560px; margin: 0 auto; color: #eee; }
h1 { font-size: 1.3rem; margin: 0 0 12px; }
.math div { display: flex; justify-content: space-between; border-bottom: 1px solid #2a2a2a; padding: 5px 0; font-size: .85rem; }
.math dt { color: #9a9a9a; margin: 0; } .math dd { margin: 0; font-variant-numeric: tabular-nums; }
.aibar { margin: 12px 0; }
.ai-btn { padding: 8px 12px; border: 0; border-radius: 3px; background: #00e5ff; color: #002; font-weight: 600; font-size: .85rem; }
.ai-btn:disabled { background: #333; color: #777; }
.ai-note { color: #aaa; font-size: .78rem; margin: 6px 0 0; }
.lbl { display: block; font-size: .8rem; color: #bbb; margin: 0 0 4px; }
.lbl .ai { margin-left: 6px; font-size: .58rem; font-weight: 700; padding: 1px 5px; border-radius: 2px; background: #00e5ff; color: #002; }
/* box-sizing: the app has no global reset, so a width:100% control with padding
and a border pokes out of its container by that padding+border (20px here) —
which becomes real page overflow whenever the column sits at the viewport edge
(measured: 375px mobile scrolled 20px). Border-box keeps the box inside. */
.inp, .sel { box-sizing: border-box; width: 100%; padding: 9px; background: #1d1d1d; border: 1px solid #444; border-radius: 3px; color: #eee; font-size: .9rem; text-align: left; }
.sel { appearance: none; background-image: linear-gradient(45deg,transparent 50%,#777 50%),linear-gradient(135deg,#777 50%,transparent 50%); background-position: calc(100% - 16px) 55%,calc(100% - 11px) 55%; background-size: 5px 5px; background-repeat: no-repeat; }
.ta { resize: vertical; }
.field { margin-bottom: 12px; }
.nudge { color: #e8a; font-size: .74rem; margin: 4px 0 0; }
.sug { color: #aaa; font-size: .74rem; font-weight: 400; margin-left: 6px; }
.gen { margin-top: 8px; } .gen-btn { padding: 8px 12px; border: 0; border-radius: 3px; background: #00e5ff; color: #002; font-weight: 600; font-size: .82rem; }
/* Lock chrome matches agreed #90 wireframe: muted dark green, not jarring neon. */
/* #89: explicit "Create" build buttons beside the title / short-desc fields. */
.create-row { display: flex; gap: 8px; align-items: stretch; }
.create-row .inp { flex: 1; }
.create-btn { box-sizing: border-box; padding: 8px 12px; border: 1px solid #333; border-radius: 3px;
background: #1d1d1d; color: #eee; font-size: .82rem; font-weight: 600; cursor: pointer; white-space: nowrap; }
.create-btn:hover { background: #242424; }
.create-btn:disabled { background: #222; color: #666; border-color: #333; cursor: not-allowed; }
/* Primary wood category — inline under wood MultiSelect (REVIEW-15 / #90 wireframe). */
.primary-set { margin-top: 8px; padding: 10px 12px; border: 1px solid #333; border-radius: 3px; background: #141414; }
.primary-set .ps-lbl { font-size: .74rem; color: #9a9a9a; margin-bottom: 6px; display: flex; align-items: center; gap: 6px; }
.primary-set .ps-req { font-size: .58rem; font-weight: 700; padding: 1px 5px; border-radius: 2px; background: #2a2a2a; color: #888; }
.ps-opt { box-sizing: border-box; width: 100%; display: flex; align-items: center; gap: 9px; padding: 8px 6px; margin: 0; border: 0; border-radius: 3px; background: transparent; color: #ddd; font-size: .88rem; cursor: pointer; text-align: left; }
.ps-opt:hover { background: #1c1c1c; }
.ps-opt.on { background: #12262b; color: #fff; }
.ps-opt:focus-visible { outline: 2px solid #00e5ff; outline-offset: 1px; }
.ps-radio { width: 14px; height: 14px; border: 2px solid #555; border-radius: 50%; flex: none; position: relative; }
.ps-opt.on .ps-radio { border-color: #00e5ff; }
.ps-opt.on .ps-radio::after { content: ''; position: absolute; inset: 2px; border-radius: 50%; background: #00e5ff; }
.ps-name { flex: 1; }
.ps-tag { font-size: .56rem; font-weight: 700; padding: 0 4px; border-radius: 2px; background: #00e5ff; color: #002; letter-spacing: .3px; }
.ps-note { font-size: .7rem; color: #777; margin: 6px 0 0; line-height: 1.4; }
.status { margin-top: 14px; font-weight: 700; padding: 10px 12px; border-radius: 3px; background: #2a1512; color: #ffb4a2; font-size: .9rem; }
.status.ok { background: #122a12; color: #7fd37f; }
.missing { color: #999; font-size: .76rem; margin: 6px 0 0; }
.row { display: flex; gap: 10px; margin-top: 16px; }
.primary { flex: 1; padding: 14px; font-size: 1rem; border-radius: 3px; border: 0; background: #00e5ff; color: #002; font-weight: 700; }
.primary:disabled { background: #333; color: #777; }
.ghost { padding: 14px 18px; border-radius: 3px; border: 1px solid #444; background: transparent; color: #ccc; }
.err { color: #e88; } .busy { color: #aaa; }
/* #90 Photo strip */
.photos { margin: 14px 0 4px; }
.ph-hint { color: #8a8a8a; font-size: .72rem; margin: 0 0 8px; }
.ph-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
@media (max-width: 420px) { .ph-grid { grid-template-columns: repeat(2, 1fr); } }
.pcard { position: relative; aspect-ratio: 1; border: 2px solid #2a2a2a; border-radius: 3px; overflow: hidden; cursor: grab;
/* Gray checkerboard, matching the Capture lineup (MeasurePanel .lineup-item.png)
so transparent inventory PNGs read the same on both screens (bug2). */
background-color: #eee;
background-image: linear-gradient(45deg,#ccc 25%,transparent 25%),linear-gradient(-45deg,#ccc 25%,transparent 25%),linear-gradient(45deg,transparent 75%,#ccc 75%),linear-gradient(-45deg,transparent 75%,#ccc 75%);
background-size: 16px 16px; background-position: 0 0,0 8px,8px -8px,-8px 0; }
.pcard:active { cursor: grabbing; }
.pcard:focus-visible { outline: 2px solid #00e5ff; outline-offset: 2px; }
.pcard.kept { border-color: #444; }
.pcard.primary { border-color: #00e5ff; }
.pcard:not(.kept) { opacity: .4; }
.ph-thumb { width: 100%; height: 100%; object-fit: cover; display: block; pointer-events: none; }
.ph-pri { position: absolute; top: 6px; right: 6px; background: #00e5ff; color: #002; font-size: .56rem; font-weight: 800; padding: 1px 5px; border-radius: 2px; letter-spacing: .03em; pointer-events: none; }
.ph-req { position: absolute; top: 6px; left: 50%; transform: translateX(-50%); background: #ffb020; color: #201500; font-size: .52rem; font-weight: 800; padding: 1px 5px; border-radius: 2px; letter-spacing: .04em; pointer-events: none; }
.ph-seq { position: absolute; bottom: 6px; left: 6px; width: 18px; height: 18px; border-radius: 50%; background: rgba(0,0,0,.7); color: #fff; font-size: .66rem; display: flex; align-items: center; justify-content: center; pointer-events: none; }
.ph-keep { position: absolute; top: 6px; left: 6px; width: 16px; height: 16px; border: 2px solid #fff; border-radius: 3px; background: rgba(0,0,0,.5); pointer-events: none; }
.pcard.kept .ph-keep { background: #00e5ff; border-color: #00e5ff; }
.pcard.kept .ph-keep::after { content: ''; position: absolute; left: 4px; top: 1px; width: 4px; height: 8px; border: solid #002; border-width: 0 2px 2px 0; transform: rotate(45deg); }
/* Mandatory photos (clean cal + dimension-overlay): the keep checkbox is
checked AND disabled. A bold padlock glyph in the box signals "required —
cannot be removed", and the card carries a thin required outline so the
owner can tell mandatory photos apart from optional batch photos. */
.pcard.required { border-color: #00e5ff55; }
.pcard.required .ph-keep { background: #00e5ff; border-color: #00e5ff; }
/* Padlock: shackle (arc) + body, drawn large enough to read on a 16px box. */
.pcard.required .ph-keep::after {
content: ''; position: absolute; left: 3px; top: 2px; width: 8px; height: 8px;
border: 2px solid #002; border-bottom: none; border-radius: 4px 4px 0 0;
}
.pcard.required .ph-keep::before {
content: ''; position: absolute; left: 2.5px; top: 6px; width: 8px; height: 6px;
background: #002; border-radius: 1px;
}
.pcard.required { cursor: default; }
.ph-nudge { color: #e8a; font-size: .74rem; margin: 8px 0 0; }
</style>