feat: style the UI with Oat and make the empty canvas a click-to-load drop zone

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
immortaly007andClaude Opus 5.5 committed 2026-10-15 15:43:06 +02:00
1 parent 353577d5ec
commit 98285d0539
5 files changed
+77 -29

No files matched your search

+6
View File
@@ -8,6 +8,7 @@
"name": "scrap-tracer", "name": "scrap-tracer",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@knadh/oat": "0.8.3",
"@techstark/opencv-js": "4.12.0-release.1" "@techstark/opencv-js": "4.12.0-release.1"
}, },
"devDependencies": { "devDependencies": {
@@ -67,6 +68,11 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@jridgewell/sourcemap-codec": "^1.4.14"
} }
}, },
"node_modules/@knadh/oat": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/@knadh/oat/-/oat-0.8.3.tgz",
"integrity": "sha512-eE07LJ6RpC21PfEbu3U6dFbTO0V6m1OcXocrYr7dwN+Ffr8sx+SqVTPwHO5/+Azv0NWEU8V7frwzBQXdx7CzLA=="
},
"node_modules/@oxc-project/types": { "node_modules/@oxc-project/types": {
"version": "0.154.0", "version": "0.154.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.154.0.tgz", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.154.0.tgz",
+1
View File
@@ -11,6 +11,7 @@
"test": "vitest run" "test": "vitest run"
}, },
"dependencies": { "dependencies": {
"@knadh/oat": "0.8.3",
"@techstark/opencv-js": "4.12.0-release.1" "@techstark/opencv-js": "4.12.0-release.1"
}, },
"devDependencies": { "devDependencies": {
+31 -7
View File
@@ -19,6 +19,11 @@
const file = e.dataTransfer?.files[0]; const file = e.dataTransfer?.files[0];
if (file) app.loadFile(file); if (file) app.loadFile(file);
} }
function onFile(e: Event & { currentTarget: HTMLInputElement }) {
const file = e.currentTarget.files?.[0];
if (file) app.loadFile(file);
}
</script> </script>
<div class="layout"> <div class="layout">
@@ -29,7 +34,11 @@
{#if app.image} {#if app.image}
<ImageCanvas {app} /> <ImageCanvas {app} />
{:else} {:else}
<p class="empty">Drop a photo of a scrap piece here, or use “Load photo”.</p> <label class="dropzone">
<input type="file" accept="image/*" hidden onchange={onFile} />
<strong>Drop a photo of a scrap piece here</strong>
<span>or click to choose a file</span>
</label>
{/if} {/if}
</main> </main>
</div> </div>
@@ -37,8 +46,6 @@
<style> <style>
:global(body) { :global(body) {
margin: 0; margin: 0;
font-family: system-ui, sans-serif;
color: #222;
} }
.layout { .layout {
display: grid; display: grid;
@@ -47,16 +54,33 @@
} }
aside { aside {
overflow-y: auto; overflow-y: auto;
border-right: 1px solid #ddd; border-right: 1px solid var(--border);
padding: 16px; padding: 16px;
} }
main { main {
position: relative; position: relative;
background: #2b2b2b; background: #2b2b2b;
} }
.empty { .dropzone {
position: absolute;
inset: 24px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
border: 2px dashed #555;
border-radius: 12px;
color: #bbb; color: #bbb;
text-align: center; cursor: pointer;
margin-top: 40vh; transition: border-color 0.15s, background-color 0.15s;
}
.dropzone:hover {
border-color: #888;
background: rgba(255, 255, 255, 0.03);
}
.dropzone span {
color: #888;
font-size: 0.9rem;
} }
</style> </style>
+38 -22
View File
@@ -6,6 +6,7 @@
import { scrapFilename, shapeSize } from '../lib/geometry'; import { scrapFilename, shapeSize } from '../lib/geometry';
let { app }: { app: AppState } = $props(); let { app }: { app: AppState } = $props();
let fileInput: HTMLInputElement;
const size = $derived(app.exportShape ? shapeSize(app.exportShape) : null); const size = $derived(app.exportShape ? shapeSize(app.exportShape) : null);
const nothingDetected = $derived(app.cv !== null && app.image !== null && app.shapePx === null); const nothingDetected = $derived(app.cv !== null && app.image !== null && app.shapePx === null);
@@ -30,24 +31,27 @@
<h1>Scrap Tracer</h1> <h1>Scrap Tracer</h1>
{#if app.cvError} {#if app.cvError}
<p class="error">{app.cvError} <button onclick={() => location.reload()}>Reload</button></p> <p class="error">{app.cvError} <button class="outline small" onclick={() => location.reload()}>Reload</button></p>
{:else if !app.cv} {:else if !app.cv}
<p class="muted">Loading image processing…</p> <p class="muted">Loading image processing…</p>
{/if} {/if}
<section> <section>
<h2>1. Photo</h2> <h2>1. Photo</h2>
<label class="file">Load photo <input type="file" accept="image/*" onchange={onFile} /></label> <button class="outline" onclick={() => fileInput.click()}>{app.image ? 'Replace photo' : 'Load photo'}</button>
<input bind:this={fileInput} type="file" accept="image/*" hidden onchange={onFile} />
{#if app.loadError}<p class="error">{app.loadError}</p>{/if} {#if app.loadError}<p class="error">{app.loadError}</p>{/if}
</section> </section>
<section class:disabled={!app.image}> <section class:disabled={!app.image}>
<h2>2. Scale</h2> <h2>2. Scale</h2>
<p class="muted">Click both ends of something whose length you know.</p> <p class="muted">Click both ends of something whose length you know.</p>
<button class:active={app.tool === 'scale'} onclick={() => toggle('scale')} disabled={!app.image}> <div class="row">
<button class:outline={app.tool !== 'scale'} onclick={() => toggle('scale')} disabled={!app.image}>
{app.scalePoints.length === 2 ? 'Re-measure' : 'Measure'} {app.scalePoints.length === 2 ? 'Re-measure' : 'Measure'}
</button> </button>
<label>Length <input type="number" min="1" step="any" bind:value={app.lengthMm} /> mm</label> <label class="inline">Length <input type="number" min="1" step="any" bind:value={app.lengthMm} /> mm</label>
</div>
{#if app.scaleInvalid} {#if app.scaleInvalid}
<p class="error">Enter a length above 0 and pick two different points.</p> <p class="error">Enter a length above 0 and pick two different points.</p>
{:else if app.pxPerMm} {:else if app.pxPerMm}
@@ -58,19 +62,26 @@
<section class:disabled={!app.image}> <section class:disabled={!app.image}>
<h2>3. Outline</h2> <h2>3. Outline</h2>
{#if app.mode === 'auto'} {#if app.mode === 'auto'}
<label>Threshold <input type="range" min="0" max="255" bind:value={app.threshold} /> {app.threshold}</label> <label>
<button onclick={() => app.autoThreshold()} disabled={!app.cv || !app.image}>Auto</button> <span class="label-row">Threshold <span class="value">{app.threshold}</span></span>
<button class:active={app.tool === 'seed'} onclick={() => toggle('seed')} disabled={!app.image}>Click to pick piece</button> <input type="range" min="0" max="255" bind:value={app.threshold} />
</label>
<div class="row">
<button class="outline" onclick={() => app.autoThreshold()} disabled={!app.cv || !app.image}>Auto</button>
<button class:outline={app.tool !== 'seed'} onclick={() => toggle('seed')} disabled={!app.image}>Click to pick piece</button>
</div>
{/if} {/if}
<button class:active={app.tool === 'draw'} onclick={() => toggle('draw')} disabled={!app.image}> <div class="row">
<button class:outline={app.tool !== 'draw'} onclick={() => toggle('draw')} disabled={!app.image}>
{app.manualOuter.length === 0 ? 'Draw outline manually' : 'Draw a hole'} {app.manualOuter.length === 0 ? 'Draw outline manually' : 'Draw a hole'}
</button> </button>
{#if app.drawing.length > 0} {#if app.drawing.length > 0}
<button onclick={() => app.finishPolygon()}>Finish {app.manualOuter.length === 0 ? 'outline' : 'hole'}</button> <button onclick={() => app.finishPolygon()}>Finish {app.manualOuter.length === 0 ? 'outline' : 'hole'}</button>
{/if} {/if}
{#if app.mode === 'manual' || app.drawing.length > 0} {#if app.mode === 'manual' || app.drawing.length > 0}
<button onclick={() => app.clearManual()}>Back to automatic</button> <button class="ghost" onclick={() => app.clearManual()}>Back to automatic</button>
{/if} {/if}
</div>
{#if nothingDetected} {#if nothingDetected}
<p class="warning">Nothing detected, try adjusting the threshold, clicking the piece, or draw manually.</p> <p class="warning">Nothing detected, try adjusting the threshold, clicking the piece, or draw manually.</p>
{/if} {/if}
@@ -79,8 +90,8 @@
<section class:disabled={!app.pxPerMm}> <section class:disabled={!app.pxPerMm}>
<h2>4. Safety margin</h2> <h2>4. Safety margin</h2>
<label> <label>
<span class="label-row">Margin <span class="value">{app.marginMm} mm</span></span>
<input type="range" min="-10" max="5" step="0.5" bind:value={app.marginMm} disabled={!app.pxPerMm} /> <input type="range" min="-10" max="5" step="0.5" bind:value={app.marginMm} disabled={!app.pxPerMm} />
{app.marginMm} mm
</label> </label>
{#if !app.pxPerMm}<p class="muted">Set the scale first.</p>{/if} {#if !app.pxPerMm}<p class="muted">Set the scale first.</p>{/if}
</section> </section>
@@ -88,8 +99,10 @@
<section> <section>
<h2>5. Export</h2> <h2>5. Export</h2>
{#if size}<p>Piece: {Math.round(size.width)} × {Math.round(size.height)} mm</p>{/if} {#if size}<p>Piece: {Math.round(size.width)} × {Math.round(size.height)} mm</p>{/if}
<button class="primary" onclick={() => exportAs('dxf')} disabled={!app.exportShape}>Download DXF</button> <div class="row">
<button onclick={() => exportAs('svg')} disabled={!app.exportShape}>Download SVG</button> <button onclick={() => exportAs('dxf')} disabled={!app.exportShape}>Download DXF</button>
<button class="outline" onclick={() => exportAs('svg')} disabled={!app.exportShape}>Download SVG</button>
</div>
{#if !app.exportShape} {#if !app.exportShape}
<p class="muted">{app.pxPerMm ? 'No outline to export yet.' : 'Set the scale first.'}</p> <p class="muted">{app.pxPerMm ? 'No outline to export yet.' : 'Set the scale first.'}</p>
{/if} {/if}
@@ -97,15 +110,18 @@
<style> <style>
h1 { font-size: 1.3rem; margin: 0 0 12px; } h1 { font-size: 1.3rem; margin: 0 0 12px; }
h2 { font-size: 1rem; margin: 0 0 6px; } h2 { font-size: 1rem; margin: 0; }
section { padding: 12px 0; border-top: 1px solid #eee; display: flex; flex-direction: column; gap: 6px; align-items: flex-start; } p { margin: 0; }
section { padding: 14px 0; border-top: 1px solid var(--border); display: flex; flex-direction: column; gap: 8px; align-items: flex-start; }
section.disabled { opacity: 0.5; } section.disabled { opacity: 0.5; }
label { display: flex; gap: 6px; align-items: center; } section > label { align-self: stretch; }
input[type='number'] { width: 80px; } input[type='range'] { width: 100%; }
.file input { max-width: 200px; } .row { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
button.active { outline: 2px solid #2962ff; } .inline { display: flex; gap: 6px; align-items: center; }
button.primary { font-weight: bold; } .inline input { width: 90px; margin: 0; }
.muted { color: #777; margin: 0; font-size: 0.9rem; } .label-row { display: flex; justify-content: space-between; }
.error { color: #c62828; margin: 0; } .value { color: var(--muted-foreground); font-variant-numeric: tabular-nums; }
.warning { color: #e65100; margin: 0; } .muted { color: var(--muted-foreground); font-size: 0.875rem; }
.error { color: var(--danger); }
.warning { color: var(--warning); }
</style> </style>
+1
View File
@@ -1,3 +1,4 @@
import '@knadh/oat/oat.min.css';
import { mount } from 'svelte'; import { mount } from 'svelte';
import App from './App.svelte'; import App from './App.svelte';