feat(ui): Jeff-facing copy + 3:4 inventory thumbs + fixed overlay font

Locked UI/copy pass on proto-per-user-woo-creds (Settings/Woo gate polish out of scope):
- Inventory PNG thumbs at 3:4 contain (Review, Publish preview, Photos ready); stop 1:1 cover squaring
- Dimension overlay labels fixed OVERLAY_FONT_PX=36 for every product
- Page 1–3 Jeff-plain copy; drop Measure red tech essays / px scale rows
- Review: lock SVG, consistent red missing/not-ready, View prompt gated (Settings default off)
This commit is contained in:
grokbot_ty
2026-09-27 09:04:47 -07:00
parent 14dcdc43e6
commit 20a7d7d9d5
21 changed files with 231 additions and 177 deletions
+3 -3
View File
@@ -140,7 +140,7 @@
enterReview(id, capture); enterReview(id, capture);
} catch (e) { } catch (e) {
saveError = saveError =
e instanceof ApiError ? e.message : 'Could not start the slab. Check your connection and try again.'; e instanceof ApiError ? e.message : 'Couldn’t start this slab. Check your connection and try again.';
backToCapture(); backToCapture();
} finally { } finally {
saving = false; saving = false;
@@ -200,7 +200,7 @@
</section> </section>
{:else if curFlow.phase === 'saving'} {:else if curFlow.phase === 'saving'}
<section class="panel" aria-busy="true"> <section class="panel" aria-busy="true">
<h1>Saving slab…</h1> <h1>Saving…</h1>
</section> </section>
{:else} {:else}
<!-- capture column (single slab, CAPTURE-25/30) --> <!-- capture column (single slab, CAPTURE-25/30) -->
@@ -244,7 +244,7 @@
/> />
{/if} {/if}
{#if saving} {#if saving}
<div class="row"><span class="busy" aria-live="polite">Saving slab…</span></div> <div class="row"><span class="busy" aria-live="polite">Saving…</span></div>
{/if} {/if}
{#if saveError} {#if saveError}
<div class="save-err" role="alert" data-testid="save-error">{saveError}</div> <div class="save-err" role="alert" data-testid="save-error">{saveError}</div>
+5 -5
View File
@@ -31,20 +31,20 @@
</script> </script>
<section class="panel"> <section class="panel">
<h1>Batch photos</h1> <h1>Add slab photos</h1>
<ul class="setup"> <ul class="setup">
<li>Sheet is green or black, fills the frame around the slab.</li> <li>Sheet is green or black, fills the frame around the slab.</li>
<li>Slab fully on the sheet, nothing on top of it.</li> <li>Slab fully on the sheet, nothing on top of it.</li>
<li>Top-down reference shot: lens parallel to the face.</li> <li>Take one photo straight above the slab.</li>
<li>Slab length along the long side of the picture.</li> <li>Keep the slab’s long side aligned with the photo’s long side.</li>
<li>Add one or more photos of the same slab, then Continue.</li> <li>Add one or more photos of the same slab, then Continue.</li>
</ul> </ul>
{#if photos.length > 0} {#if photos.length > 0}
<ul class="thumbs" aria-label="Batch photos"> <ul class="thumbs" aria-label="Slab photos">
{#each photos as _p, i (urls[i])} {#each photos as _p, i (urls[i])}
<li class="thumb"> <li class="thumb">
<img src={urls[i]} alt={`Batch photo ${i + 1}`} /> <img src={urls[i]} alt={`Photo ${i + 1}`} />
<button type="button" class="rm" aria-label={`Remove photo ${i + 1}`} on:click={() => onRemove(i)}>Remove</button> <button type="button" class="rm" aria-label={`Remove photo ${i + 1}`} on:click={() => onRemove(i)}>Remove</button>
</li> </li>
{/each} {/each}
+21 -43
View File
@@ -7,7 +7,6 @@
import { clampTiltDeg, defaultTicks } from './tapeGeometry'; import { clampTiltDeg, defaultTicks } from './tapeGeometry';
import PhotoStage from './PhotoStage.svelte'; import PhotoStage from './PhotoStage.svelte';
import { getCropFill, setCropFill } from '../settings/cropFill'; import { getCropFill, setCropFill } from '../settings/cropFill';
import { cropMarginFraction } from '../png/crop';
import { getTaxonomy } from '../api/slabs'; import { getTaxonomy } from '../api/slabs';
import { speciesLeavesFromTaxonomy } from '../create/settings/speciesLeaves'; import { speciesLeavesFromTaxonomy } from '../create/settings/speciesLeaves';
import { import {
@@ -163,8 +162,7 @@
<h1>{photosReady ? 'Photos ready' : 'Measure'}</h1> <h1>{photosReady ? 'Photos ready' : 'Measure'}</h1>
{#if !photosReady} {#if !photosReady}
<p class="hint">Orient the slab on the fixed tape line, then enter length, thickness, and species.</p> <p class="hint">Line up the slab with the black line, then enter length, thickness, and species.</p>
<p class="nudge">The black line is <b>fixed</b>: tilting, dragging and zooming the slab never move it. It stays in the middle of the rectangle.</p>
<div class="mode-toggle" role="group" aria-label="Stage gesture mode"> <div class="mode-toggle" role="group" aria-label="Stage gesture mode">
<button <button
@@ -184,13 +182,6 @@
on:click={() => (gestureMode = 'rotate')} on:click={() => (gestureMode = 'rotate')}
>Rotate</button> >Rotate</button>
</div> </div>
<p class="nudge mode-hint">
{#if gestureMode === 'zoompan'}
Pinch to zoom and drag to pan. Rotation gestures are ignored — switch to Rotate or use the tilt slider.
{:else}
Two-finger twist to rotate (or use the tilt slider). Pan and pinch-zoom are ignored in this mode.
{/if}
</p>
<!-- Same binding as Mask/Axis: oriented source + approved bitset. <!-- Same binding as Mask/Axis: oriented source + approved bitset.
PhotoStage paints opaque checkerboard on non-slab (iOS-safe under the PhotoStage paints opaque checkerboard on non-slab (iOS-safe under the
@@ -209,7 +200,7 @@
<div class="controls"> <div class="controls">
<div> <div>
<p class="ctl-label">Tilt the slab</p> <p class="ctl-label">Tilt</p>
<div class="angle"> <div class="angle">
<button type="button" class="thumb" aria-label="Tilt left" on:click={() => nudgeTilt(-1)}>−</button> <button type="button" class="thumb" aria-label="Tilt left" on:click={() => nudgeTilt(-1)}>−</button>
<input <input
@@ -220,11 +211,10 @@
<button type="button" class="thumb" aria-label="Tilt right" on:click={() => nudgeTilt(1)}>+</button> <button type="button" class="thumb" aria-label="Tilt right" on:click={() => nudgeTilt(1)}>+</button>
<span class="val">{tiltShown}°</span> <span class="val">{tiltShown}°</span>
</div> </div>
<p class="nudge">±90° · slider always works · in Rotate mode: ← → tilt 1° · Shift+← → 0.1° · two-finger twist</p>
</div> </div>
<div> <div>
<p class="ctl-label">Zoom the slab</p> <p class="ctl-label">Zoom</p>
<div class="angle"> <div class="angle">
<button type="button" class="thumb" aria-label="Zoom out" on:click={() => nudgeZoom(-1)}>−</button> <button type="button" class="thumb" aria-label="Zoom out" on:click={() => nudgeZoom(-1)}>−</button>
<input <input
@@ -236,40 +226,30 @@
<span class="val">{Math.round(tape.zoom * 100)}%</span> <span class="val">{Math.round(tape.zoom * 100)}%</span>
</div> </div>
<div class="row tight"> <div class="row tight">
<button type="button" class="ghost" on:click={fit}>Fit — 100%, centred</button> <button type="button" class="ghost" on:click={fit}>Reset view</button>
</div> </div>
</div> </div>
<div> <div>
<p class="ctl-label">Crop fill % <span class="sub">global setting</span></p> <p class="ctl-label">Frame fill</p>
<div class="croprow"> <div class="croprow">
<input <input
type="range" min="50" max="100" step="1" type="range" min="50" max="100" step="1"
value={Math.round(cropFill * 100)} value={Math.round(cropFill * 100)}
aria-label="Crop fill percent" aria-label="Frame fill"
on:input={(e) => onFill(Number(e.currentTarget.value))} on:input={(e) => onFill(Number(e.currentTarget.value))}
/> />
<span class="val">{Math.round(cropFill * 100)}%</span> <span class="val">{Math.round(cropFill * 100)}%</span>
</div> </div>
<p class="nudge">
settings.crop_fill (default 90). At {Math.round(cropFill * 100)}% the overlay ends sit
{(cropMarginFraction(cropFill) * 100).toFixed(1)}% in from top and bottom.
The 3:4 aspect never changes. Stored PNGs are not re-cropped.
</p>
</div>
<div class="nums">
<div><span>Tape span between ticks</span><span class="tab">{tape.spanPx.toFixed(0)} px</span></div>
<div><span>Scale (once L entered)</span><span class="tab dim">span / L → px/in</span></div>
</div> </div>
</div> </div>
<div class="fields"> <div class="fields">
<div class="field"> <div class="field">
<span class="label">Length L</span> <span class="label">Length (in)</span>
<input <input
type="number" type="number"
aria-label="Length L" aria-label="Length (in)"
inputmode="decimal" inputmode="decimal"
step="0.125" step="0.125"
min="0" min="0"
@@ -335,34 +315,33 @@
{:else if state.results} {:else if state.results}
<div class="results" aria-label="Photos ready"> <div class="results" aria-label="Photos ready">
<div class="nums"> <div class="nums">
<div><span>Widths</span><span>{fmtEighths(state.results.widths.min)} – {fmtEighths(state.results.widths.max)} (avg {fmtEighths(state.results.widths.avg)})</span></div> <div><span>Widths (avg {fmtEighths(state.results.widths.avg)})</span><span>{fmtEighths(state.results.widths.min)} – {fmtEighths(state.results.widths.max)}</span></div>
<div><span>Sqft</span><span>{state.results.sqft.toFixed(2)}</span></div> <div><span>Square feet</span><span>{state.results.sqft.toFixed(2)}</span></div>
<div><span>Bdft</span><span>{state.results.bdft.toFixed(2)}</span></div> <div><span>Board feet</span><span>{state.results.bdft.toFixed(2)}</span></div>
<div><span>Scale</span><span>{state.results.scale.toFixed(2)} px/in</span></div>
<div data-testid="photos-ready-species"><span>Species</span><span>{speciesOpts.find((o) => o.id === state.speciesId)?.name ?? (state.speciesId != null ? `#${state.speciesId}` : '—')}</span></div> <div data-testid="photos-ready-species"><span>Species</span><span>{speciesOpts.find((o) => o.id === state.speciesId)?.name ?? (state.speciesId != null ? `#${state.speciesId}` : '—')}</span></div>
</div> </div>
{#if state.results.sourceBelowBlock} {#if state.results.sourceBelowBlock}
<div class="warn" role="alert" data-testid="size-floor-block"> <div class="warn" role="alert" data-testid="size-floor-block">
Source shorter side is below 300px — inventory PNG blocked. Retake a larger photo. This photo is too small. Take a closer or larger photo.
</div> </div>
{:else if state.results.sourceBelowWarn} {:else if state.results.sourceBelowWarn}
<div class="warn" data-testid="size-floor-warn"> <div class="warn" data-testid="size-floor-warn">
Source shorter side is under 800px (300–799). Photo included; consider a closer/larger shot for best quality. This photo is a bit small. A closer shot will look sharper on the listing.
</div> </div>
{/if} {/if}
<ol class="lineup" aria-label="Inventory PNG lineup"> <ol class="lineup" aria-label="Photos for the listing">
{#each lineupItems as item, i (i)} {#each lineupItems as item, i (i)}
{#if item.kind === 'png'} {#if item.kind === 'png'}
<li class="lineup-item png"> <li class="lineup-item png">
<img src={lineupUrls[i]} alt={photosReadyPngAlt(item)} /> <img src={lineupUrls[i]} alt={photosReadyPngAlt(item)} />
<span class="role"> <span class="role">
{#if item.role === 'calibration'} {#if item.role === 'calibration'}
Clean calibration Reference photo
{:else if item.role === 'overlay'} {:else if item.role === 'overlay'}
Dimension overlay Photo with measurements
{:else} {:else}
Batch photo {(item.batchIndex ?? 0) + 1} Photo {(item.batchIndex ?? 0) + 1}
{/if} {/if}
</span> </span>
{#if item.warn} {#if item.warn}
@@ -381,7 +360,7 @@
<!-- Legacy single-PNG fallback only when lineup helper yielded nothing but pngUrl exists --> <!-- Legacy single-PNG fallback only when lineup helper yielded nothing but pngUrl exists -->
{#if lineupItems.length === 0 && pngUrl && !state.results.sourceBelowBlock} {#if lineupItems.length === 0 && pngUrl && !state.results.sourceBelowBlock}
<div class="png-wrap"> <div class="png-wrap">
<img src={pngUrl} alt="Inventory PNG" /> <img src={pngUrl} alt="Listing photo" />
</div> </div>
{/if} {/if}
</div> </div>
@@ -411,8 +390,7 @@
} }
.mode-btn + .mode-btn { border-left: 1px solid #444; } .mode-btn + .mode-btn { border-left: 1px solid #444; }
.mode-btn.active { background: #00e5ff; color: #002; } .mode-btn.active { background: #00e5ff; color: #002; }
.mode-hint { margin: 0 0 10px; } .nudge { color: #aaa; font-size: 0.8rem; margin: 6px 0 0; line-height: 1.45; }
.nudge { color: #e8a; font-size: 0.74rem; margin: 6px 0 0; line-height: 1.45; }
.controls { margin-top: 16px; display: flex; flex-direction: column; gap: 14px; } .controls { margin-top: 16px; display: flex; flex-direction: column; gap: 14px; }
.ctl-label { font-size: 0.78rem; color: #aaa; margin: 0 0 6px; } .ctl-label { font-size: 0.78rem; color: #aaa; margin: 0 0 6px; }
.ctl-label .sub { font-size: 0.7rem; color: #777; } .ctl-label .sub { font-size: 0.7rem; color: #777; }
@@ -461,7 +439,7 @@
background-size: 16px 16px; background-position: 0 0,0 8px,8px -8px,-8px 0; background-size: 16px 16px; background-position: 0 0,0 8px,8px -8px,-8px 0;
background-color: #eee; border-radius: 8px; padding: 8px; background-color: #eee; border-radius: 8px; padding: 8px;
} }
.lineup-item.png img { max-width: 100%; border-radius: 4px; display: block; margin: 0 auto; } .lineup-item.png img { width: 100%; aspect-ratio: 3 / 4; object-fit: contain; border-radius: 4px; display: block; margin: 0 auto; }
.role { display: block; margin-top: 8px; font-size: 0.85rem; color: #222; font-weight: 600; } .role { display: block; margin-top: 8px; font-size: 0.85rem; color: #222; font-weight: 600; }
.lineup-item.error { .lineup-item.error {
border: 1px solid #8a3a2a; background: #2a1512; border-radius: 8px; border: 1px solid #8a3a2a; background: #2a1512; border-radius: 8px;
@@ -470,5 +448,5 @@
.err-title { color: #ffb4a2; font-weight: 700; font-size: 0.95rem; } .err-title { color: #ffb4a2; font-weight: 700; font-size: 0.95rem; }
.err-msg { color: #e8cfc8; font-size: 0.9rem; } .err-msg { color: #e8cfc8; font-size: 0.9rem; }
.png-wrap { margin-top: 16px; text-align: center; 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; background-color: #eee; border-radius: 8px; padding: 8px; } .png-wrap { margin-top: 16px; text-align: center; 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; background-color: #eee; border-radius: 8px; padding: 8px; }
.png-wrap img { max-width: 100%; border-radius: 4px; } .png-wrap img { width: 100%; aspect-ratio: 3 / 4; object-fit: contain; border-radius: 4px; }
</style> </style>
+3 -3
View File
@@ -10,12 +10,12 @@ const panelSrc = readFileSync(
'utf8', 'utf8',
); );
test('MeasurePanel exposes typed number inputs for Length L and Thickness', () => { test('MeasurePanel exposes typed number inputs for Length (in) and Thickness', () => {
const inputs = panelSrc.match(/<input\b[^>]*>/g) ?? []; const inputs = panelSrc.match(/<input\b[^>]*>/g) ?? [];
assert.ok(inputs.length >= 2, 'expected at least two <input> fields'); assert.ok(inputs.length >= 2, 'expected at least two <input> fields');
assert.ok( assert.ok(
inputs.some((el) => /type="number"/.test(el) && /aria-label="Length L"/.test(el)), inputs.some((el) => /type="number"/.test(el) && /aria-label="Length \(in\)"/.test(el)),
'Length L must be a type=number input', 'Length (in) must be a type=number input',
); );
assert.ok( assert.ok(
inputs.some((el) => /type="number"/.test(el) && /aria-label="Thickness"/.test(el)), inputs.some((el) => /type="number"/.test(el) && /aria-label="Thickness"/.test(el)),
@@ -174,11 +174,11 @@ test('test(photos): warn band keeps clean cal + attaches warn; siblings with war
assert.deepEqual(roles(lineup), ['png:calibration', 'png:overlay', 'png:batch']); assert.deepEqual(roles(lineup), ['png:calibration', 'png:overlay', 'png:batch']);
assert.equal(lineup[0].kind, 'png'); assert.equal(lineup[0].kind, 'png');
if (lineup[0].kind === 'png') { if (lineup[0].kind === 'png') {
assert.match(lineup[0].warn ?? '', /warn|800|300/i); assert.match(lineup[0].warn ?? '', /bit small|closer shot/i);
} }
assert.equal(lineup[2].kind, 'png'); assert.equal(lineup[2].kind, 'png');
if (lineup[2].kind === 'png') { if (lineup[2].kind === 'png') {
assert.match(lineup[2].warn ?? '', /warn|800/i); assert.match(lineup[2].warn ?? '', /warn|800|bit small|closer/i);
} }
}); });
@@ -196,10 +196,10 @@ test('test(photos): MeasurePanel shows Photos ready lineup + M1 numbers + batch
const panel = readFileSync(join(here, 'MeasurePanel.svelte'), 'utf8'); const panel = readFileSync(join(here, 'MeasurePanel.svelte'), 'utf8');
assert.match(panel, /Photos ready|photos.?ready/i, 'end screen title after Calculate'); assert.match(panel, /Photos ready|photos.?ready/i, 'end screen title after Calculate');
assert.match(panel, /buildPhotosReadyLineup/, 'must use pure lineup helper'); assert.match(panel, /buildPhotosReadyLineup/, 'must use pure lineup helper');
assert.match(panel, /Widths/, 'M1 widths still visible'); assert.match(panel, /Widths \(avg/, 'M1 widths still visible');
assert.match(panel, /Sqft/, 'M1 sqft still visible'); assert.match(panel, /Square feet/, 'M1 sqft still visible');
assert.match(panel, /Bdft/, 'M1 bdft still visible'); assert.match(panel, /Board feet/, 'M1 bdft still visible');
assert.match(panel, /Scale/, 'M1 scale still visible'); assert.equal(/\bScale\b/.test(panel) && /px\/in/.test(panel), false, 'Scale / px/in hidden on Photos ready');
// Dishonest proxies // Dishonest proxies
assert.ok( assert.ok(
!/lineup[\s\S]{0,200}state\.source\.blob/.test(panel), !/lineup[\s\S]{0,200}state\.source\.blob/.test(panel),
+4 -4
View File
@@ -52,7 +52,7 @@ export function buildPhotosReadyLineup(
if (inventoryPng && !sourceBelowBlock) { if (inventoryPng && !sourceBelowBlock) {
const warn = sourceBelowWarn const warn = sourceBelowWarn
? (calibrationWarn ? (calibrationWarn
?? 'PNG size warn: shorter side is below 800 px (300–799); photo included') ?? 'This photo is a bit small. A closer shot will look sharper on the listing.')
: undefined; : undefined;
items.push({ items.push({
kind: 'png', kind: 'png',
@@ -91,8 +91,8 @@ export function buildPhotosReadyLineup(
} }
export function photosReadyPngAlt(item: Extract<PhotosReadyLineupItem, { kind: 'png' }>): string { export function photosReadyPngAlt(item: Extract<PhotosReadyLineupItem, { kind: 'png' }>): string {
if (item.role === 'calibration') return 'Calibration inventory PNG'; if (item.role === 'calibration') return 'Reference photo';
if (item.role === 'overlay') return 'Calibration dimension-overlay PNG'; if (item.role === 'overlay') return 'Photo with measurements';
const n = (item.batchIndex ?? 0) + 1; const n = (item.batchIndex ?? 0) + 1;
return `Batch inventory PNG ${n}`; return `Photo ${n}`;
} }
@@ -283,7 +283,7 @@ test('#62: empty mask on one sibling → per-photo error; others still proceed',
assert.equal(apply![1], null); assert.equal(apply![1], null);
assert.ok(apply![0] && apply![0].status === 'error', 'empty mask → error'); assert.ok(apply![0] && apply![0].status === 'error', 'empty mask → error');
if (apply![0]?.status === 'error') { if (apply![0]?.status === 'error') {
assert.match(apply![0].message, /empty|mask/i, 'clear per-photo empty-mask error'); assert.match(apply![0].message, /could not find the slab|empty|mask/i, 'clear per-photo empty-mask error');
} }
assert.ok(apply![2] && apply![2].status === 'ok', 'other photos still proceed'); assert.ok(apply![2] && apply![2].status === 'ok', 'other photos still proceed');
s.dispose(); s.dispose();
@@ -322,7 +322,7 @@ test('#62: below block floor (<300) on one sibling → per-photo error; others p
assert.ok(apply![0] && apply![0].status === 'ok', 'above-floor sibling ok'); assert.ok(apply![0] && apply![0].status === 'ok', 'above-floor sibling ok');
assert.ok(apply![2] && apply![2].status === 'error', 'below-block → error'); assert.ok(apply![2] && apply![2].status === 'error', 'below-block → error');
if (apply![2]?.status === 'error') { if (apply![2]?.status === 'error') {
assert.match(apply![2].message, /floor|300|size/i, 'clear per-photo size-floor error'); assert.match(apply![2].message, /too small|floor|300|size/i, 'clear per-photo size-floor error');
} }
s.dispose(); s.dispose();
}); });
@@ -360,7 +360,7 @@ test('#62: warn band (300–799) on sibling → ok with warn; photo included', a
assert.ok(apply![0] && apply![0].status === 'ok', 'above-floor sibling ok'); assert.ok(apply![0] && apply![0].status === 'ok', 'above-floor sibling ok');
assert.ok(apply![2] && apply![2].status === 'ok', 'warn-band → still ok'); assert.ok(apply![2] && apply![2].status === 'ok', 'warn-band → still ok');
if (apply![2]?.status === 'ok') { if (apply![2]?.status === 'ok') {
assert.match(apply![2].warn ?? '', /warn|800|300/i, 'warn message attached'); assert.match(apply![2].warn ?? '', /bit small|closer|warn|800|300/i, 'warn message attached');
} }
s.dispose(); s.dispose();
}); });
+4 -4
View File
@@ -722,7 +722,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
const batch = batchPhotos.length ? batchPhotos : [source]; const batch = batchPhotos.length ? batchPhotos : [source];
const batchApply: BatchPhotoResult[] = batch.map((_, index) => (index === calIndex ? null : { const batchApply: BatchPhotoResult[] = batch.map((_, index) => (index === calIndex ? null : {
status: 'error', status: 'error',
message: 'Batch photo was not processed', message: 'This photo could not be processed',
})); }));
// Process serially so each worker owns exactly one sibling photo and a // Process serially so each worker owns exactly one sibling photo and a
@@ -737,7 +737,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
slabBounds: EMPTY_ABOX, slabBounds: EMPTY_ABOX,
}); });
if (derived.mask.slabPx === 0) { if (derived.mask.slabPx === 0) {
throw new Error('empty mask: no slab pixels detected'); throw new Error('Could not find the slab in this photo.');
} }
const siblingSource: Source = { const siblingSource: Source = {
...photo, ...photo,
@@ -752,10 +752,10 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
siblingBitmap.close(); siblingBitmap.close();
} }
if (encoded.sourceBelowBlock) { if (encoded.sourceBelowBlock) {
throw new Error('PNG size floor: shorter side is below 300 px'); throw new Error('This photo is too small. Take a closer or larger photo.');
} }
const warn = encoded.sourceBelowWarn const warn = encoded.sourceBelowWarn
? 'PNG size warn: shorter side is below 800 px (300–799); photo included' ? 'This photo is a bit small. A closer shot will look sharper on the listing.'
: undefined; : undefined;
batchApply[index] = warn batchApply[index] = warn
? { status: 'ok', inventoryPng: encoded.blob, warn } ? { status: 'ok', inventoryPng: encoded.blob, warn }
@@ -56,7 +56,7 @@
} }
.thumb { .thumb {
position: relative; position: relative;
aspect-ratio: 1; aspect-ratio: 3 / 4;
border: 2px solid #2a2a2a; border: 2px solid #2a2a2a;
border-radius: 3px; border-radius: 3px;
overflow: hidden; overflow: hidden;
@@ -70,7 +70,7 @@
background-position: 0 0,0 8px,8px -8px,-8px 0; background-position: 0 0,0 8px,8px -8px,-8px 0;
} }
.thumb.primary { border-color: #00e5ff; } .thumb.primary { border-color: #00e5ff; }
.thumb img { width: 100%; height: 100%; object-fit: cover; display: block; } .thumb img { width: 100%; height: 100%; object-fit: contain; display: block; }
.pri { .pri {
position: absolute; top: 4px; right: 4px; position: absolute; top: 4px; right: 4px;
background: #00e5ff; color: #002; background: #00e5ff; color: #002;
+70 -60
View File
@@ -16,6 +16,7 @@
type LineupItem, type LineupItem,
} from './lineup'; } from './lineup';
import { nextPrimary } from './woodPrimary'; import { nextPrimary } from './woodPrimary';
import { getShowViewPrompt, VIEW_PROMPT_EVENT } from '../../settings/viewPrompt';
import { missingCreateDescLabels } from './createDescGate'; import { missingCreateDescLabels } from './createDescGate';
import { missingCreateTitleLabels, titleBuildMissingLabels } from './createTitleGate'; import { missingCreateTitleLabels, titleBuildMissingLabels } from './createTitleGate';
import { orderWoodCatGroups } from './woodCatOrder'; import { orderWoodCatGroups } from './woodCatOrder';
@@ -138,7 +139,7 @@
let reviewError = ''; let reviewError = '';
// #89: explicit "Create" actions. `createBusy` covers the deterministic // #89: explicit "Create" actions. `createBusy` covers the deterministic
// title/short-desc generation; `canCreateTitle` is its gate (species already // title/short-desc generation; `canCreateTitle` is its gate (species already
// on the slab from Measure + ≥1 wood category). `canCreateDesc` gates Call 2 // on the slab from Measure + ≥1 wood category). `canCreateDesc` gates content generate
// (writes short + long) on title + taxonomy + content LLM — not short/desc/price. // (writes short + long) on title + taxonomy + content LLM — not short/desc/price.
let createBusy = false; let createBusy = false;
let createError = ''; let createError = '';
@@ -150,8 +151,8 @@
}); });
$: canCreateTitle = createTitleMissing.length === 0; $: canCreateTitle = createTitleMissing.length === 0;
// Create-desc gate: recompute on every field change (no blur required). // Create-desc gate: recompute on every field change (no blur required).
// Short/description are Call 2 outputs; price is publish-only. // Short/description are content generate outputs; price is publish-only.
// Needs content_llm_enabled + inference URL/key (Call 2); Call 1 stays out. // Needs content_llm_enabled + inference URL/key (content generate); Call 1 stays out.
$: createDescMissing = missingCreateDescLabels({ $: createDescMissing = missingCreateDescLabels({
contentLlmEnabled: canGenerateContent, contentLlmEnabled: canGenerateContent,
inferenceConfigured, inferenceConfigured,
@@ -193,7 +194,7 @@
let genBusy = false; let genBusy = false;
let genError = ''; let genError = '';
let canGenerateContent = false; let canGenerateContent = false;
/** Inference URL + API key both configured in Settings (Call 2 creds). */ /** Inference URL + API key both configured in Settings (content generate creds). */
let inferenceConfigured = false; let inferenceConfigured = false;
const secretConfigured = (s: { configured: boolean } | string | null | undefined): boolean => { const secretConfigured = (s: { configured: boolean } | string | null | undefined): boolean => {
if (s == null) return false; if (s == null) return false;
@@ -590,7 +591,7 @@
description = r.description; description = r.description;
if (r.short_description) shortDescription = r.short_description; if (r.short_description) shortDescription = r.short_description;
contentSource = 'llm'; contentSource = 'llm';
// Persist Call 2 short + long (commitReview does not send description — see #89). // Persist content generate short + long (commitReview does not send description — see #89).
const myToken = ++commitToken; const myToken = ++commitToken;
const fresh = await updateSlab(slabId, { const fresh = await updateSlab(slabId, {
short_description: shortDescription || null, short_description: shortDescription || null,
@@ -607,8 +608,15 @@
} }
} }
// View prompt: read-only assembled Call 2 text + photo slots (shared server assembler). // View prompt: operator-only; Settings checkbox default off.
let showViewPrompt = getShowViewPrompt();
let viewPromptOpen = false; let viewPromptOpen = false;
onMount(() => {
const sync = () => { showViewPrompt = getShowViewPrompt(); };
window.addEventListener(VIEW_PROMPT_EVENT, sync);
return () => window.removeEventListener(VIEW_PROMPT_EVENT, sync);
});
let viewPromptBusy = false; let viewPromptBusy = false;
let viewPromptError = ''; let viewPromptError = '';
let viewPrompt: Call2Prompt | null = null; let viewPrompt: Call2Prompt | null = null;
@@ -712,23 +720,23 @@
{#if loading} {#if loading}
<section class="review" aria-busy="true" data-testid="review-loading"> <section class="review" aria-busy="true" data-testid="review-loading">
<h1>Review to Ready</h1> <h1>Review slab</h1>
<p class="busy">Loading slab…</p> <p class="busy">Loading slab…</p>
</section> </section>
{:else if loadError} {:else if loadError}
<section class="review"> <section class="review">
<h1>Review to Ready</h1> <h1>Review slab</h1>
<p class="err" role="alert">{loadError}</p> <p class="err" role="alert">{loadError}</p>
<button type="button" class="ghost" data-testid="back-capture" on:click={goBack}>Back to capture</button> <button type="button" class="ghost" data-testid="back-capture" on:click={goBack}>Back to capture</button>
</section> </section>
{:else} {:else}
<section class="review" data-testid="review-screen"> <section class="review" data-testid="review-screen">
<h1>Review to Ready</h1> <h1>Review slab</h1>
<dl class="math"> <dl class="math">
<div><dt>Length</dt><dd>{fmt1(capture?.lengthIn ?? slab?.length_in ?? 0)}″</dd></div> <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>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> <div><dt>Square feet / Board feet</dt><dd>{fmt2(capture?.sqft ?? slab?.sqft ?? 0)} / {fmt2(capture?.bdft ?? slab?.bdft ?? 0)}</dd></div>
</dl> </dl>
<!-- #90 Photo strip: keep/remove + drag-reorder; leftmost kept = primary. --> <!-- #90 Photo strip: keep/remove + drag-reorder; leftmost kept = primary. -->
@@ -767,12 +775,18 @@
<span class="ph-seq" aria-hidden="true">{i + 1}</span> <span class="ph-seq" aria-hidden="true">{i + 1}</span>
<!-- Keep checkbox: always checked for required photos; a disabled <!-- Keep checkbox: always checked for required photos; a disabled
glyph (no check) when a batch photo is removed. --> glyph (no check) when a batch photo is removed. -->
<span class="ph-keep" class:required={p.required} aria-hidden="true"></span> <span class="ph-keep" class:required={p.required} aria-hidden="true">
{#if p.required}
<svg class="lock" viewBox="0 0 24 24" width="12" height="12" focusable="false">
<path fill="currentColor" d="M17 8h-1V6a4 4 0 0 0-8 0v2H7a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V10a2 2 0 0 0-2-2zm-7-2a2 2 0 0 1 4 0v2h-4V6zm7 14H7V10h10v10z"/>
</svg>
{/if}
</span>
</div> </div>
{/each} {/each}
</div> </div>
{#if lineupMissing.length} {#if lineupMissing.length}
<p class="ph-nudge" data-testid="lineup-nudge" role="alert"> <p class="ph-nudge must" data-testid="lineup-nudge" role="alert">
Keep at least {lineupMissing.map(roleLabel).join(' and ')} — required to publish. Keep at least {lineupMissing.map(roleLabel).join(' and ')} — required to publish.
</p> </p>
{/if} {/if}
@@ -780,7 +794,7 @@
</div> </div>
{/if} {/if}
<!-- Call 1 OUT (overnight-whole-app): Suggest-with-AI removed. Call 2 generateContent stays. --> <!-- Call 1 OUT (overnight-whole-app): Suggest-with-AI removed. content generate generateContent stays. -->
<!-- Species: chosen on Measure; no Review picker (value stays on the slab from createSlab). --> <!-- Species: chosen on Measure; no Review picker (value stays on the slab from createSlab). -->
<!-- Wood categories (multi + inline primary radio set, REVIEW-15 / #90 wireframe) --> <!-- Wood categories (multi + inline primary radio set, REVIEW-15 / #90 wireframe) -->
@@ -798,7 +812,7 @@
/> />
{#if primaryOpts.length} {#if primaryOpts.length}
<div class="primary-set" data-testid="primary-wood-set" role="radiogroup" aria-label="Primary wood category"> <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> <div class="ps-lbl">Primary wood category <span class="ps-req">used for title</span></div>
{#each primaryOpts as o (o.id)} {#each primaryOpts as o (o.id)}
<button <button
type="button" type="button"
@@ -814,8 +828,7 @@
</button> </button>
{/each} {/each}
<p class="ps-note"> <p class="ps-note">
Primary selects the title &amp; short-description pattern (METADATA-85). Primary is used for the title. Other selected categories can appear in the description.
Secondary categories feed the description. Default = first selected.
</p> </p>
</div> </div>
{/if} {/if}
@@ -898,11 +911,11 @@
<input id="short-desc" class="inp" data-testid="short-desc" bind:value={shortDescription} on:input={scheduleFieldCommit} /> <input id="short-desc" class="inp" data-testid="short-desc" bind:value={shortDescription} on:input={scheduleFieldCommit} />
<button type="button" class="btn-sec" data-testid="create-short-desc" <button type="button" class="btn-sec" data-testid="create-short-desc"
disabled={!canCreateTitle || createBusy} on:click={createTitle}> disabled={!canCreateTitle || createBusy} on:click={createTitle}>
{createBusy ? 'Building…' : 'Create short desc'} {createBusy ? 'Building…' : 'Create short description'}
</button> </button>
</div> </div>
{#if !canCreateTitle} {#if !canCreateTitle}
<p class="nudge" data-testid="create-title-missing"> <p class="nudge must" data-testid="create-title-missing">
Create title needs: {createTitleMissing.join(', ')} Create title needs: {createTitleMissing.join(', ')}
</p> </p>
{/if} {/if}
@@ -917,29 +930,31 @@
data-testid="create-desc" data-testid="create-desc"
on:click={generateContent} on:click={generateContent}
disabled={!canCreateDesc || genBusy} disabled={!canCreateDesc || genBusy}
title={canCreateDesc ? 'Generate short + long description (Call 2)' : `Missing: ${createDescMissing.join(', ')}`} title={canCreateDesc ? 'Generate short and full description' : `Missing: ${createDescMissing.join(', ')}`}
> >
{genBusy ? 'Writing…' : 'Create desc'} {genBusy ? 'Writing…' : 'Create description'}
</button>
<button
type="button"
class="btn-sec"
data-testid="view-prompt"
on:click={() => { if (viewPromptOpen) viewPromptOpen = false; else void loadViewPrompt(); }}
disabled={viewPromptBusy}
>
{viewPromptBusy ? 'Loading…' : (viewPromptOpen ? 'Hide prompt' : 'View prompt')}
</button> </button>
{#if showViewPrompt}
<button
type="button"
class="btn-sec"
data-testid="view-prompt"
on:click={() => { if (viewPromptOpen) viewPromptOpen = false; else void loadViewPrompt(); }}
disabled={viewPromptBusy}
>
{viewPromptBusy ? 'Loading…' : (viewPromptOpen ? 'Hide prompt' : 'View prompt')}
</button>
{/if}
</div> </div>
{#if !canCreateDesc} {#if !canCreateDesc}
<p class="nudge" data-testid="create-desc-missing"> <p class="nudge must" data-testid="create-desc-missing">
Create desc needs: {createDescMissing.join(', ')} Create description needs: {createDescMissing.join(', ')}
</p> </p>
{/if} {/if}
{#if viewPromptOpen} {#if viewPromptOpen}
<div class="prompt-panel" data-testid="view-prompt-panel"> <div class="prompt-panel" data-testid="view-prompt-panel">
<div class="prompt-head"> <div class="prompt-head">
<span class="prompt-title">Call 2 prompt (read-only)</span> <span class="prompt-title">Prompt (read-only)</span>
<button type="button" class="mini" data-testid="copy-prompt" on:click={copyViewPrompt} disabled={!viewPrompt?.text}> <button type="button" class="mini" data-testid="copy-prompt" on:click={copyViewPrompt} disabled={!viewPrompt?.text}>
{viewPromptCopied ? 'Copied' : 'Copy text'} {viewPromptCopied ? 'Copied' : 'Copy text'}
</button> </button>
@@ -949,15 +964,13 @@
{#if viewPrompt} {#if viewPrompt}
<pre class="prompt-text" data-testid="prompt-text">{viewPrompt.text}</pre> <pre class="prompt-text" data-testid="prompt-text">{viewPrompt.text}</pre>
<div class="prompt-photos" data-testid="prompt-photos"> <div class="prompt-photos" data-testid="prompt-photos">
<div class="prompt-photos-lbl">Photo slots (no base64) — up to 8 processed PNGs</div> <div class="prompt-photos-lbl">Photos included</div>
{#if viewPrompt.photos.length === 0} {#if viewPrompt.photos.length === 0}
<p class="nudge">No processed photos on this slab.</p> <p class="nudge">No photos on this slab yet.</p>
{:else} {:else}
<ul> <ul>
{#each viewPrompt.photos as ph (ph.id)} {#each viewPrompt.photos as ph, i (ph.id)}
<li data-testid="prompt-photo-slot"> <li data-testid="prompt-photo-slot">Photo {i + 1}</li>
seq={ph.seq} · id={ph.id} · {ph.bytes} bytes · long-edge {ph.long_edge_px ?? '?'}px
</li>
{/each} {/each}
</ul> </ul>
{/if} {/if}
@@ -977,10 +990,10 @@
<input id="price" class="inp" data-testid="price" type="number" step="0.01" bind:value={price} on:input={scheduleFieldCommit} on:change={commitReview} /> <input id="price" class="inp" data-testid="price" type="number" step="0.01" bind:value={price} on:input={scheduleFieldCommit} on:change={commitReview} />
</div> </div>
<div class="field"> <div class="field">
<label class="lbl" for="sku">SKU <span class="sug">last field, 3–24 chars</span></label> <label class="lbl" for="sku">SKU <span class="sug">3–24 letters, numbers, or hyphens</span></label>
<input id="sku" class="inp" data-testid="sku" placeholder="SLAB-…" bind:value={sku} on:input={scheduleFieldCommit} on:blur={commitReview} /> <input id="sku" class="inp" data-testid="sku" placeholder="SLAB-…" bind:value={sku} on:input={scheduleFieldCommit} on:blur={commitReview} />
{#if sku && !/^[A-Za-z0-9-]{3,24}$/.test(sku)} {#if sku && !/^[A-Za-z0-9-]{3,24}$/.test(sku)}
<p class="nudge">SKU must be 3–24 alphanumerics or hyphens.</p> <p class="nudge must">SKU must be 3–24 letters, numbers, or hyphens.</p>
{/if} {/if}
</div> </div>
@@ -997,7 +1010,7 @@
].length} field${[...review.missing, ...lineupMissing.map(roleLabel)].length === 1 ? '' : 's'} remaining`} ].length} field${[...review.missing, ...lineupMissing.map(roleLabel)].length === 1 ? '' : 's'} remaining`}
</div> </div>
{#if !(ready() && lineupValid)} {#if !(ready() && lineupValid)}
<p class="missing" data-testid="missing-list">Missing: {[...review.missing, ...lineupMissing.map(roleLabel)].join(', ')}</p> <p class="missing must" data-testid="missing-list">Missing: {[...review.missing, ...lineupMissing.map(roleLabel)].join(', ')}</p>
{/if} {/if}
{/if} {/if}
{#if reviewError}<p class="nudge" role="alert">{reviewError}</p>{/if} {#if reviewError}<p class="nudge" role="alert">{reviewError}</p>{/if}
@@ -1031,7 +1044,8 @@
.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; } .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; } .ta { resize: vertical; }
.field { margin-bottom: 12px; } .field { margin-bottom: 12px; }
.nudge { color: #e8a; font-size: .74rem; margin: 4px 0 0; } .nudge { color: #aaa; font-size: .74rem; margin: 4px 0 0; }
.nudge.must, .must { color: #ff6b6b; }
.sug { color: #aaa; font-size: .74rem; font-weight: 400; margin-left: 6px; } .sug { color: #aaa; font-size: .74rem; font-weight: 400; margin-left: 6px; }
.gen { margin-top: 8px; display: flex; flex-wrap: wrap; gap: 8px; } .gen { margin-top: 8px; display: flex; flex-wrap: wrap; gap: 8px; }
/* Unified secondary actions: Create title/short/desc, View prompt — same size & label weight. */ /* Unified secondary actions: Create title/short/desc, View prompt — same size & label weight. */
@@ -1066,9 +1080,9 @@
.ps-opt.on .ps-radio::after { content: ''; position: absolute; inset: 2px; border-radius: 50%; background: #00e5ff; } .ps-opt.on .ps-radio::after { content: ''; position: absolute; inset: 2px; border-radius: 50%; background: #00e5ff; }
.ps-name { flex: 1; } .ps-name { flex: 1; }
.ps-note { font-size: .7rem; color: #777; margin: 6px 0 0; line-height: 1.4; } .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 { margin-top: 14px; font-weight: 700; padding: 10px 12px; border-radius: 3px; background: #2a1512; color: #ff6b6b; font-size: .9rem; }
.status.ok { background: #122a12; color: #7fd37f; } .status.ok { background: #122a12; color: #7fd37f; }
.missing { color: #999; font-size: .76rem; margin: 6px 0 0; } .missing { color: #ff6b6b; font-size: .76rem; margin: 6px 0 0; }
.row { display: flex; gap: 10px; margin-top: 16px; } .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; cursor: pointer; } .primary { flex: 1; padding: 14px; font-size: 1rem; border-radius: 3px; border: 0; background: #00e5ff; color: #002; font-weight: 700; cursor: pointer; }
.primary:disabled { background: #333; color: #777; cursor: not-allowed; } .primary:disabled { background: #333; color: #777; cursor: not-allowed; }
@@ -1080,7 +1094,7 @@
.ph-hint { color: #8a8a8a; font-size: .72rem; margin: 0 0 8px; } .ph-hint { color: #8a8a8a; font-size: .72rem; margin: 0 0 8px; }
.ph-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 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); } } @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; .pcard { position: relative; aspect-ratio: 3 / 4; border: 2px solid #2a2a2a; border-radius: 3px; overflow: hidden; cursor: grab;
/* Gray checkerboard, matching the Capture lineup (MeasurePanel .lineup-item.png) /* Gray checkerboard, matching the Capture lineup (MeasurePanel .lineup-item.png)
so transparent inventory PNGs read the same on both screens (bug2). */ so transparent inventory PNGs read the same on both screens (bug2). */
background-color: #eee; background-color: #eee;
@@ -1091,28 +1105,24 @@
.pcard.kept { border-color: #444; } .pcard.kept { border-color: #444; }
.pcard.primary { border-color: #00e5ff; } .pcard.primary { border-color: #00e5ff; }
.pcard:not(.kept) { opacity: .4; } .pcard:not(.kept) { opacity: .4; }
.ph-thumb { width: 100%; height: 100%; object-fit: cover; display: block; pointer-events: none; } .ph-thumb { width: 100%; height: 100%; object-fit: contain; 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-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-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-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; } .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 { 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); } .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 /* Mandatory photos (reference + measurements): keep is locked; SVG lock
checked AND disabled. A bold padlock glyph in the box signals "required — signals required / cannot remove. */
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 { border-color: #00e5ff55; }
.pcard.required .ph-keep { background: #00e5ff; border-color: #00e5ff; } .pcard.required .ph-keep {
/* Padlock: shackle (arc) + body, drawn large enough to read on a 16px box. */ background: #00e5ff; border-color: #00e5ff; color: #002;
.pcard.required .ph-keep::after { display: flex; align-items: center; justify-content: center;
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 .ph-keep::after { content: none; }
.pcard.required .ph-keep::before { content: none; }
.pcard.required.kept .ph-keep::after { content: none; }
.ph-keep .lock { display: block; }
.pcard.required { cursor: default; } .pcard.required { cursor: default; }
.ph-nudge { color: #e8a; font-size: .74rem; margin: 8px 0 0; } .ph-nudge { color: #ff6b6b; font-size: .74rem; margin: 8px 0 0; }
</style> </style>
@@ -66,7 +66,7 @@ test('Create desc gate: lists each missing field; no short/description/price key
assert.equal(labels.includes('price'), false); assert.equal(labels.includes('price'), false);
}); });
test('Create desc gate: inference URL+key required even when content LLM on', () => { test('Create desc gate: inference URL+key required even when description writing on', () => {
assert.equal(canCreateDesc({ ...full, inferenceConfigured: false }), false); assert.equal(canCreateDesc({ ...full, inferenceConfigured: false }), false);
assert.deepEqual(missingCreateDesc({ ...full, inferenceConfigured: false }), [ assert.deepEqual(missingCreateDesc({ ...full, inferenceConfigured: false }), [
'inference_creds', 'inference_creds',
+2 -2
View File
@@ -23,8 +23,8 @@ export type CreateDescInput = {
/** Human labels shown under Create desc when the gate fails. */ /** Human labels shown under Create desc when the gate fails. */
export const CREATE_DESC_LABELS: Record<string, string> = { export const CREATE_DESC_LABELS: Record<string, string> = {
content_llm: 'content LLM on', content_llm: 'description writing on',
inference_creds: 'inference URL + API key', inference_creds: 'description connection',
title: 'title', title: 'title',
species: 'species', species: 'species',
wood_categories: '≥1 wood category', wood_categories: '≥1 wood category',
+2 -2
View File
@@ -67,8 +67,8 @@ test('lineup: missingMinimums only counts KEPT photos', () => {
}); });
test('lineup: roleLabel is owner-facing', () => { test('lineup: roleLabel is owner-facing', () => {
assert.match(roleLabel('calibration'), /clean/); assert.match(roleLabel('calibration'), /reference photo/i);
assert.match(roleLabel('overlay'), /overlay/); assert.match(roleLabel('overlay'), /measurements/i);
}); });
test('lineup: resolvePhotoUrl turns an in-memory blob into a blob: URL (bug #1)', () => { test('lineup: resolvePhotoUrl turns an in-memory blob into a blob: URL (bug #1)', () => {
+3 -3
View File
@@ -57,9 +57,9 @@ export function lineupValid(kept: LineupItem[]): boolean {
/** A short owner-facing label for a role (used in the minimum nudge). */ /** A short owner-facing label for a role (used in the minimum nudge). */
export function roleLabel(role: PhotoRole): string { export function roleLabel(role: PhotoRole): string {
if (role === 'calibration') return 'a clean photo'; if (role === 'calibration') return 'the reference photo';
if (role === 'overlay') return 'its dimension-overlay copy'; if (role === 'overlay') return 'the photo with measurements';
return 'a batch photo'; return 'a photo';
} }
/** /**
@@ -58,3 +58,19 @@ test('Review UI: live field commit + unified secondary buttons + moisture defaul
assert.equal(reviewSrc.includes('create-btn'), false); assert.equal(reviewSrc.includes('create-btn'), false);
}); });
test('Review UI: Jeff-facing copy + 3:4 thumbs + View prompt gated', () => {
assert.match(reviewSrc, /Review slab/);
assert.match(reviewSrc, /Square feet \/ Board feet/);
assert.match(reviewSrc, /Create short description/);
assert.match(reviewSrc, /Create description/);
assert.match(reviewSrc, /Generate short and full description/);
assert.match(reviewSrc, /3–24 letters, numbers, or hyphens/);
assert.match(reviewSrc, /aspect-ratio: 3 \/ 4/);
assert.match(reviewSrc, /object-fit: contain/);
assert.match(reviewSrc, /getShowViewPrompt/);
assert.match(reviewSrc, /showViewPrompt/);
assert.equal(reviewSrc.includes('Call 2'), false);
assert.equal(reviewSrc.includes('Review to Ready'), false);
assert.equal(/aspect-ratio:\s*1\b/.test(reviewSrc), false);
});
@@ -9,6 +9,7 @@
withWooHeaders, withWooHeaders,
} from '../../api/wooDeviceCreds'; } from '../../api/wooDeviceCreds';
import { reopenWooGate, isWooAuthFailure } from '../../api/wooGate'; import { reopenWooGate, isWooAuthFailure } from '../../api/wooGate';
import { getShowViewPrompt, setShowViewPrompt } from '../../settings/viewPrompt';
interface SecretState { configured: boolean } interface SecretState { configured: boolean }
// Masked mode: object {configured}. Unmasked UAT mode (SLAB_UAT_UNMASK_SECRETS): plain string. // Masked mode: object {configured}. Unmasked UAT mode (SLAB_UAT_UNMASK_SECRETS): plain string.
@@ -52,6 +53,7 @@
// This device only — localStorage; never sent via Settings PUT. // This device only — localStorage; never sent via Settings PUT.
let deviceWoo = { username: '', appPassword: '' }; let deviceWoo = { username: '', appPassword: '' };
let deviceMsg = ''; let deviceMsg = '';
let showViewPrompt = getShowViewPrompt();
let rateRows: RateRow[] = []; let rateRows: RateRow[] = [];
let ratesLoading = false; let ratesLoading = false;
@@ -409,6 +411,19 @@
<label><input type="checkbox" bind:checked={form.content_llm_enabled} /> Content LLM Enabled (default false)</label> <label><input type="checkbox" bind:checked={form.content_llm_enabled} /> Content LLM Enabled (default false)</label>
</div> </div>
<div class="section" data-testid="ui-options">
<h3>Review UI</h3>
<label>
<input
type="checkbox"
data-testid="show-view-prompt"
checked={showViewPrompt}
on:change={(e) => (showViewPrompt = setShowViewPrompt(e.currentTarget.checked))}
/>
Show “View prompt” on Review (default off)
</label>
</div>
<div> <div>
<label>Brand Voice</label> <label>Brand Voice</label>
<input bind:value={form.brand_voice} /> <input bind:value={form.brand_voice} />
+25 -17
View File
@@ -5,6 +5,7 @@ import {
pickBottomTowardTop, pickBottomTowardTop,
axisRightUnit, axisRightUnit,
buildOverlayPlan, buildOverlayPlan,
OVERLAY_FONT_PX,
inventorySourceMap, inventorySourceMap,
BODY_WIDTH_FRAC, BODY_WIDTH_FRAC,
type OverlayPlan, type OverlayPlan,
@@ -139,7 +140,8 @@ test('buildOverlayPlan: length line on axis-right outside slab; widths at 12in s
// Color contract for draw consumers // Color contract for draw consumers
assert.equal(plan.stroke, '#000000'); assert.equal(plan.stroke, '#000000');
assert.ok(plan.fontPx >= 12, `fontPx ${plan.fontPx} large enough to read`); assert.equal(plan.fontPx, OVERLAY_FONT_PX);
assert.ok(plan.fontPx >= 28, `fontPx ${plan.fontPx} large enough to read`);
}); });
test('buildOverlayPlan: stations measured from EXIF bottom, not tip-inclusive M1 every-6 (#63)', () => { test('buildOverlayPlan: stations measured from EXIF bottom, not tip-inclusive M1 every-6 (#63)', () => {
@@ -253,8 +255,8 @@ test('buildOverlayPlan: width labels sit axis-left outside the slab (#67)', () =
for (const w of plan.widths) { for (const w of plan.widths) {
assert.ok(w.labelAt, 'width mark must expose labelAt'); assert.ok(w.labelAt, 'width mark must expose labelAt');
// left/right tips still on wood edges // left/right tips still on wood edges
assert.ok(Math.abs(w.left[1] - 149) <= 2 || Math.abs(w.left[1] - 50) <= 2); assert.ok(Math.abs(w.left[1] - 149) <= 5 || Math.abs(w.left[1] - 50) <= 5);
assert.ok(Math.abs(w.right[1] - 149) <= 2 || Math.abs(w.right[1] - 50) <= 2); assert.ok(Math.abs(w.right[1] - 149) <= 5 || Math.abs(w.right[1] - 50) <= 5);
// labelAt on axis-left = opposite of axis-right from center // labelAt on axis-left = opposite of axis-right from center
// For this geometry, axis-right is toward smaller y; axis-left toward larger y // For this geometry, axis-right is toward smaller y; axis-left toward larger y
// (outside past the bottom edge y≈149). // (outside past the bottom edge y≈149).
@@ -273,29 +275,36 @@ test('buildOverlayPlan: width labels sit axis-left outside the slab (#67)', () =
assert.ok(lenY < 50 - 4, `length dim stays axis-right outside top edge, got y=${lenY}`); assert.ok(lenY < 50 - 4, `length dim stays axis-right outside top edge, got y=${lenY}`);
}); });
test('buildOverlayPlan: font smaller than prior 28/scale*1.2 and 22/scale*1.0 (#67,#71)', () => { test('buildOverlayPlan: fixed readable measurement font for every product', () => {
const W = 480, H = 200; const W = 480, H = 200;
const bitset = new Uint8Array(W * H); const bitset = new Uint8Array(W * H);
for (let y = 50; y < 150; y++) for (let x = 0; x < W; x++) bitset[y * W + x] = 1; for (let y = 50; y < 150; y++) for (let x = 0; x < W; x++) bitset[y * W + x] = 1;
const box = orientedRect(minAreaRect(bitset, W, H)!, 0); const box = orientedRect(minAreaRect(bitset, W, H)!, 0);
const scale = 10; const planA = buildOverlayPlan({
const plan = buildOverlayPlan({
bitset, bitset,
widthPx: W, widthPx: W,
heightPx: H, heightPx: H,
bbox: box, bbox: box,
scale, scale: 10,
lengthIn: 48, lengthIn: 48,
lengthLabel: '48"', lengthLabel: '48"',
mapToInventory: (x, y) => [x, y], mapToInventory: (x, y) => [x, y],
}); });
const oldFont67 = Math.max(28, Math.round(scale * 1.2)); const planB = buildOverlayPlan({
const oldFont71 = Math.max(22, Math.round(scale * 1.0)); bitset,
assert.ok(plan.fontPx < oldFont67, `fontPx ${plan.fontPx} must be < prior ${oldFont67}`); widthPx: W,
assert.ok(plan.fontPx < oldFont71, `fontPx ${plan.fontPx} must be < prior ${oldFont71}`); heightPx: H,
assert.ok(plan.fontPx >= 12, `fontPx ${plan.fontPx} still legible`); bbox: box,
// Concrete contract (#71): capped by scale when no inventory size scale: 80,
assert.equal(plan.fontPx, Math.max(12, Math.min(16, Math.round(scale * 0.18)))); lengthIn: 48,
lengthLabel: '48"',
mapToInventory: (x, y) => [x, y],
inventoryW: 1200,
inventoryH: 1600,
});
assert.equal(planA.fontPx, OVERLAY_FONT_PX);
assert.equal(planB.fontPx, OVERLAY_FONT_PX);
assert.ok(planA.fontPx >= 28, `fontPx ${planA.fontPx} must be large enough to read`);
}); });
@@ -908,9 +917,8 @@ test('buildOverlayPlan: labels inside inventory PNG; length off wood; smaller fo
}); });
// Font: one consistent smaller size than prior max(22, scale*1.0) // Font: one consistent smaller size than prior max(22, scale*1.0)
const priorFont = Math.max(22, Math.round(scale * 1.0)); assert.equal(plan.fontPx, OVERLAY_FONT_PX);
assert.ok(plan.fontPx < priorFont, `fontPx ${plan.fontPx} must be < prior ${priorFont}`); assert.ok(plan.fontPx >= 28, `fontPx ${plan.fontPx} still readable`);
assert.ok(plan.fontPx >= 12, `fontPx ${plan.fontPx} still readable`);
// Wood AABB in inventory // Wood AABB in inventory
let mMinX = Infinity, mMaxX = -Infinity, mMinY = Infinity, mMaxY = -Infinity; let mMinX = Infinity, mMaxX = -Infinity, mMinY = Infinity, mMaxY = -Infinity;
+6 -8
View File
@@ -262,6 +262,9 @@ function probeExtent(
return { minP, maxP }; return { minP, maxP };
} }
/** Jeff-facing dimension labels: fixed size in inventory PNG pixels. */
export const OVERLAY_FONT_PX = 36;
function fmtInches(inches: number): string { function fmtInches(inches: number): string {
const whole = Math.floor(inches + 1e-9); const whole = Math.floor(inches + 1e-9);
const rem = Math.round((inches - whole) * 8); const rem = Math.round((inches - whole) * 8);
@@ -286,14 +289,9 @@ export function buildOverlayPlan(input: OverlayPlanInput): OverlayPlan {
const { bottom, top, ux, uy } = oriented; const { bottom, top, ux, uy } = oriented;
const [rx, ry] = axisRightUnit(ux, uy); const [rx, ry] = axisRightUnit(ux, uy);
// Consistent smaller readable font (#71). Cap by inventory size so high // Fixed readable measurement font in inventory image pixels (Jeff-facing).
// source scale (≈80 px/in) does not dominate the PNG. // Same size for every product — do not scale with tape px/in or canvas size.
const fontPx = (() => { const fontPx = OVERLAY_FONT_PX;
if (inventoryW && inventoryH && inventoryW > 0 && inventoryH > 0) {
return Math.max(12, Math.min(16, Math.round(Math.min(inventoryW, inventoryH) * 0.016)));
}
return Math.max(12, Math.min(16, Math.round(scale * 0.18)));
})();
// Inset for stroke + arrowhead so visual tips sit on/inside edges (#71). // Inset for stroke + arrowhead so visual tips sit on/inside edges (#71).
const lineW = Math.max(2, Math.round(fontPx / 10)); const lineW = Math.max(2, Math.round(fontPx / 10));
const arrowHead = Math.max(7, fontPx * 0.32); const arrowHead = Math.max(7, fontPx * 0.32);
+29
View File
@@ -0,0 +1,29 @@
/**
* Device-local toggle: show Review "View prompt" (default off).
* Jeff-facing UI leaves this hidden unless an operator enables it in Settings.
*/
const KEY = 'slabuploader.ui.show_view_prompt';
export const VIEW_PROMPT_EVENT = 'slabuploader:show-view-prompt';
export function getShowViewPrompt(): boolean {
try {
return localStorage.getItem(KEY) === '1';
} catch {
return false;
}
}
export function setShowViewPrompt(on: boolean): boolean {
try {
if (on) localStorage.setItem(KEY, '1');
else localStorage.removeItem(KEY);
} catch {
/* ignore quota / private mode */
}
try {
window.dispatchEvent(new CustomEvent(VIEW_PROMPT_EVENT, { detail: on }));
} catch {
/* ignore non-browser */
}
return on;
}
+10 -10
View File
@@ -22,7 +22,7 @@ const ensureFreshBatch = async (page: Page) => {
await page.goto('/'); await page.goto('/');
// resumeProbe is async onMount — wait for either Resume or Batch. // resumeProbe is async onMount — wait for either Resume or Batch.
const resumeHeading = page.getByRole('heading', { name: /resume/i }); const resumeHeading = page.getByRole('heading', { name: /resume/i });
const batchHeading = page.getByRole('heading', { name: 'Batch photos' }); const batchHeading = page.getByRole('heading', { name: 'Add slab photos' });
await expect(resumeHeading.or(batchHeading)).toBeVisible({ timeout: 15_000 }); await expect(resumeHeading.or(batchHeading)).toBeVisible({ timeout: 15_000 });
if (await resumeHeading.isVisible().catch(() => false)) { if (await resumeHeading.isVisible().catch(() => false)) {
await page.getByRole('button', { name: /start fresh/i }).click(); await page.getByRole('button', { name: /start fresh/i }).click();
@@ -74,8 +74,8 @@ test.describe('M2 #65 Photos-ready e2e (set1-48x2)', () => {
// Gallery multi-input (not the capture=environment camera input). // Gallery multi-input (not the capture=environment camera input).
const gallery = page.locator('input[type="file"][multiple]'); const gallery = page.locator('input[type="file"][multiple]');
await gallery.setInputFiles([FACE_A, FACE_B]); await gallery.setInputFiles([FACE_A, FACE_B]);
await expect(page.getByAltText('Batch photo 1')).toBeVisible({ timeout: 30_000 }); await expect(page.getByAltText('Photo 1')).toBeVisible({ timeout: 30_000 });
await expect(page.getByAltText('Batch photo 2')).toBeVisible(); await expect(page.getByAltText('Photo 2')).toBeVisible();
await shot(page, '01-batch.png'); await shot(page, '01-batch.png');
await page.getByRole('button', { name: 'Continue' }).click(); await page.getByRole('button', { name: 'Continue' }).click();
@@ -89,10 +89,10 @@ test.describe('M2 #65 Photos-ready e2e (set1-48x2)', () => {
await expect(page.getByRole('heading', { name: 'Measure' })).toBeVisible(); await expect(page.getByRole('heading', { name: 'Measure' })).toBeVisible();
await shot(page, '04-tape-measure.png'); await shot(page, '04-tape-measure.png');
await page.getByLabel('Length L').fill('48'); await page.getByLabel('Length (in)').fill('48');
await page.getByLabel('Thickness').fill('2'); await page.getByLabel('Thickness').fill('2');
// blur/change commits eighths into session before Calculate. // blur/change commits eighths into session before Calculate.
await page.getByLabel('Length L').blur(); await page.getByLabel('Length (in)').blur();
await page.getByLabel('Thickness').blur(); await page.getByLabel('Thickness').blur();
await shot(page, '05-measure.png'); await shot(page, '05-measure.png');
@@ -105,10 +105,10 @@ test.describe('M2 #65 Photos-ready e2e (set1-48x2)', () => {
timeout: 120_000, timeout: 120_000,
}); });
await expect(page.getByLabel('Photos ready')).toBeVisible(); await expect(page.getByLabel('Photos ready')).toBeVisible();
await expect(page.getByText('Clean calibration')).toBeVisible(); await expect(page.getByText('Reference photo')).toBeVisible();
await expect(page.getByText('Dimension overlay')).toBeVisible(); await expect(page.getByText('Photo with measurements')).toBeVisible();
// Sibling face-b should appear as Batch photo 2 (capture order). // Sibling face-b should appear as Photo 2 (capture order).
await expect(page.getByText(/Batch photo 2|Photo 2 failed/)).toBeVisible(); await expect(page.getByText(/Photo 2|Photo 2 failed/)).toBeVisible();
await shot(page, '06-photos-ready.png'); await shot(page, '06-photos-ready.png');
@@ -125,7 +125,7 @@ test.describe('M2 #65 Photos-ready e2e (set1-48x2)', () => {
const st = fs.existsSync(p) ? fs.statSync(p) : null; const st = fs.existsSync(p) ? fs.statSync(p) : null;
return ` - ${s} (${st ? `${st.size} bytes` : 'MISSING'})`; return ` - ${s} (${st ? `${st.size} bytes` : 'MISSING'})`;
}), }),
`assertions: Photos ready heading; Clean calibration; Dimension overlay; batch sibling slot`, `assertions: Photos ready heading; Reference photo; Photo with measurements; batch sibling slot`,
]; ];
fs.writeFileSync(logPath, lines.join('\n') + '\n', 'utf8'); fs.writeFileSync(logPath, lines.join('\n') + '\n', 'utf8');
}); });
+1 -1
View File
@@ -109,7 +109,7 @@ test.describe('Settings contrast (Gitea bug #75 black-on-black)', () => {
test('Regression: SlabList.svelte / Review.svelte / Publish.svelte / GenerateText.svelte remain unchanged (still pass their existing contrast)', async ({ page }) => { test('Regression: SlabList.svelte / Review.svelte / Publish.svelte / GenerateText.svelte remain unchanged (still pass their existing contrast)', async ({ page }) => {
const regressionScreens = [ const regressionScreens = [
{ name: 'Slabs (dev)', heading: /slabs/i }, { name: 'Slabs (dev)', heading: /slabs/i },
{ name: 'Review (dev)', heading: 'Review to Ready' }, { name: 'Review (dev)', heading: 'Review slab' },
{ name: 'Publish (dev)', heading: 'Publish to WooCommerce' }, { name: 'Publish (dev)', heading: 'Publish to WooCommerce' },
{ name: 'Generate (dev)', heading: 'Generate text' }, { name: 'Generate (dev)', heading: 'Generate text' },
]; ];