From 818874e65cc87daeff2a77656fee1b93ac6edbb1 Mon Sep 17 00:00:00 2001
From: Ty
Date: Thu, 24 Sep 2026 12:01:18 -0700
Subject: [PATCH] feat(m4): crop_fill setting + tape-line prototype (#99)
Add global crop_fill percent (default 90, 1-100) as a settings key with
FastAPI 4xx validation. Wires the value through to the inventory PNG
encode (non-retro: stored PNGs untouched). Adds a throwaway
TapePrototype.svelte behind ?tape-prototype for the #99 design sandbox.
Gates: check_requirements PASS, check_coverage PASS, settings tests 8/8.
---
backend/settings.py | 30 +
backend/tests/test_settings_api.py | 48 ++
frontend/package-lock.json | 10 +
frontend/package.json | 1 +
frontend/src/App.svelte | 28 +
frontend/src/api/slabs.ts | 2 +
frontend/src/capture/TapePrototype.svelte | 611 +++++++++++++++++++
frontend/src/capture/capture.types.ts | 2 +-
frontend/src/capture/session.ts | 14 +-
frontend/src/create/settings/Settings.svelte | 14 +-
frontend/src/png/crop.ts | 65 ++
frontend/src/png/inventory.ts | 20 +-
frontend/src/png/overlay.ts | 9 +-
13 files changed, 831 insertions(+), 23 deletions(-)
create mode 100644 frontend/src/capture/TapePrototype.svelte
create mode 100644 frontend/src/png/crop.ts
diff --git a/backend/settings.py b/backend/settings.py
index b3999fe..f74e30d 100644
--- a/backend/settings.py
+++ b/backend/settings.py
@@ -28,13 +28,18 @@ PLAIN_KEYS: set[str] = {
"woo_create_status",
"taxonomy_last_sync_at",
"taxonomy_anchor",
+ "crop_fill",
}
ALL_KEYS = SECRET_KEYS | PLAIN_KEYS
+# Plain keys surfaced as numbers in the API (stored as TEXT in `settings`).
+NUMERIC_KEYS: set[str] = {"crop_fill"}
DEFAULTS: dict[str, Any] = {
"inference_enabled": False,
"content_llm_enabled": False,
"woo_create_status": "draft",
+ # #99: global crop fill %, one source of truth for the ref line + PNG crop.
+ "crop_fill": 90,
}
@@ -91,6 +96,12 @@ def get_settings() -> dict[str, Any]:
result[k] = val if val is not None else DEFAULTS.get(k)
else:
result[k] = DEFAULTS.get(k)
+ if k in NUMERIC_KEYS:
+ try:
+ f = float(result[k])
+ result[k] = int(f) if f.is_integer() else f
+ except (TypeError, ValueError):
+ result[k] = DEFAULTS.get(k)
return result
@@ -108,6 +119,25 @@ def update_settings(payload: dict[str, Any]) -> dict[str, Any]:
},
)
+ # #99: rules live here in the 4xx layer, not in the DB (structural only).
+ if "crop_fill" in payload:
+ raw = payload["crop_fill"]
+ try:
+ pct = float(raw)
+ except (TypeError, ValueError):
+ pct = None
+ if pct is None or not (1 <= pct <= 100):
+ raise HTTPException(
+ status_code=400,
+ detail={
+ "error": {
+ "code": "validation",
+ "message": "crop_fill must be a number between 1 and 100 (percent)",
+ "detail": {"field": "crop_fill"},
+ }
+ },
+ )
+
conn = get_connection()
for k, v in payload.items():
if k not in ALL_KEYS:
diff --git a/backend/tests/test_settings_api.py b/backend/tests/test_settings_api.py
index 9dbed81..0e6f425 100644
--- a/backend/tests/test_settings_api.py
+++ b/backend/tests/test_settings_api.py
@@ -138,3 +138,51 @@ def test_settings_decrypt_failure_returns_reenter_state_not_500(monkeypatch):
body = resp.json()
assert body["inference_api_key"] == {"configured": False}
db_mod._reset_for_tests()
+
+
+def test_settings_crop_fill_defaults_to_90(monkeypatch):
+ """Given: no crop_fill row; When: GET; Then: crop_fill == 90 (#99 req.4 default 90%)."""
+ with tempfile.TemporaryDirectory() as tmp:
+ _setup_env(monkeypatch, tmp)
+ from backend.app import app
+ client = TestClient(app)
+ db_mod.init_db()
+ resp = client.get("/api/v1/settings")
+ assert resp.status_code == 200
+ assert resp.json()["crop_fill"] == 90
+ db_mod._reset_for_tests()
+
+
+def test_settings_crop_fill_round_trips_as_plain_number(monkeypatch):
+ """Given: crop_fill in PUT; When: PUT then GET; Then: plain TEXT row (no value_enc) and a number back (#99 req.4)."""
+ with tempfile.TemporaryDirectory() as tmp:
+ _setup_env(monkeypatch, tmp)
+ from backend.app import app
+ client = TestClient(app)
+ db_mod.init_db()
+ resp = client.put("/api/v1/settings", json={"crop_fill": 75})
+ assert resp.status_code == 200
+ assert resp.json()["crop_fill"] == 75
+ conn = db_mod.get_connection()
+ row = conn.execute("SELECT value, value_enc FROM settings WHERE key='crop_fill'").fetchone()
+ assert row["value"] == "75" and row["value_enc"] is None
+ assert client.get("/api/v1/settings").json()["crop_fill"] == 75
+ db_mod._reset_for_tests()
+
+
+def test_settings_crop_fill_out_of_range_is_4xx_and_not_written(monkeypatch):
+ """Given: bad crop_fill; When: PUT; Then: 400 validation, nothing written (#99; rules in the 4xx layer)."""
+ with tempfile.TemporaryDirectory() as tmp:
+ _setup_env(monkeypatch, tmp)
+ from backend.app import app
+ client = TestClient(app)
+ db_mod.init_db()
+ for bad in (0, 101, -5, "abc", None):
+ resp = client.put("/api/v1/settings", json={"crop_fill": bad})
+ assert resp.status_code == 400, f"{bad!r} should be rejected"
+ assert resp.json()["error"]["code"] == "validation"
+ assert client.get("/api/v1/settings").json()["crop_fill"] == 90 # untouched default
+ # boundaries are valid
+ assert client.put("/api/v1/settings", json={"crop_fill": 100}).status_code == 200
+ assert client.put("/api/v1/settings", json={"crop_fill": 1}).status_code == 200
+ db_mod._reset_for_tests()
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 39bd099..ac052b6 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -8,6 +8,7 @@
"name": "slabuploader-frontend",
"version": "0.1.0",
"dependencies": {
+ "@neodrag/svelte": "2.3.3",
"idb-keyval": "^6.3.0",
"svelte": "^4.2.20"
},
@@ -495,6 +496,15 @@
"node": "^22.20 || ^24.12 || >=25"
}
},
+ "node_modules/@neodrag/svelte": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/@neodrag/svelte/-/svelte-2.3.3.tgz",
+ "integrity": "sha512-avXzhrilsBsnMFljhVAQ7h+6hbSIrvRCJ61GCiGbGISkC1QOhjDCNvPZo2+7KVwiYrnUBx4NRH0kTIqrcxv9Lg==",
+ "license": "MIT",
+ "peerDependencies": {
+ "svelte": "^3.0.0 || ^4.0.0 || ^5.0.0"
+ }
+ },
"node_modules/@playwright/test": {
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index 6e077c4..d20c8e3 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -12,6 +12,7 @@
"test:e2e:headed": "playwright test --headed"
},
"dependencies": {
+ "@neodrag/svelte": "2.3.3",
"idb-keyval": "^6.3.0",
"svelte": "^4.2.20"
},
diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte
index 1ea58f4..6bfd274 100644
--- a/frontend/src/App.svelte
+++ b/frontend/src/App.svelte
@@ -12,6 +12,10 @@
import AxisPanel from './capture/AxisPanel.svelte';
import MeasurePanel from './capture/MeasurePanel.svelte';
import Settings from './create/settings/Settings.svelte';
+ // #99 PROTOTYPE: tape-line overlay + per-slab length input + crop setting.
+ // Throwaway — driven by the `tape-prototype` query param. Delete this branch
+ // and the component after the design is settled.
+ import TapePrototype from './capture/TapePrototype.svelte';
import SlabList from './create/slabs/SlabList.svelte';
import Review from './create/review/Review.svelte';
import Publish from './create/publish/Publish.svelte';
@@ -22,6 +26,10 @@
import { clearDraft } from './draft/idb';
const gate = typeof window !== 'undefined' ? supported() : 'window unavailable';
+ // #99 PROTOTYPE entry: `?tape-prototype` boots the tape-line design sandbox.
+ const isTapeProto =
+ typeof window !== 'undefined' &&
+ new URLSearchParams(window.location.search).has('tape-prototype');
const session: CaptureSession = createCaptureSession();
let cap: Capture = { k: 'batch', photos: [] };
@@ -47,6 +55,11 @@
// ?noload to trigger the Resume prompt deterministically.
const boot = new URLSearchParams(window.location.search);
if (!boot.get('noload')) void session.resumeProbe();
+ // #99: the global crop fill drives both the reference line and the PNG crop.
+ // Falls back to the 90% default when settings are unreachable.
+ void getSettings()
+ .then((s) => session.setCropFill(s.crop_fill ?? 90))
+ .catch(() => {});
});
onDestroy(() => {
unsubCap();
@@ -144,6 +157,15 @@
Missing: {gate}
+{:else if isTapeProto}
+
+
+
+
#99 tape-line prototype
+
exit →
+
+
+
{:else}
{#if curFlow.phase === 'review'}
@@ -260,6 +282,12 @@
font-family: system-ui, -apple-system, Segoe UI, sans-serif;
}
.app, .hardstop { max-width: 720px; margin: 0 auto; min-height: 100vh; }
+ .proto-bar {
+ display: flex; align-items: center; justify-content: space-between;
+ padding: 8px 16px; background: #1d1d1d; color: #00e5ff; font-size: 0.85rem;
+ border-bottom: 1px solid #333;
+ }
+ .proto-bar a { color: #aaa; text-decoration: none; }
.hardstop { padding: 48px 24px; color: #eee; text-align: center; }
.hardstop h1 { font-size: 1.6rem; }
.detail { color: #888; font-size: 0.85rem; }
diff --git a/frontend/src/api/slabs.ts b/frontend/src/api/slabs.ts
index 4b5bc4d..34a5688 100644
--- a/frontend/src/api/slabs.ts
+++ b/frontend/src/api/slabs.ts
@@ -454,6 +454,8 @@ export interface SettingsView {
woo_create_status: string;
taxonomy_last_sync_at: string | null;
taxonomy_anchor: string | null;
+ /** #99: global crop fill %, default 90. Drives the ref line + the PNG crop. */
+ crop_fill: number;
}
export function getSettings(): Promise {
diff --git a/frontend/src/capture/TapePrototype.svelte b/frontend/src/capture/TapePrototype.svelte
new file mode 100644
index 0000000..b0fc521
--- /dev/null
+++ b/frontend/src/capture/TapePrototype.svelte
@@ -0,0 +1,611 @@
+
+
+
+ Length axis — tape line
+
+ The measure line is fixed . Rotate / pan / zoom the photo
+ until its taped edge sits on the line, then drag the two ticks along it to where your
+ tape started and ended. L = the distance between the ticks.
+
+
+
+
+
+ Drag each tick to where your tape started and ended.
+ Arrow keys rotate 1° (Shift 0.1°) · with a tick selected the arrows nudge it · wheel or pinch zooms at the cursor
+
+
+
+ Global crop fill
+ setCrop(Number(e.currentTarget.value))} />
+ {cropFill}%
+
+ Default 90% · single setting replaces both duplicated FILL=0.9 · moves the fixed line + re-crops future slabs (non-retro).
+
+
+
+ nudgePhoto(-1)}>−
+ setPhotoAngle(Number(e.currentTarget.value))} />
+ nudgePhoto(1)}>+
+ {Math.round(photoAngle % 360)}°
+ { panX = 0; panY = 0; zoom = 1; photoAngle = 0; recompute(); frame(); }}>reset
+
+
+
+
+
+ Declared length (between ticks)
+ {declaredLenIn.toFixed(1)}″
+
+
+ pickTick('A')}>
+ tick A {selectedTick === 'A' ? '· arrows nudge' : ''}
+
+ pickTick('B')}>
+ tick B {selectedTick === 'B' ? '· arrows nudge' : ''}
+
+
+
+
+
+
diff --git a/frontend/src/capture/capture.types.ts b/frontend/src/capture/capture.types.ts
index ed3e134..0877fff 100644
--- a/frontend/src/capture/capture.types.ts
+++ b/frontend/src/capture/capture.types.ts
@@ -43,7 +43,7 @@ export interface Mask {
slabPx: number; // 0 ⇒ approve blocked, user retakes
bitset: Uint8Array; // source-grid mask, 1 byte/px
bbox: BBox;
- slabBounds: ABox; // axis-aligned extent of slab px, for the 80%-fill PNG crop
+ slabBounds: ABox; // axis-aligned extent of slab px, for the PNG crop (settings.crop_fill, default 90%)
}
// Direction only: an oriented rectangle over the slab. The arrow reads
diff --git a/frontend/src/capture/session.ts b/frontend/src/capture/session.ts
index 3cf36b2..4d60990 100644
--- a/frontend/src/capture/session.ts
+++ b/frontend/src/capture/session.ts
@@ -10,6 +10,7 @@ import { measure } from '../measure/measure';
import { orientedRect } from '../geometry/rect';
import { encodeInventoryPng, type InventoryInput } from '../png/inventory';
import { encodeOverlayPng, type EncodeOverlayInput } from '../png/overlay';
+import { FILL_DEFAULT, fillFromSetting } from '../png/crop';
import { decodeOrientedBitmap } from '../decodeOrientedBitmap';
const DEBOUNCE_MS = 2000;
@@ -41,6 +42,8 @@ export interface CaptureSession {
setSheet(s: Sheet): void;
approveMask(): void;
setAxisAngle(deg: number): void;
+ /** #99: global crop fill from settings (percent or fraction); drives the PNG crop. */
+ setCropFill(pctOrFraction: number): void;
approveAxis(): void;
setLength(e: Eighths): void;
setThickness(e: Eighths): void;
@@ -83,6 +86,8 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
let busyTimer: ReturnType | null = null;
let pngUrl: string | null = null;
let resumed = false;
+ // #99: global crop fill (fraction). Set from settings; FILL_DEFAULT (90%) until told otherwise.
+ let cropFill = FILL_DEFAULT;
const set = (next: Capture) => {
state = next;
@@ -487,6 +492,11 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
void persist();
},
+ setCropFill(pctOrFraction: number) {
+ // #99: non-retro — only future encodes use this; stored PNGs are untouched.
+ cropFill = fillFromSetting(pctOrFraction);
+ },
+
approveAxis() {
if (state.k !== 'axis') return;
set({
@@ -523,7 +533,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
let pngBlob: Blob;
let belowFloor = false;
try {
- const r = await encodeInventory({ bitmap, mask: approvedMask, source });
+ const r = await encodeInventory({ bitmap, mask: approvedMask, source, fill: cropFill });
pngBlob = r.blob;
belowFloor = r.sourceBelowFloor;
} catch {
@@ -593,7 +603,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
const siblingBitmap = await decodeOrientedBitmap(photo.blob);
let encoded: { blob: Blob; sourceBelowFloor: boolean };
try {
- encoded = await encodeInventory({ bitmap: siblingBitmap, mask: derived.mask, source: siblingSource });
+ encoded = await encodeInventory({ bitmap: siblingBitmap, mask: derived.mask, source: siblingSource, fill: cropFill });
} finally {
siblingBitmap.close();
}
diff --git a/frontend/src/create/settings/Settings.svelte b/frontend/src/create/settings/Settings.svelte
index 2569ef9..a0fea13 100644
--- a/frontend/src/create/settings/Settings.svelte
+++ b/frontend/src/create/settings/Settings.svelte
@@ -17,6 +17,7 @@
brand_voice: string | null;
geo_context: string | null;
woo_create_status: string | null;
+ crop_fill: number;
}
interface PriceRule {
@@ -205,6 +206,7 @@
brand_voice: settings.brand_voice || '',
geo_context: settings.geo_context || '',
woo_create_status: settings.woo_create_status || 'draft',
+ crop_fill: settings.crop_fill ?? 90,
// secrets: prefill when unmasked (UAT), else blank (masked, write-only)
woo_app_password: secretValue(settings.woo_app_password),
inference_base_url: secretValue(settings.inference_base_url),
@@ -230,6 +232,7 @@
if (form.brand_voice !== (settings?.brand_voice || '')) payload.brand_voice = form.brand_voice || null;
if (form.geo_context !== (settings?.geo_context || '')) payload.geo_context = form.geo_context || null;
if (form.woo_create_status !== (settings?.woo_create_status || 'draft')) payload.woo_create_status = form.woo_create_status;
+ if (Number(form.crop_fill) !== (settings?.crop_fill ?? 90)) payload.crop_fill = Number(form.crop_fill);
// secrets only if provided (write-only)
if (form.woo_app_password) payload.woo_app_password = form.woo_app_password;
if (form.inference_base_url) payload.inference_base_url = form.inference_base_url;
@@ -351,6 +354,12 @@
+
+ Crop Fill % (default 90) — inventory PNG + reference line
+
+ {form.crop_fill}%
+
+
Save Changes
{#if message}{message}
{/if}
@@ -415,7 +424,10 @@