Files
SlabUploader/frontend/src/capture/MaskPanel.svelte
T
grokbot_ty d86fc93ed6 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
2026-09-14 07:53:39 +00:00

94 lines
3.8 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import type { Capture, Sheet } from './capture.types';
import PhotoStage from './PhotoStage.svelte';
export let state: Extract<Capture, { k: 'mask' }>;
export let busy: boolean;
export let setSensitivity: (n: number) => void;
export let setSheet: (s: Sheet) => void;
export let approveMask: () => void;
export let retakes: () => void;
export let back: () => void;
const sheets: Sheet[] = ['auto', 'green', 'black'];
$: empty = state.overlay !== null && state.overlay.slabPx === 0;
$: maskW = state.overlay && state.source.widthPx > 0 ? state.source.widthPx : 0;
$: maskH = maskW > 0 && state.overlay ? state.overlay.bitset.length / maskW : 0;
$: canApprove = state.overlay !== null && state.overlay.slabPx > 0;
</script>
<section class="panel">
<h1>Mask</h1>
<div class="stage-wrap">
<PhotoStage
blob={state.source.blob}
maskBitset={state.overlay?.bitset ?? null}
{maskW}
{maskH}
/>
{#if busy}<div class="busy">Working…</div>{/if}
{#if state.failed}<div class="warn">Mask pass failed. Adjust and retry, or retake the photo.</div>{/if}
{#if empty}<div class="warn">No slab pixels in the mask. Adjust sensitivity or retake.</div>{/if}
</div>
<div class="controls">
<div class="seg" role="group" aria-label="Sheet">
{#each sheets as s (s)}
<button type="button" class:active={state.control.sheet === s} on:click={() => setSheet(s)}>
{s[0].toUpperCase() + s.slice(1)}
</button>
{/each}
</div>
<div class="sens">
<button type="button" class="thumb" aria-label="Decrease sensitivity" on:click={() => setSensitivity(state.control.sensitivity - 1)}>−</button>
<input
type="range" min="0" max="100" step="1"
value={state.control.sensitivity}
on:input={(e) => setSensitivity(Number(e.currentTarget.value))}
/>
<button type="button" class="thumb" aria-label="Increase sensitivity" on:click={() => setSensitivity(state.control.sensitivity + 1)}>+</button>
<span class="val">{state.control.sensitivity}</span>
</div>
</div>
<div class="row">
<button type="button" class="ghost" on:click={back}>Back</button>
<button type="button" class="ghost" on:click={retakes}>Retake</button>
<button type="button" class="primary" disabled={!canApprove} on:click={approveMask}>
Approve mask
</button>
</div>
</section>
<style>
.panel { padding: 16px; color: #eee; }
h1 { font-size: 1.3rem; margin: 0 0 12px; }
.stage-wrap { position: relative; }
.busy {
position: absolute; top: 8px; right: 8px; background: rgba(0,0,0,0.7);
color: #fff; padding: 4px 10px; border-radius: 6px; font-size: 0.85rem;
}
.warn { color: #ffb4a2; margin-top: 8px; font-size: 0.9rem; }
.controls { margin-top: 16px; display: flex; flex-direction: column; gap: 14px; }
.seg { display: flex; gap: 8px; }
.seg button {
flex: 1; padding: 12px; font-size: 1rem; border-radius: 8px;
border: 1px solid #444; background: #1d1d1d; color: #ccc;
}
.seg button.active { background: #00e5ff; color: #002; font-weight: 700; border-color: #00e5ff; }
.sens { display: flex; align-items: center; gap: 10px; }
.thumb {
width: 48px; height: 48px; border-radius: 50%; font-size: 1.6rem;
border: 1px solid #444; background: #1d1d1d; color: #eee; flex: none;
}
input[type='range'] { flex: 1; }
.val { min-width: 2.2ch; text-align: right; font-variant-numeric: tabular-nums; }
.row { display: flex; gap: 12px; 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; }
</style>