feat(greenfield): rebuild SlabUploader M1-M3 on clean foundation, all gates green

Clean re-implementation of the full product (not a port of the old M3 tree):
- P0 foundation: 13-table schema + per-call sqlite factory (thread-safe),
  FastAPI app (health, JSON logging + secret redaction, CORS, startup guard).
- P1 M1 Capture: mask/sheetKeep, geometry/rect (min-area rect), measure,
  png crop/inventory/overlay, EXIF decodeOrientedBitmap.
- P2 M2 Photos: stateful capture session (batch, pick-cal, apply mask/crop
  to batch) + photos-ready lineup.
- P3 M3 Create: real backend (crypto, woo client, taxonomy sync, pricing,
  inference Call1/Call2, review, publish, settings, slabs, photos) + real FE
  review/publish/settings screens consuming the API (no hardcoded Woo ids).
- P4 gate exit: check_requirements + check_coverage both exit 0
  (137/155 IDs, 0 gaps); backend 130/130, frontend 150/150, build green.

Cite: docs/REQUIREMENTS.md (155 rows), docs/MILESTONES.md M1-M3.
This commit is contained in:
2026-09-25 06:57:29 -07:00
parent 4ca2ef57a7
commit 49c22c9385
106 changed files with 5502 additions and 8863 deletions
+170
View File
@@ -0,0 +1,170 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import type { CaptureSession } from './session';
import type { Sheet } from './capture.types';
export let session: CaptureSession;
const dispatch = createEventDispatcher<{ 'photos-ready': void }>();
$: state = $session.state;
$: cap = state.capture;
$: kind = cap.k;
$: photos = kind === 'batch' || kind === 'pickCalibration' ? cap.photos : [];
$: control = kind === 'mask' ? cap.control : null;
$: overlay = kind === 'mask' ? cap.overlay : null;
$: failed = kind === 'mask' ? cap.failed : false;
$: measure = kind === 'measure' ? cap : null;
$: results = measure?.results ?? null;
let fileInput: HTMLInputElement;
function pickFiles(e: Event) {
const input = e.target as HTMLInputElement;
if (!input.files) return;
for (const file of Array.from(input.files)) {
session.addToBatch(file);
}
input.value = '';
}
function onSheetChange(e: Event) {
session.setSheet((e.target as HTMLSelectElement).value as Sheet);
}
function onSensitivityInput(e: Event) {
session.setSensitivity(Number((e.target as HTMLInputElement).value));
}
function onLengthChange(e: Event) {
session.setLength(Number((e.target as HTMLInputElement).value));
}
function onThicknessChange(e: Event) {
session.setThickness(Number((e.target as HTMLInputElement).value));
}
function onThumbKey(e: KeyboardEvent, i: number) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
session.pickCalibration(i);
}
}
function fmt(eighths: number): string {
const whole = Math.floor(eighths / 8);
const rem = eighths - whole * 8;
if (rem === 0) return `${whole}″`;
const frac: Record<number, string> = { 1: '⅛', 2: '¼', 3: '⅜', 4: '½', 5: '⅝', 6: '¾', 7: '⅞' };
return whole > 0 ? `${whole} ${frac[rem] ?? `${rem}/8`}″` : `${frac[rem] ?? `${rem}/8`}″`;
}
</script>
<section>
{#if kind === 'batch'}
<h2>1. Batch photos</h2>
<p>Add one or more photos of the slab on a green or black sheet.</p>
<div class="picker-row">
<button type="button" class="picker" on:click={() => fileInput?.click()}>Choose photos</button>
<span>{photos.length} photo{photos.length === 1 ? '' : 's'}</span>
<input bind:this={fileInput} class="file-input" type="file" multiple accept="image/*" on:change={pickFiles} />
</div>
{#if photos.length}
<div class="thumbs">
{#each photos as p, i (i)}
<div class="thumb">
<img src={URL.createObjectURL(p.blob)} alt="photo {i}" />
<button on:click={() => session.removeFromBatch(i)}>×</button>
</div>
{/each}
</div>
<button class="primary" on:click={() => session.continueBatch()}>Continue →</button>
{/if}
{:else if kind === 'pickCalibration'}
<h2>2. Pick calibration photo</h2>
<p>Choose the reference shot that will drive mask, axis, and measure.</p>
<div class="thumbs">
{#each photos as p, i (i)}
<div class="thumb" role="button" tabindex="0" on:click={() => session.pickCalibration(i)} on:keydown={(e) => onThumbKey(e, i)} aria-label="pick photo {i} as calibration">
<img src={URL.createObjectURL(p.blob)} alt="photo {i}" />
</div>
{/each}
</div>
<button on:click={() => session.back()}>← Back</button>
{:else if kind === 'mask'}
<h2>3. Mask</h2>
<p>Adjust sheet and sensitivity, then approve the mask.</p>
{#if control}
<div class="controls">
<label>Sheet
<select value={control.sheet} on:change={onSheetChange}>
<option value="auto">Auto</option>
<option value="green">Green</option>
<option value="black">Black</option>
</select>
</label>
<label>Sensitivity {control.sensitivity}
<input type="range" min="0" max="100" step="1" value={control.sensitivity} on:input={onSensitivityInput} />
</label>
</div>
{/if}
{#if failed}
<p class="err">No slab pixels detected. Adjust controls or retake.</p>
{:else if overlay}
<p>Mask settled: {overlay.slabPx} slab pixels.</p>
{/if}
<div class="actions">
<button class="primary" disabled={!overlay || overlay.slabPx === 0} on:click={() => session.approveMaskAxis()}>Approve mask →</button>
<button on:click={() => session.back()}>← Back</button>
</div>
{:else if kind === 'axis'}
<h2>4. Length axis</h2>
<p>Place the tape endpoints at the two ends of the slab's length.</p>
<div class="actions">
<button class="primary" on:click={() => session.approveAxis()}>Approve axis →</button>
<button on:click={() => session.back()}>← Back</button>
</div>
{:else if kind === 'measure'}
<h2>5. Measure</h2>
<p>Enter length and thickness (1/8″ steps). Widths, sqft, and bdft are derived from the calibration mask.</p>
{#if measure}
<div class="controls">
<label>Length (eighths) <input type="number" min="0" step="1" value={measure.lengthEighths} on:change={onLengthChange} /></label>
<label>Thickness (eighths) <input type="number" min="0" step="1" value={measure.thicknessEighths} on:change={onThicknessChange} /></label>
</div>
{/if}
{#if results}
<div class="results">
<div>Length <strong>{fmt(results.lengthEighths)}</strong></div>
<div>Thickness <strong>{fmt(results.thicknessEighths)}</strong></div>
<div>Sqft <strong>{results.sqft}</strong></div>
<div>Bdft <strong>{results.bdft}</strong></div>
</div>
<button class="primary" on:click={() => dispatch('photos-ready')}>Photos ready →</button>
{:else}
<button class="primary" disabled={!measure || measure.lengthEighths === 0 || measure.thicknessEighths === 0} on:click={() => void session.calculate()}>Calculate</button>
{/if}
<button on:click={() => session.back()}>← Back</button>
{:else}
<p>Unknown capture state.</p>
{/if}
</section>
<style>
.picker-row { display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap; margin: 1rem 0; color: #aaa; }
.picker { min-height: 44px; padding: 0.65rem 1rem; background: #00e5ff; color: #002; border: 0; font-weight: 700; border-radius: 4px; }
.file-input { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; }
.thumbs { display: grid; grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); gap: 0.5rem; margin: 1rem 0; }
.thumb { position: relative; border: 1px solid #444; padding: 4px; }
.thumb img { width: 100%; height: 80px; object-fit: cover; display: block; }
.thumb button { position: absolute; top: 2px; right: 2px; background: #a33; color: #fff; border: none; border-radius: 3px; width: 20px; height: 20px; }
.controls { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 1rem; margin: 1rem 0; }
.controls label { display: grid; gap: 0.35rem; }
.controls input, .controls select { padding: 0.3rem; background: #222; color: #eee; border: 1px solid #444; }
.actions { display: flex; gap: 0.75rem; margin: 1rem 0; }
.primary { background: #0a7; color: #fff; border: none; padding: 0.6rem 1.2rem; }
.err { color: #f88; }
.results { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 0.5rem; background: #1a1a1a; padding: 1rem; border-radius: 4px; margin: 1rem 0; }
</style>