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} + ++ 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
+ + +Default 90% · single setting replaces both duplicated FILL=0.9 · moves the fixed line + re-crops future slabs (non-retro).
+ + +{message}
{/if} @@ -415,7 +424,10 @@