35 Commits

Author SHA1 Message Date
grokbot_ty fc40c66c44 docs(deploy): overnight 10890 after Measure gesture modes 2026-09-26 12:01:35 -07:00
grokbot_ty f505be0bbf fix(capture): Zoom/Pan vs Rotate gesture modes; drop dead ↻ overlay
Pinch/drag were applying zoom and tilt together on Measure. Add an
explicit Zoom/Pan | Rotate segmented toggle (default Zoom/Pan). Stage
gestures are exclusive per mode; tilt/zoom sliders stay available.

Remove the bottom-center ↻ handle that sat on tick B (hitTick won, so
the icon looked live but only moved the tick).
2026-09-26 12:01:09 -07:00
grokbot_ty 685acc10dd docs(deploy): overnight 10890 after Publish preview 2026-09-26 11:47:55 -07:00
grokbot_ty 73e28180c8 feat(publish): Publish preview before Woo draft POST
After Review → Continue to Publish, show a glanceable confirmation:
ordered photo thumbs, SKU/title/price, taxonomy + dims, short and long
descriptions (plain text). Back to Review edits; Publish posts the draft.
Reuses slab/taxonomy/photos only — no invented fields.
2026-09-26 11:47:18 -07:00
grokbot_ty 8f8e0132e6 docs(deploy): overnight 10890 after Review UI nits 2026-09-26 11:08:50 -07:00
grokbot_ty 5651a38058 fix(review): UI nits — margins, buttons, moisture, live validation
Post-publish: hide Back while publishing; drop Settings/Slabs chrome on
done (remove unused white Slabs control). Review/App horizontal padding.
Unify Create desc / View prompt / Create title|short as .btn-sec.
Moisture kd default: sync select after form mount; persist via commit.
Ready/missing update on input (debounced) without blur.
2026-09-26 11:08:08 -07:00
grokbot_ty 88278a55e8 fix(call2): no metadata dump in long description
Long desc is Hook + Benefits (+ brief trust close) only; forbid Full specs /
Attribute list / product-name header that re-lists DECLARED ATTRIBUTES or
DIMENSION FACT BLOCK. Defensively strip trailing Category:/Length:/Board Feet:
labeled dumps in parse.
2026-09-26 10:52:53 -07:00
grokbot_ty 7e0157e45b fix(call2): plain text + woodworker tone
Review short/long must not show HTML tags; seed now requires plain text
only (newlines/- bullets) and practical woodworker copy. parse_call2_response
strips residual HTML as a safety net. Seed activation unchanged.
2026-09-26 10:45:28 -07:00
grokbot_ty c25e3f4927 feat(call2): short/long desc structure in seed (not brand voice)
Call 2 seed now requires benefit-first short + four-part long HTML, returned
as JSON. POST parses and persists both fields; Create-desc gate no longer
requires short (Call 2 writes it). Brand voice/geo stay Settings-injected.
2026-09-26 10:35:53 -07:00
grokbot_ty 076eb17db6 fix(review): Create desc gate does not require price 2026-09-26 10:30:55 -07:00
grokbot_ty 1464cac5f7 docs(deploy): overnight 10890 after View prompt / Call 2 package 2026-09-26 10:12:47 -07:00
grokbot_ty 1ff082f77c feat(review): View prompt + Call 2 declared attrs, Create-desc gate
Shared assemble_call2 for GET .../call2/prompt and POST call2 (seed +
DECLARED ATTRIBUTES + dimension fact + brand/geo; photo slots without
base64). Create desc disabled until content LLM + title/short + species +
wood cats/primary + edge + ≥1 figure + grade + moisture + price; show
missing. figure_term_ids required for ready. Moisture defaults to Kiln
dried (kd). Pin snl / liveedge in wood-cat UI. Ready-on-change via
MultiSelect onCommit.
2026-09-26 10:11:50 -07:00
grokbot_ty a801a757f8 docs(deploy): overnight 10890 after Review metadata slug/decode fixes 2026-09-26 09:42:30 -07:00
grokbot_ty 85786f16f2 fix(review): slug-scoped tags, decode names, drop fig picker & AI labels
- Taxonomy sync keeps fig-/feat- by slug (hyphen), not name; HTML-unescape names
- Review: remove AI assist labels; Features picker for feat-*; derive fig_tag_ids
  from Figure term names (casefold match, no bridges); decodeName on display
- Unit tests for scoped_tags, fig match, Review UI contract; docs slug wording
2026-09-26 09:41:30 -07:00
grokbot_ty c54870f5b9 docs(deploy): overnight 10890 after PNG floor warn/block split 2026-09-26 09:12:09 -07:00
grokbot_ty 1b5ad112cc fix(capture): warn 300–799px PNG floor; hard-block only below 300
Split FLOOR_WARN_PX=800 / FLOOR_BLOCK_PX=300. Warn-band still encodes and
includes the photo (Photos-ready + batchApply ok+warn). Block excludes
siblings and clean cal. CAPTURE-105 / CAPTURE.md / PHOTOS.md updated.
2026-09-26 09:11:34 -07:00
grokbot_ty df87d97a7d docs(deploy): overnight 10890 after species Create-title fix 2026-09-26 08:58:17 -07:00
grokbot_ty 76f30262f3 fix(review): carry Measure species into Create title/short-desc
Live overnight slabs after removing the Review species picker had
species_id null, so Create title stayed gated off. Require species on
Measure Calculate, stop the select sync from wiping choices before
options load, and have Review gate/PUT from slab.species_id with a
Capture handoff repair so deterministic title/short-desc always see it.
2026-09-26 08:57:53 -07:00
grokbot_ty 610b0883fe feat(review): remove species picker; species stays from Measure
Species is chosen on the merged Measure screen; the Review select was a
duplicate. Keep species_id on the slab from createSlab (Capture→draft→Woo)
and gate Create title from the hydrated value without offering edit.
2026-09-26 08:47:40 -07:00
grokbot_ty 89bc7d4ee9 fix(measure): show masked calibration preview 2026-09-26 08:38:17 -07:00
grokbot_ty 0dd5d255ec fix(mask): keep only largest connected slab component
Sheet gaps after black/green removal left stray chunks in the bitset.
After keep predicates, zero all but the largest 4-connected component
and recompute slabPx before bbox / slabBounds.
2026-09-26 08:33:19 -07:00
grokbot_ty b55344ec05 feat(capture): merge tape orientation into Measure with species
After mask approve, land on one Measure screen: interactive tape
PhotoStage plus Length L, Thickness, and Species (Woo taxonomy leaves).
Approve tape is folded into Calculate. Persist species_id on the device
draft and pass it through createSlab so Review pre-fills.
2026-09-26 08:22:08 -07:00
grokbot_ty 4ea2618475 fix(overlay): Photos-ready dims follow tape, not wood MAR
After rotateCalibration, map tape tick A→B through bakeTiltTransform into
baked cal coords, orient overlay bbox with orientedRect(..., tapeAngle), and
pass optional axis into buildOverlayPlan so length || tape and widths ⊥ tape.
2026-09-26 08:08:21 -07:00
grokbot_ty d7fc424b69 fix(capture): damp tape gesture sensitivity 2026-09-26 07:58:36 -07:00
grokbot_ty b841b16cc0 fix(capture): ±90° tilt slider midpoint + two-finger rotate
Clamp tape tilt to [-90, 90] so the slider knob starts centered and
CCW works. PhotoStage pinch now tracks angle as well as zoom.
2026-09-26 07:54:36 -07:00
grokbot_ty ba30a340e6 docs(deploy): overnight prop on 10890/10891 (slab-overnight) 2026-09-25 22:06:48 -07:00
grokbot_ty 0c263c6563 docs(deploy): record overnight-whole-app prop on ubudual5060 :10881 2026-09-25 22:05:41 -07:00
grokbot_ty 6caa4f62e7 fix(uat): allow Cloudflare quick-tunnel Host on vite preview
So phone/laptop without Tailscale can hit overnight UAT via trycloudflare.com.
2026-09-25 22:01:44 -07:00
grokbot_ty 2ac0f6f48e fix(review): adopt latest server_rev on PUT; retry once on 409
Review was POSTing price/sku to /review before each save; that endpoint
persists those overrides and bumps server_rev, so the following PUT sent
a stale client_rev and hit 409 — Continue to Publish stayed disabled.
Stop sending price/sku on review probes (PUT already persists them) and
have updateSlab refetch + retry once on revision_conflict.
2026-09-25 18:14:38 -07:00
grokbot_ty f5b84f1ad7 chore(deploy): overnight box UAT on :18181 / API :18182
Document Grok Bot computer UAT in deploy/INVENTORY.md.
Vite preview proxies /api → FastAPI :18182. Ignore .uat-data/ and
.venv-overnight/. Never bind :10880.
2026-09-25 17:50:14 -07:00
grokbot_ty 65589ac4b4 chore(review): drop unused Call 1 aibar CSS after disable 2026-09-25 17:47:12 -07:00
grokbot_ty 087557f89f chore(inference): disable Call 1 end-to-end; Call 2 stays
overnight-whole-app: Call 1 (vision classification) is OUT.
- POST /api/v1/slabs/{id}/inference/call1 always returns 410 call1_disabled
  (no model chat, no inference_log). Prompt seed/revision APIs kept dormant.
- Review: remove "Suggest with AI" / runInference (inferTaxon).
- GenerateText: remove Classify metadata (Call 1) button; Call 2 generate stays.
- PROMPTS.md banner documents Call 1 OUT / Call 2 STAYS.
- Settings: inference_enabled toggle annotated as Call-1 latch (dormant).
- Backend tests updated to assert 410; Call 2 tests unchanged.
2026-09-25 17:46:39 -07:00
grokbot_ty 60700b47fb feat(capture): tape-line overlay UI replaces axis rectangle (#99)
Pointer-Events pan/zoom/tilt under a fixed black ~2pt line with
rifle-scope end ticks (≥44×44 hit). AxisPanel becomes Tape line step;
session persists tape transform; scale = tick distance / L.
sveltedrag unavailable for Svelte 4 — Pointer Events path used.
2026-09-25 17:44:23 -07:00
grokbot_ty 52240083b3 feat(capture): shared crop_fill + tape geometry for #99
- One crop_fill (default 0.9) replaces duplicated FILL in inventory/overlay
- Thin localStorage settings store; crop % only moves long-axis end insets
- Pure tapeGeometry: fixed stage line, stage↔image map, tick span → px
- measure() prefers tape tick span for scale; maskSpanAlong demoted
- rotateCalibration bakes cal tilt into inventory + overlay only
- Fix stale 80%-fill comment on slabBounds
2026-09-25 17:44:07 -07:00
grokbot_ty d20e3d9077 docs(design): add approved #99 tape-line overlay wireframe
Wireframe settles fixed middle line + movable slab, tick-declared L,
and shared crop_fill. Scaffolding scrubbed before shipping into the app.
2026-09-25 17:44:07 -07:00
77 changed files with 5384 additions and 624 deletions
+3
View File
@@ -43,3 +43,6 @@ frontend/tests/e2e/test-results/
frontend/tests/e2e/playwright-report/
frontend/blob-report/
frontend/playwright/.cache/
.uat-data/
.venv-overnight/
+474 -78
View File
@@ -1,10 +1,14 @@
"""Inference (S7): prompt revisions (REST), Call 1 vision classification, Call 2
content prose, test-inference, inference-off server description, inference_log.
Cites METADATA-35..115, SETTINGS-30/45/90.
overnight-whole-app: Call 1 POST is hard-disabled (410 call1_disabled). Call 2 stays.
"""
import base64
import hashlib
import html
import re
import io
import json
import time
@@ -104,10 +108,49 @@ CALL1_SEED = (
'"feature_tag_ids": [<id>,...], "feature_tag_confidence": <0-1>}'
)
CALL2_SEED = (
"Write the long product description for the slab from the supplied photos and listing "
"facts. The name and short description are already built, so do not repeat them. Use the "
"brand voice and geo context given. The dimensions in the fact block are exact: use those "
"strings and invent no numbers. Write prose a premium lumber buyer would read."
"Write listing copy for this slab for woodworkers choosing stock for a project. "
"Audience: makers matching wood to a tabletop, bar, bench, or similar build — practical, "
"material/figure/spec-forward. No lifestyle or marketing hype "
"(avoid empty phrases like elevate, stunning, dramatic visual contrast).\n"
"You write BOTH the short description and the long description.\n"
"\n"
"PLAIN TEXT ONLY (short AND long):\n"
"- Output plain text only. No HTML tags (<p>, <ul>, <li>, <strong>, <br>, etc.). "
"No markdown bold/headers that look like markup (**bold**, # headers).\n"
"- Structure the long description with newlines and \"- \" bullets when helpful.\n"
"\n"
"Rules that always apply:\n"
"- Dimensions in the DIMENSION FACT BLOCK are exact: use those strings; invent no numbers.\n"
"- Use taxonomy names from DECLARED ATTRIBUTES; do not invent species, edge, figure, grade, "
"moisture, or features.\n"
"- Use the Brand voice and Geography sections when provided. If Brand voice is empty, stay "
"neutral. Never invent return, warranty, or shipping policies.\n"
"- The product name may already appear in DECLARED ATTRIBUTES — do not invent a new title.\n"
"- Lead with usable traits (species, figure, size, edge, moisture, grade) and what the piece "
"suits (tabletop, bar, etc.) without ad-copy fluff.\n"
"- Specs live in taxonomy and dimension fields on the product. Do NOT restate DECLARED "
"ATTRIBUTES or the DIMENSION FACT BLOCK as a labeled metadata list in the description "
"(no Category:/Figure:/Edge:/Grade:/Moisture:/Length:/Board Feet: dump, no \"Full specs\" "
"section, no \"Maple Slab\" attribute-header block). You may weave 1–2 key traits into "
"prose or benefit bullets naturally.\n"
"\n"
"SHORT DESCRIPTION (~50 words — most important on-page copy; plain text only):\n"
"1. Lead with a practical outcome for a maker (what they can build / how the stock helps), "
"not lifestyle hype and not a bare species list.\n"
"2. Add the key differentiator or most important spec (figure, edge, size, moisture, grade).\n"
"3. Close with a trust signal or concrete reason to choose this piece.\n"
"\n"
"LONG DESCRIPTION (plain text only — newlines and \"- \" bullets; no HTML, no markdown "
"markup). Hook + Benefits only (+ brief trust close). Do NOT add a Full specs / Attribute "
"list / product-name header that re-lists DECLARED ATTRIBUTES or DIMENSION FACT BLOCK:\n"
"1. Hook — one sentence on what the piece suits and its standout usable trait.\n"
"2. Benefits — 3–5 bullets; each maker-useful trait first, then the supporting detail. "
"Weave in at most 1–2 key traits naturally — not an exhaustive labeled catalog.\n"
"3. Trust close (optional, brief) — return/warranty/brand; use Brand voice when present; "
"otherwise a short neutral close with no invented policies.\n"
"\n"
"Return ONE JSON object only (no prose outside JSON) with exactly these keys:\n"
'{"short_description": "<plain text ~50 words>", "description": "<plain text long description>"}'
)
_SEEDS = {"call1": CALL1_SEED, "call2": CALL2_SEED}
_THRESHOLD = 0.7
@@ -295,51 +338,21 @@ def _enabled(conn, key: str) -> bool:
@router.post("/slabs/{slab_id}/inference/call1")
async def call1(slab_id: str) -> dict:
conn = get_connection()
row = conn.execute("SELECT * FROM slabs WHERE id=? AND deleted_at IS NULL", (slab_id,)).fetchone()
if row is None:
return JSONResponse(status_code=404, content=_error("not_found", "Slab not found"))
if not _enabled(conn, "inference_enabled"):
return JSONResponse(status_code=409, content=_error("inference_disabled", "Inference is off"))
base, key, model = _inference_creds(conn)
if not base or not key:
return JSONResponse(status_code=409, content=_error("inference_unconfigured", "Inference is not configured"))
client = InferenceClient(base, key, model)
_seed_prompts(conn)
conn.commit()
act = _active_prompt(conn, "call1")
prompt_text = act[1] if act else CALL1_SEED
# Build vision message with processed photos (METADATA-35: long edge <= 1024) + taxonomy.
photos = conn.execute(
"SELECT id, seq, processed_blob FROM slab_photos "
"WHERE slab_id=? AND processed_blob IS NOT NULL ORDER BY seq LIMIT 8",
(slab_id,),
).fetchall()
image_parts = []
photo_meta: list[dict] = []
for p in photos:
blob = p["processed_blob"] if hasattr(p, "keys") else p[2]
pid = p["id"] if hasattr(p, "keys") else p[0]
seq = p["seq"] if hasattr(p, "keys") else p[1]
image_parts.append(_image_part_from_processed(blob))
photo_meta.append(_photo_meta_row(pid, seq, blob))
tax = _taxonomy_lists(conn)
text = (f"{prompt_text}\n\nLISTS:\n{json.dumps(tax, ensure_ascii=False)}\n\n"
f"SKU={row['sku']} length_in={row['length_in']} thickness_in={row['thickness_in']}")
messages = [{"role": "user", "content": image_parts + [{"type": "text", "text": text}]}]
t0 = time.time()
try:
raw = client.chat(messages)
except InferenceConnectionError as e:
return JSONResponse(status_code=503, content=_error("inference_unreachable", str(e), {"recoverable": True}))
except InferenceError as e:
return JSONResponse(status_code=502, content=_error("inference_failed", str(e), {"recoverable": True}))
latency = int((time.time() - t0) * 1000)
parsed = _parse_json(raw)
_log_call(conn, slab_id, 1, act[0] if act else None, model, messages, raw, latency, photo_meta)
# Apply confidence gate (METADATA-45).
suggestions = _gate(parsed)
return {"ok": True, "suggestions": suggestions}
"""Call 1 OUT (overnight-whole-app): hard-disabled. Call 2 stays.
Route retained so clients get an explicit 410 instead of a half-wired path.
Prompt seed / revision APIs for call1 remain for docs & future re-enable.
"""
_ = slab_id # unused while disabled
return JSONResponse(
status_code=410,
content=_error(
"call1_disabled",
"Call 1 (vision classification) is disabled on this branch. Use Call 2 for description text.",
{"call": 1, "disabled": True},
),
)
def _feat_display_name(name: str) -> str:
@@ -464,39 +477,409 @@ def _gate(parsed: dict) -> dict:
return out
@router.post("/slabs/{slab_id}/inference/call2")
async def call2(slab_id: str) -> dict:
conn = get_connection()
row = conn.execute("SELECT * FROM slabs WHERE id=? AND deleted_at IS NULL", (slab_id,)).fetchone()
if row is None:
return JSONResponse(status_code=404, content=_error("not_found", "Slab not found"))
if not _enabled(conn, "content_llm_enabled"):
return JSONResponse(status_code=409, content=_error("content_disabled", "Content generation is off"))
base, key, model = _inference_creds(conn)
if not base or not key:
return JSONResponse(status_code=409, content=_error("inference_unconfigured", "Inference is not configured"))
client = InferenceClient(base, key, model)
act = _active_prompt(conn, "call2")
prompt_text = act[1] if act else CALL2_SEED
def _json_list(val: Any) -> list:
if val is None:
return []
if isinstance(val, list):
return val
if isinstance(val, str):
try:
parsed = json.loads(val)
return parsed if isinstance(parsed, list) else []
except (ValueError, TypeError):
return []
return []
def _term_name(terms: dict, term_id: Any) -> str:
if not term_id:
return ""
t = terms.get(int(term_id) if not isinstance(term_id, int) and str(term_id).isdigit() else term_id) or {}
try:
from .taxonomy import _decode_name
return _decode_name(t.get("name") or "")
except Exception:
return str(t.get("name") or "")
def _cat_name(cats: dict, cat_id: Any) -> str:
if not cat_id:
return ""
c = cats.get(int(cat_id) if not isinstance(cat_id, int) and str(cat_id).isdigit() else cat_id) or {}
try:
from .taxonomy import _decode_name
return _decode_name(c.get("name") or "")
except Exception:
return str(c.get("name") or "")
def _parent_path(cats: dict, cat_id: Any) -> str:
"""Parent-category path for a wood-category leaf (METADATA-60)."""
if not cat_id:
return ""
cid = int(cat_id) if not isinstance(cat_id, int) and str(cat_id).isdigit() else cat_id
leaf = cats.get(cid) or {}
parts: list[str] = []
pid = leaf.get("parent_id")
seen = set()
while pid and pid not in seen:
seen.add(pid)
parent = cats.get(pid) or {}
name = _cat_name(cats, pid)
if name:
parts.append(name)
pid = parent.get("parent_id")
parts.reverse()
leaf_name = _cat_name(cats, cid)
if leaf_name:
parts.append(leaf_name)
return " > ".join(parts)
def _feat_names(conn, feat_ids: list) -> list[str]:
if not feat_ids:
return []
q = ",".join("?" * len(feat_ids))
rows = conn.execute(
f"SELECT id, name FROM woo_tags WHERE id IN ({q})", feat_ids
).fetchall()
by_id = {r[0]: _feat_display_name(r[1]) for r in rows}
return [by_id[i] for i in feat_ids if i in by_id]
def build_declared_attributes(conn, slab: dict) -> str:
"""Structured DECLARED ATTRIBUTES from the slab for Call 2 (METADATA-60).
Names only — no hardcoded Curl≠Curly bridges. Primary = first wood category
when not otherwise known; secondary = the rest.
"""
cats = {r["id"]: dict(r) for r in conn.execute("SELECT * FROM woo_categories").fetchall()}
terms = {r["id"]: dict(r) for r in conn.execute("SELECT * FROM woo_attribute_terms").fetchall()}
wood_ids = _json_list(slab.get("wood_category_ids"))
primary_id = wood_ids[0] if wood_ids else None
secondary_ids = wood_ids[1:] if len(wood_ids) > 1 else []
figure_ids = _json_list(slab.get("figure_term_ids"))
feat_ids = _json_list(slab.get("feat_tag_ids"))
lines = [
"DECLARED ATTRIBUTES (confirmed in review — use these names; do not invent taxonomy):",
f"species: {_cat_name(cats, slab.get('species_id')) or '(none)'}",
f"primary wood category: {_cat_name(cats, primary_id) or '(none)'}",
f"primary path: {_parent_path(cats, primary_id) or '(none)'}",
f"secondary wood categories: {', '.join(_cat_name(cats, i) for i in secondary_ids) or '(none)'}",
f"edge: {_term_name(terms, slab.get('edge_type_term_id')) or '(none)'}",
f"figure: {', '.join(_term_name(terms, i) for i in figure_ids) or '(none)'}",
f"grade: {_term_name(terms, slab.get('grade_term_id')) or '(none)'}",
f"moisture: {_term_name(terms, slab.get('moisture_term_id')) or '(none)'}",
f"features: {', '.join(_feat_names(conn, feat_ids)) or '(none)'}",
]
title = (slab.get("title") or "").strip()
if title:
lines.append(f"title: {title}")
# short_description is Call 2 output — do not feed a prior value back into the prompt.
return "\n".join(lines)
def build_dimension_fact_block(slab: dict) -> str:
"""Exact dimension strings — invent no numbers (METADATA-65/90)."""
return (
"DIMENSION FACT BLOCK (exact, invent no numbers):\n"
f"length={slab.get('length_in')}in thickness={slab.get('thickness_in')}in "
f"avg_width={slab.get('width_avg_in')}in width_min={slab.get('width_min_in')}in "
f"width_max={slab.get('width_max_in')}in bdft={slab.get('bdft')}"
)
def assemble_call2_text(conn, slab: dict, prompt_text: str | None = None) -> str:
"""Assemble the Call 2 text payload: seed + declared attrs + dims + brand/geo.
Shared by View-prompt GET and POST .../inference/call2 so both show the same text.
"""
seed = prompt_text
if seed is None:
act = _active_prompt(conn, "call2")
seed = act[1] if act else CALL2_SEED
brand = conn.execute("SELECT value FROM settings WHERE key='brand_voice'").fetchone()
geo = conn.execute("SELECT value FROM settings WHERE key='geo_context'").fetchone()
brand_s = brand[0] if brand and brand[0] else ""
geo_s = geo[0] if geo and geo[0] else ""
return (
f"{seed}\n\n"
f"{build_declared_attributes(conn, slab)}\n\n"
f"{build_dimension_fact_block(slab)}\n\n"
f"Brand voice: {brand_s}\n"
f"Geo: {geo_s}"
)
def assemble_call2_photo_slots(conn, slab_id: str, *, for_wire: bool = False) -> tuple[list[dict], list[dict]]:
"""Up to 8 processed PNGs for Call 2.
Returns (image_parts for the model, photo_slots without base64 for View prompt / log).
Slot list: seq, id, bytes, long_edge_px of the *fitted* infer payload.
"""
photos = conn.execute(
"SELECT id, seq, processed_blob FROM slab_photos "
"WHERE slab_id=? AND processed_blob IS NOT NULL ORDER BY seq LIMIT 8",
(slab_id,),
).fetchall()
image_parts = []
photo_meta: list[dict] = []
image_parts: list[dict] = []
photo_slots: list[dict] = []
for p in photos:
blob = p["processed_blob"] if hasattr(p, "keys") else p[2]
pid = p["id"] if hasattr(p, "keys") else p[0]
seq = p["seq"] if hasattr(p, "keys") else p[1]
image_parts.append(_image_part_from_processed(blob))
photo_meta.append(_photo_meta_row(pid, seq, blob))
brand = conn.execute("SELECT value FROM settings WHERE key='brand_voice'").fetchone()
geo = conn.execute("SELECT value FROM settings WHERE key='geo_context'").fetchone()
text = (f"{prompt_text}\n\nDIMENSION FACT BLOCK (exact, invent no numbers):\n"
f"length={row['length_in']}in thickness={row['thickness_in']}in "
f"avg_width={row['width_avg_in']}in width_min={row['width_min_in']}in width_max={row['width_max_in']}in "
f"bdft={row['bdft']}\n\nBrand voice: {brand[0] if brand else ''}\nGeo: {geo[0] if geo else ''}")
fitted = fit_long_edge_png(blob)
if for_wire:
image_parts.append(_image_part_from_processed(blob))
meta = _photo_meta_row(pid, seq, fitted)
photo_slots.append({
"seq": meta["seq"],
"id": meta["photo_id"],
"bytes": meta["bytes"],
"long_edge_px": meta["infer_long_edge_px"],
})
return image_parts, photo_slots
def assemble_call2(conn, slab_id: str) -> dict[str, Any] | None:
"""Full Call 2 assembly used by View prompt and the live POST.
Returns None if the slab is missing. Does not call the model.
"""
row = conn.execute(
"SELECT * FROM slabs WHERE id=? AND deleted_at IS NULL", (slab_id,)
).fetchone()
if row is None:
return None
slab = dict(row)
for f in ("wood_category_ids", "figure_term_ids", "fig_tag_ids", "feat_tag_ids"):
if slab.get(f) is not None and isinstance(slab[f], str):
try:
slab[f] = json.loads(slab[f])
except (ValueError, TypeError):
slab[f] = []
act = _active_prompt(conn, "call2")
prompt_text = act[1] if act else CALL2_SEED
rev_id = act[0] if act else None
text = assemble_call2_text(conn, slab, prompt_text)
image_parts, photo_slots = assemble_call2_photo_slots(conn, slab_id, for_wire=True)
# photo_meta for the inference log (same order as image_parts)
photo_meta = [
{
"photo_id": s["id"],
"seq": s["seq"],
"bytes": s["bytes"],
"infer_long_edge_px": s["long_edge_px"],
"label": f"photo seq={s['seq']} id={s['id']}",
}
for s in photo_slots
]
return {
"text": text,
"photos": photo_slots,
"image_parts": image_parts,
"photo_meta": photo_meta,
"prompt_revision_id": rev_id,
"slab": slab,
}
@router.get("/slabs/{slab_id}/inference/call2/prompt")
async def call2_prompt(slab_id: str) -> dict:
"""View prompt: exact Call 2 text + photo slot list (no base64, no model call)."""
conn = get_connection()
assembled = assemble_call2(conn, slab_id)
if assembled is None:
return JSONResponse(status_code=404, content=_error("not_found", "Slab not found"))
return {
"text": assembled["text"],
"photos": assembled["photos"],
"prompt_revision_id": assembled["prompt_revision_id"],
}
# Labels that mark a Woo/taxonomy/dimension metadata dump when used as "- Label: …".
_ATTR_DUMP_LABELS = (
"Category",
"Primary",
"Secondary",
"Species",
"Figure",
"Edge",
"Grade",
"Moisture",
"Features",
"Feature",
"Title",
"Length",
"Thickness",
"Width",
"Avg Width",
"Average Width",
"Width Min",
"Width Max",
"Min Width",
"Max Width",
"Board Feet",
"Bdft",
"BF",
"Dimensions",
"Dims",
)
_ATTR_DUMP_LABEL_RE = re.compile(
r"(?i)^\s*-\s*(?:"
+ "|".join(re.escape(l) for l in _ATTR_DUMP_LABELS)
+ r")\s*:",
)
_ATTR_DUMP_HEADER_RE = re.compile(
r"(?i)^\s*(?:Full\s+specs?|Specifications?|Attributes?|Attribute\s+list|"
r"[A-Za-z][A-Za-z0-9 /&-]{0,40}\s+Slab)\s*$",
)
def _strip_attribute_dump(value: str) -> str:
"""Remove a labeled Category:/Length:/Board Feet: metadata dump from long desc.
Specs live in taxonomy/dims on the product; a trailing (or mid-body) block that
re-lists them as "- Label: value" lines drifts from Woo fields. Keeps Hook +
Benefits (+ trust close) prose around the dump.
"""
if not value:
return ""
lines = value.split("\n")
n = len(lines)
is_attr = [_ATTR_DUMP_LABEL_RE.match(ln) is not None for ln in lines]
# Find contiguous runs of ≥3 attribute bullets (optionally preceded by a header).
runs: list[tuple[int, int]] = []
i = 0
while i < n:
if not is_attr[i]:
i += 1
continue
start = i
while i < n and is_attr[i]:
i += 1
end = i # exclusive
if end - start >= 3:
# Absorb a single preceding header / "Maple Slab" title line.
hdr = start - 1
if hdr >= 0 and lines[hdr].strip() and _ATTR_DUMP_HEADER_RE.match(lines[hdr]):
# Do not swallow a benefits bullet that happens to match poorly.
if not is_attr[hdr]:
start = hdr
# Absorb blank lines immediately before the block.
while start > 0 and not lines[start - 1].strip():
start -= 1
runs.append((start, end))
if not runs:
return value.strip()
# Prefer the last run (typical trailing dump); drop all qualifying runs.
keep = [True] * n
for start, end in runs:
for j in range(start, end):
keep[j] = False
out_lines = [ln for j, ln in enumerate(lines) if keep[j]]
text = "\n".join(out_lines)
text = re.sub(r"\n{3,}", "\n\n", text)
return text.strip()
def _html_to_plain(value: str) -> str:
"""Defensive: strip HTML/markup so Review fields never show raw tags.
Converts common block tags to newlines and <li> to "- " bullets, then removes
remaining tags and unescapes entities. Already-plain text is returned cleaned.
"""
if not value:
return ""
t = value
# Normalize common break / list / paragraph boundaries before dropping tags.
t = re.sub(r"(?is)<br\s*/?>", "\n", t)
t = re.sub(r"(?is)</\s*(p|div|h[1-6]|tr|table)\s*>", "\n", t)
t = re.sub(r"(?is)<\s*li[^>]*>", "- ", t)
t = re.sub(r"(?is)</\s*(ul|ol)\s*>", "\n", t)
t = re.sub(r"(?is)<[^>]+>", "", t)
t = html.unescape(t)
# Drop leftover markdown-ish bold/headers that look like markup in Review.
t = re.sub(r"(?m)^#{1,6}\s+", "", t)
t = re.sub(r"\*\*([^*]+)\*\*", r"\1", t)
t = re.sub(r"[ \t]+\n", "\n", t)
t = re.sub(r"\n{3,}", "\n\n", t)
return t.strip()
def _plain_long_desc(value: str) -> str:
"""HTML→plain then drop labeled attribute/metadata dumps from long description."""
return _strip_attribute_dump(_html_to_plain(value))
def parse_call2_response(raw: str) -> tuple[str | None, str]:
"""Split Call 2 model output into (short_description, description).
Preferred shape is a JSON object with short_description + description.
Both fields are forced to plain text (HTML stripped) before return.
Long description also drops a trailing/mid labeled attribute dump
(Category:/Length:/Board Feet: …) so specs stay on Woo fields.
If JSON is missing or incomplete, treat the whole body as description
(stripped) and leave short_description None (caller keeps any prior short).
"""
text = (raw or "").strip()
if not text:
return None, ""
# Strip common markdown fences around JSON.
body = text
if body.startswith("```"):
lines = body.split("\n")
if lines and lines[0].startswith("```"):
lines = lines[1:]
if lines and lines[-1].strip() == "```":
lines = lines[:-1]
body = "\n".join(lines).strip()
try:
obj = json.loads(body)
except (ValueError, TypeError):
# Try to find a JSON object embedded in prose.
start = body.find("{")
end = body.rfind("}")
if start >= 0 and end > start:
try:
obj = json.loads(body[start : end + 1])
except (ValueError, TypeError):
return None, _plain_long_desc(text)
else:
return None, _plain_long_desc(text)
if not isinstance(obj, dict):
return None, _plain_long_desc(text)
short = obj.get("short_description")
desc = obj.get("description")
if isinstance(short, str):
short = _html_to_plain(short) or None
else:
short = None
if isinstance(desc, str) and desc.strip():
return short, _plain_long_desc(desc)
# JSON without a usable description — fall back to raw (stripped).
return short, _plain_long_desc(text)
@router.post("/slabs/{slab_id}/inference/call2")
async def call2(slab_id: str) -> dict:
conn = get_connection()
if not _enabled(conn, "content_llm_enabled"):
return JSONResponse(status_code=409, content=_error("content_disabled", "Content generation is off"))
base, key, model = _inference_creds(conn)
if not base or not key:
return JSONResponse(status_code=409, content=_error("inference_unconfigured", "Inference is not configured"))
assembled = assemble_call2(conn, slab_id)
if assembled is None:
return JSONResponse(status_code=404, content=_error("not_found", "Slab not found"))
client = InferenceClient(base, key, model)
text = assembled["text"]
image_parts = assembled["image_parts"]
photo_meta = assembled["photo_meta"]
messages = [{"role": "user", "content": image_parts + [{"type": "text", "text": text}]}]
t0 = time.time()
try:
@@ -506,12 +889,25 @@ async def call2(slab_id: str) -> dict:
except InferenceError as e:
return JSONResponse(status_code=502, content=_error("inference_failed", str(e), {"recoverable": True}))
latency = int((time.time() - t0) * 1000)
_log_call(conn, slab_id, 2, act[0] if act else None, model, messages, raw, latency, photo_meta)
# Store as description, mark content_source=llm.
conn.execute("UPDATE slabs SET description=?, content_source='llm', updated_at=? WHERE id=?",
(raw.strip(), _now(), slab_id))
_log_call(
conn, slab_id, 2, assembled["prompt_revision_id"], model, messages, raw, latency, photo_meta,
)
short, description = parse_call2_response(raw)
if short:
conn.execute(
"UPDATE slabs SET short_description=?, description=?, content_source='llm', updated_at=? WHERE id=?",
(short, description, _now(), slab_id),
)
else:
conn.execute(
"UPDATE slabs SET description=?, content_source='llm', updated_at=? WHERE id=?",
(description, _now(), slab_id),
)
conn.commit()
return {"ok": True, "description": raw.strip()}
out: dict[str, Any] = {"ok": True, "description": description}
if short:
out["short_description"] = short
return out
def server_description(slab: dict) -> str:
+1
View File
@@ -35,6 +35,7 @@ _REQUIRED = [
"sku", "length_in", "thickness_in", "sqft", "bdft",
"width_min_in", "width_max_in", "width_avg_in",
"species_id", "wood_category_ids", "edge_type_term_id",
"figure_term_ids", # UX requires ≥1 figure (Create-desc gate + ready)
"grade_term_id", "thickness_term_id", "moisture_term_id",
"price", "price_source", "title", "short_description", "description",
"content_source",
+25 -7
View File
@@ -1,11 +1,14 @@
"""Taxonomy sync: pull Woo categories/tags/attributes/terms into the local cache.
Rebuild happens in one transaction; on any failure the prior cache is kept.
Only fig-* / feat-* tag families are retained (METADATA-15). taxonomy_anchor is a
stable hash of the stored subset, recorded only when it changes.
Only fig-* / feat-* tag families are retained by **slug** prefix with hyphen
(SETTINGS-51 / METADATA-15). taxonomy_anchor is a stable hash of the stored
subset, recorded only when it changes. Display names are HTML-unescaped so
Woo entities like ``&amp;`` store as ``&``.
"""
import hashlib
import html
import json
from datetime import datetime, timezone
@@ -16,8 +19,23 @@ def _now_iso() -> str:
return datetime.now(timezone.utc).replace(microsecond=0, tzinfo=None).isoformat()
def _decode_name(name: str | None) -> str:
"""Decode HTML entities Woo puts in taxonomy display names."""
return html.unescape(name or "")
def _scoped_tags(tags: list[dict]) -> list[dict]:
return [t for t in tags if t.get("name", "").startswith(("fig-", "feat-"))]
"""Keep product tags whose slug starts with fig- or feat- (hyphen required).
Live Woo uses human display names (e.g. "Curl") with slugs like fig-curl.
Filtering by name prefix kept 0 live tags; slug is authoritative.
"""
out = []
for t in tags:
slug = t.get("slug") or ""
if slug.startswith("fig-") or slug.startswith("feat-"):
out.append(t)
return out
def _normalize_category(c: dict) -> tuple:
@@ -34,7 +52,7 @@ def _normalize_category(c: dict) -> tuple:
woo_id = c.get("woo_id", c.get("id"))
if woo_id is not None:
woo_id = int(woo_id)
return (c["id"], c["name"], c["slug"], parent_id, woo_id)
return (c["id"], _decode_name(c["name"]), c["slug"], parent_id, woo_id)
def _subset_hash(categories: list[dict], attributes: list[dict],
@@ -90,16 +108,16 @@ def sync(client: WooClient, conn) -> dict:
conn.executemany(
"INSERT INTO woo_attributes(id, name, slug, woo_id) VALUES (?,?,?,?)",
[(a["id"], a["name"], a["slug"], a.get("woo_id")) for a in attributes],
[(a["id"], _decode_name(a["name"]), a["slug"], a.get("woo_id")) for a in attributes],
)
for attr_id, terms in terms_by_attr.items():
conn.executemany(
"INSERT INTO woo_attribute_terms(id, attribute_id, name, slug, woo_id) VALUES (?,?,?,?,?)",
[(t["id"], attr_id, t["name"], t["slug"], t.get("woo_id")) for t in terms],
[(t["id"], attr_id, _decode_name(t["name"]), t["slug"], t.get("woo_id")) for t in terms],
)
conn.executemany(
"INSERT INTO woo_tags(id, name, slug, woo_id) VALUES (?,?,?,?)",
[(t["id"], t["name"], t["slug"], t.get("woo_id")) for t in scoped_tags],
[(t["id"], _decode_name(t["name"]), t["slug"], t.get("woo_id")) for t in scoped_tags],
)
anchor = _subset_hash(categories, attributes, terms_by_attr, scoped_tags)
+315 -91
View File
@@ -77,6 +77,42 @@ def test_call1_seed_is_locked_expert_prompt(env):
assert "character\":" not in CALL1_SEED.replace(" ", "")
def test_call2_seed_has_short_long_structure(env):
"""Call 2 seed: plain-text short+long, woodworker tone, JSON dual output (not brand voice)."""
client, _conn = env
from backend.inference import CALL2_SEED
body = client.get("/api/v1/prompts/call2").json()
assert body["active_content"] == CALL2_SEED
assert "SHORT DESCRIPTION" in CALL2_SEED
assert "LONG DESCRIPTION" in CALL2_SEED
assert "PLAIN TEXT ONLY" in CALL2_SEED
assert "plain text only" in CALL2_SEED.lower()
assert "No HTML tags" in CALL2_SEED
assert "woodworkers choosing stock" in CALL2_SEED
assert "elevate" in CALL2_SEED # banned-phrase guidance
assert "stunning" in CALL2_SEED
assert "Lead with a practical outcome" in CALL2_SEED
assert "Hook" in CALL2_SEED
assert "Brand voice and Geography sections when provided" in CALL2_SEED
assert "Whidbey" not in CALL2_SEED
assert "Pacific Northwest" not in CALL2_SEED
assert '"short_description"' in CALL2_SEED
assert '"description"' in CALL2_SEED
assert "<HTML long description>" not in CALL2_SEED
assert "plain text long description" in CALL2_SEED
# No Full-specs / attribute metadata dump in long desc (specs live on Woo fields).
assert "Hook + Benefits only" in CALL2_SEED
assert "Do NOT restate DECLARED" in CALL2_SEED
assert 'no "Full specs"' in CALL2_SEED or "Do NOT add a Full specs" in CALL2_SEED
assert "Category:/Figure:/Edge:" in CALL2_SEED
assert "1–2 key traits" in CALL2_SEED
assert "labeled catalog" in CALL2_SEED or "labeled metadata list" in CALL2_SEED
# Must not instruct a Full-specs numbered part (old 4-part structure).
assert "3. Full specs" not in CALL2_SEED
assert "Four parts, in order" not in CALL2_SEED
def test_taxonomy_lists_call1_closed_shape():
"""#86: Call 1 lists are closed Woo picks only — no grade/thickness/moisture; feat names stripped."""
import tempfile, os, shutil
@@ -153,48 +189,75 @@ def test_gate_feature_tag_ids_not_free_text():
assert "feature_tag_ids" not in _gate(low)
def test_call1_confidence_gate(env, monkeypatch):
def test_call1_hard_disabled_410(env, monkeypatch):
"""overnight-whole-app: Call 1 OUT — POST always 410, never chats the model."""
client, conn = env
# enable inference
conn.execute("INSERT OR REPLACE INTO settings(key,value,value_enc) VALUES ('inference_enabled','true',NULL)")
conn.execute("INSERT INTO woo_categories(id,name,slug,parent_id,woo_id) VALUES (1,'Black Walnut','black-walnut',NULL,100)")
conn.execute("INSERT INTO slabs(id,sku,length_in,thickness_in,status,server_rev,client_rev,created_at,updated_at) VALUES ('s1','SLAB-01',100,2,'calibrated',1,1,'t','t')")
conn.execute("INSERT INTO slab_photos(id,slab_id,seq,processed_blob,created_at) VALUES ('p1','s1',1,X'89504E47','t')")
conn.commit()
from backend.inference import InferenceClient
called = {"n": 0}
def fake_chat(self, messages):
# Capture list header for #86 shape
text = messages[0]["content"][-1]["text"]
assert "LISTS:" in text
assert "grade_terms" not in text
assert "species_leaves" not in text
return json.dumps({
"species_id": 1, "species_confidence": 0.9,
"wood_category_ids": [1], "wood_category_confidence": 0.5, # below threshold
"grade_term_id": 7, "grade_confidence": 0.99, # never suggested
"figure_term_ids": [2], "figure_confidence": 0.8, # at/above
"feature_tag_ids": [9], "feature_tag_confidence": 0.9,
"character": ["x"], "inclusions": ["y"],
})
called["n"] += 1
return "{}"
monkeypatch.setattr(InferenceClient, "chat", fake_chat)
r = client.post("/api/v1/slabs/s1/inference/call1")
assert r.status_code == 200, r.text
s = r.json()["suggestions"]
assert s.get("species_id") == 1
assert s.get("figure_term_ids") == [2]
assert s.get("feature_tag_ids") == [9]
assert "wood_category_ids" not in s # gated out
assert "grade_term_id" not in s # never
assert "character" not in s
# inference_log written with call=1
assert conn.execute("SELECT call FROM inference_log").fetchone()["call"] == 1
assert r.status_code == 410, r.text
body = r.json()
assert body["error"]["code"] == "call1_disabled"
assert called["n"] == 0
assert conn.execute("SELECT COUNT(*) AS n FROM inference_log").fetchone()["n"] == 0
def test_call2_stores_description(env, monkeypatch):
def test_call2_stores_short_and_description(env, monkeypatch):
"""Call 2 persists both fields as plain text (HTML from model is stripped)."""
client, conn = env
conn.execute("INSERT OR REPLACE INTO settings(key,value,value_enc) VALUES ('content_llm_enabled','true',NULL)")
conn.execute("INSERT INTO slabs(id,sku,length_in,thickness_in,width_avg_in,bdft,title,status,server_rev,client_rev,created_at,updated_at) VALUES ('s1','SLAB-01',100,2,20,18,'Walnut Slab','ready',1,1,'t','t')")
conn.execute(
"INSERT INTO slabs(id,sku,length_in,thickness_in,width_avg_in,bdft,title,short_description,"
"status,server_rev,client_rev,created_at,updated_at) "
"VALUES ('s1','SLAB-01',100,2,20,18,'Walnut Slab','old short','ready',1,1,'t','t')"
)
conn.execute("INSERT INTO slab_photos(id,slab_id,seq,processed_blob,created_at) VALUES ('p1','s1',1,X'89504E47','t')")
conn.commit()
from backend.inference import InferenceClient
import json as _json
payload = _json.dumps({
"short_description": "Kiln-dried walnut live-edge stock for a dining table — clear midspan, usable width. Ready to finish.",
"description": "<p><strong>Dining table stock.</strong></p><ul><li><strong>Stable:</strong> kiln dried</li></ul>",
})
monkeypatch.setattr(InferenceClient, "chat", lambda self, messages: payload)
r = client.post("/api/v1/slabs/s1/inference/call2")
assert r.status_code == 200, r.text
body = r.json()
assert "Dining table stock" in body["description"]
assert "kiln dried" in body["description"]
assert "<p>" not in body["description"]
assert "<strong>" not in body["description"]
assert body["short_description"].startswith("Kiln-dried walnut")
row = conn.execute(
"SELECT short_description, description, content_source FROM slabs WHERE id='s1'"
).fetchone()
assert row["short_description"].startswith("Kiln-dried walnut")
assert "<p>" not in row["description"]
assert "- Stable:" in row["description"] or "Stable:" in row["description"]
assert row["content_source"] == "llm"
def test_call2_html_fallback_keeps_prior_short(env, monkeypatch):
"""Non-JSON Call 2 body becomes plain-text description; prior short is left alone."""
client, conn = env
conn.execute("INSERT OR REPLACE INTO settings(key,value,value_enc) VALUES ('content_llm_enabled','true',NULL)")
conn.execute(
"INSERT INTO slabs(id,sku,length_in,thickness_in,width_avg_in,bdft,title,short_description,"
"status,server_rev,client_rev,created_at,updated_at) "
"VALUES ('s1','SLAB-01',100,2,20,18,'Walnut Slab','keep me','ready',1,1,'t','t')"
)
conn.execute("INSERT INTO slab_photos(id,slab_id,seq,processed_blob,created_at) VALUES ('p1','s1',1,X'89504E47','t')")
conn.commit()
from backend.inference import InferenceClient
@@ -202,18 +265,120 @@ def test_call2_stores_description(env, monkeypatch):
monkeypatch.setattr(InferenceClient, "chat", lambda self, messages: "<p>A fine walnut slab.</p>")
r = client.post("/api/v1/slabs/s1/inference/call2")
assert r.status_code == 200, r.text
assert r.json()["description"] == "<p>A fine walnut slab.</p>"
row = conn.execute("SELECT description, content_source FROM slabs WHERE id='s1'").fetchone()
assert row["description"] == "<p>A fine walnut slab.</p>"
assert r.json()["description"] == "A fine walnut slab."
assert "short_description" not in r.json()
row = conn.execute(
"SELECT short_description, description, content_source FROM slabs WHERE id='s1'"
).fetchone()
assert row["short_description"] == "keep me"
assert row["description"] == "A fine walnut slab."
assert row["content_source"] == "llm"
def test_inference_off_call1_409(env):
def test_parse_call2_response_json_and_fenced():
from backend.inference import parse_call2_response, _html_to_plain
import json as _json
short, desc = parse_call2_response(
_json.dumps({"short_description": " Benefit first. ", "description": " <p>Long</p> "})
)
assert short == "Benefit first."
assert desc == "Long"
fenced = "```json\n" + _json.dumps({"short_description": "S", "description": "<p>D</p>"}) + "\n```"
short2, desc2 = parse_call2_response(fenced)
assert short2 == "S"
assert desc2 == "D"
short3, desc3 = parse_call2_response("<p>only long</p>")
assert short3 is None
assert desc3 == "only long"
# Defensive strip: lists become "- " bullets; entities unescaped; no tags left.
plain = _html_to_plain(
"<p>Hook.</p><ul><li><strong>Stable:</strong> kiln dried</li>"
"<li>Live edge</li></ul><p>Trust &amp; ship.</p>"
)
assert "<" not in plain
assert "Hook." in plain
assert "- Stable: kiln dried" in plain
assert "- Live edge" in plain
assert "Trust & ship." in plain
# Already-plain text is unchanged aside from trim.
assert _html_to_plain(" Kiln-dried walnut for a bar top. ") == "Kiln-dried walnut for a bar top."
def test_strip_attribute_dump_trailing_and_header():
"""Defensive: drop labeled Category:/Length: dumps from long description."""
from backend.inference import _strip_attribute_dump, parse_call2_response
import json as _json
hook_benefits = (
"Kiln-dried maple suited to a compact bar top.\n"
"- Stable stock: kiln dried, ready to mill.\n"
"- Live edge on one side for a natural apron.\n"
"- Character grade with usable burl figure."
)
dump = (
"Maple Slab\n"
"- Category: Live edge wood\n"
"- Figure: Burl, Curl, Figured\n"
"- Edge: Live Edge 1 side\n"
"- Grade: Character\n"
"- Moisture: Kiln dried\n"
"- Length: 45.0in\n"
"- Board Feet: 12.4"
)
trust = "Ships from the mill; ask about returns in Brand voice."
full = f"{hook_benefits}\n\n{dump}\n\n{trust}"
cleaned = _strip_attribute_dump(full)
assert "Category:" not in cleaned
assert "Board Feet:" not in cleaned
assert "Maple Slab" not in cleaned
assert "Kiln-dried maple" in cleaned
assert "Live edge on one side" in cleaned
assert "Ships from the mill" in cleaned
# Mid-body Full specs header also stripped; benefits bullets without Label: kept.
mid = (
"Hook line.\n"
"- Good for a bench seat.\n"
"\n"
"Full specs\n"
"- Species: Maple\n"
"- Length: 45.0in\n"
"- Thickness: 2.0in\n"
"\n"
"Neutral trust close."
)
mid_clean = _strip_attribute_dump(mid)
assert "Full specs" not in mid_clean
assert "Species:" not in mid_clean
assert "Good for a bench seat" in mid_clean
assert "Neutral trust close" in mid_clean
# Fewer than 3 attr lines: leave alone (not a dump).
short_list = "Hook.\n- Length: 45.0in\n- Thickness: 2in\nTrust."
assert _strip_attribute_dump(short_list) == short_list.strip()
# parse_call2_response applies strip on description (not short).
payload = _json.dumps({
"short_description": "Short with - Length: 1in - Width: 2in - Thickness: 3in ok",
"description": full,
})
short, desc = parse_call2_response(payload)
assert short and "Length: 1in" in short # short untouched by attr-dump strip
assert "Category:" not in desc
assert "Board Feet:" not in desc
assert "Kiln-dried maple" in desc
def test_call1_disabled_even_when_inference_off(env):
"""Call 1 hard-disable wins over inference_enabled=false (still 410, not 409)."""
client, conn = env
conn.execute("INSERT INTO slabs(id,sku,status,server_rev,client_rev,created_at,updated_at) VALUES ('s1','SLAB-01','calibrated',1,1,'t','t')")
conn.commit()
r = client.post("/api/v1/slabs/s1/inference/call1")
assert r.status_code == 409 # inference_enabled default off
assert r.status_code == 410
assert r.json()["error"]["code"] == "call1_disabled"
def test_server_description_inference_off(env):
@@ -306,50 +471,18 @@ def test_redact_request_for_log_strips_data_urls_keeps_text():
assert "LISTS:" in parts[2]["text"]
def test_call1_log_has_text_and_image_refs_no_base64(env, monkeypatch):
"""#87 METADATA-66: inference_log stores text+refs, not image bytes."""
def test_call1_log_skipped_while_disabled(env):
"""#87 Call 1 log path dormant while Call 1 is hard-disabled (overnight-whole-app)."""
client, conn = env
conn.execute("INSERT OR REPLACE INTO settings(key,value,value_enc) VALUES ('inference_enabled','true',NULL)")
conn.execute(
"INSERT INTO slabs(id,sku,length_in,thickness_in,status,server_rev,client_rev,created_at,updated_at) "
"VALUES ('s1','SLAB-01',100,2,'calibrated',1,1,'t','t')"
)
png = _rgba_png(400, 300)
conn.execute(
"INSERT INTO slab_photos(id,slab_id,seq,processed_blob,created_at) VALUES ('photo-abc','s1',1,?, 't')",
(png,),
)
conn.commit()
from backend.inference import InferenceClient
def fake_chat(self, messages):
# Wire path still has data-URL for the model
content = messages[0]["content"]
assert any(
isinstance(p, dict) and p.get("type") == "image_url"
and str(p.get("image_url", {}).get("url", "")).startswith("data:image")
for p in content
)
return json.dumps({"species_id": 1, "species_confidence": 0.9})
monkeypatch.setattr(InferenceClient, "chat", fake_chat)
r = client.post("/api/v1/slabs/s1/inference/call1")
assert r.status_code == 200, r.text
row = conn.execute(
"SELECT request_payload, length(request_payload) AS n FROM inference_log ORDER BY id DESC LIMIT 1"
).fetchone()
payload = row["request_payload"]
assert row["n"] < 50000
assert "data:image" not in payload
assert "LISTS:" in payload
assert "image_ref" in payload
assert "photo-abc" in payload
assert '"seq": 1' in payload or '"seq":1' in payload
logged = json.loads(payload)
refs = [p for p in logged[0]["content"] if p.get("type") == "image_ref"]
assert len(refs) == 1
assert refs[0]["photo_id"] == "photo-abc"
assert refs[0]["bytes"] == len(png)
assert r.status_code == 410
assert conn.execute("SELECT COUNT(*) AS n FROM inference_log").fetchone()["n"] == 0
def test_call2_log_redacts_images(env, monkeypatch):
@@ -380,38 +513,129 @@ def test_call2_log_redacts_images(env, monkeypatch):
assert "DIMENSION FACT BLOCK" in payload
def test_call1_sends_long_edge_at_most_1024(env, monkeypatch):
"""METADATA-35 end-to-end: messages to the model use <=1024 long edge."""
def test_call1_sends_long_edge_dormant_while_disabled(env):
"""METADATA-35 Call 1 sizing path dormant while Call 1 is hard-disabled."""
client, _conn = env
r = client.post("/api/v1/slabs/missing/inference/call1")
assert r.status_code == 410
assert r.json()["error"]["code"] == "call1_disabled"
def test_assemble_call2_includes_declared_attrs_and_dims(env):
"""Call 2 text = seed + DECLARED ATTRIBUTES + dimension fact + brand/geo; shared assembler."""
client, conn = env
conn.execute("INSERT OR REPLACE INTO settings(key,value,value_enc) VALUES ('inference_enabled','true',NULL)")
conn.execute(
"INSERT INTO slabs(id,sku,length_in,thickness_in,status,server_rev,client_rev,created_at,updated_at) "
"VALUES ('s1','SLAB-01',100,2,'calibrated',1,1,'t','t')"
"INSERT INTO woo_categories(id,name,slug,parent_id,woo_id) VALUES "
"(10,'Species','species',NULL,10),"
"(11,'Black Walnut','black-walnut',10,11),"
"(20,'Wcat','wcat',NULL,20),"
"(21,'Slabs & Lumber','snl',20,21),"
"(22,'Live edge wood','liveedge',21,22)"
)
big = _rgba_png(800, 1067)
conn.execute(
"INSERT INTO slab_photos(id,slab_id,seq,processed_blob,created_at) VALUES ('p1','s1',1,?, 't')",
(big,),
"INSERT INTO woo_attributes(id,name,slug,woo_id) VALUES "
"(6,'Edge type','pa_etype',6),(8,'Figure','pa_fig',8),"
"(9,'Grade','pa_gr',9),(7,'Moisture','pa_mst',7)"
)
conn.execute(
"INSERT INTO woo_attribute_terms(id,attribute_id,name,slug,woo_id) VALUES "
"(321,6,'Live Edge 1 side','le1',321),"
"(328,8,'Curl','curl',328),"
"(342,9,'Fancy','fnc',342),"
"(326,7,'Kiln dried','kd',326)"
)
conn.execute(
"INSERT INTO woo_tags(id,name,slug,woo_id) VALUES (1,'feat-knot','feat-knot',1)"
)
conn.execute(
"INSERT OR REPLACE INTO settings(key,value,value_enc) VALUES "
"('brand_voice','Warm and precise.',NULL),"
"('geo_context','Pacific Northwest mill.',NULL)"
)
conn.execute(
"INSERT INTO slabs(id,sku,length_in,thickness_in,width_avg_in,width_min_in,width_max_in,bdft,"
"species_id,wood_category_ids,edge_type_term_id,figure_term_ids,grade_term_id,moisture_term_id,"
"feat_tag_ids,title,short_description,status,server_rev,client_rev,created_at,updated_at) "
"VALUES ('s1','SLAB-01',114.0,1.75,24.3,18.0,24.3,18.4,11,?,321,?,342,326,?,"
"'Figured Black Walnut Live Edge Slab','short line','ready',1,1,'t','t')",
(json.dumps([22]), json.dumps([328]), json.dumps([1])),
)
png = _rgba_png(200, 200)
conn.execute(
"INSERT INTO slab_photos(id,slab_id,seq,processed_blob,created_at) VALUES ('p9','s1',1,?, 't')",
(png,),
)
conn.commit()
from backend.inference import InferenceClient
from PIL import Image
import io
from backend.inference import assemble_call2, assemble_call2_text, CALL2_SEED
assembled = assemble_call2(conn, "s1")
assert assembled is not None
text = assembled["text"]
assert "DECLARED ATTRIBUTES" in text
assert "species: Black Walnut" in text
assert "primary wood category: Live edge wood" in text
assert "edge: Live Edge 1 side" in text
assert "figure: Curl" in text
assert "grade: Fancy" in text
assert "moisture: Kiln dried" in text
assert "features: knot" in text
assert "DIMENSION FACT BLOCK" in text
assert "length=114.0in" in text
assert "Brand voice: Warm and precise." in text
assert "Geo: Pacific Northwest mill." in text
assert CALL2_SEED.split("\n")[0] in text
assert "SHORT DESCRIPTION" in text
assert "LONG DESCRIPTION" in text
assert "short_description" in CALL2_SEED
assert '"description"' in CALL2_SEED or "\"description\"" in CALL2_SEED
# Prior short must not be fed back into the prompt (Call 2 writes it).
assert "short_description: short line" not in text
assert "Write BOTH" in CALL2_SEED or "BOTH the short" in CALL2_SEED
# No raw base64 in View-prompt shape
assert "photos" in assembled
assert assembled["photos"][0]["id"] == "p9"
assert assembled["photos"][0]["seq"] == 1
assert assembled["photos"][0]["bytes"] > 0
assert "base64" not in json.dumps(assembled["photos"])
# View prompt GET mirrors assembler (no model)
r = client.get("/api/v1/slabs/s1/inference/call2/prompt")
assert r.status_code == 200, r.text
body = r.json()
assert body["text"] == text
assert body["photos"][0]["id"] == "p9"
assert "data:image" not in json.dumps(body)
def test_call2_post_uses_same_assembler_text(env, monkeypatch):
"""POST call2 sends the same assembled text View prompt returns."""
client, conn = env
conn.execute("INSERT OR REPLACE INTO settings(key,value,value_enc) VALUES ('content_llm_enabled','true',NULL)")
conn.execute(
"INSERT OR REPLACE INTO settings(key,value,value_enc) VALUES "
"('brand_voice','voice',NULL),('geo_context','geo',NULL)"
)
conn.execute(
"INSERT INTO slabs(id,sku,length_in,thickness_in,width_avg_in,width_min_in,width_max_in,bdft,"
"title,status,server_rev,client_rev,created_at,updated_at) "
"VALUES ('s1','SLAB-01',100,2,20,18,22,18,'Walnut Slab','ready',1,1,'t','t')"
)
conn.execute(
"INSERT INTO slab_photos(id,slab_id,seq,processed_blob,created_at) VALUES ('p1','s1',1,?, 't')",
(_rgba_png(64, 64),),
)
conn.commit()
from backend.inference import InferenceClient, assemble_call2
expected = assemble_call2(conn, "s1")["text"]
captured = {}
def fake_chat(self, messages):
captured["messages"] = messages
return json.dumps({"species_id": 1, "species_confidence": 0.9})
captured["text"] = messages[0]["content"][-1]["text"]
return "<p>ok</p>"
monkeypatch.setattr(InferenceClient, "chat", fake_chat)
r = client.post("/api/v1/slabs/s1/inference/call1")
r = client.post("/api/v1/slabs/s1/inference/call2")
assert r.status_code == 200, r.text
content = captured["messages"][0]["content"]
imgs = [p for p in content if isinstance(p, dict) and p.get("type") == "image_url"]
assert len(imgs) == 1
url = imgs[0]["image_url"]["url"]
assert url.startswith("data:image/png;base64,")
raw = base64.b64decode(url.split(",", 1)[1])
im = Image.open(io.BytesIO(raw))
assert max(im.size) <= 1024
assert captured["text"] == expected
assert "DECLARED ATTRIBUTES" in captured["text"]
assert "DIMENSION FACT BLOCK" in captured["text"]
+26
View File
@@ -194,3 +194,29 @@ def test_review_persists_owner_overrides(env):
assert got["sku"] == "SLAB-99"
# server_rev was incremented (the UPDATE fired).
assert got["server_rev"] == 2
def test_ready_requires_figure_term_ids(env):
"""UX requires ≥1 figure — figure_term_ids is in _REQUIRED."""
from backend.review import missing_required, compute_ready
slab = _slab(figure_term_ids=[])
miss = missing_required(slab)
assert "figure_term_ids" in miss
ready, missing = compute_ready({**slab, "price": 1.0, "price_source": "manual",
"title": "T", "short_description": "S",
"edge_type_term_id": 1, "grade_term_id": 1,
"thickness_term_id": 1, "moisture_term_id": 1})
assert ready is False
assert "figure_term_ids" in missing
ready2, missing2 = compute_ready({
**slab,
"figure_term_ids": [1],
"price": 1.0, "price_source": "manual",
"title": "T", "short_description": "S",
"edge_type_term_id": 1, "grade_term_id": 1,
"thickness_term_id": 1, "moisture_term_id": 1,
})
# May still miss other fields depending on fixture; figure must not be listed
assert "figure_term_ids" not in missing2
+24
View File
@@ -200,6 +200,30 @@ def test_get_not_found(client, db):
assert resp.status_code == 404
def test_update_omitting_species_id_preserves_it(client, db):
"""Review PUTs omit species_id after Measure owns it — must not null the column."""
slab_id = str(uuid.uuid4())
create = {
"id": slab_id, "sku": "", "length_in": 48, "thickness_in": 2, "sqft": 6.25, "bdft": 12.5,
"width_min_in": 10, "width_max_in": 12, "width_avg_in": 11, "species_id": 1,
}
r = client.post("/api/v1/slabs", json=create)
assert r.status_code == 201, r.text
assert r.json()["species_id"] == 1
rev = r.json()["server_rev"]
# PUT like Review commitReview: wood cats only, no species_id key.
r2 = client.put(f"/api/v1/slabs/{slab_id}", json={
"client_rev": rev,
"wood_category_ids": [2],
"title": None,
"short_description": None,
})
assert r2.status_code == 200, r2.text
assert r2.json()["species_id"] == 1
assert r2.json()["wood_category_ids"] == [2]
def test_update_success(client, db):
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","species_confidence":0.9,"figure_confidence":0.8,"grade_confidence":0.7,"inference_status":"inferring","calibration_photo_id":None}
+26 -6
View File
@@ -95,12 +95,29 @@ def test_401_raises_auth_error(db_dir):
c.get_categories()
def test_scoped_tags_by_slug_not_name():
"""SETTINGS-51: keep fig-/feat- by slug prefix with hyphen; human names stay."""
tags = [
{"id": 1, "name": "Curl", "slug": "fig-curl"},
{"id": 2, "name": "Knot", "slug": "feat-knot"},
{"id": 3, "name": "fig-looks-like", "slug": "other"}, # name prefix alone — drop
{"id": 4, "name": "Figured", "slug": "figured"}, # no hyphen — drop
{"id": 5, "name": "other", "slug": "other-tag"},
]
kept = taxonomy_mod._scoped_tags(tags)
assert {t["id"] for t in kept} == {1, 2}
def test_sync_populates_scoped(db_dir):
cats = (200, {"X-WP-TotalPages": "1"}, json.dumps([{"id": 1, "name": "Cedar", "slug": "cedar", "parent_id": None, "woo_id": 10}]).encode())
# Live Woo: human names + fig-/feat- slugs. Name-prefix filter would keep 0.
cats = (200, {"X-WP-TotalPages": "1"}, json.dumps([
{"id": 1, "name": "Cookies &amp; Burl Slices", "slug": "cookies-burl", "parent_id": None, "woo_id": 10},
]).encode())
tags = (200, {"X-WP-TotalPages": "1"}, json.dumps([
{"id": 2, "name": "fig-curl", "slug": "fig-curl", "woo_id": 20},
{"id": 3, "name": "feat-knot", "slug": "feat-knot", "woo_id": 21},
{"id": 4, "name": "other-tag", "slug": "other-tag", "woo_id": 22}, # must be dropped
{"id": 2, "name": "Curl", "slug": "fig-curl", "woo_id": 20},
{"id": 3, "name": "Knot", "slug": "feat-knot", "woo_id": 21},
{"id": 4, "name": "fig-fake", "slug": "other-tag", "woo_id": 22}, # name looks scoped; slug not — drop
{"id": 5, "name": "other-tag", "slug": "other-tag-2", "woo_id": 23},
]).encode())
attrs = (200, {"X-WP-TotalPages": "1"}, json.dumps([{"id": 5, "name": "Edge Type", "slug": "edge", "woo_id": 30}]).encode())
terms = (200, {"X-WP-TotalPages": "1"}, json.dumps([{"id": 6, "name": "Flat", "slug": "flat", "woo_id": 40}]).encode())
@@ -108,8 +125,11 @@ def test_sync_populates_scoped(db_dir):
result = taxonomy_mod.sync(c, db_mod.get_connection())
conn = db_mod.get_connection()
assert [r[0] for r in conn.execute("SELECT id FROM woo_categories")] == [1]
tag_ids = {r[0] for r in conn.execute("SELECT id FROM woo_tags")}
assert tag_ids == {2, 3} # fig- + feat- kept, other-tag dropped
assert conn.execute("SELECT name FROM woo_categories WHERE id=1").fetchone()[0] == "Cookies & Burl Slices"
tag_rows = {r[0]: (r[1], r[2]) for r in conn.execute("SELECT id, name, slug FROM woo_tags")}
assert set(tag_rows) == {2, 3} # fig- + feat- by slug; name-only fig-fake dropped
assert tag_rows[2] == ("Curl", "fig-curl")
assert tag_rows[3] == ("Knot", "feat-knot")
assert [r[0] for r in conn.execute("SELECT id FROM woo_attribute_terms")] == [6]
assert result["taxonomy_anchor"]
assert conn.execute("SELECT value FROM settings WHERE key='taxonomy_last_sync_at'").fetchone()[0]
+108
View File
@@ -27,6 +27,114 @@ Do not put a host name in the product spec.
## Deploy records
### 2026-09-26 ~12:01 PT — Measure Zoom/Pan vs Rotate + drop dead ↻ on :10890
- Source: `overnight-whole-app` @ **`f505be0`** (`fix(capture): Zoom/Pan vs Rotate gesture modes; drop dead ↻ overlay`)
- Compose: `-p slab-overnight` + `deploy/override-overnight.yml` — **frontend** rebuilt/recreated (`slab-overnight-fastapi-1` left running)
- Left alone: `deploy-*` 10881/10882, greenfield 10884/10885, m4 10883, m1 18080; never :10880
- Health: `GET http://127.0.0.1:10890/api/health` → ok; root `/` → 200; asset `index-CsENRLHF.js`
- UX: Measure stage segmented **Zoom / Pan** (default) vs **Rotate**; pinch/pan exclusive of two-finger twist; removed bottom-center ↻ that overlapped tick B
- URLs: Tailscale `http://100.64.0.9:10890` · LAN `http://192.168.1.202:10890`
### 2026-09-26 ~11:47 PT — Publish preview before Woo draft POST on :10890
- Source: `overnight-whole-app` @ **`73e2818`** (`feat(publish): Publish preview before Woo draft POST`)
- Compose: `-p slab-overnight` + `deploy/override-overnight.yml` — **frontend** rebuilt/recreated (`slab-overnight-fastapi-1` left running)
- Left alone: `deploy-*` 10881/10882, greenfield 10884/10885, m4 10883, m1 18080; never :10880
- Health: `GET http://127.0.0.1:10890/api/health` → ok; root `/` → 200; asset `index-Co2F8tsa.js`
- UX: Review → Continue to Publish → glanceable preview (thumbs, SKU/title/price, taxonomy/dims, short+long desc) → Publish / Back to Review
- URLs: Tailscale `http://100.64.0.9:10890` · LAN `http://192.168.1.202:10890`
### 2026-09-26 ~11:08 PT — Review UI nits (margins, buttons, moisture, live validation) on :10890
- Source: `overnight-whole-app` @ **`5651a38`** (`fix(review): UI nits — margins, buttons, moisture, live validation`)
- Compose: `-p slab-overnight` + `deploy/override-overnight.yml` — **frontend** rebuilt/recreated (`slab-overnight-fastapi-1` left running)
- Left alone: `deploy-*` 10881/10882, greenfield 10884/10885, m4 10883, m1 18080; never :10880
- Health: `GET http://127.0.0.1:10890/api/health` → ok; root `/` → 200; asset `index-C1jF-uuB.js`
- Nits: post-publish no Back; removed white Slabs footer control; Review/App padding; unified `.btn-sec`; moisture kd + live input validation
- URLs: Tailscale `http://100.64.0.9:10890` · LAN `http://192.168.1.202:10890`
### 2026-09-26 ~10:12 PT — Review View prompt + Call 2 declared attrs / Create-desc gate on :10890
- Source: `overnight-whole-app` @ **`1ff082f`** (`feat(review): View prompt + Call 2 declared attrs, Create-desc gate`)
- Compose: `-p slab-overnight` + `deploy/override-overnight.yml` — **fastapi + frontend** rebuilt/recreated
- Left alone: `deploy-*` 10881/10882, greenfield 10884/10885, m4 10883, m1 18080; never :10880
- Health: `GET http://127.0.0.1:10890/api/health` → ok; root `/` → 200; asset `index-BJxrob4c.js`
- Call 1 still **410** `call1_disabled`; `GET .../inference/call2/prompt` live (View prompt)
- URLs: Tailscale `http://100.64.0.9:10890` · LAN `http://192.168.1.202:10890`
### 2026-09-26 ~09:42 PT — Review metadata: slug tags, decode names, no fig picker / AI labels on :10890
- Source: `overnight-whole-app` @ **`85786f1`** (`fix(review): slug-scoped tags, decode names, drop fig picker & AI labels`)
- Compose: `-p slab-overnight` + `deploy/override-overnight.yml` — **fastapi + frontend** rebuilt/recreated
- Taxonomy re-sync: `POST /api/v1/admin/taxonomy/sync` → 14 tags (11 `fig-*`, 3 `feat-*`); cats decode `&amp;` → `&`
- Left alone: `deploy-*` 10881/10882, greenfield 10884/10885, m4 10883, m1 18080
- Health: `GET http://127.0.0.1:10890/api/health` → ok; root `/` → 200; asset `index-DA24IrWT.js`
- Call 1 still **410** `call1_disabled`
- URLs: Tailscale `http://100.64.0.9:10890` · LAN `http://192.168.1.202:10890`
### 2026-09-26 ~09:12 PT — PNG size floor warn 300–799 / block <300 on :10890
- Source: `overnight-whole-app` @ **`1b5ad11`** (`fix(capture): warn 300–799px PNG floor; hard-block only below 300`)
- Compose: `-p slab-overnight` + `deploy/override-overnight.yml` — **frontend only** rebuilt/recreated (`slab-overnight-fastapi-1` left running)
- Left alone: `deploy-*` 10881/10882, greenfield 10884/10885, m4 10883, m1 18080
- Health: `GET http://127.0.0.1:10890/api/health` → ok; root `/` → 200; asset `index-Cgc1wxKP.js`
- Call 1 still **410** `call1_disabled`
- URLs: Tailscale `http://100.64.0.9:10890` · LAN `http://192.168.1.202:10890`
### 2026-09-26 ~08:58 PT — fix Create title species carry-forward on :10890
- Source: `overnight-whole-app` @ **`76f3026`** (`fix(review): carry Measure species into Create title/short-desc`)
- Compose: `-p slab-overnight` + `deploy/override-overnight.yml` — **frontend only** rebuilt/recreated (`slab-overnight-fastapi-1` left running)
- Left alone: `deploy-*` 10881/10882, greenfield 10884/10885, m4 10883, m1 18080
- Health: `GET http://127.0.0.1:10890/api/health` → ok; Tailscale `100.64.0.9:10890` → ok; root `/` → 200; asset `index-4mcBthul.js`
- Call 1 still **410** `call1_disabled`
- URLs: Tailscale `http://100.64.0.9:10890` · LAN `http://192.168.1.202:10890`
### 2026-09-25 ~22:06 PT — overnight-whole-app SEPARATE stack on :10890/:10891
- **Intentional ports**: frontend **10890**, fastapi **10891** (1089x range; avoids fighting 10881/10882).
- Host: `ubudual5060` / LAN `192.168.1.202` / Tailscale `100.64.0.9`
- Checkout: `~/slab-deploy` @ **`6caa4f6`** branch `overnight-whole-app`
- Compose: host-local untracked `deploy/override-overnight.yml` as **standalone** file (not merged with base); project **`-p slab-overnight`**
- Containers: `slab-overnight-frontend-1` / `slab-overnight-fastapi-1`
- Binds: `0.0.0.0:10890→80`, `0.0.0.0:10891→8000` (+ loopback 18090/18091)
- Volumes: **dedicated** `slab-overnight_slab_overnight_db` / `_images` (did not reuse `deploy_slab_*`)
- Left alone: `deploy-*` 10881/10882, greenfield 10884/10885, m4 10883, m1 18080
- Health: `GET http://127.0.0.1:10890/api/health` and Tailscale → `{"status":"ok","version":"0.5.0-m3","woo_reachable":false}` (fresh DB; Woo app password not yet in Settings). Root `/` → 200.
- Call 1: `POST /api/v1/slabs/{id}/inference/call1` → **410** `call1_disabled`
- URLs for Ty: Tailscale `http://100.64.0.9:10890` · LAN `http://192.168.1.202:10890`
- Note: an earlier mistaken recreate put overnight bits onto `deploy-*` :10881/:10882; that stack was then **left running untouched**. Canonical overnight prop is **10890/10891**.
- Box Cloudflare quick tunnel stopped; box local :18181/:18182 left running.
### 2026-09-25 ~22:05 PT — overnight-whole-app on ubudual5060 :10881/:10882
- Host: `ubudual5060` / LAN `192.168.1.202` / Tailscale `100.64.0.9`
- Checkout: `~/slab-deploy` branch `overnight-whole-app` @ **`6caa4f6`** (`fix(uat): allow Cloudflare quick-tunnel Host on vite preview`)
- Compose project: `deploy` → containers `deploy-frontend-1` / `deploy-fastapi-1` only
- Ports: `0.0.0.0:10881→80`, `0.0.0.0:10882→8000` (+ loopback 18082/18083 from host `.env`); **did not** touch 10880 / `slabuploader-m1` (18080) / greenfield 10884–10885 / m4-review 10883
- Build: on UAT host (`docker compose -f deploy/docker-compose.yml --env-file deploy/.env build` then `up -d`, **no `-v`**); volumes `deploy_slab_db` / `deploy_slab_images` preserved
- Health: `GET /api/health` → `{"status":"ok","version":"0.5.0-m3","woo_reachable":true}` from 127.0.0.1 and from Tailscale `100.64.0.9`; root `/` → 200
- Call 1: `POST /api/v1/slabs/{id}/inference/call1` → **410** `call1_disabled`
- URLs for Ty: Tailscale `http://100.64.0.9:10881` · LAN `http://192.168.1.202:10881`
- Box Cloudflare quick tunnel (`cloudflared tunnel --url http://127.0.0.1:18181`) stopped; box local :18181/:18182 left running
### 2026-09-25 ~17:50 PT — overnight-whole-app box UAT (Grok Bot computer)
- Branch: `overnight-whole-app` (main @ 0c00d25 + cherry-picked #99 tape-line `b44e231`→`323f29b`→`be2ea6c` + Call 1 OUT).
- **Frontend**: vite preview `0.0.0.0:18181` (proxy `/api` → FastAPI). Never touch `:10880`.
- **Backend**: uvicorn `0.0.0.0:18182` (`backend.app:app`), local SQLite under `.uat-data/` (gitignored).
- Env: `WOO_BASE_URL` from `~/.config/slabuploader/woo.env` (password not logged); `SLAB_AES_KEY` from local file (not in repo).
- Call 1: `POST .../inference/call1` → **410 call1_disabled**. Call 2 path retained.
- Health: `/api/health` ok; `woo_reachable` may be false from this box (network) — draft-only smoke.
- Partner Woo UAT containers: **not touched**.
### 2026-09-21 02:41 PDT — #90 wood primary category + derived thickness band
- Source ref: `0329052` on `issue-89-metadata-lock` (pushed to Gitea; pulled into `~/slab-deploy`).
+2 -2
View File
@@ -35,7 +35,7 @@ Sources reconciled 2026-09-18 from the current branch docs plus the `pre-simplif
| CAPTURE-90 | bdft equals sqft times thickness_in, rounded half-up to 2 decimals, with no divide-by-12; 1 sqft at 1 inch equals 1 bdft. | shipped | frontend/src/measure/measure.test.ts |
| CAPTURE-95 | The inventory PNG is 8-bit RGBA with alpha preserved through encode and upload. | shipped | frontend/src/png/inventory.test.ts |
| CAPTURE-100 | Crop is 3:4 portrait, slab centered, 90 percent fill at the widest and tallest extremes, on a transparent background. | shipped | frontend/src/png/inventory.test.ts |
| CAPTURE-105 | After crop the shorter side is at least 800 px when the source allows; otherwise warn and require a new photo, and never upscale. | shipped | frontend/src/png/inventory.test.ts |
| CAPTURE-105 | After crop the shorter side targets 800 px when the source allows; warn (still encode/include) when 300–799 px; hard-block below 300 px; never upscale. | shipped | frontend/src/png/inventory.test.ts |
| CAPTURE-110 | A non-calibration photo that yields an empty mask or fails the size floor is excluded with a per-photo error while the rest proceed. | shipped | frontend/src/capture/session.batchApply.test.ts |
| CAPTURE-115 | Scale, widths, sqft, and bdft stay tied to the calibration photo; non-calibration photos are visual inventory only. | shipped | frontend/src/capture/session.batchApply.test.ts |
| CAPTURE-120 | The dimension overlay is black and oriented to the approved length axis, with the length graphic beside the wood on the right in axis space. | shipped | frontend/src/png/overlay.test.ts |
@@ -133,7 +133,7 @@ The listing is built in two inference calls, both optional. Call 1 (vision) read
| SETTINGS-40 | test-woo verifies the connection and always resyncs taxonomy on success. | M3 | — |
| SETTINGS-45 | test-inference is a two-turn probe returning ok, mode (stateful, stateless, or fail), vision_capable, and detail; fail blocks Call 2 only. | M3 | — |
| SETTINGS-50 | Taxonomy sync pulls categories, tags, and attributes with terms, rebuilds the cache in one transaction, and on failure returns 502 while keeping the previous cache. | M3 | — |
| SETTINGS-51 | Taxonomy sync keeps only the store's figure and feature product-tag families; all other store product tags are ignored so non-wood product metadata does not enter the workflow. | M3 | — |
| SETTINGS-51 | Taxonomy sync keeps only product tags whose slug starts with fig- or feat- (hyphen required); all other store product tags are ignored so non-wood product metadata does not enter the workflow. | M3 | backend/tests/test_woo.py |
| SETTINGS-55 | Every successful sync updates taxonomy_last_sync_at; taxonomy_anchor bumps only when the stored-subset hash changes, including deletions. | M3 | — |
| SETTINGS-60 | The client re-pulls the full taxonomy only when the anchor advances, and publish refreshes when the last sync is over one hour old. | M3 | — |
| SETTINGS-65 | woo_create_status defaults to draft. | M3 | — |
+3 -3
View File
@@ -123,7 +123,7 @@ On Calculate, the app builds the inventory image from the approved mask:
1. Keep slab pixels from the source photo. Removed pixels are transparent.
2. Place the slab in a 3:4 portrait frame, centered.
3. Scale so the widest and tallest points of the slab fill 90% of the frame. Extra margin where the slab is narrower.
4. After that crop, shorter side ≥ 800px when the source can supply it. Otherwise warn and require a new photo. No upscale from an undersized source.
4. After that crop, shorter side targets ≥ 800px when the source can supply it. If the slab bbox shorter side is 300–799 px, warn but still encode and include the photo. Below 300 px is a hard block (exclude / retake). No upscale from an undersized source.
5. Encode as PNG with alpha.
## Device draft
@@ -147,6 +147,6 @@ The calibration photo is the measurement reference (`CAPTURE-25`, `CAPTURE-115`)
| Alpha | Kept through encode and upload |
| Aspect | 3:4 portrait |
| Layout | Slab centered, 90% fill at the widest and tallest points |
| Size | After crop, shorter side ≥ 800px when the source can supply it |
| Size | After crop, shorter side targets ≥ 800px when the source can supply it |
Source below that floor: warn and require a new photo. No upscale from an undersized source.
Shorter side 300–799 px: warn but still encode/include. Below 300 px: hard block (exclude / retake). No upscale from an undersized source.
+1 -1
View File
@@ -78,7 +78,7 @@ default off (`SETTINGS-90`). Species rates are one flat value per synced leaf (`
test-woo returns ok and detail, fails closed on missing credentials or non-HTTPS, and always
resyncs taxonomy on success (`SETTINGS-80`, `SETTINGS-40`). test-inference returns ok, mode
(stateful, stateless, or fail), vision_capable, and detail; fail blocks Call 2 only
(`SETTINGS-45`). Taxonomy sync keeps only fig-* and feat-* tag families (`SETTINGS-51`), rebuilds
(`SETTINGS-45`). Taxonomy sync keeps only tags whose **slug** starts with `fig-` or `feat-` (`SETTINGS-51`), rebuilds
in one transaction, keeps the previous cache on failure (`SETTINGS-50`), and bumps the anchor only
on a real stored-subset change (`SETTINGS-55`). The client re-pulls when the anchor advances, and
publish refreshes when the last sync is over an hour old (`SETTINGS-60`).
+1 -1
View File
@@ -48,7 +48,7 @@ For each non-calibration photo:
- Reuse the approved Mask sheet (Auto / Green / Black) and sensitivity from the calibration photo. Do not re-open Mask controls for that photo in this milestone.
- Run background removal with those params; compose inventory PNG with the same crop rules as Milestone 1 Calculate.
- If a photo yields an empty mask or fails the PNG size floor, keep it out of the ready set and show a clear per-photo error; other photos still proceed.
- If a photo yields an empty mask or fails the PNG size block floor (<300 px shorter side), keep it out of the ready set and show a clear per-photo error; other photos still proceed. A warn-band size (300–799 px) still includes the photo with a clear per-photo warn.
Scale, widths, sqft, and bdft stay tied to the calibration photo and user `L` / thickness. Non-calibration photos are visual inventory only.
+1 -1
View File
@@ -170,7 +170,7 @@ Returns `202`; the client polls. Cites PUBLISH-5, PUBLISH-10, PUBLISH-15, PUBLIS
## 8. Taxonomy
- `GET /api/v1/admin/taxonomy` → grouped categories, attributes with terms, and tags, plus
`taxonomy_anchor` and `taxonomy_last_sync_at`. Only fig-* and feat-* tag families are kept.
`taxonomy_anchor` and `taxonomy_last_sync_at`. Only tags whose slug starts with `fig-` or `feat-` are kept.
These lists are the only legal picker options.
- `POST /api/v1/admin/taxonomy/sync` → pulls from Woo, rebuilds the cache in one transaction, sets
`taxonomy_last_sync_at`, bumps `taxonomy_anchor` only when the stored-subset hash changes, and
+1 -1
View File
@@ -109,7 +109,7 @@ Cites METADATA-5, SETTINGS-50, SETTINGS-51, SETTINGS-55.
- `woo_categories` — species and wood-category leaves only; parents group and are never assigned.
- `woo_attributes` / `woo_attribute_terms` — exactly the five locked attributes Edge Type, Figure,
Grade, Thickness, Moisture, with their terms.
- `woo_tags` — only the fig-* and feat-* families; other store tags are ignored.
- `woo_tags` — only tags whose slug starts with `fig-` or `feat-`; other store tags are ignored.
Rebuild is one transaction; on failure the previous cache is kept.
+64 -7
View File
@@ -1,5 +1,11 @@
# Prompts contract
> **overnight-whole-app / Call 1 OUT:** Call 1 (vision classification) is hard-disabled.
> `POST /api/v1/slabs/{id}/inference/call1` returns **410 call1_disabled**. UI triggers
> (Review "Suggest with AI", GenerateText classify) are removed. Call 2
> (`POST .../inference/call2`, Review "Generate description") stays invokable.
> Call 1 seed text and prompt-revision APIs remain for documentation / future re-enable.
Status: SPEC · 2026-09-18.
Owns prompt storage, prompt inputs, and the two inference calls.
@@ -90,9 +96,14 @@ No provider thread id is used, and Call 1's raw prompt and result are not sent (
fact block (`METADATA-65`).
- The dimension-overlay image is never sent (`METADATA-95`).
**Output.** The long `description` prose. The server assembles `name` and `short_description`
(`METADATA-55`, `METADATA-80`). The primary wood category selects the specialized prompt
(`METADATA-85`). The owner can edit the result (`REVIEW-40`).
**Output.** Call 2 writes **both** `short_description` (plain text, ~50 words) and
`description` (HTML). The seed carries short/long writing structure (benefit→diff→trust short;
Hook→Benefits→Specs→Trust long) and requires a JSON object
`{"short_description":"...","description":"..."}`. Brand voice and geo stay Settings-injected
at assemble time — not hardcoded in the seed. The server still assembles `name`
deterministically (`METADATA-55`, `METADATA-80`); short may also be owner-edited or rebuilt via
Create short desc. The primary wood category selects the specialized prompt (`METADATA-85`).
The owner can edit the result (`REVIEW-40`).
## 5. Fallback and templates
@@ -155,10 +166,56 @@ Return one JSON object with exactly these keys:
Runtime appends `LISTS:` (JSON of the five closed lists) plus SKU/length/thickness and the photos.
**`call2` seed.** Write the long product description for the slab from the supplied photos and
listing facts. The name and short description are already built, so do not repeat them. Use the
brand voice and geo context given. The dimensions in the fact block are exact: use those strings
and invent no numbers. Write prose a premium lumber buyer would read.
**`call2` seed.** Shipped text in `backend/inference.py` (`CALL2_SEED`), mirrored here for review.
Write listing copy for this slab for woodworkers choosing stock for a project. Audience:
makers matching wood to a tabletop, bar, bench, or similar build — practical,
material/figure/spec-forward. No lifestyle or marketing hype (avoid empty phrases like
elevate, stunning, dramatic visual contrast).
You write BOTH the short description and the long description.
PLAIN TEXT ONLY (short AND long):
- Output plain text only. No HTML tags (`<p>`, `<ul>`, `<li>`, `<strong>`, `<br>`, etc.).
No markdown bold/headers that look like markup (`**bold**`, `#` headers).
- Structure the long description with newlines and `- ` bullets when helpful.
Rules that always apply:
- Dimensions in the DIMENSION FACT BLOCK are exact: use those strings; invent no numbers.
- Use taxonomy names from DECLARED ATTRIBUTES; do not invent species, edge, figure, grade,
moisture, or features.
- Use the Brand voice and Geography sections when provided. If Brand voice is empty, stay
neutral. Never invent return, warranty, or shipping policies.
- The product name may already appear in DECLARED ATTRIBUTES — do not invent a new title.
- Lead with usable traits (species, figure, size, edge, moisture, grade) and what the piece
suits (tabletop, bar, etc.) without ad-copy fluff.
- Specs live in taxonomy and dimension fields on the product. Do NOT restate DECLARED
ATTRIBUTES or the DIMENSION FACT BLOCK as a labeled metadata list in the description
(no Category:/Figure:/Edge:/Grade:/Moisture:/Length:/Board Feet: dump, no "Full specs"
section, no "Maple Slab" attribute-header block). You may weave 1–2 key traits into
prose or benefit bullets naturally.
SHORT DESCRIPTION (~50 words — most important on-page copy; plain text only):
1. Lead with a practical outcome for a maker (what they can build / how the stock helps),
not lifestyle hype and not a bare species list.
2. Add the key differentiator or most important spec (figure, edge, size, moisture, grade).
3. Close with a trust signal or concrete reason to choose this piece.
LONG DESCRIPTION (plain text only — newlines and `- ` bullets; no HTML, no markdown markup).
Hook + Benefits only (+ brief trust close). Do NOT add a Full specs / Attribute list /
product-name header that re-lists DECLARED ATTRIBUTES or DIMENSION FACT BLOCK:
1. Hook — one sentence on what the piece suits and its standout usable trait.
2. Benefits — 3–5 bullets; each maker-useful trait first, then the supporting detail.
Weave in at most 1–2 key traits naturally — not an exhaustive labeled catalog.
3. Trust close (optional, brief) — return/warranty/brand; use Brand voice when present;
otherwise a short neutral close with no invented policies.
Return ONE JSON object only (no prose outside JSON) with exactly these keys:
`{"short_description": "<plain text ~50 words>", "description": "<plain text long description>"}`
Runtime appends DECLARED ATTRIBUTES (title + taxonomy; not a prior short), DIMENSION FACT
BLOCK, and `Brand voice:` / `Geo:` from Settings (`SETTINGS-30`). The POST handler parses the
JSON, strips any residual HTML to plain text, drops a labeled Category:/Length:/Board Feet:
attribute dump if the model still emits one, and persists both fields.
## 7. Prompt management
+713
View File
@@ -0,0 +1,713 @@
<!doctype html>
<!--
Design artifact for SlabUploader issue #99 — "Tape-line overlay replaces axis
rectangle (calibration length + crop)".
Owner-approved design proposal. This is a WIREFRAME, not shippable code.
What it settles: the black ~2pt line is a FIXED entity in the MIDDLE of the
3:4 rectangle; the SLAB alone pans / zooms / tilts so its long axis lands on
the line; the ticks belong to the line, slide along it, and L = tick to tick;
the crop % moves only the overlay's two ends — (1 - fill)/2 of the long axis
in from the top and the bottom — and never changes the aspect ratio.
SCRUB BEFORE SHIPPING — wireframe scaffolding, none of this belongs in the
production workflow:
- the viewport switcher (412 / 390 / 720 / 1440) and the flowbar step nav
- the requirements-coverage board below the stage
- the "was / now" pair of mini diagrams
- the PROPOSAL / OPEN markers and the implementation-notes card
- in-stage tutorial labels ("crop 90% — overlay 5.0% in from the top", …)
- the alignment read-out box (a proposal, not in the issue; nothing gated on it)
- the synthetic slab drawing + grain, and the sample values (88.0" tape, 2.125 thickness)
- the window.__wf debug hook
What stays: the fixed middle line + its two end ticks, the slab transform set
(pan / zoom / tilt) with the visible zoom row and Fit, the tick drag + nudge
semantics, and the single shared crop_fill.
-->
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>SlabUploader — tape-line overlay wireframe (issue #99)</title>
<style>
:root{
--bg:#121212; --card:#1c1c1c; --card2:#1d1d1d; --line:#333;
--fg:#eee; --mut:#aaa; --acc:#00e5ff; --acc2:#002;
--ok:#7fd37f; --warn:#ffb4a2; --r:3px;
}
*{box-sizing:border-box}
html,body{margin:0;background:var(--bg);color:var(--fg);font-family:system-ui,-apple-system,"Segoe UI",sans-serif;font-size:16px}
.top{max-width:900px;margin:0 auto;padding:14px 12px 4px}
.top h1{font-size:1rem;margin:0 0 8px;color:var(--acc);letter-spacing:.04em;text-transform:uppercase}
.flowbar{display:flex;flex-wrap:wrap;gap:6px}
.fstep{font-size:.72rem;padding:5px 8px;border-radius:var(--r);border:1px solid var(--line);background:var(--card2);color:var(--mut);cursor:pointer}
.fstep.on{background:var(--acc);color:var(--acc2);font-weight:700;border-color:var(--acc)}
.vp{display:flex;align-items:center;gap:8px;margin-top:10px;font-size:.72rem;color:var(--mut)}
.vp button{border:1px solid var(--line);background:var(--card2);color:var(--fg);font-size:.72rem;padding:4px 8px;border-radius:var(--r);cursor:pointer}
.vp button.on{background:var(--acc);color:var(--acc2);font-weight:700}
.stage{display:flex;justify-content:center;padding:18px 10px 40px}
#phone{width:412px;max-width:100%;background:#0c0c0c;border:1px solid #2a2a2a;border-radius:18px;padding:14px;min-height:640px}
.panel{padding:10px 4px;color:var(--fg)}
h1{font-size:1.3rem;margin:0 0 10px}
h3{font-size:.95rem;margin:0 0 8px}
.hint,.sub{color:var(--mut);font-size:.82rem;line-height:1.5;margin:0 0 12px}
.row{display:flex;gap:10px;margin-top:16px}
.primary{flex:1;padding:9px;font-size:.95rem;border-radius:var(--r);border:0;background:var(--acc);color:var(--acc2);font-weight:700;cursor:pointer;align-self:flex-start}
.ghost{padding:9px 18px;border-radius:var(--r);border:1px solid #444;background:transparent;color:#ccc;cursor:pointer}
.nudge{color:#e8a;font-size:.74rem;margin:6px 0 0}
/* ---- the stage: fixed line + movable slab ---- */
.tw{position:relative;width:100%;aspect-ratio:3/4;background:#111;border:1px solid var(--line);border-radius:var(--r);overflow:hidden}
.tw svg{display:block;width:100%;height:100%;touch-action:none;cursor:grab;user-select:none}
.tw.dragging svg{cursor:grabbing}
.tickg{cursor:ns-resize}
.rotg{cursor:ew-resize}
.controls{margin-top:16px;display:flex;flex-direction:column;gap:14px}
.angle,.croprow{display:flex;align-items:center;gap:10px}
.thumbbtn{width:44px;height:44px;border-radius:50%;font-size:1.4rem;border:1px solid #444;background:var(--card2);color:var(--fg);flex:none;cursor:pointer}
input[type=range]{flex:1}
.val{min-width:4ch;text-align:right;font-variant-numeric:tabular-nums}
.ctl-label{font-size:.78rem;color:var(--mut);margin:0 0 6px}
.nums{display:flex;flex-direction:column;gap:8px;margin-top:8px}
.nums div{display:flex;justify-content:space-between;border-bottom:1px solid #2a2a2a;padding-bottom:6px;font-size:.84rem;gap:10px}
.nums span:first-child{color:var(--mut)}
.nums .tab{font-variant-numeric:tabular-nums;text-align:right}
.nums .dim{color:#777}
.status{margin-top:12px;padding:10px 12px;border-radius:var(--r);font-size:.88rem;font-weight:600;background:#2a1512;color:var(--warn)}
.status.ok{background:#12210f;color:var(--ok)}
.plan{margin-top:18px;padding:10px 12px;border:1px solid var(--line);border-radius:var(--r);background:var(--card)}
.plan h3{font-size:.76rem;text-transform:uppercase;letter-spacing:.04em;color:var(--acc);margin:0 0 8px}
.plan ul{margin:0;padding-left:16px;color:var(--mut);font-size:.76rem;line-height:1.65}
.plan code{color:#ddd;font-size:.72rem}
.proposal{display:inline-block;font-size:.6rem;font-weight:800;letter-spacing:.03em;background:#3a2a00;color:#ffc96b;border-radius:2px;padding:0 4px;margin-left:5px;vertical-align:1px}
/* replacement two-up */
.swap{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:14px}
.swap figure{margin:0;background:var(--card2);border:1px solid var(--line);border-radius:var(--r);padding:6px;text-align:center}
.swap svg{width:100%;display:block;background:#0f0f0f;border-radius:2px}
.swap figcaption{font-size:.68rem;color:var(--mut);margin-top:6px;line-height:1.4}
.swap .gone{color:#ff9d8a}
/* scope lineup */
.lineup{list-style:none;padding:0;margin:12px 0 0;display:grid;grid-template-columns:repeat(3,1fr);gap:8px}
.lineup-item{text-align:center;background:var(--card2);border:1px solid var(--line);border-radius:var(--r);padding:6px}
.lineup-item svg{width:100%;display:block;border-radius:2px;background:#0f0f0f}
.role{display:block;margin-top:6px;font-size:.72rem;color:var(--fg);font-weight:600}
.rot-badge{display:inline-block;margin-top:3px;font-size:.6rem;padding:0 4px;border-radius:2px;font-weight:700}
.rot-badge.yes{background:var(--acc);color:var(--acc2)}
.rot-badge.no{background:#3a3a3a;color:#bbb}
/* fields + settings */
.fields{display:flex;gap:16px;margin-top:16px}
.field{flex:1}
.label{display:block;font-size:.85rem;color:var(--mut);margin-bottom:8px}
.inp{box-sizing:border-box;width:100%;min-height:44px;padding:10px;font-size:.95rem;border-radius:var(--r);border:1px solid #444;background:var(--card2);color:var(--fg);font-variant-numeric:tabular-nums}
.settings label{display:block;margin:10px 0 4px;font-size:.85rem;color:var(--mut)}
.settings .section{margin-top:20px;padding-top:14px;border-top:1px solid var(--line)}
.settings button{padding:8px 12px;font-size:.85rem;border-radius:var(--r);border:1px solid #444;background:var(--card2);color:#ddd;cursor:pointer;margin-top:10px}
.settings .msg{font-size:.8rem;margin:8px 0 0;color:var(--mut)}
.settings .msg.ok{color:var(--ok)}
/* requirement coverage board (outside the phone) */
.cov{max-width:900px;margin:0 auto 40px;padding:0 12px}
.cov-inner{border:1px solid var(--line);border-radius:var(--r);background:var(--card);padding:12px 14px}
.cov h2{font-size:.86rem;text-transform:uppercase;letter-spacing:.04em;color:var(--acc);margin:0 0 10px}
.cov table{width:100%;border-collapse:collapse;font-size:.76rem}
.cov th,.cov td{text-align:left;padding:6px 8px;border-bottom:1px solid #2a2a2a;vertical-align:top;color:var(--mut)}
.cov th{color:var(--fg);font-size:.72rem;text-transform:uppercase;letter-spacing:.03em}
.cov td:first-child{color:var(--fg);white-space:nowrap}
.cov .where{color:#ddd}
/* narrow screens: stack the table instead of forcing its 400px min width */
@media (max-width:760px){
.cov thead{display:none}
.cov table,.cov tbody,.cov tr,.cov td{display:block;width:auto}
.cov tr{border-bottom:1px solid #2a2a2a;padding:8px 0}
.cov tr:last-child{border-bottom:0}
.cov td{border:0;padding:1px 0}
.cov td:first-child{white-space:normal;color:var(--acc);font-weight:700}
.cov td:nth-child(3)::before{content:"→ ";color:#666}
}
.hidden{display:none}
</style>
</head>
<body>
<div class="top">
<h1>SlabUploader · tape-line overlay — issue #99</h1>
<div class="flowbar" id="flowbar"></div>
<div class="vp">
<span>Viewport</span>
<span id="vpval">412</span>
<button data-w="390">390</button>
<button data-w="412" class="on">412</button>
<button data-w="720">720</button>
<button data-w="1440">1440</button>
</div>
</div>
<div class="stage"><div id="phone"></div></div>
<div class="cov"><div class="cov-inner">
<h2>Issue #99 — requirement coverage in this wireframe</h2>
<table>
<thead><tr><th>Req</th><th>What it says</th><th>Where it shows up</th></tr></thead>
<tbody>
<tr><td>1 Overlay</td><td>Black ~2pt line + two rifle-scope end ticks; ticks draggable; replaces the cyan bbox + red arrow </td><td class="where">Tape line step: the black line, its two end ticks, the "was / now" pair below the stage, and the hint copy</td></tr>
<tr><td>2 Controls</td><td>Pan / zoom / tilt the photo; ticks ≥44×44px, draggable, arrow + Shift+arrow fine nudge; one pointer path</td><td class="where">Zoom now has a visible row (− / slider / + / Fit) beside the tilt row, plus wheel, pinch, <b>+</b>/<b>−</b> keys and double-click to fit; pan = drag, tilt = handle + arrows</td></tr>
<tr><td>3 Fixed line</td><td>The reference line is fixed in the MIDDLE of the rectangle, and it IS the crop line: its ends are the crop insets</td><td class="where">The line's x is computed from the frame, never from the slab's transform; the crop % moves only its two ends, (1 − fill)/2 of the long axis in from the top and the bottom</td></tr>
<tr><td>4 Crop %</td><td>Global <code>settings.crop_fill</code> (default 90) replaces both <code>FILL = 0.9</code> copies, read live, non-retro. <b>It does not change the aspect ratio</b></td><td class="where">Crop fill control on the tape step and the Settings step: 90% puts the overlay 5% in from the top and the bottom; the 3:4 rectangle itself never changes</td></tr>
<tr><td>5 Crop content</td><td>Post-BG-removal, slab pixels only, transparent background, bounds = slab extent</td><td class="where">Mask step note + the crop frame outline around the stage</td></tr>
<tr><td>6 Transform scope</td><td>Tilt persists only to the calibration pic and its dimension-overlay clone; siblings keep their own orientation</td><td class="where">Photos-ready step lineup: calibration + dimensioned copy tilted, backside sibling untouched</td></tr>
<tr><td>7 Doc fix</td><td><code>capture/types.ts:46</code> says "80%-fill"; live value is 90%</td><td class="where">Noted in the Implementation notes card on the Settings step</td></tr>
</tbody>
</table>
</div></div>
<script>
// ===========================================================================
// issue #99 — the tape line is a FIXED ENTITY.
//
// * The black ~2pt line and its two end ticks never move: not on tilt, not
// on pan, not on zoom. It is fixed in the MIDDLE of the rectangle.
// * The SLAB is the only thing the operator moves (pan / zoom / tilt) until
// its long axis lies on the line, tick to tick.
// * The ticks belong to the line - they slide along it, never with the photo.
// * L = the distance between the two ticks (the owner's tape reading).
// ===========================================================================
const S = {
screen: "tape",
// slab transform (the only movable entity)
tilt: 14, // degrees
zoom: 1,
panX: 60, panY: 0,
// the line's ticks: y positions along the fixed line. They rest at the crop
// insets - (1 - fill)/2 of the rectangle's LONG axis in from the top and the
// bottom - and the operator drags them onto their tape marks from there.
tickA: 32,
tickB: 608,
focus: "A",
fill: 90, // settings.crop_fill - global, default 90
// declared values (entered on Measure, shown on both steps)
L: "88",
thickness: "2.125",
savedMsg: "",
};
// The stage IS the crop frame: 3:4, the same aspect composeInventoryFrame emits.
const VB = { w: 480, h: 640 };
// Slab drawn in stage coordinates. What has to be aligned is the slab's LONG
// AXIS - the centreline running down the middle of the board, which is where
// the tape is laid. (Not a physical side edge.)
const SLAB = {
path: "M 166 40 L 161 150 L 168 300 L 162 455 L 166 600 L 330 608 L 396 596 L 400 320 L 396 96 L 330 34 Z",
axis: [{ x: 282, y: 40 }, { x: 282, y: 600 }],
};
const TICK_LEN = 26; // rifle-scope end tick, total
const TICK_GAP = 40; // min separation between ticks
// Grab radius in stage units. The stage renders ~352-354px wide for a 480-unit
// viewBox, so 68 units is ~50px on screen - comfortably over the required 44x44.
const HIT_R = 34;
const ALIGN_TOL = 3; // stage units
const rad = (a) => a * Math.PI / 180;
// The fixed line sits in the MIDDLE of the rectangle: the centre of the 3:4
// crop frame, which is where the crop centres the slab's long axis.
const lineX = () => VB.w / 2;
// Crop % = (1 - fill)/2 of the rectangle's LONG axis in from the top and the
// bottom (cropMarginFraction() in png/crop.ts). It never touches the aspect
// ratio: the rectangle stays 3:4 and only the overlay's two ends move.
const cropInset = () => VB.h * (1 - S.fill / 100) / 2;
const cropTicks = () => ({ a: cropInset(), b: VB.h - cropInset() });
const spanPx = () => Math.abs(S.tickB - S.tickA);
const pxPerIn = () => spanPx() / (parseFloat(S.L) || 1);
// slab transform: zoom + tilt about the stage centre, then pan
function mapPoint(p) {
const cx = VB.w / 2, cy = VB.h / 2, a = rad(S.tilt), z = S.zoom;
const x = (p.x - cx) * z, y = (p.y - cy) * z;
return { x: cx + x * Math.cos(a) - y * Math.sin(a) + S.panX,
y: cy + x * Math.sin(a) + y * Math.cos(a) + S.panY };
}
function slabTransform() {
return `translate(${S.panX} ${S.panY}) translate(${VB.w / 2} ${VB.h / 2}) rotate(${S.tilt}) scale(${S.zoom}) translate(${-VB.w / 2} ${-VB.h / 2})`;
}
// where the slab's long axis crosses a given stage y
function axisXAt(y) {
const a = mapPoint(SLAB.axis[0]), b = mapPoint(SLAB.axis[1]);
if (Math.abs(b.y - a.y) < 1e-6) return a.x;
return a.x + ((y - a.y) / (b.y - a.y)) * (b.x - a.x);
}
const axisAngle = () => {
const a = mapPoint(SLAB.axis[0]), b = mapPoint(SLAB.axis[1]);
return Math.atan2(b.y - a.y, b.x - a.x) * 180 / Math.PI;
};
// the axis is vertical when its angle points straight down (or up) the screen
const axisVertical = () => { const d = ((axisAngle() % 180) + 180) % 180; return Math.abs(d - 90) < 1.5; };
const axisOffset = () => axisXAt((S.tickA + S.tickB) / 2) - lineX();
const aligned = () => axisVertical() && Math.abs(axisOffset()) <= ALIGN_TOL;
// ---------------------------------------------------------------- the stage
function slabSvg() {
return `
<g id="slab" transform="${slabTransform()}">
<clipPath id="slabClip"><path d="${SLAB.path}"/></clipPath>
<path d="${SLAB.path}" fill="hsl(28,36%,31%)" stroke="#000" stroke-opacity=".25"/>
<g clip-path="url(#slabClip)" opacity=".8">
<path d="M 176 90 Q 290 74 400 96" stroke="hsl(28,34%,36%)" stroke-width="4" fill="none"/>
<path d="M 174 200 Q 290 190 400 210" stroke="hsl(28,34%,35%)" stroke-width="4" fill="none"/>
<path d="M 172 320 Q 290 306 400 326" stroke="hsl(28,34%,37%)" stroke-width="4" fill="none"/>
<path d="M 172 440 Q 290 432 400 452" stroke="hsl(28,34%,34%)" stroke-width="4" fill="none"/>
<path d="M 174 550 Q 290 540 400 560" stroke="hsl(28,34%,36%)" stroke-width="4" fill="none"/>
</g>
</g>`;
}
// the FIXED entity: black 2pt line + two rifle-scope end ticks. No transform.
function fixtureSvg() {
const x = lineX();
const tick = (y, id) => {
const on = S.focus === id;
return `
<line x1="${x - TICK_LEN / 2}" y1="${y}" x2="${x + TICK_LEN / 2}" y2="${y}" stroke="rgba(255,255,255,.92)" stroke-width="6" stroke-linecap="round"/>
<line x1="${x - TICK_LEN / 2}" y1="${y}" x2="${x + TICK_LEN / 2}" y2="${y}" stroke="#000" stroke-width="2.4" stroke-linecap="round"/>
<circle cx="${x}" cy="${y}" r="5" fill="${on ? "#ff3d71" : "#000"}" stroke="#fff" stroke-width="1.5"/>
<circle class="tickg" data-tick="${id}" cx="${x}" cy="${y}" r="${HIT_R}" fill="transparent"/>`;
};
return `
<line x1="${x}" y1="${S.tickA}" x2="${x}" y2="${S.tickB}" stroke="rgba(255,255,255,.28)" stroke-width="6" stroke-linecap="round"/>
<line id="tapeLine" x1="${x}" y1="${S.tickA}" x2="${x}" y2="${S.tickB}" stroke="#000" stroke-width="2.4" stroke-linecap="round"/>
${tick(S.tickA, "A")}${tick(S.tickB, "B")}`;
}
// The crop frame: the rectangle's ASPECT NEVER CHANGES (3:4, from crop.ts).
// The crop % only moves the overlay's ends: (1 - fill)/2 of the long axis in
// from the top and the bottom, shaded here as the two bands it leaves outside.
function cropFrameSvg() {
const m = cropInset();
return `
<rect x="0" y="0" width="${VB.w}" height="${m}" fill="#000" opacity=".30"/>
<rect x="0" y="${VB.h - m}" width="${VB.w}" height="${m}" fill="#000" opacity=".30"/>
<rect id="cropframe" x=".5" y=".5" width="${VB.w - 1}" height="${VB.h - 1}" fill="none" stroke="#555" stroke-width="1" stroke-dasharray="6 5"/>
<text x="6" y="${m / 2 + 4}" fill="#8a8a8a" font-family="system-ui" font-size="11">crop ${S.fill}% — overlay ${((1 - S.fill / 100) / 2 * 100).toFixed(1)}% in from the top</text>
<text x="6" y="${VB.h - m / 2 + 4}" fill="#8a8a8a" font-family="system-ui" font-size="11">${((1 - S.fill / 100) / 2 * 100).toFixed(1)}% in from the bottom</text>`;
}
function rotateHandleSvg() {
return `
<g class="rotg" data-rot="1">
<circle cx="${VB.w / 2}" cy="${VB.h - 26}" r="17" fill="rgba(0,0,0,.6)" stroke="var(--acc)" stroke-width="1.5"/>
<text x="${VB.w / 2}" y="${VB.h - 21}" fill="var(--acc)" font-size="15" font-family="system-ui" text-anchor="middle">&#8635;</text>
<text x="${VB.w / 2 + 24}" y="${VB.h - 22}" fill="#aaa" font-size="10.5" font-family="system-ui">drag to tilt the slab</text>
</g>`;
}
function stageSvg() {
return `<svg viewBox="0 0 ${VB.w} ${VB.h}" id="tsvg" aria-label="fixed tape line over a movable slab">
<rect width="${VB.w}" height="${VB.h}" fill="#16281c"/>
${cropFrameSvg()}
${slabSvg()}
${fixtureSvg()}
${rotateHandleSvg()}
</svg>`;
}
// ------------------------------------------------------------------ screens
const screens = {
batch() {
return `<section class="panel">
<h1>Batch photos</h1>
<p class="hint">Unchanged by #99 — shown for context. Sheet is green or black and fills the frame around the slab.</p>
<div class="row"><button class="primary" data-act="to-mask">Pick calibration photo</button></div>
</section>`;
},
mask() {
return `<section class="panel">
<h1>Mask</h1>
<p class="hint">Unchanged by #99. The approved mask is still what the crop bounds come from.</p>
<div class="nums">
<div><span>Crop bounds</span><span class="tab">slab extent (kept pixels only)</span></div>
<div><span>Crop contents</span><span class="tab">slab pixels only, transparent background</span></div>
</div>
<div class="row">
<button class="ghost" data-act="to-batch">Back</button>
<button class="primary" style="flex:2" data-act="to-tape">Approve mask</button>
</div>
</section>`;
},
tape() {
const off = axisOffset(), ok = aligned();
return `<section class="panel">
<h1>Tape line</h1>
<p class="hint">Drag each tick to where your tape started and ended.</p>
<div class="tw" id="tw">${stageSvg()}</div>
<p class="nudge">The black line is <b>fixed</b>: tilting, dragging and zooming the slab never move it. It stays in the middle of the rectangle.</p>
<p class="nudge">Move the <b>slab</b> instead: drag it to pan, wheel or pinch to zoom, tilt it with the &#8635; handle or the arrow keys — until the board's long axis (the line down the middle of the slab) lies on the fixed line, tick to tick.</p>
<div class="controls">
<div>
<p class="ctl-label">Tilt the slab</p>
<div class="angle">
<button class="thumbbtn" data-act="tilt" data-d="-1" aria-label="tilt left">&#8722;</button>
<input type="range" min="0" max="359" value="${S.tilt}" data-act="tilt-range" aria-label="slab tilt"/>
<button class="thumbbtn" data-act="tilt" data-d="1" aria-label="tilt right">+</button>
<span class="val" id="tiltVal">${S.tilt}&#176;</span>
</div>
<p class="nudge">&larr; &rarr; tilt 1&#176; &nbsp;·&nbsp; Shift + &larr; &rarr; tilt 0.1&#176; &nbsp;·&nbsp; or drag the &#8635; handle</p>
</div>
<div>
<p class="ctl-label">Zoom the slab <span class="sub" style="font-size:.7rem">wheel or pinch over the photo</span></p>
<div class="angle">
<button class="thumbbtn" data-act="zoom" data-d="-1" aria-label="zoom out">&#8722;</button>
<input type="range" min="50" max="300" value="${Math.round(S.zoom * 100)}" data-act="zoom-range" aria-label="slab zoom"/>
<button class="thumbbtn" data-act="zoom" data-d="1" aria-label="zoom in">+</button>
<span class="val" id="zoomVal">${Math.round(S.zoom * 100)}%</span>
</div>
<p class="nudge">Wheel or pinch over the photo zooms to the cursor &nbsp;·&nbsp; <b>+</b> / <b>&#8722;</b> keys step 10% &nbsp;·&nbsp; double-click the photo to fit</p>
<div class="row" style="margin-top:8px"><button class="ghost" data-act="fit">Fit — 100%, centred</button></div>
</div>
<div>
<p class="ctl-label">Tape end ticks <span class="sub" style="font-size:.7rem">on the line; drag along it</span></p>
<p class="nudge">Grab a tick anywhere in its 44&#215;44px target and drag it along the line. The tick you last grabbed takes the arrow keys: <b>&#8593; &#8595;</b> nudge 1px, Shift = 0.1px &mdash; its centre dot turns red to show which one.</p>
</div>
<div>
<p class="ctl-label">Crop fill % <span class="sub" style="font-size:.7rem">global setting</span></p>
<div class="croprow">
<input type="range" min="1" max="100" value="${S.fill}" data-act="fill-range" aria-label="crop fill percent"/>
<span class="val" id="fillVal">${S.fill}%</span>
</div>
<p class="nudge">settings.crop_fill (default 90). It sets how much of the rectangle the overlay covers: at ${S.fill}&#37; the overlay's ends sit ${((1 - S.fill / 100) / 2 * 100).toFixed(1)}&#37; in from the top and the bottom. <b>The rectangle keeps its 3:4 aspect</b> — only the ends move, never the middle. Existing stored PNGs are never re-cropped.</p>
</div>
</div>
<div class="status ${ok ? "ok" : ""}" id="alignstatus">${ok
? "The board's long axis is on the line &#10003;"
: `The board's long axis is ${off > 0 ? "right" : "left"} of the line by ${Math.abs(off).toFixed(0)} px${axisVertical() ? "" : `, and tilted ${S.tilt}&#176;`}.`}</div>
<div class="nums">
<div><span>Fixed line x <span class="dim">(middle of the 3:4 frame)</span></span><span class="tab" id="lineReadout">${lineX().toFixed(1)} of ${VB.w}</span></div>
<div><span>Overlay insets <span class="dim">(from crop fill)</span></span><span class="tab" id="insetReadout">${((1 - S.fill / 100) / 2 * 100).toFixed(1)}% in from top and bottom</span></div>
<div><span>Tape span between ticks</span><span class="tab" id="spanReadout">${spanPx().toFixed(0)} px</span></div>
<div><span>Your tape length <span class="dim">(entered on Measure)</span></span><span class="tab">${(parseFloat(S.L) || 0).toFixed(1)}&#8243;</span></div>
<div><span>Scale from your ticks</span><span class="tab" id="scaleReadout">${pxPerIn().toFixed(2)} px/in</span></div>
<div><span>Mask span projection</span><span class="tab dim">not used — sanity check only</span></div>
</div>
<p class="nudge" style="margin-top:10px">Slab tilt ${S.tilt}&#176; &nbsp;·&nbsp; zoom ${S.zoom.toFixed(2)}&#215; <span class="dim">(moving these never changes the line)</span></p>
<div class="swap">
<figure>${oldOverlayMini()}<figcaption class="gone">&#10007; removed — cyan axis rectangle + red arrow <span class="dim">(PhotoStage.svelte:56-82)</span></figcaption></figure>
<figure>${newOverlayMini()}<figcaption>this step — fixed black 2pt line + two end ticks</figcaption></figure>
</div>
<div class="row">
<button class="ghost" data-act="to-mask">Back</button>
<button class="primary" style="flex:2" data-act="to-measure">Approve tape line</button>
</div>
<div class="plan">
<h3>Two things on this screen are my proposal, not the issue</h3>
<ul>
<li>The alignment read-out above <span class="proposal">PROPOSAL</span> — the issue says "rotate until the taped line sits on the fixed reference line" and doesn't specify how the operator knows. Tell me to drop it and it's gone; nothing is gated on it.</li>
<li>The shaded crop bands <span class="proposal">PROPOSAL</span> — drawn only to make the crop fill visible: the two strips the overlay leaves outside itself at the top and the bottom.</li>
</ul>
</div>
</section>`;
},
measure() {
const mini = (tilt, marks) => `
<svg viewBox="0 0 480 640">
<rect width="480" height="640" fill="#16281c"/>
<g transform="translate(240 320) rotate(${tilt}) translate(-240 -320)">
<path d="M 168 60 L 300 52 L 392 80 L 400 300 L 388 560 L 300 596 L 168 588 Z" fill="hsl(28,36%,31%)"/>
${marks ? `<line x1="168" y1="100" x2="168" y2="548" stroke="#000" stroke-width="2.4"/>
<line x1="155" y1="100" x2="181" y2="100" stroke="#000" stroke-width="2.4"/>
<line x1="155" y1="548" x2="181" y2="548" stroke="#000" stroke-width="2.4"/>` : ""}
</g>
</svg>`;
return `<section class="panel">
<h1>Photos ready</h1>
<div class="fields">
<div class="field"><span class="label">Length L (tape reading)</span><input class="inp" type="number" step="0.125" data-act="length" value="${S.L}"/></div>
<div class="field"><span class="label">Thickness</span><input class="inp" type="number" step="0.125" data-act="thickness" value="${S.thickness}"/></div>
</div>
<div class="nums">
<div><span>L maps to</span><span class="tab">tick-to-tick span (${spanPx().toFixed(0)} px)</span></div>
<div><span>Scale used</span><span class="tab">${pxPerIn().toFixed(2)} px/in</span></div>
<div><span>Mask span projection</span><span class="tab dim">not used</span></div>
</div>
<div class="plan">
<h3>Rotation scope — req. 6</h3>
<ul><li>The aligning tilt reaches the calibration pic and its dimension-overlay clone only.</li>
<li>A backside sibling keeps its own orientation; rotation is never propagated through the shared mask/crop settings.</li></ul>
</div>
<ol class="lineup">
<li class="lineup-item">${mini(S.tilt, true)}<span class="role">Clean calibration</span><span class="rot-badge yes">tilted ${S.tilt}&#176;</span></li>
<li class="lineup-item">${mini(S.tilt, false)}<span class="role">Dimension overlay</span><span class="rot-badge yes">same ${S.tilt}&#176;</span></li>
<li class="lineup-item">${mini(0, false)}<span class="role">Backside sibling</span><span class="rot-badge no">not re-tilted</span></li>
</ol>
<div class="row">
<button class="ghost" data-act="to-tape">Back</button>
<button class="primary" style="flex:2" data-act="to-settings">Continue</button>
</div>
</section>`;
},
settings() {
return `<section class="panel settings">
<h1>Settings</h1>
<div class="section">
<h3>Crop — global</h3>
<label for="crop-fill">Crop Fill % (default 90) — how much of the rectangle the overlay covers</label>
<div class="croprow">
<input type="range" id="crop-fill" min="1" max="100" data-act="fill-range" value="${S.fill}"/>
<span class="val" id="fillVal">${S.fill}%</span>
</div>
<p class="sub" style="margin-top:8px">One shared value replaces the two duplicated <code>FILL = 0.9</code> constants. Both the overlay's ends and the PNG crop read it live, so they can never disagree. At 90&#37; the overlay's ends sit ${((1 - S.fill / 100) / 2 * 100).toFixed(1)}&#37; in from the top and the bottom; <b>the 3:4 aspect ratio never changes</b>. Applies to slabs processed from now on; stored PNGs are not re-cropped.</p>
<button data-act="save-fill">Save Changes</button>
<p class="msg ${S.savedMsg ? "ok" : ""}">${S.savedMsg}</p>
</div>
<div class="section">
<h3>Implementation notes</h3>
<ul class="plan" style="border:0;padding:0;background:none">
<li><code>capture/types.ts:46</code> says "80%-fill PNG crop" — stale; the live value is 90%. Comment gets fixed (req. 7).</li>
<li>Replaces the cyan bbox + red arrow in <code>PhotoStage.svelte:56-82</code>.</li>
<li><code>maskSpanAlong</code> is demoted to a sanity check.</li>
</ul>
</div>
<div class="row"><button class="ghost" data-act="to-tape">Back to tape line</button></div>
</section>`;
},
};
// the two mini overlays for the was/now pair
function oldOverlayMini() {
return `<svg viewBox="0 0 120 160">
<rect width="120" height="160" fill="#141414"/>
<rect x="26" y="18" width="68" height="124" fill="hsl(28,30%,30%)"/>
<rect x="20" y="12" width="80" height="136" fill="none" stroke="#00e5ff" stroke-width="3" stroke-dasharray="8 5"/>
<line x1="60" y1="150" x2="60" y2="26" stroke="#ff3d71" stroke-width="4"/>
<path d="M 60 20 L 52 34 L 68 34 Z" fill="#ff3d71"/>
</svg>`;
}
function newOverlayMini() {
return `<svg viewBox="0 0 120 160">
<rect width="120" height="160" fill="#141414"/>
<rect x="34" y="18" width="52" height="124" fill="hsl(28,30%,30%)"/>
<line x1="60" y1="30" x2="60" y2="132" stroke="#000" stroke-width="2.4"/>
<line x1="48" y1="30" x2="72" y2="30" stroke="#000" stroke-width="2.4"/>
<line x1="48" y1="132" x2="72" y2="132" stroke="#000" stroke-width="2.4"/>
</svg>`;
}
// ----------------------------------------------------------------- plumbing
const phone = document.getElementById('phone');
const flowbar = document.getElementById('flowbar');
const STEPS = [
["batch", "Batch photos"], ["mask", "Mask"], ["tape", "Tape line (#99)"],
["measure", "Photos ready"], ["settings", "&#9881; Settings"],
];
function render() {
phone.innerHTML = screens[S.screen]();
flowbar.innerHTML = STEPS.map(([k, label]) =>
`<button class="fstep ${S.screen === k ? "on" : ""}" data-goto="${k}">${label}</button>`).join('');
if (S.screen === "tape") { bindStage(); refreshStage(); }
}
flowbar.addEventListener('click', (e) => {
const b = e.target.closest('[data-goto]');
if (!b) return;
S.screen = b.dataset.goto;
render();
});
document.querySelector('.vp').addEventListener('click', (e) => {
const b = e.target.closest('[data-w]');
if (!b) return;
document.querySelectorAll('.vp [data-w]').forEach((x) => x.classList.remove('on'));
b.classList.add('on');
phone.style.width = (b.dataset.w > 720 ? '412px' : b.dataset.w + 'px');
document.getElementById('vpval').textContent = b.dataset.w;
});
// Replace only the stage + the live readouts, so an in-progress drag survives
// (a full panel re-render would destroy the element under the pointer).
function refreshStage() {
const host = document.getElementById('tw');
if (!host) return;
host.innerHTML = stageSvg();
const set = (id, txt) => { const el = document.getElementById(id); if (el) el.textContent = txt; };
set('tiltVal', S.tilt + '\u00B0');
set('zoomVal', Math.round(S.zoom * 100) + '%');
set('fillVal', S.fill + '%');
set('insetReadout', `${((1 - S.fill / 100) / 2 * 100).toFixed(1)}% in from top and bottom`);
set('lineReadout', `${lineX().toFixed(1)} of ${VB.w}`);
set('spanReadout', `${spanPx().toFixed(0)} px`);
set('scaleReadout', `${pxPerIn().toFixed(2)} px/in`);
const st = document.getElementById('alignstatus');
if (st) {
const ok = aligned(), off = axisOffset();
st.className = 'status' + (ok ? ' ok' : '');
st.innerHTML = ok
? "The board's long axis is on the line \u2713"
: `The board's long axis is ${off > 0 ? 'right' : 'left'} of the line by ${Math.abs(off).toFixed(0)} px`
+ `${axisVertical() ? '' : `, and tilted ${S.tilt}\u00B0`}.`;
}
window.__wf = { tilt: S.tilt, zoom: S.zoom, panX: S.panX, panY: S.panY,
tickA: S.tickA, tickB: S.tickB, focus: S.focus, fill: S.fill,
lineX: lineX(), spanPx: spanPx(), axisOffset: axisOffset(),
axisXAt: axisXAt((S.tickA + S.tickB) / 2), axisVertical: axisVertical(),
axisAngle: axisAngle(), aligned: aligned() };
}
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
const norm360 = (a) => ((a % 360) + 360) % 360;
function tiltBy(d) { S.tilt = norm360(S.tilt + d); render(); }
// Visible zoom path. #99 names wheel / trackpad pinch; this is the discoverable
// twin, stepping about the stage centre so the slab stays where it is.
function stepZoom(dir) {
S.zoom = clamp(S.zoom * (dir > 0 ? 1.1 : 1 / 1.1), 0.5, 3);
refreshStage();
}
function fitSlab() { S.zoom = 1; S.panX = 0; S.panY = 0; refreshStage(); }
// no tick picker: the tick you last grabbed is the focused one (set on pointerdown)
function nudgeTick(dir, mult) {
const step = (mult === undefined ? 1 : mult);
if (S.focus === "A") S.tickA = clamp(S.tickA + dir * step, 20, S.tickB - TICK_GAP);
else S.tickB = clamp(S.tickB + dir * step, S.tickA + TICK_GAP, VB.h - 20);
render();
}
function bindStage() {
const host = document.getElementById('tw');
if (!host) return;
const toVB = (e) => {
const r = (host.querySelector('svg') || host).getBoundingClientRect();
return { x: (e.clientX - r.left) * (VB.w / r.width), y: (e.clientY - r.top) * (VB.h / r.height) };
};
// zoom-to-cursor — moves the SLAB only; the line ignores it entirely
function zoomAt(p, factor) {
const cx = VB.w / 2, cy = VB.h / 2;
const z0 = S.zoom, z1 = clamp(z0 * factor, 0.5, 3);
if (z1 === z0) return;
const qx = cx + (p.x - cx - S.panX) / z0;
const qy = cy + (p.y - cy - S.panY) / z0;
S.zoom = z1;
S.panX = p.x - cx - z1 * (qx - cx);
S.panY = p.y - cy - z1 * (qy - cy);
refreshStage();
}
host.addEventListener('wheel', (e) => {
e.preventDefault();
// No single event may slam the clamp: a PC ctrl+wheel notch arrives at
// deltaY ~300 and a trackpad pinch arrives as ctrlKey too, so cap both the
// delta and the per-event factor. Plain wheel stays ~16% per notch.
const dy = clamp(e.deltaY, -200, 200);
const k = e.ctrlKey ? 0.01 : 0.0015;
zoomAt(toVB(e), clamp(Math.exp(-dy * k), 0.8, 1.25));
}, { passive: false });
// belt-and-braces fit: Chromium suppresses this after our pointerdown
// preventDefault (see the tap tracking in end()), some engines still emit it
host.addEventListener('dblclick', (e) => { e.preventDefault(); fitSlab(); });
let drag = null, tb = null, moved = false, downP = null;
const off = { x: 0, y: 0 };
host.addEventListener('pointerdown', (e) => {
const tick = e.target.closest('[data-tick]');
const rot = e.target.closest('[data-rot]');
const p = toVB(e);
moved = false; downP = p;
if (tick) {
S.focus = tick.dataset.tick;
drag = { kind: "tick", id: S.focus };
} else if (rot) {
drag = { kind: "rot", start: p.x, tilt0: S.tilt };
} else {
drag = { kind: "slab" };
off.x = p.x - S.panX; off.y = p.y - S.panY;
}
host.setPointerCapture(e.pointerId);
host.classList.add('dragging');
refreshStage();
e.preventDefault();
});
host.addEventListener('pointermove', (e) => {
if (!drag) return;
const p = toVB(e);
if (downP && (Math.abs(p.x - downP.x) > 3 || Math.abs(p.y - downP.y) > 3)) moved = true;
if (drag.kind === "tick") {
// ticks slide ALONG the fixed line — x is ignored on purpose
const y = clamp(p.y, 20, VB.h - 20);
if (drag.id === "A") S.tickA = clamp(y, 20, S.tickB - TICK_GAP);
else S.tickB = clamp(y, S.tickA + TICK_GAP, VB.h - 20);
} else if (drag.kind === "rot") {
S.tilt = norm360(drag.tilt0 + (p.x - drag.start) * 0.6);
} else {
S.panX = clamp(p.x - off.x, -400, 400);
S.panY = clamp(p.y - off.y, -400, 400);
}
refreshStage();
});
const end = (e) => {
if (!drag) return;
const wasSlab = drag.kind === "slab";
const startP = toVB(e);
drag = null;
host.classList.remove('dragging');
try { host.releasePointerCapture(e.pointerId); } catch (_) {}
// double-tap to fit. We cannot use a 'dblclick' listener: preventDefault on
// pointerdown suppresses the whole compatibility mouse sequence (click and
// dblclick both stop arriving), so track the taps ourselves.
if (wasSlab && !moved) {
const now = performance.now();
const near = tb && Math.abs(startP.x - tb.x) < 12 && Math.abs(startP.y - tb.y) < 12;
if (near && now - tb.t < 350) { tb = null; fitSlab(); return; }
tb = { t: now, x: startP.x, y: startP.y };
}
refreshStage();
};
host.addEventListener('pointerup', end);
host.addEventListener('pointercancel', end);
}
// keyboard: arrows = Adobe nudge model (1 / 0.1 with Shift), +/- = zoom steps
document.addEventListener('keydown', (e) => {
if (S.screen !== "tape") return;
if (e.key === "+" || e.key === "=" || e.key === "-" || e.key === "_") {
stepZoom(e.key === "+" || e.key === "=" ? 1 : -1);
e.preventDefault();
return;
}
if (!e.key.startsWith("Arrow")) return;
const fine = e.shiftKey ? 0.1 : 1;
if (e.key === "ArrowLeft") { S.tilt = norm360(S.tilt - fine); render(); }
else if (e.key === "ArrowRight") { S.tilt = norm360(S.tilt + fine); render(); }
else if (e.key === "ArrowUp") { nudgeTick(-1, fine); }
else if (e.key === "ArrowDown") { nudgeTick(1, fine); }
else return;
e.preventDefault();
});
phone.addEventListener('click', (e) => {
const t = e.target.closest('[data-act]');
if (!t) return;
const a = t.dataset.act;
if (a === "tilt") tiltBy(+t.dataset.d);
else if (a === "zoom") stepZoom(+t.dataset.d);
else if (a === "fit") fitSlab();
else if (a === "to-mask") { S.screen = "mask"; render(); }
else if (a === "to-batch") { S.screen = "batch"; render(); }
else if (a === "to-tape") { S.screen = "tape"; render(); }
else if (a === "to-measure") { S.screen = "measure"; render(); }
else if (a === "to-settings") { S.screen = "settings"; render(); }
else if (a === "save-fill") { S.savedMsg = `Saved crop_fill = ${S.fill}. The overlay's ends and every future PNG crop follow it; the rectangle keeps its 3:4 aspect and stored PNGs are untouched.`; render(); }
});
phone.addEventListener('input', (e) => {
const t = e.target;
if (t.dataset.act === "tilt-range") { S.tilt = +t.value; refreshStage(); }
else if (t.dataset.act === "zoom-range") { S.zoom = clamp(+t.value / 100, 0.5, 3); refreshStage(); }
else if (t.dataset.act === "fill-range") {
// crop % moves the overlay's ENDS in/out from the top and bottom; the
// rectangle's aspect never changes and the line's x never moves.
S.fill = +t.value;
const ct = cropTicks();
S.tickA = ct.a; S.tickB = ct.b;
refreshStage();
}
});
phone.addEventListener('change', (e) => {
const t = e.target;
if (t.dataset.act === "length") { S.L = t.value; render(); }
else if (t.dataset.act === "thickness") { S.thickness = t.value; render(); }
});
render();
</script>
</body>
</html>
+27 -25
View File
@@ -9,10 +9,8 @@
import BatchPhotos from './capture/BatchPhotos.svelte';
import PickCalibration from './capture/PickCalibration.svelte';
import MaskPanel from './capture/MaskPanel.svelte';
import AxisPanel from './capture/AxisPanel.svelte';
import MeasurePanel from './capture/MeasurePanel.svelte';
import Settings from './create/settings/Settings.svelte';
import SlabList from './create/slabs/SlabList.svelte';
import Review from './create/review/Review.svelte';
import Publish from './create/publish/Publish.svelte';
import {
@@ -28,7 +26,6 @@
let busy = false;
let pngUrl: string | null = null;
let showSettings = false;
let showSlabs = false;
let curFlow: import('./create/flow').Flow = { phase: 'capture' };
$: curFlow = $flow;
@@ -72,17 +69,20 @@
const capture: CalibratedCapture = {
lengthIn: eighthsToIn(m.lengthEighths),
thicknessIn: eighthsToIn(m.thicknessEighths),
speciesId: m.speciesId,
sqft: r.sqft,
bdft: r.bdft,
widthMinIn: eighthsToIn(r.widths.min),
widthMaxIn: eighthsToIn(r.widths.max),
widthAvgIn: eighthsToIn(r.widths.avg),
sourceBelowFloor: r.sourceBelowFloor,
sourceBelowBlock: r.sourceBelowBlock,
sourceBelowWarn: r.sourceBelowWarn,
lineup: buildPhotosReadyLineup({
inventoryPng: r.inventoryPng,
overlayPng: r.overlayPng,
batchApply: m.batchApply,
sourceBelowFloor: r.sourceBelowFloor,
sourceBelowBlock: r.sourceBelowBlock,
sourceBelowWarn: r.sourceBelowWarn,
}),
};
saving = true;
@@ -100,6 +100,7 @@
width_min_in: capture.widthMinIn,
width_max_in: capture.widthMaxIn,
width_avg_in: capture.widthAvgIn,
species_id: capture.speciesId,
price: null,
price_source: 'recommendation',
content_source: 'manual',
@@ -213,18 +214,14 @@
retakes={session.retakes}
back={session.back}
/>
{:else if cap.k === 'axis'}
<AxisPanel
state={cap}
setAxisAngle={session.setAxisAngle}
approveAxis={session.approveAxis}
back={session.back}
/>
{:else}
{:else if cap.k === 'measure'}
<MeasurePanel
state={cap}
setLength={session.setLength}
setThickness={session.setThickness}
setSpecies={session.setSpecies}
setAxisAngle={session.setAxisAngle}
setTape={session.setTape}
calculate={session.calculate}
back={session.back}
{pngUrl}
@@ -239,16 +236,14 @@
{/if}
{/if}
<!-- Separate pages, never in the flow (UX.md §1). -->
<div style="margin-top:1rem;display:flex;gap:8px">
<button on:click={() => (showSettings = !showSettings)} data-testid="open-settings">Settings</button>
<button on:click={() => (showSlabs = !showSlabs)} data-testid="open-slabs">Slabs</button>
</div>
{#if showSettings}
<Settings />
<!-- Settings only (dev Slabs list removed — unused white control). Hidden after publish. -->
{#if curFlow.phase !== 'done'}
<div class="app-tools">
<button type="button" class="tool" on:click={() => (showSettings = !showSettings)} data-testid="open-settings">Settings</button>
</div>
{/if}
{#if showSlabs}
<SlabList />
{#if showSettings && curFlow.phase !== 'done'}
<Settings />
{/if}
</main>
{/if}
@@ -259,17 +254,24 @@
background: #121212;
font-family: system-ui, -apple-system, Segoe UI, sans-serif;
}
.app, .hardstop { max-width: 720px; margin: 0 auto; min-height: 100vh; }
.app, .hardstop { max-width: 720px; margin: 0 auto; min-height: 100vh; box-sizing: border-box; }
.app { padding: 12px 16px 24px; }
.hardstop { padding: 48px 24px; color: #eee; text-align: center; }
.hardstop h1 { font-size: 1.6rem; }
.detail { color: #888; font-size: 0.85rem; }
.panel { padding: 16px; color: #eee; }
.panel { padding: 4px 0 16px; color: #eee; }
.done .ok { color: #7fd37f; font-weight: 600; }
.row { display: flex; gap: 10px; margin-top: 16px; }
.primary {
flex: 1; padding: 14px; font-size: 1.05rem; border-radius: 10px; border: 0;
flex: 1; padding: 14px; font-size: 1rem; border-radius: 3px; border: 0;
background: #00e5ff; color: #002; font-weight: 700;
}
.busy { color: #aaa; }
.save-err { margin-top: 12px; color: #ffb4a2; font-weight: 600; }
.app-tools { margin-top: 1rem; display: flex; gap: 8px; }
.tool {
box-sizing: border-box; padding: 8px 12px; border-radius: 3px; border: 1px solid #444;
background: #1d1d1d; color: #ccc; font-size: .82rem; font-weight: 600; cursor: pointer;
}
.tool:hover { background: #242424; }
</style>
+59 -2
View File
@@ -5,7 +5,7 @@
// photos.py POST/GET /api/v1/slabs/{id}/photos[.../original|processed]
// review.py POST /api/v1/slabs/{id}/review
// publish.py POST /api/v1/slabs/{id}/publish
// inference.py POST /api/v1/slabs/{id}/inference/call1|call2
// inference.py POST /api/v1/slabs/{id}/inference/call1 (DISABLED 410) | call2
// settings.py GET/PUT /api/v1/settings ; POST /api/v1/settings/test-woo|test-inference
// taxonomy.py GET/POST /api/v1/admin/taxonomy[.../sync]
// pricing.py CRUD /api/v1/admin/pricing[/{id}]
@@ -45,6 +45,9 @@ export interface Slab {
inference_status: string | null;
calibration_photo_id: string | null;
woo_product_id: number | null;
/** Create-time optimistic token; updates compare against server_rev, not this. */
client_rev: number;
/** Optimistic concurrency token; send as client_rev on PUT. */
server_rev: number;
created_at: string;
updated_at: string;
@@ -135,7 +138,7 @@ export function getSlab(id: string): Promise<Slab> {
return fetch(`/api/v1/slabs/${encodeURIComponent(id)}`).then((r) => parse<Slab>(r));
}
export function updateSlab(id: string, patch: SlabInput & { client_rev: number }): Promise<Slab> {
function putSlab(id: string, patch: SlabInput & { client_rev: number }): Promise<Slab> {
return fetch(`/api/v1/slabs/${encodeURIComponent(id)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
@@ -143,6 +146,37 @@ export function updateSlab(id: string, patch: SlabInput & { client_rev: number }
}).then((r) => parse<Slab>(r));
}
/**
* PUT a slab patch. `client_rev` must equal the slab's current `server_rev`.
* On 409 revision_conflict (stale rev — e.g. review POST bumped server_rev, or
* a concurrent save), refetch once, adopt the latest server_rev, and retry.
* A second 409 surfaces a clear reload message.
*/
export async function updateSlab(
id: string,
patch: SlabInput & { client_rev: number },
): Promise<Slab> {
try {
return await putSlab(id, patch);
} catch (e) {
if (!(e instanceof ApiError) || e.status !== 409) throw e;
const latest = await getSlab(id);
try {
return await putSlab(id, { ...patch, client_rev: latest.server_rev });
} catch (e2) {
if (e2 instanceof ApiError && e2.status === 409) {
throw new ApiError(
409,
e2.code ?? 'revision_conflict',
'This slab changed elsewhere. Reload the page and try again.',
e2.detail,
);
}
throw e2;
}
}
}
export function deleteSlab(id: string): Promise<void> {
return fetch(`/api/v1/slabs/${encodeURIComponent(id)}`, { method: 'DELETE' }).then((r) =>
parse<void>(r),
@@ -330,8 +364,11 @@ export interface Call1Result {
export interface Call2Result {
ok: boolean;
description: string;
/** Present when Call 2 returned a parsed short_description. */
short_description?: string;
}
/** Call 1 OUT (overnight-whole-app): always fails with 410 call1_disabled. Prefer manual taxonomy. */
export function inferTaxon(slabId: string): Promise<Call1Result> {
return fetch(`/api/v1/slabs/${encodeURIComponent(slabId)}/inference/call1`, {
method: 'POST',
@@ -344,6 +381,26 @@ export function inferContent(slabId: string): Promise<Call2Result> {
}).then((r) => parse<Call2Result>(r));
}
/** View prompt: assembled Call 2 text + photo slots (no base64, no model call). */
export interface Call2PromptPhoto {
seq: number;
id: string;
bytes: number;
long_edge_px: number | null;
}
export interface Call2Prompt {
text: string;
photos: Call2PromptPhoto[];
prompt_revision_id: number | null;
}
export function getCall2Prompt(slabId: string): Promise<Call2Prompt> {
return fetch(`/api/v1/slabs/${encodeURIComponent(slabId)}/inference/call2/prompt`).then((r) =>
parse<Call2Prompt>(r),
);
}
// ---------------------------------------------------------------------------
// Taxonomy (METADATA-5/15/30; only legal picker options)
// ---------------------------------------------------------------------------
+146
View File
@@ -0,0 +1,146 @@
import assert from 'node:assert/strict';
import { afterEach, test } from 'node:test';
import { ApiError, updateSlab, type Slab } from './slabs';
const SLAB_ID = 'slab-rev-1';
function baseSlab(server_rev: number): Slab {
return {
id: SLAB_ID,
sku: 'SLAB-01',
status: 'calibrated',
price: 100,
price_source: 'recommendation',
length_in: 48,
thickness_in: 2,
sqft: 2.4,
bdft: 4.8,
width_min_in: 6,
width_max_in: 9,
width_avg_in: 8,
species_id: 1,
wood_category_ids: [2],
edge_type_term_id: 3,
figure_term_ids: [],
grade_term_id: 4,
thickness_term_id: 5,
moisture_term_id: 6,
fig_tag_ids: [],
feat_tag_ids: [],
title: 'Maple Slab',
short_description: 'short',
description: null,
content_source: 'manual',
species_confidence: null,
figure_confidence: null,
grade_confidence: null,
inference_status: null,
calibration_photo_id: null,
woo_product_id: null,
client_rev: 1,
server_rev,
created_at: 't',
updated_at: 't',
};
}
type Call = { method: string; url: string; body: unknown };
const calls: Call[] = [];
let handler: (method: string, url: string, body: unknown) => { status: number; body: unknown };
const origFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = origFetch;
calls.length = 0;
});
function mockFetch() {
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
const method = (init?.method ?? 'GET').toUpperCase();
const body = init?.body ? JSON.parse(String(init.body)) : null;
calls.push({ method, url, body });
const { status, body: resp } = handler(method, url, body);
return new Response(JSON.stringify(resp), {
status,
headers: { 'Content-Type': 'application/json' },
});
}) as typeof fetch;
}
test('updateSlab succeeds on first PUT when client_rev matches', async () => {
mockFetch();
handler = (method, _url, body) => {
assert.equal(method, 'PUT');
const patch = body as { client_rev: number };
assert.equal(patch.client_rev, 3);
return { status: 200, body: baseSlab(4) };
};
const out = await updateSlab(SLAB_ID, { client_rev: 3, title: 'Maple Slab' });
assert.equal(out.server_rev, 4);
assert.equal(calls.length, 1);
});
test('updateSlab on 409 refetches, adopts server_rev, retries once', async () => {
mockFetch();
let puts = 0;
handler = (method, url, body) => {
if (method === 'PUT') {
puts += 1;
const patch = body as { client_rev: number; title?: string };
if (puts === 1) {
assert.equal(patch.client_rev, 9); // stale (e.g. review POST bumped)
return {
status: 409,
body: {
error: {
code: 'revision_conflict',
message: 'client_rev mismatch',
detail: { current_server_rev: 10 },
},
},
};
}
assert.equal(patch.client_rev, 10); // adopted from GET
assert.equal(patch.title, 'Maple Slab');
return { status: 200, body: baseSlab(11) };
}
if (method === 'GET' && url.includes(SLAB_ID)) {
return { status: 200, body: baseSlab(10) };
}
throw new Error(`unexpected ${method} ${url}`);
};
const out = await updateSlab(SLAB_ID, { client_rev: 9, title: 'Maple Slab' });
assert.equal(out.server_rev, 11);
assert.equal(puts, 2);
assert.equal(calls.map((c) => c.method).join(','), 'PUT,GET,PUT');
});
test('updateSlab second 409 surfaces a reload message', async () => {
mockFetch();
handler = (method) => {
if (method === 'PUT') {
return {
status: 409,
body: {
error: {
code: 'revision_conflict',
message: 'client_rev mismatch',
detail: { current_server_rev: 99 },
},
},
};
}
return { status: 200, body: baseSlab(99) };
};
await assert.rejects(
() => updateSlab(SLAB_ID, { client_rev: 1, sku: 'X' }),
(e: unknown) => {
assert.ok(e instanceof ApiError);
assert.equal(e.status, 409);
assert.match(e.message, /Reload the page/i);
return true;
},
);
});
+115 -17
View File
@@ -1,58 +1,156 @@
<!-- DEPRECATED: tape orientation merged into MeasurePanel (overnight-whole-app). Kept for reference. -->
<script lang="ts">
import type { Capture } from './capture.types';
import type { TapeTransform } from './tapeGeometry';
import { clampTiltDeg, defaultTicks } from './tapeGeometry';
import PhotoStage from './PhotoStage.svelte';
import { getCropFill, setCropFill } from '../settings/cropFill';
import { cropMarginFraction } from '../png/crop';
export let state: Extract<Capture, { k: 'axis' }>;
export let setAxisAngle: (deg: number) => void;
export let setTape: (tape: TapeTransform) => void;
export let approveAxis: () => void;
export let back: () => void;
let cropFill = getCropFill();
$: maskW = state.source.widthPx > 0 ? state.source.widthPx : 0;
$: maskH = maskW > 0 ? state.approvedMask.bitset.length / maskW : 0;
$: angleDeg = Math.round(state.axis.bbox.angleDeg);
$: tape = state.axis.tape;
$: tiltShown = Math.round(tape.tiltDeg * 10) / 10;
const onTapeChange = (t: TapeTransform) => setTape(t);
const nudgeTilt = (delta: number) => {
setTape({ ...tape, tiltDeg: clampTiltDeg(tape.tiltDeg + delta) });
};
const setTilt = (deg: number) => {
const clamped = clampTiltDeg(deg);
setTape({ ...tape, tiltDeg: clamped });
// Keep legacy path warm for any callers still using setAxisAngle.
setAxisAngle(clamped);
};
const nudgeZoom = (dir: number) => {
const z = dir < 0 ? Math.max(0.5, tape.zoom / 1.1) : Math.min(3, tape.zoom * 1.1);
setTape({ ...tape, zoom: z });
};
const onFill = (pct: number) => {
const fill = Math.min(1, Math.max(0.01, pct / 100));
cropFill = setCropFill(fill);
const ticks = defaultTicks(cropFill);
setTape({ ...tape, ...ticks });
};
const fit = () => setTape({ ...tape, zoom: 1, panX: 0, panY: 0 });
</script>
<section class="panel">
<h1>Length axis</h1>
<p class="hint">Rotate the rectangle until it follows the slab length. The rectangle sets direction only; you enter length L on the next step.</p>
<h1>Tape line</h1>
<p class="hint">Drag each tick to where your tape started and ended.</p>
<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>
<PhotoStage
mode="tape"
blob={state.source.blob}
bbox={state.axis.bbox}
maskBitset={state.approvedMask.bitset}
{maskW}
{maskH}
{tape}
{cropFill}
onTapeChange={onTapeChange}
/>
<div class="angle">
<button type="button" class="thumb" aria-label="Rotate axis counter-clockwise" on:click={() => setAxisAngle(angleDeg - 1)}>−</button>
<input
type="range" min="0" max="179" step="1"
value={angleDeg}
on:input={(e) => setAxisAngle(Number(e.currentTarget.value))}
/>
<button type="button" class="thumb" aria-label="Rotate axis clockwise" on:click={() => setAxisAngle(angleDeg + 1)}>+</button>
<span class="val">{angleDeg}°</span>
<div class="controls">
<div>
<p class="ctl-label">Tilt the slab</p>
<div class="angle">
<button type="button" class="thumb" aria-label="Tilt left" on:click={() => nudgeTilt(-1)}>−</button>
<input
type="range" min="-90" max="90" step="0.1"
value={clampTiltDeg(tiltShown)}
on:input={(e) => setTilt(Number(e.currentTarget.value))}
/>
<button type="button" class="thumb" aria-label="Tilt right" on:click={() => nudgeTilt(1)}>+</button>
<span class="val">{tiltShown}°</span>
</div>
<p class="nudge">±90° · ← → tilt 1° · Shift+← → 0.1° · two-finger twist (Rotate gesture mode)</p>
</div>
<div>
<p class="ctl-label">Zoom the slab</p>
<div class="angle">
<button type="button" class="thumb" aria-label="Zoom out" on:click={() => nudgeZoom(-1)}>−</button>
<input
type="range" min="50" max="300" step="1"
value={Math.round(tape.zoom * 100)}
on:input={(e) => setTape({ ...tape, zoom: Number(e.currentTarget.value) / 100 })}
/>
<button type="button" class="thumb" aria-label="Zoom in" on:click={() => nudgeZoom(1)}>+</button>
<span class="val">{Math.round(tape.zoom * 100)}%</span>
</div>
<div class="row tight">
<button type="button" class="ghost" on:click={fit}>Fit — 100%, centred</button>
</div>
</div>
<div>
<p class="ctl-label">Crop fill % <span class="sub">global setting</span></p>
<div class="croprow">
<input
type="range" min="50" max="100" step="1"
value={Math.round(cropFill * 100)}
aria-label="Crop fill percent"
on:input={(e) => onFill(Number(e.currentTarget.value))}
/>
<span class="val">{Math.round(cropFill * 100)}%</span>
</div>
<p class="nudge">
settings.crop_fill (default 90). At {Math.round(cropFill * 100)}% the overlay ends sit
{(cropMarginFraction(cropFill) * 100).toFixed(1)}% in from top and bottom.
The 3:4 aspect never changes. Stored PNGs are not re-cropped.
</p>
</div>
<div class="nums">
<div><span>Tape span between ticks</span><span class="tab">{tape.spanPx.toFixed(0)} px</span></div>
<div><span>Scale (once L entered)</span><span class="tab dim">span / L → px/in</span></div>
</div>
</div>
<div class="row">
<button type="button" class="ghost" on:click={back}>Back</button>
<button type="button" class="primary" on:click={approveAxis}>Approve axis</button>
<button type="button" class="primary" on:click={approveAxis}>Approve tape line</button>
</div>
</section>
<style>
.panel { padding: 16px; color: #eee; }
h1 { font-size: 1.3rem; margin: 0 0 8px; }
.hint { color: #aaa; font-size: 0.9rem; margin: 0 0 12px; }
.angle { display: flex; align-items: center; gap: 10px; margin-top: 16px; }
.hint { color: #aaa; font-size: 0.9rem; margin: 0 0 8px; }
.nudge { color: #e8a; font-size: 0.74rem; margin: 6px 0 0; line-height: 1.45; }
.controls { margin-top: 16px; display: flex; flex-direction: column; gap: 14px; }
.ctl-label { font-size: 0.78rem; color: #aaa; margin: 0 0 6px; }
.ctl-label .sub { font-size: 0.7rem; color: #777; }
.angle, .croprow { display: flex; align-items: center; gap: 10px; }
.thumb {
width: 48px; height: 48px; border-radius: 50%; font-size: 1.6rem;
width: 44px; height: 44px; border-radius: 50%; font-size: 1.4rem;
border: 1px solid #444; background: #1d1d1d; color: #eee; flex: none;
}
input[type='range'] { flex: 1; }
.val { min-width: 4ch; text-align: right; font-variant-numeric: tabular-nums; }
.row { display: flex; gap: 10px; margin-top: 16px; }
.row.tight { margin-top: 8px; }
.primary {
flex: 1; padding: 14px; font-size: 1rem; border-radius: 10px; border: 0;
background: #00e5ff; color: #002; font-weight: 700;
}
.ghost { flex: 0.7; padding: 14px; border-radius: 10px; border: 1px solid #444; background: transparent; color: #ccc; }
.nums { display: flex; flex-direction: column; gap: 8px; margin-top: 4px; }
.nums div { display: flex; justify-content: space-between; border-bottom: 1px solid #2a2a2a; padding-bottom: 6px; font-size: 0.84rem; gap: 10px; }
.nums span:first-child { color: #aaa; }
.tab { font-variant-numeric: tabular-nums; text-align: right; }
.dim { color: #777; }
</style>
+295 -18
View File
@@ -1,9 +1,16 @@
<script lang="ts">
import { onDestroy } from 'svelte';
import { onDestroy, onMount } from 'svelte';
import type { Capture } from './capture.types';
import { inToEighths, eighthsToIn } from './capture.types';
import { fmtEighths } from './session';
import type { TapeTransform } from './tapeGeometry';
import { clampTiltDeg, defaultTicks } from './tapeGeometry';
import PhotoStage from './PhotoStage.svelte';
import { getCropFill, setCropFill } from '../settings/cropFill';
import { cropMarginFraction } from '../png/crop';
import { createMaskedPreviewBlob } from '../png/maskedPreview';
import { getTaxonomy } from '../api/slabs';
import { speciesLeavesFromTaxonomy } from '../create/settings/speciesLeaves';
import {
buildPhotosReadyLineup,
photosReadyPngAlt,
@@ -13,6 +20,9 @@
export let state: Extract<Capture, { k: 'measure' }>;
export let setLength: (e: number) => void;
export let setThickness: (e: number) => void;
export let setSpecies: (id: number | null) => void;
export let setAxisAngle: (deg: number) => void;
export let setTape: (tape: TapeTransform) => void;
export let calculate: () => Promise<void>;
export let back: () => void;
export let pngUrl: string | null;
@@ -22,6 +32,51 @@
let calcBusy = false;
let lengthIn = state.lengthEighths === 0 ? '' : String(eighthsToIn(state.lengthEighths));
let thicknessIn = state.thicknessEighths === 0 ? '' : String(eighthsToIn(state.thicknessEighths));
let speciesSel = state.speciesId == null ? '' : String(state.speciesId);
let speciesOpts: { id: number; name: string }[] = [];
let speciesLoadErr = '';
let cropFill = getCropFill();
/** Exclusive stage gestures: Zoom/Pan (default) vs Rotate. Sliders stay available either way. */
let gestureMode: 'zoompan' | 'rotate' = 'zoompan';
$: maskW = state.source.widthPx > 0 ? state.source.widthPx : 0;
$: maskH = maskW > 0 ? state.approvedMask.bitset.length / maskW : 0;
$: tape = state.axis.tape;
$: tiltShown = Math.round(tape.tiltDeg * 10) / 10;
let maskedPreview: Blob | null = null;
let previewError = '';
let previewGeneration = 0;
let previewSource: Blob | null = null;
let previewMask: Uint8Array | null = null;
let previewW = 0;
let previewH = 0;
// Build the tape photo once from the oriented source plus the approved mask.
// Never fall back to the raw source here: tape alignment must show the same
// background-removed slab footprint that was approved in Mask.
$: {
const sourceBlob = state.source.blob;
const bitset = state.approvedMask.bitset;
const width = state.source.widthPx;
const height = state.source.heightPx;
if (sourceBlob !== previewSource || bitset !== previewMask || width !== previewW || height !== previewH) {
previewSource = sourceBlob;
previewMask = bitset;
previewW = width;
previewH = height;
maskedPreview = null;
previewError = '';
const generation = ++previewGeneration;
void createMaskedPreviewBlob(sourceBlob, bitset)
.then((blob) => {
if (generation === previewGeneration) maskedPreview = blob;
})
.catch(() => {
if (generation === previewGeneration) previewError = 'Could not build the background-removed preview.';
});
}
}
let lineupUrls: string[] = [];
let lineupItems: PhotosReadyLineupItem[] = [];
@@ -35,7 +90,8 @@
inventoryPng: state.results.inventoryPng,
overlayPng: state.results.overlayPng,
batchApply: state.batchApply,
sourceBelowFloor: state.results.sourceBelowFloor,
sourceBelowBlock: state.results.sourceBelowBlock,
sourceBelowWarn: state.results.sourceBelowWarn,
});
lineupUrls = lineupItems.map((it) =>
it.kind === 'png' ? URL.createObjectURL(it.blob) : '',
@@ -45,10 +101,63 @@
}
}
// Sync local select only when session speciesId changes (draft restore / setSpecies).
// Do NOT depend on speciesSel — that fights bind:value and can clear a choice
// before setSpecies lands (or wipe restored ids while options are still loading).
let syncedSpeciesId: number | null | undefined = undefined;
$: if (syncedSpeciesId !== state.speciesId) {
syncedSpeciesId = state.speciesId;
speciesSel = state.speciesId == null ? '' : String(state.speciesId);
}
let speciesOptsReady = false;
onMount(() => {
void getTaxonomy()
.then((tax) => {
speciesOpts = speciesLeavesFromTaxonomy(tax.categories).map((c) => ({
id: c.id,
name: c.name,
}));
speciesOptsReady = true;
})
.catch(() => {
speciesLoadErr = 'Could not load species list.';
speciesOpts = [];
speciesOptsReady = true;
});
});
onDestroy(() => {
previewGeneration += 1;
for (const u of lineupUrls) URL.revokeObjectURL(u);
});
const onTapeChange = (t: TapeTransform) => setTape(t);
const nudgeTilt = (delta: number) => {
setTape({ ...tape, tiltDeg: clampTiltDeg(tape.tiltDeg + delta) });
};
const setTilt = (deg: number) => {
const clamped = clampTiltDeg(deg);
setTape({ ...tape, tiltDeg: clamped });
setAxisAngle(clamped);
};
const nudgeZoom = (dir: number) => {
const z = dir < 0 ? Math.max(0.5, tape.zoom / 1.1) : Math.min(3, tape.zoom * 1.1);
setTape({ ...tape, zoom: z });
};
const onFill = (pct: number) => {
const fill = Math.min(1, Math.max(0.01, pct / 100));
cropFill = setCropFill(fill);
const ticks = defaultTicks(cropFill);
setTape({ ...tape, ...ticks });
};
const fit = () => setTape({ ...tape, zoom: 1, panX: 0, panY: 0 });
const positiveInches = (raw: string): number | null => {
const inches = Number.parseFloat(raw);
return Number.isFinite(inches) && inches > 0 ? inches : null;
@@ -66,14 +175,21 @@
setThickness(eighths);
thicknessIn = eighths === 0 ? '' : String(eighthsToIn(eighths));
};
const commitSpecies = () => {
setSpecies(speciesSel === '' ? null : Number(speciesSel));
};
$: canCalc = positiveInches(String(lengthIn)) !== null && positiveInches(String(thicknessIn)) !== null;
$: canCalc =
positiveInches(String(lengthIn)) !== null
&& positiveInches(String(thicknessIn)) !== null
&& speciesSel !== '';
const doCalc = async () => {
calcBusy = true;
try {
commitLength();
commitThickness();
commitSpecies();
await calculate();
} finally { calcBusy = false; }
};
@@ -83,13 +199,106 @@
<h1>{photosReady ? 'Photos ready' : 'Measure'}</h1>
{#if !photosReady}
<PhotoStage
blob={state.source.blob}
bbox={state.axis.bbox}
maskBitset={state.approvedMask.bitset}
maskW={state.source.widthPx > 0 ? state.source.widthPx : 0}
maskH={state.source.widthPx > 0 ? state.approvedMask.bitset.length / state.source.widthPx : 0}
/>
<p class="hint">Orient the slab on the fixed tape line, then enter length, thickness, and species.</p>
<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>
<div class="mode-toggle" role="group" aria-label="Stage gesture mode">
<button
type="button"
class="mode-btn"
class:active={gestureMode === 'zoompan'}
aria-pressed={gestureMode === 'zoompan'}
data-testid="gesture-mode-zoompan"
on:click={() => (gestureMode = 'zoompan')}
>Zoom / Pan</button>
<button
type="button"
class="mode-btn"
class:active={gestureMode === 'rotate'}
aria-pressed={gestureMode === 'rotate'}
data-testid="gesture-mode-rotate"
on:click={() => (gestureMode = 'rotate')}
>Rotate</button>
</div>
<p class="nudge mode-hint">
{#if gestureMode === 'zoompan'}
Pinch to zoom and drag to pan. Rotation gestures are ignored — switch to Rotate or use the tilt slider.
{:else}
Two-finger twist to rotate (or use the tilt slider). Pan and pinch-zoom are ignored in this mode.
{/if}
</p>
{#if maskedPreview}
<PhotoStage
mode="tape"
blob={maskedPreview}
{tape}
{cropFill}
{gestureMode}
onTapeChange={onTapeChange}
/>
{:else}
<div class="preview-loading" role="status">
{previewError || 'Preparing background-removed preview…'}
</div>
{/if}
<div class="controls">
<div>
<p class="ctl-label">Tilt the slab</p>
<div class="angle">
<button type="button" class="thumb" aria-label="Tilt left" on:click={() => nudgeTilt(-1)}>−</button>
<input
type="range" min="-90" max="90" step="0.1"
value={clampTiltDeg(tiltShown)}
on:input={(e) => setTilt(Number(e.currentTarget.value))}
/>
<button type="button" class="thumb" aria-label="Tilt right" on:click={() => nudgeTilt(1)}>+</button>
<span class="val">{tiltShown}°</span>
</div>
<p class="nudge">±90° · slider always works · in Rotate mode: ← → tilt 1° · Shift+← → 0.1° · two-finger twist</p>
</div>
<div>
<p class="ctl-label">Zoom the slab</p>
<div class="angle">
<button type="button" class="thumb" aria-label="Zoom out" on:click={() => nudgeZoom(-1)}>−</button>
<input
type="range" min="50" max="300" step="1"
value={Math.round(tape.zoom * 100)}
on:input={(e) => setTape({ ...tape, zoom: Number(e.currentTarget.value) / 100 })}
/>
<button type="button" class="thumb" aria-label="Zoom in" on:click={() => nudgeZoom(1)}>+</button>
<span class="val">{Math.round(tape.zoom * 100)}%</span>
</div>
<div class="row tight">
<button type="button" class="ghost" on:click={fit}>Fit — 100%, centred</button>
</div>
</div>
<div>
<p class="ctl-label">Crop fill % <span class="sub">global setting</span></p>
<div class="croprow">
<input
type="range" min="50" max="100" step="1"
value={Math.round(cropFill * 100)}
aria-label="Crop fill percent"
on:input={(e) => onFill(Number(e.currentTarget.value))}
/>
<span class="val">{Math.round(cropFill * 100)}%</span>
</div>
<p class="nudge">
settings.crop_fill (default 90). At {Math.round(cropFill * 100)}% the overlay ends sit
{(cropMarginFraction(cropFill) * 100).toFixed(1)}% in from top and bottom.
The 3:4 aspect never changes. Stored PNGs are not re-cropped.
</p>
</div>
<div class="nums">
<div><span>Tape span between ticks</span><span class="tab">{tape.spanPx.toFixed(0)} px</span></div>
<div><span>Scale (once L entered)</span><span class="tab dim">span / L → px/in</span></div>
</div>
</div>
<div class="fields">
<div class="field">
@@ -122,6 +331,32 @@
</div>
</div>
<div class="field species">
<label class="label" for="measure-species">Species</label>
{#if !speciesOptsReady}
<select id="measure-species" class="sel" data-testid="measure-species" aria-label="Species" disabled>
<option value="">Loading species…</option>
</select>
{:else}
<select
id="measure-species"
class="sel"
data-testid="measure-species"
aria-label="Species"
bind:value={speciesSel}
on:change={commitSpecies}
>
<option value="">Select species…</option>
{#each speciesOpts as o (o.id)}
<option value={String(o.id)}>{o.name}</option>
{/each}
</select>
{/if}
{#if speciesLoadErr}
<p class="species-err" role="status">{speciesLoadErr}</p>
{/if}
</div>
<div class="row">
<button type="button" class="ghost" on:click={back}>Back</button>
<button
@@ -140,9 +375,16 @@
<div><span>Sqft</span><span>{state.results.sqft.toFixed(2)}</span></div>
<div><span>Bdft</span><span>{state.results.bdft.toFixed(2)}</span></div>
<div><span>Scale</span><span>{state.results.scale.toFixed(2)} px/in</span></div>
<div data-testid="photos-ready-species"><span>Species</span><span>{speciesOpts.find((o) => o.id === state.speciesId)?.name ?? (state.speciesId != null ? `#${state.speciesId}` : '—')}</span></div>
</div>
{#if state.results.sourceBelowFloor}
<div class="warn">Source is below the 800px floor for the inventory PNG. Retake a larger photo.</div>
{#if state.results.sourceBelowBlock}
<div class="warn" role="alert" data-testid="size-floor-block">
Source shorter side is below 300px — inventory PNG blocked. Retake a larger photo.
</div>
{:else if state.results.sourceBelowWarn}
<div class="warn" data-testid="size-floor-warn">
Source shorter side is under 800px (300–799). Photo included; consider a closer/larger shot for best quality.
</div>
{/if}
<ol class="lineup" aria-label="Inventory PNG lineup">
@@ -159,6 +401,9 @@
Batch photo {(item.batchIndex ?? 0) + 1}
{/if}
</span>
{#if item.warn}
<span class="item-warn" data-testid="lineup-warn">{item.warn}</span>
{/if}
</li>
{:else}
<li class="lineup-item error" role="alert">
@@ -170,7 +415,7 @@
</ol>
<!-- Legacy single-PNG fallback only when lineup helper yielded nothing but pngUrl exists -->
{#if lineupItems.length === 0 && pngUrl && !state.results.sourceBelowFloor}
{#if lineupItems.length === 0 && pngUrl && !state.results.sourceBelowBlock}
<div class="png-wrap">
<img src={pngUrl} alt="Inventory PNG" />
</div>
@@ -190,27 +435,59 @@
<style>
.panel { padding: 16px; color: #eee; }
h1 { font-size: 1.3rem; margin: 0 0 12px; }
h1 { font-size: 1.3rem; margin: 0 0 8px; }
.hint { color: #aaa; font-size: 0.9rem; margin: 0 0 8px; }
.mode-toggle {
display: flex; gap: 0; margin: 10px 0 4px;
border: 1px solid #444; border-radius: 10px; overflow: hidden; width: fit-content; max-width: 100%;
}
.mode-btn {
min-height: 44px; padding: 10px 18px; font-size: 0.95rem; font-weight: 600;
border: 0; background: #1d1d1d; color: #aaa; cursor: pointer;
}
.mode-btn + .mode-btn { border-left: 1px solid #444; }
.mode-btn.active { background: #00e5ff; color: #002; }
.mode-hint { margin: 0 0 10px; }
.nudge { color: #e8a; font-size: 0.74rem; margin: 6px 0 0; line-height: 1.45; }
.preview-loading { min-height: 420px; border: 1px solid #333; border-radius: 8px; display: flex; align-items: center; justify-content: center; color: #999; text-align: center; padding: 16px; box-sizing: border-box; }
.controls { margin-top: 16px; display: flex; flex-direction: column; gap: 14px; }
.ctl-label { font-size: 0.78rem; color: #aaa; margin: 0 0 6px; }
.ctl-label .sub { font-size: 0.7rem; color: #777; }
.angle, .croprow { display: flex; align-items: center; gap: 10px; }
.thumb {
width: 44px; height: 44px; border-radius: 50%; font-size: 1.4rem;
border: 1px solid #444; background: #1d1d1d; color: #eee; flex: none;
}
input[type='range'] { flex: 1; }
.val { min-width: 4ch; text-align: right; font-variant-numeric: tabular-nums; }
.fields { display: flex; gap: 16px; margin-top: 16px; }
.field { flex: 1; }
.field.species { margin-top: 16px; flex: none; }
.label { display: block; font-size: 0.9rem; color: #aaa; margin-bottom: 8px; }
.field input {
.field input, .sel {
box-sizing: border-box; width: 100%; min-height: 52px; padding: 12px; font-size: 1rem;
border-radius: 10px; border: 1px solid #444; background: #1d1d1d; color: #eee;
font-variant-numeric: tabular-nums;
}
.species-err { color: #ffb4a2; font-size: 0.85rem; margin: 8px 0 0; }
.row { display: flex; gap: 10px; margin-top: 20px; }
.row.tight { margin-top: 8px; }
.primary {
flex: 2; padding: 14px; font-size: 1.05rem; border-radius: 10px; border: 0;
background: #00e5ff; color: #002; font-weight: 700;
}
.primary:disabled { background: #333; color: #777; }
.ghost { flex: 1; padding: 14px; border-radius: 10px; border: 1px solid #444; background: transparent; color: #ccc; }
.results { margin-top: 8px; }
.nums { display: flex; flex-direction: column; gap: 8px; }
.nums div { display: flex; justify-content: space-between; border-bottom: 1px solid #2a2a2a; padding-bottom: 6px; }
.nums { display: flex; flex-direction: column; gap: 8px; margin-top: 4px; }
.nums div { display: flex; justify-content: space-between; border-bottom: 1px solid #2a2a2a; padding-bottom: 6px; font-size: 0.84rem; gap: 10px; }
.nums span:first-child { color: #aaa; }
.tab { font-variant-numeric: tabular-nums; text-align: right; }
.dim { color: #777; }
.results { margin-top: 8px; }
.results .nums { font-size: 1rem; }
.results .nums div { font-size: 1rem; }
.warn { color: #ffb4a2; margin-top: 12px; font-size: 0.9rem; }
.item-warn { display: block; margin-top: 6px; color: #c07020; font-size: 0.8rem; font-weight: 600; }
.lineup {
list-style: none; padding: 0; margin: 16px 0 0;
display: flex; flex-direction: column; gap: 14px;
+285 -13
View File
@@ -1,8 +1,12 @@
<script lang="ts">
import { onDestroy, onMount, tick } from 'svelte';
import type { BBox } from './capture.types';
import type { TapeTransform } from './tapeGeometry';
import { STAGE_H, STAGE_W, clampTickPair, clampTiltDeg, defaultTicks, lineX } from './tapeGeometry';
import { fitDisplay } from './fitDisplay';
import { decodeOrientedBitmap } from '../decodeOrientedBitmap';
import { cropMarginFraction } from '../png/crop';
import { getCropFill } from '../settings/cropFill';
// Display-only: renders the photo fitted to device width (height capped) plus
// one overlay layer. All math lives on source pixels, not this canvas.
@@ -14,15 +18,49 @@
export let maskW = 0;
export let maskH = 0;
/** #99 tape-line mode: fixed middle line + movable photo. */
export let mode: 'bbox' | 'tape' = 'bbox';
export let tape: TapeTransform | null = null;
export let cropFill: number = getCropFill();
export let onTapeChange: ((t: TapeTransform) => void) | null = null;
/** When false, tape overlay is display-only (Measure step). */
export let interactive = true;
/**
* Exclusive stage gestures so pinch/drag don't fight.
* zoompan (default): pinch zoom + one-finger pan; ignore rotate gestures.
* rotate: two-finger twist only; ignore pan/zoom from those gestures.
* Tilt/zoom sliders outside the stage stay available either way.
*/
export let gestureMode: 'zoompan' | 'rotate' = 'zoompan';
let canvas: HTMLCanvasElement | undefined;
let stage: HTMLDivElement | undefined;
let imgW = 0, imgH = 0; // source px (oriented), learned once per blob
let tapeWrap: HTMLDivElement | undefined;
let imgW = 0, imgH = 0;
let parentW = 0;
let decodeGen = 0;
let decoding = false;
let focusTick: 'A' | 'B' = 'A';
// Local editable copy while dragging; flushed via onTapeChange.
let live: TapeTransform = tape ?? {
tiltDeg: 0, zoom: 1, panX: 0, panY: 0, ...defaultTicks(cropFill),
};
$: if (tape) live = { ...tape };
const emit = (next: TapeTransform) => {
live = next;
onTapeChange?.(next);
};
const applyFit = () => {
if (!canvas || !imgW) return;
if (mode === 'tape') {
// Photo is sized to the logical stage; CSS transform handles pan/zoom/tilt.
canvas.style.width = '100%';
canvas.style.height = '100%';
return;
}
if (parentW <= 0 && stage) parentW = stage.clientWidth;
const fit = fitDisplay(parentW, imgW, imgH, maxH);
if (!fit) return;
@@ -31,7 +69,7 @@
};
const paintOverlays = (ctx: CanvasRenderingContext2D) => {
if (!canvas) return;
if (!canvas || mode === 'tape') return;
if (maskBitset && maskW > 0 && maskH > 0) {
const off = document.createElement('canvas');
off.width = maskW;
@@ -62,7 +100,6 @@
for (let i = 1; i < 4; i++) ctx.lineTo(c[i][0], c[i][1]);
ctx.closePath();
ctx.stroke();
// The axis reads corners[3] → corners[0], the long side.
const a = c[3];
const b = c[0];
ctx.strokeStyle = '#ff3d71';
@@ -106,19 +143,185 @@
}
};
$: if (blob || bbox || maskBitset) void tick().then(redraw);
$: if (blob || bbox || maskBitset || mode) void tick().then(redraw);
// ---- tape pointer interactions ----
type DragKind = null | 'pan' | 'tickA' | 'tickB';
let drag: { kind: DragKind; x0: number; y0: number; panX0: number; panY0: number; tilt0: number } | null = null;
let pointers = new Map<number, { x: number; y: number }>();
let pinchStartDist = 0;
let pinchStartZoom = 1;
let pinchStartAngle = 0;
let pinchStartTilt = 0;
const PAN_DAMP = 0.4; // Keep pointer pan movement deliberate without changing keyboard nudges.
const ROTATE_DAMP = 0.4; // Soften two-finger and rotation-handle tilt changes.
const PINCH_ZOOM_DAMP = 0.4; // Blend pinch distance changes toward 1 to avoid zoom jumps.
const stagePoint = (clientX: number, clientY: number): { x: number; y: number } => {
const el = tapeWrap;
if (!el) return { x: 0, y: 0 };
const r = el.getBoundingClientRect();
return {
x: ((clientX - r.left) / Math.max(r.width, 1)) * STAGE_W,
y: ((clientY - r.top) / Math.max(r.height, 1)) * STAGE_H,
};
};
const hitTick = (x: number, y: number): 'A' | 'B' | null => {
// ≥44×44 CSS px hit → ~34 stage units on a ~352px-wide stage (wireframe HIT_R).
const HIT = 34;
const lx = lineX();
if (Math.hypot(x - lx, y - live.tickA) <= HIT) return 'A';
if (Math.hypot(x - lx, y - live.tickB) <= HIT) return 'B';
return null;
};
const onPointerDown = (e: PointerEvent) => {
if (!interactive || mode !== 'tape') return;
(e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId);
pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
if (pointers.size === 2) {
const pts = [...pointers.values()];
const dx = pts[1].x - pts[0].x;
const dy = pts[1].y - pts[0].y;
pinchStartDist = Math.hypot(dx, dy) || 1;
pinchStartZoom = live.zoom;
pinchStartAngle = Math.atan2(dy, dx);
pinchStartTilt = live.tiltDeg;
drag = null;
return;
}
const p = stagePoint(e.clientX, e.clientY);
const tick = hitTick(p.x, p.y);
if (tick) {
focusTick = tick;
drag = { kind: tick === 'A' ? 'tickA' : 'tickB', x0: p.x, y0: p.y, panX0: live.panX, panY0: live.panY, tilt0: live.tiltDeg };
return;
}
// One-finger pan only in Zoom/Pan mode (Rotate mode uses slider + two-finger twist).
if (gestureMode === 'zoompan') {
drag = { kind: 'pan', x0: p.x, y0: p.y, panX0: live.panX, panY0: live.panY, tilt0: live.tiltDeg };
}
};
const onPointerMove = (e: PointerEvent) => {
if (!interactive || mode !== 'tape') return;
if (pointers.has(e.pointerId)) pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
if (pointers.size === 2) {
const pts = [...pointers.values()];
const dx = pts[1].x - pts[0].x;
const dy = pts[1].y - pts[0].y;
if (gestureMode === 'zoompan') {
const dist = Math.hypot(dx, dy) || 1;
const ratio = dist / pinchStartDist;
const softenedRatio = 1 + (ratio - 1) * PINCH_ZOOM_DAMP;
const z = Math.min(3, Math.max(0.5, pinchStartZoom * softenedRatio));
emit({ ...live, zoom: z });
} else {
// Screen y-down atan2 delta matches CSS rotate (positive = clockwise).
const angle = Math.atan2(dy, dx);
const deltaDeg = ((angle - pinchStartAngle) * 180) / Math.PI;
emit({ ...live, tiltDeg: clampTiltDeg(pinchStartTilt + deltaDeg * ROTATE_DAMP) });
}
return;
}
if (!drag) return;
const p = stagePoint(e.clientX, e.clientY);
if (drag.kind === 'pan') {
if (gestureMode !== 'zoompan') return;
emit({
...live,
panX: drag.panX0 + (p.x - drag.x0) * PAN_DAMP,
panY: drag.panY0 + (p.y - drag.y0) * PAN_DAMP,
});
} else if (drag.kind === 'tickA' || drag.kind === 'tickB') {
const pair = drag.kind === 'tickA'
? clampTickPair(p.y, live.tickB)
: clampTickPair(live.tickA, p.y);
emit({ ...live, ...pair });
}
};
const onPointerUp = (e: PointerEvent) => {
pointers.delete(e.pointerId);
if (pointers.size < 2) drag = null;
};
const onWheel = (e: WheelEvent) => {
if (!interactive || mode !== 'tape' || gestureMode !== 'zoompan') return;
e.preventDefault();
const factor = e.deltaY > 0 ? 0.92 : 1.08;
emit({ ...live, zoom: Math.min(3, Math.max(0.5, live.zoom * factor)) });
};
const onKeyDown = (e: KeyboardEvent) => {
if (!interactive || mode !== 'tape') return;
const fine = e.shiftKey;
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
if (gestureMode !== 'rotate') return;
e.preventDefault();
const step = fine ? 0.1 : 1;
const dir = e.key === 'ArrowLeft' ? -1 : 1;
emit({ ...live, tiltDeg: clampTiltDeg(live.tiltDeg + dir * step) });
} else if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
e.preventDefault();
const step = fine ? 0.1 : 1;
const dir = e.key === 'ArrowUp' ? -1 : 1;
if (focusTick === 'A') {
emit({ ...live, ...clampTickPair(live.tickA + dir * step, live.tickB) });
} else {
emit({ ...live, ...clampTickPair(live.tickA, live.tickB + dir * step) });
}
} else if (e.key === '+' || e.key === '=') {
if (gestureMode !== 'zoompan') return;
e.preventDefault();
emit({ ...live, zoom: Math.min(3, live.zoom * 1.1) });
} else if (e.key === '-' || e.key === '_') {
if (gestureMode !== 'zoompan') return;
e.preventDefault();
emit({ ...live, zoom: Math.max(0.5, live.zoom / 1.1) });
}
};
const fitPhoto = () => emit({ ...live, zoom: 1, panX: 0, panY: 0 });
let vbScale = 1;
const updateVbScale = () => {
if (!tapeWrap) return;
vbScale = tapeWrap.clientWidth / STAGE_W || 1;
};
$: inset = STAGE_H * cropMarginFraction(cropFill);
$: photoTransform = `translate(${live.panX}px, ${live.panY}px) translate(${STAGE_W / 2}px, ${STAGE_H / 2}px) rotate(${live.tiltDeg}deg) scale(${live.zoom}) translate(${-STAGE_W / 2}px, ${-STAGE_H / 2}px)`;
// Contain-fit the canvas bitmap into the logical stage.
$: photoLayerStyle = imgW > 0 && imgH > 0
? (() => {
const scale0 = Math.min(STAGE_W / imgW, STAGE_H / imgH);
const w = imgW * scale0;
const h = imgH * scale0;
const left = (STAGE_W - w) / 2;
const top = (STAGE_H - h) / 2;
return `position:absolute;left:${left}px;top:${top}px;width:${w}px;height:${h}px;transform-origin:center center;`;
})()
: 'position:absolute;inset:0;';
onMount(() => {
if (stage) parentW = stage.clientWidth;
updateVbScale();
const ro = new ResizeObserver((entries) => {
const e = entries[0];
if (!e) return;
const w = e.contentRect.width;
if (mode === 'tape') {
updateVbScale();
}
if (w === parentW) return;
parentW = w;
applyFit();
});
if (stage) ro.observe(stage);
if (tapeWrap) ro.observe(tapeWrap);
return () => ro.disconnect();
});
onDestroy(() => {
@@ -126,16 +329,85 @@
});
</script>
<div class="stage" bind:this={stage}>
{#if imgW > 0 || decoding || blob}
<canvas bind:this={canvas}></canvas>
{:else}
<div class="loading">Decoding photo…</div>
{/if}
</div>
{#if mode === 'tape'}
<div
class="tape-stage"
bind:this={stage}
tabindex="0"
role="application"
aria-label="Fixed tape line over a movable slab photo"
on:keydown={onKeyDown}
>
<div
class="tw"
class:dragging={!!drag}
class:mode-rotate={gestureMode === 'rotate'}
bind:this={tapeWrap}
role="group"
aria-label="Tape alignment stage"
on:pointerdown={onPointerDown}
on:pointermove={onPointerMove}
on:pointerup={onPointerUp}
on:pointercancel={onPointerUp}
on:wheel={onWheel}
on:dblclick={() => interactive && fitPhoto()}
>
<div class="vb" style="width:{STAGE_W}px;height:{STAGE_H}px;transform:scale({vbScale});">
<div class="photo-layer" style="{photoLayerStyle} transform: {photoTransform};">
<canvas bind:this={canvas}></canvas>
</div>
<!-- crop end bands (proposal visual; aspect stays 3:4) -->
<div class="band top" style="height:{inset}px"></div>
<div class="band bot" style="height:{inset}px"></div>
<svg class="fixture" viewBox="0 0 {STAGE_W} {STAGE_H}" width={STAGE_W} height={STAGE_H} aria-hidden="true">
<line x1={lineX()} y1={live.tickA} x2={lineX()} y2={live.tickB} stroke="rgba(255,255,255,.28)" stroke-width="6" stroke-linecap="round" />
<line x1={lineX()} y1={live.tickA} x2={lineX()} y2={live.tickB} stroke="#000" stroke-width="2.4" stroke-linecap="round" />
{#each [['A', live.tickA], ['B', live.tickB]] as [id, y]}
<line x1={lineX() - 13} y1={y} x2={lineX() + 13} y2={y} stroke="rgba(255,255,255,.92)" stroke-width="6" stroke-linecap="round" />
<line x1={lineX() - 13} y1={y} x2={lineX() + 13} y2={y} stroke="#000" stroke-width="2.4" stroke-linecap="round" />
<circle cx={lineX()} cy={y} r="5" fill={focusTick === id ? '#ff3d71' : '#000'} stroke="#fff" stroke-width="1.5" />
<circle class="tick-hit" cx={lineX()} cy={y} r="34" fill="transparent" />
{/each}
</svg>
{#if decoding && imgW === 0}
<div class="loading">Decoding photo…</div>
{/if}
</div>
</div>
</div>
{:else}
<div class="stage" bind:this={stage}>
{#if imgW > 0 || decoding || blob}
<canvas bind:this={canvas}></canvas>
{:else}
<div class="loading">Decoding photo…</div>
{/if}
</div>
{/if}
<style>
.stage { width: 100%; display: flex; justify-content: center; }
canvas { display: block; border-radius: 8px; background: #222; }
.loading { padding: 40px; color: #999; }
canvas { display: block; border-radius: 8px; background: #222; width: 100%; height: 100%; }
.loading { padding: 40px; color: #999; position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; }
.tape-stage { width: 100%; outline: none; }
.tw {
position: relative; width: 100%; aspect-ratio: 3 / 4;
background: #111; border: 1px solid #333; border-radius: 8px; overflow: hidden;
touch-action: none; user-select: none; cursor: grab;
}
.tw.dragging { cursor: grabbing; }
.tw.mode-rotate { cursor: ew-resize; }
.vb {
position: absolute; top: 0; left: 0;
transform-origin: top left;
pointer-events: none;
}
.vb > :global(*) { pointer-events: auto; }
.photo-layer { will-change: transform; pointer-events: none; }
.photo-layer canvas { border-radius: 0; background: transparent; pointer-events: none; }
.band { position: absolute; left: 0; right: 0; background: #000; opacity: 0.3; pointer-events: none; z-index: 1; }
.band.top { top: 0; }
.band.bot { bottom: 0; }
.fixture { position: absolute; inset: 0; z-index: 2; pointer-events: none; }
.tick-hit { pointer-events: auto; cursor: ns-resize; }
</style>
+28 -7
View File
@@ -33,7 +33,7 @@ export interface ABox {
// Minimum-area bounding rectangle of the mask, source px.
export interface BBox {
corners: [number, number][]; // 4 corners; corners[3] → corners[0] is the long side
longSidePx: number; // geometric extent only; scale uses mask span along the axis
longSidePx: number; // geometric extent only; scale prefers tape tick span (#99)
angleDeg: number; // long-side angle, degrees, [0, 180)
}
@@ -43,13 +43,26 @@ export interface Mask {
slabPx: number; // 0 ⇒ approve blocked, user retakes
bitset: Uint8Array; // source-grid mask, 1 byte/px
bbox: BBox;
slabBounds: ABox; // axis-aligned extent of slab px, for the 80%-fill PNG crop
slabBounds: ABox; // axis-aligned extent of slab px, for the crop_fill PNG crop (default 90%)
}
// Tape-line calibration (#99): photo tilt/pan/zoom under a fixed middle line;
// ticks declare L. bbox is derived for overlay/width sampling direction.
export interface TapeLine {
tiltDeg: number;
zoom: number;
panX: number;
panY: number;
/** Tick Y in logical stage units (STAGE_H = 640). */
tickA: number;
tickB: number;
/** Image-space distance between ticks (px); drives scale = spanPx / L. */
spanPx: number;
}
// Direction only: an oriented rectangle over the slab. The arrow reads
// corners[3] → corners[0] (the long side).
export interface Axis {
bbox: BBox;
tape: TapeLine;
}
export interface Widths {
@@ -69,14 +82,17 @@ export interface Measures {
inventoryPng: Blob;
/** Dimension-overlay copy of the calibration inventory PNG (#63). */
overlayPng?: Blob;
sourceBelowFloor: boolean;
/** Shorter side of slab bbox < 300 px — hard fail / exclude clean cal. */
sourceBelowBlock: boolean;
/** Shorter side in 300–799 px — encode/include with warn. */
sourceBelowWarn: boolean;
}
// Result of applying the calibration mask to one batch sibling. The
// calibration slot is null because its inventory PNG lives in Measures.
export type BatchPhotoResult =
| null
| { status: 'ok'; inventoryPng: Blob }
| { status: 'ok'; inventoryPng: Blob; /** Soft size-floor concern; photo still included. */ warn?: string }
| { status: 'error'; message: string };
// The machine. 'resume' exists only while a draft exists and has not been chosen.
@@ -102,9 +118,11 @@ export type Capture =
k: 'measure';
source: Source;
approvedMask: Mask;
axis: Axis; // frozen
axis: Axis; // editable tape until Calculate; frozen after results
lengthEighths: Eighths;
thicknessEighths: Eighths;
/** Woo species leaf id; set on Measure. Persisted on draft + createSlab; Review does not edit. */
speciesId: number | null;
results: Measures | null;
/** Capture-order sibling results from the post-Calculate batch pass. */
batchApply?: BatchPhotoResult[];
@@ -138,8 +156,11 @@ export interface Draft {
maskParams: MaskParams; // → re-derive mask on resume (ignored while stage==='batch')
mask: Omit<Mask, 'bitset' | 'params'>; // slabPx, bbox, slabBounds
axisBbox?: BBox | null; // present when stage >= axis; absent on legacy drafts
tape?: TapeLine | null; // #99 tape-line transform; absent on legacy drafts
lengthEighths: Eighths;
thicknessEighths: Eighths;
/** Woo species leaf; absent on legacy drafts. */
species_id?: number | null;
results: Omit<Measures, 'inventoryPng' | 'overlayPng'> | null; // present when results computed
inventoryPng: Blob | null; // stored so Continue can show Measure results
/** Dimension-overlay calibration PNG; kept out of batchApply[cal] (#63). */
@@ -40,3 +40,22 @@ test('typed inches snap to nearest 1/8 via inToEighths', () => {
assert.equal(inToEighths(0.07), 1);
assert.equal(inToEighths(1.999), 16);
});
test('MeasurePanel exposes Species select (Woo taxonomy leaves)', () => {
assert.match(panelSrc, /data-testid="measure-species"/);
assert.match(panelSrc, /speciesLeavesFromTaxonomy|getTaxonomy/);
assert.match(panelSrc, /aria-label="Species"/);
});
test('MeasurePanel keeps interactive tape controls (merged Axis)', () => {
assert.match(panelSrc, /mode="tape"/);
assert.match(panelSrc, /onTapeChange/);
assert.ok(!/Approve tape line/.test(panelSrc), 'Approve tape folded into Calculate');
assert.match(panelSrc, /Calculate/);
});
test('Calculate requires Species (carries to createSlab / Create title)', () => {
assert.match(panelSrc, /speciesSel !== ''/);
assert.match(panelSrc, /speciesOptsReady/);
assert.match(panelSrc, /photos-ready-species/);
});
+25 -4
View File
@@ -92,7 +92,7 @@ test('test(photos): batchApply errors become clear error slots (empty mask / flo
null,
{ status: 'error', message: 'empty mask: no slab pixels detected' },
{ status: 'ok', inventoryPng: ok },
{ status: 'error', message: 'PNG size floor: shorter side is below 800 px' },
{ status: 'error', message: 'PNG size floor: shorter side is below 300 px' },
];
const lineup = buildPhotosReadyLineup({
@@ -118,7 +118,7 @@ test('test(photos): batchApply errors become clear error slots (empty mask / flo
const err3 = lineup[4];
assert.equal(err3.kind, 'error');
if (err3.kind === 'error') {
assert.match(err3.message, /floor|800/i);
assert.match(err3.message, /floor|300/i);
}
// Successful sibling still appears after cal+overlay, in capture order among siblings
@@ -151,16 +151,37 @@ test('test(photos): lineup never uses source JPEG thumbs as inventory PNGs (#64)
}
});
test('test(photos): skip clean cal when sourceBelowFloor; keep overlay + siblings (#64)', () => {
test('test(photos): skip clean cal when sourceBelowBlock; keep overlay + siblings (#64)', () => {
const lineup = buildPhotosReadyLineup({
inventoryPng: png('below'),
overlayPng: png('overlay'),
batchApply: [null, { status: 'ok', inventoryPng: png('sib') }],
sourceBelowFloor: true,
sourceBelowBlock: true,
});
assert.deepEqual(roles(lineup), ['png:overlay', 'png:batch']);
});
test('test(photos): warn band keeps clean cal + attaches warn; siblings with warn stay png (#64)', () => {
const lineup = buildPhotosReadyLineup({
inventoryPng: png('warn-cal'),
overlayPng: png('overlay'),
batchApply: [
null,
{ status: 'ok', inventoryPng: png('sib'), warn: 'PNG size warn: shorter side is below 800 px (300–799); photo included' },
],
sourceBelowWarn: true,
});
assert.deepEqual(roles(lineup), ['png:calibration', 'png:overlay', 'png:batch']);
assert.equal(lineup[0].kind, 'png');
if (lineup[0].kind === 'png') {
assert.match(lineup[0].warn ?? '', /warn|800|300/i);
}
assert.equal(lineup[2].kind, 'png');
if (lineup[2].kind === 'png') {
assert.match(lineup[2].warn ?? '', /warn|800/i);
}
});
test('test(photos): empty/missing results yield empty lineup (#64)', () => {
assert.deepEqual(buildPhotosReadyLineup({}), []);
assert.deepEqual(
+30 -7
View File
@@ -8,6 +8,8 @@ export type PhotosReadyLineupItem =
blob: Blob;
/** Capture-order index for non-calibration batch inventory PNGs. */
batchIndex?: number;
/** Soft size-floor warn (300–799); photo still shown. */
warn?: string;
}
| {
kind: 'error';
@@ -19,25 +21,45 @@ export interface PhotosReadyLineupInput {
inventoryPng?: Blob | null;
overlayPng?: Blob | null;
batchApply?: BatchPhotoResult[] | null;
/** When true, omit the clean calibration PNG (M1 floor warn path). */
sourceBelowFloor?: boolean;
/** When true, omit the clean calibration PNG (hard block < 300 px). */
sourceBelowBlock?: boolean;
/** Soft warn for calibration (300–799); clean cal still included. */
sourceBelowWarn?: boolean;
/** Calibration warn message when sourceBelowWarn. */
calibrationWarn?: string;
}
/**
* Build the Photos-ready lineup:
* 1. Clean calibration inventory PNG
* 1. Clean calibration inventory PNG (skipped only on hard block)
* 2. Dimension-overlay copy
* 3. Other batch inventory PNGs in capture order (batchApply, skip cal null)
* with error slots for empty mask / floor failures.
* with error slots for empty mask / block-floor failures; warn stays on png.
*/
export function buildPhotosReadyLineup(
input: PhotosReadyLineupInput,
): PhotosReadyLineupItem[] {
const items: PhotosReadyLineupItem[] = [];
const { inventoryPng, overlayPng, batchApply, sourceBelowFloor } = input;
const {
inventoryPng,
overlayPng,
batchApply,
sourceBelowBlock,
sourceBelowWarn,
calibrationWarn,
} = input;
if (inventoryPng && !sourceBelowFloor) {
items.push({ kind: 'png', role: 'calibration', blob: inventoryPng });
if (inventoryPng && !sourceBelowBlock) {
const warn = sourceBelowWarn
? (calibrationWarn
?? 'PNG size warn: shorter side is below 800 px (300–799); photo included')
: undefined;
items.push({
kind: 'png',
role: 'calibration',
blob: inventoryPng,
...(warn ? { warn } : {}),
});
}
if (overlayPng) {
items.push({ kind: 'png', role: 'overlay', blob: overlayPng });
@@ -53,6 +75,7 @@ export function buildPhotosReadyLineup(
role: 'batch',
blob: slot.inventoryPng,
batchIndex: i,
...(slot.warn ? { warn: slot.warn } : {}),
});
} else {
items.push({
+61 -15
View File
@@ -211,7 +211,8 @@ test('#62: Calculate applies frozen mask params to every non-calibration batch p
// Distinctive PNG blob so tests cannot confuse with source JPEG thumbs
return {
blob: new Blob([`png-${input.source.widthPx}x${input.source.heightPx}`], { type: 'image/png' }),
sourceBelowFloor: false,
sourceBelowBlock: false,
sourceBelowWarn: false,
};
},
} as Parameters<typeof createCaptureSession>[0]);
@@ -269,7 +270,8 @@ test('#62: empty mask on one sibling → per-photo error; others still proceed',
draft: store,
encodeInventory: async () => ({
blob: new Blob(['ok-png'], { type: 'image/png' }),
sourceBelowFloor: false,
sourceBelowBlock: false,
sourceBelowWarn: false,
}),
} as Parameters<typeof createCaptureSession>[0]);
@@ -287,15 +289,15 @@ test('#62: empty mask on one sibling → per-photo error; others still proceed',
s.dispose();
});
test('#62: below PNG size floor on one sibling → per-photo error; others proceed', async () => {
test('#62: below block floor (<300) on one sibling → per-photo error; others proceed', async () => {
installFakes();
const a = tinyJpeg('a'); // ok
const b = tinyJpeg('b'); // cal
const c = tinyJpeg('c'); // below floor
const c = tinyJpeg('c'); // below block floor
FakeMaskWorker.behaviorFor = (blob) => {
if (blob === c) {
return { w: 400, h: 300, slabPx: 400 * 300, slabBounds: { x: 0, y: 0, w: 400, h: 300 } };
return { w: 250, h: 200, slabPx: 250 * 200, slabBounds: { x: 0, y: 0, w: 250, h: 200 } };
}
return { w: 1000, h: 900, slabPx: 1000 * 900, slabBounds: { x: 0, y: 0, w: 1000, h: 900 } };
};
@@ -303,10 +305,11 @@ test('#62: below PNG size floor on one sibling → per-photo error; others proce
const s = createCaptureSession({
draft: store,
encodeInventory: async (input) => {
const below = Math.min(input.mask.slabBounds.w, input.mask.slabBounds.h) < 800;
const short = Math.min(input.mask.slabBounds.w, input.mask.slabBounds.h);
return {
blob: new Blob([below ? 'tiny' : 'ok'], { type: 'image/png' }),
sourceBelowFloor: below,
blob: new Blob([short < 300 ? 'tiny' : 'ok'], { type: 'image/png' }),
sourceBelowBlock: short < 300,
sourceBelowWarn: short >= 300 && short < 800,
};
},
} as Parameters<typeof createCaptureSession>[0]);
@@ -317,9 +320,47 @@ test('#62: below PNG size floor on one sibling → per-photo error; others proce
const apply = batchApplyOf(get(s.capture));
assert.ok(apply);
assert.ok(apply![0] && apply![0].status === 'ok', 'above-floor sibling ok');
assert.ok(apply![2] && apply![2].status === 'error', 'below-floor → error');
assert.ok(apply![2] && apply![2].status === 'error', 'below-block → error');
if (apply![2]?.status === 'error') {
assert.match(apply![2].message, /floor|small|800|size/i, 'clear per-photo size-floor error');
assert.match(apply![2].message, /floor|300|size/i, 'clear per-photo size-floor error');
}
s.dispose();
});
test('#62: warn band (300–799) on sibling → ok with warn; photo included', async () => {
installFakes();
const a = tinyJpeg('a'); // ok
const b = tinyJpeg('b'); // cal
const c = tinyJpeg('c'); // warn band
FakeMaskWorker.behaviorFor = (blob) => {
if (blob === c) {
return { w: 500, h: 400, slabPx: 500 * 400, slabBounds: { x: 0, y: 0, w: 500, h: 400 } };
}
return { w: 1000, h: 900, slabPx: 1000 * 900, slabBounds: { x: 0, y: 0, w: 1000, h: 900 } };
};
const s = createCaptureSession({
draft: store,
encodeInventory: async (input) => {
const short = Math.min(input.mask.slabBounds.w, input.mask.slabBounds.h);
return {
blob: new Blob([short < 800 ? 'warn' : 'ok'], { type: 'image/png' }),
sourceBelowBlock: short < 300,
sourceBelowWarn: short >= 300 && short < 800,
};
},
} as Parameters<typeof createCaptureSession>[0]);
await driveToMeasure(s, [a, b, c], 1, { sheet: 'auto' });
await s.calculate();
const apply = batchApplyOf(get(s.capture));
assert.ok(apply);
assert.ok(apply![0] && apply![0].status === 'ok', 'above-floor sibling ok');
assert.ok(apply![2] && apply![2].status === 'ok', 'warn-band → still ok');
if (apply![2]?.status === 'ok') {
assert.match(apply![2].warn ?? '', /warn|800|300/i, 'warn message attached');
}
s.dispose();
});
@@ -330,7 +371,8 @@ test('#62: scale/widths/sqft/bdft stay on calibration results only (siblings are
draft: store,
encodeInventory: async () => ({
blob: new Blob(['png'], { type: 'image/png' }),
sourceBelowFloor: false,
sourceBelowBlock: false,
sourceBelowWarn: false,
}),
} as Parameters<typeof createCaptureSession>[0]);
@@ -373,7 +415,8 @@ test('#62: draft persists batchApply after Calculate (for #64 UI later)', async
draft: store,
encodeInventory: async () => ({
blob: new Blob(['draft-png'], { type: 'image/png' }),
sourceBelowFloor: false,
sourceBelowBlock: false,
sourceBelowWarn: false,
}),
} as Parameters<typeof createCaptureSession>[0]);
@@ -401,7 +444,8 @@ test('#62: single-photo batch Calculate leaves batchApply=[null] (no sibling Mas
draft: store,
encodeInventory: async () => ({
blob: new Blob(['only'], { type: 'image/png' }),
sourceBelowFloor: false,
sourceBelowBlock: false,
sourceBelowWarn: false,
}),
} as Parameters<typeof createCaptureSession>[0]);
@@ -426,7 +470,8 @@ test('#62: Calculate does not re-enter Mask / does not open Mask controls for si
draft: store,
encodeInventory: async () => ({
blob: new Blob(['x'], { type: 'image/png' }),
sourceBelowFloor: false,
sourceBelowBlock: false,
sourceBelowWarn: false,
}),
} as Parameters<typeof createCaptureSession>[0]);
@@ -463,7 +508,8 @@ test('#62: resume restores batchApply alongside calibration results', async () =
draft: store,
encodeInventory: async () => ({
blob: new Blob(['resume-png'], { type: 'image/png' }),
sourceBelowFloor: false,
sourceBelowBlock: false,
sourceBelowWarn: false,
}),
} as Parameters<typeof createCaptureSession>[0]);
+8 -12
View File
@@ -343,16 +343,11 @@ test('#61: after pick of index N≠0, Mask/Axis/Measure operate on that blob onl
s.approveMask();
cap = get(s.capture);
assert.equal(cap.k, 'axis', 'approve Mask → Length axis');
if (cap.k === 'axis') {
assert.equal(cap.source.blob, b, 'Axis must keep the calibration source');
}
s.approveAxis();
cap = get(s.capture);
assert.equal(cap.k, 'measure', 'approve Axis → Measure');
assert.equal(cap.k, 'measure', 'approve Mask → combined Measure (tape+fields)');
if (cap.k === 'measure') {
assert.equal(cap.source.blob, b, 'Measure must keep the calibration source');
assert.equal(cap.results, null);
assert.equal(cap.speciesId, null);
}
s.dispose();
@@ -447,15 +442,16 @@ test('#61: other batch photos stay in draft.batch untouched by mask approve', as
const after = store.peek();
assert.ok(after);
assert.equal(after!.stage, 'axis');
assert.equal(after!.stage, 'measure', 'mask approve lands on combined tape+measure');
assert.equal(after!.calibrationIndex, 1);
assert.equal(after!.source.blob, b, 'draft.source is calibration only');
assert.equal((after!.batch ?? []).length, 3, 'full batch retained');
assert.equal(after!.batch![0].blob, a, 'batch[0] untouched');
assert.equal(after!.batch![1].blob, b);
assert.equal(after!.batch![2].blob, c, 'batch[2] untouched — no Mask UI on other shots');
assert.equal(after!.species_id ?? null, null);
// Approve axis too — batch still untouched.
// approveAxis is a no-op on measure — batch still untouched.
s.approveAxis();
await new Promise((r) => setTimeout(r, 20));
const meas = store.peek();
@@ -520,10 +516,10 @@ test('#61: draft resume through measure restores calibration source (not photos[
s2.dispose();
});
test('#61: App runs single Mask/Axis/Measure panels (no per-batch Mask loop)', () => {
test('#61: App runs single Mask/Measure panels (no per-batch Mask loop)', () => {
const app = readFileSync(join(here, '..', 'App.svelte'), 'utf8');
assert.match(app, /MaskPanel/);
assert.match(app, /AxisPanel/);
assert.ok(!/AxisPanel/.test(app), 'AxisPanel folded into MeasurePanel');
assert.match(app, /MeasurePanel/);
// Dishonest proxy: must not iterate batch photos into Mask UI.
assert.ok(
+6 -3
View File
@@ -185,7 +185,8 @@ test('test(photos): Calculate stores overlayPng on results, clean inventory unto
encodeN++;
return {
blob: new Blob([`clean-${encodeN}`], { type: 'image/png' }),
sourceBelowFloor: false,
sourceBelowBlock: false,
sourceBelowWarn: false,
};
},
} as Parameters<typeof createCaptureSession>[0]);
@@ -221,7 +222,8 @@ test('test(photos): single-photo batch still yields clean + overlayPng (#63)', a
draft: store,
encodeInventory: async () => ({
blob: new Blob(['only-clean'], { type: 'image/png' }),
sourceBelowFloor: false,
sourceBelowBlock: false,
sourceBelowWarn: false,
}),
} as Parameters<typeof createCaptureSession>[0]);
@@ -243,7 +245,8 @@ test('test(photos): draft persists overlayPng alongside inventoryPng (#63)', asy
draft: store,
encodeInventory: async () => ({
blob: new Blob(['draft-clean'], { type: 'image/png' }),
sourceBelowFloor: false,
sourceBelowBlock: false,
sourceBelowWarn: false,
}),
} as Parameters<typeof createCaptureSession>[0]);
+198 -31
View File
@@ -10,7 +10,11 @@ import { measure } from '../measure/measure';
import { orientedRect } from '../geometry/rect';
import { encodeInventoryPng, type InventoryInput } from '../png/inventory';
import { encodeOverlayPng, type EncodeOverlayInput } from '../png/overlay';
import { bakeTiltTransform, rotateCalibration } from '../png/rotateCal';
import { decodeOrientedBitmap } from '../decodeOrientedBitmap';
import { clampTiltDeg, defaultTape, tapeEndpoints, type TapeTransform } from './tapeGeometry';
import { getCropFill } from '../settings/cropFill';
import type { TapeLine } from './capture.types';
const DEBOUNCE_MS = 2000;
const BUSY_MS = 300;
@@ -41,9 +45,12 @@ export interface CaptureSession {
setSheet(s: Sheet): void;
approveMask(): void;
setAxisAngle(deg: number): void;
/** #99: replace axis rectangle nudge with full tape transform. */
setTape(tape: TapeTransform): void;
approveAxis(): void;
setLength(e: Eighths): void;
setThickness(e: Eighths): void;
setSpecies(id: number | null): void;
calculate(): Promise<void>;
back(): void;
getInventoryUrl(): string | null;
@@ -55,7 +62,7 @@ export interface CaptureSession {
export interface SessionOpts {
draft?: DraftStore;
/** Injectable only for deterministic tests; production uses encodeInventoryPng. */
encodeInventory?: (input: InventoryInput) => Promise<{ blob: Blob; sourceBelowFloor: boolean }>;
encodeInventory?: (input: InventoryInput) => Promise<{ blob: Blob; sourceBelowBlock: boolean; sourceBelowWarn: boolean }>;
/** Injectable only for deterministic tests; production uses encodeOverlayPng. */
encodeOverlay?: (input: EncodeOverlayInput) => Promise<Blob>;
}
@@ -206,7 +213,7 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
version: 1, stage: 'axis', batch: batchPhotos.length ? batchPhotos : [state.source],
calibrationIndex: calIdx,
source: state.source, maskParams: state.approvedMask.params,
mask: strip(state.approvedMask), axisBbox: state.axis.bbox,
mask: strip(state.approvedMask), axisBbox: state.axis.bbox, tape: state.axis.tape,
lengthEighths: 0, thicknessEighths: 0, results: null, inventoryPng: null,
};
}
@@ -216,8 +223,9 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
version: 1, stage: 'measure', batch: batchPhotos.length ? batchPhotos : [state.source],
calibrationIndex: calIdx,
source: state.source, maskParams: state.approvedMask.params,
mask: strip(state.approvedMask), axisBbox: state.axis.bbox,
mask: strip(state.approvedMask), axisBbox: state.axis.bbox, tape: state.axis.tape,
lengthEighths: state.lengthEighths, thicknessEighths: state.thicknessEighths,
species_id: state.speciesId,
results: state.results ? rest : null,
inventoryPng: state.results?.inventoryPng ?? null,
overlayPng: state.results?.overlayPng ?? null,
@@ -312,6 +320,37 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
// fall back to the mask's own bbox.
const draftAxisBbox = (d: Draft) => d.axisBbox ?? d.mask.bbox;
const tapeFromDraftOrBbox = (d: Draft, bbox: import('./capture.types').BBox, w: number, h: number): TapeLine => {
if (d.tape && Number.isFinite(d.tape.spanPx)) {
return { ...d.tape };
}
// Seed tilt so the mask long side sits near vertical under the fixed line.
const seedTilt = clampTiltDeg(90 - bbox.angleDeg);
const t = defaultTape(getCropFill(), seedTilt);
const ep = w > 0 && h > 0 ? tapeEndpoints(t, w, h) : { spanPx: 0, start: [0, 0] as [number, number], end: [0, 0] as [number, number], ux: 0, uy: 1 };
return { ...t, spanPx: ep.spanPx };
};
const axisWithTape = (bbox: import('./capture.types').BBox, tape: TapeTransform, w: number, h: number) => {
const ep = w > 0 && h > 0
? tapeEndpoints(tape, w, h)
: { spanPx: 0, start: [0, 0] as [number, number], end: [0, 0] as [number, number], ux: 0, uy: 1 };
const angleDeg = ep.spanPx > 0
? ((Math.atan2(ep.uy, ep.ux) * 180) / Math.PI + 180) % 180
: bbox.angleDeg;
const nextBbox = orientedRect(bbox, angleDeg);
const line: TapeLine = {
tiltDeg: clampTiltDeg(tape.tiltDeg),
zoom: tape.zoom,
panX: tape.panX,
panY: tape.panY,
tickA: tape.tickA,
tickB: tape.tickB,
spanPx: ep.spanPx,
};
return { bbox: nextBbox, tape: line };
};
const restoreFromDraft = async (d: Draft) => {
batchPhotos = batchPhotosOf(d);
calibrationIndex = typeof d.calibrationIndex === 'number' ? d.calibrationIndex : null;
@@ -338,26 +377,54 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
try {
const mask = await rederiveMask(source, d.maskParams, d.mask);
if (d.stage === 'axis') {
set({ k: 'axis', source, approvedMask: mask, axis: { bbox: draftAxisBbox(d) } });
// Legacy axis-only drafts land on the combined tape+measure screen.
const bbox = draftAxisBbox(d);
const w = source.widthPx;
const h = source.heightPx;
const axis = d.tape
? axisWithTape(bbox, d.tape, w, h)
: { bbox, tape: tapeFromDraftOrBbox(d, bbox, w, h) };
set({
k: 'measure', source, approvedMask: mask, axis,
lengthEighths: d.lengthEighths || 0,
thicknessEighths: d.thicknessEighths || 0,
speciesId: d.species_id ?? null,
results: null,
});
return;
}
if (d.inventoryPng) {
if (pngUrl) URL.revokeObjectURL(pngUrl);
pngUrl = URL.createObjectURL(d.inventoryPng);
}
{
const bbox = draftAxisBbox(d);
const w = source.widthPx;
const h = source.heightPx;
const axis = d.tape
? axisWithTape(bbox, d.tape, w, h)
: { bbox, tape: tapeFromDraftOrBbox(d, bbox, w, h) };
set({
k: 'measure', source, approvedMask: mask,
axis: { bbox: draftAxisBbox(d) },
axis,
lengthEighths: d.lengthEighths, thicknessEighths: d.thicknessEighths,
speciesId: d.species_id ?? null,
results: d.results
? {
...d.results,
inventoryPng: d.inventoryPng ?? new Blob(),
overlayPng: d.overlayPng ?? undefined,
// Migrate legacy sourceBelowFloor → warn; missing flags default false.
sourceBelowBlock: (d.results as { sourceBelowBlock?: boolean }).sourceBelowBlock ?? false,
sourceBelowWarn:
(d.results as { sourceBelowWarn?: boolean }).sourceBelowWarn
?? (d.results as { sourceBelowFloor?: boolean }).sourceBelowFloor
?? false,
}
: null,
batchApply: d.batchApply,
});
}
} catch {
set({ k: 'mask', source, control: { ...d.maskParams }, overlay: null, failed: true });
makeWorker(source.blob);
@@ -471,41 +538,84 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
runPass();
},
// #61: Axis uses the same calibration source; draft.batch siblings stay untouched.
// #61: combined tape+measure uses the same calibration source; draft.batch siblings stay untouched.
approveMask() {
if (state.k !== 'mask' || !state.overlay || state.overlay.slabPx === 0) return;
teardownWorker();
const overlay = state.overlay;
set({ k: 'axis', source: state.source, approvedMask: overlay, axis: { bbox: { corners: overlay.bbox.corners.map((c) => [...c] as [number, number]), longSidePx: overlay.bbox.longSidePx, angleDeg: overlay.bbox.angleDeg } } });
const bbox = {
corners: overlay.bbox.corners.map((c) => [...c] as [number, number]),
longSidePx: overlay.bbox.longSidePx,
angleDeg: overlay.bbox.angleDeg,
};
const w = state.source.widthPx;
const h = state.source.heightPx;
const seedTilt = clampTiltDeg(90 - bbox.angleDeg);
const tape0 = defaultTape(getCropFill(), seedTilt);
const axis = axisWithTape(bbox, tape0, w, h);
set({
k: 'measure', source: state.source, approvedMask: overlay, axis,
lengthEighths: 0, thicknessEighths: 0, speciesId: null, results: null,
});
void persist();
},
// Legacy slider path — maps to photo tilt under the fixed tape line (#99).
setAxisAngle(deg: number) {
if (state.k !== 'axis') return;
const a = ((Math.round(deg) % 180) + 180) % 180;
set({ ...state, axis: { bbox: orientedRect(state.axis.bbox, a) } });
void persist();
if (state.k === 'axis' || (state.k === 'measure' && !state.results)) {
const tape = { ...state.axis.tape, tiltDeg: clampTiltDeg(deg) };
const axis = axisWithTape(state.axis.bbox, tape, state.source.widthPx, state.source.heightPx);
set({ ...state, axis });
void persist();
}
},
setTape(tape: TapeTransform) {
if (state.k === 'axis' || (state.k === 'measure' && !state.results)) {
const clamped = { ...tape, tiltDeg: clampTiltDeg(tape.tiltDeg) };
const axis = axisWithTape(state.axis.bbox, clamped, state.source.widthPx, state.source.heightPx);
set({ ...state, axis });
void persist();
}
},
// Folded into Calculate on the combined screen; kept for tests / legacy axis drafts.
approveAxis() {
if (state.k === 'measure') {
void persist();
return;
}
if (state.k !== 'axis') return;
const axis = axisWithTape(
state.axis.bbox,
state.axis.tape,
state.source.widthPx,
state.source.heightPx,
);
set({
k: 'measure', source: state.source, approvedMask: state.approvedMask,
axis: { bbox: state.axis.bbox },
lengthEighths: 0, thicknessEighths: 0, results: null,
axis,
lengthEighths: 0, thicknessEighths: 0, speciesId: null, results: null,
});
void persist();
},
setLength(e: Eighths) {
if (state.k !== 'measure') return;
set({ ...state, lengthEighths: Math.max(0, Math.round(e)) });
set({ ...state, lengthEighths: Math.max(0, Math.round(e)), results: null });
void persist();
},
setThickness(e: Eighths) {
if (state.k !== 'measure') return;
set({ ...state, thicknessEighths: Math.max(0, Math.round(e)) });
set({ ...state, thicknessEighths: Math.max(0, Math.round(e)), results: null });
void persist();
},
setSpecies(id: number | null) {
if (state.k !== 'measure') return;
const speciesId = id != null && Number.isFinite(id) ? Math.round(id) : null;
set({ ...state, speciesId });
void persist();
},
@@ -516,39 +626,82 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
const { source, approvedMask, axis, lengthEighths, thicknessEighths } = state;
if (lengthEighths === 0 || thicknessEighths === 0) return;
const bitmap = await decodeOrientedBitmap(source.blob);
const tapeAxis = axis.tape.spanPx > 0
? (() => {
const ep = tapeEndpoints(axis.tape, source.widthPx, source.heightPx);
return { start: ep.start, ux: ep.ux, uy: ep.uy, spanPx: ep.spanPx };
})()
: null;
const base = measure(
approvedMask.bitset, source.widthPx, source.heightPx,
axis.bbox, lengthEighths, thicknessEighths, approvedMask.slabPx,
tapeAxis,
);
let pngBlob: Blob;
let belowFloor = false;
let belowBlock = false;
let belowWarn = false;
// #99: bake photo tilt into cal inventory + overlay only; siblings untouched.
let calBitmap = bitmap;
let calMask = approvedMask;
let calSource = source;
let rotatedExtra: ImageBitmap | null = null;
try {
const r = await encodeInventory({ bitmap, mask: approvedMask, source });
const rotated = await rotateCalibration(bitmap, approvedMask, source, axis.tape.tiltDeg);
calBitmap = rotated.bitmap;
calMask = rotated.mask;
calSource = { ...source, widthPx: rotated.source.widthPx, heightPx: rotated.source.heightPx };
if (rotated.bitmap !== bitmap) rotatedExtra = rotated.bitmap;
} catch {
/* keep unrotated cal if bake fails */
}
try {
const r = await encodeInventory({ bitmap: calBitmap, mask: calMask, source: calSource });
pngBlob = r.blob;
belowFloor = r.sourceBelowFloor;
belowBlock = r.sourceBelowBlock;
belowWarn = r.sourceBelowWarn;
} catch {
// CAPTURE.md: inventory is image/png even when encode is unavailable.
pngBlob = new Blob([], { type: 'image/png' });
}
bitmap.close();
if (rotatedExtra) rotatedExtra.close();
if (pngUrl) URL.revokeObjectURL(pngUrl);
pngUrl = URL.createObjectURL(pngBlob);
// #63: second copy of the calibration inventory PNG with dimension overlays.
// Keep clean inventoryPng untouched; never stash overlay into batchApply[cal].
// Overlay uses the same baked orientation as the clean cal PNG (#99 scope).
// Length / width directions follow the tape (not wood MAR): map tick A→B
// through the same bake as rotateCal, then orient the overlay bbox to that.
let overlayBlob: Blob;
try {
const preEp = axis.tape.spanPx > 0
? tapeEndpoints(axis.tape, source.widthPx, source.heightPx)
: null;
const bakeXform = bakeTiltTransform(source.widthPx, source.heightPx, axis.tape.tiltDeg);
let overlayBbox = calMask.bbox;
let overlayAxis: { start: [number, number]; end: [number, number] } | undefined;
if (preEp && preEp.spanPx > 0) {
const start = bakeXform.mapPoint(preEp.start[0], preEp.start[1]);
const end = bakeXform.mapPoint(preEp.end[0], preEp.end[1]);
const dx = end[0] - start[0];
const dy = end[1] - start[1];
const angleDeg = ((Math.atan2(dy, dx) * 180) / Math.PI + 180) % 180;
overlayBbox = orientedRect(calMask.bbox, angleDeg);
overlayAxis = { start, end };
}
overlayBlob = await encodeOverlay({
inventoryPng: pngBlob,
bitset: approvedMask.bitset,
widthPx: source.widthPx,
heightPx: source.heightPx,
bbox: axis.bbox,
bitset: calMask.bitset,
widthPx: calSource.widthPx,
heightPx: calSource.heightPx,
bbox: overlayBbox,
scale: base.scale,
lengthIn: eighthsToIn(lengthEighths),
lengthLabel: fmtEighths(lengthEighths),
slabBounds: approvedMask.slabBounds,
source,
slabBounds: calMask.slabBounds,
source: calSource,
axis: overlayAxis,
});
} catch {
// Deterministic tests may inject a non-decodable inventory blob; still
@@ -559,7 +712,8 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
...base,
inventoryPng: pngBlob,
overlayPng: overlayBlob,
sourceBelowFloor: belowFloor,
sourceBelowBlock: belowBlock,
sourceBelowWarn: belowWarn,
};
const params = approvedMask.params;
const calIndex = calibrationIndex != null && calibrationIndex >= 0
@@ -591,16 +745,21 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
heightPx: derived.heightPx,
};
const siblingBitmap = await decodeOrientedBitmap(photo.blob);
let encoded: { blob: Blob; sourceBelowFloor: boolean };
let encoded: { blob: Blob; sourceBelowBlock: boolean; sourceBelowWarn: boolean };
try {
encoded = await encodeInventory({ bitmap: siblingBitmap, mask: derived.mask, source: siblingSource });
} finally {
siblingBitmap.close();
}
if (encoded.sourceBelowFloor) {
throw new Error('PNG size floor: shorter side is below 800 px');
if (encoded.sourceBelowBlock) {
throw new Error('PNG size floor: shorter side is below 300 px');
}
batchApply[index] = { status: 'ok', inventoryPng: encoded.blob };
const warn = encoded.sourceBelowWarn
? 'PNG size warn: shorter side is below 800 px (300–799); photo included'
: undefined;
batchApply[index] = warn
? { status: 'ok', inventoryPng: encoded.blob, warn }
: { status: 'ok', inventoryPng: encoded.blob };
} catch (error) {
const message = error instanceof Error ? error.message : 'unable to process photo';
batchApply[index] = { status: 'error', message };
@@ -622,11 +781,19 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
set({ k: 'pickCalibration', photos: [...batchPhotos] });
void persist();
} else if (state.k === 'axis') {
// back to mask keeps the last approved mask as the live overlay
// Legacy: back to mask keeps the last approved mask as the live overlay
const m = state.approvedMask;
set({ k: 'mask', source: state.source, control: m.params, overlay: m, failed: false });
} else if (state.k === 'measure') {
set({ k: 'axis', source: state.source, approvedMask: state.approvedMask, axis: { bbox: state.axis.bbox } });
if (state.results) {
// Photos ready → re-edit tape + L/thickness/species on the same screen.
set({ ...state, results: null, batchApply: undefined });
if (pngUrl) { URL.revokeObjectURL(pngUrl); pngUrl = null; }
void persist();
} else {
const m = state.approvedMask;
set({ k: 'mask', source: state.source, control: m.params, overlay: m, failed: false });
}
}
},
+71
View File
@@ -0,0 +1,71 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
STAGE_H, STAGE_W, TILT_MAX, TILT_MIN, clampTickPair, clampTiltDeg, containFit,
defaultTape, defaultTicks, lineX, stageToImage, tapeEndpoints,
} from './tapeGeometry.ts';
import { cropMarginFraction, DEFAULT_CROP_FILL } from '../png/crop.ts';
test('defaultTicks sit at crop insets for 90% fill', () => {
const t = defaultTicks(0.9);
const inset = STAGE_H * cropMarginFraction(0.9);
assert.ok(Math.abs(t.tickA - inset) < 1e-9);
assert.ok(Math.abs(t.tickB - (STAGE_H - inset)) < 1e-9);
});
test('lineX is the middle of the 3:4 stage', () => {
assert.equal(lineX(), STAGE_W / 2);
});
test('identity tape: stage centre maps near image centre', () => {
const imgW = 480, imgH = 640;
const tape = defaultTape(DEFAULT_CROP_FILL, 0);
const [ix, iy] = stageToImage(STAGE_W / 2, STAGE_H / 2, tape, imgW, imgH);
assert.ok(Math.abs(ix - imgW / 2) < 1e-6, `ix=${ix}`);
assert.ok(Math.abs(iy - imgH / 2) < 1e-6, `iy=${iy}`);
});
test('tapeEndpoints span equals tick distance in image px at identity (matching aspect)', () => {
const imgW = 480, imgH = 640;
const tape = defaultTape(0.9, 0);
const ep = tapeEndpoints(tape, imgW, imgH);
const { scale0 } = containFit(imgW, imgH);
const expected = Math.abs(tape.tickB - tape.tickA) / scale0;
assert.ok(Math.abs(ep.spanPx - expected) < 1e-6, `span ${ep.spanPx} vs ${expected}`);
// Direction should be +y (down the stage → down the image)
assert.ok(Math.abs(ep.ux) < 1e-6, `ux=${ep.ux}`);
assert.ok(ep.uy > 0.99, `uy=${ep.uy}`);
});
test('zoom scales inverse: 2× zoom halves image-space tick span', () => {
const imgW = 480, imgH = 640;
const base = defaultTape(0.9, 0);
const zoomed = { ...base, zoom: 2 };
const a = tapeEndpoints(base, imgW, imgH).spanPx;
const b = tapeEndpoints(zoomed, imgW, imgH).spanPx;
assert.ok(Math.abs(b * 2 - a) < 1e-6, `base=${a} zoomed=${b}`);
});
test('clampTickPair enforces gap and order', () => {
const p = clampTickPair(400, 100, 40);
assert.ok(p.tickB - p.tickA >= 40);
assert.ok(p.tickA <= p.tickB);
});
test('clampTiltDeg clamps to ±90', () => {
assert.equal(TILT_MIN, -90);
assert.equal(TILT_MAX, 90);
assert.equal(clampTiltDeg(0), 0);
assert.equal(clampTiltDeg(45.5), 45.5);
assert.equal(clampTiltDeg(-45.5), -45.5);
assert.equal(clampTiltDeg(90), 90);
assert.equal(clampTiltDeg(-90), -90);
assert.equal(clampTiltDeg(91), 90);
assert.equal(clampTiltDeg(-100), -90);
assert.equal(clampTiltDeg(Number.NaN), 0);
});
test('defaultTape clamps seed tilt', () => {
assert.equal(defaultTape(0.9, 120).tiltDeg, 90);
assert.equal(defaultTape(0.9, -200).tiltDeg, -90);
});
+126
View File
@@ -0,0 +1,126 @@
/**
* Pure tape-line geometry (#99).
* Fixed vertical line in the middle of a 3:4 stage; photo pans/zooms/tilts;
* ticks slide on the line; L↔px from tick distance in image space.
*/
import { cropMarginFraction, DEFAULT_CROP_FILL } from '../png/crop';
/** Logical stage matching the wireframe / inventory aspect. */
export const STAGE_W = 480;
export const STAGE_H = 640;
/** Interactive tilt limits (degrees). Slider midpoint = 0; both ways ±90. */
export const TILT_MIN = -90;
export const TILT_MAX = 90;
export function clampTiltDeg(deg: number): number {
if (!Number.isFinite(deg)) return 0;
return Math.min(TILT_MAX, Math.max(TILT_MIN, deg));
}
export interface TapeTransform {
/** Photo tilt in degrees (CSS-style; positive = clockwise). */
tiltDeg: number;
zoom: number;
/** Pan in stage units. */
panX: number;
panY: number;
/** Tick Y positions in stage units. */
tickA: number;
tickB: number;
}
export function defaultTicks(fill: number = DEFAULT_CROP_FILL): { tickA: number; tickB: number } {
const inset = STAGE_H * cropMarginFraction(fill);
return { tickA: inset, tickB: STAGE_H - inset };
}
export function defaultTape(fill: number = DEFAULT_CROP_FILL, seedTiltDeg = 0): TapeTransform {
const t = defaultTicks(fill);
return {
tiltDeg: clampTiltDeg(seedTiltDeg),
zoom: 1,
panX: 0,
panY: 0,
tickA: t.tickA,
tickB: t.tickB,
};
}
const rad = (deg: number) => (deg * Math.PI) / 180;
/** Contain-fit of the photo into the stage at zoom=1, pan=0. */
export function containFit(imgW: number, imgH: number): { scale0: number; offX: number; offY: number } {
const scale0 = Math.min(STAGE_W / Math.max(imgW, 1), STAGE_H / Math.max(imgH, 1));
return {
scale0,
offX: (STAGE_W - imgW * scale0) / 2,
offY: (STAGE_H - imgH * scale0) / 2,
};
}
/**
* Map a stage point through the inverse photo transform into source image pixels.
* Transform order (forward): contain-fit → zoom+tilt about stage centre → pan.
*/
export function stageToImage(
sx: number,
sy: number,
tape: TapeTransform,
imgW: number,
imgH: number,
): [number, number] {
const cx = STAGE_W / 2;
const cy = STAGE_H / 2;
const { scale0, offX, offY } = containFit(imgW, imgH);
// undo pan
let x = sx - tape.panX - cx;
let y = sy - tape.panY - cy;
// undo rotate(tilt) scale(zoom) about centre
const z = tape.zoom || 1;
const a = -rad(tape.tiltDeg);
const cos = Math.cos(a);
const sin = Math.sin(a);
const xr = (x * cos - y * sin) / z;
const yr = (x * sin + y * cos) / z;
x = xr + cx;
y = yr + cy;
// undo contain fit
const ix = (x - offX) / scale0;
const iy = (y - offY) / scale0;
return [ix, iy];
}
export function lineX(): number {
return STAGE_W / 2;
}
/** Image-space endpoints of the two ticks + span in px. */
export function tapeEndpoints(
tape: TapeTransform,
imgW: number,
imgH: number,
): { start: [number, number]; end: [number, number]; spanPx: number; ux: number; uy: number } {
const start = stageToImage(lineX(), tape.tickA, tape, imgW, imgH);
const end = stageToImage(lineX(), tape.tickB, tape, imgW, imgH);
const dx = end[0] - start[0];
const dy = end[1] - start[1];
const spanPx = Math.hypot(dx, dy);
const len = spanPx || 1;
return { start, end, spanPx, ux: dx / len, uy: dy / len };
}
export function clampTickPair(a: number, b: number, gap = 40): { tickA: number; tickB: number } {
let tickA = a;
let tickB = b;
if (tickB < tickA) {
const t = tickA;
tickA = tickB;
tickB = t;
}
const minY = 0;
const maxY = STAGE_H;
tickA = Math.max(minY, Math.min(tickA, maxY - gap));
tickB = Math.min(maxY, Math.max(tickB, tickA + gap));
return { tickA, tickB };
}
+10
View File
@@ -0,0 +1,10 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { decodeName } from './decodeName';
test('decodeName: ampersand and common entities', () => {
assert.equal(decodeName('Cookies &amp; Burl Slices'), 'Cookies & Burl Slices');
assert.equal(decodeName('A &lt; B &gt; C'), 'A < B > C');
assert.equal(decodeName('say &quot;hi&quot; &#39;there&#39;'), 'say "hi" \'there\'');
assert.equal(decodeName('plain'), 'plain');
});
+9
View File
@@ -0,0 +1,9 @@
/** Decode common HTML entities Woo puts in taxonomy display names. */
export function decodeName(name: string): string {
return name
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'");
}
+4 -1
View File
@@ -11,12 +11,15 @@ import type { PhotosReadyLineupItem } from '../capture/photosReadyLineup';
export interface CalibratedCapture {
lengthIn: number;
thicknessIn: number;
/** Woo species leaf from Measure; null if unset. Review does not edit. */
speciesId: number | null;
sqft: number;
bdft: number;
widthMinIn: number;
widthMaxIn: number;
widthAvgIn: number;
sourceBelowFloor: boolean;
sourceBelowBlock: boolean;
sourceBelowWarn: boolean;
lineup: PhotosReadyLineupItem[];
}
@@ -3,7 +3,7 @@
let contentEnabled = false;
let reachable = false;
let description: string | null = null;
let call1Suggestions: Record<string, unknown> | null = null;
let shortDescription: string | null = null;
let error = '';
let busy = false;
@@ -30,10 +30,14 @@
busy = true;
error = '';
description = null;
shortDescription = null;
try {
const res = await fetch(`/api/v1/slabs/${encodeURIComponent(slabId)}/inference/call2`, { method: 'POST' });
const body = await res.json();
if (res.ok) description = body.description;
if (res.ok) {
description = body.description;
shortDescription = body.short_description ?? null;
}
else error = body.error?.message || `call2 failed (${res.status})`;
} catch {
error = 'network error';
@@ -41,25 +45,6 @@
busy = false;
}
async function classify() {
if (!slabId) {
error = 'Enter a slab id.';
return;
}
busy = true;
error = '';
call1Suggestions = null;
try {
const res = await fetch(`/api/v1/slabs/${encodeURIComponent(slabId)}/inference/call1`, { method: 'POST' });
const body = await res.json();
if (res.ok) call1Suggestions = body.suggestions;
else error = body.error?.message || `call1 failed (${res.status})`;
} catch {
error = 'network error';
}
busy = false;
}
checkReadiness();
</script>
@@ -68,6 +53,7 @@
<p class="state">
Content generation: {contentEnabled ? 'on' : 'off'} · endpoint: {reachable ? 'reachable' : 'unreachable'}
</p>
<p class="note">Call 1 (vision classification) is disabled on this branch. Call 2 description generation stays.</p>
{#if !contentEnabled || !reachable}
<p class="note">The Generate text control is disabled: it appears only when content generation is
enabled and the inference endpoint is reachable.</p>
@@ -78,13 +64,10 @@
</div>
<div class="row">
<button on:click={generate} disabled={busy}>{busy ? 'Working…' : 'Generate description (Call 2)'}</button>
<button on:click={classify} disabled={busy}>Classify metadata (Call 1)</button>
</div>
{#if error}<p class="err">{error}</p>{/if}
{#if description}<p class="desc">{description}</p>{/if}
{#if call1Suggestions}
<pre class="sug">{JSON.stringify(call1Suggestions, null, 2)}</pre>
{/if}
{#if shortDescription}<p class="desc"><strong>Short:</strong> {shortDescription}</p>{/if}
{#if description}<p class="desc"><strong>Long:</strong> {description}</p>{/if}
{/if}
<button class="mini" on:click={checkReadiness}>Recheck readiness</button>
</div>
@@ -98,6 +81,5 @@
.note { color: #e8a; }
.err { color: #e88; }
.desc { margin-top: 12px; }
.sug { background: #1c1c1c; padding: 8px; }
.mini { margin-top: 12px; font-size: 12px; }
</style>
+77 -22
View File
@@ -1,14 +1,23 @@
<script lang="ts">
import { onMount, createEventDispatcher } from 'svelte';
import { getSlab, publishSlab, ApiError, type Slab } from '../../api/slabs';
import {
getSlab, getTaxonomy, listPhotos, photoUrl, publishSlab, ApiError,
type Slab, type Taxonomy, type PhotoRow,
} from '../../api/slabs';
import PublishPreview from './PublishPreview.svelte';
import { buildPublishPreviewModel, type PublishPreviewModel } from './publishPreviewModel';
// One-column flow: Review hands to Publish by id (UX.md §1).
// Idle phase = Publish preview (glanceable confirm) before Woo draft POST.
export let slabId: string;
export let onPublished: (wooProductId: number, draft: boolean) => void;
export let onBack: () => void;
const dispatch = createEventDispatcher();
let slab: Slab | null = null;
let tax: Taxonomy | null = null;
let photos: PhotoRow[] = [];
let preview: PublishPreviewModel | null = null;
let loading = true;
let loadError = '';
@@ -60,34 +69,68 @@
function backToReview() { onBack(); }
onMount(async () => {
try { slab = await getSlab(slabId); }
catch (e) { loadError = e instanceof ApiError ? e.message : 'Could not load the slab.'; }
finally { loading = false; }
try {
const [s, t, ph] = await Promise.all([
getSlab(slabId),
getTaxonomy().catch(() => null),
listPhotos(slabId).catch(() => [] as PhotoRow[]),
]);
slab = s;
tax = t;
photos = ph;
preview = buildPublishPreviewModel(
s,
t,
ph,
(id, kind) => photoUrl(slabId, id, kind),
);
} catch (e) {
loadError = e instanceof ApiError ? e.message : 'Could not load the slab.';
} finally {
loading = false;
}
});
</script>
{#if loading}
<section class="pub" aria-busy="true" data-testid="publish-loading"><h1>Publish to WooCommerce</h1><p class="busy">Loading…</p></section>
<section class="pub" aria-busy="true" data-testid="publish-loading">
<h1>Publish preview</h1>
<p class="busy">Loading…</p>
</section>
{:else if loadError}
<section class="pub"><h1>Publish to WooCommerce</h1><p class="err" role="alert">{loadError}</p>
<section class="pub">
<h1>Publish preview</h1>
<p class="err" role="alert">{loadError}</p>
<button type="button" class="ghost" data-testid="back-review" on:click={backToReview}>Back to Review</button>
</section>
{:else}
<section class="pub" data-testid="publish-screen">
<h1>Publish to WooCommerce</h1>
<p class="meta">SKU {slab?.sku || '—'} · {slab?.bdft != null ? slab.bdft.toFixed(1) : '—'} bdft · {slab?.price != null ? `$${slab.price.toFixed(2)}` : '—'}</p>
<h1>Publish preview</h1>
<p class="lede">Confirm what’s about to publish to WooCommerce as a draft.</p>
{#if st.phase === 'idle' || st.phase === 'working'}
<button type="button" class="primary" data-testid="publish-btn" on:click={doPublish} disabled={st.phase === 'working'}>
{st.phase === 'working' ? 'Publishing…' : 'Publish'}
{#if preview && (st.phase === 'idle' || st.phase === 'working')}
<PublishPreview model={preview} />
{:else if slab && st.phase !== 'idle' && st.phase !== 'working'}
<p class="meta">SKU {slab.sku || '—'} · {slab.bdft != null ? slab.bdft.toFixed(1) : '—'} bdft · {slab.price != null ? `$${slab.price.toFixed(2)}` : '—'}</p>
{/if}
{#if st.phase === 'idle'}
<div class="row">
<button type="button" class="ghost" data-testid="back-review" on:click={backToReview}>Back to Review</button>
<button type="button" class="primary" data-testid="publish-btn" on:click={doPublish}>
Publish
</button>
</div>
{:else if st.phase === 'working'}
<button type="button" class="primary full" data-testid="publish-btn" disabled>
Publishing…
</button>
<button type="button" class="ghost" data-testid="back-review" on:click={backToReview} disabled={st.phase === 'working'}>Back to Review</button>
{/if}
{#if st.phase === 'success'}
<div class="status ok" data-testid="publish-ok">Published{st.ok?.draft ? ' (UAT draft)' : ''} → Woo product #{st.ok?.wooProductId}</div>
<p class="meta">Photos purged · device draft cleared.</p>
<button type="button" class="primary" data-testid="new-slab" on:click={() => {}}>Start a new slab</button>
<button type="button" class="primary full" data-testid="new-slab" on:click={() => {}}>Start a new slab</button>
{:else if st.phase === 'failure' && st.fail}
{#if st.fail.duplicateSku}
<div class="step-x" data-testid="dup-sku">
@@ -95,15 +138,15 @@
<h2>SKU already exists</h2>
<p class="meta">“{slab?.sku}” is already in the store.</p>
</div>
<button type="button" class="primary" data-testid="edit-sku" on:click={editSku}>Edit SKU</button>
<button type="button" class="ghost" data-testid="open-existing" on:click={openExisting}>Open existing product</button>
<button type="button" class="primary full" data-testid="edit-sku" on:click={editSku}>Edit SKU</button>
<button type="button" class="ghost full" data-testid="open-existing" on:click={openExisting}>Open existing product</button>
{:else if st.fail.code === 'unpublishable'}
<div class="step-x" data-testid="not-ready">
<div class="x n">✕</div>
<h2>Not ready to publish</h2>
<p class="meta">Some fields are missing. Go back and finish Review.</p>
</div>
<button type="button" class="primary" data-testid="back-review" on:click={backToReview}>Back to Review</button>
<button type="button" class="primary full" data-testid="back-review" on:click={backToReview}>Back to Review</button>
{:else}
<div class="step-x" data-testid="store-failure">
<div class="x n" class:rec={st.fail.recoverable}>{st.fail.partialMedia ? '⚠' : '↻'}</div>
@@ -112,19 +155,31 @@
{#if st.fail.partialMedia}<span class="tag warn">orphan media — remove manually</span>{/if}
{#if !st.fail.recoverable}<span class="tag bad">check Settings</span>{/if}
</div>
<button type="button" class="primary" data-testid="retry-publish" on:click={doPublish}>Retry</button>
<button type="button" class="primary full" data-testid="retry-publish" on:click={doPublish}>Retry</button>
{/if}
{/if}
</section>
{/if}
<style>
.pub { max-width: 520px; margin: 0 auto; color: #eee; }
h1 { font-size: 1.3rem; margin: 0 0 6px; } h2 { font-size: 1.1rem; margin: 0 0 6px; }
/* Match Review margins (UI nits): tight horizontal padding, mobile-first column. */
.pub { max-width: 560px; margin: 0 auto; color: #eee; padding: 4px 4px 20px; box-sizing: border-box; }
h1 { font-size: 1.3rem; margin: 0 0 6px; }
h2 { font-size: 1.1rem; margin: 0 0 6px; }
.lede { color: #9a9a9a; font-size: .82rem; margin: 0 0 14px; }
.meta { color: #888; font-size: .82rem; margin: 4px 0 10px; }
.primary { width: 100%; padding: 14px; font-size: 1rem; border-radius: 3px; border: 0; background: #00e5ff; color: #002; font-weight: 700; margin-top: 12px; }
.primary:disabled { background: #333; color: #777; }
.ghost { width: 100%; padding: 13px; border-radius: 3px; border: 1px solid #444; background: transparent; color: #ccc; margin-top: 10px; }
.row { display: flex; gap: 10px; margin-top: 16px; }
.primary {
flex: 1; padding: 14px; font-size: 1rem; border-radius: 3px; border: 0;
background: #00e5ff; color: #002; font-weight: 700; cursor: pointer;
}
.primary:disabled { background: #333; color: #777; cursor: not-allowed; }
.primary.full, .ghost.full { width: 100%; margin-top: 12px; }
.ghost {
box-sizing: border-box; padding: 14px 18px; border-radius: 3px;
border: 1px solid #444; background: transparent; color: #ccc;
font-size: .9rem; font-weight: 600; cursor: pointer;
}
.status { font-weight: 700; padding: 11px 13px; border-radius: 3px; font-size: .92rem; }
.status.ok { background: #122a12; color: #7fd37f; }
.step-x { background: #2a1512; border-radius: 3px; padding: 14px; margin: 14px 0; }
@@ -0,0 +1,115 @@
<script lang="ts">
import type { PublishPreviewModel } from './publishPreviewModel';
export let model: PublishPreviewModel;
</script>
<div class="preview" data-testid="publish-preview">
{#if model.photos.length}
<div class="thumbs" data-testid="preview-thumbs" aria-label="Photo lineup">
{#each model.photos as p, i (p.id)}
<div class="thumb" class:primary={p.primary} data-testid="preview-thumb" data-primary={p.primary ? 'true' : ''}>
<img src={p.url} alt="Photo {i + 1}" />
{#if p.primary}<span class="pri">PRIMARY</span>{/if}
<span class="seq" aria-hidden="true">{i + 1}</span>
</div>
{/each}
</div>
{/if}
<div class="head" data-testid="preview-head">
<div class="sku" data-testid="preview-sku">SKU {model.sku}</div>
<h2 class="title" data-testid="preview-title">{model.title}</h2>
<div class="price" data-testid="preview-price">{model.price}</div>
</div>
<dl class="facts" data-testid="preview-facts">
{#each model.facts as f (f.label)}
<div class="fact" data-testid="preview-fact" data-label={f.label}>
<dt>{f.label}</dt>
<dd>{f.value}</dd>
</div>
{/each}
</dl>
<div class="block" data-testid="preview-short">
<div class="lbl">Short description</div>
<p class="body short">{model.shortDescription}</p>
</div>
<div class="block" data-testid="preview-long">
<div class="lbl">Description</div>
<pre class="body long">{model.description}</pre>
</div>
</div>
<style>
.preview { color: #eee; }
.thumbs {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
margin: 0 0 14px;
}
@media (max-width: 420px) {
.thumbs { grid-template-columns: repeat(3, 1fr); }
}
.thumb {
position: relative;
aspect-ratio: 1;
border: 2px solid #2a2a2a;
border-radius: 3px;
overflow: hidden;
background-color: #eee;
background-image:
linear-gradient(45deg,#ccc 25%,transparent 25%),
linear-gradient(-45deg,#ccc 25%,transparent 25%),
linear-gradient(45deg,transparent 75%,#ccc 75%),
linear-gradient(-45deg,transparent 75%,#ccc 75%);
background-size: 16px 16px;
background-position: 0 0,0 8px,8px -8px,-8px 0;
}
.thumb.primary { border-color: #00e5ff; }
.thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
.pri {
position: absolute; top: 4px; right: 4px;
background: #00e5ff; color: #002;
font-size: .52rem; font-weight: 800;
padding: 1px 4px; border-radius: 2px; letter-spacing: .03em;
}
.seq {
position: absolute; bottom: 4px; left: 4px;
width: 16px; height: 16px; border-radius: 50%;
background: rgba(0,0,0,.7); color: #fff;
font-size: .62rem; display: flex; align-items: center; justify-content: center;
}
.head { margin: 0 0 12px; }
.sku { color: #888; font-size: .78rem; margin: 0 0 4px; }
.title { font-size: 1.15rem; font-weight: 700; margin: 0 0 6px; line-height: 1.3; }
.price { font-size: 1.05rem; font-weight: 700; color: #7fd37f; }
.facts { margin: 0 0 14px; }
.fact {
display: flex; justify-content: space-between; gap: 12px;
border-bottom: 1px solid #2a2a2a; padding: 6px 0; font-size: .85rem;
}
.fact dt { color: #9a9a9a; margin: 0; flex: none; }
.fact dd {
margin: 0; text-align: right; font-variant-numeric: tabular-nums;
word-break: break-word;
}
.block { margin: 0 0 14px; }
.lbl { display: block; font-size: .78rem; color: #bbb; margin: 0 0 4px; }
.body {
margin: 0; padding: 10px 12px;
background: #141414; border: 1px solid #333; border-radius: 3px;
color: #ddd; font-size: .86rem; line-height: 1.45;
}
.short { white-space: pre-wrap; word-break: break-word; }
.long {
white-space: pre-wrap; word-break: break-word;
font-family: inherit; max-height: 320px; overflow: auto;
}
</style>
@@ -0,0 +1,50 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { test } from 'node:test';
const dir = dirname(fileURLToPath(import.meta.url));
const publishSrc = readFileSync(join(dir, 'Publish.svelte'), 'utf8');
const previewSrc = readFileSync(join(dir, 'PublishPreview.svelte'), 'utf8');
const modelSrc = readFileSync(join(dir, 'publishPreviewModel.ts'), 'utf8');
test('Publish uses dedicated PublishPreview before Woo POST', () => {
assert.match(publishSrc, /import PublishPreview from '\.\/PublishPreview\.svelte'/);
assert.match(publishSrc, /buildPublishPreviewModel/);
assert.match(publishSrc, /data-testid="publish-preview"|PublishPreview/);
// Idle confirm still posts only via doPublish / publish-btn
assert.match(publishSrc, /data-testid="publish-btn"/);
assert.match(publishSrc, /data-testid="back-review"/);
assert.match(publishSrc, /publishSlab\(slabId\)/);
});
test('PublishPreview layout: thumbs → head → facts → short → long', () => {
assert.match(previewSrc, /data-testid="publish-preview"/);
assert.match(previewSrc, /data-testid="preview-thumbs"/);
assert.match(previewSrc, /data-testid="preview-head"/);
assert.match(previewSrc, /data-testid="preview-sku"/);
assert.match(previewSrc, /data-testid="preview-title"/);
assert.match(previewSrc, /data-testid="preview-price"/);
assert.match(previewSrc, /data-testid="preview-facts"/);
assert.match(previewSrc, /data-testid="preview-short"/);
assert.match(previewSrc, /data-testid="preview-long"/);
// Plain-text long description (pre-wrap), not HTML render
assert.match(previewSrc, /white-space:\s*pre-wrap/);
assert.match(previewSrc, /<pre class="body long"/);
});
test('Preview model resolves taxonomy names; does not invent fields', () => {
assert.match(modelSrc, /species_id/);
assert.match(modelSrc, /wood_category_ids/);
assert.match(modelSrc, /edge_type_term_id/);
assert.match(modelSrc, /figure_term_ids/);
assert.match(modelSrc, /grade_term_id/);
assert.match(modelSrc, /moisture_term_id/);
assert.match(modelSrc, /feat_tag_ids/);
assert.match(modelSrc, /width_min_in/);
assert.match(modelSrc, /bdft/);
assert.match(modelSrc, /decodeName/);
// No invented brand/voice fields
assert.equal(modelSrc.includes('brand_voice'), false);
});
@@ -0,0 +1,150 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import {
buildPublishPreviewModel,
formatWidths,
} from './publishPreviewModel';
import type { PhotoRow, Slab, Taxonomy } from '../../api/slabs';
function baseSlab(over: Partial<Slab> = {}): Slab {
return {
id: 's1',
sku: 'SLAB-99',
status: 'ready',
price: 420.5,
price_source: 'recommendation',
length_in: 96,
thickness_in: 2,
sqft: 8,
bdft: 16,
width_min_in: 12,
width_max_in: 18,
width_avg_in: 15,
species_id: 10,
wood_category_ids: [20, 21],
edge_type_term_id: 30,
figure_term_ids: [40],
grade_term_id: 50,
thickness_term_id: 60,
moisture_term_id: 70,
fig_tag_ids: [],
feat_tag_ids: [80],
title: 'Live Edge Cedar Slab',
short_description: '96″ × 12–18″ × 2″ cedar',
description: 'A solid cedar slab.\n\n- Kiln dried\n- Live edge',
content_source: 'llm',
species_confidence: null,
figure_confidence: null,
grade_confidence: null,
inference_status: null,
calibration_photo_id: null,
woo_product_id: null,
client_rev: 1,
server_rev: 2,
created_at: 't',
updated_at: 't',
...over,
};
}
const tax: Taxonomy = {
categories: [
{ id: 1, name: 'Species', slug: 'species', parent_id: null, woo_id: null },
{ id: 10, name: 'Western Red Cedar', slug: 'wrc', parent_id: 1, woo_id: null },
{ id: 2, name: 'wcat', slug: 'wcat', parent_id: null, woo_id: null },
{ id: 20, name: 'Live edge wood', slug: 'liveedge', parent_id: 2, woo_id: null },
{ id: 21, name: 'Slabs &amp; Lumber', slug: 'snl', parent_id: 2, woo_id: null },
],
attributes: [
{
id: 1, name: 'Edge type', slug: 'edge',
terms: [{ id: 30, name: 'Live Edge', slug: 'le' }],
},
{
id: 2, name: 'Figure', slug: 'figure',
terms: [{ id: 40, name: 'Quilted', slug: 'quilt' }],
},
{
id: 3, name: 'Grade', slug: 'grade',
terms: [{ id: 50, name: 'Select', slug: 'sel' }],
},
{
id: 4, name: 'Moisture', slug: 'moisture',
terms: [{ id: 70, name: 'Kiln dried', slug: 'kd' }],
},
],
tags: [
{ id: 80, name: 'Bookmatched', slug: 'feat-bookmatched' },
],
taxonomy_anchor: null,
taxonomy_last_sync_at: null,
};
const photos: PhotoRow[] = [
{
id: 'p2', seq: 2, is_calibration: 0, role: 'batch',
width_px: 100, height_px: 100, has_original: 1, has_processed: 1,
},
{
id: 'p1', seq: 1, is_calibration: 1, role: 'calibration',
width_px: 100, height_px: 100, has_original: 1, has_processed: 0,
},
];
test('formatWidths joins min–max and avg', () => {
assert.equal(formatWidths(baseSlab()), '12–18″ (avg 15″)');
});
test('buildPublishPreviewModel: ordered thumbs, decoded names, all facts', () => {
const m = buildPublishPreviewModel(baseSlab(), tax, photos, (id, kind) => `/p/${id}/${kind}`);
assert.deepEqual(m.photos.map((p) => p.id), ['p1', 'p2']);
assert.equal(m.photos[0].primary, true);
assert.equal(m.photos[0].url, '/p/p1/original');
assert.equal(m.photos[1].url, '/p/p2/processed');
assert.equal(m.sku, 'SLAB-99');
assert.equal(m.title, 'Live Edge Cedar Slab');
assert.equal(m.price, '$420.50');
assert.equal(m.shortDescription, '96″ × 12–18″ × 2″ cedar');
assert.match(m.description, /Kiln dried/);
const by = Object.fromEntries(m.facts.map((f) => [f.label, f.value]));
assert.equal(by.Species, 'Western Red Cedar');
assert.equal(by['Wood categories'], 'Live edge wood, Slabs & Lumber');
assert.equal(by.Edge, 'Live Edge');
assert.equal(by.Figure, 'Quilted');
assert.equal(by.Grade, 'Select');
assert.equal(by.Moisture, 'Kiln dried');
assert.equal(by.Features, 'Bookmatched');
assert.equal(by.Length, '96″');
assert.equal(by.Widths, '12–18″ (avg 15″)');
assert.equal(by.Thickness, '2″');
assert.equal(by.Bdft, '16.00');
});
test('buildPublishPreviewModel: empty optional fields show em dash', () => {
const m = buildPublishPreviewModel(
baseSlab({
figure_term_ids: [],
feat_tag_ids: [],
edge_type_term_id: null,
title: null,
short_description: null,
description: null,
price: null,
sku: '',
}),
tax,
[],
() => '',
);
assert.equal(m.photos.length, 0);
assert.equal(m.title, '—');
assert.equal(m.sku, '—');
assert.equal(m.price, '—');
assert.equal(m.shortDescription, '—');
assert.equal(m.description, '—');
const by = Object.fromEntries(m.facts.map((f) => [f.label, f.value]));
assert.equal(by.Edge, '—');
assert.equal(by.Figure, '—');
assert.equal(by.Features, '—');
});
@@ -0,0 +1,133 @@
/**
* Pure builders for the Publish preview confirmation screen.
* Resolves taxonomy ids → display names; never invents fields beyond the slab.
*/
import { decodeName } from '../decodeName';
import type { PhotoRow, Slab, Taxonomy, TaxAttribute, TaxCategory, TaxTag } from '../../api/slabs';
export interface PreviewPhoto {
id: string;
url: string;
role: 'calibration' | 'overlay' | 'batch';
/** Leftmost (seq order) = Woo cover. */
primary: boolean;
}
export interface PreviewFact {
label: string;
value: string;
}
export interface PublishPreviewModel {
photos: PreviewPhoto[];
sku: string;
title: string;
price: string;
facts: PreviewFact[];
shortDescription: string;
description: string;
}
const fmt1 = (v: number) => {
const s = v.toFixed(1);
return s.endsWith('.0') ? s.slice(0, -2) : s;
};
const fmt2 = (v: number) => v.toFixed(2);
function catName(cats: TaxCategory[], id: number | null | undefined): string {
if (id == null) return '—';
const c = cats.find((x) => x.id === id);
return c ? decodeName(c.name) : `#${id}`;
}
function termName(attrs: TaxAttribute[], id: number | null | undefined): string {
if (id == null) return '—';
for (const a of attrs) {
const t = a.terms.find((x) => x.id === id);
if (t) return decodeName(t.name);
}
return `#${id}`;
}
function attrTermsByName(attrs: TaxAttribute[], name: string): { id: number; name: string }[] {
const attr = attrs.find((a) => a.name.toLowerCase() === name.toLowerCase());
return (attr?.terms ?? []).map((t) => ({ id: t.id, name: decodeName(t.name) }));
}
function namesForIds(
ids: number[],
lookup: (id: number) => string | null,
): string {
if (!ids.length) return '—';
return ids.map((id) => lookup(id) ?? `#${id}`).join(', ');
}
function tagName(tags: TaxTag[], id: number): string | null {
const t = tags.find((x) => x.id === id);
return t ? decodeName(t.name) : null;
}
/** Widths as "min–max (avg)" inches, matching Review dims language. */
export function formatWidths(slab: Pick<Slab, 'width_min_in' | 'width_max_in' | 'width_avg_in'>): string {
return `${fmt1(slab.width_min_in)}–${fmt1(slab.width_max_in)}″ (avg ${fmt1(slab.width_avg_in)}″)`;
}
export function buildPublishPreviewModel(
slab: Slab,
tax: Taxonomy | null,
photos: PhotoRow[],
urlFor: (photoId: string, kind: 'original' | 'processed') => string,
): PublishPreviewModel {
const cats = tax?.categories ?? [];
const attrs = tax?.attributes ?? [];
const tags = tax?.tags ?? [];
const figureTerms = attrTermsByName(attrs, 'figure');
const figureLookup = (id: number) => {
const t = figureTerms.find((x) => x.id === id);
return t ? t.name : null;
};
const ordered = [...photos].sort((a, b) => a.seq - b.seq);
const previewPhotos: PreviewPhoto[] = ordered.map((p, i) => ({
id: p.id,
url: urlFor(p.id, p.has_processed ? 'processed' : 'original'),
role: p.role ?? 'batch',
primary: i === 0,
}));
const woodNames = namesForIds(slab.wood_category_ids ?? [], (id) => {
const c = cats.find((x) => x.id === id);
return c ? decodeName(c.name) : null;
});
const facts: PreviewFact[] = [
{ label: 'Species', value: catName(cats, slab.species_id) },
{ label: 'Wood categories', value: woodNames },
{ label: 'Edge', value: termName(attrs, slab.edge_type_term_id) },
{
label: 'Figure',
value: namesForIds(slab.figure_term_ids ?? [], figureLookup),
},
{ label: 'Grade', value: termName(attrs, slab.grade_term_id) },
{ label: 'Moisture', value: termName(attrs, slab.moisture_term_id) },
{
label: 'Features',
value: namesForIds(slab.feat_tag_ids ?? [], (id) => tagName(tags, id)),
},
{ label: 'Length', value: `${fmt1(slab.length_in)}″` },
{ label: 'Widths', value: formatWidths(slab) },
{ label: 'Thickness', value: `${fmt1(slab.thickness_in)}″` },
{ label: 'Bdft', value: fmt2(slab.bdft) },
];
return {
photos: previewPhotos,
sku: slab.sku || '—',
title: slab.title || '—',
price: slab.price != null ? `$${slab.price.toFixed(2)}` : '—',
facts,
shortDescription: (slab.short_description ?? '').trim() || '—',
description: (slab.description ?? '').trim() || '—',
};
}
+260 -173
View File
@@ -1,18 +1,24 @@
<script lang="ts">
import { onMount, onDestroy, tick } from 'svelte';
import {
getSlab, updateSlab, reviewSlab, getTaxonomy, getSettings, inferTaxon, inferContent,
getSlab, updateSlab, reviewSlab, getTaxonomy, getSettings, inferContent, getCall2Prompt,
listPhotos, persistLineup, photoUrl, ApiError,
type Slab, type Taxonomy, type TaxCategory, type TaxTag, type SettingsView, type PhotoRow,
type Call2Prompt,
} from '../../api/slabs';
import type { CalibratedCapture } from '../flow';
import { canCreateTitleFromSpecies, effectiveSpeciesId } from './speciesFromCapture';
import MultiSelect from './MultiSelect.svelte';
import { deriveThicknessTermId } from './thicknessBand';
import { decodeName } from '../decodeName';
import { deriveFigTagIds, scopedFeatTags } from './figTags';
import {
keptInOrder, moveItem, missingMinimums, roleLabel, resolvePhotoUrl,
type LineupItem,
} from './lineup';
import { nextPrimary } from './woodPrimary';
import { missingCreateDescLabels } from './createDescGate';
import { orderWoodCatGroups } from './woodCatOrder';
import { applyMoistureDefault } from './moistureDefault';
// Props handed off by App (one-column flow, UX.md §1).
export let slabId: string;
@@ -27,7 +33,7 @@
let loading = true;
// Picker option types (id + name + whether the AI recommended it).
interface Opt { id: number; name: string; ai: boolean; lowIn?: number }
interface Opt { id: number; name: string; ai: boolean; lowIn?: number; slug?: string }
const byId = <T extends { id: number }>(arr: T[]) => {
const m = new Map<number, T>();
for (const x of arr) m.set(x.id, x);
@@ -35,22 +41,19 @@
};
// --- Derived taxonomy option lists (resolve by slug, never hardcoded ids) ---
let speciesOpts: Opt[] = [];
let woodCatGroups: import('./MultiSelect.svelte').Group[] = [];
let edgeOpts: Opt[] = [];
let figureOpts: Opt[] = [];
let gradeOpts: Opt[] = [];
let moistureOpts: Opt[] = [];
let figTagOpts: Opt[] = [];
let featTagOpts: Opt[] = [];
function deriveOptions() {
if (!tax) return;
const cats: TaxCategory[] = tax.categories;
// Resolve the two top-level groupers BY SLUG (QUALITY-105 / METADATA-5 — never
// hardcode live Woo term ids): `species` → species options; `wcat` → wood
// categories. Both are parent_id:null groupers; their children are the leaves.
const speciesGrouper = cats.find((c) => c.parent_id === null && c.slug === 'species');
// Resolve the wood-category grouper BY SLUG (QUALITY-105 / METADATA-5 — never
// hardcode live Woo term ids). Species is chosen on Measure and is not editable
// on Review; only `wcat` leaves are listed here.
const wcatGrouper = cats.find((c) => c.parent_id === null && c.slug === 'wcat');
const childrenOf = (g: TaxCategory | undefined): TaxCategory[] =>
g ? cats.filter((c) => c.parent_id === g.id) : [];
@@ -64,47 +67,53 @@
// attributes ignore it. "2-4 in" → 2; a name with no leading number → undefined.
const m = t.name.match(/\d+(?:[./]\d+)?/);
const lowIn = m ? Number(m[0]) : undefined;
return { id: t.id, name: t.name, ai: false, lowIn: Number.isFinite(lowIn) ? lowIn : undefined };
return { id: t.id, name: decodeName(t.name), ai: false, lowIn: Number.isFinite(lowIn) ? lowIn : undefined };
});
};
speciesOpts = childrenOf(speciesGrouper).map((c) => ({ id: c.id, name: c.name, ai: false }));
// Wood categories: parent/child grouped (METADATA-10 — parents group only, never
// assigned). Each direct child of `wcat` that has its own children becomes a parent
// group with those children as leaves; a child with no children is a leaf under the
// (implicit) top group. This keeps the full tree visible with non-selectable parents.
woodCatGroups = (childrenOf(wcatGrouper)).map((parent) => {
// UI order only: pin Slabs & Lumber (snl) parent first; Live edge wood (liveedge) leaf first.
const rawGroups = (childrenOf(wcatGrouper)).map((parent) => {
const kids = cats.filter((c) => c.parent_id === parent.id);
const leaves: Opt[] = kids.length
? kids.map((c) => ({ id: c.id, name: c.name, ai: false }))
: [{ id: parent.id, name: parent.name, ai: false }];
return { parent: { id: parent.id, name: parent.name }, leaves };
? kids.map((c) => ({ id: c.id, name: decodeName(c.name), ai: false, slug: c.slug }))
: [{ id: parent.id, name: decodeName(parent.name), ai: false, slug: parent.slug }];
return {
parent: { id: parent.id, name: decodeName(parent.name), slug: parent.slug },
leaves,
};
});
woodCatGroups = orderWoodCatGroups(rawGroups);
edgeOpts = termsOf('edge type');
figureOpts = termsOf('figure');
gradeOpts = termsOf('grade');
// No thickness options: the band is derived server-side and never shown here
// (METADATA-15 / QUALITY-95).
moistureOpts = termsOf('moisture');
// fig-* / feat-* product tags (METADATA-30): pulled from the live synced set,
// filtered by the family the backend keeps. Never invent a prefix — if the
// store has none, the picker is legitimately empty.
// feat-* product tags (METADATA-30): slug prefix with hyphen. fig-* tags are
// derived from Figure attribute selections (no separate picker).
const tags: TaxTag[] = tax.tags ?? [];
figTagOpts = tags.filter((t) => t.slug.startsWith('fig')).map((t) => ({ id: t.id, name: t.name, ai: false }));
featTagOpts = tags.filter((t) => t.slug.startsWith('feat')).map((t) => ({ id: t.id, name: t.name, ai: false }));
featTagOpts = scopedFeatTags(tags).map((t) => ({
id: t.id, name: decodeName(t.name), ai: false,
}));
}
// --- Editable model (mirrors the live slab) ---
// Species is set on Measure / createSlab — Review only reads it (no picker).
// speciesSel mirrors the resolved id for hydrate/debug; gates use effectiveSpeciesId.
let speciesSel: string = '';
// #90: single-selects bind a STRING var to the native <select>, because
// <option value={id}> coerces the option value to a string. Binding a
// `number | ''` var directly to those string values is the classic
// number/string mismatch that leaves the selected option blank after AI
// prefill or reload. `Number(...)` at commit normalizes back.
let speciesSel: string = '';
let edgeSel: string = '';
let gradeSel: string = '';
let woodCatIds: number[] = [];
let figureIds: number[] = [];
let moistureId: number | '' = '';
let moistureSel: string = '';
let figTagIds: number[] = [];
let featTagIds: number[] = [];
let primaryCatId: number | '' = '';
@@ -126,31 +135,56 @@
let reviewBusy = false;
let reviewError = '';
// #89: explicit "Create" actions. `createBusy` covers the deterministic
// title/short-desc generation; `canCreateTitle` is its gate (species + ≥1 wood
// category). `canCreateDesc` gates the AI long-description on title+short-desc
// being populated AND the content LLM being enabled.
// title/short-desc generation; `canCreateTitle` is its gate (species already
// on the slab from Measure + ≥1 wood category). `canCreateDesc` gates Call 2
// (writes short + long) on title + taxonomy + content LLM — not short/desc/price.
let createBusy = false;
let createError = '';
$: canCreateTitle =
speciesSel !== '' && woodCatIds.length > 0;
$: canCreateDesc =
canGenerateContent && title.trim() !== '' && shortDescription.trim() !== '';
// Prefer slab.species_id; fall back to Capture handoff if createSlab omitted it.
$: resolvedSpeciesId = effectiveSpeciesId(slab?.species_id, capture?.speciesId ?? null);
$: canCreateTitle = canCreateTitleFromSpecies(resolvedSpeciesId, woodCatIds.length);
// Create-desc gate: recompute on every field change (no blur required).
// Short/description are Call 2 outputs; price is publish-only.
$: createDescMissing = missingCreateDescLabels({
contentLlmEnabled: canGenerateContent,
title,
speciesId: resolvedSpeciesId,
woodCategoryIds: woodCatIds,
primaryCatId,
edgeTermId: edgeSel,
figureTermIds: figureIds,
gradeTermId: gradeSel,
moistureTermId: moistureSel,
});
$: canCreateDesc = createDescMissing.length === 0;
// Monotonic token: each commit captures the current value and ignores its
// result if a newer commit has since started. Without this, two concurrent
// commits (e.g. a select change racing the SKU blur) resolve out of order and
// a stale "not ready" review clobbers the final ready one — the
// continue-to-Publish button stays disabled even though the status reads ready.
let commitToken = 0;
// Live ready/missing: commit on input/change without requiring blur (extend Create-desc gate).
let fieldCommitTimer: ReturnType<typeof setTimeout> | undefined;
let descCommitTimer: ReturnType<typeof setTimeout> | undefined;
function scheduleFieldCommit() {
if (fieldCommitTimer) clearTimeout(fieldCommitTimer);
fieldCommitTimer = setTimeout(() => {
fieldCommitTimer = undefined;
void commitReview();
}, 250);
}
function scheduleDescCommit() {
if (descCommitTimer) clearTimeout(descCommitTimer);
descCommitTimer = setTimeout(() => {
descCommitTimer = undefined;
void commitDescription();
}, 250);
}
// --- Inference ---
let aiBusy = false;
let aiNote = '';
let genBusy = false;
let genError = '';
let canGenerateContent = false;
let aiRecommendations: number[] = []; // union of AI-suggested ids (for row bubbles)
const num = (v: unknown) => (typeof v === 'number' ? v : null);
const fmt1 = (v: number) => { const s = v.toFixed(1); return s.endsWith('.0') ? s.slice(0, -2) : s; };
const fmt2 = (v: number) => v.toFixed(2);
@@ -240,6 +274,8 @@
onDestroy(() => {
for (const u of photoUrls) URL.revokeObjectURL(u);
photoUrls = [];
if (fieldCommitTimer) clearTimeout(fieldCommitTimer);
if (descCommitTimer) clearTimeout(descCommitTimer);
});
// Whole-card tap = keep/remove toggle. A real drag (>6px) reorders and does NOT
@@ -320,13 +356,23 @@
}
function hydrate(s: Slab, t: Taxonomy | null) {
speciesSel = s.species_id != null ? String(s.species_id) : '';
const sid = effectiveSpeciesId(s.species_id, capture?.speciesId ?? null);
speciesSel = sid != null ? String(sid) : '';
woodCatIds = [...(s.wood_category_ids ?? [])];
edgeSel = s.edge_type_term_id != null ? String(s.edge_type_term_id) : '';
figureIds = [...(s.figure_term_ids ?? [])];
gradeSel = s.grade_term_id != null ? String(s.grade_term_id) : '';
moistureId = s.moisture_term_id ?? '';
figTagIds = [...(s.fig_tag_ids ?? [])];
// Moisture default Kiln dried (slug kd) when empty — mount commitReview persists it.
{
const moistAttr = t?.attributes.find((a) => a.name.toLowerCase() === 'moisture');
const moistTerms = (moistAttr?.terms ?? []).map((x) => ({
id: x.id, name: x.name, slug: x.slug,
}));
const applied = applyMoistureDefault(s.moisture_term_id, moistTerms);
moistureSel = applied === '' ? '' : String(applied);
// Keep native <select> in sync when we programmatically default empty → kd.
void syncSelects();
}
featTagIds = [...(s.feat_tag_ids ?? [])];
title = s.title ?? '';
shortDescription = s.short_description ?? '';
@@ -337,37 +383,29 @@
sku = s.sku ?? '';
// REVIEW-15: primary defaults to first selected wood cat; keep if still valid.
primaryCatId = nextPrimary(woodCatIds, woodCatIds[0] ?? '');
// Seed AI-recommended markers from stored confidences (>=0.7 means the AI put
// its weight behind the value the owner kept).
aiRecommendations = [];
if (num(s.species_confidence) != null && num(s.species_confidence)! >= 0.7 && s.species_id)
aiRecommendations.push(s.species_id);
if (num(s.figure_confidence) != null && num(s.figure_confidence)! >= 0.7)
aiRecommendations.push(...(s.figure_term_ids ?? []));
// fig_tag_ids are derived reactively from figureIds (no hydrate of stored fig tags).
}
// AI-marked option views (badge on rows after Call 1 / stored confidences).
$: speciesOptsView = speciesOpts.map((o) => ({ ...o, ai: aiRecommendations.includes(o.id) }));
$: woodCatGroupsView = woodCatGroups.map((g) => ({
parent: g.parent,
leaves: g.leaves.map((o) => ({ ...o, ai: aiRecommendations.includes(o.id) })),
}));
$: figureOptsView = figureOpts.map((o) => ({ ...o, ai: aiRecommendations.includes(o.id) }));
$: featTagOptsView = featTagOpts.map((o) => ({ ...o, ai: aiRecommendations.includes(o.id) }));
// Call 1 out: no AI badges on options. Groups/opts pass through as-is.
$: woodCatGroupsView = woodCatGroups;
$: figureOptsView = figureOpts;
$: featTagOptsView = featTagOpts;
// fig_tag_ids derived from selected Figure term names ↔ fig-* tag names (casefold).
$: figTagIds = tax
? deriveFigTagIds(figureIds, figureOpts, tax.tags ?? [])
: figTagIds;
// Flat leaf name lookup for the primary radio set (REVIEW-15).
$: woodLeafById = new Map(
woodCatGroups.flatMap((g) => g.leaves.map((l) => [l.id, l] as [number, Opt])),
);
$: primaryOpts = woodCatIds
.map((id) => woodLeafById.get(id))
.filter((o): o is Opt => !!o)
.map((o) => ({ ...o, ai: aiRecommendations.includes(o.id) }));
.filter((o): o is Opt => !!o);
// METADATA-15 / QUALITY-95: the thickness band is derived server-side from the
// measured thickness against the live synced Woo bands. It is deliberately absent
// from this screen — no owner control, no read-only echo. The only thickness the
// owner sees here is the measured value in the summary above.
const aiIn = (ids: number[]) => (id: number) => ids.includes(id);
// Persist taxonomy/price/sku edits to the slab (PUT), then recompute ready.
// #89: the deterministic title/short_description are written here — the server's
// review returns the build from the current metadata, and if the field is empty
@@ -379,12 +417,20 @@
reviewBusy = true;
reviewError = '';
try {
// Repair Measure species onto the row before probing title (review reads the DB).
if (resolvedSpeciesId != null && slab.species_id == null) {
slab = await updateSlab(slabId, {
species_id: resolvedSpeciesId,
client_rev: slab.server_rev,
});
if (myToken !== commitToken) return;
}
// Run the deterministic review FIRST (reads the current metadata) to get the
// title/short_description build before we persist.
// Do NOT send price/sku here: review POST persists those overrides and bumps
// server_rev (#80), which would stale the client_rev on the PUT below.
const probe = await reviewSlab(slabId, {
primary_wood_category_id: primaryCatId === '' ? undefined : Number(primaryCatId),
price: price === '' ? undefined : Number(price),
sku: sku || undefined,
});
if (myToken !== commitToken) return;
// If the owner hasn't typed a title/short-desc, adopt the deterministic build
@@ -392,13 +438,14 @@
if (probe.title && title === '') title = probe.title;
if (probe.short_description && shortDescription === '') shortDescription = probe.short_description;
const patch = {
species_id: speciesSel === '' ? null : Number(speciesSel),
// Always echo known species_id (slab or Capture repair) so Review PUTs never
// leave a Measure-chosen species off the row; never send null (would wipe).
const patch: Record<string, unknown> = {
wood_category_ids: woodCatIds,
edge_type_term_id: edgeSel === '' ? null : Number(edgeSel),
figure_term_ids: figureIds,
grade_term_id: gradeSel === '' ? null : Number(gradeSel),
moisture_term_id: moistureId === '' ? null : Number(moistureId),
moisture_term_id: moistureSel === '' ? null : Number(moistureSel),
fig_tag_ids: figTagIds,
feat_tag_ids: featTagIds,
title: title || null,
@@ -409,17 +456,21 @@
sku: sku || null,
client_rev: slab.server_rev,
};
const fresh = await updateSlab(slabId, patch);
if (resolvedSpeciesId != null) patch.species_id = resolvedSpeciesId;
const fresh = await updateSlab(slabId, patch as Parameters<typeof updateSlab>[1]);
if (myToken !== commitToken) return; // superseded; don't clobber the latest
slab = fresh;
// Re-run the review against the just-persisted row (authoritative ready/missing).
// No price/sku in payload — already on the row from the PUT; sending them
// would bump server_rev again and stale the next edit's client_rev.
review = await reviewSlab(slabId, {
primary_wood_category_id: primaryCatId === '' ? undefined : Number(primaryCatId),
price: price === '' ? undefined : Number(price),
sku: sku || undefined,
});
if (myToken !== commitToken) return;
if (review.primary_wood_category_id != null) primaryCatId = review.primary_wood_category_id;
// Post-PUT review may newly have species+wood cats — fill empty Create fields.
if (review.title && title === '') title = review.title;
if (review.short_description && shortDescription === '') shortDescription = review.short_description;
} catch (e) {
if (myToken !== commitToken) return;
reviewError = e instanceof ApiError ? e.message : 'Could not review. Try again.';
@@ -439,6 +490,14 @@
createBusy = true;
createError = '';
try {
// 0. Repair: if Measure chose a species but createSlab omitted it, write it
// before the deterministic build (review reads species_id from the row).
if (resolvedSpeciesId != null && slab.species_id == null) {
slab = await updateSlab(slabId, {
species_id: resolvedSpeciesId,
client_rev: slab.server_rev,
});
}
// 1. Run the deterministic review to get title / short_description.
const res = await reviewSlab(slabId, {
primary_wood_category_id: primaryCatId === '' ? undefined : Number(primaryCatId),
@@ -449,10 +508,11 @@
if (res.short_description) shortDescription = res.short_description;
// 3. Persist the generated prose (single PUT, fresh client_rev).
if ((title || shortDescription) && (title !== (slab as Slab).title || shortDescription !== (slab as Slab).short_description)) {
const prosePatch = {
const prosePatch: Parameters<typeof updateSlab>[1] = {
title: title || null,
short_description: shortDescription || null,
client_rev: (slab as Slab).server_rev,
...(resolvedSpeciesId != null ? { species_id: resolvedSpeciesId } : {}),
};
const fresh = await updateSlab(slabId, prosePatch);
slab = fresh;
@@ -494,40 +554,6 @@
}
}
async function runInference() {
if (!slab) return;
aiBusy = true;
aiNote = '';
try {
const r = await inferTaxon(slabId);
const s = r.suggestions;
const recs: number[] = [];
if (s.species_id) { speciesSel = String(s.species_id); recs.push(s.species_id); }
if (s.wood_category_ids) {
woodCatIds = [...s.wood_category_ids];
primaryCatId = nextPrimary(woodCatIds, primaryCatId);
recs.push(...s.wood_category_ids);
}
if (s.edge_type_term_id) edgeSel = String(s.edge_type_term_id);
if (s.figure_term_ids) { figureIds = [...s.figure_term_ids]; recs.push(...s.figure_term_ids); }
if (s.feature_tag_ids?.length) {
featTagIds = [...s.feature_tag_ids];
recs.push(...s.feature_tag_ids);
}
// Grade is not AI-suggested (METADATA-40) — owner judgment. Do not auto-fill.
aiRecommendations = recs;
aiNote = recs.length ? `AI suggested ${recs.length} field${recs.length === 1 ? '' : 's'} — review & edit below.` : 'No confident AI suggestions.';
await commitReview();
// #90: force the <select> DOM to reflect the prefilled values after Svelte's
// own select_option(reset) has run.
syncSelects();
} catch (e) {
aiNote = e instanceof ApiError ? e.message : 'Inference unavailable.';
} finally {
aiBusy = false;
}
}
async function generateContent() {
if (!slab || !canCreateDesc) return;
genBusy = true;
@@ -535,10 +561,12 @@
try {
const r = await inferContent(slabId);
description = r.description;
if (r.short_description) shortDescription = r.short_description;
contentSource = 'llm';
// commitReview() no longer sends description (see #89), so persist it explicitly.
// Persist Call 2 short + long (commitReview does not send description — see #89).
const myToken = ++commitToken;
const fresh = await updateSlab(slabId, {
short_description: shortDescription || null,
description: description || null,
content_source: contentSource || null,
client_rev: slab.server_rev,
@@ -552,6 +580,40 @@
}
}
// View prompt: read-only assembled Call 2 text + photo slots (shared server assembler).
let viewPromptOpen = false;
let viewPromptBusy = false;
let viewPromptError = '';
let viewPrompt: Call2Prompt | null = null;
let viewPromptCopied = false;
async function loadViewPrompt() {
viewPromptBusy = true;
viewPromptError = '';
viewPromptCopied = false;
try {
// Persist current Review fields first so the assembled text matches what Create desc would send.
await commitReview();
viewPrompt = await getCall2Prompt(slabId);
viewPromptOpen = true;
} catch (e) {
viewPromptError = e instanceof ApiError ? e.message : 'Could not load the prompt.';
viewPromptOpen = true;
} finally {
viewPromptBusy = false;
}
}
async function copyViewPrompt() {
if (!viewPrompt?.text) return;
try {
await navigator.clipboard.writeText(viewPrompt.text);
viewPromptCopied = true;
} catch {
viewPromptError = 'Clipboard unavailable — select the text and copy manually.';
}
}
const missingCount = () => (review ? review.missing.length : null);
const ready = () => review?.ready === true;
// The button's enabled state must derive from the SAME reactive `review` object
@@ -575,13 +637,13 @@
// option matches the bound value. Idempotent: only writes when they differ.
async function syncSelects() {
await tick();
if (speciesEl && speciesEl.value !== speciesSel) speciesEl.value = speciesSel;
if (edgeEl && edgeEl.value !== edgeSel) edgeEl.value = edgeSel;
if (gradeEl && gradeEl.value !== gradeSel) gradeEl.value = gradeSel;
if (moistureEl && moistureEl.value !== moistureSel) moistureEl.value = moistureSel;
}
let speciesEl: HTMLSelectElement | undefined;
let edgeEl: HTMLSelectElement | undefined;
let gradeEl: HTMLSelectElement | undefined;
let moistureEl: HTMLSelectElement | undefined;
/** Wood categories: toggle leaf + keep primary valid (REVIEW-15 / wireframe #90). */
function toggleWoodCat(id: number) {
woodCatIds = toggleMulti(woodCatIds, id);
@@ -609,6 +671,8 @@
loadError = e instanceof ApiError ? e.message : 'Could not load the slab.';
} finally {
loading = false;
// Form mounts after loading flips false — sync selects (incl. moisture kd default).
void syncSelects();
}
});
@@ -687,29 +751,12 @@
</div>
{/if}
{#if settings?.inference_enabled}
<div class="aibar">
<button type="button" class="ai-btn" data-testid="run-inference" on:click={runInference} disabled={aiBusy}>
{aiBusy ? 'Reading slab…' : 'Suggest with AI'}
</button>
{#if aiNote}<p class="ai-note" data-testid="ai-note">{aiNote}</p>{/if}
</div>
{/if}
<!-- Species (single, AI-assist) -->
<div class="field">
<label class="lbl" for="species">Species <span class="ai">AI assist</span></label>
<select id="species" class="sel" data-testid="species" bind:this={speciesEl} bind:value={speciesSel} on:change={commitReview}>
<option value="">— select —</option>
{#each speciesOpts as o (o.id)}
<option value={o.id} data-ai={aiRecommendations.includes(o.id) ? 'true' : ''}>{o.name}{aiRecommendations.includes(o.id) ? ' (AI)' : ''}</option>
{/each}
</select>
</div>
<!-- Call 1 OUT (overnight-whole-app): Suggest-with-AI removed. Call 2 generateContent stays. -->
<!-- Species: chosen on Measure; no Review picker (value stays on the slab from createSlab). -->
<!-- Wood categories (multi + inline primary radio set, REVIEW-15 / #90 wireframe) -->
<div class="field" data-testid="wood-cat-field">
<label class="lbl">Wood categories <span class="ai">AI assist</span></label>
<label class="lbl">Wood categories</label>
<MultiSelect
label="Wood categories"
groups={woodCatGroupsView}
@@ -717,6 +764,7 @@
placeholder="Choose wood categories"
testid="wood-cat"
onToggle={toggleWoodCat}
onCommit={commitReview}
on:commit={commitReview}
/>
{#if primaryOpts.length}
@@ -734,7 +782,6 @@
>
<span class="ps-radio" aria-hidden="true"></span>
<span class="ps-name">{o.name}</span>
{#if o.ai}<span class="ps-tag">AI</span>{/if}
</button>
{/each}
<p class="ps-note">
@@ -745,9 +792,9 @@
{/if}
</div>
<!-- Edge type (single, AI-assist) -->
<!-- Edge type (single) -->
<div class="field">
<label class="lbl" for="edge">Edge type <span class="ai">AI assist</span></label>
<label class="lbl" for="edge">Edge type</label>
<select id="edge" class="sel" data-testid="edge-type" bind:this={edgeEl} bind:value={edgeSel} on:change={commitReview}>
<option value="">— select —</option>
{#each edgeOpts as o (o.id)}
@@ -756,9 +803,9 @@
</select>
</div>
<!-- Figure (multi, searchable list, AI-assist) -->
<!-- Figure (multi, searchable list); fig-* tags derived from selections -->
<div class="field">
<label class="lbl">Figure <span class="ai">AI assist</span></label>
<label class="lbl">Figure</label>
<MultiSelect
label="Figure"
options={figureOptsView}
@@ -766,6 +813,7 @@
placeholder="Choose figure"
testid="figure"
onToggle={(id) => (figureIds = toggleMulti(figureIds, id))}
onCommit={commitReview}
on:commit={commitReview}
/>
</div>
@@ -779,30 +827,17 @@
</select>
</div>
<!-- fig-* product tags (optional, owner-only, no AI) -->
<!-- Features = feat-* product tags (optional; Call 1 out — no AI badge) -->
<div class="field">
<label class="lbl">fig-* tags <span class="sug">optional</span></label>
<label class="lbl">Features <span class="sug">optional</span></label>
<MultiSelect
label="fig-* tags"
options={figTagOpts}
selected={figTagIds}
placeholder="Choose fig-* tags"
testid="fig-tag"
onToggle={(id) => (figTagIds = toggleMulti(figTagIds, id))}
on:commit={commitReview}
/>
</div>
<!-- feat-* product tags (optional, AI-assisted by Call 1) -->
<div class="field">
<label class="lbl">feat-* tags <span class="sug">optional</span> <span class="ai">AI assist</span></label>
<MultiSelect
label="feat-* tags"
label="Features"
options={featTagOptsView}
selected={featTagIds}
placeholder="Choose feat-* tags"
placeholder="Choose features"
testid="feat-tag"
onToggle={(id) => (featTagIds = toggleMulti(featTagIds, id))}
onCommit={commitReview}
on:commit={commitReview}
/>
</div>
@@ -811,7 +846,7 @@
<div class="field">
<label class="lbl" for="moisture">Moisture</label>
<select id="moisture" class="sel" data-testid="moisture" bind:value={moistureId} on:change={commitReview}>
<select id="moisture" class="sel" data-testid="moisture" bind:this={moistureEl} bind:value={moistureSel} on:change={commitReview}>
<option value="">— select —</option>
{#each moistureOpts as o (o.id)}<option value={o.id}>{o.name}</option>{/each}
</select>
@@ -821,8 +856,8 @@
<div class="field">
<label class="lbl" for="title">Title</label>
<div class="create-row">
<input id="title" class="inp" data-testid="title" bind:value={title} />
<button type="button" class="create-btn" data-testid="create-title"
<input id="title" class="inp" data-testid="title" bind:value={title} on:input={scheduleFieldCommit} />
<button type="button" class="btn-sec" data-testid="create-title"
disabled={!canCreateTitle || createBusy} on:click={createTitle}>
{createBusy ? 'Building…' : 'Create title'}
</button>
@@ -831,8 +866,8 @@
<div class="field">
<label class="lbl" for="short-desc">Short description</label>
<div class="create-row">
<input id="short-desc" class="inp" data-testid="short-desc" bind:value={shortDescription} />
<button type="button" class="create-btn" data-testid="create-short-desc"
<input id="short-desc" class="inp" data-testid="short-desc" bind:value={shortDescription} on:input={scheduleFieldCommit} />
<button type="button" class="btn-sec" data-testid="create-short-desc"
disabled={!canCreateTitle || createBusy} on:click={createTitle}>
{createBusy ? 'Building…' : 'Create short desc'}
</button>
@@ -840,12 +875,60 @@
</div>
<div class="field">
<label class="lbl" for="desc">Description</label>
<textarea id="desc" class="inp ta" data-testid="desc" rows="3" bind:value={description} on:blur={commitDescription}></textarea>
{#if canCreateDesc}
<div class="gen">
<button type="button" class="gen-btn" data-testid="create-desc" on:click={generateContent} disabled={genBusy}>
{genBusy ? 'Writing…' : 'Create desc'}
</button>
<textarea id="desc" class="inp ta" data-testid="desc" rows="3" bind:value={description} on:input={scheduleDescCommit} on:blur={commitDescription}></textarea>
<div class="gen">
<button
type="button"
class="btn-sec"
data-testid="create-desc"
on:click={generateContent}
disabled={!canCreateDesc || genBusy}
title={canCreateDesc ? 'Generate short + long description (Call 2)' : `Missing: ${createDescMissing.join(', ')}`}
>
{genBusy ? 'Writing…' : 'Create desc'}
</button>
<button
type="button"
class="btn-sec"
data-testid="view-prompt"
on:click={() => { if (viewPromptOpen) viewPromptOpen = false; else void loadViewPrompt(); }}
disabled={viewPromptBusy}
>
{viewPromptBusy ? 'Loading…' : (viewPromptOpen ? 'Hide prompt' : 'View prompt')}
</button>
</div>
{#if !canCreateDesc}
<p class="nudge" data-testid="create-desc-missing">
Create desc needs: {createDescMissing.join(', ')}
</p>
{/if}
{#if viewPromptOpen}
<div class="prompt-panel" data-testid="view-prompt-panel">
<div class="prompt-head">
<span class="prompt-title">Call 2 prompt (read-only)</span>
<button type="button" class="mini" data-testid="copy-prompt" on:click={copyViewPrompt} disabled={!viewPrompt?.text}>
{viewPromptCopied ? 'Copied' : 'Copy text'}
</button>
<button type="button" class="mini" data-testid="close-prompt" on:click={() => (viewPromptOpen = false)}>Close</button>
</div>
{#if viewPromptError}<p class="nudge" role="alert">{viewPromptError}</p>{/if}
{#if viewPrompt}
<pre class="prompt-text" data-testid="prompt-text">{viewPrompt.text}</pre>
<div class="prompt-photos" data-testid="prompt-photos">
<div class="prompt-photos-lbl">Photo slots (no base64) — up to 8 processed PNGs</div>
{#if viewPrompt.photos.length === 0}
<p class="nudge">No processed photos on this slab.</p>
{:else}
<ul>
{#each viewPrompt.photos as ph (ph.id)}
<li data-testid="prompt-photo-slot">
seq={ph.seq} · id={ph.id} · {ph.bytes} bytes · long-edge {ph.long_edge_px ?? '?'}px
</li>
{/each}
</ul>
{/if}
</div>
{/if}
</div>
{/if}
{#if genError}<p class="nudge" role="alert">{genError}</p>{/if}
@@ -857,11 +940,11 @@
<label class="lbl" for="price">Price ($)
{#if review?.price_suggestion != null}<span class="sug">suggested ${review.price_suggestion.toFixed(2)}</span>{/if}
</label>
<input id="price" class="inp" data-testid="price" type="number" step="0.01" bind:value={price} on:change={commitReview} />
<input id="price" class="inp" data-testid="price" type="number" step="0.01" bind:value={price} on:input={scheduleFieldCommit} on:change={commitReview} />
</div>
<div class="field">
<label class="lbl" for="sku">SKU <span class="sug">last field, 3–24 chars</span></label>
<input id="sku" class="inp" data-testid="sku" placeholder="SLAB-…" bind:value={sku} on:blur={commitReview} />
<input id="sku" class="inp" data-testid="sku" placeholder="SLAB-…" bind:value={sku} on:input={scheduleFieldCommit} on:blur={commitReview} />
{#if sku && !/^[A-Za-z0-9-]{3,24}$/.test(sku)}
<p class="nudge">SKU must be 3–24 alphanumerics or hyphens.</p>
{/if}
@@ -901,16 +984,11 @@
{/if}
<style>
.review { max-width: 560px; margin: 0 auto; color: #eee; }
.review { max-width: 560px; margin: 0 auto; color: #eee; padding: 4px 4px 20px; box-sizing: border-box; }
h1 { font-size: 1.3rem; margin: 0 0 12px; }
.math div { display: flex; justify-content: space-between; border-bottom: 1px solid #2a2a2a; padding: 5px 0; font-size: .85rem; }
.math dt { color: #9a9a9a; margin: 0; } .math dd { margin: 0; font-variant-numeric: tabular-nums; }
.aibar { margin: 12px 0; }
.ai-btn { padding: 8px 12px; border: 0; border-radius: 3px; background: #00e5ff; color: #002; font-weight: 600; font-size: .85rem; }
.ai-btn:disabled { background: #333; color: #777; }
.ai-note { color: #aaa; font-size: .78rem; margin: 6px 0 0; }
.lbl { display: block; font-size: .8rem; color: #bbb; margin: 0 0 4px; }
.lbl .ai { margin-left: 6px; font-size: .58rem; font-weight: 700; padding: 1px 5px; border-radius: 2px; background: #00e5ff; color: #002; }
/* box-sizing: the app has no global reset, so a width:100% control with padding
and a border pokes out of its container by that padding+border (20px here) —
which becomes real page overflow whenever the column sits at the viewport edge
@@ -921,15 +999,25 @@
.field { margin-bottom: 12px; }
.nudge { color: #e8a; font-size: .74rem; margin: 4px 0 0; }
.sug { color: #aaa; font-size: .74rem; font-weight: 400; margin-left: 6px; }
.gen { margin-top: 8px; } .gen-btn { padding: 8px 12px; border: 0; border-radius: 3px; background: #00e5ff; color: #002; font-weight: 600; font-size: .82rem; }
/* Lock chrome matches agreed #90 wireframe: muted dark green, not jarring neon. */
/* #89: explicit "Create" build buttons beside the title / short-desc fields. */
.gen { margin-top: 8px; display: flex; flex-wrap: wrap; gap: 8px; }
/* Unified secondary actions: Create title/short/desc, View prompt — same size & label weight. */
.create-row { display: flex; gap: 8px; align-items: stretch; }
.create-row .inp { flex: 1; }
.create-btn { box-sizing: border-box; padding: 8px 12px; border: 1px solid #333; border-radius: 3px;
.btn-sec { box-sizing: border-box; padding: 9px 12px; border: 1px solid #444; border-radius: 3px;
background: #1d1d1d; color: #eee; font-size: .82rem; font-weight: 600; cursor: pointer; white-space: nowrap; }
.create-btn:hover { background: #242424; }
.create-btn:disabled { background: #222; color: #666; border-color: #333; cursor: not-allowed; }
.btn-sec:hover { background: #242424; }
.btn-sec:disabled { background: #222; color: #666; border-color: #333; cursor: not-allowed; }
.prompt-panel { margin-top: 10px; border: 1px solid #333; border-radius: 3px; background: #141414; padding: 10px 12px; }
.prompt-head { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin-bottom: 8px; }
.prompt-title { font-size: .78rem; color: #9a9a9a; flex: 1; min-width: 120px; }
.mini { box-sizing: border-box; padding: 6px 10px; border: 1px solid #444; border-radius: 3px;
background: #1d1d1d; color: #ccc; font-size: .74rem; font-weight: 600; cursor: pointer; }
.mini:disabled { color: #666; cursor: not-allowed; }
.prompt-text { margin: 0; padding: 10px; background: #0f0f0f; border: 1px solid #2a2a2a; border-radius: 3px;
color: #ddd; font-size: .72rem; line-height: 1.45; white-space: pre-wrap; word-break: break-word; max-height: 280px; overflow: auto; }
.prompt-photos { margin-top: 10px; }
.prompt-photos-lbl { font-size: .72rem; color: #888; margin-bottom: 4px; }
.prompt-photos ul { margin: 0; padding-left: 18px; color: #aaa; font-size: .72rem; }
/* Primary wood category — inline under wood MultiSelect (REVIEW-15 / #90 wireframe). */
.primary-set { margin-top: 8px; padding: 10px 12px; border: 1px solid #333; border-radius: 3px; background: #141414; }
@@ -943,15 +1031,14 @@
.ps-opt.on .ps-radio { border-color: #00e5ff; }
.ps-opt.on .ps-radio::after { content: ''; position: absolute; inset: 2px; border-radius: 50%; background: #00e5ff; }
.ps-name { flex: 1; }
.ps-tag { font-size: .56rem; font-weight: 700; padding: 0 4px; border-radius: 2px; background: #00e5ff; color: #002; letter-spacing: .3px; }
.ps-note { font-size: .7rem; color: #777; margin: 6px 0 0; line-height: 1.4; }
.status { margin-top: 14px; font-weight: 700; padding: 10px 12px; border-radius: 3px; background: #2a1512; color: #ffb4a2; font-size: .9rem; }
.status.ok { background: #122a12; color: #7fd37f; }
.missing { color: #999; font-size: .76rem; margin: 6px 0 0; }
.row { display: flex; gap: 10px; margin-top: 16px; }
.primary { flex: 1; padding: 14px; font-size: 1rem; border-radius: 3px; border: 0; background: #00e5ff; color: #002; font-weight: 700; }
.primary:disabled { background: #333; color: #777; }
.ghost { padding: 14px 18px; border-radius: 3px; border: 1px solid #444; background: transparent; color: #ccc; }
.primary { flex: 1; padding: 14px; font-size: 1rem; border-radius: 3px; border: 0; background: #00e5ff; color: #002; font-weight: 700; cursor: pointer; }
.primary:disabled { background: #333; color: #777; cursor: not-allowed; }
.ghost { box-sizing: border-box; padding: 14px 18px; border-radius: 3px; border: 1px solid #444; background: transparent; color: #ccc; font-size: .9rem; font-weight: 600; cursor: pointer; }
.err { color: #e88; } .busy { color: #aaa; }
/* #90 Photo strip */
@@ -0,0 +1,72 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import {
canCreateDesc,
missingCreateDesc,
missingCreateDescLabels,
} from './createDescGate';
const full = {
contentLlmEnabled: true,
title: 'Figured Walnut Live Edge Slab',
speciesId: 11,
woodCategoryIds: [21],
primaryCatId: 21,
edgeTermId: '321',
figureTermIds: [328],
gradeTermId: 342,
moistureTermId: '326',
};
test('Create desc gate: full set passes; does not require short or description', () => {
assert.equal(canCreateDesc(full), true);
assert.deepEqual(missingCreateDesc(full), []);
});
test('Create desc gate: empty price still passes when other fields full', () => {
// Price is publish-only; Create desc must not gate on it.
assert.equal(canCreateDesc(full), true);
assert.deepEqual(missingCreateDesc(full), []);
});
test('Create desc gate: lists each missing field; no short/description/price key', () => {
const miss = missingCreateDesc({
...full,
contentLlmEnabled: false,
title: '',
speciesId: null,
woodCategoryIds: [],
primaryCatId: '',
edgeTermId: '',
figureTermIds: [],
gradeTermId: '',
moistureTermId: '',
});
assert.deepEqual(miss, [
'content_llm',
'title',
'species',
'wood_categories',
'primary',
'edge',
'figure',
'grade',
'moisture',
]);
assert.equal(miss.includes('short_description'), false);
assert.equal(miss.includes('description'), false);
assert.equal(miss.includes('price'), false);
const labels = missingCreateDescLabels({
...full,
figureTermIds: [],
});
assert.ok(labels.includes('≥1 figure'));
assert.equal(labels.includes('price'), false);
});
test('Create desc gate: recomputes on field change (reactive-ready)', () => {
assert.equal(canCreateDesc({ ...full, figureTermIds: [] }), false);
assert.equal(canCreateDesc({ ...full, figureTermIds: [1] }), true);
assert.equal(canCreateDesc({ ...full, moistureTermId: '' }), false);
assert.equal(canCreateDesc({ ...full, moistureTermId: 326 }), true);
});
@@ -0,0 +1,59 @@
/**
* Create-desc gate (Call 2): disable Create desc unless these are present.
* Does NOT require short_description, description, or price — Call 2 writes
* short + long; price stays publish-only.
*/
export type CreateDescInput = {
contentLlmEnabled: boolean;
title: string;
speciesId: number | null | undefined;
woodCategoryIds: number[];
primaryCatId: number | '' | null | undefined;
edgeTermId: number | string | '' | null | undefined;
figureTermIds: number[];
gradeTermId: number | string | '' | null | undefined;
moistureTermId: number | string | '' | null | undefined;
};
/** Human labels shown under Create desc when the gate fails. */
export const CREATE_DESC_LABELS: Record<string, string> = {
content_llm: 'content LLM on',
title: 'title',
species: 'species',
wood_categories: '≥1 wood category',
primary: 'primary wood category',
edge: 'edge type',
figure: '≥1 figure',
grade: 'grade',
moisture: 'moisture',
};
function presentId(v: number | string | '' | null | undefined): boolean {
if (v === null || v === undefined || v === '') return false;
if (typeof v === 'number') return Number.isFinite(v);
return String(v).trim() !== '';
}
/** Return missing field keys (stable order). Empty ⇒ Create desc enabled. */
export function missingCreateDesc(input: CreateDescInput): string[] {
const missing: string[] = [];
if (!input.contentLlmEnabled) missing.push('content_llm');
if (!String(input.title ?? '').trim()) missing.push('title');
if (input.speciesId == null || !Number.isFinite(Number(input.speciesId))) missing.push('species');
if (!input.woodCategoryIds?.length) missing.push('wood_categories');
if (!presentId(input.primaryCatId)) missing.push('primary');
if (!presentId(input.edgeTermId)) missing.push('edge');
if (!input.figureTermIds?.length) missing.push('figure');
if (!presentId(input.gradeTermId)) missing.push('grade');
if (!presentId(input.moistureTermId)) missing.push('moisture');
return missing;
}
export function canCreateDesc(input: CreateDescInput): boolean {
return missingCreateDesc(input).length === 0;
}
export function missingCreateDescLabels(input: CreateDescInput): string[] {
return missingCreateDesc(input).map((k) => CREATE_DESC_LABELS[k] ?? k);
}
@@ -0,0 +1,42 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import {
deriveFigTagIds,
normName,
scopedFeatTags,
scopedFigTags,
} from './figTags';
test('scoped tags require hyphenated slug prefix (not bare fig/feat)', () => {
const tags = [
{ id: 1, name: 'Curl', slug: 'fig-curl' },
{ id: 2, name: 'Knot', slug: 'feat-knot' },
{ id: 3, name: 'Figured', slug: 'figured' }, // no hyphen — out
{ id: 4, name: 'Featured', slug: 'featured' },
{ id: 5, name: 'fig-fake-name', slug: 'other' },
];
assert.deepEqual(scopedFigTags(tags).map((t) => t.id), [1]);
assert.deepEqual(scopedFeatTags(tags).map((t) => t.id), [2]);
});
test('deriveFigTagIds: casefold/strip name equality; Curl≠Curly', () => {
const terms = [
{ id: 10, name: 'Curl' },
{ id: 11, name: ' Birdseye ' },
];
const tags = [
{ id: 100, name: 'curl', slug: 'fig-curl' },
{ id: 101, name: 'Curly', slug: 'fig-curly' },
{ id: 102, name: 'Birdseye', slug: 'fig-birdseye' },
{ id: 103, name: 'Knot', slug: 'feat-knot' },
];
assert.deepEqual(deriveFigTagIds([10], terms, tags), [100]);
assert.deepEqual(deriveFigTagIds([10, 11], terms, tags).sort(), [100, 102]);
// Curly tag stays unmatched when only Curl is selected
assert.ok(!deriveFigTagIds([10], terms, tags).includes(101));
assert.deepEqual(deriveFigTagIds([], terms, tags), []);
});
test('normName strips and lowercases', () => {
assert.equal(normName(' Curl '), 'curl');
});
+33
View File
@@ -0,0 +1,33 @@
/**
* Derive fig-* product tag ids from selected Figure attribute terms.
* Match is case-insensitive name equality (casefold/strip) against tags whose
* slug starts with `fig-`. No hardcoded name bridges (Curl ≠ Curly).
*/
export function normName(name: string): string {
// JS has no String.casefold; toLowerCase covers ASCII wood-term names.
return name.trim().toLowerCase();
}
export function scopedFigTags<T extends { slug: string }>(tags: T[]): T[] {
return tags.filter((t) => t.slug.startsWith('fig-'));
}
export function scopedFeatTags<T extends { slug: string }>(tags: T[]): T[] {
return tags.filter((t) => t.slug.startsWith('feat-'));
}
export function deriveFigTagIds(
figureTermIds: number[],
figureTerms: { id: number; name: string }[],
tags: { id: number; name: string; slug: string }[],
): number[] {
const selected = new Set(
figureTerms
.filter((t) => figureTermIds.includes(t.id))
.map((t) => normName(t.name)),
);
if (selected.size === 0) return [];
return scopedFigTags(tags)
.filter((t) => selected.has(normName(t.name)))
.map((t) => t.id);
}
@@ -0,0 +1,17 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { applyMoistureDefault, resolveKilnDriedId } from './moistureDefault';
const terms = [
{ id: 1, name: 'Air dried', slug: 'ad' },
{ id: 326, name: 'Kiln dried', slug: 'kd' },
];
test('moisture default: slug kd wins; name match fallback; scoped empty stays empty', () => {
assert.equal(resolveKilnDriedId(terms), 326);
assert.equal(resolveKilnDriedId([{ id: 9, name: 'Kiln dried', slug: 'kiln-dried' }]), 9);
assert.equal(resolveKilnDriedId([{ id: 1, name: 'Air dried', slug: 'ad' }]), null);
assert.equal(applyMoistureDefault('', terms), 326);
assert.equal(applyMoistureDefault(null, terms), 326);
assert.equal(applyMoistureDefault(1, terms), 1);
});
@@ -0,0 +1,28 @@
/**
* Moisture default: Kiln dried (slug `kd` / name match). METADATA-15.
* Resolve by synced taxonomy — never a hardcoded Woo term id.
*/
export type MoistureTerm = { id: number; name: string; slug: string };
export function resolveKilnDriedId(terms: MoistureTerm[]): number | null {
const bySlug = terms.find((t) => t.slug.toLowerCase() === 'kd');
if (bySlug) return bySlug.id;
const byName = terms.find((t) => {
const n = t.name.trim().toLowerCase();
return n === 'kiln dried' || n === 'kiln-dried';
});
return byName ? byName.id : null;
}
/** If moisture is empty, return kiln-dried id; else keep current. */
export function applyMoistureDefault(
current: number | '' | null | undefined,
terms: MoistureTerm[],
): number | '' {
if (current !== null && current !== undefined && current !== '') {
return typeof current === 'number' ? current : Number(current) || '';
}
const kd = resolveKilnDriedId(terms);
return kd == null ? '' : kd;
}
@@ -0,0 +1,58 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { test } from 'node:test';
const dir = dirname(fileURLToPath(import.meta.url));
const reviewSrc = readFileSync(join(dir, 'Review.svelte'), 'utf8');
const figSrc = readFileSync(join(dir, 'figTags.ts'), 'utf8');
test('Review UI: no AI assist labels or AI badge chrome', () => {
assert.equal(reviewSrc.includes('AI assist'), false);
assert.equal(reviewSrc.includes('class="ai"'), false);
assert.equal(reviewSrc.includes('ps-tag'), false);
});
test('Review UI: no fig-* MultiSelect picker', () => {
assert.equal(reviewSrc.includes('data-testid="fig-tag"'), false);
assert.equal(reviewSrc.includes('figTagOpts'), false);
assert.equal(reviewSrc.includes('label="fig-* tags"'), false);
assert.equal(reviewSrc.includes('placeholder="Choose fig-* tags"'), false);
assert.match(reviewSrc, /deriveFigTagIds/);
assert.match(reviewSrc, /Features/);
assert.match(reviewSrc, /testid="feat-tag"/);
});
test('Tag family scope uses hyphenated slug prefix', () => {
assert.match(figSrc, /startsWith\('fig-'\)/);
assert.match(figSrc, /startsWith\('feat-'\)/);
assert.equal(/startsWith\('fig'\)/.test(figSrc), false);
assert.equal(/startsWith\('feat'\)/.test(figSrc), false);
assert.equal(/startsWith\('fig'\)/.test(reviewSrc), false);
assert.equal(/startsWith\('feat'\)/.test(reviewSrc), false);
});
test('Review UI: View prompt + Create desc gate wiring', () => {
assert.match(reviewSrc, /data-testid="view-prompt"/);
assert.match(reviewSrc, /getCall2Prompt/);
assert.match(reviewSrc, /createDescMissing/);
assert.match(reviewSrc, /missingCreateDescLabels/);
assert.match(reviewSrc, /orderWoodCatGroups/);
assert.match(reviewSrc, /applyMoistureDefault/);
assert.match(reviewSrc, /onCommit=\{commitReview\}/);
// Description is not part of the Create-desc gate label list
assert.equal(reviewSrc.includes("Create desc needs: description"), false);
});
test('Review UI: live field commit + unified secondary buttons + moisture default', () => {
assert.match(reviewSrc, /scheduleFieldCommit/);
assert.match(reviewSrc, /scheduleDescCommit/);
assert.match(reviewSrc, /class="btn-sec"/);
assert.match(reviewSrc, /applyMoistureDefault/);
assert.match(reviewSrc, /void syncSelects\(\)/);
// No leftover white unstyled view-prompt class
assert.equal(reviewSrc.includes('view-prompt-btn'), false);
assert.equal(reviewSrc.includes('create-btn'), false);
});
@@ -0,0 +1,23 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { canCreateTitleFromSpecies, effectiveSpeciesId } from './speciesFromCapture';
test('effectiveSpeciesId prefers slab over capture', () => {
assert.equal(effectiveSpeciesId(281, 11), 281);
assert.equal(effectiveSpeciesId(null, 11), 11);
assert.equal(effectiveSpeciesId(undefined, 11), 11);
assert.equal(effectiveSpeciesId(null, null), null);
assert.equal(effectiveSpeciesId(undefined, undefined), null);
});
test('effectiveSpeciesId ignores non-finite values', () => {
assert.equal(effectiveSpeciesId(Number.NaN, 11), 11);
assert.equal(effectiveSpeciesId(null, Number.NaN), null);
});
test('canCreateTitleFromSpecies requires species + ≥1 wood category', () => {
assert.equal(canCreateTitleFromSpecies(281, 1), true);
assert.equal(canCreateTitleFromSpecies(281, 0), false);
assert.equal(canCreateTitleFromSpecies(null, 2), false);
assert.equal(canCreateTitleFromSpecies(null, 0), false);
});
@@ -0,0 +1,26 @@
/** Resolve Woo species leaf for Review Create-title gate + PUTs.
*
* Species is chosen on Measure and stored on the slab at createSlab. Review has
* no picker; Create title/short-desc read species from the slab (Capture
* handoff is a repair fallback when create omitted it).
*/
export function effectiveSpeciesId(
slabSpeciesId: number | null | undefined,
captureSpeciesId: number | null | undefined,
): number | null {
if (slabSpeciesId != null && Number.isFinite(Number(slabSpeciesId))) {
return Math.round(Number(slabSpeciesId));
}
if (captureSpeciesId != null && Number.isFinite(Number(captureSpeciesId))) {
return Math.round(Number(captureSpeciesId));
}
return null;
}
export function canCreateTitleFromSpecies(
speciesId: number | null,
woodCategoryCount: number,
): boolean {
return speciesId != null && woodCategoryCount > 0;
}
@@ -0,0 +1,28 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { orderWoodCatGroups, PIN_LEAF_SLUG, PIN_PARENT_SLUG } from './woodCatOrder';
test('wood cat UI: snl parent first; liveedge leaf first under its parent', () => {
const groups = orderWoodCatGroups([
{
parent: { id: 2, name: 'Turning', slug: 'turn' },
leaves: [
{ id: 20, name: 'Bowl blanks', slug: 'bowl' },
{ id: 21, name: 'Live edge wood', slug: PIN_LEAF_SLUG },
],
},
{
parent: { id: 1, name: 'Slabs & Lumber', slug: PIN_PARENT_SLUG },
leaves: [
{ id: 11, name: 'Dimensional', slug: 'dim' },
{ id: 10, name: 'Live edge wood', slug: PIN_LEAF_SLUG },
],
},
]);
assert.equal(groups[0].parent.slug, PIN_PARENT_SLUG);
assert.equal(groups[0].leaves[0].slug, PIN_LEAF_SLUG);
assert.equal(groups[0].leaves[1].slug, 'dim');
assert.equal(groups[1].parent.slug, 'turn');
// liveedge also pinned first under Turning when present
assert.equal(groups[1].leaves[0].slug, PIN_LEAF_SLUG);
});
@@ -0,0 +1,33 @@
/**
* UI-only wood-category display order on Review.
* Pin parent Slabs & Lumber (`snl`) first; pin leaf Live edge wood (`liveedge`)
* first under its parent. Never changes assignment ids — sort only.
*/
export const PIN_PARENT_SLUG = 'snl';
export const PIN_LEAF_SLUG = 'liveedge';
export type OrderedLeaf = { id: number; name: string; slug?: string; ai?: boolean };
export type OrderedGroup = {
parent: { id: number; name: string; slug?: string };
leaves: OrderedLeaf[];
};
function pinFirst<T>(items: T[], isPinned: (item: T) => boolean): T[] {
const pinned: T[] = [];
const rest: T[] = [];
for (const it of items) {
if (isPinned(it)) pinned.push(it);
else rest.push(it);
}
return [...pinned, ...rest];
}
/** Sort groups + leaves for the wood-category MultiSelect. */
export function orderWoodCatGroups<T extends OrderedGroup>(groups: T[]): T[] {
const withLeaves = groups.map((g) => ({
...g,
leaves: pinFirst(g.leaves, (l) => (l.slug ?? '') === PIN_LEAF_SLUG),
})) as T[];
return pinFirst(withLeaves, (g) => (g.parent.slug ?? '') === PIN_PARENT_SLUG);
}
+2 -11
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import { decodeName } from '../decodeName';
import { onMount } from 'svelte';
import { speciesLeavesFromTaxonomy, type TaxonomyCategory } from './speciesLeaves';
@@ -52,16 +53,6 @@
const secretValue = (s: Secret): string => (isUnmasked(s) ? (s as string) : '');
const secretConfigured = (s: Secret): boolean => (isUnmasked(s) ? (s as string).length > 0 : s.configured);
function decodeName(name: string): string {
// Woo sometimes returns HTML entities in category names.
return name
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'");
}
async function loadRates() {
ratesLoading = true;
ratesMessage = '';
@@ -326,7 +317,7 @@
</div>
<div>
<label><input type="checkbox" bind:checked={form.inference_enabled} /> Inference Enabled (default false)</label>
<label><input type="checkbox" bind:checked={form.inference_enabled} /> Inference Enabled (Call 1 — DISABLED on this branch; toggle kept for settings round-trip)</label>
</div>
<div>
+2
View File
@@ -115,6 +115,8 @@ export function orientedRect(prev: BBox, angleDeg: number): BBox {
// Project every slab pixel onto the unit vector (ux, uy); return max − min
// extent in px. 0 when the mask is empty.
// #99: demoted to an optional sanity check — declared L comes from tape tick
// distance in image space, not this projected guess.
export function maskSpanAlong(bitset: Uint8Array, w: number, h: number, ux: number, uy: number): number {
let min = Infinity, max = -Infinity;
for (let y = 0; y < h; y++) {
@@ -0,0 +1,68 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { keepLargestComponent } from './largestComponent.ts';
test('keepLargestComponent: two blobs → only larger remains', () => {
// 10×6 grid: large 4×3 block (12 px) at left, small 2×2 (4 px) at right
const W = 10, H = 6;
const bitset = new Uint8Array(W * H);
for (let y = 1; y <= 3; y++)
for (let x = 1; x <= 4; x++) bitset[y * W + x] = 1;
for (let y = 2; y <= 3; y++)
for (let x = 7; x <= 8; x++) bitset[y * W + x] = 1;
const before = bitset.reduce((a, c) => a + c, 0);
assert.equal(before, 16);
const slabPx = keepLargestComponent(bitset, W, H);
assert.equal(slabPx, 12, 'slabPx is pixel count of largest blob');
// Large blob intact
for (let y = 1; y <= 3; y++)
for (let x = 1; x <= 4; x++)
assert.equal(bitset[y * W + x], 1, `large (${x},${y}) kept`);
// Small blob zeroed
for (let y = 2; y <= 3; y++)
for (let x = 7; x <= 8; x++)
assert.equal(bitset[y * W + x], 0, `small (${x},${y}) removed`);
assert.equal(bitset.reduce((a, c) => a + c, 0), 12);
});
test('keepLargestComponent: empty bitset → 0', () => {
const bitset = new Uint8Array(4 * 4);
assert.equal(keepLargestComponent(bitset, 4, 4), 0);
});
test('keepLargestComponent: single blob unchanged', () => {
const W = 5, H = 5;
const bitset = new Uint8Array(W * H);
for (let y = 1; y <= 3; y++)
for (let x = 1; x <= 3; x++) bitset[y * W + x] = 1;
const slabPx = keepLargestComponent(bitset, W, H);
assert.equal(slabPx, 9);
assert.equal(bitset.reduce((a, c) => a + c, 0), 9);
});
test('keepLargestComponent: 4-connected — diagonal touch is two components', () => {
// Two pixels only touching diagonally; larger is the two-pixel L on the left
// of a separate single pixel that is diagonal to nothing of equal size...
// Layout: three pixels as an L (size 3) and one diagonal fleck (size 1)
// . # .
// # # .
// . . #
const W = 3, H = 3;
const bitset = new Uint8Array(W * H);
bitset[0 * W + 1] = 1;
bitset[1 * W + 0] = 1;
bitset[1 * W + 1] = 1;
bitset[2 * W + 2] = 1; // diagonal-only to the L
const slabPx = keepLargestComponent(bitset, W, H);
assert.equal(slabPx, 3);
assert.equal(bitset[2 * W + 2], 0, 'diagonal fleck removed under 4-conn');
assert.equal(bitset[0 * W + 1], 1);
assert.equal(bitset[1 * W + 0], 1);
assert.equal(bitset[1 * W + 1], 1);
});
+80
View File
@@ -0,0 +1,80 @@
/**
* Keep only the single largest connected component in a slab bitset.
* Sheet gaps can leave stray chunks after sheet removal; those must not
* affect bbox / slabBounds / slabPx.
*
* Connectivity: 4-connected (N/E/S/W). Mutates bitset in place; returns
* remaining slab pixel count.
*/
export function keepLargestComponent(
bitset: Uint8Array,
w: number,
h: number,
): number {
const n = w * h;
if (n === 0) return 0;
// labels[i] = component id (1-based), 0 = background / unvisited
const labels = new Int32Array(n);
const sizes: number[] = [0]; // sizes[id]
let nextId = 0;
const qx = new Int32Array(n);
const qy = new Int32Array(n);
for (let y = 0; y < h; y++) {
const row = y * w;
for (let x = 0; x < w; x++) {
const i = row + x;
if (!bitset[i] || labels[i]) continue;
nextId++;
let size = 0;
let qh = 0, qt = 0;
qx[qt] = x;
qy[qt] = y;
qt++;
labels[i] = nextId;
while (qh < qt) {
const cx = qx[qh];
const cy = qy[qh];
qh++;
size++;
// 4-connected neighbors
const nbrs: [number, number][] = [
[cx - 1, cy],
[cx + 1, cy],
[cx, cy - 1],
[cx, cy + 1],
];
for (const [nx, ny] of nbrs) {
if (nx < 0 || ny < 0 || nx >= w || ny >= h) continue;
const ni = ny * w + nx;
if (!bitset[ni] || labels[ni]) continue;
labels[ni] = nextId;
qx[qt] = nx;
qy[qt] = ny;
qt++;
}
}
sizes[nextId] = size;
}
}
if (nextId === 0) return 0;
let bestId = 1;
let bestSize = sizes[1];
for (let id = 2; id <= nextId; id++) {
if (sizes[id] > bestSize) {
bestSize = sizes[id];
bestId = id;
}
}
if (nextId === 1) return bestSize;
for (let i = 0; i < n; i++) {
if (bitset[i] && labels[i] !== bestId) bitset[i] = 0;
}
return bestSize;
}
+18
View File
@@ -0,0 +1,18 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { minAreaRect, orientedRect } from '../geometry/rect.ts';
import { measure } from './measure.ts';
test('#99: tape span drives scale, not maskSpanAlong', () => {
const W = 480, H = 100;
const bitset = new Uint8Array(W * H).fill(1);
const box = orientedRect(minAreaRect(bitset, W, H)!, 0);
// Declared L = 48"; tape span = 240 px → 5 px/in (not the full 480 mask span).
const m = measure(bitset, W, H, box, 384, 8, W * H, {
start: [100, 50],
ux: 1,
uy: 0,
spanPx: 240,
});
assert.ok(Math.abs(m.scale - 5) < 1e-9, `scale ${m.scale}`);
});
+39 -5
View File
@@ -6,8 +6,19 @@ export function axisEndpoints(bbox: BBox): { start: [number, number]; end: [numb
return { start: bbox.corners[3], end: bbox.corners[0] };
}
export interface TapeAxis {
/** Image-space tape start (tick A). */
start: [number, number];
/** Image-space unit direction tick A → tick B. */
ux: number;
uy: number;
/** Image-space distance between ticks (owner-declared tape length in px). */
spanPx: number;
}
// Wood end→end along the approved direction: project the mask onto the axis
// unit vector and return the segment on the bbox center line covering that span.
// Used only as a fallback / sanity path when no tape ticks are present (#99).
function maskAxisSegment(
bitset: Uint8Array, w: number, h: number, bbox: BBox,
): { start: [number, number]; end: [number, number]; span: number; ux: number; uy: number } {
@@ -71,16 +82,38 @@ function sampleWidths(
return out;
}
/**
* @param tape When provided (#99), scale = tape.spanPx / L and width stations
* walk from the tick-A endpoint. maskSpanAlong is not used for scale.
*/
export function measure(
bitset: Uint8Array, w: number, h: number,
bbox: BBox,
lengthEighths: Eighths, thicknessEighths: Eighths,
slabPx: number,
): Omit<Measures, 'inventoryPng'> & { sourceBelowFloor: false } {
tape?: TapeAxis | null,
): Omit<Measures, 'inventoryPng'> & { sourceBelowBlock: false; sourceBelowWarn: false } {
const lengthIn = lengthEighths / 8;
const seg = maskAxisSegment(bitset, w, h, bbox);
const scale = (seg.span || maskSpanAlong(bitset, w, h, seg.ux, seg.uy)) / lengthIn;
const every6In = sampleWidths(bitset, w, h, seg.start, seg.ux, seg.uy, scale, lengthIn);
let start: [number, number];
let ux: number;
let uy: number;
let scale: number;
if (tape && tape.spanPx > 0 && lengthIn > 0) {
start = tape.start;
ux = tape.ux;
uy = tape.uy;
scale = tape.spanPx / lengthIn;
// Optional sanity: maskSpanAlong remains available for diagnostics only.
void maskSpanAlong;
} else {
const seg = maskAxisSegment(bitset, w, h, bbox);
// Legacy fallback when no tape ticks — keep prior behaviour for old drafts.
scale = (seg.span || maskSpanAlong(bitset, w, h, seg.ux, seg.uy)) / lengthIn;
start = seg.start;
ux = seg.ux;
uy = seg.uy;
}
const every6In = sampleWidths(bitset, w, h, start, ux, uy, scale, lengthIn);
let widths: Widths;
if (every6In.length > 0) {
const min = Math.min(...every6In);
@@ -99,6 +132,7 @@ export function measure(
widths,
sqft,
bdft,
sourceBelowFloor: false,
sourceBelowBlock: false,
sourceBelowWarn: false,
};
}
+56
View File
@@ -0,0 +1,56 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { clampFill, cropMarginFraction, DEFAULT_CROP_FILL } from './crop.ts';
import { _resetCropFillForTests, getCropFill, setCropFill } from '../settings/cropFill.ts';
import { composeInventoryFrame } from './inventory.ts';
import type { Mask, Source } from '../capture/capture.types';
test('cropMarginFraction(0.9) === 0.05', () => {
assert.ok(Math.abs(cropMarginFraction(0.9) - 0.05) < 1e-12);
assert.equal(DEFAULT_CROP_FILL, 0.9);
});
test('clampFill bounds', () => {
assert.equal(clampFill(2), 1);
assert.equal(clampFill(0), 0.01);
assert.equal(clampFill(Number.NaN), DEFAULT_CROP_FILL);
});
test('getCropFill / setCropFill round-trip', () => {
_resetCropFillForTests();
assert.equal(getCropFill(), 0.9);
assert.equal(setCropFill(0.8), 0.8);
assert.equal(getCropFill(), 0.8);
_resetCropFillForTests();
});
test('composeInventoryFrame honors cropFill override (#99)', () => {
const source: Pick<Source, 'widthPx' | 'heightPx'> = { widthPx: 2200, heightPx: 2000 };
const bitset = new Uint8Array(source.widthPx * source.heightPx).fill(1);
const mask: Pick<Mask, 'bitset' | 'slabBounds'> = {
bitset,
slabBounds: { x: 100, y: 100, w: 2000, h: 1800 },
};
const rgba = new Uint8Array(source.widthPx * source.heightPx * 4).fill(255);
const out90 = composeInventoryFrame({ width: 2200, height: 2000, rgba }, mask, source, 0.9);
const out80 = composeInventoryFrame({ width: 2200, height: 2000, rgba }, mask, source, 0.8);
const extent = (frame: typeof out90) => {
let minx = frame.width, maxx = -1, miny = frame.height, maxy = -1;
for (let y = 0; y < frame.height; y++) {
for (let x = 0; x < frame.width; x++) {
if (frame.rgba[(y * frame.width + x) * 4 + 3] !== 0) {
if (x < minx) minx = x;
if (x > maxx) maxx = x;
if (y < miny) miny = y;
if (y > maxy) maxy = y;
}
}
}
return { w: maxx - minx + 1, h: maxy - miny + 1 };
};
const e90 = extent(out90);
const e80 = extent(out80);
assert.ok(Math.abs(e90.w - 720) <= 2, `90% w ${e90.w}`);
assert.ok(e80.w < e90.w - 10, `80% fill must be tighter than 90%: ${e80.w} vs ${e90.w}`);
});
+31
View File
@@ -0,0 +1,31 @@
/**
* Shared crop constants for inventory PNG + tape-line overlay (#99).
* Aspect is fixed 3:4; crop_fill only moves the long-axis end insets.
* Non-retro: changing fill affects future encodes only, not stored PNGs.
*
* Size floors (shorter side of slab bbox, source px):
* - FLOOR_WARN_PX (800): target frame shorter side when source allows;
* 300–799 → warn but still encode / include.
* - FLOOR_BLOCK_PX (300): hard fail / exclude below this.
*/
export const DEFAULT_CROP_FILL = 0.9;
export const ASPECT_W = 3;
export const ASPECT_H = 4;
/** Target inventory frame shorter side when source can supply it. */
export const FLOOR_WARN_PX = 800;
/** Hard-block threshold: shorter side below this fails / excludes the photo. */
export const FLOOR_BLOCK_PX = 300;
/** @deprecated Prefer FLOOR_WARN_PX; kept as alias for the target frame size. */
export const FLOOR_PX = FLOOR_WARN_PX;
/** Fraction of the long axis inset from each end: (1 - fill) / 2. */
export function cropMarginFraction(fill: number = DEFAULT_CROP_FILL): number {
const f = clampFill(fill);
return (1 - f) / 2;
}
export function clampFill(fill: number): number {
if (!Number.isFinite(fill)) return DEFAULT_CROP_FILL;
return Math.min(1, Math.max(0.01, fill));
}
+37 -7
View File
@@ -30,7 +30,8 @@ test('alpha contract: removed pixels transparent, slab pixels keep RGB at alpha
const source: Pick<Source, 'widthPx' | 'heightPx'> = { widthPx: W, heightPx: H };
const out = composeInventoryFrame(src, maskFor(bitset, { x: 1, y: 1, w: 4, h: 4 }), source);
assert.equal(out.sourceBelowFloor, true);
assert.equal(out.sourceBelowBlock, true, '4px slab is below block floor');
assert.equal(out.sourceBelowWarn, false);
assert.ok(out.width < 800, 'undersized source stays small');
let opaque = 0;
@@ -66,10 +67,11 @@ test('3:4 frame at the floor: 2000×1800 slab → 800×1067', () => {
);
assert.equal(out.width, 800);
assert.equal(out.height, 1067);
assert.equal(out.sourceBelowFloor, false);
assert.equal(out.sourceBelowBlock, false);
assert.equal(out.sourceBelowWarn, false);
});
test('mid-band: slab shorter side 1000 (between 800 and 1600) → at floor, not belowFloor', () => {
test('mid-band: slab shorter side 1000 (between 800 and 1600) → at floor, no warn/block', () => {
const source: Pick<Source, 'widthPx' | 'heightPx'> = { widthPx: 1200, heightPx: 1000 };
const bitset = new Uint8Array(source.widthPx * source.heightPx).fill(1);
const out = composeInventoryFrame(
@@ -77,10 +79,24 @@ test('mid-band: slab shorter side 1000 (between 800 and 1600) → at floor, not
maskFor(bitset, { x: 0, y: 0, w: 1200, h: 1000 }),
source,
);
assert.equal(out.sourceBelowFloor, false);
assert.equal(out.sourceBelowBlock, false);
assert.equal(out.sourceBelowWarn, false);
assert.equal(out.width, 800);
});
test('warn band: shorter side 500 (300–799) → sourceBelowWarn, encode allowed, no upscale past source', () => {
const source: Pick<Source, 'widthPx' | 'heightPx'> = { widthPx: 600, heightPx: 500 };
const bitset = new Uint8Array(source.widthPx * source.heightPx).fill(1);
const out = composeInventoryFrame(
makePhoto(source.widthPx, source.heightPx),
maskFor(bitset, { x: 0, y: 0, w: 600, h: 500 }),
source,
);
assert.equal(out.sourceBelowWarn, true);
assert.equal(out.sourceBelowBlock, false);
assert.ok(out.width <= 800, `outW ${out.width} must not exceed warn floor`);
});
test('90% fill: limiting dimension maps to 0.9 × frame side (#68)', () => {
const source: Pick<Source, 'widthPx' | 'heightPx'> = { widthPx: 2200, heightPx: 2000 };
const bitset = new Uint8Array(source.widthPx * source.heightPx).fill(1);
@@ -107,7 +123,7 @@ test('90% fill: limiting dimension maps to 0.9 × frame side (#68)', () => {
assert.ok(Math.abs(hExtent - 648) <= 2, `height extent ${hExtent} ≈ 648 (1800×0.36)`);
});
test('belowFloor: small slab → sourceBelowFloor, outW capped at floor (no upscale)', () => {
test('belowWarn (was belowFloor): 900×700 → warn not block, outW capped at warn floor', () => {
const source: Pick<Source, 'widthPx' | 'heightPx'> = { widthPx: 900, heightPx: 700 };
const bitset = new Uint8Array(source.widthPx * source.heightPx).fill(1);
const out = composeInventoryFrame(
@@ -115,6 +131,20 @@ test('belowFloor: small slab → sourceBelowFloor, outW capped at floor (no upsc
maskFor(bitset, { x: 0, y: 0, w: 900, h: 700 }),
source,
);
assert.equal(out.sourceBelowFloor, true);
assert.ok(out.width <= 800, `outW ${out.width} must not exceed the floor`);
assert.equal(out.sourceBelowWarn, true);
assert.equal(out.sourceBelowBlock, false);
assert.ok(out.width <= 800, `outW ${out.width} must not exceed the warn floor`);
});
test('belowBlock: shorter side 200 → sourceBelowBlock, no upscale', () => {
const source: Pick<Source, 'widthPx' | 'heightPx'> = { widthPx: 250, heightPx: 200 };
const bitset = new Uint8Array(source.widthPx * source.heightPx).fill(1);
const out = composeInventoryFrame(
makePhoto(source.widthPx, source.heightPx),
maskFor(bitset, { x: 0, y: 0, w: 250, h: 200 }),
source,
);
assert.equal(out.sourceBelowBlock, true);
assert.equal(out.sourceBelowWarn, false);
assert.ok(out.width <= 800);
});
+25 -11
View File
@@ -1,9 +1,6 @@
import type { Mask, Source } from '../capture/capture.types';
const FLOOR = 800; // shorter side, px
const FILL = 0.9; // 90% fill at the widest and tallest points of the slab
const ASPECT_W = 3;
const ASPECT_H = 4;
import { ASPECT_H, ASPECT_W, FLOOR_WARN_PX, FLOOR_BLOCK_PX, DEFAULT_CROP_FILL } from './crop';
import { getCropFill } from '../settings/cropFill';
type MaskPick = Pick<Mask, 'bitset' | 'slabBounds'>;
type SourcePick = Pick<Source, 'widthPx' | 'heightPx'>;
@@ -12,20 +9,27 @@ export interface InventoryInput {
bitmap: ImageBitmap;
mask: MaskPick;
source: SourcePick;
/** Optional override; default reads live settings.crop_fill (#99). */
cropFill?: number;
}
export interface Frame {
width: number;
height: number;
rgba: Uint8Array;
sourceBelowFloor: boolean;
/** Shorter side of slab bbox is below FLOOR_BLOCK_PX (hard fail). */
sourceBelowBlock: boolean;
/** Shorter side is in [FLOOR_BLOCK_PX, FLOOR_WARN_PX) — encode/include with warn. */
sourceBelowWarn: boolean;
}
export function composeInventoryFrame(
src: { width: number; height: number; rgba: Uint8Array | Uint8ClampedArray },
mask: MaskPick,
source: SourcePick,
cropFill: number = DEFAULT_CROP_FILL,
): Frame {
const FILL = cropFill > 0 && cropFill <= 1 ? cropFill : DEFAULT_CROP_FILL;
const b = mask.slabBounds;
const x0 = Math.max(0, Math.round(b.x));
const y0 = Math.max(0, Math.round(b.y));
@@ -33,8 +37,12 @@ export function composeInventoryFrame(
const y1 = Math.min(source.heightPx, y0 + Math.max(1, Math.round(b.h)));
const sw = x1 - x0;
const sh = y1 - y0;
const belowFloor = Math.min(sw, sh) < FLOOR;
const outW = belowFloor ? Math.max(1, Math.min(sw, FLOOR)) : FLOOR;
const shortSide = Math.min(sw, sh);
const sourceBelowBlock = shortSide < FLOOR_BLOCK_PX;
const sourceBelowWarn = !sourceBelowBlock && shortSide < FLOOR_WARN_PX;
// Undersized vs warn floor: frame from available source (no upscale to 800).
const undersized = shortSide < FLOOR_WARN_PX;
const outW = undersized ? Math.max(1, Math.min(sw, FLOOR_WARN_PX)) : FLOOR_WARN_PX;
const outH = Math.round((outW * ASPECT_H) / ASPECT_W);
const scale = Math.min((outW * FILL) / Math.max(sw, 1), (outH * FILL) / Math.max(sh, 1));
const dw = sw * scale;
@@ -59,13 +67,14 @@ export function composeInventoryFrame(
rgba[di + 3] = 255;
}
}
return { width: outW, height: outH, rgba, sourceBelowFloor: belowFloor };
return { width: outW, height: outH, rgba, sourceBelowBlock, sourceBelowWarn };
}
export async function encodeInventoryPng(
input: InventoryInput,
): Promise<{ blob: Blob; sourceBelowFloor: boolean }> {
): Promise<{ blob: Blob; sourceBelowBlock: boolean; sourceBelowWarn: boolean }> {
const { bitmap, mask, source } = input;
const fill = input.cropFill ?? getCropFill();
const srcCanvas = new OffscreenCanvas(source.widthPx, source.heightPx);
const srcCtx = srcCanvas.getContext('2d')!;
srcCtx.drawImage(bitmap, 0, 0);
@@ -74,10 +83,15 @@ export async function encodeInventoryPng(
{ width: source.widthPx, height: source.heightPx, rgba: img.data },
mask,
source,
fill,
);
const out = new OffscreenCanvas(frame.width, frame.height);
const ctx = out.getContext('2d')!;
ctx.putImageData(new ImageData(new Uint8ClampedArray(frame.rgba), frame.width, frame.height), 0, 0);
const blob = await out.convertToBlob({ type: 'image/png' });
return { blob, sourceBelowFloor: frame.sourceBelowFloor };
return {
blob,
sourceBelowBlock: frame.sourceBelowBlock,
sourceBelowWarn: frame.sourceBelowWarn,
};
}
+14
View File
@@ -0,0 +1,14 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { applyMaskAlpha } from './maskedPreview';
test('applyMaskAlpha keeps slab pixels and clears the background', () => {
const rgba = new Uint8ClampedArray([
1, 2, 3, 255,
4, 5, 6, 255,
7, 8, 9, 255,
]);
const out = applyMaskAlpha(rgba, new Uint8Array([1, 0]));
assert.deepEqual([...out], [1, 2, 3, 255, 4, 5, 6, 0, 7, 8, 9, 0]);
assert.deepEqual([...rgba], [1, 2, 3, 255, 4, 5, 6, 255, 7, 8, 9, 255]);
});
+52
View File
@@ -0,0 +1,52 @@
import { decodeOrientedBitmap } from '../decodeOrientedBitmap';
/**
* Apply a source-grid mask to decoded RGBA pixels without changing their
* dimensions. The transparent pixels remain in the full source frame so the
* tape transform and all measurement geometry keep using source coordinates.
*/
export function applyMaskAlpha(
rgba: Uint8ClampedArray,
bitset: Uint8Array,
): Uint8ClampedArray {
const out = new Uint8ClampedArray(rgba);
const pixels = out.length / 4;
for (let p = 0; p < pixels; p++) {
if (p >= bitset.length || !bitset[p]) out[p * 4 + 3] = 0;
}
return out;
}
/**
* Encode an EXIF-oriented source photo with the approved mask as alpha.
* This is display-only; the original source blob and mask stay in session
* state for geometry, measurement, and inventory encoding.
*/
export async function createMaskedPreviewBlob(
source: Blob,
bitset: Uint8Array,
): Promise<Blob> {
const bitmap = await decodeOrientedBitmap(source);
try {
const width = bitmap.width;
const height = bitmap.height;
const hasOffscreenCanvas = typeof OffscreenCanvas !== 'undefined';
const canvas: OffscreenCanvas | HTMLCanvasElement = hasOffscreenCanvas
? new OffscreenCanvas(width, height)
: Object.assign(document.createElement('canvas'), { width, height });
const ctx = canvas.getContext('2d') as CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D | null;
if (!ctx) throw new Error('Could not create preview canvas');
ctx.drawImage(bitmap, 0, 0);
const image = ctx.getImageData(0, 0, width, height);
image.data.set(applyMaskAlpha(image.data, bitset));
ctx.putImageData(image, 0, 0);
if (hasOffscreenCanvas) {
return await (canvas as OffscreenCanvas).convertToBlob({ type: 'image/png' });
}
return await new Promise<Blob>((resolve, reject) => {
(canvas as HTMLCanvasElement).toBlob((blob: Blob | null) => (blob ? resolve(blob) : reject(new Error('Could not encode preview'))), 'image/png');
});
} finally {
bitmap.close();
}
}
+107
View File
@@ -1117,3 +1117,110 @@ test('buildOverlayPlan: tapered tips (~20% max width) included; length span ≥
const tipSpanSrc = tipHi - tipLo;
assert.ok(tipSpanSrc <= woodSpan + 2, `tips must not overshoot wood (${tipSpanSrc} vs ${woodSpan})`);
});
test('buildOverlayPlan: when MAR angle ≠ tape angle, length || tape and width ⊥ tape (~2°)', () => {
// Wood MAR is horizontal (angleDeg 0); tape / approved axis is vertical (90°).
// Without optional axis (or orientedRect to tape), widths would be vertical.
// With tape-driven axis, length is vertical and width left→right ≈ horizontal.
const W = 240, H = 400;
const bitset = new Uint8Array(W * H);
// Slightly elongated horizontally so MAR long side ≈ 0°, but still a tall slab.
for (let y = 40; y <= 360; y++) {
for (let x = 40; x <= 200; x++) bitset[y * W + x] = 1;
}
const mar = minAreaRect(bitset, W, H)!;
// Force wood-long-axis bbox to 0° — intentionally NOT the tape (vertical).
const woodBbox = orientedRect(mar, 0);
assert.equal(woodBbox.angleDeg, 0);
// Tape vertical: tick A near bottom, B near top (image y↓).
const tapeStart: [number, number] = [120, 350];
const tapeEnd: [number, number] = [120, 50];
const tapeAngleDeg = ((Math.atan2(tapeEnd[1] - tapeStart[1], tapeEnd[0] - tapeStart[0]) * 180) / Math.PI + 180) % 180;
// Overlay bbox keeps MAR extents but direction from tape (session.calculate path).
const tapeBbox = orientedRect(woodBbox, tapeAngleDeg);
const scale = 10;
const plan = buildOverlayPlan({
bitset,
widthPx: W,
heightPx: H,
bbox: tapeBbox,
scale,
lengthIn: 32,
lengthLabel: '32"',
mapToInventory: (x, y) => [x, y],
axis: { start: tapeStart, end: tapeEnd },
});
// Length a→b must be parallel to tape (vertical → |dx| small vs |dy|).
const ldx = plan.length.b[0] - plan.length.a[0];
const ldy = plan.length.b[1] - plan.length.a[1];
const llen = Math.hypot(ldx, ldy) || 1;
const lux = ldx / llen, luy = ldy / llen;
const tdx = tapeEnd[0] - tapeStart[0], tdy = tapeEnd[1] - tapeStart[1];
const tlen = Math.hypot(tdx, tdy) || 1;
const tux = tdx / tlen, tuy = tdy / tlen;
const lengthDot = Math.abs(lux * tux + luy * tuy);
assert.ok(lengthDot > 0.999, `length must || tape (dot=${lengthDot})`);
assert.ok(plan.widths.length > 0, 'expected width stations');
// Tape-perpendicular = horizontal for a vertical tape.
for (const w of plan.widths) {
const ang = Math.atan2(w.right[1] - w.left[1], w.right[0] - w.left[0]);
let deg = (ang * 180) / Math.PI;
if (deg > 90) deg -= 180;
if (deg < -90) deg += 180;
assert.ok(
Math.abs(deg) <= 2,
`width station ${w.stationIn} left→right must be within 2° of tape-perpendicular/horizontal (deg=${deg.toFixed(2)})`,
);
}
// Contrast: same wood bbox WITHOUT tape axis would follow MAR (horizontal length).
const woodPlan = buildOverlayPlan({
bitset,
widthPx: W,
heightPx: H,
bbox: woodBbox,
scale,
lengthIn: 32,
lengthLabel: '32"',
mapToInventory: (x, y) => [x, y],
});
const wdx = woodPlan.length.b[0] - woodPlan.length.a[0];
const wdy = woodPlan.length.b[1] - woodPlan.length.a[1];
const wlen = Math.hypot(wdx, wdy) || 1;
// Wood MAR length should NOT be parallel to vertical tape.
const woodDot = Math.abs((wdx / wlen) * tux + (wdy / wlen) * tuy);
assert.ok(woodDot < 0.5, `wood-MAR overlay must disagree with tape (dot=${woodDot})`);
});
test('buildOverlayPlan: optional axis alone drives direction even if bbox angle wrong', () => {
const W = 200, H = 400;
const bitset = new Uint8Array(W * H);
for (let y = 50; y <= 350; y++) {
for (let x = 70; x <= 130; x++) bitset[y * W + x] = 1;
}
const mar = minAreaRect(bitset, W, H)!;
// Wrong bbox angle on purpose (horizontal) while axis is vertical.
const wrongBbox = orientedRect(mar, 0);
const plan = buildOverlayPlan({
bitset,
widthPx: W,
heightPx: H,
bbox: wrongBbox,
scale: 10,
lengthIn: 30,
lengthLabel: '30"',
mapToInventory: (x, y) => [x, y],
axis: { start: [100, 340], end: [100, 60] },
});
assert.ok(plan.widths.length > 0);
for (const w of plan.widths) {
const ang = Math.atan2(w.right[1] - w.left[1], w.right[0] - w.left[0]);
let deg = (ang * 180) / Math.PI;
if (deg > 90) deg -= 180;
if (deg < -90) deg += 180;
assert.ok(Math.abs(deg) <= 2, `axis override: width deg=${deg.toFixed(2)}`);
}
});
+21 -5
View File
@@ -1,5 +1,7 @@
import type { BBox } from '../capture/capture.types';
import { axisEndpoints } from '../measure/measure';
import { DEFAULT_CROP_FILL } from './crop';
import { getCropFill } from '../settings/cropFill';
/** Width overlay stations: 6, 18, 30, … while ≤ L − 6 (tip-exclusive). */
export function overlayWidthStations(lengthIn: number): number[] {
@@ -79,6 +81,12 @@ export interface OverlayPlanInput {
/** Inventory PNG size — used to clamp labels inside the canvas (#71). */
inventoryW?: number;
inventoryH?: number;
/**
* Optional explicit length axis (e.g. baked tape tick A→B). When set, (ux,uy)
* come from this segment instead of bbox corners — keeps width ⊥ tape even
* when MAR wood long side differs from the tape.
*/
axis?: { start: [number, number]; end: [number, number] };
}
/**
@@ -152,6 +160,7 @@ function maskAxisSegment(
w: number,
h: number,
bbox: BBox,
axis?: { start: [number, number]; end: [number, number] },
): {
start: [number, number];
end: [number, number];
@@ -162,7 +171,7 @@ function maskAxisSegment(
uy: number;
span: number;
} {
const { start: a, end: b } = axisEndpoints(bbox);
const { start: a, end: b } = axis ?? axisEndpoints(bbox);
const ex = b[0] - a[0];
const ey = b[1] - a[1];
const elen = Math.hypot(ex, ey) || 1;
@@ -268,9 +277,9 @@ function fmtInches(inches: number): string {
export function buildOverlayPlan(input: OverlayPlanInput): OverlayPlan {
const {
bitset, widthPx, heightPx, bbox, scale, lengthIn, lengthLabel, mapToInventory,
inventoryW, inventoryH,
inventoryW, inventoryH, axis,
} = input;
const seg = maskAxisSegment(bitset, widthPx, heightPx, bbox);
const seg = maskAxisSegment(bitset, widthPx, heightPx, bbox, axis);
// Re-orient span ends so EXIF bottom establishes station origin; axis still
// lies on the approved M1 direction (seg ux/uy flipped only when needed).
const oriented = pickBottomTowardTop(seg.start, seg.end);
@@ -485,8 +494,9 @@ export function inventorySourceMap(
source: { widthPx: number; heightPx: number },
outW: number,
outH: number,
cropFill: number = DEFAULT_CROP_FILL,
): InventoryMap {
const FILL = 0.9;
const FILL = cropFill > 0 && cropFill <= 1 ? cropFill : DEFAULT_CROP_FILL;
const x0 = Math.max(0, Math.round(slabBounds.x));
const y0 = Math.max(0, Math.round(slabBounds.y));
const x1 = Math.min(source.widthPx, x0 + Math.max(1, Math.round(slabBounds.w)));
@@ -517,6 +527,10 @@ export interface EncodeOverlayInput {
lengthLabel: string;
slabBounds: { x: number; y: number; w: number; h: number };
source: { widthPx: number; heightPx: number };
/** Optional override; default reads live settings.crop_fill (#99). */
cropFill?: number;
/** Optional explicit length axis in source px (baked tape A→B). */
axis?: { start: [number, number]; end: [number, number] };
}
/**
@@ -531,7 +545,8 @@ export async function encodeOverlayPng(input: EncodeOverlayInput): Promise<Blob>
const canvas = new OffscreenCanvas(outW, outH);
const ctx = canvas.getContext('2d')!;
ctx.drawImage(bmp, 0, 0);
const { mapToInventory } = inventorySourceMap(input.slabBounds, input.source, outW, outH);
const fill = input.cropFill ?? getCropFill();
const { mapToInventory } = inventorySourceMap(input.slabBounds, input.source, outW, outH, fill);
const plan = buildOverlayPlan({
bitset: input.bitset,
widthPx: input.widthPx,
@@ -543,6 +558,7 @@ export async function encodeOverlayPng(input: EncodeOverlayInput): Promise<Blob>
mapToInventory,
inventoryW: outW,
inventoryH: outH,
axis: input.axis,
});
drawOverlayPlan(ctx, plan);
return await canvas.convertToBlob({ type: 'image/png' });
+56
View File
@@ -0,0 +1,56 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { bakeTiltTransform } from './rotateCal.ts';
test('bakeTiltTransform: identity ≈0° leaves points and size unchanged', () => {
const x = bakeTiltTransform(800, 600, 0);
assert.equal(x.identity, true);
assert.equal(x.outW, 800);
assert.equal(x.outH, 600);
assert.deepEqual(x.mapPoint(100, 200), [100, 200]);
assert.deepEqual(x.mapPoint(0, 0), [0, 0]);
});
test('bakeTiltTransform: 360° folds to identity', () => {
const x = bakeTiltTransform(400, 300, 360);
assert.equal(x.identity, true);
assert.deepEqual(x.mapPoint(10, 20), [10, 20]);
});
test('bakeTiltTransform: 90° CW maps centre→centre; corner into AABB canvas', () => {
const srcW = 200, srcH = 100;
const x = bakeTiltTransform(srcW, srcH, 90);
assert.equal(x.identity, false);
// Rotated AABB of 200×100 about centre ≈ 100×200 (ceil may add 1px from float).
assert.ok(x.outW >= 100 && x.outW <= 101, `outW=${x.outW}`);
assert.ok(x.outH >= 200 && x.outH <= 201, `outH=${x.outH}`);
const [cx, cy] = x.mapPoint(srcW / 2, srcH / 2);
assert.ok(Math.abs(cx - x.outW / 2) < 1e-9);
assert.ok(Math.abs(cy - x.outH / 2) < 1e-9);
// Top-left (0,0) relative (−100,−50) → CW90 → (~50,~−100) → +out/2
const [tlx, tly] = x.mapPoint(0, 0);
assert.ok(Math.abs(tlx - (50 + x.outW / 2)) < 1e-4, `tlx=${tlx}`);
assert.ok(Math.abs(tly - (-100 + x.outH / 2)) < 1e-4, `tly=${tly}`);
});
test('bakeTiltTransform: maps a tape-like vertical segment; direction rotates with tilt', () => {
const srcW = 400, srcH = 600;
const start: [number, number] = [200, 100];
const end: [number, number] = [200, 500];
const preUx = 0, preUy = 1; // vertical down
for (const tilt of [15, 30, -20, 45]) {
const x = bakeTiltTransform(srcW, srcH, tilt);
const a = x.mapPoint(start[0], start[1]);
const b = x.mapPoint(end[0], end[1]);
const dx = b[0] - a[0], dy = b[1] - a[1];
const len = Math.hypot(dx, dy) || 1;
const ux = dx / len, uy = dy / len;
// Expected: rotate (preUx, preUy) clockwise by tilt
const rad = (((tilt % 360) + 360) % 360) * Math.PI / 180;
const cos = Math.cos(rad), sin = Math.sin(rad);
const expUx = preUx * cos - preUy * sin;
const expUy = preUx * sin + preUy * cos;
const dot = ux * expUx + uy * expUy;
assert.ok(dot > 0.999, `tilt=${tilt} direction mismatch dot=${dot}`);
}
});
+140
View File
@@ -0,0 +1,140 @@
/**
* Bake calibration photo tilt into a rotated bitmap + mask (#99 transform scope).
* Used only for the calibration inventory PNG + dimension-overlay clone.
* Batch siblings are never passed through here.
*/
import type { Mask, Source } from '../capture/capture.types';
import { minAreaRect, slabBounds } from '../geometry/rect';
export interface RotatedCal {
bitmap: ImageBitmap;
mask: Mask;
source: Pick<Source, 'widthPx' | 'heightPx'>;
}
/** Pure math for the same clockwise-about-centre bake used by rotateCalibration. */
export interface BakeTiltTransform {
/** True when tilt is ~0° (identity; no resample). */
identity: boolean;
tiltDeg: number;
srcW: number;
srcH: number;
outW: number;
outH: number;
/** Map a point from pre-bake source coords into baked canvas coords. */
mapPoint: (x: number, y: number) => [number, number];
}
/**
* Clockwise rotation about image centre; output canvas sized to AABB of rotated
* corners — identical sizing to rotateCalibration (canvas translate+rotate path).
*/
export function bakeTiltTransform(
srcW: number,
srcH: number,
tiltDeg: number,
): BakeTiltTransform {
const a = ((tiltDeg % 360) + 360) % 360;
if (a < 0.05 || a > 359.95) {
return {
identity: true,
tiltDeg: a,
srcW,
srcH,
outW: srcW,
outH: srcH,
mapPoint: (x, y) => [x, y],
};
}
const rad = (a * Math.PI) / 180;
const cos = Math.cos(rad);
const sin = Math.sin(rad);
const corners = [
[0, 0],
[srcW, 0],
[srcW, srcH],
[0, srcH],
].map(([x, y]) => {
const cx = x - srcW / 2;
const cy = y - srcH / 2;
return [cx * cos - cy * sin, cx * sin + cy * cos] as [number, number];
});
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
for (const [x, y] of corners) {
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
}
const outW = Math.max(1, Math.ceil(maxX - minX));
const outH = Math.max(1, Math.ceil(maxY - minY));
return {
identity: false,
tiltDeg: a,
srcW,
srcH,
outW,
outH,
mapPoint: (x, y) => {
const cx = x - srcW / 2;
const cy = y - srcH / 2;
return [cx * cos - cy * sin + outW / 2, cx * sin + cy * cos + outH / 2];
},
};
}
/**
* Rotate source clockwise by tiltDeg (same sign as CSS/canvas rotate).
* Returns a new bitmap the caller must close, plus recomputed mask geometry.
* Identity (≈0°) returns clones of inputs without resampling.
*/
export async function rotateCalibration(
bitmap: ImageBitmap,
mask: Mask,
source: Pick<Source, 'widthPx' | 'heightPx'>,
tiltDeg: number,
): Promise<RotatedCal> {
const xform = bakeTiltTransform(source.widthPx, source.heightPx, tiltDeg);
if (xform.identity) {
// No meaningful rotation — hand back the same bitmap reference; caller closes once.
return { bitmap, mask, source };
}
const { srcW, srcH, outW, outH, tiltDeg: a } = xform;
const rad = (a * Math.PI) / 180;
const canvas = new OffscreenCanvas(outW, outH);
const ctx = canvas.getContext('2d')!;
ctx.translate(outW / 2, outH / 2);
ctx.rotate(rad);
ctx.drawImage(bitmap, -srcW / 2, -srcH / 2);
// Nearest-neighbour resample of the bitset into the rotated frame.
const bitset = new Uint8Array(outW * outH);
const invCos = Math.cos(-rad);
const invSin = Math.sin(-rad);
for (let y = 0; y < outH; y++) {
for (let x = 0; x < outW; x++) {
const dx = x - outW / 2;
const dy = y - outH / 2;
const sx = Math.round(dx * invCos - dy * invSin + srcW / 2);
const sy = Math.round(dx * invSin + dy * invCos + srcH / 2);
if (sx < 0 || sy < 0 || sx >= srcW || sy >= srcH) continue;
bitset[y * outW + x] = mask.bitset[sy * srcW + sx];
}
}
let slabPx = 0;
for (let i = 0; i < bitset.length; i++) if (bitset[i]) slabPx++;
const bounds = slabBounds(bitset, outW, outH);
const bbox = minAreaRect(bitset, outW, outH) ?? mask.bbox;
const outBmp = canvas.transferToImageBitmap();
return {
bitmap: outBmp,
mask: {
params: mask.params,
slabPx,
bitset,
bbox,
slabBounds: bounds,
},
source: { widthPx: outW, heightPx: outH },
};
}
+55
View File
@@ -0,0 +1,55 @@
/**
* Thin frontend-only crop_fill setting (#99).
* Default 0.9 — replaces duplicated FILL = 0.9 in png/inventory + png/overlay.
* Not a full M3 Settings surface; localStorage so it survives reloads.
*/
import { DEFAULT_CROP_FILL, clampFill } from '../png/crop';
const KEY = 'slabuploader.crop_fill';
let cached: number | null = null;
export function getCropFill(): number {
if (cached != null) return cached;
if (typeof localStorage === 'undefined') {
cached = DEFAULT_CROP_FILL;
return cached;
}
try {
const raw = localStorage.getItem(KEY);
if (raw == null) {
cached = DEFAULT_CROP_FILL;
return cached;
}
cached = clampFill(Number.parseFloat(raw));
return cached;
} catch {
cached = DEFAULT_CROP_FILL;
return cached;
}
}
export function setCropFill(fill: number): number {
const next = clampFill(fill);
cached = next;
if (typeof localStorage !== 'undefined') {
try {
localStorage.setItem(KEY, String(next));
} catch {
/* private mode / quota — in-memory still updated */
}
}
return next;
}
/** Test helper: clear cached value (and optional storage). */
export function _resetCropFillForTests(): void {
cached = null;
if (typeof localStorage !== 'undefined') {
try {
localStorage.removeItem(KEY);
} catch {
/* ignore */
}
}
}
+6
View File
@@ -2,6 +2,7 @@ import { minAreaRect, slabBounds } from '../geometry/rect';
import type { MaskParams, WorkerIn, WorkerOut } from '../capture/capture.types';
import { decodeOrientedBitmap } from '../decodeOrientedBitmap';
import { greenKeep, blackKeep, autoPickSheet } from '../mask/sheetKeep';
import { keepLargestComponent } from '../mask/largestComponent';
let bitmap: ImageBitmap | null = null;
let data: Uint8ClampedArray | null = null;
@@ -33,6 +34,11 @@ function runMask(params: MaskParams, id: number): WorkerOut {
if (slabPx === 0) {
return { id, kind: 'ready', slabPx: 0, bitset, bbox: { corners: [], longSidePx: 0, angleDeg: 0 }, slabBounds: { x: 0, y: 0, w: 0, h: 0 } };
}
// Sheet gaps leave stray chunks; lock onto the single largest slab object.
slabPx = keepLargestComponent(bitset, w, h);
if (slabPx === 0) {
return { id, kind: 'ready', slabPx: 0, bitset, bbox: { corners: [], longSidePx: 0, angleDeg: 0 }, slabBounds: { x: 0, y: 0, w: 0, h: 0 } };
}
const bbox = minAreaRect(bitset, w, h);
if (!bbox) {
return { id, kind: 'ready', slabPx: 0, bitset, bbox: { corners: [], longSidePx: 0, angleDeg: 0 }, slabBounds: { x: 0, y: 0, w: 0, h: 0 } };
+6 -1
View File
@@ -20,6 +20,8 @@ export interface SeedMeasure {
sqft: number;
bdft: number;
widthIn: number;
/** Woo species leaf; required for Review-ready e2e now that Review has no species picker. */
speciesId?: number | null;
}
const toEighths = (inches: number) => Math.round(inches * 8);
@@ -58,6 +60,7 @@ export async function seedMeasureDraft(
axisBbox: null,
lengthEighths: payload.lengthEighths,
thicknessEighths: payload.thicknessEighths,
species_id: payload.speciesId ?? null,
results: {
lengthEighths: payload.lengthEighths,
thicknessEighths: payload.thicknessEighths,
@@ -70,7 +73,8 @@ export async function seedMeasureDraft(
},
sqft: payload.sqft,
bdft: payload.bdft,
sourceBelowFloor: false,
sourceBelowBlock: false,
sourceBelowWarn: false,
},
inventoryPng: png,
overlayPng: null,
@@ -97,6 +101,7 @@ export async function seedMeasureDraft(
widthEighths,
sqft: d.sqft,
bdft: d.bdft,
speciesId: d.speciesId ?? null,
},
);
}
+28 -1
View File
@@ -243,6 +243,20 @@ async function mockApi(page: Page, hooks: ApiHooks = {}) {
await json(route, slab);
return;
}
if (path === `/api/v1/slabs/${slab.id}/photos` && method === 'GET') {
// Publish preview + Review strip: ordered lineup (empty is fine for golden path).
await json(route, [
{
id: 'photo-1', seq: 1, is_calibration: 1, role: 'calibration',
width_px: 100, height_px: 100, has_original: 1, has_processed: 1,
},
{
id: 'photo-2', seq: 2, is_calibration: 0, role: 'overlay',
width_px: 100, height_px: 100, has_original: 1, has_processed: 1,
},
]);
return;
}
if (path === `/api/v1/slabs/${slab.id}/photos` && method === 'POST') {
// Photo uploads are multipart; `payload` is {} — the client's photo_id is
// in the form body, not JSON, so echo a stable synthetic id.
@@ -251,6 +265,7 @@ async function mockApi(page: Page, hooks: ApiHooks = {}) {
slab_id: slab.id,
seq: 1,
is_calibration: 1,
role: 'calibration',
original_retained: false,
processed_retained: true,
}, 201);
@@ -294,6 +309,7 @@ async function reachReview(page: Page) {
await page.goto('/?noload');
await seedMeasureDraft(page, {
sku: '', lengthIn: 48, thicknessIn: 2, sqft: 6.25, bdft: 12.5, widthIn: 11,
speciesId: SPECIES_ID,
});
// Phase 2: reload WITHOUT ?noload so onMount probes IDB and shows Resume.
await page.goto('/');
@@ -309,7 +325,8 @@ async function reachReview(page: Page) {
/** Fill every ready-gating field to make the slab publishable. */
async function completeForReady(page: Page) {
await page.getByTestId('species').selectOption(String(SPECIES_ID));
// Species comes from Measure/createSlab (seeded on the draft); no Review picker.
await expect(page.getByTestId('species')).toHaveCount(0);
// Wood categories: open, select Live edge, Done.
await page.getByTestId('wood-cat-btn').click();
await page.getByTestId(`wood-cat-opt-${LIVEEDGE_ID}`).click();
@@ -322,6 +339,8 @@ async function completeForReady(page: Page) {
await page.getByTestId('create-title').click();
// Wait for the build to complete (title field populated = deterministic copy).
await expect(page.getByTestId('title')).not.toHaveValue('');
await expect(page.getByTestId('title')).toHaveValue(/Cedar/);
await expect(page.getByTestId('short-desc')).toHaveValue(/Cedar/);
await page.getByTestId('sku').fill('SLAB-0001');
// SKU commits on blur — press Tab to move focus off it (deterministic, single
// commit; a body click can re-focus the SKU and re-fire blur).
@@ -337,13 +356,21 @@ test.describe('M3 Review→Publish flow (#77)', () => {
await expect(page.getByTestId('ready-status')).toContainText(/Not ready|Ready/);
await expect(page.getByTestId('title')).toHaveValue('');
await completeForReady(page);
// Species from Measure draft must land on the slab (Create title reads it).
expect(slab.species_id).toBe(SPECIES_ID);
// #89: the Create build populated the deterministic copy on the slab row.
expect(slab.title).toBeTruthy();
expect(slab.short_description).toBeTruthy();
expect(String(slab.title)).toMatch(/Cedar/);
// Continue to Publish is enabled and hands off.
await expect(page.getByTestId('continue-publish')).toBeEnabled();
await page.getByTestId('continue-publish').click();
await expect(page.getByTestId('publish-screen')).toBeVisible();
// Publish preview: glanceable confirm before Woo draft POST.
await expect(page.getByTestId('publish-preview')).toBeVisible();
await expect(page.getByTestId('preview-title')).toBeVisible();
await expect(page.getByTestId('preview-facts')).toBeVisible();
await expect(page.getByTestId('back-review')).toBeVisible();
// Publish → success → Done panel (draft in UAT store).
await page.getByTestId('publish-btn').click();
await expect(page.getByText('Published')).toBeVisible({ timeout: 15000 });
+3 -6
View File
@@ -64,7 +64,7 @@ const prepareMaskForApprove = async (page: Page) => {
};
test.describe('M2 #65 Photos-ready e2e (set1-48x2)', () => {
test('batch → pick → mask → axis → measure → photos ready + screenshots', async ({ page }) => {
test('batch → pick → mask → measure (tape+fields) → photos ready + screenshots', async ({ page }) => {
test.setTimeout(180_000);
expect(fs.existsSync(FACE_A), 'fixture face-a').toBeTruthy();
expect(fs.existsSync(FACE_B), 'fixture face-b').toBeTruthy();
@@ -87,11 +87,8 @@ test.describe('M2 #65 Photos-ready e2e (set1-48x2)', () => {
await shot(page, '03-mask.png');
await page.getByRole('button', { name: 'Approve mask' }).click();
await expect(page.getByRole('heading', { name: 'Length axis' })).toBeVisible();
await shot(page, '04-axis.png');
await page.getByRole('button', { name: 'Approve axis' }).click();
await expect(page.getByRole('heading', { name: 'Measure' })).toBeVisible();
await shot(page, '04-tape-measure.png');
await page.getByLabel('Length L').fill('48');
await page.getByLabel('Thickness').fill('2');
// blur/change commits eighths into session before Calculate.
@@ -117,7 +114,7 @@ test.describe('M2 #65 Photos-ready e2e (set1-48x2)', () => {
// Write a short run log for parent AI visual review (not a substitute for eyes).
const logPath = path.join(ARTIFACTS, 'run-log.txt');
const shots = ['01-batch.png', '02-pick-calibration.png', '03-mask.png', '04-axis.png', '05-measure.png', '06-photos-ready.png'];
const shots = ['01-batch.png', '02-pick-calibration.png', '03-mask.png', '04-tape-measure.png', '05-measure.png', '06-photos-ready.png'];
const lines = [
`M2 #65 Playwright e2e — set1-48x2 (L=48, thickness=2)`,
`baseURL: ${page.url().split('/').slice(0, 3).join('/')}`,
@@ -165,7 +165,10 @@ async function mockApi(page: Page) {
async function reachReview(page: Page) {
await page.goto('/?noload');
await seedMeasureDraft(page, { sku: '', lengthIn: 48, thicknessIn: 2, sqft: 6.25, bdft: 12.5, widthIn: 11 });
await seedMeasureDraft(page, {
sku: '', lengthIn: 48, thicknessIn: 2, sqft: 6.25, bdft: 12.5, widthIn: 11,
speciesId: SPECIES_ID,
});
await page.goto('/');
await expect(page.getByRole('heading', { name: 'Resume?' })).toBeVisible({ timeout: 15000 });
await page.getByRole('button', { name: 'Continue' }).click();
@@ -175,7 +178,8 @@ async function reachReview(page: Page) {
}
async function completeForReady(page: Page) {
await page.getByTestId('species').selectOption(String(SPECIES_ID));
// Species comes from Measure/createSlab (seeded on the draft); no Review picker.
await expect(page.getByTestId('species')).toHaveCount(0);
await page.getByTestId('wood-cat-btn').click();
await page.getByTestId(`wood-cat-opt-${LIVEEDGE_ID}`).click();
await page.getByTestId('wood-cat-done').click();
+9
View File
@@ -5,4 +5,13 @@ import sveltePreprocess from 'svelte-preprocess';
export default defineConfig({
plugins: [svelte({ preprocess: sveltePreprocess() })],
worker: { format: 'es' },
// overnight-whole-app box UAT: vite preview on :18181 → FastAPI on :18182
server: {
proxy: { '/api': { target: 'http://127.0.0.1:18182', changeOrigin: true } },
},
preview: {
// Cloudflare quick tunnel Host header (public UAT without Tailscale)
allowedHosts: true,
proxy: { '/api': { target: 'http://127.0.0.1:18182', changeOrigin: true } },
},
});