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

+ + +
+ + 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).

+ + +
+ + setPhotoAngle(Number(e.currentTarget.value))} /> + + {Math.round(photoAngle % 360)}° + +
+ + +
+
+ Declared length (between ticks) + {declaredLenIn.toFixed(1)}″ +
+
+ + +
+
+
+ + 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 @@ +
+ + + {form.crop_fill}% +
+ {#if message}

{message}

{/if} @@ -415,7 +424,10 @@