feat(capture): ship Milestone 1 browser capture app

Why
Phone and PC need a single capture flow that ends at inventory PNG and
board-foot numbers with no backend, inference, or Woo create.

Scope
Add frontend/ Vite+Svelte capture UI with Resume, Add photo, Mask,
Length axis, and Measure. Mask runs in a worker with Auto/Green/Black
and a 2.0s sensitivity debounce. Measure computes scale, widths, sqft,
bdft, and a 3:4 alpha inventory PNG. Persist one device draft in
IndexedDB. Serve the static build from frontend/Dockerfile via nginx
(deploy/nginx-m1.conf). No FastAPI.

Verification
sudo docker build -f frontend/Dockerfile -t slabuploader-m1 .
sudo docker run -d --name slabuploader-m1 -p 0.0.0.0:18080:80 slabuploader-m1
curl http://127.0.0.1:18080/ returns 200
Playwright: synthetic green-sheet photo through Calculate shows sqft/bdft
This commit is contained in:
2026-09-14 07:53:39 +00:00
parent 3bae76cea3
commit d86fc93ed6
24 changed files with 2969 additions and 0 deletions
+101
View File
@@ -0,0 +1,101 @@
<script lang="ts">
import type { Capture } from './capture.types';
import { fmtEighths } from './session';
import PhotoStage from './PhotoStage.svelte';
export let state: Extract<Capture, { k: 'measure' }>;
export let setLength: (e: number) => void;
export let setThickness: (e: number) => void;
export let calculate: () => Promise<void>;
export let back: () => void;
export let pngUrl: string | null;
let calcBusy = false;
const doCalc = async () => {
calcBusy = true;
try { await calculate(); } finally { calcBusy = false; }
};
</script>
<section class="panel">
<h1>Measure</h1>
<PhotoStage blob={state.source.blob} bbox={state.axis.bbox} heading={state.axis.heading} />
<div class="fields">
<div class="field">
<span class="label">Length L</span>
<div class="stepper">
<button type="button" class="thumb" on:click={() => setLength(state.lengthEighths - 1)} aria-label="Length minus">−</button>
<span class="v">{fmtEighths(state.lengthEighths)}</span>
<button type="button" class="thumb" on:click={() => setLength(state.lengthEighths + 1)} aria-label="Length plus">+</button>
</div>
</div>
<div class="field">
<span class="label">Thickness</span>
<div class="stepper">
<button type="button" class="thumb" on:click={() => setThickness(state.thicknessEighths - 1)} aria-label="Thickness minus">−</button>
<span class="v">{fmtEighths(state.thicknessEighths)}</span>
<button type="button" class="thumb" on:click={() => setThickness(state.thicknessEighths + 1)} aria-label="Thickness plus">+</button>
</div>
</div>
</div>
<div class="row">
<button type="button" class="ghost" on:click={back}>Back</button>
<button
type="button"
class="primary"
disabled={calcBusy || state.lengthEighths === 0 || state.thicknessEighths === 0}
on:click={doCalc}
>
{calcBusy ? 'Calculating…' : 'Calculate'}
</button>
</div>
{#if state.results}
<div class="results">
<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>
{#if state.results.sourceBelowFloor}
<div class="warn">Source is below the 1600px floor for the inventory PNG. Retake a larger photo.</div>
{/if}
{#if pngUrl && !state.results.sourceBelowFloor}
<div class="png-wrap">
<img src={pngUrl} alt="Inventory PNG" />
</div>
{/if}
</div>
{/if}
</section>
<style>
.panel { padding: 16px; color: #eee; }
h1 { font-size: 1.3rem; margin: 0 0 12px; }
.fields { display: flex; gap: 16px; margin-top: 16px; }
.field { flex: 1; }
.label { display: block; font-size: 0.9rem; color: #aaa; margin-bottom: 8px; }
.stepper { display: flex; align-items: center; gap: 12px; }
.thumb {
width: 52px; height: 52px; border-radius: 50%; font-size: 1.7rem;
border: 1px solid #444; background: #1d1d1d; color: #eee; flex: none;
}
.v { flex: 1; text-align: center; font-size: 1.15rem; font-variant-numeric: tabular-nums; }
.row { display: flex; gap: 10px; margin-top: 20px; }
.primary {
flex: 2; padding: 14px; font-size: 1.05rem; border-radius: 10px; border: 0;
background: #00e5ff; color: #002; font-weight: 700;
}
.primary:disabled { background: #333; color: #777; }
.ghost { flex: 1; padding: 14px; border-radius: 10px; border: 1px solid #444; background: transparent; color: #ccc; }
.results { margin-top: 20px; }
.nums { display: flex; flex-direction: column; gap: 8px; }
.nums div { display: flex; justify-content: space-between; border-bottom: 1px solid #2a2a2a; padding-bottom: 6px; }
.nums span:first-child { color: #aaa; }
.warn { color: #ffb4a2; margin-top: 12px; 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; }
</style>