Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 63b8879720 | |||
| 0c00d25cc7 | |||
| 96a697a50a | |||
| 67aa3d6b0d |
+22
-18
@@ -25,7 +25,7 @@ from starlette.middleware.base import BaseHTTPMiddleware
|
|||||||
from .db import get_connection, init_db, probe_images_writable, probe_writable
|
from .db import get_connection, init_db, probe_images_writable, probe_writable
|
||||||
from . import settings as settings_mod
|
from . import settings as settings_mod
|
||||||
|
|
||||||
__version__ = "0.5.0-m3-s1"
|
__version__ = "0.5.0-m3"
|
||||||
|
|
||||||
# --- Structured JSON logging (stdlib only) ---
|
# --- Structured JSON logging (stdlib only) ---
|
||||||
request_id_var: ContextVar[str] = ContextVar("request_id", default="")
|
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")
|
@app.get("/api/health")
|
||||||
async def health() -> dict[str, Any]:
|
async def health() -> dict[str, Any]:
|
||||||
"""Health per APP-30/45: non-200 when db or image volume not writable (probe file).
|
"""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()
|
db_ok = probe_writable()
|
||||||
img_ok = probe_images_writable()
|
img_ok = probe_images_writable()
|
||||||
if not (db_ok and img_ok):
|
if not (db_ok and img_ok):
|
||||||
logger.warning("health: storage not writable", extra={"request_id": request_id_var.get("")})
|
logger.warning("health: storage not writable", extra={"request_id": request_id_var.get("")})
|
||||||
raise HTTPException(status_code=503, detail="storage not writable")
|
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 {
|
return {
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"version": __version__,
|
"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")
|
@app.get("/api/v1/meta")
|
||||||
async def meta() -> dict[str, Any]:
|
async def meta() -> dict[str, Any]:
|
||||||
"""Per API.md §3."""
|
"""Per API.md §3."""
|
||||||
|
|||||||
+42
-29
@@ -16,7 +16,14 @@ from fastapi import APIRouter
|
|||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
from .db import get_connection
|
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
|
from .review import compute_ready, _slab_dict
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/v1/slabs")
|
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 [])]
|
tags = [t for t in (slab.get("fig_tag_ids") or [])]+[t for t in (slab.get("feat_tag_ids") or [])]
|
||||||
if tags:
|
if tags:
|
||||||
payload["tags"] = [{"id": t} for t in 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()}
|
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()}
|
attrs = {r["id"]: dict(r) for r in conn.execute("SELECT * FROM woo_attributes").fetchall()}
|
||||||
built_attrs = []
|
built_attrs = []
|
||||||
for field, attr_name, pos in (
|
for field, attr_slug, attr_name, pos in (
|
||||||
("edge_type_term_id", "Edge Type", 0),
|
("edge_type_term_id", "edge-type", "Edge Type", 0),
|
||||||
("figure_term_ids", "Figure", 1),
|
("figure_term_ids", "pa_figure", "Figure", 1),
|
||||||
("grade_term_id", "Grade", 2),
|
("grade_term_id", "pa_grade", "Grade", 2),
|
||||||
("thickness_term_id", "Thickness", 3),
|
("thickness_term_id", "pa_thickness", "Thickness", 3),
|
||||||
("moisture_term_id", "Moisture", 4),
|
("moisture_term_id", "pa_moisture", "Moisture", 4),
|
||||||
):
|
):
|
||||||
raw = slab.get(field)
|
raw = slab.get(field)
|
||||||
term_ids = raw if isinstance(raw, list) else ([raw] if raw else [])
|
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]
|
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:
|
if attr_id and options:
|
||||||
built_attrs.append({"id": attr_id, "position": pos, "visible": True, "variation": False, "options": options})
|
built_attrs.append({"id": attr_id, "position": pos, "visible": True, "variation": False, "options": options})
|
||||||
if built_attrs:
|
if built_attrs:
|
||||||
@@ -94,19 +104,35 @@ def _build_payload(slab: dict, conn, status: str) -> dict:
|
|||||||
return payload
|
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():
|
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 a["id"]
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _classify(e: Exception) -> tuple[str, str, bool]:
|
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):
|
if isinstance(e, WooCredentialsMissing):
|
||||||
return "woo_unconfigured", "Woo is not configured. Set the store credentials in Settings.", False
|
return "woo_unconfigured", "Woo is not configured. Set the store credentials in Settings.", False
|
||||||
if isinstance(e, WooAuthError):
|
if isinstance(e, WooAuthError):
|
||||||
return "woo_auth", "The store rejected the credentials. Check the app password.", False
|
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):
|
if isinstance(e, WooConnectionError):
|
||||||
return "woo_unreachable", "The store is unreachable right now. You can retry.", True
|
return "woo_unreachable", "The store is unreachable right now. You can retry.", True
|
||||||
return "woo_failed", "The store create failed. 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:
|
def _sku_exists(client: WooClient, sku: str) -> bool:
|
||||||
code, _h, body = client._request(
|
"""SKU lookup via WooClient so User-Agent and 403 typing apply (#79)."""
|
||||||
f"{client.base_url}/wp-json/wc/v3/products?sku={sku}",
|
return len(client.get_products_by_sku(sku)) > 0
|
||||||
"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
|
|
||||||
|
|
||||||
|
|
||||||
def _create_product(client: WooClient, payload: dict) -> int:
|
def _create_product(client: WooClient, payload: dict) -> int:
|
||||||
body = json.dumps(payload).encode("utf-8")
|
"""Product create via WooClient so User-Agent and 403 typing apply (#79)."""
|
||||||
code, _h, resp = client._request(
|
return client.create_product(payload)
|
||||||
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"])
|
|
||||||
|
|
||||||
|
|
||||||
def _log(conn, slab_id: str, woo_product_id: int | None, status: str, message: str) -> None:
|
def _log(conn, slab_id: str, woo_product_id: int | None, status: str, message: str) -> None:
|
||||||
|
|||||||
+38
-11
@@ -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"
|
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(
|
pattern, noun, carries_dims = _LEAF_PATTERNS.get(
|
||||||
primary_leaf.get("slug"), ("{figure}{species} Slab", "Slab", False)
|
primary_leaf.get("slug"), ("{figure}{species} Slab", "Slab", False)
|
||||||
)
|
)
|
||||||
figure = figure.strip() if figure else ""
|
figure = figure.strip() if figure else ""
|
||||||
live_edge = ""
|
live_edge = ""
|
||||||
if primary_leaf["slug"] in ("dimensional-wood", "bowl-blanks"):
|
if primary_leaf.get("slug") in ("dimensional-wood", "bowl-blanks"):
|
||||||
live_edge = "Live Edge " if slab.get("edge_type_term_id") else ""
|
# "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(
|
title = pattern.format(
|
||||||
figure=figure + " " if figure else "",
|
figure=figure + " " if figure else "",
|
||||||
species=species_name,
|
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()
|
return re.sub(r"\s+", " ", title).strip()
|
||||||
|
|
||||||
|
|
||||||
def build_short_description(slab: dict, figure: str | None, species_name: str, primary_leaf: dict) -> str:
|
def _edge_is_live(edge_term_id: Any, terms: dict) -> bool:
|
||||||
title = build_title(slab, figure, species_name, primary_leaf)
|
"""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"])
|
ft, rem = _split_length(slab["length_in"])
|
||||||
length_str = f"{ft}ft {_fmt1(rem)}in"
|
length_str = f"{ft}ft {_fmt1(rem)}in"
|
||||||
bdft = round(slab["bdft"], 1)
|
bdft = round(slab["bdft"], 1)
|
||||||
@@ -147,6 +158,13 @@ def price_suggestion(conn, slab: dict) -> float | None:
|
|||||||
|
|
||||||
@router.post("/{slab_id}/review")
|
@router.post("/{slab_id}/review")
|
||||||
async def review_slab(slab_id: str, payload: dict[str, Any] | None = None) -> dict:
|
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 {}
|
payload = payload or {}
|
||||||
conn = get_connection()
|
conn = get_connection()
|
||||||
row = conn.execute(
|
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)
|
slab = _slab_dict(row)
|
||||||
|
|
||||||
# --- Deterministic review: report ready/missing + the deterministic
|
# --- 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
|
# "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
|
# #80: persist owner overrides (price/sku) from the payload so the review
|
||||||
# endpoint is self-contained. title/short_description are NOT overridden here —
|
# endpoint is self-contained. title/short_description are NOT overridden here —
|
||||||
# they are produced by the deterministic build below, not persisted from the
|
# 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 []
|
wood_cats = slab.get("wood_category_ids") or []
|
||||||
if not primary_id:
|
if not primary_id:
|
||||||
primary_id = wood_cats[0] if wood_cats else None
|
primary_id = wood_cats[0] if wood_cats else None
|
||||||
primary_leaf = cats.get(primary_id, {})
|
primary_leaf = cats.get(primary_id, {}) if primary_id else {}
|
||||||
species_name = cats.get(slab.get("species_id"), {}).get("name", "")
|
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_term_ids = slab.get("figure_term_ids") or []
|
||||||
figure = terms.get(figure_term_ids[0], {}).get("name") if figure_term_ids else None
|
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
|
# Deterministic title / short_description, re-derived from the current
|
||||||
# metadata on every review (the Create button's source of truth).
|
# 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
|
# #98: pass synced terms so Live Edge comes from the edge term, not any id.
|
||||||
short_desc = build_short_description(slab, figure, species_name, primary_leaf) if (primary_leaf and species_name) else slab.get("short_description") or None
|
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)
|
price = _resolve_price(conn, slab, payload)
|
||||||
ready, missing = compute_ready({**slab, "price": price, "title": title, "short_description": short_desc})
|
ready, missing = compute_ready({**slab, "price": price, "title": title, "short_description": short_desc})
|
||||||
|
|||||||
+90
-26
@@ -43,6 +43,20 @@ TAXONOMY_TABLES = {
|
|||||||
"fig_tag_ids": "woo_tags",
|
"fig_tag_ids": "woo_tags",
|
||||||
"feat_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:
|
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
|
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
|
# 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
|
# taxonomy/content fields after create (CREATE.md §1: "every other field stays
|
||||||
# editable until create"), so create must accept a capture-math payload.
|
# 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
|
# 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
|
# "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.
|
# governs present SKUs, and NULL is the honest "absent" representation.
|
||||||
sku = payload["sku"]
|
sku = payload.get("sku")
|
||||||
if isinstance(sku, str) and sku.strip() == "":
|
if isinstance(sku, str) and sku.strip() == "":
|
||||||
payload = {**payload, "sku": None}
|
payload = {**payload, "sku": None}
|
||||||
|
enum_err = _validate_enums(payload)
|
||||||
|
if enum_err is not None:
|
||||||
|
return enum_err
|
||||||
conn = get_connection()
|
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)
|
return _err("duplicate_sku", "SKU already exists", {"sku": payload["sku"]}, status=409)
|
||||||
# METADATA-15 / QUALITY-95: the thickness band is derived, never owner-chosen.
|
# 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
|
# 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.
|
# 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
|
vals = [data[f] for f in allowed_cols] + server_vals
|
||||||
ph = ",".join(["?"] * len(cols))
|
ph = ",".join(["?"] * len(cols))
|
||||||
conn.execute(f"INSERT INTO slabs ({', '.join(cols)}) VALUES ({ph})", vals)
|
assert len(vals) == len(cols), f"create_slab binding mismatch: {len(vals)} vals vs {len(cols)} cols"
|
||||||
conn.commit()
|
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()
|
row = conn.execute("SELECT * FROM slabs WHERE id=?", (payload["id"],)).fetchone()
|
||||||
return _row_to_slab(row)
|
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]:
|
async def update_slab(id: str, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
if "client_rev" not in payload:
|
if "client_rev" not in payload:
|
||||||
return _err("validation", "client_rev required", status=400)
|
return _err("validation", "client_rev required", status=400)
|
||||||
# #89: the metadata lock is server-managed via the explicit review lock
|
# #89: server-managed fields a plain PUT must not set.
|
||||||
# action; a plain PUT may not flip it.
|
|
||||||
for field in CLIENT_FORBIDDEN_FIELDS:
|
for field in CLIENT_FORBIDDEN_FIELDS:
|
||||||
if field in payload:
|
if field in payload:
|
||||||
return _err("validation", f"{field} is set by the review lock action, not by update", status=400)
|
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()
|
conn = get_connection()
|
||||||
current = conn.execute("SELECT * FROM slabs WHERE id=? AND deleted_at IS NULL", (id,)).fetchone()
|
current = conn.execute("SELECT * FROM slabs WHERE id=? AND deleted_at IS NULL", (id,)).fetchone()
|
||||||
if current is None:
|
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",
|
return _err("revision_conflict", "client_rev mismatch",
|
||||||
{"current_server_rev": current["server_rev"]}, status=409)
|
{"current_server_rev": current["server_rev"]}, status=409)
|
||||||
if "sku" in payload and payload["sku"] != current["sku"]:
|
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)
|
return _err("duplicate_sku", "SKU already exists", {"sku": payload["sku"]}, status=409)
|
||||||
for field, table in TAXONOMY_TABLES.items():
|
tax_err = _validate_taxonomy_fields(conn, payload)
|
||||||
if field in payload and payload[field] is not None:
|
if tax_err is not None:
|
||||||
val = payload[field]
|
return tax_err
|
||||||
if isinstance(val, str):
|
# METADATA-15 / QUALITY-95: thickness band is derived, never owner-chosen.
|
||||||
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)
|
|
||||||
eff_thickness = payload.get("thickness_in", current["thickness_in"])
|
eff_thickness = payload.get("thickness_in", current["thickness_in"])
|
||||||
band = resolve_thickness_band_id(eff_thickness, load_thickness_terms(conn))
|
band = resolve_thickness_band_id(eff_thickness, load_thickness_terms(conn))
|
||||||
if band is not None:
|
if band is not None:
|
||||||
|
|||||||
@@ -300,3 +300,63 @@ def test_partial_media_failure_502(env, monkeypatch):
|
|||||||
# sync_log written, status failed
|
# sync_log written, status failed
|
||||||
assert conn.execute("SELECT status FROM slabs WHERE id='s1'").fetchone()["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
|
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()
|
||||||
|
|||||||
@@ -340,18 +340,45 @@ def test_create_rejects_unknown_fields(client, db):
|
|||||||
|
|
||||||
|
|
||||||
def test_update_stale_field_returns_live_options(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())
|
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"}
|
payload = {
|
||||||
client.post("/api/v1/slabs", json=payload)
|
"id": slab_id, "sku": "SKU-STALE", "length_in": 10, "thickness_in": 2, "sqft": 20, "bdft": 5,
|
||||||
# Submit a wood_category_id that doesn't exist in the taxonomy (id=999).
|
"width_min_in": 1, "width_max_in": 2, "width_avg_in": 1.5,
|
||||||
resp = client.put(f"/api/v1/slabs/{slab_id}", json={"client_rev": 1, "wood_category_ids": json.dumps([999])})
|
}
|
||||||
|
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
|
assert resp.status_code == 422
|
||||||
body = resp.json()
|
body = resp.json()
|
||||||
assert body["error"]["code"] == "stale_field"
|
assert body["error"]["code"] == "stale_field"
|
||||||
detail = body["error"]["detail"]
|
opts = body["error"]["detail"]["current_options"]
|
||||||
assert detail["submitted"] == [999]
|
assert isinstance(opts, list) and opts
|
||||||
# current_options must be the LIVE ids from woo_categories, not [999].
|
assert all(isinstance(o, dict) and "id" in o and "name" in o for o in opts)
|
||||||
assert 999 not in detail["current_options"]
|
assert 999 not in [o["id"] for o in opts]
|
||||||
# The seeded taxonomy has ids 1 and 2 in woo_categories.
|
assert "Edge type" in body["error"]["message"] or "edge" in body["error"]["message"].lower()
|
||||||
assert set(detail["current_options"]) == {1, 2}
|
|
||||||
|
|
||||||
|
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
@@ -98,14 +98,7 @@ class WooClient:
|
|||||||
|
|
||||||
def _get(self, path: str) -> tuple[int, dict, bytes]:
|
def _get(self, path: str) -> tuple[int, dict, bytes]:
|
||||||
url = f"{self.base_url}/wp-json/wc/v3{path}"
|
url = f"{self.base_url}/wp-json/wc/v3{path}"
|
||||||
return self._request(
|
return self._request(url, "GET", None, self._auth_headers())
|
||||||
url, "GET", None,
|
|
||||||
{
|
|
||||||
"Authorization": f"Basic {self._auth}",
|
|
||||||
"Accept": "application/json",
|
|
||||||
"User-Agent": _USER_AGENT,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
def _fetch_all(self, path: str) -> list[dict[str, Any]]:
|
def _fetch_all(self, path: str) -> list[dict[str, Any]]:
|
||||||
page = 1
|
page = 1
|
||||||
@@ -132,6 +125,42 @@ class WooClient:
|
|||||||
def get_attribute_terms(self, attr_id: int) -> list[dict[str, Any]]:
|
def get_attribute_terms(self, attr_id: int) -> list[dict[str, Any]]:
|
||||||
return self._fetch_all(f"/products/attributes/{attr_id}/terms")
|
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:
|
def upload_media(self, png_bytes: bytes, filename: str) -> int:
|
||||||
"""POST a PNG to WordPress core media; return the attachment id."""
|
"""POST a PNG to WordPress core media; return the attachment id."""
|
||||||
boundary = "----slabuploader-media-boundary"
|
boundary = "----slabuploader-media-boundary"
|
||||||
@@ -143,11 +172,7 @@ class WooClient:
|
|||||||
url = f"{self.base_url}/wp-json/wp/v2/media"
|
url = f"{self.base_url}/wp-json/wp/v2/media"
|
||||||
code, _headers, resp = self._request(
|
code, _headers, resp = self._request(
|
||||||
url, "POST", body,
|
url, "POST", body,
|
||||||
{
|
self._auth_headers({"Content-Type": f"multipart/form-data; boundary={boundary}"}),
|
||||||
"Authorization": f"Basic {self._auth}",
|
|
||||||
"Content-Type": f"multipart/form-data; boundary={boundary}",
|
|
||||||
"User-Agent": _USER_AGENT,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
if code not in (200, 201):
|
if code not in (200, 201):
|
||||||
raise WooServerError(f"media upload returned {code}")
|
raise WooServerError(f"media upload returned {code}")
|
||||||
|
|||||||
@@ -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
|
- 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
|
m3-create + review-photo-strip; the endpoint + PUT path are proven live above). Woo publish path
|
||||||
not exercised this round.
|
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.
|
||||||
|
|||||||
@@ -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">↻</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 ↻ 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">−</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}°</span>
|
||||||
|
</div>
|
||||||
|
<p class="nudge">← → tilt 1° · Shift + ← → tilt 0.1° · or drag the ↻ 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">−</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 · <b>+</b> / <b>−</b> keys step 10% · 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×44px target and drag it along the line. The tick you last grabbed takes the arrow keys: <b>↑ ↓</b> nudge 1px, Shift = 0.1px — 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}% the overlay's ends sit ${((1 - S.fill / 100) / 2 * 100).toFixed(1)}% 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 ✓"
|
||||||
|
: `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}°`}.`}</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)}″</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}° · zoom ${S.zoom.toFixed(2)}× <span class="dim">(moving these never changes the line)</span></p>
|
||||||
|
|
||||||
|
<div class="swap">
|
||||||
|
<figure>${oldOverlayMini()}<figcaption class="gone">✗ 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}°</span></li>
|
||||||
|
<li class="lineup-item">${mini(S.tilt, false)}<span class="role">Dimension overlay</span><span class="rot-badge yes">same ${S.tilt}°</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% the overlay's ends sit ${((1 - S.fill / 100) / 2 * 100).toFixed(1)}% 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", "⚙ 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,6 +8,11 @@
|
|||||||
import type { CalibratedCapture } from '../flow';
|
import type { CalibratedCapture } from '../flow';
|
||||||
import MultiSelect from './MultiSelect.svelte';
|
import MultiSelect from './MultiSelect.svelte';
|
||||||
import { deriveThicknessTermId } from './thicknessBand';
|
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).
|
// Props handed off by App (one-column flow, UX.md §1).
|
||||||
export let slabId: string;
|
export let slabId: string;
|
||||||
@@ -99,7 +104,6 @@
|
|||||||
let gradeSel: string = '';
|
let gradeSel: string = '';
|
||||||
let woodCatIds: number[] = [];
|
let woodCatIds: number[] = [];
|
||||||
let figureIds: number[] = [];
|
let figureIds: number[] = [];
|
||||||
let gradeId: number | '' = '';
|
|
||||||
let moistureId: number | '' = '';
|
let moistureId: number | '' = '';
|
||||||
let figTagIds: number[] = [];
|
let figTagIds: number[] = [];
|
||||||
let featTagIds: number[] = [];
|
let featTagIds: number[] = [];
|
||||||
@@ -128,7 +132,7 @@
|
|||||||
let createBusy = false;
|
let createBusy = false;
|
||||||
let createError = '';
|
let createError = '';
|
||||||
$: canCreateTitle =
|
$: canCreateTitle =
|
||||||
speciesId !== '' && woodCatIds.length > 0;
|
speciesSel !== '' && woodCatIds.length > 0;
|
||||||
$: canCreateDesc =
|
$: canCreateDesc =
|
||||||
canGenerateContent && title.trim() !== '' && shortDescription.trim() !== '';
|
canGenerateContent && title.trim() !== '' && shortDescription.trim() !== '';
|
||||||
// Monotonic token: each commit captures the current value and ignores its
|
// Monotonic token: each commit captures the current value and ignores its
|
||||||
@@ -320,7 +324,7 @@
|
|||||||
woodCatIds = [...(s.wood_category_ids ?? [])];
|
woodCatIds = [...(s.wood_category_ids ?? [])];
|
||||||
edgeSel = s.edge_type_term_id != null ? String(s.edge_type_term_id) : '';
|
edgeSel = s.edge_type_term_id != null ? String(s.edge_type_term_id) : '';
|
||||||
figureIds = [...(s.figure_term_ids ?? [])];
|
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 ?? '';
|
moistureId = s.moisture_term_id ?? '';
|
||||||
figTagIds = [...(s.fig_tag_ids ?? [])];
|
figTagIds = [...(s.fig_tag_ids ?? [])];
|
||||||
featTagIds = [...(s.feat_tag_ids ?? [])];
|
featTagIds = [...(s.feat_tag_ids ?? [])];
|
||||||
@@ -393,7 +397,7 @@
|
|||||||
wood_category_ids: woodCatIds,
|
wood_category_ids: woodCatIds,
|
||||||
edge_type_term_id: edgeSel === '' ? null : Number(edgeSel),
|
edge_type_term_id: edgeSel === '' ? null : Number(edgeSel),
|
||||||
figure_term_ids: figureIds,
|
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),
|
moisture_term_id: moistureId === '' ? null : Number(moistureId),
|
||||||
fig_tag_ids: figTagIds,
|
fig_tag_ids: figTagIds,
|
||||||
feat_tag_ids: featTagIds,
|
feat_tag_ids: featTagIds,
|
||||||
|
|||||||
Reference in New Issue
Block a user