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:
@@ -140,7 +140,7 @@
|
||||
enterReview(id, capture);
|
||||
} catch (e) {
|
||||
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();
|
||||
} finally {
|
||||
saving = false;
|
||||
@@ -200,7 +200,7 @@
|
||||
</section>
|
||||
{:else if curFlow.phase === 'saving'}
|
||||
<section class="panel" aria-busy="true">
|
||||
<h1>Saving slab…</h1>
|
||||
<h1>Saving…</h1>
|
||||
</section>
|
||||
{:else}
|
||||
<!-- capture column (single slab, CAPTURE-25/30) -->
|
||||
@@ -244,7 +244,7 @@
|
||||
/>
|
||||
{/if}
|
||||
{#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 saveError}
|
||||
<div class="save-err" role="alert" data-testid="save-error">{saveError}</div>
|
||||
|
||||
@@ -31,20 +31,20 @@
|
||||
</script>
|
||||
|
||||
<section class="panel">
|
||||
<h1>Batch photos</h1>
|
||||
<h1>Add slab photos</h1>
|
||||
<ul class="setup">
|
||||
<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>Top-down reference shot: lens parallel to the face.</li>
|
||||
<li>Slab length along the long side of the picture.</li>
|
||||
<li>Take one photo straight above the slab.</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>
|
||||
</ul>
|
||||
|
||||
{#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])}
|
||||
<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>
|
||||
</li>
|
||||
{/each}
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
import { clampTiltDeg, defaultTicks } from './tapeGeometry';
|
||||
import PhotoStage from './PhotoStage.svelte';
|
||||
import { getCropFill, setCropFill } from '../settings/cropFill';
|
||||
import { cropMarginFraction } from '../png/crop';
|
||||
import { getTaxonomy } from '../api/slabs';
|
||||
import { speciesLeavesFromTaxonomy } from '../create/settings/speciesLeaves';
|
||||
import {
|
||||
@@ -163,8 +162,7 @@
|
||||
<h1>{photosReady ? 'Photos ready' : 'Measure'}</h1>
|
||||
|
||||
{#if !photosReady}
|
||||
<p class="hint">Orient the slab on the fixed tape 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>
|
||||
<p class="hint">Line up the slab with the black line, then enter length, thickness, and species.</p>
|
||||
|
||||
<div class="mode-toggle" role="group" aria-label="Stage gesture mode">
|
||||
<button
|
||||
@@ -184,13 +182,6 @@
|
||||
on:click={() => (gestureMode = 'rotate')}
|
||||
>Rotate</button>
|
||||
</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.
|
||||
PhotoStage paints opaque checkerboard on non-slab (iOS-safe under the
|
||||
@@ -209,7 +200,7 @@
|
||||
|
||||
<div class="controls">
|
||||
<div>
|
||||
<p class="ctl-label">Tilt the slab</p>
|
||||
<p class="ctl-label">Tilt</p>
|
||||
<div class="angle">
|
||||
<button type="button" class="thumb" aria-label="Tilt left" on:click={() => nudgeTilt(-1)}>−</button>
|
||||
<input
|
||||
@@ -220,11 +211,10 @@
|
||||
<button type="button" class="thumb" aria-label="Tilt right" on:click={() => nudgeTilt(1)}>+</button>
|
||||
<span class="val">{tiltShown}°</span>
|
||||
</div>
|
||||
<p class="nudge">±90° · slider always works · in Rotate mode: ← → tilt 1° · Shift+← → 0.1° · two-finger twist</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="ctl-label">Zoom the slab</p>
|
||||
<p class="ctl-label">Zoom</p>
|
||||
<div class="angle">
|
||||
<button type="button" class="thumb" aria-label="Zoom out" on:click={() => nudgeZoom(-1)}>−</button>
|
||||
<input
|
||||
@@ -236,40 +226,30 @@
|
||||
<span class="val">{Math.round(tape.zoom * 100)}%</span>
|
||||
</div>
|
||||
<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>
|
||||
<p class="ctl-label">Crop fill % <span class="sub">global setting</span></p>
|
||||
<p class="ctl-label">Frame fill</p>
|
||||
<div class="croprow">
|
||||
<input
|
||||
type="range" min="50" max="100" step="1"
|
||||
value={Math.round(cropFill * 100)}
|
||||
aria-label="Crop fill percent"
|
||||
aria-label="Frame fill"
|
||||
on:input={(e) => onFill(Number(e.currentTarget.value))}
|
||||
/>
|
||||
<span class="val">{Math.round(cropFill * 100)}%</span>
|
||||
</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 class="fields">
|
||||
<div class="field">
|
||||
<span class="label">Length L</span>
|
||||
<span class="label">Length (in)</span>
|
||||
<input
|
||||
type="number"
|
||||
aria-label="Length L"
|
||||
aria-label="Length (in)"
|
||||
inputmode="decimal"
|
||||
step="0.125"
|
||||
min="0"
|
||||
@@ -335,34 +315,33 @@
|
||||
{:else if state.results}
|
||||
<div class="results" aria-label="Photos ready">
|
||||
<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>Sqft</span><span>{state.results.sqft.toFixed(2)}</span></div>
|
||||
<div><span>Bdft</span><span>{state.results.bdft.toFixed(2)}</span></div>
|
||||
<div><span>Scale</span><span>{state.results.scale.toFixed(2)} px/in</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>Square feet</span><span>{state.results.sqft.toFixed(2)}</span></div>
|
||||
<div><span>Board feet</span><span>{state.results.bdft.toFixed(2)}</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>
|
||||
{#if state.results.sourceBelowBlock}
|
||||
<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>
|
||||
{:else if state.results.sourceBelowWarn}
|
||||
<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>
|
||||
{/if}
|
||||
|
||||
<ol class="lineup" aria-label="Inventory PNG lineup">
|
||||
<ol class="lineup" aria-label="Photos for the listing">
|
||||
{#each lineupItems as item, i (i)}
|
||||
{#if item.kind === 'png'}
|
||||
<li class="lineup-item png">
|
||||
<img src={lineupUrls[i]} alt={photosReadyPngAlt(item)} />
|
||||
<span class="role">
|
||||
{#if item.role === 'calibration'}
|
||||
Clean calibration
|
||||
Reference photo
|
||||
{:else if item.role === 'overlay'}
|
||||
Dimension overlay
|
||||
Photo with measurements
|
||||
{:else}
|
||||
Batch photo {(item.batchIndex ?? 0) + 1}
|
||||
Photo {(item.batchIndex ?? 0) + 1}
|
||||
{/if}
|
||||
</span>
|
||||
{#if item.warn}
|
||||
@@ -381,7 +360,7 @@
|
||||
<!-- Legacy single-PNG fallback only when lineup helper yielded nothing but pngUrl exists -->
|
||||
{#if lineupItems.length === 0 && pngUrl && !state.results.sourceBelowBlock}
|
||||
<div class="png-wrap">
|
||||
<img src={pngUrl} alt="Inventory PNG" />
|
||||
<img src={pngUrl} alt="Listing photo" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -411,8 +390,7 @@
|
||||
}
|
||||
.mode-btn + .mode-btn { border-left: 1px solid #444; }
|
||||
.mode-btn.active { background: #00e5ff; color: #002; }
|
||||
.mode-hint { margin: 0 0 10px; }
|
||||
.nudge { color: #e8a; font-size: 0.74rem; margin: 6px 0 0; line-height: 1.45; }
|
||||
.nudge { color: #aaa; font-size: 0.8rem; margin: 6px 0 0; line-height: 1.45; }
|
||||
.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 .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-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; }
|
||||
.lineup-item.error {
|
||||
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-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 img { max-width: 100%; border-radius: 4px; }
|
||||
.png-wrap img { width: 100%; aspect-ratio: 3 / 4; object-fit: contain; border-radius: 4px; }
|
||||
</style>
|
||||
|
||||
@@ -10,12 +10,12 @@ const panelSrc = readFileSync(
|
||||
'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) ?? [];
|
||||
assert.ok(inputs.length >= 2, 'expected at least two <input> fields');
|
||||
assert.ok(
|
||||
inputs.some((el) => /type="number"/.test(el) && /aria-label="Length L"/.test(el)),
|
||||
'Length L must be a type=number input',
|
||||
inputs.some((el) => /type="number"/.test(el) && /aria-label="Length \(in\)"/.test(el)),
|
||||
'Length (in) must be a type=number input',
|
||||
);
|
||||
assert.ok(
|
||||
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.equal(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');
|
||||
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');
|
||||
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, /Widths/, 'M1 widths still visible');
|
||||
assert.match(panel, /Sqft/, 'M1 sqft still visible');
|
||||
assert.match(panel, /Bdft/, 'M1 bdft still visible');
|
||||
assert.match(panel, /Scale/, 'M1 scale still visible');
|
||||
assert.match(panel, /Widths \(avg/, 'M1 widths still visible');
|
||||
assert.match(panel, /Square feet/, 'M1 sqft still visible');
|
||||
assert.match(panel, /Board feet/, 'M1 bdft still visible');
|
||||
assert.equal(/\bScale\b/.test(panel) && /px\/in/.test(panel), false, 'Scale / px/in hidden on Photos ready');
|
||||
// Dishonest proxies
|
||||
assert.ok(
|
||||
!/lineup[\s\S]{0,200}state\.source\.blob/.test(panel),
|
||||
|
||||
@@ -52,7 +52,7 @@ export function buildPhotosReadyLineup(
|
||||
if (inventoryPng && !sourceBelowBlock) {
|
||||
const warn = sourceBelowWarn
|
||||
? (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;
|
||||
items.push({
|
||||
kind: 'png',
|
||||
@@ -91,8 +91,8 @@ export function buildPhotosReadyLineup(
|
||||
}
|
||||
|
||||
export function photosReadyPngAlt(item: Extract<PhotosReadyLineupItem, { kind: 'png' }>): string {
|
||||
if (item.role === 'calibration') return 'Calibration inventory PNG';
|
||||
if (item.role === 'overlay') return 'Calibration dimension-overlay PNG';
|
||||
if (item.role === 'calibration') return 'Reference photo';
|
||||
if (item.role === 'overlay') return 'Photo with measurements';
|
||||
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.ok(apply![0] && apply![0].status === 'error', 'empty mask → 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');
|
||||
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![2] && apply![2].status === 'error', 'below-block → 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();
|
||||
});
|
||||
@@ -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![2] && apply![2].status === 'ok', 'warn-band → still 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();
|
||||
});
|
||||
|
||||
@@ -722,7 +722,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
||||
const batch = batchPhotos.length ? batchPhotos : [source];
|
||||
const batchApply: BatchPhotoResult[] = batch.map((_, index) => (index === calIndex ? null : {
|
||||
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
|
||||
@@ -737,7 +737,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
||||
slabBounds: EMPTY_ABOX,
|
||||
});
|
||||
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 = {
|
||||
...photo,
|
||||
@@ -752,10 +752,10 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
||||
siblingBitmap.close();
|
||||
}
|
||||
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
|
||||
? '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;
|
||||
batchApply[index] = warn
|
||||
? { status: 'ok', inventoryPng: encoded.blob, warn }
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
}
|
||||
.thumb {
|
||||
position: relative;
|
||||
aspect-ratio: 1;
|
||||
aspect-ratio: 3 / 4;
|
||||
border: 2px solid #2a2a2a;
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
@@ -70,7 +70,7 @@
|
||||
background-position: 0 0,0 8px,8px -8px,-8px 0;
|
||||
}
|
||||
.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 {
|
||||
position: absolute; top: 4px; right: 4px;
|
||||
background: #00e5ff; color: #002;
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
type LineupItem,
|
||||
} from './lineup';
|
||||
import { nextPrimary } from './woodPrimary';
|
||||
import { getShowViewPrompt, VIEW_PROMPT_EVENT } from '../../settings/viewPrompt';
|
||||
import { missingCreateDescLabels } from './createDescGate';
|
||||
import { missingCreateTitleLabels, titleBuildMissingLabels } from './createTitleGate';
|
||||
import { orderWoodCatGroups } from './woodCatOrder';
|
||||
@@ -138,7 +139,7 @@
|
||||
let reviewError = '';
|
||||
// #89: explicit "Create" actions. `createBusy` covers the deterministic
|
||||
// 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.
|
||||
let createBusy = false;
|
||||
let createError = '';
|
||||
@@ -150,8 +151,8 @@
|
||||
});
|
||||
$: canCreateTitle = createTitleMissing.length === 0;
|
||||
// Create-desc gate: recompute on every field change (no blur required).
|
||||
// Short/description are Call 2 outputs; price is publish-only.
|
||||
// Needs content_llm_enabled + inference URL/key (Call 2); Call 1 stays out.
|
||||
// Short/description are content generate outputs; price is publish-only.
|
||||
// Needs content_llm_enabled + inference URL/key (content generate); Call 1 stays out.
|
||||
$: createDescMissing = missingCreateDescLabels({
|
||||
contentLlmEnabled: canGenerateContent,
|
||||
inferenceConfigured,
|
||||
@@ -193,7 +194,7 @@
|
||||
let genBusy = false;
|
||||
let genError = '';
|
||||
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;
|
||||
const secretConfigured = (s: { configured: boolean } | string | null | undefined): boolean => {
|
||||
if (s == null) return false;
|
||||
@@ -590,7 +591,7 @@
|
||||
description = r.description;
|
||||
if (r.short_description) shortDescription = r.short_description;
|
||||
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 fresh = await updateSlab(slabId, {
|
||||
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;
|
||||
|
||||
onMount(() => {
|
||||
const sync = () => { showViewPrompt = getShowViewPrompt(); };
|
||||
window.addEventListener(VIEW_PROMPT_EVENT, sync);
|
||||
return () => window.removeEventListener(VIEW_PROMPT_EVENT, sync);
|
||||
});
|
||||
let viewPromptBusy = false;
|
||||
let viewPromptError = '';
|
||||
let viewPrompt: Call2Prompt | null = null;
|
||||
@@ -712,23 +720,23 @@
|
||||
|
||||
{#if 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>
|
||||
</section>
|
||||
{:else if loadError}
|
||||
<section class="review">
|
||||
<h1>Review to Ready</h1>
|
||||
<h1>Review slab</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>
|
||||
<h1>Review slab</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>
|
||||
<div><dt>Square feet / Board feet</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. -->
|
||||
@@ -767,12 +775,18 @@
|
||||
<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>
|
||||
<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>
|
||||
{/each}
|
||||
</div>
|
||||
{#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.
|
||||
</p>
|
||||
{/if}
|
||||
@@ -780,7 +794,7 @@
|
||||
</div>
|
||||
{/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). -->
|
||||
|
||||
<!-- Wood categories (multi + inline primary radio set, REVIEW-15 / #90 wireframe) -->
|
||||
@@ -798,7 +812,7 @@
|
||||
/>
|
||||
{#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>
|
||||
<div class="ps-lbl">Primary wood category <span class="ps-req">used for title</span></div>
|
||||
{#each primaryOpts as o (o.id)}
|
||||
<button
|
||||
type="button"
|
||||
@@ -814,8 +828,7 @@
|
||||
</button>
|
||||
{/each}
|
||||
<p class="ps-note">
|
||||
Primary selects the title & short-description pattern (METADATA-85).
|
||||
Secondary categories feed the description. Default = first selected.
|
||||
Primary is used for the title. Other selected categories can appear in the description.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -898,11 +911,11 @@
|
||||
<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"
|
||||
disabled={!canCreateTitle || createBusy} on:click={createTitle}>
|
||||
{createBusy ? 'Building…' : 'Create short desc'}
|
||||
{createBusy ? 'Building…' : 'Create short description'}
|
||||
</button>
|
||||
</div>
|
||||
{#if !canCreateTitle}
|
||||
<p class="nudge" data-testid="create-title-missing">
|
||||
<p class="nudge must" data-testid="create-title-missing">
|
||||
Create title needs: {createTitleMissing.join(', ')}
|
||||
</p>
|
||||
{/if}
|
||||
@@ -917,29 +930,31 @@
|
||||
data-testid="create-desc"
|
||||
on:click={generateContent}
|
||||
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'}
|
||||
</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')}
|
||||
{genBusy ? 'Writing…' : 'Create description'}
|
||||
</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>
|
||||
{#if !canCreateDesc}
|
||||
<p class="nudge" data-testid="create-desc-missing">
|
||||
Create desc needs: {createDescMissing.join(', ')}
|
||||
<p class="nudge must" data-testid="create-desc-missing">
|
||||
Create description needs: {createDescMissing.join(', ')}
|
||||
</p>
|
||||
{/if}
|
||||
{#if viewPromptOpen}
|
||||
<div class="prompt-panel" data-testid="view-prompt-panel">
|
||||
<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}>
|
||||
{viewPromptCopied ? 'Copied' : 'Copy text'}
|
||||
</button>
|
||||
@@ -949,15 +964,13 @@
|
||||
{#if viewPrompt}
|
||||
<pre class="prompt-text" data-testid="prompt-text">{viewPrompt.text}</pre>
|
||||
<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}
|
||||
<p class="nudge">No processed photos on this slab.</p>
|
||||
<p class="nudge">No photos on this slab yet.</p>
|
||||
{:else}
|
||||
<ul>
|
||||
{#each viewPrompt.photos as ph (ph.id)}
|
||||
<li data-testid="prompt-photo-slot">
|
||||
seq={ph.seq} · id={ph.id} · {ph.bytes} bytes · long-edge {ph.long_edge_px ?? '?'}px
|
||||
</li>
|
||||
{#each viewPrompt.photos as ph, i (ph.id)}
|
||||
<li data-testid="prompt-photo-slot">Photo {i + 1}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/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} />
|
||||
</div>
|
||||
<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} />
|
||||
{#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}
|
||||
</div>
|
||||
|
||||
@@ -997,7 +1010,7 @@
|
||||
].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>
|
||||
<p class="missing must" data-testid="missing-list">Missing: {[...review.missing, ...lineupMissing.map(roleLabel)].join(', ')}</p>
|
||||
{/if}
|
||||
{/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; }
|
||||
.ta { resize: vertical; }
|
||||
.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; }
|
||||
.gen { margin-top: 8px; display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
/* 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-name { flex: 1; }
|
||||
.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; }
|
||||
.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; }
|
||||
.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; }
|
||||
@@ -1080,7 +1094,7 @@
|
||||
.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;
|
||||
.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)
|
||||
so transparent inventory PNGs read the same on both screens (bug2). */
|
||||
background-color: #eee;
|
||||
@@ -1091,28 +1105,24 @@
|
||||
.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-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-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. */
|
||||
/* Mandatory photos (reference + measurements): keep is locked; SVG lock
|
||||
signals required / cannot remove. */
|
||||
.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 .ph-keep {
|
||||
background: #00e5ff; border-color: #00e5ff; color: #002;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.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; }
|
||||
.ph-nudge { color: #e8a; font-size: .74rem; margin: 8px 0 0; }
|
||||
.ph-nudge { color: #ff6b6b; font-size: .74rem; margin: 8px 0 0; }
|
||||
</style>
|
||||
|
||||
@@ -66,7 +66,7 @@ test('Create desc gate: lists each missing field; no short/description/price key
|
||||
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.deepEqual(missingCreateDesc({ ...full, inferenceConfigured: false }), [
|
||||
'inference_creds',
|
||||
|
||||
@@ -23,8 +23,8 @@ export type CreateDescInput = {
|
||||
|
||||
/** Human labels shown under Create desc when the gate fails. */
|
||||
export const CREATE_DESC_LABELS: Record<string, string> = {
|
||||
content_llm: 'content LLM on',
|
||||
inference_creds: 'inference URL + API key',
|
||||
content_llm: 'description writing on',
|
||||
inference_creds: 'description connection',
|
||||
title: 'title',
|
||||
species: 'species',
|
||||
wood_categories: '≥1 wood category',
|
||||
|
||||
@@ -67,8 +67,8 @@ test('lineup: missingMinimums only counts KEPT photos', () => {
|
||||
});
|
||||
|
||||
test('lineup: roleLabel is owner-facing', () => {
|
||||
assert.match(roleLabel('calibration'), /clean/);
|
||||
assert.match(roleLabel('overlay'), /overlay/);
|
||||
assert.match(roleLabel('calibration'), /reference photo/i);
|
||||
assert.match(roleLabel('overlay'), /measurements/i);
|
||||
});
|
||||
|
||||
test('lineup: resolvePhotoUrl turns an in-memory blob into a blob: URL (bug #1)', () => {
|
||||
|
||||
@@ -57,9 +57,9 @@ export function lineupValid(kept: LineupItem[]): boolean {
|
||||
|
||||
/** A short owner-facing label for a role (used in the minimum nudge). */
|
||||
export function roleLabel(role: PhotoRole): string {
|
||||
if (role === 'calibration') return 'a clean photo';
|
||||
if (role === 'overlay') return 'its dimension-overlay copy';
|
||||
return 'a batch photo';
|
||||
if (role === 'calibration') return 'the reference photo';
|
||||
if (role === 'overlay') return 'the photo with measurements';
|
||||
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);
|
||||
});
|
||||
|
||||
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,
|
||||
} from '../../api/wooDeviceCreds';
|
||||
import { reopenWooGate, isWooAuthFailure } from '../../api/wooGate';
|
||||
import { getShowViewPrompt, setShowViewPrompt } from '../../settings/viewPrompt';
|
||||
|
||||
interface SecretState { configured: boolean }
|
||||
// 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.
|
||||
let deviceWoo = { username: '', appPassword: '' };
|
||||
let deviceMsg = '';
|
||||
let showViewPrompt = getShowViewPrompt();
|
||||
|
||||
let rateRows: RateRow[] = [];
|
||||
let ratesLoading = false;
|
||||
@@ -409,6 +411,19 @@
|
||||
<label><input type="checkbox" bind:checked={form.content_llm_enabled} /> Content LLM Enabled (default false)</label>
|
||||
</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>
|
||||
<label>Brand Voice</label>
|
||||
<input bind:value={form.brand_voice} />
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
pickBottomTowardTop,
|
||||
axisRightUnit,
|
||||
buildOverlayPlan,
|
||||
OVERLAY_FONT_PX,
|
||||
inventorySourceMap,
|
||||
BODY_WIDTH_FRAC,
|
||||
type OverlayPlan,
|
||||
@@ -139,7 +140,8 @@ test('buildOverlayPlan: length line on axis-right outside slab; widths at 12in s
|
||||
|
||||
// Color contract for draw consumers
|
||||
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)', () => {
|
||||
@@ -253,8 +255,8 @@ test('buildOverlayPlan: width labels sit axis-left outside the slab (#67)', () =
|
||||
for (const w of plan.widths) {
|
||||
assert.ok(w.labelAt, 'width mark must expose labelAt');
|
||||
// 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.right[1] - 149) <= 2 || Math.abs(w.right[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) <= 5 || Math.abs(w.right[1] - 50) <= 5);
|
||||
// labelAt on axis-left = opposite of axis-right from center
|
||||
// For this geometry, axis-right is toward smaller y; axis-left toward larger y
|
||||
// (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}`);
|
||||
});
|
||||
|
||||
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 bitset = new Uint8Array(W * H);
|
||||
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 scale = 10;
|
||||
const plan = buildOverlayPlan({
|
||||
const planA = buildOverlayPlan({
|
||||
bitset,
|
||||
widthPx: W,
|
||||
heightPx: H,
|
||||
bbox: box,
|
||||
scale,
|
||||
scale: 10,
|
||||
lengthIn: 48,
|
||||
lengthLabel: '48"',
|
||||
mapToInventory: (x, y) => [x, y],
|
||||
});
|
||||
const oldFont67 = Math.max(28, Math.round(scale * 1.2));
|
||||
const oldFont71 = Math.max(22, Math.round(scale * 1.0));
|
||||
assert.ok(plan.fontPx < oldFont67, `fontPx ${plan.fontPx} must be < prior ${oldFont67}`);
|
||||
assert.ok(plan.fontPx < oldFont71, `fontPx ${plan.fontPx} must be < prior ${oldFont71}`);
|
||||
assert.ok(plan.fontPx >= 12, `fontPx ${plan.fontPx} still legible`);
|
||||
// Concrete contract (#71): capped by scale when no inventory size
|
||||
assert.equal(plan.fontPx, Math.max(12, Math.min(16, Math.round(scale * 0.18))));
|
||||
const planB = buildOverlayPlan({
|
||||
bitset,
|
||||
widthPx: W,
|
||||
heightPx: H,
|
||||
bbox: box,
|
||||
scale: 80,
|
||||
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)
|
||||
const priorFont = Math.max(22, Math.round(scale * 1.0));
|
||||
assert.ok(plan.fontPx < priorFont, `fontPx ${plan.fontPx} must be < prior ${priorFont}`);
|
||||
assert.ok(plan.fontPx >= 12, `fontPx ${plan.fontPx} still readable`);
|
||||
assert.equal(plan.fontPx, OVERLAY_FONT_PX);
|
||||
assert.ok(plan.fontPx >= 28, `fontPx ${plan.fontPx} still readable`);
|
||||
|
||||
// Wood AABB in inventory
|
||||
let mMinX = Infinity, mMaxX = -Infinity, mMinY = Infinity, mMaxY = -Infinity;
|
||||
|
||||
@@ -262,6 +262,9 @@ function probeExtent(
|
||||
return { minP, maxP };
|
||||
}
|
||||
|
||||
/** Jeff-facing dimension labels: fixed size in inventory PNG pixels. */
|
||||
export const OVERLAY_FONT_PX = 36;
|
||||
|
||||
function fmtInches(inches: number): string {
|
||||
const whole = Math.floor(inches + 1e-9);
|
||||
const rem = Math.round((inches - whole) * 8);
|
||||
@@ -286,14 +289,9 @@ export function buildOverlayPlan(input: OverlayPlanInput): OverlayPlan {
|
||||
const { bottom, top, ux, uy } = oriented;
|
||||
const [rx, ry] = axisRightUnit(ux, uy);
|
||||
|
||||
// Consistent smaller readable font (#71). Cap by inventory size so high
|
||||
// source scale (≈80 px/in) does not dominate the PNG.
|
||||
const fontPx = (() => {
|
||||
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)));
|
||||
})();
|
||||
// Fixed readable measurement font in inventory image pixels (Jeff-facing).
|
||||
// Same size for every product — do not scale with tape px/in or canvas size.
|
||||
const fontPx = OVERLAY_FONT_PX;
|
||||
// Inset for stroke + arrowhead so visual tips sit on/inside edges (#71).
|
||||
const lineW = Math.max(2, Math.round(fontPx / 10));
|
||||
const arrowHead = Math.max(7, fontPx * 0.32);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -22,7 +22,7 @@ const ensureFreshBatch = async (page: Page) => {
|
||||
await page.goto('/');
|
||||
// resumeProbe is async onMount — wait for either Resume or Batch.
|
||||
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 });
|
||||
if (await resumeHeading.isVisible().catch(() => false)) {
|
||||
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).
|
||||
const gallery = page.locator('input[type="file"][multiple]');
|
||||
await gallery.setInputFiles([FACE_A, FACE_B]);
|
||||
await expect(page.getByAltText('Batch photo 1')).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByAltText('Batch photo 2')).toBeVisible();
|
||||
await expect(page.getByAltText('Photo 1')).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByAltText('Photo 2')).toBeVisible();
|
||||
await shot(page, '01-batch.png');
|
||||
|
||||
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 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');
|
||||
// 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 shot(page, '05-measure.png');
|
||||
|
||||
@@ -105,10 +105,10 @@ test.describe('M2 #65 Photos-ready e2e (set1-48x2)', () => {
|
||||
timeout: 120_000,
|
||||
});
|
||||
await expect(page.getByLabel('Photos ready')).toBeVisible();
|
||||
await expect(page.getByText('Clean calibration')).toBeVisible();
|
||||
await expect(page.getByText('Dimension overlay')).toBeVisible();
|
||||
// Sibling face-b should appear as Batch photo 2 (capture order).
|
||||
await expect(page.getByText(/Batch photo 2|Photo 2 failed/)).toBeVisible();
|
||||
await expect(page.getByText('Reference photo')).toBeVisible();
|
||||
await expect(page.getByText('Photo with measurements')).toBeVisible();
|
||||
// Sibling face-b should appear as Photo 2 (capture order).
|
||||
await expect(page.getByText(/Photo 2|Photo 2 failed/)).toBeVisible();
|
||||
|
||||
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;
|
||||
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');
|
||||
});
|
||||
|
||||
@@ -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 }) => {
|
||||
const regressionScreens = [
|
||||
{ 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: 'Generate (dev)', heading: 'Generate text' },
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user