4 Commits

Author SHA1 Message Date
Ty 63b8879720 docs(design): add approved #99 tape-line overlay wireframe
Owner-approved design proposal for #99 (tape-line overlay replaces the axis
rectangle). Wireframe only - no production code.

Settles: the black ~2pt line is a fixed entity in the middle of the 3:4
rectangle; the slab alone pans/zooms/tilts so its long axis lands on the line;
the ticks belong to the line and L = tick to tick; crop % moves only the
overlay's ends ((1 - fill)/2 of the long axis in from the top and the bottom)
and never changes the aspect ratio.

The file header lists the wireframe scaffolding to scrub before this reaches the
production workflow (viewport switcher, coverage board, was/now pair, PROPOSAL
markers, in-stage tutorial labels, sample values, window.__wf debug hook).
2026-09-24 11:30:13 -07:00
Ty 0c00d25cc7 fix(m3): Option A must-fix #79 #82 #83 #84 #89 #98
- #79 publish SKU/create via WooClient (User-Agent + typed 403 unrecoverable)
- #82 stale_field current_options from live taxonomy + owner labels
- #83 create allowlist/extra-key safe; taxonomy+enum 4xx on create
- #84 health woo_reachable real probe (non-failing); version 0.5.0-m3
- #89 review persists assembled title/short after taxonomy lock; UI applies
- #98 live-edge from synced term; attrs resolved by slug then name
2026-09-21 12:55:17 -07:00
Ty 96a697a50a fix(review): restore lineup/woodPrimary imports after merge
Morning merge kept Review call sites (nextPrimary, keptInOrder, roleLabel, …)
and dropped the imports, so hydrate threw ReferenceError and the UI showed
"Could not load the slab." Live UAT redeployed; sample data preserved.
2026-09-21 12:36:24 -07:00
Ty 67aa3d6b0d fix(review): black screen on Capture→Review — speciesId/gradeId refs
The merge auto-kept the branch's canCreateTitle reactive (speciesId) which is
undeclared in the merged file (main's canonical var is speciesSel) → ReferenceError
on Review mount → black screen. Also dropped the branch's duplicate gradeId (main
uses gradeSel) and repointed hydrate/patch to gradeSel. Live UAT verified: Review
now mounts and renders 'Review to Ready' with photo strip + Create buttons.
2026-09-21 12:17:24 -07:00
10 changed files with 1089 additions and 112 deletions
+22 -18
View File
@@ -25,7 +25,7 @@ from starlette.middleware.base import BaseHTTPMiddleware
from .db import get_connection, init_db, probe_images_writable, probe_writable
from . import settings as settings_mod
__version__ = "0.5.0-m3-s1"
__version__ = "0.5.0-m3"
# --- Structured JSON logging (stdlib only) ---
request_id_var: ContextVar[str] = ContextVar("request_id", default="")
@@ -156,35 +156,39 @@ def _error(code: str, message: str, detail: Any = None) -> dict:
@app.get("/api/health")
async def health() -> dict[str, Any]:
"""Health per APP-30/45: non-200 when db or image volume not writable (probe file).
#84: woo_reachable is a real check (HEAD /wp-json/ with a short timeout),
not a hardcoded stub.
#84: woo_reachable is a real authenticated probe (signal only — store outage
does not fail health).
"""
db_ok = probe_writable()
img_ok = probe_images_writable()
if not (db_ok and img_ok):
logger.warning("health: storage not writable", extra={"request_id": request_id_var.get("")})
raise HTTPException(status_code=503, detail="storage not writable")
woo_reachable = False
try:
woo_base = os.environ.get("WOO_BASE_URL", "")
if woo_base:
import urllib.request
req = urllib.request.Request(
woo_base.rstrip("/") + "/wp-json/",
method="GET",
headers={"User-Agent": "SlabUploader/health"},
)
with urllib.request.urlopen(req, timeout=3) as resp:
woo_reachable = resp.status < 500
except Exception:
woo_reachable = False
return {
"status": "ok",
"version": __version__,
"woo_reachable": woo_reachable,
"woo_reachable": _probe_woo_reachable(),
}
def _probe_woo_reachable() -> bool:
"""Cheap non-failing Woo reachability probe for health (SETTINGS-40 style).
Storage health never depends on this result. Missing creds → False.
"""
try:
base, username, enc_password = _woo_credentials()
if not base or not username or not enc_password:
return False
client = WooClient.from_settings(base, username, enc_password)
# One lightweight authenticated call (categories page 1).
code, _h, _b = client._get("/products/categories?per_page=1&page=1")
return code == 200
except Exception:
return False
@app.get("/api/v1/meta")
async def meta() -> dict[str, Any]:
"""Per API.md §3."""
+42 -29
View File
@@ -16,7 +16,14 @@ from fastapi import APIRouter
from fastapi.responses import JSONResponse
from .db import get_connection
from .woo import WooAuthError, WooClient, WooConnectionError, WooCredentialsMissing, WooError
from .woo import (
WooAuthError,
WooClient,
WooConnectionError,
WooCredentialsMissing,
WooError,
WooForbiddenError,
)
from .review import compute_ready, _slab_dict
router = APIRouter(prefix="/api/v1/slabs")
@@ -72,21 +79,24 @@ def _build_payload(slab: dict, conn, status: str) -> dict:
tags = [t for t in (slab.get("fig_tag_ids") or [])]+[t for t in (slab.get("feat_tag_ids") or [])]
if tags:
payload["tags"] = [{"id": t} for t in tags]
# Five locked attributes by synced term name.
# Five locked attributes: resolve the attribute row from the synced cache by
# slug first (METADATA-5 / QUALITY-105 — never hardcode live Woo attribute ids),
# then fall back to case-insensitive name. Term *options* are always the
# names of the selected synced terms.
terms = {r["id"]: dict(r) for r in conn.execute("SELECT * FROM woo_attribute_terms").fetchall()}
attrs = {r["id"]: dict(r) for r in conn.execute("SELECT * FROM woo_attributes").fetchall()}
built_attrs = []
for field, attr_name, pos in (
("edge_type_term_id", "Edge Type", 0),
("figure_term_ids", "Figure", 1),
("grade_term_id", "Grade", 2),
("thickness_term_id", "Thickness", 3),
("moisture_term_id", "Moisture", 4),
for field, attr_slug, attr_name, pos in (
("edge_type_term_id", "edge-type", "Edge Type", 0),
("figure_term_ids", "pa_figure", "Figure", 1),
("grade_term_id", "pa_grade", "Grade", 2),
("thickness_term_id", "pa_thickness", "Thickness", 3),
("moisture_term_id", "pa_moisture", "Moisture", 4),
):
raw = slab.get(field)
term_ids = raw if isinstance(raw, list) else ([raw] if raw else [])
options = [terms[t]["name"] for t in term_ids if t in terms]
attr_id = _attr_id_by_name(attrs, attr_name)
attr_id = _attr_id_from_synced(attrs, attr_slug, attr_name)
if attr_id and options:
built_attrs.append({"id": attr_id, "position": pos, "visible": True, "variation": False, "options": options})
if built_attrs:
@@ -94,19 +104,35 @@ def _build_payload(slab: dict, conn, status: str) -> dict:
return payload
def _attr_id_by_name(attrs: dict, name: str) -> int | None:
def _attr_id_from_synced(attrs: dict, slug: str, name: str) -> int | None:
"""Match a locked attribute against the synced Woo set by slug, then name."""
slug_l = slug.lower().lstrip()
# Accept both "figure" and "pa_figure" style slugs from Woo.
slug_alts = {slug_l, slug_l.removeprefix("pa_"), f"pa_{slug_l.removeprefix('pa_')}"}
for a in attrs.values():
if a.get("name", "").lower() == name.lower():
s = (a.get("slug") or "").lower()
if s in slug_alts:
return a["id"]
name_l = name.lower()
for a in attrs.values():
if (a.get("name") or "").lower() == name_l:
return a["id"]
return None
def _classify(e: Exception) -> tuple[str, str, bool]:
"""Return (code, plain message, recoverable)."""
"""Return (code, plain message, recoverable). PUBLISH-36/37/38."""
if isinstance(e, WooCredentialsMissing):
return "woo_unconfigured", "Woo is not configured. Set the store credentials in Settings.", False
if isinstance(e, WooAuthError):
return "woo_auth", "The store rejected the credentials. Check the app password.", False
if isinstance(e, WooForbiddenError):
# WAF / security plugin block — retrying the same request will not help.
return (
"woo_forbidden",
"The store blocked this request (security filter). Check the store firewall or User-Agent allowlist — retrying will not help.",
False,
)
if isinstance(e, WooConnectionError):
return "woo_unreachable", "The store is unreachable right now. You can retry.", True
return "woo_failed", "The store create failed. You can retry.", True
@@ -209,26 +235,13 @@ def _create_status(conn) -> str:
def _sku_exists(client: WooClient, sku: str) -> bool:
code, _h, body = client._request(
f"{client.base_url}/wp-json/wc/v3/products?sku={sku}",
"GET", None,
{"Authorization": f"Basic {client._auth}", "Accept": "application/json"},
)
if code != 200:
raise WooError(f"sku lookup returned {code}")
return len(json.loads(body)) > 0
"""SKU lookup via WooClient so User-Agent and 403 typing apply (#79)."""
return len(client.get_products_by_sku(sku)) > 0
def _create_product(client: WooClient, payload: dict) -> int:
body = json.dumps(payload).encode("utf-8")
code, _h, resp = client._request(
f"{client.base_url}/wp-json/wc/v3/products",
"POST", body,
{"Authorization": f"Basic {client._auth}", "Content-Type": "application/json", "Accept": "application/json"},
)
if code not in (200, 201):
raise WooError(f"product create returned {code}")
return int(json.loads(resp)["id"])
"""Product create via WooClient so User-Agent and 403 typing apply (#79)."""
return client.create_product(payload)
def _log(conn, slab_id: str, woo_product_id: int | None, status: str, message: str) -> None:
+38 -11
View File
@@ -71,14 +71,16 @@ def _dims(length_in: float, width_avg_in: float, thickness_in: float) -> str:
return f"{_fmt1(length_in)} x {_fmt1(width_avg_in)} x {_fmt2(thickness_in)} in"
def build_title(slab: dict, figure: str | None, species_name: str, primary_leaf: dict) -> str:
def build_title(slab: dict, figure: str | None, species_name: str, primary_leaf: dict, terms: dict | None = None) -> str:
pattern, noun, carries_dims = _LEAF_PATTERNS.get(
primary_leaf.get("slug"), ("{figure}{species} Slab", "Slab", False)
)
figure = figure.strip() if figure else ""
live_edge = ""
if primary_leaf["slug"] in ("dimensional-wood", "bowl-blanks"):
live_edge = "Live Edge " if slab.get("edge_type_term_id") else ""
if primary_leaf.get("slug") in ("dimensional-wood", "bowl-blanks"):
# "Live Edge" is a product-form flag driven by the *synced* edge term
# (METADATA-5 / #98) — never treat any non-null edge id as Live Edge.
live_edge = "Live Edge " if _edge_is_live(slab.get("edge_type_term_id"), terms or {}) else ""
title = pattern.format(
figure=figure + " " if figure else "",
species=species_name,
@@ -89,8 +91,17 @@ def build_title(slab: dict, figure: str | None, species_name: str, primary_leaf:
return re.sub(r"\s+", " ", title).strip()
def build_short_description(slab: dict, figure: str | None, species_name: str, primary_leaf: dict) -> str:
title = build_title(slab, figure, species_name, primary_leaf)
def _edge_is_live(edge_term_id: Any, terms: dict) -> bool:
"""True when the synced edge term name/slug indicates live edge."""
if not edge_term_id:
return False
term = terms.get(edge_term_id) or {}
blob = f"{term.get('name', '')} {term.get('slug', '')}".lower()
return "live" in blob
def build_short_description(slab: dict, figure: str | None, species_name: str, primary_leaf: dict, terms: dict | None = None) -> str:
title = build_title(slab, figure, species_name, primary_leaf, terms)
ft, rem = _split_length(slab["length_in"])
length_str = f"{ft}ft {_fmt1(rem)}in"
bdft = round(slab["bdft"], 1)
@@ -147,6 +158,13 @@ def price_suggestion(conn, slab: dict) -> float | None:
@router.post("/{slab_id}/review")
async def review_slab(slab_id: str, payload: dict[str, Any] | None = None) -> dict:
"""Compute ready + deterministic title/short_description from *locked* taxonomy.
METADATA-80 / REVIEW-10 / #89: title and short_description are assembled only
from the confirmed (persisted) listing facts. Owner-supplied title/short_desc
win (METADATA-50). Assembled values are written back to the slab so the lock
sticks for publish.
"""
payload = payload or {}
conn = get_connection()
row = conn.execute(
@@ -157,9 +175,9 @@ async def review_slab(slab_id: str, payload: dict[str, Any] | None = None) -> di
slab = _slab_dict(row)
# --- Deterministic review: report ready/missing + the deterministic
# title/short_description built from the current locked metadata. The client's
# title/short_description built from the current metadata. The client's
# "Create title" / "Create short desc" buttons call this endpoint and write the
# returned strings into the fields (item 1: no lock action, no metadata_locked).
# returned strings into the fields (#89: no separate lock action).
# #80: persist owner overrides (price/sku) from the payload so the review
# endpoint is self-contained. title/short_description are NOT overridden here —
# they are produced by the deterministic build below, not persisted from the
@@ -169,8 +187,8 @@ async def review_slab(slab_id: str, payload: dict[str, Any] | None = None) -> di
wood_cats = slab.get("wood_category_ids") or []
if not primary_id:
primary_id = wood_cats[0] if wood_cats else None
primary_leaf = cats.get(primary_id, {})
species_name = cats.get(slab.get("species_id"), {}).get("name", "")
primary_leaf = cats.get(primary_id, {}) if primary_id else {}
species_name = cats.get(slab.get("species_id"), {}).get("name", "") if slab.get("species_id") else ""
figure_term_ids = slab.get("figure_term_ids") or []
figure = terms.get(figure_term_ids[0], {}).get("name") if figure_term_ids else None
@@ -190,8 +208,17 @@ async def review_slab(slab_id: str, payload: dict[str, Any] | None = None) -> di
# Deterministic title / short_description, re-derived from the current
# metadata on every review (the Create button's source of truth).
title = build_title(slab, figure, species_name, primary_leaf) if (primary_leaf and species_name) else slab.get("title") or None
short_desc = build_short_description(slab, figure, species_name, primary_leaf) if (primary_leaf and species_name) else slab.get("short_description") or None
# #98: pass synced terms so Live Edge comes from the edge term, not any id.
title = (
build_title(slab, figure, species_name, primary_leaf, terms)
if (primary_leaf and species_name)
else slab.get("title") or None
)
short_desc = (
build_short_description(slab, figure, species_name, primary_leaf, terms)
if (primary_leaf and species_name)
else slab.get("short_description") or None
)
price = _resolve_price(conn, slab, payload)
ready, missing = compute_ready({**slab, "price": price, "title": title, "short_description": short_desc})
+90 -26
View File
@@ -43,6 +43,20 @@ TAXONOMY_TABLES = {
"fig_tag_ids": "woo_tags",
"feat_tag_ids": "woo_tags",
}
# Owner-facing labels for stale_field messages (QUALITY-120 — never raw column names).
FIELD_LABELS = {
"species_id": "Species",
"wood_category_ids": "Wood category",
"edge_type_term_id": "Edge type",
"figure_term_ids": "Figure",
"grade_term_id": "Grade",
"thickness_term_id": "Thickness",
"moisture_term_id": "Moisture",
"fig_tag_ids": "Figure tags",
"feat_tag_ids": "Feature tags",
}
PRICE_SOURCES = {"recommendation", "override", "manual"}
INFERENCE_STATUSES = {"idle", "inferring", "done", "failed", "skipped"}
def _now_iso() -> str:
@@ -102,6 +116,58 @@ def _check_taxonomy(conn, table: str, val: Any) -> bool:
return conn.execute(f"SELECT 1 FROM {table} WHERE id=?", (val,)).fetchone() is not None
def _current_options(conn, table: str) -> list[dict[str, Any]]:
"""Live taxonomy options for a stale_field response (PUBLISH-20 / #82)."""
rows = conn.execute(f"SELECT id, name FROM {table} ORDER BY name, id").fetchall()
return [{"id": r["id"], "name": r["name"]} for r in rows]
def _normalize_tax_val(val: Any) -> Any:
if isinstance(val, str):
try:
return json.loads(val)
except (ValueError, TypeError):
return []
return val
def _validate_taxonomy_fields(conn, payload: Dict[str, Any]) -> JSONResponse | None:
"""Return a 422 stale_field response, or None if all present taxonomy ids are live."""
for field, table in TAXONOMY_TABLES.items():
if field not in payload or payload[field] is None:
continue
val = _normalize_tax_val(payload[field])
if not _check_taxonomy(conn, table, val):
label = FIELD_LABELS.get(field, field)
return _err(
"stale_field",
f"{label} is no longer in the store taxonomy",
{"field": field, "current_options": _current_options(conn, table)},
status=422,
)
return None
def _validate_enums(payload: Dict[str, Any]) -> JSONResponse | None:
if "price_source" in payload and payload["price_source"] is not None:
if payload["price_source"] not in PRICE_SOURCES:
return _err(
"validation",
"price_source is not a known value",
{"field": "price_source", "allowed": sorted(PRICE_SOURCES)},
status=400,
)
if "inference_status" in payload and payload["inference_status"] is not None:
if payload["inference_status"] not in INFERENCE_STATUSES:
return _err(
"validation",
"inference_status is not a known value",
{"field": "inference_status", "allowed": sorted(INFERENCE_STATUSES)},
status=400,
)
return None
# Capture-only fields required to create a slab. The Review screen fills the
# taxonomy/content fields after create (CREATE.md §1: "every other field stays
# editable until create"), so create must accept a capture-math payload.
@@ -127,11 +193,17 @@ async def create_slab(payload: Dict[str, Any]) -> Dict[str, Any]:
# Business rule (middle tier, not the DB): an empty/blank SKU at create means
# "not set yet" (the owner fills it in Review). Store NULL — the DB CHECK only
# governs present SKUs, and NULL is the honest "absent" representation.
sku = payload["sku"]
sku = payload.get("sku")
if isinstance(sku, str) and sku.strip() == "":
payload = {**payload, "sku": None}
enum_err = _validate_enums(payload)
if enum_err is not None:
return enum_err
conn = get_connection()
if payload["sku"] is not None and _sku_taken(conn, payload["sku"]):
tax_err = _validate_taxonomy_fields(conn, payload)
if tax_err is not None:
return tax_err
if payload.get("sku") is not None and _sku_taken(conn, payload["sku"]):
return _err("duplicate_sku", "SKU already exists", {"sku": payload["sku"]}, status=409)
# METADATA-15 / QUALITY-95: the thickness band is derived, never owner-chosen.
# Resolve it from the measured thickness against the live synced Woo bands and
@@ -148,8 +220,13 @@ async def create_slab(payload: Dict[str, Any]) -> Dict[str, Any]:
# One value per col, in cols order: data values for the allowed fields, then the 6 server values.
vals = [data[f] for f in allowed_cols] + server_vals
ph = ",".join(["?"] * len(cols))
conn.execute(f"INSERT INTO slabs ({', '.join(cols)}) VALUES ({ph})", vals)
conn.commit()
assert len(vals) == len(cols), f"create_slab binding mismatch: {len(vals)} vals vs {len(cols)} cols"
try:
conn.execute(f"INSERT INTO slabs ({', '.join(cols)}) VALUES ({ph})", vals)
conn.commit()
except Exception:
# QUALITY-120: never surface a driver stack to the owner.
return _err("validation", "Could not create the slab. Check the fields and try again.", status=400)
row = conn.execute("SELECT * FROM slabs WHERE id=?", (payload["id"],)).fetchone()
return _row_to_slab(row)
@@ -174,11 +251,13 @@ async def get_slab(id: str) -> Dict[str, Any]:
async def update_slab(id: str, payload: Dict[str, Any]) -> Dict[str, Any]:
if "client_rev" not in payload:
return _err("validation", "client_rev required", status=400)
# #89: the metadata lock is server-managed via the explicit review lock
# action; a plain PUT may not flip it.
# #89: server-managed fields a plain PUT must not set.
for field in CLIENT_FORBIDDEN_FIELDS:
if field in payload:
return _err("validation", f"{field} is set by the review lock action, not by update", status=400)
enum_err = _validate_enums(payload)
if enum_err is not None:
return enum_err
conn = get_connection()
current = conn.execute("SELECT * FROM slabs WHERE id=? AND deleted_at IS NULL", (id,)).fetchone()
if current is None:
@@ -188,27 +267,12 @@ async def update_slab(id: str, payload: Dict[str, Any]) -> Dict[str, Any]:
return _err("revision_conflict", "client_rev mismatch",
{"current_server_rev": current["server_rev"]}, status=409)
if "sku" in payload and payload["sku"] != current["sku"]:
if _sku_taken(conn, payload["sku"], exclude_id=id):
if payload["sku"] is not None and _sku_taken(conn, payload["sku"], exclude_id=id):
return _err("duplicate_sku", "SKU already exists", {"sku": payload["sku"]}, status=409)
for field, table in TAXONOMY_TABLES.items():
if field in payload and payload[field] is not None:
val = payload[field]
if isinstance(val, str):
try:
val = json.loads(val)
except (ValueError, TypeError):
val = []
if not _check_taxonomy(conn, table, val):
# #82: return the LIVE taxonomy options (valid ids in the table),
# not the submitted value. The client needs to know what's valid.
if isinstance(val, list):
live = [r[0] for r in conn.execute(
f"SELECT id FROM {table} ORDER BY id"
).fetchall()]
else:
live = [val] # single-id field: just show what was submitted
return _err("stale_field", f"{field} not in current taxonomy",
{"field": field, "submitted": val, "current_options": live}, status=422)
tax_err = _validate_taxonomy_fields(conn, payload)
if tax_err is not None:
return tax_err
# METADATA-15 / QUALITY-95: thickness band is derived, never owner-chosen.
eff_thickness = payload.get("thickness_in", current["thickness_in"])
band = resolve_thickness_band_id(eff_thickness, load_thickness_terms(conn))
if band is not None:
+60
View File
@@ -300,3 +300,63 @@ def test_partial_media_failure_502(env, monkeypatch):
# sync_log written, status failed
assert conn.execute("SELECT status FROM slabs WHERE id='s1'").fetchone()["status"] == "failed"
assert conn.execute("SELECT COUNT(*) c FROM sync_log WHERE status='failed'").fetchone()["c"] >= 1
def test_publish_sends_user_agent_on_sku_and_create(env, monkeypatch):
"""#79: every publish Woo hop carries User-Agent (same transport as client)."""
client, conn = env
s = _full_slab()
_insert_slab(conn, s)
conn.commit()
seen = []
def fake_request(url, method, data, headers):
seen.append({"url": url, "method": method, "headers": dict(headers or {})})
if "/products?sku=" in url:
return 200, {}, b"[]"
if url.endswith("/wp-json/wc/v3/products") and method == "POST":
return 201, {}, json.dumps({"id": 42}).encode()
return 200, {}, b"[]"
from backend.woo import WooClient, _USER_AGENT
orig_init = WooClient.__init__
def patched_init(self, *a, **k):
orig_init(self, *a, **k)
self._request = fake_request
monkeypatch.setattr(WooClient, "__init__", patched_init)
r = client.post("/api/v1/slabs/s1/publish")
assert r.status_code == 200, r.text
sku_hops = [h for h in seen if "/products?sku=" in h["url"]]
create_hops = [h for h in seen if h["url"].endswith("/wp-json/wc/v3/products") and h["method"] == "POST"]
assert sku_hops and create_hops
for hop in sku_hops + create_hops:
assert hop["headers"].get("User-Agent") == _USER_AGENT
def test_publish_403_is_unrecoverable(env, monkeypatch):
"""#79: WAF 403 on SKU lookup is unrecoverable — do not invite retry."""
client, conn = env
s = _full_slab()
_insert_slab(conn, s)
conn.commit()
from backend.woo import WooClient, WooForbiddenError
def fake_request(url, method, data, headers):
raise WooForbiddenError("request forbidden (403)")
orig_init = WooClient.__init__
def patched_init(self, *a, **k):
orig_init(self, *a, **k)
self._request = fake_request
monkeypatch.setattr(WooClient, "__init__", patched_init)
r = client.post("/api/v1/slabs/s1/publish")
assert r.status_code == 502
body = r.json()
assert body["error"]["code"] == "woo_forbidden"
assert body["error"]["detail"]["recoverable"] is False
assert "retry" not in body["error"]["message"].lower() or "will not help" in body["error"]["message"].lower()
+38 -11
View File
@@ -340,18 +340,45 @@ def test_create_rejects_unknown_fields(client, db):
def test_update_stale_field_returns_live_options(client, db):
# #82: stale_field error returns the LIVE taxonomy ids, not the submitted value.
# #82: stale_field returns LIVE taxonomy options (id+name), not the submitted id.
slab_id = str(uuid.uuid4())
payload = {"id": slab_id, "sku": "SKU001", "length_in":10,"thickness_in":2,"sqft":20,"bdft":5,"width_min_in":1,"width_max_in":2,"width_avg_in":1.5,"species_id":1,"wood_category_ids":json.dumps([2]),"edge_type_term_id":1,"figure_term_ids":json.dumps([1]),"grade_term_id":1,"thickness_term_id":1,"moisture_term_id":1,"fig_tag_ids":json.dumps([1]),"feat_tag_ids":json.dumps([2]),"price":100,"price_source":"recommendation","title":"Test","short_description":"short","description":"desc","content_source":"manual"}
client.post("/api/v1/slabs", json=payload)
# Submit a wood_category_id that doesn't exist in the taxonomy (id=999).
resp = client.put(f"/api/v1/slabs/{slab_id}", json={"client_rev": 1, "wood_category_ids": json.dumps([999])})
payload = {
"id": slab_id, "sku": "SKU-STALE", "length_in": 10, "thickness_in": 2, "sqft": 20, "bdft": 5,
"width_min_in": 1, "width_max_in": 2, "width_avg_in": 1.5,
}
assert client.post("/api/v1/slabs", json=payload).status_code == 201
resp = client.put(f"/api/v1/slabs/{slab_id}", json={"client_rev": 1, "edge_type_term_id": 999})
assert resp.status_code == 422
body = resp.json()
assert body["error"]["code"] == "stale_field"
detail = body["error"]["detail"]
assert detail["submitted"] == [999]
# current_options must be the LIVE ids from woo_categories, not [999].
assert 999 not in detail["current_options"]
# The seeded taxonomy has ids 1 and 2 in woo_categories.
assert set(detail["current_options"]) == {1, 2}
opts = body["error"]["detail"]["current_options"]
assert isinstance(opts, list) and opts
assert all(isinstance(o, dict) and "id" in o and "name" in o for o in opts)
assert 999 not in [o["id"] for o in opts]
assert "Edge type" in body["error"]["message"] or "edge" in body["error"]["message"].lower()
def test_create_extra_key_and_bad_taxonomy_are_4xx(client, db):
"""#83: server-managed/extra keys and bad taxonomy/enums are 4xx, not 500."""
base = {
"id": str(uuid.uuid4()), "sku": "SKU-XTRA", "length_in": 10, "thickness_in": 2, "sqft": 20, "bdft": 5,
"width_min_in": 1, "width_max_in": 2, "width_avg_in": 1.5,
"status": "published", # server-managed → unknown field 400
}
r = client.post("/api/v1/slabs", json=base)
assert r.status_code == 400, r.text
assert "status" in r.json()["error"]["detail"]["fields"]
# Bad taxonomy on create
r2 = client.post("/api/v1/slabs", json={
"id": str(uuid.uuid4()), "sku": "SKU-BADTAX", "length_in": 10, "thickness_in": 2, "sqft": 20, "bdft": 5,
"width_min_in": 1, "width_max_in": 2, "width_avg_in": 1.5, "species_id": 999,
})
assert r2.status_code == 422
assert r2.json()["error"]["code"] == "stale_field"
# Bad enum
r3 = client.post("/api/v1/slabs", json={
"id": str(uuid.uuid4()), "sku": "SKU-BADENUM", "length_in": 10, "thickness_in": 2, "sqft": 20, "bdft": 5,
"width_min_in": 1, "width_max_in": 2, "width_avg_in": 1.5, "price_source": "not-a-source",
})
assert r3.status_code == 400
assert r3.json()["error"]["code"] == "validation"
+38 -13
View File
@@ -98,14 +98,7 @@ class WooClient:
def _get(self, path: str) -> tuple[int, dict, bytes]:
url = f"{self.base_url}/wp-json/wc/v3{path}"
return self._request(
url, "GET", None,
{
"Authorization": f"Basic {self._auth}",
"Accept": "application/json",
"User-Agent": _USER_AGENT,
},
)
return self._request(url, "GET", None, self._auth_headers())
def _fetch_all(self, path: str) -> list[dict[str, Any]]:
page = 1
@@ -132,6 +125,42 @@ class WooClient:
def get_attribute_terms(self, attr_id: int) -> list[dict[str, Any]]:
return self._fetch_all(f"/products/attributes/{attr_id}/terms")
def _auth_headers(self, extra: dict[str, str] | None = None) -> dict[str, str]:
"""Shared headers for every outbound Woo/WP request (User-Agent + auth)."""
h = {
"Authorization": f"Basic {self._auth}",
"Accept": "application/json",
"User-Agent": _USER_AGENT,
}
if extra:
h.update(extra)
return h
def get_products_by_sku(self, sku: str) -> list[dict[str, Any]]:
"""GET products filtered by SKU. Raises typed Woo* errors on non-200."""
code, _h, body = self._request(
f"{self.base_url}/wp-json/wc/v3/products?sku={sku}",
"GET",
None,
self._auth_headers(),
)
if code != 200:
raise WooServerError(f"sku lookup returned {code}")
return list(json.loads(body))
def create_product(self, payload: dict[str, Any]) -> int:
"""POST /products. Raises typed Woo* errors on failure; returns product id."""
body = json.dumps(payload).encode("utf-8")
code, _h, resp = self._request(
f"{self.base_url}/wp-json/wc/v3/products",
"POST",
body,
self._auth_headers({"Content-Type": "application/json"}),
)
if code not in (200, 201):
raise WooServerError(f"product create returned {code}")
return int(json.loads(resp)["id"])
def upload_media(self, png_bytes: bytes, filename: str) -> int:
"""POST a PNG to WordPress core media; return the attachment id."""
boundary = "----slabuploader-media-boundary"
@@ -143,11 +172,7 @@ class WooClient:
url = f"{self.base_url}/wp-json/wp/v2/media"
code, _headers, resp = self._request(
url, "POST", body,
{
"Authorization": f"Basic {self._auth}",
"Content-Type": f"multipart/form-data; boundary={boundary}",
"User-Agent": _USER_AGENT,
},
self._auth_headers({"Content-Type": f"multipart/form-data; boundary={boundary}"}),
)
if code not in (200, 201):
raise WooServerError(f"media upload returned {code}")
+40
View File
@@ -77,3 +77,43 @@ Do not put a host name in the product spec.
- Not exercised live: the full browser Create-button click (verified via in-repo Playwright 6/6
m3-create + review-photo-strip; the endpoint + PUT path are proven live above). Woo publish path
not exercised this round.
### 2026-09-21 12:10 PDT — redeploy: `main` after merge `8ee3da9` (reconciled PR #97 + branch)
- Source: `main` @ `8ee3da9` (merge of `work/merge-89-90` into main, which itself reconciled
main's PR #97 client-side `thicknessBand.ts` + `syncSelects`/`box-sizing` against the branch's
server-side `thickness_band.py` + Create-string #89). `matches HEAD`: **yes**.
- Resolution outcome in the deployed bundle: band is **server-side** — `Review.svelte` no longer
imports `thicknessBand` and the read-only band display is gone; PR #97's `syncSelects()` (3× in
source) + `box-sizing` CSS preserved; `metadata_locked` still dropped; Create-string + photo
strip + wood primary live.
- Rebuilt both images from `main`; recreated the stack (`down --remove-orphans` + `up -d`, no `-v`).
LAN binds 10881/10882 intact.
- Bundle: `assets/index-BScrgd8o.js` (new; differs from pre-merge `YPAqx609`; contains
`create-title`, **zero** `thicknessBand` references). API `/api/health` `woo_reachable:true`.
- Sample data preserved: **yes** — 35 slabs (one added upstream since last deploy).
- Live verification (LAN URL, real DB): `POST /review` on slab `ec4088da…` →
`title:'Curl Black Walnut Slab'`, `ready:True`.
- Gates at merge time: backend 117/117, unit 88/88, tsc clean, e2e 6/6 (m3-create + photo-strip).
- Branches `work/merge-89-90` and `issue-89-metadata-lock` deleted (local + remote); remote has
only `main`.
### 2026-09-21 12:34 PDT — Review mount: restore dropped lineup/woodPrimary imports
- Source: uncommitted working tree on `main` (parent `67aa3d6`). `matches HEAD`: **no**.
Diff: `frontend/src/create/review/Review.svelte` +5 lines restoring imports of
`keptInOrder`/`moveItem`/`missingMinimums`/`roleLabel`/`resolvePhotoUrl`/`LineupItem`
from `lineup.ts` and `nextPrimary` from `woodPrimary.ts`. The morning merge kept the
call sites and dropped the imports → `ReferenceError` on Review mount → "Could not load the slab."
- Rebuilt `deploy-frontend` only (`compose build frontend` + `up -d frontend`, no `-v`).
Image `56b35f6cdcdf`. FastAPI container left running.
- URL: http://192.168.1.202:10881 (LAN) · loopback 127.0.0.1:18082
- Bundle: `assets/index-BVCyM130.js` (replaced `index-ck41xD02.js`). Served JS has **0**
`nextPrimary`/`keptInOrder` (minified locals) vs 3 unresolved `nextPrimary` in the broken bundle.
- Sample data preserved: **yes** — 47 slabs before rebuild; 48 after live Capture→Review probe
(created `ef1f4101-b1d2-459d-b9a0-842e7650ea3c`, sku NULL, status calibrated, 48×2 in; not published).
- Live verification (loopback, real API): Continue to Review → `review-screen` visible,
heading "Review to Ready", no loadError, no pageerror. POST /slabs 201, photos 201×2,
GET slab/taxonomy/settings 200, review 200.
- In-repo mock e2e (m3-create + photo-strip) 6/6 green against the same bundle hash before deploy.
- Not done: commit/push of the import restore; Woo publish path not re-exercised this round.
+713
View File
@@ -0,0 +1,713 @@
<!doctype html>
<!--
Design artifact for SlabUploader issue #99 — "Tape-line overlay replaces axis
rectangle (calibration length + crop)".
Owner-approved design proposal. This is a WIREFRAME, not shippable code.
What it settles: the black ~2pt line is a FIXED entity in the MIDDLE of the
3:4 rectangle; the SLAB alone pans / zooms / tilts so its long axis lands on
the line; the ticks belong to the line, slide along it, and L = tick to tick;
the crop % moves only the overlay's two ends — (1 - fill)/2 of the long axis
in from the top and the bottom — and never changes the aspect ratio.
SCRUB BEFORE SHIPPING — wireframe scaffolding, none of this belongs in the
production workflow:
- the viewport switcher (412 / 390 / 720 / 1440) and the flowbar step nav
- the requirements-coverage board below the stage
- the "was / now" pair of mini diagrams
- the PROPOSAL / OPEN markers and the implementation-notes card
- in-stage tutorial labels ("crop 90% — overlay 5.0% in from the top", …)
- the alignment read-out box (a proposal, not in the issue; nothing gated on it)
- the synthetic slab drawing + grain, and the sample values (88.0" tape, 2.125 thickness)
- the window.__wf debug hook
What stays: the fixed middle line + its two end ticks, the slab transform set
(pan / zoom / tilt) with the visible zoom row and Fit, the tick drag + nudge
semantics, and the single shared crop_fill.
-->
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>SlabUploader — tape-line overlay wireframe (issue #99)</title>
<style>
:root{
--bg:#121212; --card:#1c1c1c; --card2:#1d1d1d; --line:#333;
--fg:#eee; --mut:#aaa; --acc:#00e5ff; --acc2:#002;
--ok:#7fd37f; --warn:#ffb4a2; --r:3px;
}
*{box-sizing:border-box}
html,body{margin:0;background:var(--bg);color:var(--fg);font-family:system-ui,-apple-system,"Segoe UI",sans-serif;font-size:16px}
.top{max-width:900px;margin:0 auto;padding:14px 12px 4px}
.top h1{font-size:1rem;margin:0 0 8px;color:var(--acc);letter-spacing:.04em;text-transform:uppercase}
.flowbar{display:flex;flex-wrap:wrap;gap:6px}
.fstep{font-size:.72rem;padding:5px 8px;border-radius:var(--r);border:1px solid var(--line);background:var(--card2);color:var(--mut);cursor:pointer}
.fstep.on{background:var(--acc);color:var(--acc2);font-weight:700;border-color:var(--acc)}
.vp{display:flex;align-items:center;gap:8px;margin-top:10px;font-size:.72rem;color:var(--mut)}
.vp button{border:1px solid var(--line);background:var(--card2);color:var(--fg);font-size:.72rem;padding:4px 8px;border-radius:var(--r);cursor:pointer}
.vp button.on{background:var(--acc);color:var(--acc2);font-weight:700}
.stage{display:flex;justify-content:center;padding:18px 10px 40px}
#phone{width:412px;max-width:100%;background:#0c0c0c;border:1px solid #2a2a2a;border-radius:18px;padding:14px;min-height:640px}
.panel{padding:10px 4px;color:var(--fg)}
h1{font-size:1.3rem;margin:0 0 10px}
h3{font-size:.95rem;margin:0 0 8px}
.hint,.sub{color:var(--mut);font-size:.82rem;line-height:1.5;margin:0 0 12px}
.row{display:flex;gap:10px;margin-top:16px}
.primary{flex:1;padding:9px;font-size:.95rem;border-radius:var(--r);border:0;background:var(--acc);color:var(--acc2);font-weight:700;cursor:pointer;align-self:flex-start}
.ghost{padding:9px 18px;border-radius:var(--r);border:1px solid #444;background:transparent;color:#ccc;cursor:pointer}
.nudge{color:#e8a;font-size:.74rem;margin:6px 0 0}
/* ---- the stage: fixed line + movable slab ---- */
.tw{position:relative;width:100%;aspect-ratio:3/4;background:#111;border:1px solid var(--line);border-radius:var(--r);overflow:hidden}
.tw svg{display:block;width:100%;height:100%;touch-action:none;cursor:grab;user-select:none}
.tw.dragging svg{cursor:grabbing}
.tickg{cursor:ns-resize}
.rotg{cursor:ew-resize}
.controls{margin-top:16px;display:flex;flex-direction:column;gap:14px}
.angle,.croprow{display:flex;align-items:center;gap:10px}
.thumbbtn{width:44px;height:44px;border-radius:50%;font-size:1.4rem;border:1px solid #444;background:var(--card2);color:var(--fg);flex:none;cursor:pointer}
input[type=range]{flex:1}
.val{min-width:4ch;text-align:right;font-variant-numeric:tabular-nums}
.ctl-label{font-size:.78rem;color:var(--mut);margin:0 0 6px}
.nums{display:flex;flex-direction:column;gap:8px;margin-top:8px}
.nums div{display:flex;justify-content:space-between;border-bottom:1px solid #2a2a2a;padding-bottom:6px;font-size:.84rem;gap:10px}
.nums span:first-child{color:var(--mut)}
.nums .tab{font-variant-numeric:tabular-nums;text-align:right}
.nums .dim{color:#777}
.status{margin-top:12px;padding:10px 12px;border-radius:var(--r);font-size:.88rem;font-weight:600;background:#2a1512;color:var(--warn)}
.status.ok{background:#12210f;color:var(--ok)}
.plan{margin-top:18px;padding:10px 12px;border:1px solid var(--line);border-radius:var(--r);background:var(--card)}
.plan h3{font-size:.76rem;text-transform:uppercase;letter-spacing:.04em;color:var(--acc);margin:0 0 8px}
.plan ul{margin:0;padding-left:16px;color:var(--mut);font-size:.76rem;line-height:1.65}
.plan code{color:#ddd;font-size:.72rem}
.proposal{display:inline-block;font-size:.6rem;font-weight:800;letter-spacing:.03em;background:#3a2a00;color:#ffc96b;border-radius:2px;padding:0 4px;margin-left:5px;vertical-align:1px}
/* replacement two-up */
.swap{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:14px}
.swap figure{margin:0;background:var(--card2);border:1px solid var(--line);border-radius:var(--r);padding:6px;text-align:center}
.swap svg{width:100%;display:block;background:#0f0f0f;border-radius:2px}
.swap figcaption{font-size:.68rem;color:var(--mut);margin-top:6px;line-height:1.4}
.swap .gone{color:#ff9d8a}
/* scope lineup */
.lineup{list-style:none;padding:0;margin:12px 0 0;display:grid;grid-template-columns:repeat(3,1fr);gap:8px}
.lineup-item{text-align:center;background:var(--card2);border:1px solid var(--line);border-radius:var(--r);padding:6px}
.lineup-item svg{width:100%;display:block;border-radius:2px;background:#0f0f0f}
.role{display:block;margin-top:6px;font-size:.72rem;color:var(--fg);font-weight:600}
.rot-badge{display:inline-block;margin-top:3px;font-size:.6rem;padding:0 4px;border-radius:2px;font-weight:700}
.rot-badge.yes{background:var(--acc);color:var(--acc2)}
.rot-badge.no{background:#3a3a3a;color:#bbb}
/* fields + settings */
.fields{display:flex;gap:16px;margin-top:16px}
.field{flex:1}
.label{display:block;font-size:.85rem;color:var(--mut);margin-bottom:8px}
.inp{box-sizing:border-box;width:100%;min-height:44px;padding:10px;font-size:.95rem;border-radius:var(--r);border:1px solid #444;background:var(--card2);color:var(--fg);font-variant-numeric:tabular-nums}
.settings label{display:block;margin:10px 0 4px;font-size:.85rem;color:var(--mut)}
.settings .section{margin-top:20px;padding-top:14px;border-top:1px solid var(--line)}
.settings button{padding:8px 12px;font-size:.85rem;border-radius:var(--r);border:1px solid #444;background:var(--card2);color:#ddd;cursor:pointer;margin-top:10px}
.settings .msg{font-size:.8rem;margin:8px 0 0;color:var(--mut)}
.settings .msg.ok{color:var(--ok)}
/* requirement coverage board (outside the phone) */
.cov{max-width:900px;margin:0 auto 40px;padding:0 12px}
.cov-inner{border:1px solid var(--line);border-radius:var(--r);background:var(--card);padding:12px 14px}
.cov h2{font-size:.86rem;text-transform:uppercase;letter-spacing:.04em;color:var(--acc);margin:0 0 10px}
.cov table{width:100%;border-collapse:collapse;font-size:.76rem}
.cov th,.cov td{text-align:left;padding:6px 8px;border-bottom:1px solid #2a2a2a;vertical-align:top;color:var(--mut)}
.cov th{color:var(--fg);font-size:.72rem;text-transform:uppercase;letter-spacing:.03em}
.cov td:first-child{color:var(--fg);white-space:nowrap}
.cov .where{color:#ddd}
/* narrow screens: stack the table instead of forcing its 400px min width */
@media (max-width:760px){
.cov thead{display:none}
.cov table,.cov tbody,.cov tr,.cov td{display:block;width:auto}
.cov tr{border-bottom:1px solid #2a2a2a;padding:8px 0}
.cov tr:last-child{border-bottom:0}
.cov td{border:0;padding:1px 0}
.cov td:first-child{white-space:normal;color:var(--acc);font-weight:700}
.cov td:nth-child(3)::before{content:"→ ";color:#666}
}
.hidden{display:none}
</style>
</head>
<body>
<div class="top">
<h1>SlabUploader · tape-line overlay — issue #99</h1>
<div class="flowbar" id="flowbar"></div>
<div class="vp">
<span>Viewport</span>
<span id="vpval">412</span>
<button data-w="390">390</button>
<button data-w="412" class="on">412</button>
<button data-w="720">720</button>
<button data-w="1440">1440</button>
</div>
</div>
<div class="stage"><div id="phone"></div></div>
<div class="cov"><div class="cov-inner">
<h2>Issue #99 — requirement coverage in this wireframe</h2>
<table>
<thead><tr><th>Req</th><th>What it says</th><th>Where it shows up</th></tr></thead>
<tbody>
<tr><td>1 Overlay</td><td>Black ~2pt line + two rifle-scope end ticks; ticks draggable; replaces the cyan bbox + red arrow </td><td class="where">Tape line step: the black line, its two end ticks, the "was / now" pair below the stage, and the hint copy</td></tr>
<tr><td>2 Controls</td><td>Pan / zoom / tilt the photo; ticks ≥44×44px, draggable, arrow + Shift+arrow fine nudge; one pointer path</td><td class="where">Zoom now has a visible row (− / slider / + / Fit) beside the tilt row, plus wheel, pinch, <b>+</b>/<b>−</b> keys and double-click to fit; pan = drag, tilt = handle + arrows</td></tr>
<tr><td>3 Fixed line</td><td>The reference line is fixed in the MIDDLE of the rectangle, and it IS the crop line: its ends are the crop insets</td><td class="where">The line's x is computed from the frame, never from the slab's transform; the crop % moves only its two ends, (1 − fill)/2 of the long axis in from the top and the bottom</td></tr>
<tr><td>4 Crop %</td><td>Global <code>settings.crop_fill</code> (default 90) replaces both <code>FILL = 0.9</code> copies, read live, non-retro. <b>It does not change the aspect ratio</b></td><td class="where">Crop fill control on the tape step and the Settings step: 90% puts the overlay 5% in from the top and the bottom; the 3:4 rectangle itself never changes</td></tr>
<tr><td>5 Crop content</td><td>Post-BG-removal, slab pixels only, transparent background, bounds = slab extent</td><td class="where">Mask step note + the crop frame outline around the stage</td></tr>
<tr><td>6 Transform scope</td><td>Tilt persists only to the calibration pic and its dimension-overlay clone; siblings keep their own orientation</td><td class="where">Photos-ready step lineup: calibration + dimensioned copy tilted, backside sibling untouched</td></tr>
<tr><td>7 Doc fix</td><td><code>capture/types.ts:46</code> says "80%-fill"; live value is 90%</td><td class="where">Noted in the Implementation notes card on the Settings step</td></tr>
</tbody>
</table>
</div></div>
<script>
// ===========================================================================
// issue #99 — the tape line is a FIXED ENTITY.
//
// * The black ~2pt line and its two end ticks never move: not on tilt, not
// on pan, not on zoom. It is fixed in the MIDDLE of the rectangle.
// * The SLAB is the only thing the operator moves (pan / zoom / tilt) until
// its long axis lies on the line, tick to tick.
// * The ticks belong to the line - they slide along it, never with the photo.
// * L = the distance between the two ticks (the owner's tape reading).
// ===========================================================================
const S = {
screen: "tape",
// slab transform (the only movable entity)
tilt: 14, // degrees
zoom: 1,
panX: 60, panY: 0,
// the line's ticks: y positions along the fixed line. They rest at the crop
// insets - (1 - fill)/2 of the rectangle's LONG axis in from the top and the
// bottom - and the operator drags them onto their tape marks from there.
tickA: 32,
tickB: 608,
focus: "A",
fill: 90, // settings.crop_fill - global, default 90
// declared values (entered on Measure, shown on both steps)
L: "88",
thickness: "2.125",
savedMsg: "",
};
// The stage IS the crop frame: 3:4, the same aspect composeInventoryFrame emits.
const VB = { w: 480, h: 640 };
// Slab drawn in stage coordinates. What has to be aligned is the slab's LONG
// AXIS - the centreline running down the middle of the board, which is where
// the tape is laid. (Not a physical side edge.)
const SLAB = {
path: "M 166 40 L 161 150 L 168 300 L 162 455 L 166 600 L 330 608 L 396 596 L 400 320 L 396 96 L 330 34 Z",
axis: [{ x: 282, y: 40 }, { x: 282, y: 600 }],
};
const TICK_LEN = 26; // rifle-scope end tick, total
const TICK_GAP = 40; // min separation between ticks
// Grab radius in stage units. The stage renders ~352-354px wide for a 480-unit
// viewBox, so 68 units is ~50px on screen - comfortably over the required 44x44.
const HIT_R = 34;
const ALIGN_TOL = 3; // stage units
const rad = (a) => a * Math.PI / 180;
// The fixed line sits in the MIDDLE of the rectangle: the centre of the 3:4
// crop frame, which is where the crop centres the slab's long axis.
const lineX = () => VB.w / 2;
// Crop % = (1 - fill)/2 of the rectangle's LONG axis in from the top and the
// bottom (cropMarginFraction() in png/crop.ts). It never touches the aspect
// ratio: the rectangle stays 3:4 and only the overlay's two ends move.
const cropInset = () => VB.h * (1 - S.fill / 100) / 2;
const cropTicks = () => ({ a: cropInset(), b: VB.h - cropInset() });
const spanPx = () => Math.abs(S.tickB - S.tickA);
const pxPerIn = () => spanPx() / (parseFloat(S.L) || 1);
// slab transform: zoom + tilt about the stage centre, then pan
function mapPoint(p) {
const cx = VB.w / 2, cy = VB.h / 2, a = rad(S.tilt), z = S.zoom;
const x = (p.x - cx) * z, y = (p.y - cy) * z;
return { x: cx + x * Math.cos(a) - y * Math.sin(a) + S.panX,
y: cy + x * Math.sin(a) + y * Math.cos(a) + S.panY };
}
function slabTransform() {
return `translate(${S.panX} ${S.panY}) translate(${VB.w / 2} ${VB.h / 2}) rotate(${S.tilt}) scale(${S.zoom}) translate(${-VB.w / 2} ${-VB.h / 2})`;
}
// where the slab's long axis crosses a given stage y
function axisXAt(y) {
const a = mapPoint(SLAB.axis[0]), b = mapPoint(SLAB.axis[1]);
if (Math.abs(b.y - a.y) < 1e-6) return a.x;
return a.x + ((y - a.y) / (b.y - a.y)) * (b.x - a.x);
}
const axisAngle = () => {
const a = mapPoint(SLAB.axis[0]), b = mapPoint(SLAB.axis[1]);
return Math.atan2(b.y - a.y, b.x - a.x) * 180 / Math.PI;
};
// the axis is vertical when its angle points straight down (or up) the screen
const axisVertical = () => { const d = ((axisAngle() % 180) + 180) % 180; return Math.abs(d - 90) < 1.5; };
const axisOffset = () => axisXAt((S.tickA + S.tickB) / 2) - lineX();
const aligned = () => axisVertical() && Math.abs(axisOffset()) <= ALIGN_TOL;
// ---------------------------------------------------------------- the stage
function slabSvg() {
return `
<g id="slab" transform="${slabTransform()}">
<clipPath id="slabClip"><path d="${SLAB.path}"/></clipPath>
<path d="${SLAB.path}" fill="hsl(28,36%,31%)" stroke="#000" stroke-opacity=".25"/>
<g clip-path="url(#slabClip)" opacity=".8">
<path d="M 176 90 Q 290 74 400 96" stroke="hsl(28,34%,36%)" stroke-width="4" fill="none"/>
<path d="M 174 200 Q 290 190 400 210" stroke="hsl(28,34%,35%)" stroke-width="4" fill="none"/>
<path d="M 172 320 Q 290 306 400 326" stroke="hsl(28,34%,37%)" stroke-width="4" fill="none"/>
<path d="M 172 440 Q 290 432 400 452" stroke="hsl(28,34%,34%)" stroke-width="4" fill="none"/>
<path d="M 174 550 Q 290 540 400 560" stroke="hsl(28,34%,36%)" stroke-width="4" fill="none"/>
</g>
</g>`;
}
// the FIXED entity: black 2pt line + two rifle-scope end ticks. No transform.
function fixtureSvg() {
const x = lineX();
const tick = (y, id) => {
const on = S.focus === id;
return `
<line x1="${x - TICK_LEN / 2}" y1="${y}" x2="${x + TICK_LEN / 2}" y2="${y}" stroke="rgba(255,255,255,.92)" stroke-width="6" stroke-linecap="round"/>
<line x1="${x - TICK_LEN / 2}" y1="${y}" x2="${x + TICK_LEN / 2}" y2="${y}" stroke="#000" stroke-width="2.4" stroke-linecap="round"/>
<circle cx="${x}" cy="${y}" r="5" fill="${on ? "#ff3d71" : "#000"}" stroke="#fff" stroke-width="1.5"/>
<circle class="tickg" data-tick="${id}" cx="${x}" cy="${y}" r="${HIT_R}" fill="transparent"/>`;
};
return `
<line x1="${x}" y1="${S.tickA}" x2="${x}" y2="${S.tickB}" stroke="rgba(255,255,255,.28)" stroke-width="6" stroke-linecap="round"/>
<line id="tapeLine" x1="${x}" y1="${S.tickA}" x2="${x}" y2="${S.tickB}" stroke="#000" stroke-width="2.4" stroke-linecap="round"/>
${tick(S.tickA, "A")}${tick(S.tickB, "B")}`;
}
// The crop frame: the rectangle's ASPECT NEVER CHANGES (3:4, from crop.ts).
// The crop % only moves the overlay's ends: (1 - fill)/2 of the long axis in
// from the top and the bottom, shaded here as the two bands it leaves outside.
function cropFrameSvg() {
const m = cropInset();
return `
<rect x="0" y="0" width="${VB.w}" height="${m}" fill="#000" opacity=".30"/>
<rect x="0" y="${VB.h - m}" width="${VB.w}" height="${m}" fill="#000" opacity=".30"/>
<rect id="cropframe" x=".5" y=".5" width="${VB.w - 1}" height="${VB.h - 1}" fill="none" stroke="#555" stroke-width="1" stroke-dasharray="6 5"/>
<text x="6" y="${m / 2 + 4}" fill="#8a8a8a" font-family="system-ui" font-size="11">crop ${S.fill}% — overlay ${((1 - S.fill / 100) / 2 * 100).toFixed(1)}% in from the top</text>
<text x="6" y="${VB.h - m / 2 + 4}" fill="#8a8a8a" font-family="system-ui" font-size="11">${((1 - S.fill / 100) / 2 * 100).toFixed(1)}% in from the bottom</text>`;
}
function rotateHandleSvg() {
return `
<g class="rotg" data-rot="1">
<circle cx="${VB.w / 2}" cy="${VB.h - 26}" r="17" fill="rgba(0,0,0,.6)" stroke="var(--acc)" stroke-width="1.5"/>
<text x="${VB.w / 2}" y="${VB.h - 21}" fill="var(--acc)" font-size="15" font-family="system-ui" text-anchor="middle">&#8635;</text>
<text x="${VB.w / 2 + 24}" y="${VB.h - 22}" fill="#aaa" font-size="10.5" font-family="system-ui">drag to tilt the slab</text>
</g>`;
}
function stageSvg() {
return `<svg viewBox="0 0 ${VB.w} ${VB.h}" id="tsvg" aria-label="fixed tape line over a movable slab">
<rect width="${VB.w}" height="${VB.h}" fill="#16281c"/>
${cropFrameSvg()}
${slabSvg()}
${fixtureSvg()}
${rotateHandleSvg()}
</svg>`;
}
// ------------------------------------------------------------------ screens
const screens = {
batch() {
return `<section class="panel">
<h1>Batch photos</h1>
<p class="hint">Unchanged by #99 — shown for context. Sheet is green or black and fills the frame around the slab.</p>
<div class="row"><button class="primary" data-act="to-mask">Pick calibration photo</button></div>
</section>`;
},
mask() {
return `<section class="panel">
<h1>Mask</h1>
<p class="hint">Unchanged by #99. The approved mask is still what the crop bounds come from.</p>
<div class="nums">
<div><span>Crop bounds</span><span class="tab">slab extent (kept pixels only)</span></div>
<div><span>Crop contents</span><span class="tab">slab pixels only, transparent background</span></div>
</div>
<div class="row">
<button class="ghost" data-act="to-batch">Back</button>
<button class="primary" style="flex:2" data-act="to-tape">Approve mask</button>
</div>
</section>`;
},
tape() {
const off = axisOffset(), ok = aligned();
return `<section class="panel">
<h1>Tape line</h1>
<p class="hint">Drag each tick to where your tape started and ended.</p>
<div class="tw" id="tw">${stageSvg()}</div>
<p class="nudge">The black line is <b>fixed</b>: tilting, dragging and zooming the slab never move it. It stays in the middle of the rectangle.</p>
<p class="nudge">Move the <b>slab</b> instead: drag it to pan, wheel or pinch to zoom, tilt it with the &#8635; handle or the arrow keys — until the board's long axis (the line down the middle of the slab) lies on the fixed line, tick to tick.</p>
<div class="controls">
<div>
<p class="ctl-label">Tilt the slab</p>
<div class="angle">
<button class="thumbbtn" data-act="tilt" data-d="-1" aria-label="tilt left">&#8722;</button>
<input type="range" min="0" max="359" value="${S.tilt}" data-act="tilt-range" aria-label="slab tilt"/>
<button class="thumbbtn" data-act="tilt" data-d="1" aria-label="tilt right">+</button>
<span class="val" id="tiltVal">${S.tilt}&#176;</span>
</div>
<p class="nudge">&larr; &rarr; tilt 1&#176; &nbsp;·&nbsp; Shift + &larr; &rarr; tilt 0.1&#176; &nbsp;·&nbsp; or drag the &#8635; handle</p>
</div>
<div>
<p class="ctl-label">Zoom the slab <span class="sub" style="font-size:.7rem">wheel or pinch over the photo</span></p>
<div class="angle">
<button class="thumbbtn" data-act="zoom" data-d="-1" aria-label="zoom out">&#8722;</button>
<input type="range" min="50" max="300" value="${Math.round(S.zoom * 100)}" data-act="zoom-range" aria-label="slab zoom"/>
<button class="thumbbtn" data-act="zoom" data-d="1" aria-label="zoom in">+</button>
<span class="val" id="zoomVal">${Math.round(S.zoom * 100)}%</span>
</div>
<p class="nudge">Wheel or pinch over the photo zooms to the cursor &nbsp;·&nbsp; <b>+</b> / <b>&#8722;</b> keys step 10% &nbsp;·&nbsp; double-click the photo to fit</p>
<div class="row" style="margin-top:8px"><button class="ghost" data-act="fit">Fit — 100%, centred</button></div>
</div>
<div>
<p class="ctl-label">Tape end ticks <span class="sub" style="font-size:.7rem">on the line; drag along it</span></p>
<p class="nudge">Grab a tick anywhere in its 44&#215;44px target and drag it along the line. The tick you last grabbed takes the arrow keys: <b>&#8593; &#8595;</b> nudge 1px, Shift = 0.1px &mdash; its centre dot turns red to show which one.</p>
</div>
<div>
<p class="ctl-label">Crop fill % <span class="sub" style="font-size:.7rem">global setting</span></p>
<div class="croprow">
<input type="range" min="1" max="100" value="${S.fill}" data-act="fill-range" aria-label="crop fill percent"/>
<span class="val" id="fillVal">${S.fill}%</span>
</div>
<p class="nudge">settings.crop_fill (default 90). It sets how much of the rectangle the overlay covers: at ${S.fill}&#37; the overlay's ends sit ${((1 - S.fill / 100) / 2 * 100).toFixed(1)}&#37; in from the top and the bottom. <b>The rectangle keeps its 3:4 aspect</b> — only the ends move, never the middle. Existing stored PNGs are never re-cropped.</p>
</div>
</div>
<div class="status ${ok ? "ok" : ""}" id="alignstatus">${ok
? "The board's long axis is on the line &#10003;"
: `The board's long axis is ${off > 0 ? "right" : "left"} of the line by ${Math.abs(off).toFixed(0)} px${axisVertical() ? "" : `, and tilted ${S.tilt}&#176;`}.`}</div>
<div class="nums">
<div><span>Fixed line x <span class="dim">(middle of the 3:4 frame)</span></span><span class="tab" id="lineReadout">${lineX().toFixed(1)} of ${VB.w}</span></div>
<div><span>Overlay insets <span class="dim">(from crop fill)</span></span><span class="tab" id="insetReadout">${((1 - S.fill / 100) / 2 * 100).toFixed(1)}% in from top and bottom</span></div>
<div><span>Tape span between ticks</span><span class="tab" id="spanReadout">${spanPx().toFixed(0)} px</span></div>
<div><span>Your tape length <span class="dim">(entered on Measure)</span></span><span class="tab">${(parseFloat(S.L) || 0).toFixed(1)}&#8243;</span></div>
<div><span>Scale from your ticks</span><span class="tab" id="scaleReadout">${pxPerIn().toFixed(2)} px/in</span></div>
<div><span>Mask span projection</span><span class="tab dim">not used — sanity check only</span></div>
</div>
<p class="nudge" style="margin-top:10px">Slab tilt ${S.tilt}&#176; &nbsp;·&nbsp; zoom ${S.zoom.toFixed(2)}&#215; <span class="dim">(moving these never changes the line)</span></p>
<div class="swap">
<figure>${oldOverlayMini()}<figcaption class="gone">&#10007; removed — cyan axis rectangle + red arrow <span class="dim">(PhotoStage.svelte:56-82)</span></figcaption></figure>
<figure>${newOverlayMini()}<figcaption>this step — fixed black 2pt line + two end ticks</figcaption></figure>
</div>
<div class="row">
<button class="ghost" data-act="to-mask">Back</button>
<button class="primary" style="flex:2" data-act="to-measure">Approve tape line</button>
</div>
<div class="plan">
<h3>Two things on this screen are my proposal, not the issue</h3>
<ul>
<li>The alignment read-out above <span class="proposal">PROPOSAL</span> — the issue says "rotate until the taped line sits on the fixed reference line" and doesn't specify how the operator knows. Tell me to drop it and it's gone; nothing is gated on it.</li>
<li>The shaded crop bands <span class="proposal">PROPOSAL</span> — drawn only to make the crop fill visible: the two strips the overlay leaves outside itself at the top and the bottom.</li>
</ul>
</div>
</section>`;
},
measure() {
const mini = (tilt, marks) => `
<svg viewBox="0 0 480 640">
<rect width="480" height="640" fill="#16281c"/>
<g transform="translate(240 320) rotate(${tilt}) translate(-240 -320)">
<path d="M 168 60 L 300 52 L 392 80 L 400 300 L 388 560 L 300 596 L 168 588 Z" fill="hsl(28,36%,31%)"/>
${marks ? `<line x1="168" y1="100" x2="168" y2="548" stroke="#000" stroke-width="2.4"/>
<line x1="155" y1="100" x2="181" y2="100" stroke="#000" stroke-width="2.4"/>
<line x1="155" y1="548" x2="181" y2="548" stroke="#000" stroke-width="2.4"/>` : ""}
</g>
</svg>`;
return `<section class="panel">
<h1>Photos ready</h1>
<div class="fields">
<div class="field"><span class="label">Length L (tape reading)</span><input class="inp" type="number" step="0.125" data-act="length" value="${S.L}"/></div>
<div class="field"><span class="label">Thickness</span><input class="inp" type="number" step="0.125" data-act="thickness" value="${S.thickness}"/></div>
</div>
<div class="nums">
<div><span>L maps to</span><span class="tab">tick-to-tick span (${spanPx().toFixed(0)} px)</span></div>
<div><span>Scale used</span><span class="tab">${pxPerIn().toFixed(2)} px/in</span></div>
<div><span>Mask span projection</span><span class="tab dim">not used</span></div>
</div>
<div class="plan">
<h3>Rotation scope — req. 6</h3>
<ul><li>The aligning tilt reaches the calibration pic and its dimension-overlay clone only.</li>
<li>A backside sibling keeps its own orientation; rotation is never propagated through the shared mask/crop settings.</li></ul>
</div>
<ol class="lineup">
<li class="lineup-item">${mini(S.tilt, true)}<span class="role">Clean calibration</span><span class="rot-badge yes">tilted ${S.tilt}&#176;</span></li>
<li class="lineup-item">${mini(S.tilt, false)}<span class="role">Dimension overlay</span><span class="rot-badge yes">same ${S.tilt}&#176;</span></li>
<li class="lineup-item">${mini(0, false)}<span class="role">Backside sibling</span><span class="rot-badge no">not re-tilted</span></li>
</ol>
<div class="row">
<button class="ghost" data-act="to-tape">Back</button>
<button class="primary" style="flex:2" data-act="to-settings">Continue</button>
</div>
</section>`;
},
settings() {
return `<section class="panel settings">
<h1>Settings</h1>
<div class="section">
<h3>Crop — global</h3>
<label for="crop-fill">Crop Fill % (default 90) — how much of the rectangle the overlay covers</label>
<div class="croprow">
<input type="range" id="crop-fill" min="1" max="100" data-act="fill-range" value="${S.fill}"/>
<span class="val" id="fillVal">${S.fill}%</span>
</div>
<p class="sub" style="margin-top:8px">One shared value replaces the two duplicated <code>FILL = 0.9</code> constants. Both the overlay's ends and the PNG crop read it live, so they can never disagree. At 90&#37; the overlay's ends sit ${((1 - S.fill / 100) / 2 * 100).toFixed(1)}&#37; in from the top and the bottom; <b>the 3:4 aspect ratio never changes</b>. Applies to slabs processed from now on; stored PNGs are not re-cropped.</p>
<button data-act="save-fill">Save Changes</button>
<p class="msg ${S.savedMsg ? "ok" : ""}">${S.savedMsg}</p>
</div>
<div class="section">
<h3>Implementation notes</h3>
<ul class="plan" style="border:0;padding:0;background:none">
<li><code>capture/types.ts:46</code> says "80%-fill PNG crop" — stale; the live value is 90%. Comment gets fixed (req. 7).</li>
<li>Replaces the cyan bbox + red arrow in <code>PhotoStage.svelte:56-82</code>.</li>
<li><code>maskSpanAlong</code> is demoted to a sanity check.</li>
</ul>
</div>
<div class="row"><button class="ghost" data-act="to-tape">Back to tape line</button></div>
</section>`;
},
};
// the two mini overlays for the was/now pair
function oldOverlayMini() {
return `<svg viewBox="0 0 120 160">
<rect width="120" height="160" fill="#141414"/>
<rect x="26" y="18" width="68" height="124" fill="hsl(28,30%,30%)"/>
<rect x="20" y="12" width="80" height="136" fill="none" stroke="#00e5ff" stroke-width="3" stroke-dasharray="8 5"/>
<line x1="60" y1="150" x2="60" y2="26" stroke="#ff3d71" stroke-width="4"/>
<path d="M 60 20 L 52 34 L 68 34 Z" fill="#ff3d71"/>
</svg>`;
}
function newOverlayMini() {
return `<svg viewBox="0 0 120 160">
<rect width="120" height="160" fill="#141414"/>
<rect x="34" y="18" width="52" height="124" fill="hsl(28,30%,30%)"/>
<line x1="60" y1="30" x2="60" y2="132" stroke="#000" stroke-width="2.4"/>
<line x1="48" y1="30" x2="72" y2="30" stroke="#000" stroke-width="2.4"/>
<line x1="48" y1="132" x2="72" y2="132" stroke="#000" stroke-width="2.4"/>
</svg>`;
}
// ----------------------------------------------------------------- plumbing
const phone = document.getElementById('phone');
const flowbar = document.getElementById('flowbar');
const STEPS = [
["batch", "Batch photos"], ["mask", "Mask"], ["tape", "Tape line (#99)"],
["measure", "Photos ready"], ["settings", "&#9881; Settings"],
];
function render() {
phone.innerHTML = screens[S.screen]();
flowbar.innerHTML = STEPS.map(([k, label]) =>
`<button class="fstep ${S.screen === k ? "on" : ""}" data-goto="${k}">${label}</button>`).join('');
if (S.screen === "tape") { bindStage(); refreshStage(); }
}
flowbar.addEventListener('click', (e) => {
const b = e.target.closest('[data-goto]');
if (!b) return;
S.screen = b.dataset.goto;
render();
});
document.querySelector('.vp').addEventListener('click', (e) => {
const b = e.target.closest('[data-w]');
if (!b) return;
document.querySelectorAll('.vp [data-w]').forEach((x) => x.classList.remove('on'));
b.classList.add('on');
phone.style.width = (b.dataset.w > 720 ? '412px' : b.dataset.w + 'px');
document.getElementById('vpval').textContent = b.dataset.w;
});
// Replace only the stage + the live readouts, so an in-progress drag survives
// (a full panel re-render would destroy the element under the pointer).
function refreshStage() {
const host = document.getElementById('tw');
if (!host) return;
host.innerHTML = stageSvg();
const set = (id, txt) => { const el = document.getElementById(id); if (el) el.textContent = txt; };
set('tiltVal', S.tilt + '\u00B0');
set('zoomVal', Math.round(S.zoom * 100) + '%');
set('fillVal', S.fill + '%');
set('insetReadout', `${((1 - S.fill / 100) / 2 * 100).toFixed(1)}% in from top and bottom`);
set('lineReadout', `${lineX().toFixed(1)} of ${VB.w}`);
set('spanReadout', `${spanPx().toFixed(0)} px`);
set('scaleReadout', `${pxPerIn().toFixed(2)} px/in`);
const st = document.getElementById('alignstatus');
if (st) {
const ok = aligned(), off = axisOffset();
st.className = 'status' + (ok ? ' ok' : '');
st.innerHTML = ok
? "The board's long axis is on the line \u2713"
: `The board's long axis is ${off > 0 ? 'right' : 'left'} of the line by ${Math.abs(off).toFixed(0)} px`
+ `${axisVertical() ? '' : `, and tilted ${S.tilt}\u00B0`}.`;
}
window.__wf = { tilt: S.tilt, zoom: S.zoom, panX: S.panX, panY: S.panY,
tickA: S.tickA, tickB: S.tickB, focus: S.focus, fill: S.fill,
lineX: lineX(), spanPx: spanPx(), axisOffset: axisOffset(),
axisXAt: axisXAt((S.tickA + S.tickB) / 2), axisVertical: axisVertical(),
axisAngle: axisAngle(), aligned: aligned() };
}
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
const norm360 = (a) => ((a % 360) + 360) % 360;
function tiltBy(d) { S.tilt = norm360(S.tilt + d); render(); }
// Visible zoom path. #99 names wheel / trackpad pinch; this is the discoverable
// twin, stepping about the stage centre so the slab stays where it is.
function stepZoom(dir) {
S.zoom = clamp(S.zoom * (dir > 0 ? 1.1 : 1 / 1.1), 0.5, 3);
refreshStage();
}
function fitSlab() { S.zoom = 1; S.panX = 0; S.panY = 0; refreshStage(); }
// no tick picker: the tick you last grabbed is the focused one (set on pointerdown)
function nudgeTick(dir, mult) {
const step = (mult === undefined ? 1 : mult);
if (S.focus === "A") S.tickA = clamp(S.tickA + dir * step, 20, S.tickB - TICK_GAP);
else S.tickB = clamp(S.tickB + dir * step, S.tickA + TICK_GAP, VB.h - 20);
render();
}
function bindStage() {
const host = document.getElementById('tw');
if (!host) return;
const toVB = (e) => {
const r = (host.querySelector('svg') || host).getBoundingClientRect();
return { x: (e.clientX - r.left) * (VB.w / r.width), y: (e.clientY - r.top) * (VB.h / r.height) };
};
// zoom-to-cursor — moves the SLAB only; the line ignores it entirely
function zoomAt(p, factor) {
const cx = VB.w / 2, cy = VB.h / 2;
const z0 = S.zoom, z1 = clamp(z0 * factor, 0.5, 3);
if (z1 === z0) return;
const qx = cx + (p.x - cx - S.panX) / z0;
const qy = cy + (p.y - cy - S.panY) / z0;
S.zoom = z1;
S.panX = p.x - cx - z1 * (qx - cx);
S.panY = p.y - cy - z1 * (qy - cy);
refreshStage();
}
host.addEventListener('wheel', (e) => {
e.preventDefault();
// No single event may slam the clamp: a PC ctrl+wheel notch arrives at
// deltaY ~300 and a trackpad pinch arrives as ctrlKey too, so cap both the
// delta and the per-event factor. Plain wheel stays ~16% per notch.
const dy = clamp(e.deltaY, -200, 200);
const k = e.ctrlKey ? 0.01 : 0.0015;
zoomAt(toVB(e), clamp(Math.exp(-dy * k), 0.8, 1.25));
}, { passive: false });
// belt-and-braces fit: Chromium suppresses this after our pointerdown
// preventDefault (see the tap tracking in end()), some engines still emit it
host.addEventListener('dblclick', (e) => { e.preventDefault(); fitSlab(); });
let drag = null, tb = null, moved = false, downP = null;
const off = { x: 0, y: 0 };
host.addEventListener('pointerdown', (e) => {
const tick = e.target.closest('[data-tick]');
const rot = e.target.closest('[data-rot]');
const p = toVB(e);
moved = false; downP = p;
if (tick) {
S.focus = tick.dataset.tick;
drag = { kind: "tick", id: S.focus };
} else if (rot) {
drag = { kind: "rot", start: p.x, tilt0: S.tilt };
} else {
drag = { kind: "slab" };
off.x = p.x - S.panX; off.y = p.y - S.panY;
}
host.setPointerCapture(e.pointerId);
host.classList.add('dragging');
refreshStage();
e.preventDefault();
});
host.addEventListener('pointermove', (e) => {
if (!drag) return;
const p = toVB(e);
if (downP && (Math.abs(p.x - downP.x) > 3 || Math.abs(p.y - downP.y) > 3)) moved = true;
if (drag.kind === "tick") {
// ticks slide ALONG the fixed line — x is ignored on purpose
const y = clamp(p.y, 20, VB.h - 20);
if (drag.id === "A") S.tickA = clamp(y, 20, S.tickB - TICK_GAP);
else S.tickB = clamp(y, S.tickA + TICK_GAP, VB.h - 20);
} else if (drag.kind === "rot") {
S.tilt = norm360(drag.tilt0 + (p.x - drag.start) * 0.6);
} else {
S.panX = clamp(p.x - off.x, -400, 400);
S.panY = clamp(p.y - off.y, -400, 400);
}
refreshStage();
});
const end = (e) => {
if (!drag) return;
const wasSlab = drag.kind === "slab";
const startP = toVB(e);
drag = null;
host.classList.remove('dragging');
try { host.releasePointerCapture(e.pointerId); } catch (_) {}
// double-tap to fit. We cannot use a 'dblclick' listener: preventDefault on
// pointerdown suppresses the whole compatibility mouse sequence (click and
// dblclick both stop arriving), so track the taps ourselves.
if (wasSlab && !moved) {
const now = performance.now();
const near = tb && Math.abs(startP.x - tb.x) < 12 && Math.abs(startP.y - tb.y) < 12;
if (near && now - tb.t < 350) { tb = null; fitSlab(); return; }
tb = { t: now, x: startP.x, y: startP.y };
}
refreshStage();
};
host.addEventListener('pointerup', end);
host.addEventListener('pointercancel', end);
}
// keyboard: arrows = Adobe nudge model (1 / 0.1 with Shift), +/- = zoom steps
document.addEventListener('keydown', (e) => {
if (S.screen !== "tape") return;
if (e.key === "+" || e.key === "=" || e.key === "-" || e.key === "_") {
stepZoom(e.key === "+" || e.key === "=" ? 1 : -1);
e.preventDefault();
return;
}
if (!e.key.startsWith("Arrow")) return;
const fine = e.shiftKey ? 0.1 : 1;
if (e.key === "ArrowLeft") { S.tilt = norm360(S.tilt - fine); render(); }
else if (e.key === "ArrowRight") { S.tilt = norm360(S.tilt + fine); render(); }
else if (e.key === "ArrowUp") { nudgeTick(-1, fine); }
else if (e.key === "ArrowDown") { nudgeTick(1, fine); }
else return;
e.preventDefault();
});
phone.addEventListener('click', (e) => {
const t = e.target.closest('[data-act]');
if (!t) return;
const a = t.dataset.act;
if (a === "tilt") tiltBy(+t.dataset.d);
else if (a === "zoom") stepZoom(+t.dataset.d);
else if (a === "fit") fitSlab();
else if (a === "to-mask") { S.screen = "mask"; render(); }
else if (a === "to-batch") { S.screen = "batch"; render(); }
else if (a === "to-tape") { S.screen = "tape"; render(); }
else if (a === "to-measure") { S.screen = "measure"; render(); }
else if (a === "to-settings") { S.screen = "settings"; render(); }
else if (a === "save-fill") { S.savedMsg = `Saved crop_fill = ${S.fill}. The overlay's ends and every future PNG crop follow it; the rectangle keeps its 3:4 aspect and stored PNGs are untouched.`; render(); }
});
phone.addEventListener('input', (e) => {
const t = e.target;
if (t.dataset.act === "tilt-range") { S.tilt = +t.value; refreshStage(); }
else if (t.dataset.act === "zoom-range") { S.zoom = clamp(+t.value / 100, 0.5, 3); refreshStage(); }
else if (t.dataset.act === "fill-range") {
// crop % moves the overlay's ENDS in/out from the top and bottom; the
// rectangle's aspect never changes and the line's x never moves.
S.fill = +t.value;
const ct = cropTicks();
S.tickA = ct.a; S.tickB = ct.b;
refreshStage();
}
});
phone.addEventListener('change', (e) => {
const t = e.target;
if (t.dataset.act === "length") { S.L = t.value; render(); }
else if (t.dataset.act === "thickness") { S.thickness = t.value; render(); }
});
render();
</script>
</body>
</html>
+8 -4
View File
@@ -8,6 +8,11 @@
import type { CalibratedCapture } from '../flow';
import MultiSelect from './MultiSelect.svelte';
import { deriveThicknessTermId } from './thicknessBand';
import {
keptInOrder, moveItem, missingMinimums, roleLabel, resolvePhotoUrl,
type LineupItem,
} from './lineup';
import { nextPrimary } from './woodPrimary';
// Props handed off by App (one-column flow, UX.md §1).
export let slabId: string;
@@ -99,7 +104,6 @@
let gradeSel: string = '';
let woodCatIds: number[] = [];
let figureIds: number[] = [];
let gradeId: number | '' = '';
let moistureId: number | '' = '';
let figTagIds: number[] = [];
let featTagIds: number[] = [];
@@ -128,7 +132,7 @@
let createBusy = false;
let createError = '';
$: canCreateTitle =
speciesId !== '' && woodCatIds.length > 0;
speciesSel !== '' && woodCatIds.length > 0;
$: canCreateDesc =
canGenerateContent && title.trim() !== '' && shortDescription.trim() !== '';
// Monotonic token: each commit captures the current value and ignores its
@@ -320,7 +324,7 @@
woodCatIds = [...(s.wood_category_ids ?? [])];
edgeSel = s.edge_type_term_id != null ? String(s.edge_type_term_id) : '';
figureIds = [...(s.figure_term_ids ?? [])];
gradeId = s.grade_term_id ?? '';
gradeSel = s.grade_term_id != null ? String(s.grade_term_id) : '';
moistureId = s.moisture_term_id ?? '';
figTagIds = [...(s.fig_tag_ids ?? [])];
featTagIds = [...(s.feat_tag_ids ?? [])];
@@ -393,7 +397,7 @@
wood_category_ids: woodCatIds,
edge_type_term_id: edgeSel === '' ? null : Number(edgeSel),
figure_term_ids: figureIds,
grade_term_id: gradeId === '' ? null : Number(gradeId),
grade_term_id: gradeSel === '' ? null : Number(gradeSel),
moisture_term_id: moistureId === '' ? null : Number(moistureId),
fig_tag_ids: figTagIds,
feat_tag_ids: featTagIds,