63 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
Ty 0c00d25cc7 fix(m3): Option A must-fix #79 #82 #83 #84 #89 #98
- #79 publish SKU/create via WooClient (User-Agent + typed 403 unrecoverable)
- #82 stale_field current_options from live taxonomy + owner labels
- #83 create allowlist/extra-key safe; taxonomy+enum 4xx on create
- #84 health woo_reachable real probe (non-failing); version 0.5.0-m3
- #89 review persists assembled title/short after taxonomy lock; UI applies
- #98 live-edge from synced term; attrs resolved by slug then name
2026-09-21 12:55:17 -07:00
Ty 96a697a50a fix(review): restore lineup/woodPrimary imports after merge
Morning merge kept Review call sites (nextPrimary, keptInOrder, roleLabel, …)
and dropped the imports, so hydrate threw ReferenceError and the UI showed
"Could not load the slab." Live UAT redeployed; sample data preserved.
2026-09-21 12:36:24 -07:00
Ty 67aa3d6b0d fix(review): black screen on Capture→Review — speciesId/gradeId refs
The merge auto-kept the branch's canCreateTitle reactive (speciesId) which is
undeclared in the merged file (main's canonical var is speciesSel) → ReferenceError
on Review mount → black screen. Also dropped the branch's duplicate gradeId (main
uses gradeSel) and repointed hydrate/patch to gradeSel. Live UAT verified: Review
now mounts and renders 'Review to Ready' with photo strip + Create buttons.
2026-09-21 12:17:24 -07:00
Ty 8ee3da9dcf merge work/merge-89-90 into main (Create-string #89/#90 + PR #97 fixes)
Reconciles two independent lines of work that both touched Review.svelte,
MultiSelect.svelte, and the thickness band:

- main's PR #97 (client-side thicknessBand.ts + MultiSelect names/commit +
  syncSelects DOM race fix + box-sizing CSS)
- work/merge-89-90 (server-side thickness band in thickness_band.py,
  Create-string #89, metadata_locked dropped, photo strip, wood primary)

Canonical resolution: the band is SERVER-side (hidden from UI, sent to Woo
on publish), so the client-side thicknessBand.ts + read-only band display
are removed from Review.svelte; PR #97's syncSelects() and box-sizing CSS
are preserved (orthogonal fixes). MultiSelect takes selectedSummary from
woodPrimary.ts (names in closed state) + the ms-count testid + onDestroy
timer cleanup; duplicate close()/summary definitions dropped.

Gates: backend 117/117, unit 88/88, tsc clean, e2e 6/6 (m3-create +
photo-strip).
2026-09-21 11:59:36 -07:00
Ty 1a50fad0a5 merge issue-89-metadata-lock → main (Create-string model, #89/#90)
Drop the metadata_locked column + "Save and lock" UI end-to-end. The
review endpoint now builds the deterministic title/short_description on
every call; the owner's Create title / Create short desc buttons fire
the build and write the result (force-overwrite contract). commitReview
auto-fills only empty fields (no owner-clobber) and folds the
deterministic copy into its single PUT (kills the client_rev 409
cascade).

Taken wholesale from the branch: photo strip, wood primary/secondary,
server-side thickness band (hidden from UI, passed to Woo on publish),
and fixes #80/#82/#83/#84.

- backend: remove metadata_locked from schema.sql, db sentinels,
  slabs.py client fields/exposure, review.py lock action; review
  always computes title/short_desc
- frontend: slabs.ts + Review.svelte drop the lock model, add
  Create-string workflow + wood primary + photo strip
- e2e: m3-create + review-photo-strip rewritten for the Create-string
  contract; thickness-band test asserts absence (per #90)
- deploy: recreate LAN binds (override-m3.yml was absent); verify
  metadata_locked gone, review builds title, PUT persists

Gates: backend 117/117, unit 88/88, tsc clean, e2e 6/6 (m3-create +
photo-strip). Live UAT verified at 192.168.1.202:10881.
2026-09-21 11:54:48 -07:00
grokbot_ty e61303e2d8 Merge pull request 'fix(review): #88 thickness band derived; #90 MultiSelect names + commit' (#97) from work/ux-bugs-fixes into main 2026-09-21 17:01:49 +00:00
Ty bdd03afce4 fix(review): thickness band derived from thickness_in (#88); MultiSelect shows selected names + commits on toggle settle (#90)
#88 — thickness band is now display-only, derived deterministically from
thickness_in against the synced Woo thickness terms (TV-9 / QUALITY-95).
Replaces the <select> with a read-only div. New pure function
deriveThicknessTermId + 6 unit tests. Docs (UAT-PRACTICE, contracts/UX)
updated to reflect the band is not an owner control.

#90 — MultiSelect closed state now shows the selected option names
(truncated to 2 + +N) instead of just a count. onCommit prop wired so
multi-select changes persist + re-review immediately (250ms debounce,
flush on Done, cleanup on destroy). Single-select binds use string vars
(speciesSel/edgeSel/gradeSel) to fix the number/string mismatch with
<option value={id}>; a tick()-deferred syncSelects() keeps the DOM
option in sync after programmatic writes (AI prefill, reload hydrate)
that Svelte's own select_option would otherwise reset.
2026-09-21 09:56:44 -07:00
Ty e5acef4c9b chore(deploy): record final #90 UAT state (band omitted) 2026-09-21 02:50:06 -07:00
Ty 204bc5e960 fix(review): #90 thickness band omitted entirely (not read-only) 2026-09-21 02:49:47 -07:00
Ty 191b507934 chore(deploy): record #90 UAT deploy marker 2026-09-21 02:43:26 -07:00
Ty 0329052d37 feat(review): #90 wood primary category + derived thickness band 2026-09-21 02:41:20 -07:00
Ty e1b928ccbd fix(review): #90 clearer mandatory-photo indicator (padlock + REQUIRED tag)
The mandatory clean-cal and dimension-overlay photos now show a padlock glyph
in their keep box and an amber REQUIRED tag at the top of the card, so the
owner can see at a glance which photos cannot be removed (vs. optional batch
photos, which keep a plain checkmark).
2026-09-21 02:18:01 -07:00
Ty 76fcb36a2b fix(review): #90 clean cal + dimension-overlay are mandatory (checked + disabled)
REVIEW-5 requires both the clean calibration photo and its dimension-overlay
copy to ship. Their keep checkbox is now checked AND disabled (aria-disabled,
a lock glyph, cursor:default, tap is a no-op) so the owner can't remove them.
Only batch photos are removable. The submission minimum can no longer be
broken by the owner, so publish is never lineup-blocked by a missing required
photo.
2026-09-21 02:15:00 -07:00
Ty 5a1800df91 fix(review): #90 persist the dimension-overlay copy with its role
The Capture→Review upload filtered out the overlay (role !== 'overlay') and
sent no role field, so the dimension-overlay copy was never stored and every
photo defaulted to role=batch. Review then fell back to a stored /processed
URL that had no dimensions drawn — the owner saw the overlay thumbnail
without its measurements. Now upload the full lineup with the correct role,
keeping the overlay's dimension-drawn blob as both original and processed.
2026-09-21 01:59:27 -07:00
Ty cd326d1ec9 test(review): #90 assert Primary photo src is valid + checkerboard on strip
Locks in the two live-bug fixes: the Primary (leftmost kept) photo's <img src>
must be a real URL (blob: for an in-memory capture blob) and never empty/
"object Blob"; and each strip card must carry the gray checkerboard
background matching the Capture lineup.
2026-09-21 01:54:21 -07:00
Ty 502314b338 Update deploy/INVENTORY.md 2026-09-21 08:50:13 +00:00
Ty 0612ce66a6 fix(review): #90 photo strip — blob: URL for Primary photo, checkerboard thumbs
Bug #1: the first/Primary (calibration) photo rendered broken because its
in-memory capture Blob was assigned to <img src> directly (String(Blob)=
"object Blob"). resolvePhotoUrl() now mints a blob: object URL (revoked on
unload) and falls back to the stored photo endpoint when there is no blob.
Bug #2: strip thumbnails used a flat dark bg; now carry the gray checkerboard
from the Capture lineup (MeasurePanel) so transparent inventory PNGs read the
same on both screens.
Also harden missingMinimums to filter kept internally (pre-existing test 7
failed on the clean base because it passed un-kept items).
2026-09-21 01:48:43 -07:00
Ty 7b7adbdb34 fix(deploy): nginx client_max_body_size 25m for photo uploads
Capture→Review uploads the calibrated lineup PNGs (clean cal + batch) as
multipart to /api/v1/slabs/{id}/photos. nginx's default 1MB
client_max_body_size 413s on real multi-MB photos, blocking the
Capture→Review transition. Allow 25m.
2026-09-21 01:41:31 -07:00
Ty d9af8420bc feat(review): #90 photo strip — keep/remove, drag-reorder, leftmost=primary, persist
REVIEW-5 / PUBLISH-45: owner edits the stored photo lineup in Review — tap a
card to keep/remove, drag to reorder, leftmost kept photo = primary (Woo
cover). The kept set must contain a clean calibration + its dimension-overlay
copy; publish is blocked until it does.

- backend: role column on slab_photos (schema + idempotent _migrate ALTER),
  role in photo CRUD + new DELETE endpoint, Woo image order by role-rank
  (clean→overlay→batch) per PUBLISH-45
- frontend: Review photo strip (render from listPhotos, keep/remove,
  HTML5 drag-reorder, dynamic PRIMARY badge from .primary class),
  persistLineup() re-seq kept + drop removed before entering Publish
- tests: lineup.ts unit, e2e review-photo-strip.spec, backend role + order

Refs: #90, REVIEW-5, PUBLISH-45
2026-09-21 01:34:22 -07:00
Ty fe82133ca4 fix(backend): #84 real woo_reachable, #83 reject unknown fields, #82 live taxonomy in stale_field, #80 persist review overrides
#84: /api/health woo_reachable is now a real check (GET /wp-json/ with a
3s timeout) instead of a hardcoded False stub. Version bumped to 0.5.0-m3-s1.

#83: POST /slabs rejects unknown (non-ALLOWED_FIELDS) keys with a 400
'unknown fields' error, instead of silently dropping them. The INSERT
binding count is now provably correct (no more assert needed).

#82: stale_field 422 error now returns the LIVE taxonomy ids
(SELECT id FROM table) as current_options, plus the field name and
submitted value, instead of echoing the submitted value back.

#80: regular review (no lock) persists owner overrides (price/sku/title/
short_description) from the payload to the DB via an UPDATE, so the
review endpoint is self-contained. server_rev is incremented on persist.

Tests: +3 (test_create_rejects_unknown_fields,
test_update_stale_field_returns_live_options,
test_review_persists_owner_overrides). 87/87 passing.
2026-09-20 23:38:29 -07:00
Ty 693719e85f fix(review): #89 explicit metadata lock — deterministic copy on lock, not on review
The Review screen now has an explicit 'Lock & Build Title' step. Until the
owner locks, title/short_description stay empty (no 'ready' copy from an
unlocked AI draft). On lock the server validates the taxonomy, sets
metadata_locked=1, builds + persists the deterministic title/short_description
(owner prose wins), and returns the ready state.

- schema.sql: add metadata_locked INTEGER DEFAULT 0 to slabs
- db/__init__.py: bump _SCHEMA_SENTINELS for metadata_locked
- slabs.py: expose metadata_locked in GET; reject it in PUT (server-managed)
- review.py: POST /{id}/review action=lock path; regular review reports
  stored (empty) copy until locked
- test_review.py: 8 tests covering lock gate, owner-prose-wins, stale taxonomy
- frontend/api/slabs.ts: Slab.metadata_locked, ReviewResult.locked,
  ReviewInput.action, lockSlab() helper
- Review.svelte: lockMetadata() with correct commit-token ordering (bump AFTER
  inner commit so the inner PUT isn't superseded), Lock button, locked banner
- e2e/m3-create.spec.ts: pare to single golden path (capture→review→lock→
  ready→publish→done); mock respects optimistic concurrency (client_rev) so
  stale PUTs 409 like the real backend
2026-09-20 23:36:31 -07:00
Ty 41401a7266 fix(inference): #87 redact image bytes from inference_log
Log full text + image_ref (photo_id/seq/bytes) only; wire path still
sends data-URLs. Call 1/2 SELECT id+seq for refs. Cap is safety net
after redaction (METADATA-66).
2026-09-20 23:01:38 -07:00
Ty 7287a55707 fix(inference): #86 tighten Call 1 prompt and closed lists
Ship locked expert Call 1 seed: order cats before edge, species=best
confidence, mill-count edge rules, Wild for cookies/burl, multi figure,
feature_tag_ids only (no free-text bags, no grade). LISTS keys match the
seed; feat- prefix stripped for the model. Gate and review UI apply
feature_tag_ids. Seed auto-activates on deploy when still seed-authored.
2026-09-20 22:54:36 -07:00
Ty 81be17d5cd fix(inference): METADATA-35 fit Call 1/2 images to 1024 long edge
Downscale processed PNGs only on the inference send path (keep alpha, never
upscale). Stored inventory/Woo blobs unchanged. Adds Pillow + unit/e2e-style
tests; cite tests on METADATA-35.
2026-09-20 22:32:14 -07:00
Ty cec03c5bd3 fix(m3): AI-assist scope + confidence gate + grouped wood categories
- inference: _taxonomy_lists hands the model the synced species leaves + wood
  leaves + case-insensitive attribute terms (was furniture roots / empty)
- inference: CALL1_SEED pins exact flat schema + *_confidence keys, drops grade
- inference: _gate maps field->confidence key correctly (was species_id_confidence),
  reads nested shape as safety net, omits grade (METADATA-40)
- review.svelte: figure gets AI assist, grade dropped from AI (owner judgment)
- MultiSelect: grouped mode (parents non-selectable headers, selectable leaves)
- METADATA-40 updated: grade not suggested by Call 1
2026-09-20 21:47:36 -07:00
Ty e36979617b fix(m3): uuid secure-context fallback + middle-tier SKU business rules
- frontend: uuid() helper (crypto.randomUUID -> getRandomValues) for plain-HTTP LAN UAT
- slabs.py: empty SKU at create -> NULL (no-SKU-yet is transient, filled in Review)
- review.py: ready-gate enforces REVIEW-45 (SKU 3-24 alnum+hyphen) in the middle tier
- DB CHECK stays as backstop only (referential/structural), per layering principle
2026-09-20 21:15:29 -07:00
Ty c8d6d5eda0 fix(backend): create accepts capture-only payload; fix INSERT binding count
create_slab required ALL ALLOWED_FIELDS present, but the Review screen fills
taxonomy/content after create (CREATE.md §1). Now requires only the capture
fields (id, sku, dims, widths); the rest stay nullable for Review.

Also fixed a pre-existing off-by-one: vals re-filtered cols by 'in data',
dropping the 6 server-managed columns and under-counting bindings (19 vs 18).
Split allowed_cols/server_cols so vals always matches cols.

Verified: 9/9 slabs tests pass; live POST /slabs with the app's capture-math
payload returns 201.
2026-09-20 14:45:32 -07:00
Ty 1133201f30 M3 Review→Publish UI (#77): one-column flow, searchable pickers, fig/feat tags, e2e
- Review screen: slug-resolved species/wcat groupers, case-insensitive attrs,
  fig-*/feat-* tag pickers (METADATA-30/40), AI-assist labels + AI bubbles.
- App orchestrator: single writable flow store, capture→review→publish, Resume boot.
- API client (slabs.ts) mirroring live backend routes; Blob name fix.
- Playwright e2e (7 tests, hermetic mock): canvas PNG seed, client-UUID mock,
  SKU blur commit, ready-state reactive fix; expect timeout 4s, 2 workers.
- e2e green 7/7 in 6s; check_requirements + check_coverage PASS.
2026-09-20 14:34:24 -07:00
105 changed files with 10370 additions and 790 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/
+34
View File
@@ -14,6 +14,32 @@
- Subagents run on one local GPU and cannot handle concurrency. Invoke one at a time; never in parallel.
## Build & test (verified commands)
Frontend (`frontend/`, Svelte 4 + Vite + TS, no router):
```
cd frontend && npm install
npm run test # node --import tsx --test src/**/*.test.ts (unit)
npm run build # tsc --noEmit && vite build (type-check + build)
npm run test:e2e # playwright test (see Pitfalls: mocks the API)
```
Backend (`backend/`, FastAPI + stdlib sqlite, no ORM, deps pinned in `backend/requirements.txt`):
```
cd backend && python -m pytest tests/ -q
uvicorn backend.app:app --host 127.0.0.1 --port 18081
```
No Makefile, no CI workflows, no Gitea CI — nothing runs in a pipeline; the only automation is the local commands above plus the human UAT walk (`deploy/uat_walk.sh`).
## Conventions
- The LIVE FastAPI routes in `backend/*.py` are the API source of truth; `docs/contracts/API.md` is aspirational and can drift (it describes a polling model the backend never shipped). Read the router, not the doc, before wiring a client.
- Frontend is one Svelte app, no router: `App.svelte` switches on a `writable` flow store. Screens live under `frontend/src/<area>/`.
- Commit subjects: `feat(scope): …` / `fix(scope): …` / `test(scope): …` / `chore(scope): …`, often citing a requirement ID or Gitea number.
## Gates (must pass before claiming work complete)
Run from repo root:
@@ -25,6 +51,14 @@ python tools/check_coverage.py
Exit 0 on both = pass.
## Pitfalls
- E2E mocks the API: `tests/e2e/*.spec.ts` use Playwright `page.route('**/api/v1/**')` with fixture ids — there is **no live backend** under e2e. A green `test:e2e` is not proof the real backend works; that's the human `deploy/uat_walk.sh` (real Woo, never a mock — QUALITY-105).
- Ports on the 5060 UAT host (192.168.1.202): FastAPI `18083`, FE `18082`/`10881` are typically already up (compose on `deploy/docker-compose.yml`). Check before binding; don't assume `18081` is free.
- `SLAB_UAT_UNMASK_SECRETS=1` makes `GET /settings` return decrypted Woo/inference secrets — dev-test only, never set in prod (SETTINGS-75 masking is the default).
- Never hardcode live Woo category/term ids into app code (QUALITY-105 / METADATA-5); resolve groupers by slug (`species`, `wcat`) at runtime.
- `tsc` won't catch Svelte template errors; `npm run build` (which runs `tsc --noEmit`) is the real type gate for the frontend.
## Pointers (read these for everything else)
- Docs map and shipped status: `docs/README.md`
+24 -3
View File
@@ -25,7 +25,7 @@ from starlette.middleware.base import BaseHTTPMiddleware
from .db import get_connection, init_db, probe_images_writable, probe_writable
from . import settings as settings_mod
__version__ = "0.5.0-m3-s0"
__version__ = "0.5.0-m3"
# --- Structured JSON logging (stdlib only) ---
request_id_var: ContextVar[str] = ContextVar("request_id", default="")
@@ -155,7 +155,11 @@ def _error(code: str, message: str, detail: Any = None) -> dict:
@app.get("/api/health")
async def health() -> dict[str, Any]:
"""Health per APP-30/45: non-200 when db or image volume not writable (probe file)."""
"""Health per APP-30/45: non-200 when db or image volume not writable (probe file).
#84: woo_reachable is a real authenticated probe (signal only — store outage
does not fail health).
"""
db_ok = probe_writable()
img_ok = probe_images_writable()
if not (db_ok and img_ok):
@@ -164,10 +168,27 @@ async def health() -> dict[str, Any]:
return {
"status": "ok",
"version": __version__,
"woo_reachable": False, # S0 stub; S4 will implement real check
"woo_reachable": _probe_woo_reachable(),
}
def _probe_woo_reachable() -> bool:
"""Cheap non-failing Woo reachability probe for health (SETTINGS-40 style).
Storage health never depends on this result. Missing creds → False.
"""
try:
base, username, enc_password = _woo_credentials()
if not base or not username or not enc_password:
return False
client = WooClient.from_settings(base, username, enc_password)
# One lightweight authenticated call (categories page 1).
code, _h, _b = client._get("/products/categories?per_page=1&page=1")
return code == 200
except Exception:
return False
@app.get("/api/v1/meta")
async def meta() -> dict[str, Any]:
"""Per API.md §3."""
+19
View File
@@ -79,6 +79,21 @@ _M3_TABLES = [
]
def _migrate(conn: sqlite3.Connection) -> None:
"""Idempotent column additions for volumes created before a column existed.
`CREATE TABLE IF NOT EXISTS` in schema.sql is a no-op on an existing table,
so new columns must be added here. Each guard is a no-op once the column is
present. #90 adds slab_photos.role (lineup kind) for the Review photo strip.
"""
photo_cols = {r[1] for r in conn.execute("PRAGMA table_info(slab_photos)").fetchall()}
if photo_cols and "role" not in photo_cols:
conn.execute(
"ALTER TABLE slab_photos ADD COLUMN role TEXT NOT NULL DEFAULT 'batch' "
"CHECK(role IN ('calibration','overlay','batch'))"
)
def init_db(db_path: str | None = None) -> sqlite3.Connection:
conn = get_connection(db_path)
schema_path = _get_schema_path()
@@ -98,6 +113,10 @@ def init_db(db_path: str | None = None) -> sqlite3.Connection:
conn.executescript(schema)
conn.execute("INSERT OR REPLACE INTO schema_version (version) VALUES (1)")
conn.commit()
# Safe to run every boot: no-op once the columns exist.
with _lock:
_migrate(conn)
conn.commit()
return conn
+3
View File
@@ -54,6 +54,9 @@ CREATE TABLE IF NOT EXISTS slab_photos (
slab_id TEXT NOT NULL,
seq INTEGER NOT NULL,
is_calibration INTEGER NOT NULL DEFAULT 0 CHECK(is_calibration IN (0,1)),
-- #90: lineup kind (clean cal / dimension-overlay copy / batch sibling).
-- Drives the Review photo strip labels and the PUBLISH-45 Woo image order.
role TEXT NOT NULL DEFAULT 'batch' CHECK(role IN ('calibration','overlay','batch')),
original_blob BLOB,
processed_blob BLOB,
width_px INTEGER,
+787 -109
View File
@@ -1,10 +1,15 @@
"""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
import urllib.error
@@ -13,25 +18,139 @@ from typing import Any
from fastapi import APIRouter
from fastapi.responses import JSONResponse
from PIL import Image
from .db import get_connection
from .crypto import decrypt
router = APIRouter(prefix="/api/v1")
# METADATA-35: Call 1 (and Call 2) send clean processed photos at 1024 px long edge or less.
_INFER_MAX_LONG_EDGE = 1024
def fit_long_edge_png(png_bytes: bytes, max_long_edge: int = _INFER_MAX_LONG_EDGE) -> bytes:
"""Downscale a PNG so max(width, height) <= max_long_edge. Never upscale. Keep alpha.
Used only for the inference request payload; stored processed_blob / Woo media stay full size.
Unreadable or non-image bytes are returned unchanged so a bad blob cannot crash Call 1.
"""
if not png_bytes or max_long_edge < 1:
return png_bytes
try:
im = Image.open(io.BytesIO(png_bytes))
im.load()
except Exception:
return png_bytes
w, h = im.size
long_edge = max(w, h)
if long_edge <= max_long_edge:
# Already within budget; re-encode only if we need a stable PNG (caller always base64s).
return png_bytes
scale = max_long_edge / float(long_edge)
nw = max(1, int(round(w * scale)))
nh = max(1, int(round(h * scale)))
# Preserve alpha: convert palette/etc. to RGBA when needed, LANCZOS downscale.
if im.mode not in ("RGB", "RGBA", "L", "LA"):
im = im.convert("RGBA")
out = im.resize((nw, nh), Image.Resampling.LANCZOS)
buf = io.BytesIO()
out.save(buf, format="PNG", optimize=True)
return buf.getvalue()
def _image_part_from_processed(png_bytes: bytes) -> dict:
"""Build one OpenAI-style image_url part from a processed PNG (METADATA-35 sized)."""
fitted = fit_long_edge_png(png_bytes)
b64 = base64.b64encode(fitted).decode()
return {"type": "image_url", "image_url": {"url": f"data:image/png;base64,{b64}"}}
# Locked Call 1 seed (#86 / METADATA-40): expert helper + closed Woo lists only.
CALL1_SEED = (
"Classify the slab's species, wood categories, edge type, figure, and grade from the "
"supplied photos, choosing only from the supplied synced taxonomy lists, and return the "
"result fields as JSON: species_id, wood_category_ids, edge_type_term_id, figure_term_ids, "
"grade_term_id, character, inclusions, voids, checks. Report a confidence (0-1) for each "
"classified field. Map free-text observations to the supplied feature-tag list only. "
"Invent no category or term."
"You are a wood identification expert for retail lumber slabs. Judge only from the photos "
"and the LISTS below. Use list ids only — never invent a name or id.\n"
"\n"
"Work in this order: (1) species, (2) wood categories, (3) edge type, (4) figure, "
"(5) feature tags. Decide wood categories before edge type — edge rules depend on product form.\n"
"\n"
"For each field, pick from its list and give a confidence 0–1 (1 = certain, 0 = no idea). "
"Prefer null or [] over a weak guess.\n"
"\n"
"Species — which tree species is this slab. Match grain, color, and figure to the species "
"list. If more than one entry is plausible, return only the single highest-confidence match "
"(one id). Null only when none are credible.\n"
"\n"
"Wood categories — product form / how it will be sold (live edge, dimensional, cookies & "
"burl slices, turning blanks, etc.). Choose every wood_categories id that clearly fits; "
"often one, sometimes more. [] if none fit. Finish this field before edge type.\n"
"\n"
"Edge type — the long-edge mill profile. One id from edge_terms, or null only if none fit.\n"
" Meanings (match the closest list name):\n"
" • Square / square edge — both long edges milled straight (2 milled long edges).\n"
" • Live one side / Live Edge 1 side — one long edge milled, the other natural bark or "
"waney (1 milled long edge).\n"
" • Live both / Live edge 2 sides — neither long edge milled; both natural "
"(0 milled long edges).\n"
" • Wild edge — use when the three mill-count cases do not fit, including cookies and burl "
"cuts/slices (no conventional slab long-edge pair). Prefer Wild for those forms rather than null.\n"
"\n"
"Figure — visible figure in the wood (curl, quilt, spalt, plain, burl, …). Zero or more ids "
"from figure_terms that clearly apply.\n"
"\n"
"Feature tags — defects and character marks the store tags for shoppers (knots, checks, "
"inclusions, …). Zero or more ids from feature_tags. If that list is empty, return [].\n"
"\n"
"Return one JSON object with exactly these keys:\n"
'{"species_id": <id|null>, "species_confidence": <0-1>, '
'"wood_category_ids": [<id>,...], "wood_category_confidence": <0-1>, '
'"edge_type_term_id": <id|null>, "edge_confidence": <0-1>, '
'"figure_term_ids": [<id>,...], "figure_confidence": <0-1>, '
'"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
@@ -52,19 +171,53 @@ def _hash(text: str) -> str:
def _seed_prompts(conn) -> None:
"""Ensure each call has a prompt row and the shipped seed revision.
If the shipped seed hash is new and the active revision is still seed-authored
(or missing), activate the new seed so deploys pick up CALL1_SEED / CALL2_SEED
updates without clobbering an owner-activated API revision.
"""
for name, seed in _SEEDS.items():
if conn.execute("SELECT 1 FROM prompts WHERE name=?", (name,)).fetchone():
continue
cur = conn.execute(
"INSERT INTO prompts(name, active_revision_id, created_at, updated_at) VALUES (?,?,?,?)",
(name, None, _now(), _now()),
)
pid = cur.lastrowid
cur2 = conn.execute(
"INSERT INTO prompt_revisions(prompt_id, content, content_hash, created_at, created_by) VALUES (?,?,?,?,?)",
(pid, seed, _hash(seed), _now(), "seed"),
)
conn.execute("UPDATE prompts SET active_revision_id=? WHERE id=?", (cur2.lastrowid, pid))
prow = conn.execute("SELECT id, active_revision_id FROM prompts WHERE name=?", (name,)).fetchone()
if prow is None:
cur = conn.execute(
"INSERT INTO prompts(name, active_revision_id, created_at, updated_at) VALUES (?,?,?,?)",
(name, None, _now(), _now()),
)
pid = cur.lastrowid
active_id = None
else:
pid = prow[0]
active_id = prow[1]
h = _hash(seed)
existing = conn.execute(
"SELECT id FROM prompt_revisions WHERE prompt_id=? AND content_hash=?",
(pid, h),
).fetchone()
if existing is None:
cur2 = conn.execute(
"INSERT INTO prompt_revisions(prompt_id, content, content_hash, created_at, created_by) "
"VALUES (?,?,?,?,?)",
(pid, seed, h, _now(), "seed"),
)
seed_rev_id = cur2.lastrowid
else:
seed_rev_id = existing[0]
# Activate shipped seed when nothing active, or active is still a seed revision.
activate = False
if active_id is None:
activate = True
else:
act = conn.execute(
"SELECT created_by FROM prompt_revisions WHERE id=?", (active_id,)
).fetchone()
if act is None or act[0] == "seed":
activate = True
if activate and active_id != seed_rev_id:
conn.execute(
"UPDATE prompts SET active_revision_id=?, updated_at=? WHERE id=?",
(seed_rev_id, _now(), pid),
)
def _active_prompt(conn, call: str) -> tuple[int, str] | None:
@@ -185,62 +338,86 @@ 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)
act = _active_prompt(conn, "call1")
prompt_text = act[1] if act else CALL1_SEED
# Build vision message with processed photos (1024px) + taxonomy lists.
photos = conn.execute(
"SELECT processed_blob FROM slab_photos WHERE slab_id=? AND processed_blob IS NOT NULL ORDER BY seq LIMIT 8",
(slab_id,),
).fetchall()
image_parts = []
for p in photos:
image_parts.append({
"type": "image_url",
"image_url": {"url": f"data:image/png;base64,{base64.b64encode(p[0]).decode()}"},
})
tax = _taxonomy_lists(conn)
text = (f"{prompt_text}\n\nSYNCED TAXONOMY (only legal choices):\n{json.dumps(tax)}\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)
# 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:
"""Strip leading feat- / feat_ for the model (#86); Woo id stays authoritative."""
n = (name or "").strip()
low = n.lower()
for prefix in ("feat-", "feat_"):
if low.startswith(prefix):
return n[len(prefix):].strip() or n
return n
def _taxonomy_lists(conn) -> dict:
out = {"species_leaves": [], "edge_terms": [], "figure_terms": [], "grade_terms": [],
"thickness_terms": [], "moisture_terms": [], "feat_tags": []}
for r in conn.execute("SELECT id,name FROM woo_categories WHERE parent_id IS NULL").fetchall():
out["species_leaves"].append({"id": r[0], "name": r[1]})
for attr, key in (("Edge Type", "edge_terms"), ("Figure", "figure_terms"), ("Grade", "grade_terms"),
("Thickness", "thickness_terms"), ("Moisture", "moisture_terms")):
"""Closed Call 1 lists only (#86 / METADATA-40).
Keys match the seed prompt. No grade/thickness/moisture (owner fields).
Feature tag names are shown without the feat- prefix; ids are Woo/local tag ids.
Groupers resolved by slug only (QUALITY-105).
"""
out: dict[str, list] = {
"species": [],
"wood_categories": [],
"edge_terms": [],
"figure_terms": [],
"feature_tags": [],
}
# species = leaves under the 'species' grouper
sp = conn.execute("SELECT id FROM woo_categories WHERE slug='species'").fetchone()
if sp:
for r in conn.execute(
"SELECT id,name FROM woo_categories WHERE parent_id=?", (sp[0],)
).fetchall():
out["species"].append({"id": r[0], "name": r[1]})
# wood categories = leaves under the 'wcat' grouper (leaves may sit under sub-groupers).
wc = conn.execute("SELECT id FROM woo_categories WHERE slug='wcat'").fetchone()
if wc:
kids = [r[0] for r in conn.execute(
"SELECT id FROM woo_categories WHERE parent_id=?", (wc[0],)
).fetchall()]
leaf_ids = []
for cid in kids:
has_kids = conn.execute(
"SELECT 1 FROM woo_categories WHERE parent_id=? LIMIT 1", (cid,)
).fetchone()
if has_kids:
leaf_ids.extend(r[0] for r in conn.execute(
"SELECT id FROM woo_categories WHERE parent_id=?", (cid,)
).fetchall())
else:
leaf_ids.append(cid)
if leaf_ids:
q = ",".join("?" * len(leaf_ids))
for r in conn.execute(
"SELECT id,name FROM woo_categories WHERE id IN (%s)" % q, leaf_ids
).fetchall():
out["wood_categories"].append({"id": r[0], "name": r[1]})
for attr, key in (("Edge type", "edge_terms"), ("Figure", "figure_terms")):
rows = conn.execute(
"SELECT t.id,t.name FROM woo_attribute_terms t JOIN woo_attributes a ON t.attribute_id=a.id WHERE a.name=?",
"SELECT t.id,t.name FROM woo_attribute_terms t JOIN woo_attributes a ON t.attribute_id=a.id "
"WHERE lower(a.name)=lower(?)",
(attr,),
).fetchall()
out[key] = [{"id": r[0], "name": r[1]} for r in rows]
for r in conn.execute("SELECT id,name FROM woo_tags WHERE slug LIKE 'feat-%'").fetchall():
out["feat_tags"].append({"id": r[0], "name": r[1]})
out["feature_tags"].append({"id": r[0], "name": _feat_display_name(r[1])})
return out
@@ -257,58 +434,452 @@ def _parse_json(raw: str) -> dict:
def _gate(parsed: dict) -> dict:
"""Apply the 0.7 confidence gate; below-threshold fields are omitted (METADATA-45)."""
def keep(field: str, conf_key: str) -> Any:
conf = parsed.get(conf_key)
"""Apply the 0.7 confidence gate; below-threshold fields are omitted (METADATA-45).
Confidence is read from the flat '<field>_confidence' keys; as a safety net it also
understands the nested 'confidence: {<field>: n}' shape some models emit, so a shape
wobble cannot silently nuke every suggestion. Grade is never suggested (METADATA-40).
Free-text observation bags are dropped — feature tags are ids only (#86).
"""
conf_key = {
"species_id": "species_confidence",
"wood_category_ids": "wood_category_confidence",
"edge_type_term_id": "edge_confidence",
"figure_term_ids": "figure_confidence",
"feature_tag_ids": "feature_tag_confidence",
}
conf_obj = parsed.get("confidence") if isinstance(parsed.get("confidence"), dict) else {}
def _conf(field: str) -> Any:
k = conf_key.get(field, f"{field}_confidence")
if k in conf_obj:
return conf_obj[k]
return parsed.get(k)
def keep(field: str) -> Any:
conf = _conf(field)
if isinstance(conf, (int, float)) and conf >= _THRESHOLD and field in parsed:
return parsed[field]
return None
out: dict[str, Any] = {}
if v := keep("species_id", "species_confidence"):
if v := keep("species_id"):
out["species_id"] = v
if v := keep("wood_category_ids", "wood_category_confidence"):
if v := keep("wood_category_ids"):
out["wood_category_ids"] = v
if v := keep("edge_type_term_id", "edge_confidence"):
if v := keep("edge_type_term_id"):
out["edge_type_term_id"] = v
if v := keep("figure_term_ids", "figure_confidence"):
if v := keep("figure_term_ids"):
out["figure_term_ids"] = v
if v := keep("grade_term_id", "grade_confidence"):
out["grade_term_id"] = v
for k in ("character", "inclusions", "voids", "checks"):
if parsed.get(k):
out[k] = parsed[k]
if v := keep("feature_tag_ids"):
out["feature_tag_ids"] = v
# grade / free-text bags intentionally omitted (METADATA-40, #86).
return out
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: 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]
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()
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"))
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)
act = _active_prompt(conn, "call2")
prompt_text = act[1] if act else CALL2_SEED
photos = conn.execute(
"SELECT processed_blob FROM slab_photos WHERE slab_id=? AND processed_blob IS NOT NULL ORDER BY seq LIMIT 8",
(slab_id,),
).fetchall()
image_parts = [
{"type": "image_url", "image_url": {"url": f"data:image/png;base64,{base64.b64encode(p[0]).decode()}"}}
for p in photos
]
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 ''}")
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:
@@ -318,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)
# 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:
@@ -336,12 +920,106 @@ def server_description(slab: dict) -> str:
)
def _photo_meta_row(photo_id: str, seq: int, blob: bytes) -> dict[str, Any]:
"""Metadata for one photo slot in the inference log (#87)."""
edge = None
try:
im = Image.open(io.BytesIO(blob))
im.load()
edge = max(im.size)
except Exception:
edge = None
return {
"photo_id": photo_id,
"seq": seq,
"bytes": len(blob) if blob is not None else 0,
"infer_long_edge_px": edge,
"label": f"photo seq={seq} id={photo_id}",
}
def _redact_request_for_log(messages: list, photo_meta: list[dict] | None = None) -> list:
"""Replace image data-URLs with photo refs for inference_log (#87 / METADATA-66).
Wire messages still carry base64 for the model; only the logged copy is redacted.
Text parts are kept in full. Image parts become type=image_ref using photo_meta by
image-part index (same order as built from slab_photos).
"""
meta = list(photo_meta or [])
out: list = []
img_i = 0
for msg in messages:
if not isinstance(msg, dict):
out.append(msg)
continue
content = msg.get("content")
if not isinstance(content, list):
out.append(msg)
continue
new_parts: list = []
for part in content:
if not isinstance(part, dict):
new_parts.append(part)
continue
ptype = part.get("type")
if ptype == "text":
new_parts.append({"type": "text", "text": part.get("text", "")})
continue
if ptype == "image_url":
url = ""
iu = part.get("image_url")
if isinstance(iu, dict):
url = str(iu.get("url") or "")
if url.startswith("data:image") or "base64," in url:
ref = {
"type": "image_ref",
"photo_id": None,
"seq": None,
"bytes": None,
"infer_long_edge_px": None,
"label": f"photo slot={img_i}",
}
if img_i < len(meta):
m = meta[img_i]
ref.update({
"photo_id": m.get("photo_id"),
"seq": m.get("seq"),
"bytes": m.get("bytes"),
"infer_long_edge_px": m.get("infer_long_edge_px"),
"label": m.get("label") or ref["label"],
})
new_parts.append(ref)
img_i += 1
else:
# Non-data URL (rare): keep URL string only, never invent bytes
new_parts.append({
"type": "image_ref",
"url": url[:200],
"label": f"external image slot={img_i}",
})
img_i += 1
continue
new_parts.append(part)
out.append({**msg, "content": new_parts})
return out
_LOG_PAYLOAD_CAP = 200_000
def _log_call(conn, slab_id: str, call: int, rev_id: int | None, model: str,
request: list, response: str, latency_ms: int) -> None:
request: list, response: str, latency_ms: int,
photo_meta: list[dict] | None = None) -> None:
"""Append inference_log with redacted request (no image bytes) — #87 / METADATA-66."""
redacted = _redact_request_for_log(request, photo_meta)
payload = json.dumps(redacted, ensure_ascii=False)
if len(payload) > _LOG_PAYLOAD_CAP:
# Safety net only; after redaction normal calls are far under the cap.
payload = payload[: _LOG_PAYLOAD_CAP - 20] + "\n/* truncated */"
conn.execute(
"INSERT INTO inference_log(slab_id, call, prompt_revision_id, model, request_payload, response_payload, latency_ms, created_at) "
"VALUES (?,?,?,?,?,?,?,?)",
(slab_id, call, rev_id, model, json.dumps(request)[:200000], response, latency_ms, _now()),
(slab_id, call, rev_id, model, payload, response, latency_ms, _now()),
)
conn.commit()
@@ -361,4 +1039,4 @@ async def test_inference() -> dict:
except InferenceError as e:
return {"ok": False, "mode": "fail", "vision_capable": False, "detail": str(e)}
detail = f"reachable in {int((time.time()-t0)*1000)}ms"
return {"ok": True, "mode": "stateless", "vision_capable": True, "detail": detail}
return {"ok": True, "mode": "stateless", "vision_capable": True, "detail": detail}
+25 -6
View File
@@ -26,7 +26,7 @@ def _get_slab(conn, slab_id: str):
def _get_photo(conn, slab_id: str, photo_id: str):
row = conn.execute(
"SELECT id, original_blob, processed_blob, width_px, height_px, is_calibration, seq "
"SELECT id, original_blob, processed_blob, width_px, height_px, is_calibration, seq, role "
"FROM slab_photos WHERE id=? AND slab_id=?",
(photo_id, slab_id),
).fetchone()
@@ -44,6 +44,7 @@ async def upload_photo(
photo_id: str = Form(...),
seq: int | None = Form(None),
is_calibration: int = Form(0),
role: str = Form("batch"),
original: UploadFile = File(...),
processed: UploadFile | None = File(None),
) -> dict:
@@ -52,6 +53,8 @@ async def upload_photo(
return JSONResponse(status_code=404, content=_error("not_found", "Slab not found"))
if is_calibration not in (0, 1):
return JSONResponse(status_code=400, content=_error("validation", "is_calibration must be 0 or 1"))
if role not in ("calibration", "overlay", "batch"):
return JSONResponse(status_code=400, content=_error("validation", "role must be calibration, overlay, or batch"))
orig_bytes = await original.read()
proc_bytes = await processed.read() if processed is not None else None
if not orig_bytes:
@@ -60,14 +63,14 @@ async def upload_photo(
conn.execute("DELETE FROM slab_photos WHERE id=? AND slab_id=?", (photo_id, slab_id))
seq_val = seq if seq is not None else _next_seq(conn, slab_id)
conn.execute(
"INSERT INTO slab_photos(id, slab_id, seq, is_calibration, original_blob, processed_blob, created_at) "
"VALUES (?,?,?,?,?,?,?)",
(photo_id, slab_id, seq_val, is_calibration, orig_bytes, proc_bytes, _now()),
"INSERT INTO slab_photos(id, slab_id, seq, is_calibration, role, original_blob, processed_blob, created_at) "
"VALUES (?,?,?,?,?,?,?,?)",
(photo_id, slab_id, seq_val, is_calibration, role, orig_bytes, proc_bytes, _now()),
)
conn.commit()
row = _get_photo(conn, slab_id, photo_id)
return {"id": row["id"], "slab_id": slab_id, "seq": row["seq"], "is_calibration": row["is_calibration"],
"original_retained": True, "processed_retained": row["processed_blob"] is not None}
"role": row["role"], "original_retained": True, "processed_retained": row["processed_blob"] is not None}
def _now() -> str:
@@ -108,9 +111,25 @@ async def list_photos(slab_id: str) -> list[dict]:
if _get_slab(conn, slab_id) is None:
return JSONResponse(status_code=404, content=_error("not_found", "Slab not found"))
rows = conn.execute(
"SELECT id, seq, is_calibration, width_px, height_px, "
"SELECT id, seq, is_calibration, role, width_px, height_px, "
"(original_blob IS NOT NULL) AS has_original, (processed_blob IS NOT NULL) AS has_processed "
"FROM slab_photos WHERE slab_id=? ORDER BY seq",
(slab_id,),
).fetchall()
return [dict(r) for r in rows]
@router.delete("/{slab_id}/photos/{photo_id}", status_code=204)
async def delete_photo(slab_id: str, photo_id: str):
"""#90: drop a photo the owner removed in Review (keep/remove edit). The
remaining photos are re-numbered by the client via re-upload."""
conn = get_connection()
if _get_slab(conn, slab_id) is None:
return JSONResponse(status_code=404, content=_error("not_found", "Slab not found"))
cur = conn.execute(
"DELETE FROM slab_photos WHERE id=? AND slab_id=?", (photo_id, slab_id)
)
conn.commit()
if cur.rowcount == 0:
return JSONResponse(status_code=404, content=_error("not_found", "Photo not found"))
return Response(status_code=204)
+50 -30
View File
@@ -16,7 +16,14 @@ from fastapi import APIRouter
from fastapi.responses import JSONResponse
from .db import get_connection
from .woo import WooAuthError, WooClient, WooConnectionError, WooCredentialsMissing, WooError
from .woo import (
WooAuthError,
WooClient,
WooConnectionError,
WooCredentialsMissing,
WooError,
WooForbiddenError,
)
from .review import compute_ready, _slab_dict
router = APIRouter(prefix="/api/v1/slabs")
@@ -72,21 +79,24 @@ def _build_payload(slab: dict, conn, status: str) -> dict:
tags = [t for t in (slab.get("fig_tag_ids") or [])]+[t for t in (slab.get("feat_tag_ids") or [])]
if tags:
payload["tags"] = [{"id": t} for t in tags]
# Five locked attributes by synced term name.
# Five locked attributes: resolve the attribute row from the synced cache by
# slug first (METADATA-5 / QUALITY-105 — never hardcode live Woo attribute ids),
# then fall back to case-insensitive name. Term *options* are always the
# names of the selected synced terms.
terms = {r["id"]: dict(r) for r in conn.execute("SELECT * FROM woo_attribute_terms").fetchall()}
attrs = {r["id"]: dict(r) for r in conn.execute("SELECT * FROM woo_attributes").fetchall()}
built_attrs = []
for field, attr_name, pos in (
("edge_type_term_id", "Edge Type", 0),
("figure_term_ids", "Figure", 1),
("grade_term_id", "Grade", 2),
("thickness_term_id", "Thickness", 3),
("moisture_term_id", "Moisture", 4),
for field, attr_slug, attr_name, pos in (
("edge_type_term_id", "edge-type", "Edge Type", 0),
("figure_term_ids", "pa_figure", "Figure", 1),
("grade_term_id", "pa_grade", "Grade", 2),
("thickness_term_id", "pa_thickness", "Thickness", 3),
("moisture_term_id", "pa_moisture", "Moisture", 4),
):
raw = slab.get(field)
term_ids = raw if isinstance(raw, list) else ([raw] if raw else [])
options = [terms[t]["name"] for t in term_ids if t in terms]
attr_id = _attr_id_by_name(attrs, attr_name)
attr_id = _attr_id_from_synced(attrs, attr_slug, attr_name)
if attr_id and options:
built_attrs.append({"id": attr_id, "position": pos, "visible": True, "variation": False, "options": options})
if built_attrs:
@@ -94,19 +104,35 @@ def _build_payload(slab: dict, conn, status: str) -> dict:
return payload
def _attr_id_by_name(attrs: dict, name: str) -> int | None:
def _attr_id_from_synced(attrs: dict, slug: str, name: str) -> int | None:
"""Match a locked attribute against the synced Woo set by slug, then name."""
slug_l = slug.lower().lstrip()
# Accept both "figure" and "pa_figure" style slugs from Woo.
slug_alts = {slug_l, slug_l.removeprefix("pa_"), f"pa_{slug_l.removeprefix('pa_')}"}
for a in attrs.values():
if a.get("name", "").lower() == name.lower():
s = (a.get("slug") or "").lower()
if s in slug_alts:
return a["id"]
name_l = name.lower()
for a in attrs.values():
if (a.get("name") or "").lower() == name_l:
return a["id"]
return None
def _classify(e: Exception) -> tuple[str, str, bool]:
"""Return (code, plain message, recoverable)."""
"""Return (code, plain message, recoverable). PUBLISH-36/37/38."""
if isinstance(e, WooCredentialsMissing):
return "woo_unconfigured", "Woo is not configured. Set the store credentials in Settings.", False
if isinstance(e, WooAuthError):
return "woo_auth", "The store rejected the credentials. Check the app password.", False
if isinstance(e, WooForbiddenError):
# WAF / security plugin block — retrying the same request will not help.
return (
"woo_forbidden",
"The store blocked this request (security filter). Check the store firewall or User-Agent allowlist — retrying will not help.",
False,
)
if isinstance(e, WooConnectionError):
return "woo_unreachable", "The store is unreachable right now. You can retry.", True
return "woo_failed", "The store create failed. You can retry.", True
@@ -150,9 +176,16 @@ async def publish(slab_id: str) -> dict:
code, msg, rec = _classify(e)
return JSONResponse(status_code=502, content=_error(code, msg, {"recoverable": rec}))
# 4/5. Upload processed PNGs, then POST product.
# PUBLISH-45: Woo image order is the photos-ready lineup — clean calibration,
# its dimension-overlay copy, then the remaining batch photos. The Review
# photo strip stores the owner's keep order (leftmost = primary), so the
# overlay (which can sit after a moved batch photo) is re-ordered here by
# role rather than by stored seq. Ties fall back to stored seq.
status = "draft" if _is_uat(slab["sku"]) else _create_status(conn)
photos = conn.execute(
"SELECT id, processed_blob FROM slab_photos WHERE slab_id=? AND processed_blob IS NOT NULL ORDER BY seq",
"SELECT id, processed_blob, role FROM slab_photos "
"WHERE slab_id=? AND processed_blob IS NOT NULL "
"ORDER BY CASE role WHEN 'calibration' THEN 0 WHEN 'overlay' THEN 1 ELSE 2 END, seq",
(slab_id,),
).fetchall()
uploaded_ids: list[int] = []
@@ -202,26 +235,13 @@ def _create_status(conn) -> str:
def _sku_exists(client: WooClient, sku: str) -> bool:
code, _h, body = client._request(
f"{client.base_url}/wp-json/wc/v3/products?sku={sku}",
"GET", None,
{"Authorization": f"Basic {client._auth}", "Accept": "application/json"},
)
if code != 200:
raise WooError(f"sku lookup returned {code}")
return len(json.loads(body)) > 0
"""SKU lookup via WooClient so User-Agent and 403 typing apply (#79)."""
return len(client.get_products_by_sku(sku)) > 0
def _create_product(client: WooClient, payload: dict) -> int:
body = json.dumps(payload).encode("utf-8")
code, _h, resp = client._request(
f"{client.base_url}/wp-json/wc/v3/products",
"POST", body,
{"Authorization": f"Basic {client._auth}", "Content-Type": "application/json", "Accept": "application/json"},
)
if code not in (200, 201):
raise WooError(f"product create returned {code}")
return int(json.loads(resp)["id"])
"""Product create via WooClient so User-Agent and 403 typing apply (#79)."""
return client.create_product(payload)
def _log(conn, slab_id: str, woo_product_id: int | None, status: str, message: str) -> None:
+1
View File
@@ -5,4 +5,5 @@ python-multipart==0.0.9
pytest==8.3.3
httpx==0.27.2
cryptography==43.0.1
Pillow==11.3.0
# stdlib only for sqlite3, logging, json, contextvars, threading
+95 -37
View File
@@ -5,6 +5,7 @@ METADATA-80/85/90, SETTINGS-35.
import json
import re
from datetime import datetime, timezone
from typing import Any
from fastapi import APIRouter
@@ -34,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",
@@ -44,6 +46,10 @@ def _error(code: str, message: str, detail: Any = None) -> dict:
return {"error": {"code": code, "message": message, "detail": detail}}
def _now() -> str:
return datetime.now(timezone.utc).replace(microsecond=0, tzinfo=None).isoformat()
def _split_length(length_in: float) -> tuple[int, float]:
ft = int(length_in // 12)
rem = round(length_in - ft * 12, 1)
@@ -66,14 +72,16 @@ def _dims(length_in: float, width_avg_in: float, thickness_in: float) -> str:
return f"{_fmt1(length_in)} x {_fmt1(width_avg_in)} x {_fmt2(thickness_in)} in"
def build_title(slab: dict, figure: str | None, species_name: str, primary_leaf: dict) -> str:
def build_title(slab: dict, figure: str | None, species_name: str, primary_leaf: dict, terms: dict | None = None) -> str:
pattern, noun, carries_dims = _LEAF_PATTERNS.get(
primary_leaf.get("slug"), ("{figure}{species} Slab", "Slab", False)
)
figure = figure.strip() if figure else ""
live_edge = ""
if primary_leaf["slug"] in ("dimensional-wood", "bowl-blanks"):
live_edge = "Live Edge " if slab.get("edge_type_term_id") else ""
if primary_leaf.get("slug") in ("dimensional-wood", "bowl-blanks"):
# "Live Edge" is a product-form flag driven by the *synced* edge term
# (METADATA-5 / #98) — never treat any non-null edge id as Live Edge.
live_edge = "Live Edge " if _edge_is_live(slab.get("edge_type_term_id"), terms or {}) else ""
title = pattern.format(
figure=figure + " " if figure else "",
species=species_name,
@@ -84,8 +92,17 @@ def build_title(slab: dict, figure: str | None, species_name: str, primary_leaf:
return re.sub(r"\s+", " ", title).strip()
def build_short_description(slab: dict, figure: str | None, species_name: str, primary_leaf: dict) -> str:
title = build_title(slab, figure, species_name, primary_leaf)
def _edge_is_live(edge_term_id: Any, terms: dict) -> bool:
"""True when the synced edge term name/slug indicates live edge."""
if not edge_term_id:
return False
term = terms.get(edge_term_id) or {}
blob = f"{term.get('name', '')} {term.get('slug', '')}".lower()
return "live" in blob
def build_short_description(slab: dict, figure: str | None, species_name: str, primary_leaf: dict, terms: dict | None = None) -> str:
title = build_title(slab, figure, species_name, primary_leaf, terms)
ft, rem = _split_length(slab["length_in"])
length_str = f"{ft}ft {_fmt1(rem)}in"
bdft = round(slab["bdft"], 1)
@@ -114,8 +131,20 @@ def missing_required(slab: dict) -> list[str]:
return [f for f in _REQUIRED if slab.get(f) in (None, "", [])]
# REVIEW-45: SKU is 3 to 24 chars, alphanumeric and hyphen only. The middle tier
# (not the DB CHECK) is the business-logic arbiter here; the DB constraint is a backstop.
_SKU_RE = re.compile(r"^[A-Za-z0-9-]{3,24}$")
def sku_valid(sku: Any) -> bool:
return isinstance(sku, str) and bool(_SKU_RE.match(sku))
def compute_ready(slab: dict) -> tuple[bool, list[str]]:
missing = missing_required(slab)
# A present-but-malformed SKU is not ready (business rule, enforced in the middle tier).
if "sku" not in missing and not sku_valid(slab.get("sku")):
missing.append("sku")
return (len(missing) == 0), missing
@@ -130,6 +159,13 @@ def price_suggestion(conn, slab: dict) -> float | None:
@router.post("/{slab_id}/review")
async def review_slab(slab_id: str, payload: dict[str, Any] | None = None) -> dict:
"""Compute ready + deterministic title/short_description from *locked* taxonomy.
METADATA-80 / REVIEW-10 / #89: title and short_description are assembled only
from the confirmed (persisted) listing facts. Owner-supplied title/short_desc
win (METADATA-50). Assembled values are written back to the slab so the lock
sticks for publish.
"""
payload = payload or {}
conn = get_connection()
row = conn.execute(
@@ -138,46 +174,68 @@ async def review_slab(slab_id: str, payload: dict[str, Any] | None = None) -> di
if row is None:
return JSONResponse(status_code=404, content=_error("not_found", "Slab not found"))
slab = _slab_dict(row)
# Apply any review edits (primary category declaration + prose).
editable = {"primary_wood_category_id", "title", "short_description", "description", "price", "price_source", "sku", "content_source"}
for k in editable:
if k in payload and payload[k] is not None:
if k == "primary_wood_category_id":
continue # not stored as a column; derived
slab[k] = payload[k]
# --- Deterministic review: report ready/missing + the deterministic
# title/short_description built from the current metadata. The client's
# "Create title" / "Create short desc" buttons call this endpoint and write the
# returned strings into the fields (#89: no separate lock action).
# #80: persist owner overrides (price/sku) from the payload so the review
# endpoint is self-contained. title/short_description are NOT overridden here —
# they are produced by the deterministic build below, not persisted from the
# payload (so the review POST can't clobber the owner's Create workflow).
cats, terms = _taxonomy_lookup(conn)
# Primary wood category: declared, else first wood_category_ids, else first category.
primary_id = payload.get("primary_wood_category_id")
wood_cats = slab.get("wood_category_ids") or []
if not primary_id:
primary_id = wood_cats[0] if wood_cats else None
primary_leaf = cats.get(primary_id, {})
species_name = cats.get(slab.get("species_id"), {}).get("name", "")
primary_leaf = cats.get(primary_id, {}) if primary_id else {}
species_name = cats.get(slab.get("species_id"), {}).get("name", "") if slab.get("species_id") else ""
figure_term_ids = slab.get("figure_term_ids") or []
figure = terms.get(figure_term_ids[0], {}).get("name") if figure_term_ids else None
# Deterministic copy (only if not already overridden by owner).
if not payload.get("title"):
if primary_leaf and species_name:
slab["title"] = build_title(slab, figure, species_name, primary_leaf)
if not payload.get("short_description"):
if primary_leaf and species_name:
slab["short_description"] = build_short_description(slab, figure, species_name, primary_leaf)
# Price suggestion (REVIEW-25): bdft * species rate, empty if no rate; owner override sticks.
suggested = price_suggestion(conn, slab)
if payload.get("price") is not None:
price = payload["price"]
elif slab.get("price") not in (None, 0):
price = slab["price"]
else:
price = suggested
ready, missing = compute_ready({**slab, "price": price})
# #80: persist owner overrides from the payload (price/sku only).
overrides: dict[str, Any] = {}
for k in ("price", "sku"):
if k in payload and payload[k] is not None:
overrides[k] = payload[k]
if overrides:
set_clause = ", ".join(f"{k} = ?" for k in overrides)
conn.execute(
f"UPDATE slabs SET {set_clause}, server_rev=server_rev+1, updated_at=? WHERE id=?",
[*overrides.values(), _now(), slab_id],
)
conn.commit()
slab.update(overrides)
# Deterministic title / short_description, re-derived from the current
# metadata on every review (the Create button's source of truth).
# #98: pass synced terms so Live Edge comes from the edge term, not any id.
title = (
build_title(slab, figure, species_name, primary_leaf, terms)
if (primary_leaf and species_name)
else slab.get("title") or None
)
short_desc = (
build_short_description(slab, figure, species_name, primary_leaf, terms)
if (primary_leaf and species_name)
else slab.get("short_description") or None
)
price = _resolve_price(conn, slab, payload)
ready, missing = compute_ready({**slab, "price": price, "title": title, "short_description": short_desc})
return {
"ready": ready,
"missing": missing,
"title": slab.get("title"),
"short_description": slab.get("short_description"),
"price": price,
"price_suggestion": suggested,
"ready": ready, "missing": missing, "title": title, "short_description": short_desc,
"price": price, "price_suggestion": price_suggestion(conn, slab),
"primary_wood_category_id": primary_id,
}
def _resolve_price(conn, slab: dict, payload: dict) -> float | None:
"""REVIEW-25: bdft * species rate as the suggestion; owner override sticks,
else the stored price, else the suggestion (None if no rate)."""
suggested = price_suggestion(conn, slab)
if payload.get("price") is not None:
return payload["price"]
if slab.get("price") not in (None, 0):
return slab["price"]
return suggested
+139 -18
View File
@@ -13,6 +13,7 @@ from fastapi import APIRouter
from fastapi.responses import JSONResponse, Response
from .db import get_connection
from .thickness_band import load_thickness_terms, resolve_thickness_band_id
router = APIRouter(prefix="/api/v1/slabs")
@@ -42,6 +43,20 @@ TAXONOMY_TABLES = {
"fig_tag_ids": "woo_tags",
"feat_tag_ids": "woo_tags",
}
# Owner-facing labels for stale_field messages (QUALITY-120 — never raw column names).
FIELD_LABELS = {
"species_id": "Species",
"wood_category_ids": "Wood category",
"edge_type_term_id": "Edge type",
"figure_term_ids": "Figure",
"grade_term_id": "Grade",
"thickness_term_id": "Thickness",
"moisture_term_id": "Moisture",
"fig_tag_ids": "Figure tags",
"feat_tag_ids": "Feature tags",
}
PRICE_SOURCES = {"recommendation", "override", "manual"}
INFERENCE_STATUSES = {"idle", "inferring", "done", "failed", "skipped"}
def _now_iso() -> str:
@@ -64,6 +79,12 @@ def _encode(payload: Dict[str, Any]) -> Dict[str, Any]:
return out
# #89: server-managed fields that a plain client PUT must not set directly.
# (metadata_locked was dropped — the deterministic title/short-desc are built by
# the review endpoint and written back by the client's Create buttons.)
CLIENT_FORBIDDEN_FIELDS = set()
def _row_to_slab(row) -> Dict[str, Any]:
d = dict(row)
for f in JSON_ARRAY_FIELDS:
@@ -95,21 +116,117 @@ def _check_taxonomy(conn, table: str, val: Any) -> bool:
return conn.execute(f"SELECT 1 FROM {table} WHERE id=?", (val,)).fetchone() is not None
def _current_options(conn, table: str) -> list[dict[str, Any]]:
"""Live taxonomy options for a stale_field response (PUBLISH-20 / #82)."""
rows = conn.execute(f"SELECT id, name FROM {table} ORDER BY name, id").fetchall()
return [{"id": r["id"], "name": r["name"]} for r in rows]
def _normalize_tax_val(val: Any) -> Any:
if isinstance(val, str):
try:
return json.loads(val)
except (ValueError, TypeError):
return []
return val
def _validate_taxonomy_fields(conn, payload: Dict[str, Any]) -> JSONResponse | None:
"""Return a 422 stale_field response, or None if all present taxonomy ids are live."""
for field, table in TAXONOMY_TABLES.items():
if field not in payload or payload[field] is None:
continue
val = _normalize_tax_val(payload[field])
if not _check_taxonomy(conn, table, val):
label = FIELD_LABELS.get(field, field)
return _err(
"stale_field",
f"{label} is no longer in the store taxonomy",
{"field": field, "current_options": _current_options(conn, table)},
status=422,
)
return None
def _validate_enums(payload: Dict[str, Any]) -> JSONResponse | None:
if "price_source" in payload and payload["price_source"] is not None:
if payload["price_source"] not in PRICE_SOURCES:
return _err(
"validation",
"price_source is not a known value",
{"field": "price_source", "allowed": sorted(PRICE_SOURCES)},
status=400,
)
if "inference_status" in payload and payload["inference_status"] is not None:
if payload["inference_status"] not in INFERENCE_STATUSES:
return _err(
"validation",
"inference_status is not a known value",
{"field": "inference_status", "allowed": sorted(INFERENCE_STATUSES)},
status=400,
)
return None
# Capture-only fields required to create a slab. The Review screen fills the
# taxonomy/content fields after create (CREATE.md §1: "every other field stays
# editable until create"), so create must accept a capture-math payload.
# client_rev is server-managed (set to 1 at create) — never required from the client.
REQUIRED_CREATE_FIELDS = [
"id", "sku", "length_in", "thickness_in", "sqft", "bdft",
"width_min_in", "width_max_in", "width_avg_in",
]
@router.post("", status_code=201)
async def create_slab(payload: Dict[str, Any]) -> Dict[str, Any]:
missing = [f for f in ALLOWED_FIELDS if f not in payload]
missing = [f for f in REQUIRED_CREATE_FIELDS if f not in payload]
if missing:
return _err("validation", "missing fields", {"fields": missing}, status=400)
# #83: reject unknown keys (client sent a field that is not client-settable).
# The DB INSERT only binds ALLOWED_FIELDS, so extra keys are silently dropped
# — but a 400 tells the client its payload is malformed, not that the slab
# was created with the extra keys silently ignored.
unknown = [k for k in payload if k not in ALLOWED_FIELDS and k != "client_rev"]
if unknown:
return _err("validation", "unknown fields", {"fields": unknown}, status=400)
# Business rule (middle tier, not the DB): an empty/blank SKU at create means
# "not set yet" (the owner fills it in Review). Store NULL — the DB CHECK only
# governs present SKUs, and NULL is the honest "absent" representation.
sku = payload.get("sku")
if isinstance(sku, str) and sku.strip() == "":
payload = {**payload, "sku": None}
enum_err = _validate_enums(payload)
if enum_err is not None:
return enum_err
conn = get_connection()
if _sku_taken(conn, payload["sku"]):
tax_err = _validate_taxonomy_fields(conn, payload)
if tax_err is not None:
return tax_err
if payload.get("sku") is not None and _sku_taken(conn, payload["sku"]):
return _err("duplicate_sku", "SKU already exists", {"sku": payload["sku"]}, status=409)
# METADATA-15 / QUALITY-95: the thickness band is derived, never owner-chosen.
# Resolve it from the measured thickness against the live synced Woo bands and
# overwrite whatever the client sent (the UI has no band control).
band = resolve_thickness_band_id(payload.get("thickness_in"), load_thickness_terms(conn))
if band is not None:
payload = {**payload, "thickness_term_id": band}
now = _now_iso()
data = _encode(payload)
cols = [f for f in ALLOWED_FIELDS if f in data] + ["status", "client_rev", "server_rev", "deleted_at", "created_at", "updated_at"]
vals = [data[f] for f in cols if f in data] + ["calibrated", 1, 1, None, now, now]
allowed_cols = [f for f in ALLOWED_FIELDS if f in data]
server_cols = ["status", "client_rev", "server_rev", "deleted_at", "created_at", "updated_at"]
server_vals = ["calibrated", 1, 1, None, now, now]
cols = allowed_cols + server_cols
# One value per col, in cols order: data values for the allowed fields, then the 6 server values.
vals = [data[f] for f in allowed_cols] + server_vals
ph = ",".join(["?"] * len(cols))
conn.execute(f"INSERT INTO slabs ({', '.join(cols)}) VALUES ({ph})", vals)
conn.commit()
assert len(vals) == len(cols), f"create_slab binding mismatch: {len(vals)} vals vs {len(cols)} cols"
try:
conn.execute(f"INSERT INTO slabs ({', '.join(cols)}) VALUES ({ph})", vals)
conn.commit()
except Exception:
# QUALITY-120: never surface a driver stack to the owner.
return _err("validation", "Could not create the slab. Check the fields and try again.", status=400)
row = conn.execute("SELECT * FROM slabs WHERE id=?", (payload["id"],)).fetchone()
return _row_to_slab(row)
@@ -134,6 +251,13 @@ async def get_slab(id: str) -> Dict[str, Any]:
async def update_slab(id: str, payload: Dict[str, Any]) -> Dict[str, Any]:
if "client_rev" not in payload:
return _err("validation", "client_rev required", status=400)
# #89: server-managed fields a plain PUT must not set.
for field in CLIENT_FORBIDDEN_FIELDS:
if field in payload:
return _err("validation", f"{field} is set by the review lock action, not by update", status=400)
enum_err = _validate_enums(payload)
if enum_err is not None:
return enum_err
conn = get_connection()
current = conn.execute("SELECT * FROM slabs WHERE id=? AND deleted_at IS NULL", (id,)).fetchone()
if current is None:
@@ -143,19 +267,16 @@ async def update_slab(id: str, payload: Dict[str, Any]) -> Dict[str, Any]:
return _err("revision_conflict", "client_rev mismatch",
{"current_server_rev": current["server_rev"]}, status=409)
if "sku" in payload and payload["sku"] != current["sku"]:
if _sku_taken(conn, payload["sku"], exclude_id=id):
if payload["sku"] is not None and _sku_taken(conn, payload["sku"], exclude_id=id):
return _err("duplicate_sku", "SKU already exists", {"sku": payload["sku"]}, status=409)
for field, table in TAXONOMY_TABLES.items():
if field in payload and payload[field] is not None:
val = payload[field]
if isinstance(val, str):
try:
val = json.loads(val)
except (ValueError, TypeError):
val = []
if not _check_taxonomy(conn, table, val):
return _err("stale_field", f"{field} not in current taxonomy",
{"current_options": val}, status=422)
tax_err = _validate_taxonomy_fields(conn, payload)
if tax_err is not None:
return tax_err
# METADATA-15 / QUALITY-95: thickness band is derived, never owner-chosen.
eff_thickness = payload.get("thickness_in", current["thickness_in"])
band = resolve_thickness_band_id(eff_thickness, load_thickness_terms(conn))
if band is not None:
payload = {**payload, "thickness_term_id": band}
data = _encode(payload)
cols = [f for f in ALLOWED_UPDATE_FIELDS if f in data]
if not cols:
+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)
+537 -24
View File
@@ -58,39 +58,206 @@ def test_prompt_seed_and_revision(env):
assert client.get("/api/v1/prompts/call1").json()["active_content"] is not None
def test_call1_confidence_gate(env, monkeypatch):
def test_call1_seed_is_locked_expert_prompt(env):
"""#86: shipped Call 1 seed is the locked expert prompt (no free-text bags, no grade)."""
client, _conn = env
from backend.inference import CALL1_SEED
body = client.get("/api/v1/prompts/call1").json()
assert body["active_content"] == CALL1_SEED
assert "wood identification expert" in CALL1_SEED
assert "feature_tag_ids" in CALL1_SEED
assert "Decide wood categories before edge type" in CALL1_SEED
assert "highest-confidence" in CALL1_SEED
assert "Wild edge" in CALL1_SEED
assert "cookies and burl" in CALL1_SEED.lower() or "cookies and burl" in CALL1_SEED
assert "grade_term_id" not in CALL1_SEED
assert '"character"' not in CALL1_SEED
assert '"inclusions"' not in CALL1_SEED
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
tmp = tempfile.mkdtemp(prefix="tax_")
os.environ["SLAB_DB_PATH"] = os.path.join(tmp, "t.db")
os.environ["SLAB_IMAGES_DIR"] = tmp
os.environ.setdefault("WOO_BASE_URL", "https://x.com")
os.environ.setdefault(
"SLAB_AES_KEY",
base64.b64encode(b"0123456789abcdef0123456789abcdef").decode(),
)
db_mod._reset_for_tests()
conn = db_mod.init_db()
conn.execute("INSERT INTO woo_categories(id,name,slug,parent_id,woo_id) VALUES (10,'Species','species',NULL,10)")
conn.execute("INSERT INTO woo_categories(id,name,slug,parent_id,woo_id) VALUES (11,'Walnut','walnut',10,11)")
conn.execute("INSERT INTO woo_categories(id,name,slug,parent_id,woo_id) VALUES (20,'Wcat','wcat',NULL,20)")
conn.execute("INSERT INTO woo_categories(id,name,slug,parent_id,woo_id) VALUES (21,'Live edge wood','liveedge',20,21)")
conn.execute("INSERT INTO woo_attributes(id,name,slug,woo_id) VALUES (6,'Edge type','pa_etype',6)")
conn.execute("INSERT INTO woo_attributes(id,name,slug,woo_id) VALUES (8,'Figure','pa_fig',8)")
conn.execute("INSERT INTO woo_attributes(id,name,slug,woo_id) VALUES (9,'Grade','pa_gr',9)")
conn.execute("INSERT INTO woo_attributes(id,name,slug,woo_id) VALUES (10,'Thickness','pa_thk',10)")
conn.execute("INSERT INTO woo_attributes(id,name,slug,woo_id) VALUES (7,'Moisture','pa_mst',7)")
conn.execute("INSERT INTO woo_attribute_terms(id,attribute_id,name,slug,woo_id) VALUES (321,6,'Square edge','sqe',321)")
conn.execute("INSERT INTO woo_attribute_terms(id,attribute_id,name,slug,woo_id) VALUES (328,8,'Plain','pl',328)")
conn.execute("INSERT INTO woo_attribute_terms(id,attribute_id,name,slug,woo_id) VALUES (342,9,'Fancy','fnc',342)")
conn.execute("INSERT INTO woo_attribute_terms(id,attribute_id,name,slug,woo_id) VALUES (350,10,'1\"- 1 1/2\"','4-6qtr',350)")
conn.execute("INSERT INTO woo_attribute_terms(id,attribute_id,name,slug,woo_id) VALUES (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 INTO woo_tags(id,name,slug,woo_id) VALUES (2,'fig-curl','fig-curl',2)")
conn.commit()
from backend.inference import _taxonomy_lists
tax = _taxonomy_lists(conn)
assert set(tax.keys()) == {"species", "wood_categories", "edge_terms", "figure_terms", "feature_tags"}
assert tax["species"] == [{"id": 11, "name": "Walnut"}]
assert tax["wood_categories"] == [{"id": 21, "name": "Live edge wood"}]
assert tax["edge_terms"] == [{"id": 321, "name": "Square edge"}]
assert tax["figure_terms"] == [{"id": 328, "name": "Plain"}]
# feat- prefix stripped for the model; fig- tags are not feature_tags
assert tax["feature_tags"] == [{"id": 1, "name": "knot"}]
assert "grade_terms" not in tax
assert "thickness_terms" not in tax
assert "moisture_terms" not in tax
db_mod._reset_for_tests()
shutil.rmtree(tmp, ignore_errors=True)
def test_gate_feature_tag_ids_not_free_text():
"""#86: gate keeps feature_tag_ids at threshold; drops free-text bags and grade."""
from backend.inference import _gate
parsed = {
"species_id": 11, "species_confidence": 0.9,
"wood_category_ids": [21], "wood_category_confidence": 0.8,
"edge_type_term_id": 321, "edge_confidence": 0.85,
"figure_term_ids": [328, 335], "figure_confidence": 0.75,
"feature_tag_ids": [1], "feature_tag_confidence": 0.8,
"grade_term_id": 342, "grade_confidence": 0.99,
"character": ["rustic"], "inclusions": ["bark"], "voids": [], "checks": ["end"],
}
out = _gate(parsed)
assert out["species_id"] == 11
assert out["wood_category_ids"] == [21]
assert out["edge_type_term_id"] == 321
assert out["figure_term_ids"] == [328, 335]
assert out["feature_tag_ids"] == [1]
assert "grade_term_id" not in out
assert "character" not in out
assert "inclusions" not in out
assert "voids" not in out
assert "checks" not in out
# below threshold feature tags dropped
low = {**parsed, "feature_tag_confidence": 0.2}
assert "feature_tag_ids" not in _gate(low)
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):
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.3, # below
"figure_term_ids": [2], "figure_confidence": 0.8, # at/above
})
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 "wood_category_ids" not in s # gated out
assert "grade_term_id" not in s # gated out
# 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
@@ -98,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):
@@ -126,3 +395,247 @@ def test_test_inference_unconfigured(env):
r = client.post("/api/v1/admin/test-inference")
assert r.status_code == 200
assert r.json()["ok"] is False and r.json()["mode"] == "fail"
def _rgba_png(w: int, h: int) -> bytes:
"""RGBA PNG: opaque body + transparent top-left block (capture-like alpha)."""
from PIL import Image
import io
im = Image.new("RGBA", (w, h), (10, 20, 30, 255))
for y in range(min(80, h)):
for x in range(min(80, w)):
im.putpixel((x, y), (0, 0, 0, 0))
buf = io.BytesIO()
im.save(buf, format="PNG")
return buf.getvalue()
def test_fit_long_edge_png_downscales_over_1024():
"""METADATA-35: Call 1 images long edge <= 1024; never upscale; keep alpha."""
from backend.inference import fit_long_edge_png
from PIL import Image
import io
src = _rgba_png(800, 1067) # inventory 3:4, long edge 1067
out = fit_long_edge_png(src, max_long_edge=1024)
im = Image.open(io.BytesIO(out)).convert("RGBA")
assert max(im.size) == 1024
assert min(im.size) < 1024
w, h = im.size
assert abs((w / h) - (800 / 1067)) < 0.02
# Transparent block still transparent near origin after downscale
assert im.getpixel((0, 0))[3] == 0
assert im.getpixel((5, 5))[3] == 0
def test_fit_long_edge_png_leaves_small_unchanged_dims():
from backend.inference import fit_long_edge_png
from PIL import Image
import io
src = _rgba_png(400, 300)
out = fit_long_edge_png(src, max_long_edge=1024)
im = Image.open(io.BytesIO(out))
assert im.size == (400, 300)
def test_redact_request_for_log_strips_data_urls_keeps_text():
"""#87: log sanitizer replaces data-URL images with photo refs; keeps text."""
from backend.inference import _redact_request_for_log
b64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="
messages = [{
"role": "user",
"content": [
{"type": "image_url", "image_url": {"url": f"data:image/png;base64,{b64}"}},
{"type": "image_url", "image_url": {"url": f"data:image/png;base64,{b64 * 20}"}},
{"type": "text", "text": "LISTS:\n{\"species\":[]}\n\nSKU=X length_in=1 thickness_in=2"},
],
}]
meta = [
{"photo_id": "p1", "seq": 1, "bytes": 100, "infer_long_edge_px": 64},
{"photo_id": "p2", "seq": 2, "bytes": 200, "infer_long_edge_px": 128},
]
out = _redact_request_for_log(messages, meta)
blob = json.dumps(out)
assert "data:image" not in blob
assert "base64" not in blob.lower()
assert "LISTS:" in blob
assert "SKU=X" in blob
parts = out[0]["content"]
assert parts[0]["type"] == "image_ref"
assert parts[0]["photo_id"] == "p1"
assert parts[0]["seq"] == 1
assert parts[1]["photo_id"] == "p2"
assert parts[2]["type"] == "text"
assert "LISTS:" in parts[2]["text"]
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')"
)
conn.commit()
r = client.post("/api/v1/slabs/s1/inference/call1")
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):
"""#87: Call 2 log also redacts image data-URLs."""
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','ready',1,1,'t','t')"
)
png = _rgba_png(200, 200)
conn.execute(
"INSERT INTO slab_photos(id,slab_id,seq,processed_blob,created_at) VALUES ('p2','s1',1,?, 't')",
(png,),
)
conn.commit()
from backend.inference import InferenceClient
monkeypatch.setattr(InferenceClient, "chat", lambda self, messages: "<p>ok</p>")
r = client.post("/api/v1/slabs/s1/inference/call2")
assert r.status_code == 200, r.text
payload = conn.execute(
"SELECT request_payload FROM inference_log WHERE call=2 ORDER BY id DESC LIMIT 1"
).fetchone()["request_payload"]
assert "data:image" not in payload
assert "image_ref" in payload
assert "p2" in payload
assert "DIMENSION FACT BLOCK" in payload
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 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)"
)
conn.execute(
"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 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["text"] = messages[0]["content"][-1]["text"]
return "<p>ok</p>"
monkeypatch.setattr(InferenceClient, "chat", fake_chat)
r = client.post("/api/v1/slabs/s1/inference/call2")
assert r.status_code == 200, r.text
assert captured["text"] == expected
assert "DECLARED ATTRIBUTES" in captured["text"]
assert "DIMENSION FACT BLOCK" in captured["text"]
+41 -4
View File
@@ -34,13 +34,16 @@ PNG_ORIG = b"\x89PNG-orig-bytes"
PNG_PROC = b"\x89PNG-proc-bytes"
def _multipart(client, slab_id, photo_id, original=PNG_ORIG, processed=PNG_PROC, is_calibration=0):
def _multipart(client, slab_id, photo_id, original=PNG_ORIG, processed=PNG_PROC, is_calibration=0, role="batch", seq=None):
files = {"original": ("orig.png", original, "image/png")}
if processed is not None:
files["processed"] = ("proc.png", processed, "image/png")
data = {"photo_id": photo_id, "is_calibration": str(is_calibration), "role": role}
if seq is not None:
data["seq"] = str(seq)
return client.post(
f"/api/v1/slabs/{slab_id}/photos",
data={"photo_id": photo_id, "is_calibration": str(is_calibration)},
data=data,
files=files,
)
@@ -90,9 +93,43 @@ def test_reupload_replaces(photos_env):
def test_list_photos(photos_env):
client, _ = photos_env
_multipart(client, "s1", "pa", is_calibration=1)
_multipart(client, "s1", "pb")
_multipart(client, "s1", "pa", is_calibration=1, role="calibration")
_multipart(client, "s1", "pb", role="overlay")
r = client.get("/api/v1/slabs/s1/photos")
assert r.status_code == 200
ids = [row["id"] for row in r.json()]
assert "pa" in ids and "pb" in ids
def test_upload_stores_and_returns_role(photos_env):
client, _ = photos_env
r = _multipart(client, "s1", "r1", role="overlay")
assert r.status_code == 201
assert r.json()["role"] == "overlay"
r = client.get("/api/v1/slabs/s1/photos")
row = {row_["id"]: row_ for row_ in r.json()}["r1"]
assert row["role"] == "overlay"
def test_upload_rejects_bad_role(photos_env):
client, _ = photos_env
r = _multipart(client, "s1", "r2", role="not-a-role")
assert r.status_code == 400
assert r.json()["error"]["code"] == "validation"
def test_delete_photo(photos_env):
client, _ = photos_env
_multipart(client, "s1", "d1")
assert client.get("/api/v1/slabs/s1/photos/d1/processed").status_code == 200
r = client.delete("/api/v1/slabs/s1/photos/d1")
assert r.status_code == 204
# Gone from the list and blob no longer served.
ids = [row["id"] for row in client.get("/api/v1/slabs/s1/photos").json()]
assert "d1" not in ids
assert client.get("/api/v1/slabs/s1/photos/d1/processed").status_code == 404
def test_delete_photo_not_found(photos_env):
client, _ = photos_env
assert client.delete("/api/v1/slabs/s1/photos/ghost").status_code == 404
+178
View File
@@ -45,6 +45,21 @@ def env(monkeypatch):
conn.execute("INSERT INTO woo_categories(id,name,slug,parent_id,woo_id) VALUES (2,'Live edge wood','live-edge-wood',NULL,101)")
conn.execute("INSERT INTO woo_attributes(id,name,slug,woo_id) VALUES (8,'Edge Type','edge-type',200)")
conn.execute("INSERT INTO woo_attribute_terms(id,attribute_id,name,slug,woo_id) VALUES (1,8,'Flat','flat',300)")
# Thickness bands, names as synced (the derivation parses them) — QUALITY-95.
conn.execute("INSERT INTO woo_attributes(id,name,slug,woo_id) VALUES (9,'Thickness','pa_thk',201)")
for tid, name, slug in [
(347, 'Under 1/2"', 'xthin'), (348, '1/2" - 3/4"', '2-3qtr'),
(349, '3/4" - 1"', '3-4qtr'), (350, '1"- 1 1/2"', '4-6qtr'),
(351, '1 1/2" - 2"', '6-8qtr'), (352, '2" - 3"', '8-12qtr'),
(353, 'Over 3"', '12qtr'),
]:
conn.execute(
"INSERT INTO woo_attribute_terms(id,attribute_id,name,slug,woo_id) VALUES (?,9,?,?,?)",
(tid, name, slug, tid),
)
# Moisture keeps its own attribute so the five locked attributes stay distinct.
conn.execute("INSERT INTO woo_attributes(id,name,slug,woo_id) VALUES (10,'Moisture','pa_mst',202)")
conn.execute("INSERT INTO woo_attribute_terms(id,attribute_id,name,slug,woo_id) VALUES (326,10,'Kiln dried','kd',326)")
# creds
conn.execute("INSERT OR REPLACE INTO settings(key,value,value_enc) VALUES ('woo_wp_username','admin',NULL)")
conn.execute("INSERT OR REPLACE INTO settings(key,value,value_enc) VALUES ('woo_app_password',NULL,?)", (encrypt("secret"),))
@@ -119,6 +134,109 @@ def test_publish_success(env, monkeypatch):
assert conn.execute("SELECT processed_blob FROM slab_photos WHERE id='p1'").fetchone()["processed_blob"] is None
def test_publish_sends_derived_thickness_band(env, monkeypatch):
"""#90 / QUALITY-95: the band is off the Review screen but must still reach Woo.
The owner has no thickness-band control, so nothing client-side carries it: the
server derives it from the measurement on create/update, and publish must send the
live synced term name on the product payload. This pins that chain end to end.
"""
client, conn = env
create = client.post("/api/v1/slabs", json={
"id": "s1", "sku": "SLAB-BAND", "length_in": 114.0, "thickness_in": 1.75,
"sqft": 30.0, "bdft": 18.4, "width_min_in": 18.0, "width_max_in": 24.3, "width_avg_in": 24.3,
})
assert create.status_code == 201, create.text
# 1.75 in bands into the live '1 1/2" - 2"' term — never sent by the client.
assert create.json()["thickness_term_id"] == 351
put = client.put("/api/v1/slabs/s1", json={
"client_rev": create.json()["server_rev"], "species_id": 1, "wood_category_ids": [2],
"edge_type_term_id": 1, "figure_term_ids": [1], "grade_term_id": 1, "moisture_term_id": 326,
"price": 184.0, "price_source": "recommendation", "title": "T", "short_description": "S",
"description": "D", "content_source": "manual",
})
assert put.status_code == 200, put.text
conn.execute("UPDATE slabs SET status='ready' WHERE id='s1'")
conn.execute("INSERT INTO slab_photos(id,slab_id,seq,is_calibration,processed_blob,created_at) VALUES ('p1','s1',1,1,X'89504E47','t')")
conn.commit()
products = []
def fake_request(url, method, data, headers):
if "/products?sku=" in url:
return 200, {}, b"[]"
if url.endswith("/wp-json/wp/v2/media") and method == "POST":
return 201, {}, json.dumps({"id": 42}).encode()
if url.endswith("/wp-json/wc/v3/products") and method == "POST":
products.append(json.loads(data))
return 201, {}, json.dumps({"id": 999}).encode()
return 200, {}, b"[]"
from backend.woo import WooClient
orig_init = WooClient.__init__
def patched_init(self, *a, **k):
orig_init(self, *a, **k)
self._request = fake_request
monkeypatch.setattr(WooClient, "__init__", patched_init)
r = client.post("/api/v1/slabs/s1/publish")
assert r.status_code == 200, r.text
assert len(products) == 1, "expected exactly one product create"
attrs = {a["id"]: a for a in products[0].get("attributes", [])}
assert 9 in attrs, f"Thickness attribute missing from the Woo payload: {products[0].get('attributes')}"
assert attrs[9]["options"] == ['1 1/2" - 2"']
def test_publish_woo_image_order_by_role(env, monkeypatch):
# PUBLISH-45: Woo order is clean cal, dimension-overlay, then batch — by
# role, not by stored seq (the Review strip stores the owner's keep order).
client, conn = env
s = _full_slab()
_insert_slab(conn, s)
# Stored seq deliberately out of lineup order: batch(1), calibration(2),
# overlay(3). The publish query must reorder to cal, overlay, batch.
conn.execute("INSERT INTO slab_photos(id,slab_id,seq,is_calibration,role,processed_blob,created_at) VALUES ('pb','s1',1,0,'batch',X'89504E47','t')")
conn.execute("INSERT INTO slab_photos(id,slab_id,seq,is_calibration,role,processed_blob,created_at) VALUES ('pc','s1',2,1,'calibration',X'89504E47','t')")
conn.execute("INSERT INTO slab_photos(id,slab_id,seq,is_calibration,role,processed_blob,created_at) VALUES ('po','s1',3,0,'overlay',X'89504E47','t')")
conn.commit()
media_calls = []
def fake_request(url, method, data, headers):
if "/products?sku=" in url:
return 200, {}, b"[]"
if url.endswith("/wp-json/wp/v2/media") and method == "POST":
media_calls.append(data)
return 201, {}, json.dumps({"id": 42}).encode()
if url.endswith("/wp-json/wc/v3/products") and method == "POST":
return 201, {}, json.dumps({"id": 999}).encode()
return 200, {}, b"[]"
from backend.woo import WooClient
orig_init = WooClient.__init__
def patched_init(self, *a, **k):
orig_init(self, *a, **k)
self._request = fake_request
monkeypatch.setattr(WooClient, "__init__", patched_init)
r = client.post("/api/v1/slabs/s1/publish")
assert r.status_code == 200, r.text
assert len(media_calls) == 3
# The photo id is embedded in the multipart filename (slab.py publish
# passes f"{sku}-{photo_id}.png"). Order of media uploads = cal, overlay,
# batch (PUBLISH-45), not stored seq.
def photo_id(body: bytes) -> str:
import re
m = re.search(rb'filename="[^"]*-(pc|pb|po)\.png"', body)
return m.group(1).decode() if m else ""
assert [photo_id(c) for c in media_calls] == ["pc", "po", "pb"]
def test_uat_draft(env, monkeypatch):
client, conn = env
s = _full_slab(sku="SLAB-UAT-1")
@@ -182,3 +300,63 @@ def test_partial_media_failure_502(env, monkeypatch):
# sync_log written, status failed
assert conn.execute("SELECT status FROM slabs WHERE id='s1'").fetchone()["status"] == "failed"
assert conn.execute("SELECT COUNT(*) c FROM sync_log WHERE status='failed'").fetchone()["c"] >= 1
def test_publish_sends_user_agent_on_sku_and_create(env, monkeypatch):
"""#79: every publish Woo hop carries User-Agent (same transport as client)."""
client, conn = env
s = _full_slab()
_insert_slab(conn, s)
conn.commit()
seen = []
def fake_request(url, method, data, headers):
seen.append({"url": url, "method": method, "headers": dict(headers or {})})
if "/products?sku=" in url:
return 200, {}, b"[]"
if url.endswith("/wp-json/wc/v3/products") and method == "POST":
return 201, {}, json.dumps({"id": 42}).encode()
return 200, {}, b"[]"
from backend.woo import WooClient, _USER_AGENT
orig_init = WooClient.__init__
def patched_init(self, *a, **k):
orig_init(self, *a, **k)
self._request = fake_request
monkeypatch.setattr(WooClient, "__init__", patched_init)
r = client.post("/api/v1/slabs/s1/publish")
assert r.status_code == 200, r.text
sku_hops = [h for h in seen if "/products?sku=" in h["url"]]
create_hops = [h for h in seen if h["url"].endswith("/wp-json/wc/v3/products") and h["method"] == "POST"]
assert sku_hops and create_hops
for hop in sku_hops + create_hops:
assert hop["headers"].get("User-Agent") == _USER_AGENT
def test_publish_403_is_unrecoverable(env, monkeypatch):
"""#79: WAF 403 on SKU lookup is unrecoverable — do not invite retry."""
client, conn = env
s = _full_slab()
_insert_slab(conn, s)
conn.commit()
from backend.woo import WooClient, WooForbiddenError
def fake_request(url, method, data, headers):
raise WooForbiddenError("request forbidden (403)")
orig_init = WooClient.__init__
def patched_init(self, *a, **k):
orig_init(self, *a, **k)
self._request = fake_request
monkeypatch.setattr(WooClient, "__init__", patched_init)
r = client.post("/api/v1/slabs/s1/publish")
assert r.status_code == 502
body = r.json()
assert body["error"]["code"] == "woo_forbidden"
assert body["error"]["detail"]["recoverable"] is False
assert "retry" not in body["error"]["message"].lower() or "will not help" in body["error"]["message"].lower()
+84
View File
@@ -55,6 +55,8 @@ def test_title_live_edge_no_dims(env):
conn.execute("INSERT INTO slabs(id,sku,length_in,thickness_in,sqft,bdft,width_min_in,width_max_in,width_avg_in,species_id,wood_category_ids,figure_term_ids,price,price_source,title,short_description,description,content_source,status,server_rev,client_rev,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
("s1","SLAB-01",114.0,1.75,30.0,18.4,18.0,24.3,24.3,1,json.dumps([2]),json.dumps([1]),None,None,None,None,"A slab","manual","calibrated",1,1,"t","t"))
conn.commit()
# #89: the review always returns the deterministic title/short_description
# built from the current metadata (the Create button's source of truth).
r = env[0].post("/api/v1/slabs/s1/review", json={})
assert r.status_code == 200, r.text
body = r.json()
@@ -134,5 +136,87 @@ def test_knife_blank_carries_dims(env):
conn.execute("INSERT INTO slabs(id,sku,length_in,thickness_in,sqft,bdft,width_min_in,width_max_in,width_avg_in,species_id,wood_category_ids,figure_term_ids,price,price_source,title,short_description,description,content_source,status,server_rev,client_rev,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
("s1","SLAB-01",12.0,1.5,6.0,0.7,6.0,6.5,6.5,1,json.dumps([3]),json.dumps([1]),None,None,None,None,"A slab","manual","calibrated",1,1,"t","t"))
conn.commit()
# #89: the deterministic build runs on every review.
r = client.post("/api/v1/slabs/s1/review", json={})
assert r.json()["title"] == "Figured Black Walnut Knife Blank (12 x 6.5 x 1.5 in)"
def test_review_always_reports_deterministic_title(env):
"""#89: the review always returns the deterministic title/short_description
built from the current metadata. The client's Create button writes these into
the fields; the server does not persist them from the review payload."""
client, conn = env
conn.execute("INSERT INTO slabs(id,sku,length_in,thickness_in,sqft,bdft,width_min_in,width_max_in,width_avg_in,species_id,wood_category_ids,figure_term_ids,price,price_source,title,short_description,description,content_source,status,server_rev,client_rev,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
("s1","SLAB-01",114.0,1.75,30.0,18.4,18.0,24.3,24.3,1,json.dumps([2]),json.dumps([1]),None,None,None,None,"A slab","manual","calibrated",1,1,"t","t"))
conn.commit()
r = client.post("/api/v1/slabs/s1/review", json={})
body = r.json()
assert body["title"] == "Figured Black Walnut Live Edge Slab"
assert body["short_description"].startswith("Figured Black Walnut Live Edge Slab,")
# The review does NOT persist title/short_description from the payload.
got = client.get("/api/v1/slabs/s1").json()
assert got["title"] is None
assert got["short_description"] is None
def test_review_no_species_reports_stored_title(env):
"""With no species, there is no deterministic pattern; the review reports the
stored title (None here) and lists title as missing."""
client, conn = env
conn.execute("INSERT INTO slabs(id,sku,length_in,thickness_in,sqft,bdft,width_min_in,width_max_in,width_avg_in,species_id,wood_category_ids,price,title,short_description,status,server_rev,client_rev,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
("s1","SLAB-01",114.0,1.75,30.0,18.4,18.0,24.3,24.3,None,json.dumps([2]),None,None,None,"calibrated",1,1,"t","t"))
conn.commit()
r = client.post("/api/v1/slabs/s1/review", json={})
body = r.json()
assert body["title"] is None
assert "title" in body["missing"]
def test_review_persists_owner_overrides(env):
"""#80: regular review persists price/sku overrides from the payload to the DB,
so the client doesn't need a separate PUT for each. (title/short_description
are NOT persisted here — they come from the deterministic build, not the payload.)"""
client, conn = env
conn.execute("INSERT INTO slabs(id,sku,length_in,thickness_in,sqft,bdft,width_min_in,width_max_in,width_avg_in,species_id,wood_category_ids,figure_term_ids,price,price_source,title,short_description,description,content_source,status,server_rev,client_rev,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
("s1","SLAB-01",114.0,1.75,30.0,18.4,18.0,24.3,24.3,1,json.dumps([2]),json.dumps([1]),None,None,None,None,"A slab","manual","calibrated",1,1,"t","t"))
conn.commit()
# Review with owner overrides for price + sku.
r = client.post("/api/v1/slabs/s1/review", json={
"price": 250.0,
"sku": "SLAB-99",
})
assert r.status_code == 200
body = r.json()
assert body["price"] == 250.0
# The overrides are persisted: GET reflects them.
got = client.get("/api/v1/slabs/s1").json()
assert got["price"] == 250.0
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
+216
View File
@@ -33,6 +33,23 @@ def _seed_taxonomy(conn):
# tags
conn.execute("INSERT OR REPLACE INTO woo_tags(id, name, slug, woo_id) VALUES (1, 'Fig Tag A', 'fig-tag-a', 50)")
conn.execute("INSERT OR REPLACE INTO woo_tags(id, name, slug, woo_id) VALUES (2, 'Feat Tag A', 'feat-tag-a', 60)")
# Thickness bands (METADATA-15 / QUALITY-95) — names exactly as synced from the
# live Woo attribute, because the derivation parses them.
conn.execute("INSERT OR REPLACE INTO woo_attributes(id, name, slug, woo_id) VALUES (2, 'Thickness', 'pa_thk', 31)")
for tid, name, slug in [
(347, 'Under 1/2"', 'xthin'),
(348, '1/2" - 3/4"', '2-3qtr'),
(349, '3/4" - 1"', '3-4qtr'),
(350, '1"- 1 1/2"', '4-6qtr'),
(351, '1 1/2" - 2"', '6-8qtr'),
(352, '2" - 3"', '8-12qtr'),
(353, 'Over 3"', '12qtr'),
]:
conn.execute(
"INSERT OR REPLACE INTO woo_attribute_terms(id, attribute_id, name, slug, woo_id) "
"VALUES (?, 2, ?, ?, ?)",
(tid, name, slug, tid),
)
conn.commit()
@pytest.fixture
@@ -103,6 +120,55 @@ def test_create_and_fetch(client, db):
assert body[key] == payload[key]
def test_create_derives_thickness_band(client, db):
"""QUALITY-95: 1 in rounds up into the live synced band, not a hardcoded one.
The client-supplied thickness_term_id (1, an Edge Type term) must be ignored —
the UI has no band control, so the server's derivation is authoritative.
"""
slab_id = str(uuid.uuid4())
payload = {
"id": slab_id, "sku": "SKU-BAND", "length_in": 10, "thickness_in": 1.0, "sqft": 20,
"bdft": 5, "width_min_in": 1, "width_max_in": 2, "width_avg_in": 1.5,
"thickness_term_id": 1,
}
resp = client.post("/api/v1/slabs", json=payload)
assert resp.status_code == 201
assert resp.json()["thickness_term_id"] == 350 # '1"- 1 1/2"', not the sent 1
def test_update_rebands_when_thickness_changes(client, db):
"""A thickness edit moves the slab to the band the new measurement rounds into."""
slab_id = str(uuid.uuid4())
create = client.post("/api/v1/slabs", json={
"id": slab_id, "sku": "SKU-BAND2", "length_in": 10, "thickness_in": 0.5, "sqft": 20,
"bdft": 5, "width_min_in": 1, "width_max_in": 2, "width_avg_in": 1.5,
})
assert create.status_code == 201
assert create.json()["thickness_term_id"] == 348 # 1/2" - 3/4"
put = client.put(f"/api/v1/slabs/{slab_id}", json={
"client_rev": create.json()["server_rev"], "thickness_in": 2.75,
})
assert put.status_code == 200
assert put.json()["thickness_term_id"] == 352 # 2" - 3"
def test_update_ignores_client_supplied_band(client, db):
"""A stale client band cannot pin the slab to the wrong band."""
slab_id = str(uuid.uuid4())
create = client.post("/api/v1/slabs", json={
"id": slab_id, "sku": "SKU-BAND3", "length_in": 10, "thickness_in": 1.75, "sqft": 20,
"bdft": 5, "width_min_in": 1, "width_max_in": 2, "width_avg_in": 1.5,
})
assert create.status_code == 201
assert create.json()["thickness_term_id"] == 351 # 1 1/2" - 2"
put = client.put(f"/api/v1/slabs/{slab_id}", json={
"client_rev": create.json()["server_rev"], "thickness_term_id": 347,
})
assert put.status_code == 200
assert put.json()["thickness_term_id"] == 351 # unchanged: still the derived band
def test_duplicate_sku(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}
@@ -134,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}
@@ -180,6 +270,76 @@ def test_soft_delete(client, db):
assert resp_get.status_code == 404
def test_create_empty_sku_stored_as_null(client, db):
# Business rule (middle tier): empty SKU at create = "not set yet" -> stored as NULL,
# not rejected by the DB CHECK. This is the UAT "Continue to Review" path.
slab_id = str(uuid.uuid4())
payload = {"id": slab_id, "sku": "", "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}
resp = client.post("/api/v1/slabs", json=payload)
assert resp.status_code == 201
assert resp.json()["sku"] is None
# A second empty-SKU slab is allowed (NULLs are not unique-conflicts).
resp2 = client.post("/api/v1/slabs", json={**payload, "id": str(uuid.uuid4())})
assert resp2.status_code == 201
def test_create_null_sku_allowed(client, db):
slab_id = str(uuid.uuid4())
payload = {"id": slab_id, "sku": None, "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}
resp = client.post("/api/v1/slabs", json=payload)
assert resp.status_code == 201
assert resp.json()["sku"] is None
def test_gate_reads_flat_confidence_keys(client, db):
# Regression: the gate's confidence keys are 'species_confidence' / 'edge_confidence'
# etc., NOT f'{field}_confidence' (field 'species_id' would give the wrong key). A
# high-confidence response must surface its picks; low-confidence ones must not.
from backend.inference import _gate, _parse_json
good = '```json\n{"species_id": 284, "species_confidence": 0.85, "wood_category_ids": [251], "wood_category_confidence": 1.0, "edge_type_term_id": 323, "edge_confidence": 0.95, "figure_term_ids": [332], "figure_confidence": 0.9, "feature_tag_ids": [1], "feature_tag_confidence": 0.8, "character": [], "inclusions": ["bark"], "voids": [], "checks": []}\n```'
out = _gate(_parse_json(good))
assert out["species_id"] == 284
assert out["wood_category_ids"] == [251]
assert out["edge_type_term_id"] == 323
assert out["figure_term_ids"] == [332]
assert out["feature_tag_ids"] == [1]
# grade must never be gated in (METADATA-40)
assert "grade_term_id" not in out
# free-text bags dropped (#86)
assert "character" not in out
assert "inclusions" not in out
# low-confidence fields are dropped (METADATA-45)
low = '```json\n{"species_id": 284, "species_confidence": 0.4, "wood_category_ids": [251], "wood_category_confidence": 0.8, "edge_type_term_id": 323, "edge_confidence": 0.2, "figure_term_ids": [332], "figure_confidence": 0.9}\n```'
out2 = _gate(_parse_json(low))
assert out2["wood_category_ids"] == [251]
assert out2["figure_term_ids"] == [332]
assert "species_id" not in out2
assert "edge_type_term_id" not in out2
def test_ready_gate_rejects_malformed_sku(client, db):
# REVIEW-45 enforced in the middle tier: a present-but-out-of-range/char-set SKU is not ready.
from backend.review import compute_ready, sku_valid
base = {"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,"price":100,"price_source":"recommendation","title":"Test","short_description":"short","description":"desc","content_source":"manual"}
# valid
assert sku_valid("AB-1") is True
ready, missing = compute_ready({**base, "sku": "AB-1"})
assert ready is True and "sku" not in missing
# too short
assert sku_valid("AB") is False
ready, missing = compute_ready({**base, "sku": "AB"})
assert ready is False and "sku" in missing
# bad char
ready, missing = compute_ready({**base, "sku": "AB!"})
assert ready is False and "sku" in missing
# too long
ready, missing = compute_ready({**base, "sku": "A" * 25})
assert ready is False and "sku" in missing
# missing (None)
ready, missing = compute_ready({**base, "sku": None})
assert ready is False and "sku" in missing
def test_delete_published_conflict(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}
@@ -190,3 +350,59 @@ def test_delete_published_conflict(client, db):
conn.commit()
resp = client.delete(f"/api/v1/slabs/{slab_id}")
assert resp.status_code == 409
def test_create_rejects_unknown_fields(client, db):
# #83: POST /slabs with extra (non-ALLOWED_FIELDS) keys → 400, not a silent drop.
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, "bogus_field": 42}
resp = client.post("/api/v1/slabs", json=payload)
assert resp.status_code == 400
body = resp.json()
assert body["error"]["code"] == "validation"
assert "bogus_field" in body["error"]["detail"]["fields"]
def test_update_stale_field_returns_live_options(client, db):
# #82: stale_field returns LIVE taxonomy options (id+name), not the submitted id.
slab_id = str(uuid.uuid4())
payload = {
"id": slab_id, "sku": "SKU-STALE", "length_in": 10, "thickness_in": 2, "sqft": 20, "bdft": 5,
"width_min_in": 1, "width_max_in": 2, "width_avg_in": 1.5,
}
assert client.post("/api/v1/slabs", json=payload).status_code == 201
resp = client.put(f"/api/v1/slabs/{slab_id}", json={"client_rev": 1, "edge_type_term_id": 999})
assert resp.status_code == 422
body = resp.json()
assert body["error"]["code"] == "stale_field"
opts = body["error"]["detail"]["current_options"]
assert isinstance(opts, list) and opts
assert all(isinstance(o, dict) and "id" in o and "name" in o for o in opts)
assert 999 not in [o["id"] for o in opts]
assert "Edge type" in body["error"]["message"] or "edge" in body["error"]["message"].lower()
def test_create_extra_key_and_bad_taxonomy_are_4xx(client, db):
"""#83: server-managed/extra keys and bad taxonomy/enums are 4xx, not 500."""
base = {
"id": str(uuid.uuid4()), "sku": "SKU-XTRA", "length_in": 10, "thickness_in": 2, "sqft": 20, "bdft": 5,
"width_min_in": 1, "width_max_in": 2, "width_avg_in": 1.5,
"status": "published", # server-managed → unknown field 400
}
r = client.post("/api/v1/slabs", json=base)
assert r.status_code == 400, r.text
assert "status" in r.json()["error"]["detail"]["fields"]
# Bad taxonomy on create
r2 = client.post("/api/v1/slabs", json={
"id": str(uuid.uuid4()), "sku": "SKU-BADTAX", "length_in": 10, "thickness_in": 2, "sqft": 20, "bdft": 5,
"width_min_in": 1, "width_max_in": 2, "width_avg_in": 1.5, "species_id": 999,
})
assert r2.status_code == 422
assert r2.json()["error"]["code"] == "stale_field"
# Bad enum
r3 = client.post("/api/v1/slabs", json={
"id": str(uuid.uuid4()), "sku": "SKU-BADENUM", "length_in": 10, "thickness_in": 2, "sqft": 20, "bdft": 5,
"width_min_in": 1, "width_max_in": 2, "width_avg_in": 1.5, "price_source": "not-a-source",
})
assert r3.status_code == 400
assert r3.json()["error"]["code"] == "validation"
+94
View File
@@ -0,0 +1,94 @@
"""Thickness band resolution (METADATA-15 / QUALITY-95).
The owner never picks the Woo thickness band in the UI — the server derives it
from the measured thickness_in against the live synced bands. These tests pin the
derivation on the real live band names (from the UAT taxonomy) and on the
quarter-inch slug fallback.
"""
import pytest
from backend.thickness_band import (
parse_inch_token,
resolve_thickness_band_id,
term_bounds,
)
# The live UAT Thickness attribute (pa_thk) as synced: name, slug.
LIVE_TERMS = [
{"id": 347, "name": "Under 1/2\"", "slug": "xthin"},
{"id": 348, "name": "1/2\" - 3/4\"", "slug": "2-3qtr"},
{"id": 349, "name": "3/4\" - 1\"", "slug": "3-4qtr"},
{"id": 350, "name": "1\"- 1 1/2\"", "slug": "4-6qtr"},
{"id": 351, "name": "1 1/2\" - 2\"", "slug": "6-8qtr"},
{"id": 352, "name": "2\" - 3\"", "slug": "8-12qtr"},
{"id": 353, "name": "Over 3\"", "slug": "12qtr"},
]
@pytest.mark.parametrize(
"text,expected",
[
("1 1/2", 1.5),
("3/4", 0.75),
("2", 2.0),
("1.75", 1.75),
("2\"", 2.0),
("1 1/2\"", 1.5),
],
)
def test_parse_inch_token(text, expected):
assert parse_inch_token(text) == pytest.approx(expected)
def test_parse_inch_token_rejects_junk():
assert parse_inch_token("") is None
assert parse_inch_token(" ") is None
def test_term_bounds_from_live_names():
assert term_bounds(LIVE_TERMS[0]) == (0.0, 0.5)
assert term_bounds(LIVE_TERMS[1]) == (0.5, 0.75)
assert term_bounds(LIVE_TERMS[3]) == (1.0, 1.5)
assert term_bounds(LIVE_TERMS[6]) == (3.0, float("inf"))
def test_term_bounds_falls_back_to_qtr_slug():
assert term_bounds({"id": 9, "name": "weird", "slug": "4-6qtr"}) == (1.0, 1.5)
assert term_bounds({"id": 9, "name": "weird", "slug": "12qtr"}) == (3.0, float("inf"))
assert term_bounds({"id": 9, "name": "weird", "slug": "xthin"}) == (0.0, 0.5)
@pytest.mark.parametrize(
"thickness,expected_id",
[
(0.25, 347), # Under 1/2"
(0.5, 348), # boundary rounds UP into 1/2"-3/4", not down
(0.625, 348),
(0.75, 349), # boundary rounds UP
(0.875, 349),
(1.0, 350), # QUALITY-95: 1 inch rounds up into 1"-1 1/2", not a hardcoded band
(1.25, 350),
(1.5, 351),
(1.75, 351), # the UAT sample thickness
(2.0, 352),
(2.75, 352),
(3.0, 353),
(4.0, 353),
],
)
def test_resolve_rounds_up_on_boundary(thickness, expected_id):
assert resolve_thickness_band_id(thickness, LIVE_TERMS) == expected_id
def test_resolve_handles_missing_inputs():
assert resolve_thickness_band_id(None, LIVE_TERMS) is None
assert resolve_thickness_band_id(-1, LIVE_TERMS) is None
assert resolve_thickness_band_id(1.0, []) is None
# A term with no parseable name/slug is skipped, never guessed at.
assert resolve_thickness_band_id(1.0, [{"id": 1, "name": "mystery", "slug": "mystery"}]) is None
def test_resolve_is_id_order_independent():
"""The result must not depend on the sync order of the terms."""
assert resolve_thickness_band_id(1.75, list(reversed(LIVE_TERMS))) == 351
+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]
+177
View File
@@ -0,0 +1,177 @@
"""Deterministic thickness band from free-text thickness_in (METADATA-15 / QUALITY-95).
The owner enters thickness in 1/8-inch steps on capture. The Woo attribute is a
synced band term. We map thickness_in into the first half-open band [low, high)
that contains it, derived only from the live synced term names/slugs — never a
hardcoded Woo term id.
Round-up semantics: bands are half-open on the upper bound, so a value that sits
on a shared boundary (e.g. 1.0 in) belongs to the band that *starts* at that
value (1"–1 1/2"), not the band that ends there (3/4"–1").
"""
from __future__ import annotations
import re
from typing import Any, Iterable, Optional
# Mixed number / fraction / decimal inch token, optional trailing quote.
_INCH_RE = re.compile(
r"(?P<a>\d+)\s+(?P<n>\d+)\s*/\s*(?P<d>\d+)" # 1 1/2
r"|(?P<n2>\d+)\s*/\s*(?P<d2>\d+)" # 1/2
r"|(?P<dec>\d+(?:\.\d+)?)", # 1 or 1.5
re.IGNORECASE,
)
_QTR_RANGE_RE = re.compile(r"^(\d+)-(\d+)qtr$", re.IGNORECASE)
_QTR_MIN_RE = re.compile(r"^(\d+)qtr$", re.IGNORECASE)
def parse_inch_token(text: str) -> Optional[float]:
"""Parse one inch token like '1 1/2', '3/4', '2', or 2-with-quote → float inches."""
t = (text or "").strip().rstrip("\"'").strip()
if not t:
return None
m = _INCH_RE.fullmatch(t) or _INCH_RE.search(t)
if not m:
return None
if m.group("a") is not None:
return float(m.group("a")) + float(m.group("n")) / float(m.group("d"))
if m.group("n2") is not None:
return float(m.group("n2")) / float(m.group("d2"))
if m.group("dec") is not None:
return float(m.group("dec"))
return None
def _bounds_from_name(name: str) -> Optional[tuple[float, float]]:
"""Return half-open [low, high) inches from a Woo thickness term name."""
raw = (name or "").strip()
low_s = raw.lower()
if not raw:
return None
# Under X / less than X / < X
m = re.match(r"^(?:under|below|less than|<)\s*(.+)$", low_s, re.IGNORECASE)
if m:
hi = parse_inch_token(m.group(1))
if hi is not None:
return (0.0, hi)
# Over X / above X / > X / X+
m = re.match(r"^(?:over|above|greater than|>)\s*(.+)$", low_s, re.IGNORECASE)
if m:
lo = parse_inch_token(m.group(1))
if lo is not None:
return (lo, float("inf"))
m = re.match(r"^(.+?)\+\s*$", raw)
if m:
lo = parse_inch_token(m.group(1))
if lo is not None:
return (lo, float("inf"))
# Range: "A - B" / "A–B" / "A- B" (quotes optional on either side)
parts = re.split(r"\s*[-–—]\s*", raw, maxsplit=1)
if len(parts) == 2:
lo = parse_inch_token(parts[0])
hi = parse_inch_token(parts[1])
if lo is not None and hi is not None and hi > lo:
return (lo, hi)
# Single value band treated as [v, v+epsilon) — rare
only = parse_inch_token(raw)
if only is not None:
return (only, only + 1e-9)
return None
def _bounds_from_slug(slug: str) -> Optional[tuple[float, float]]:
"""Fallback: quarter-inch slugs like '4-6qtr', '12qtr', 'xthin'."""
s = (slug or "").strip().lower()
if not s:
return None
if s in {"xthin", "under", "thin"}:
return (0.0, 0.5)
m = _QTR_RANGE_RE.match(s)
if m:
lo = int(m.group(1)) * 0.25
hi = int(m.group(2)) * 0.25
if hi > lo:
return (lo, hi)
m = _QTR_MIN_RE.match(s)
if m:
lo = int(m.group(1)) * 0.25
return (lo, float("inf"))
return None
def term_bounds(term: dict[str, Any]) -> Optional[tuple[float, float]]:
"""Bounds for one synced thickness term; name first, slug fallback."""
return _bounds_from_name(str(term.get("name") or "")) or _bounds_from_slug(
str(term.get("slug") or "")
)
def resolve_thickness_band_id(
thickness_in: float | int | None,
terms: Iterable[dict[str, Any]],
) -> Optional[int]:
"""Pick the synced thickness term id for thickness_in, or None if unknown.
terms: iterable of {id, name, slug?} from the live Woo thickness attribute.
"""
if thickness_in is None:
return None
try:
t = float(thickness_in)
except (TypeError, ValueError):
return None
if t < 0:
return None
scored: list[tuple[float, float, int]] = [] # (low, high, id)
for term in terms:
b = term_bounds(term)
if b is None:
continue
tid = term.get("id")
if tid is None:
continue
lo, hi = b
scored.append((lo, hi, int(tid)))
if not scored:
return None
# Prefer the tightest band that contains t under half-open [low, high).
matches = [(hi - lo, lo, hi, tid) for lo, hi, tid in scored if lo <= t < hi]
if matches:
matches.sort() # tightest span first
return matches[0][3]
# If nothing matched (gaps in taxonomy), pick nearest band by lower edge.
scored.sort(key=lambda x: x[0])
for lo, hi, tid in scored:
if t < hi:
return tid
return scored[-1][2]
def load_thickness_terms(conn) -> list[dict[str, Any]]:
"""Live Thickness attribute terms from the synced Woo cache."""
row = conn.execute(
"SELECT id FROM woo_attributes WHERE lower(name)=? OR lower(slug)=?",
("thickness", "thickness"),
).fetchone()
if row is None:
# Case-insensitive name variants ("Thickness", "pa_thk" style slugs stay open)
row = conn.execute(
"SELECT id FROM woo_attributes WHERE lower(name) LIKE '%thickness%'"
).fetchone()
if row is None:
return []
attr_id = row[0]
rows = conn.execute(
"SELECT id, name, slug FROM woo_attribute_terms WHERE attribute_id=? ORDER BY id",
(attr_id,),
).fetchall()
return [{"id": r[0], "name": r[1], "slug": r[2]} for r in rows]
+38 -13
View File
@@ -98,14 +98,7 @@ class WooClient:
def _get(self, path: str) -> tuple[int, dict, bytes]:
url = f"{self.base_url}/wp-json/wc/v3{path}"
return self._request(
url, "GET", None,
{
"Authorization": f"Basic {self._auth}",
"Accept": "application/json",
"User-Agent": _USER_AGENT,
},
)
return self._request(url, "GET", None, self._auth_headers())
def _fetch_all(self, path: str) -> list[dict[str, Any]]:
page = 1
@@ -132,6 +125,42 @@ class WooClient:
def get_attribute_terms(self, attr_id: int) -> list[dict[str, Any]]:
return self._fetch_all(f"/products/attributes/{attr_id}/terms")
def _auth_headers(self, extra: dict[str, str] | None = None) -> dict[str, str]:
"""Shared headers for every outbound Woo/WP request (User-Agent + auth)."""
h = {
"Authorization": f"Basic {self._auth}",
"Accept": "application/json",
"User-Agent": _USER_AGENT,
}
if extra:
h.update(extra)
return h
def get_products_by_sku(self, sku: str) -> list[dict[str, Any]]:
"""GET products filtered by SKU. Raises typed Woo* errors on non-200."""
code, _h, body = self._request(
f"{self.base_url}/wp-json/wc/v3/products?sku={sku}",
"GET",
None,
self._auth_headers(),
)
if code != 200:
raise WooServerError(f"sku lookup returned {code}")
return list(json.loads(body))
def create_product(self, payload: dict[str, Any]) -> int:
"""POST /products. Raises typed Woo* errors on failure; returns product id."""
body = json.dumps(payload).encode("utf-8")
code, _h, resp = self._request(
f"{self.base_url}/wp-json/wc/v3/products",
"POST",
body,
self._auth_headers({"Content-Type": "application/json"}),
)
if code not in (200, 201):
raise WooServerError(f"product create returned {code}")
return int(json.loads(resp)["id"])
def upload_media(self, png_bytes: bytes, filename: str) -> int:
"""POST a PNG to WordPress core media; return the attachment id."""
boundary = "----slabuploader-media-boundary"
@@ -143,11 +172,7 @@ class WooClient:
url = f"{self.base_url}/wp-json/wp/v2/media"
code, _headers, resp = self._request(
url, "POST", body,
{
"Authorization": f"Basic {self._auth}",
"Content-Type": f"multipart/form-data; boundary={boundary}",
"User-Agent": _USER_AGENT,
},
self._auth_headers({"Content-Type": f"multipart/form-data; boundary={boundary}"}),
)
if code not in (200, 201):
raise WooServerError(f"media upload returned {code}")
+202
View File
@@ -3,6 +3,7 @@
Port block `10880`–`10889` on ubudual5060 is allocated to this project.
`:10880` is Ty's local Node/SvelteKit and must never be bound by project deploys.
The dev-test environment binds `0.0.0.0` so any machine on `192.168.1.0/24` can reach it.
Check to see if you are running on ubudual5060 (192.168.1.202) before attempting SSH.
## Dev-test environment (M2 current, M3 incoming)
@@ -23,3 +24,204 @@ The dev-test environment binds `0.0.0.0` so any machine on `192.168.1.0/24` can
Future deploys use a **separate** container name and a free port in `10880`–`10889`
(other than `10880`), bound `0.0.0.0`.
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`).
- Images (built on `ubudual5060`): `deploy-frontend:latest` `1c169c29f131`,
`deploy-fastapi:latest` `32671659639b`.
- Compose: `deploy/docker-compose.yml` + host-local (untracked) `deploy/override-m3.yml`.
- URL: `http://192.168.1.202:10881` (LAN) · `http://100.64.0.9:10881` (Tailscale).
- `matches HEAD`: **yes**.
- Sample data preserved: **yes** — 24 slabs before and after `up -d` (no `-v`, no volume removal).
- Verified on the live stack: served asset `assets/index-tlFib3RM.js` (sha256 `3b665746ec81f28e…`)
contains the new markers and no `#thickness` select; `POST /api/v1/slabs` at `thickness_in 1.0`
stored band `350` (`1"- 1 1/2"`); `PUT` at `2.75` re-banded to `352`; a client-sent stale band id
was ignored; `POST /review` no longer lists thickness as missing; the Review screen in a real
browser (LAN URL) shows the wood-category MultiSelect closed summary with names + count, the
`Primary wood category` radio set switching on click, and the band as read-only text.
- Not exercised: the Woo publish path. Rows created **before** this deploy keep `thickness_term_id`
NULL until their next save (the band is derived on write, so any Review commit fills it in).
### 2026-09-21 03:12 PDT — follow-up: thickness band omitted entirely from Review
- Commit: `204bc5e` (fix #90: band removed from UI, not merely read-only; publish test added).
- Rebuilt `deploy-frontend:latest` and recreated the container on 10881.
- Live verification: served HTML and bundle contain zero occurrences of "Thickness band" or "set from the measurement".
- Sample data unchanged (still 25 slabs post-recreate).
- All gates (unit, build, requirements, coverage, m3-create e2e) pass.
### 2026-09-21 11:52 PDT — merge `issue-89-metadata-lock` → main (Create-string model)
- Source: branch `work/merge-89-90` (base `origin/issue-89-metadata-lock` + Create-string re-applied).
`matches HEAD`: **no** — staged from the uncommitted working tree (local sync; Gitea push not
attempted this round). 9 files changed: Review.svelte, slabs.ts, review.py, slabs.py, schema.sql,
db/__init__.py, test_review.py, m3-create.spec.ts, review-photo-strip.spec.ts.
- Model change: `metadata_locked` DB column + "Save and lock" UI **dropped end-to-end**. The
deterministic title/short_description are now built by the review endpoint on every call; the
owner's **Create title / Create short desc** buttons fire the build and write the result
(overwrite contract). `commitReview` auto-fills only empty fields (no owner-clobber); `createTitle`
is the explicit force-overwrite. Photo strip, wood primary/secondary, and server-side thickness
band (item: not shown in UI, passed to Woo on publish) all taken from the branch.
- LAN binds: `deploy/override-m3.yml` was absent from disk; the live stack's 10881/10882 LAN binds
were re-created by editing `deploy/docker-compose.yml` ports in-place (loopback 18082/18083 from
`.env` + `0.0.0.0:10881`/`0.0.0.0:10882`), then `down --remove-orphans` + `up -d` (no `-v`).
- Bundle: `assets/index-YPAqx609.js` (new; contains `create-title`). API `/api/health`
`woo_reachable:true`.
- Sample data preserved: **yes** — 34 slabs before/after.
- Live verification (LAN URL, real DB): `slabs` table has **no** `metadata_locked` column;
`slab_photos.role` present. `POST /review` on slab `ec4088da…` → `title:'Curl Black Walnut Slab'`,
`ready:True`. `PUT` title persisted (rev 1→2) then restored (original sample value intact, rev 3).
- Not exercised live: the full browser Create-button click (verified via in-repo Playwright 6/6
m3-create + review-photo-strip; the endpoint + PUT path are proven live above). Woo publish path
not exercised this round.
### 2026-09-21 12:10 PDT — redeploy: `main` after merge `8ee3da9` (reconciled PR #97 + branch)
- Source: `main` @ `8ee3da9` (merge of `work/merge-89-90` into main, which itself reconciled
main's PR #97 client-side `thicknessBand.ts` + `syncSelects`/`box-sizing` against the branch's
server-side `thickness_band.py` + Create-string #89). `matches HEAD`: **yes**.
- Resolution outcome in the deployed bundle: band is **server-side** — `Review.svelte` no longer
imports `thicknessBand` and the read-only band display is gone; PR #97's `syncSelects()` (3× in
source) + `box-sizing` CSS preserved; `metadata_locked` still dropped; Create-string + photo
strip + wood primary live.
- Rebuilt both images from `main`; recreated the stack (`down --remove-orphans` + `up -d`, no `-v`).
LAN binds 10881/10882 intact.
- Bundle: `assets/index-BScrgd8o.js` (new; differs from pre-merge `YPAqx609`; contains
`create-title`, **zero** `thicknessBand` references). API `/api/health` `woo_reachable:true`.
- Sample data preserved: **yes** — 35 slabs (one added upstream since last deploy).
- Live verification (LAN URL, real DB): `POST /review` on slab `ec4088da…` →
`title:'Curl Black Walnut Slab'`, `ready:True`.
- Gates at merge time: backend 117/117, unit 88/88, tsc clean, e2e 6/6 (m3-create + photo-strip).
- Branches `work/merge-89-90` and `issue-89-metadata-lock` deleted (local + remote); remote has
only `main`.
### 2026-09-21 12:34 PDT — Review mount: restore dropped lineup/woodPrimary imports
- Source: uncommitted working tree on `main` (parent `67aa3d6`). `matches HEAD`: **no**.
Diff: `frontend/src/create/review/Review.svelte` +5 lines restoring imports of
`keptInOrder`/`moveItem`/`missingMinimums`/`roleLabel`/`resolvePhotoUrl`/`LineupItem`
from `lineup.ts` and `nextPrimary` from `woodPrimary.ts`. The morning merge kept the
call sites and dropped the imports → `ReferenceError` on Review mount → "Could not load the slab."
- Rebuilt `deploy-frontend` only (`compose build frontend` + `up -d frontend`, no `-v`).
Image `56b35f6cdcdf`. FastAPI container left running.
- URL: http://192.168.1.202:10881 (LAN) · loopback 127.0.0.1:18082
- Bundle: `assets/index-BVCyM130.js` (replaced `index-ck41xD02.js`). Served JS has **0**
`nextPrimary`/`keptInOrder` (minified locals) vs 3 unresolved `nextPrimary` in the broken bundle.
- Sample data preserved: **yes** — 47 slabs before rebuild; 48 after live Capture→Review probe
(created `ef1f4101-b1d2-459d-b9a0-842e7650ea3c`, sku NULL, status calibrated, 48×2 in; not published).
- Live verification (loopback, real API): Continue to Review → `review-screen` visible,
heading "Review to Ready", no loadError, no pageerror. POST /slabs 201, photos 201×2,
GET slab/taxonomy/settings 200, review 200.
- In-repo mock e2e (m3-create + photo-strip) 6/6 green against the same bundle hash before deploy.
- Not done: commit/push of the import restore; Woo publish path not re-exercised this round.
+6 -2
View File
@@ -16,7 +16,9 @@ services:
build:
context: ..
dockerfile: frontend/Dockerfile
ports: ["127.0.0.1:${FRONTEND_PORT:-18080}:80"]
ports:
- "127.0.0.1:${FRONTEND_PORT:-18080}:80"
- "0.0.0.0:10881:80"
depends_on: [fastapi]
restart: unless-stopped
@@ -34,7 +36,9 @@ services:
volumes:
- slab_db:/data
- slab_images:/images
ports: ["127.0.0.1:${FASTAPI_PORT:-18081}:8000"]
ports:
- "127.0.0.1:${FASTAPI_PORT:-18081}:8000"
- "0.0.0.0:10882:8000"
mem_limit: 2g
restart: unless-stopped
+5
View File
@@ -4,6 +4,11 @@ server {
root /usr/share/nginx/html;
index index.html;
# Capture→Review uploads the calibrated lineup PNGs (clean cal + batch) as
# multipart to /api/v1/slabs/{id}/photos. nginx's default client_max_body_size
# is 1MB, which 413s on real multi-MB photos. Allow large photo bodies.
client_max_body_size 25m;
# Docker embedded DNS for service name resolution (fastapi)
resolver 127.0.0.11 valid=10s;
+7 -7
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 |
@@ -62,9 +62,9 @@ The listing is built in two inference calls, both optional. Call 1 (vision) read
| METADATA-20 | Missing attributes or terms are skipped, never created. | M3 | — |
| METADATA-25 | Species, character, length, and free-text thickness are not product attributes. | M3 | — |
| METADATA-30 | Product tags come from the synced Woo set and are all optional; no hardcoded prefixes, and the bare fig tag is kept. | M3 | — |
| METADATA-100 | Call 1 free-text observations (character, inclusions, voids, checks) are mapped to existing synced feat-* tags only; WooCommerce is the authority for feat-* definitions. | M3 | — |
| METADATA-35 | Call 1 runs only when inference is enabled, automatically after a calibrated draft uploads or on demand, and sends the clean processed photos at 1024 px long edge or less plus user metadata and the synced taxonomy. | M3 | — |
| METADATA-40 | Call 1 returns species, wood categories, edge, figure, grade, and feature-tag suggestions constrained to the synced lists, each with confidence. | M3 | — |
| METADATA-100 | Call 1 returns feature suggestions as feature_tag_ids chosen only from the synced feat-* tag set (display names without the feat- prefix); WooCommerce is the authority for those tags. No free-text observation bags. | M3 | backend/tests/test_inference.py |
| METADATA-35 | Call 1 runs only when inference is enabled, automatically after a calibrated draft uploads or on demand, and sends the clean processed photos at 1024 px long edge or less plus user metadata and the synced taxonomy. | M3 | backend/tests/test_inference.py |
| METADATA-40 | Call 1 returns species, wood categories, edge, figure (multi), and feature-tag id suggestions constrained to the synced LISTS, each with confidence. Grade is not suggested by Call 1 (owner judgment). Wood category is decided before edge; cookies/burl prefer Wild edge when present. | M3 | backend/tests/test_inference.py |
| METADATA-45 | Call 1 confidence at or above the threshold (default 0.7) pre-fills review fields; below it the field stays empty for the owner to fill by hand or re-run Call 1; suggestions are always editable. | M3 | — |
| METADATA-50 | Typed values always win over inferred values. | M3 | — |
| METADATA-85 | The primary wood category selects the Call 2 prompt and the title and short-description patterns. | M3 | — |
@@ -75,7 +75,7 @@ The listing is built in two inference calls, both optional. Call 1 (vision) read
| METADATA-65 | The model never invents or alters a dimension; every number it prints comes from the fact block. | M3 | — |
| METADATA-95 | The dimension-overlay image is never sent to the model. | M3 | — |
| METADATA-70 | With inference off, the server produces the long description. | M3 | — |
| METADATA-66 | Every content generation, from a deterministic pattern or from the model, is logged with its inputs, the model request and response when a model was used, and the final stored content. | M3 | — |
| METADATA-66 | Every content generation, from a deterministic pattern or from the model, is logged with its inputs, the model request and response when a model was used, and the final stored content. The logged request keeps full text and photo refs only — never base64 image payloads. | M3 | backend/tests/test_inference.py |
| METADATA-75 | Inference never blocks create and never auto-publishes. | M3 | — |
| METADATA-105 | Inference prompts are stored in the database with all revisions; each call uses the active revision, the revision history is retained, and the revision used is recorded with the call. | M3 | — |
| METADATA-106 | The prompt revision interface is a REST API: append a revision, activate a revision, and read the active prompt with its revision list. There is no prompt editing UI. | M3 | — |
@@ -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 | — |
@@ -199,7 +199,7 @@ The listing is built in two inference calls, both optional. Call 1 (vision) read
| QUALITY-80 | Pipeline vector TV-6: 3:4 crop, 90 percent fill at extremes, transparent PNG, shorter side at least 800 when the source allows. | shipped | — |
| QUALITY-85 | Pipeline vector TV-7: an undersized source warns, is not upscaled, and is not publishable until retake. | shipped | — |
| QUALITY-90 | Pipeline vector TV-8: bdft 0.96 at 12.50 per bdft gives a 12.00 recommendation, and an override of 150.00 is stored. | M3 | — |
| QUALITY-95 | Pipeline vector TV-9: a 1 inch thickness rounds up into the synced Woo thickness band, not a hardcoded one. | M3 | — |
| QUALITY-95 | Pipeline vector TV-9: a 1 inch thickness rounds up into the synced Woo thickness band, not a hardcoded one. | M3 | backend/tests/test_thickness_band.py |
| QUALITY-100 | Pipeline vector TV-10: half-up 2 decimal rounding, including 96.04 to 96, 0.641 to 0.64, 11.525 to 11.53, 12.345 to 12.35. | shipped | — |
| QUALITY-105 | Dishonest proxies that do not count as pass: hardcoded or fixture taxonomy treated as synced, a Woo mock that never hits the real store for the exit walk, unit tests alone without a real SLAB-UAT-* draft, consumer-key auth, inference wired as dead config or required for create, claiming ready with empty fields, or parking work on the wrong branch. | M3 | — |
| QUALITY-110 | The M3 exit evidence is a real SLAB-UAT-* Woo draft matching the reviewed ready set with inference off, plus test-woo and test-inference results. | M3 | — |
+4 -1
View File
@@ -121,7 +121,10 @@ hard stop. Switch to a supported browser.
3. **Wood categories.** Pick at least one from the synced list.
4. **Edge type, figure, grade.** Pick from the synced lists. One edge type, at least one figure,
exactly one grade.
5. **Thickness band.** Pick the Woo band that the measured thickness rounds up into.
5. **Thickness band.** Not on the screen and not picked: the server bands the measured thickness
up into the synced Woo band, stores it, and publish sends it to the store. Verify after publish
that the Woo product's Thickness attribute is the band the captured thickness rounds up into
(`QUALITY-95`).
6. **Moisture.** Keep the default (kiln-dried) or change it.
7. **fig-* tags.** Optional; pick any from the synced list.
8. **feat-* tags.** Zero or more. Only pick existing `feat-*` tags.
+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.
+4 -4
View File
@@ -144,9 +144,9 @@ block, and brand/geo context; no provider thread id and no raw Call 1 result. Re
and never touches dimensions. Cites METADATA-55, METADATA-65.
Call 1 result fields: `species_id`, `species_confidence`, `wood_category_ids`,
`edge_type_term_id`, `figure_term_ids`, `figure_confidence`, `grade_term_id`, `grade_confidence`,
`character`, `inclusions`, `voids`, `checks`. Free-text observations map to synced `feat-*` tags
only.
`wood_category_confidence`, `edge_type_term_id`, `edge_confidence`, `figure_term_ids`,
`figure_confidence`, `feature_tag_ids`, `feature_tag_confidence`. Feature tags are ids from the
synced `feat-*` set only (no free-text bags). Grade is never suggested.
Call 2 result field: `description`. The name and short description are assembled by the server from
the primary wood category's patterns, not returned by the call.
@@ -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
+9 -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.
@@ -160,6 +160,14 @@ Append-only; never deleted on publish. Columns: `id`, `slab_id`, `woo_product_id
Append-only audit. Columns: `id`, `slab_id`, `call` (1 | 2), `prompt_revision_id` FK, `model`,
`request_payload`, `response_payload`, `latency_ms`, `created_at`.
`request_payload` is auditable JSON of the model request **without media bytes**
(`METADATA-66`). Text parts (active prompt, LISTS / fact block, SKU/dims) are stored in full.
Each vision attachment is an `image_ref` (`photo_id`, `seq`, `bytes`, optional
`infer_long_edge_px`, `label`) pointing at `slab_photos` — never a
`data:image/...;base64,...` URL. The wire request to the inference API still includes images;
only the log is redacted. A byte cap remains as a safety net after redaction; normal Call 1/2
rows are far under it.
## 10. Status transitions
Cites APP-5.
+118 -22
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.
@@ -46,20 +52,23 @@ true; also on demand or retry. With inference off it is skipped and the fields a
**Inputs.**
- The clean processed photos at 1024 px long edge or less.
- The synced taxonomy: species leaves, Edge Type, Figure, Grade, Thickness, Moisture terms, each
with its Woo id. These are the only legal choices.
- Closed LISTS from the synced Woo cache only: `species`, `wood_categories`, `edge_terms`,
`figure_terms`, `feature_tags` (each with id + display name). Grade, thickness, and moisture are
not sent to Call 1.
- User metadata: SKU, length, thickness.
**Constraints.**
- The model may only choose values from the supplied synced lists; it never invents categories or
terms.
- Free-text observations (character, inclusions, voids, checks) are mapped to existing synced
`feat-*` tags only; Woo is authoritative for those tags.
- The model may only choose values from the supplied LISTS; it never invents categories or terms.
- Feature tags are returned as `feature_tag_ids` from the supplied list only. Display names omit a
leading `feat-` prefix; Woo remains authoritative for the tag set. No free-text observation bags.
- Grade is never suggested (owner judgment).
- Decide wood categories before edge type. Cookies and burl cuts prefer Wild edge when that term
exists in the list.
**Result fields.** `species_id` + `species_confidence`; `wood_category_ids`;
`edge_type_term_id`; `figure_term_ids` + `figure_confidence`; `grade_term_id` +
`grade_confidence`; `character`, `inclusions`, `voids`, `checks`. Grade, edge, thickness, and
moisture are singular.
**Result fields.** `species_id` + `species_confidence`; `wood_category_ids` +
`wood_category_confidence`; `edge_type_term_id` + `edge_confidence`; `figure_term_ids` +
`figure_confidence` (multi allowed); `feature_tag_ids` + `feature_tag_confidence`. Edge is singular;
figure and feature tags are multi.
**Pre-fill.** At or above the threshold (default 0.7) the field is pre-filled and remains
editable; below it the field stays empty and is not shown as a low-confidence guess. Typed values
@@ -87,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
@@ -110,16 +124,98 @@ application.
The seed is the starting point for tuning, not a final text. Both calls are revised ad hoc through
the §2 interface when UAT surfaces a miss.
**`call1` seed.** Classify the slab's species, wood categories, edge type, figure, and grade from
the supplied photos, choosing only from the supplied synced taxonomy lists, and return the result
fields in §3 as JSON. Report a confidence for each classified field. Map free-text observations
(character, inclusions, voids, checks) to the supplied feature-tag list only. Invent no category or
term.
**`call1` seed.** Shipped text in `backend/inference.py` (`CALL1_SEED`), mirrored here for review.
**`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.
You are a wood identification expert for retail lumber slabs. Judge only from the photos and the
LISTS below. Use list ids only — never invent a name or id.
Work in this order: (1) species, (2) wood categories, (3) edge type, (4) figure, (5) feature tags.
Decide wood categories before edge type — edge rules depend on product form.
For each field, pick from its list and give a confidence 0–1 (1 = certain, 0 = no idea). Prefer
null or [] over a weak guess.
Species — which tree species is this slab. Match grain, color, and figure to the species list. If
more than one entry is plausible, return only the single highest-confidence match (one id). Null
only when none are credible.
Wood categories — product form / how it will be sold (live edge, dimensional, cookies & burl
slices, turning blanks, etc.). Choose every wood_categories id that clearly fits; often one,
sometimes more. [] if none fit. Finish this field before edge type.
Edge type — the long-edge mill profile. One id from edge_terms, or null only if none fit.
Meanings (match the closest list name):
• Square / square edge — both long edges milled straight (2 milled long edges).
• Live one side / Live Edge 1 side — one long edge milled, the other natural bark or waney
(1 milled long edge).
• Live both / Live edge 2 sides — neither long edge milled; both natural (0 milled long edges).
• Wild edge — use when the three mill-count cases do not fit, including cookies and burl
cuts/slices (no conventional slab long-edge pair). Prefer Wild for those forms rather than null.
Figure — visible figure in the wood (curl, quilt, spalt, plain, burl, …). Zero or more ids from
figure_terms that clearly apply.
Feature tags — defects and character marks the store tags for shoppers (knots, checks,
inclusions, …). Zero or more ids from feature_tags. If that list is empty, return [].
Return one JSON object with exactly these keys:
{"species_id": <id|null>, "species_confidence": <0-1>, "wood_category_ids": [<id>,...],
"wood_category_confidence": <0-1>, "edge_type_term_id": <id|null>, "edge_confidence": <0-1>,
"figure_term_ids": [<id>,...], "figure_confidence": <0-1>, "feature_tag_ids": [<id>,...],
"feature_tag_confidence": <0-1>}
Runtime appends `LISTS:` (JSON of the five closed lists) plus SKU/length/thickness and the photos.
**`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
+9 -3
View File
@@ -12,7 +12,7 @@ Status: SPEC · 2026-09-18. Presentation spec for the mill-owner flow on phone a
| CAPTURE-40, CAPTURE-41, CAPTURE-45, CAPTURE-50, CAPTURE-55 | sheet/sensitivity controls, overlay, retake |
| CAPTURE-60, CAPTURE-65, CAPTURE-70, CAPTURE-80, CAPTURE-85, CAPTURE-90 | axis, entry, derived numbers |
| CAPTURE-105, CAPTURE-130, CAPTURE-135, CAPTURE-141, CAPTURE-145, CAPTURE-150, CAPTURE-155 | crop guard, lineup order, upload, preview/controls, failure |
| METADATA-10, METADATA-15, METADATA-30 | required taxonomy fields and cardinality |
| METADATA-10, METADATA-15, METADATA-30, QUALITY-95 | required taxonomy fields and cardinality; the thickness band is derived from the measurement, not picked |
| METADATA-45, METADATA-55, METADATA-110 | pre-fill, Generate text availability |
| PUBLISH-5, PUBLISH-10, PUBLISH-15, PUBLISH-20, PUBLISH-25, PUBLISH-30, PUBLISH-35, PUBLISH-36, PUBLISH-37, PUBLISH-38, PUBLISH-50 | publish sequence, outcomes and messaging |
| REVIEW-5, REVIEW-10, REVIEW-15, REVIEW-20, REVIEW-25, REVIEW-30, REVIEW-40, REVIEW-45, REVIEW-50 | ready set, gating, fields, limits |
@@ -55,10 +55,16 @@ Cites REVIEW-5, REVIEW-15, REVIEW-20, METADATA-45.
with an "AI" badge; below it the field starts empty. The badge is presentation only and clears
on edit.
- **Required set.** Exactly one species and at least one wood-category leaf; one Edge Type, at
least one Figure, exactly one Grade, one Thickness band, one Moisture; fig-* and feat-* tags
optional; price; title, short_description, and description.
least one Figure, exactly one Grade, one Moisture; fig-* and feat-* tags optional; price;
title, short_description, and description. The **Thickness band is not an owner control** — it
is derived deterministically from `thickness_in` (the value rounds up into the synced Woo band,
`TV-9` / `QUALITY-95`), displayed read-only, and satisfies the readiness requirement
(`REVIEW-5`) via that derived value.
- **Primary category.** The owner declares one primary wood category and any secondary ones; the
default is the first selected.
- **Thickness band is derived and not shown.** The measured thickness bands up into the synced Woo
band server-side (`METADATA-15`, `QUALITY-95`); Review carries no thickness-band control and no
read-only echo of it. The derived band is stored on the slab and sent to the store at publish.
- **Inline nudges.** Every empty required field shows an inline nudge as the owner works; there is
no single submit-time gap list.
- **Calculated fields.** Widths, sqft, and bdft are read-only and not overridable.
+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>
+1 -1
View File
@@ -7,7 +7,7 @@
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview",
"test": "node --import tsx --test src/**/*.test.ts",
"test": "node --import tsx --test --test-reporter=spec src/**/*.test.ts",
"test:e2e": "playwright test",
"test:e2e:headed": "playwright test --headed"
},
+4 -4
View File
@@ -6,12 +6,12 @@ import { defineConfig, devices } from '@playwright/test';
*/
export default defineConfig({
testDir: './tests/e2e',
fullyParallel: false,
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: 0,
workers: 1,
timeout: 180_000,
expect: { timeout: 30_000 },
workers: 2,
timeout: 30_000,
expect: { timeout: 4_000 },
reporter: [['list'], ['html', { open: 'never', outputFolder: 'tests/e2e/playwright-report' }]],
use: {
baseURL: 'http://127.0.0.1:4173',
+221 -86
View File
@@ -1,20 +1,23 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { supported } from './browser';
import { supported, uuid } from './browser';
import { createCaptureSession, type CaptureSession } from './capture/session';
import type { Capture } from './capture/capture.types';
import Resume from './capture/Resume.svelte';
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 PhotoAttach from './create/photos/PhotoAttach.svelte';
import Review from './create/review/Review.svelte';
import Publish from './create/publish/Publish.svelte';
import GenerateText from './create/inference/GenerateText.svelte';
import type { Capture, Measures } from './capture/capture.types';
import { eighthsToIn } from './capture/capture.types';
import { buildPhotosReadyLineup } from './capture/photosReadyLineup';
import Resume from './capture/Resume.svelte';
import BatchPhotos from './capture/BatchPhotos.svelte';
import PickCalibration from './capture/PickCalibration.svelte';
import MaskPanel from './capture/MaskPanel.svelte';
import MeasurePanel from './capture/MeasurePanel.svelte';
import Settings from './create/settings/Settings.svelte';
import Review from './create/review/Review.svelte';
import Publish from './create/publish/Publish.svelte';
import {
flow, startSaving, enterReview, enterPublish, markDone, backToCapture, backToReview, type CalibratedCapture,
} from './create/flow';
import { createSlab, uploadPhoto, getSettings, ApiError } from './api/slabs';
import { clearDraft } from './draft/idb';
const gate = typeof window !== 'undefined' ? supported() : 'window unavailable';
const session: CaptureSession = createCaptureSession();
@@ -23,28 +26,114 @@ import GenerateText from './create/inference/GenerateText.svelte';
let busy = false;
let pngUrl: string | null = null;
let showSettings = false;
let showSlabs = false;
let showPhotos = false;
let showReview = false;
let showPublish = false;
let showGenerate = false;
let curFlow: import('./create/flow').Flow = { phase: 'capture' };
$: curFlow = $flow;
let saving = false;
let saveError = '';
const unsubCap = session.capture.subscribe((c) => {
cap = c;
pngUrl = c.k === 'measure' ? session.getInventoryUrl() : null;
});
const unsubBusy = session.busy.subscribe((b) => {
busy = b;
});
const unsubBusy = session.busy.subscribe((b) => (busy = b));
onMount(() => {
void session.resumeProbe();
// e2e boot guard: `?noload` skips the resume probe so a test can seed the
// device draft into IndexedDB after the origin exists, then reload without
// ?noload to trigger the Resume prompt deterministically.
const boot = new URLSearchParams(window.location.search);
if (!boot.get('noload')) void session.resumeProbe();
});
onDestroy(() => {
unsubCap();
unsubBusy();
session.dispose();
});
// APP-15: the device draft is cleared only on publish success / "new slab".
function startNewSlab(): void {
void clearDraft().then(() => {
session.startFresh();
backToCapture();
});
}
// C2: Photos-ready → build the calibrated slab, upload the lineup PNGs, then
// hand off to Review. The device draft is retained here (APP-10); it is
// cleared only on publish success (APP-15).
async function continueToReview(): Promise<void> {
const m = session.currentMeasure();
if (!m || !m.results) return;
const r: Measures = m.results;
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),
sourceBelowBlock: r.sourceBelowBlock,
sourceBelowWarn: r.sourceBelowWarn,
lineup: buildPhotosReadyLineup({
inventoryPng: r.inventoryPng,
overlayPng: r.overlayPng,
batchApply: m.batchApply,
sourceBelowBlock: r.sourceBelowBlock,
sourceBelowWarn: r.sourceBelowWarn,
}),
};
saving = true;
saveError = '';
startSaving();
try {
const id = uuid();
await createSlab({
id,
sku: '',
length_in: capture.lengthIn,
thickness_in: capture.thicknessIn,
sqft: capture.sqft,
bdft: capture.bdft,
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',
client_rev: 0,
});
// #90 / REVIEW-5: persist the full lineup with correct roles so the Review
// strip matches in-memory blobs by role. The overlay is the dimension-drawn
// copy — it must be persisted (its blob already has the dimensions) and is
// the photo the owner sees with the measurement overlay. Excluding it (the
// old `role !== 'overlay'` filter) dropped the dimension copy from Review.
const pngItems = capture.lineup.filter((it) => it.kind === 'png');
for (let i = 0; i < pngItems.length; i++) {
const it = pngItems[i];
await uploadPhoto(id, {
photoId: uuid(),
seq: i + 1,
isCalibration: it.role === 'calibration',
role: it.role,
original: it.blob,
// The calibration/batch PNGs have no separate processed variant; the
// overlay blob already carries the drawn dimensions.
processed: it.blob,
});
}
enterReview(id, capture);
} catch (e) {
saveError =
e instanceof ApiError ? e.message : 'Could not start the slab. Check your connection and try again.';
backToCapture();
} finally {
saving = false;
}
}
</script>
{#if gate}
@@ -58,74 +147,104 @@ import GenerateText from './create/inference/GenerateText.svelte';
</main>
{:else}
<main class="app">
{#if cap.k === 'resume'}
<Resume onContinue={() => session.continueDraft()} onFresh={() => void session.startFresh()} />
{:else if cap.k === 'batch'}
<BatchPhotos
photos={cap.photos}
onAdd={(f) => void session.addToBatch(f)}
onRemove={session.removeFromBatch}
onContinue={session.continueBatch}
{#if curFlow.phase === 'review'}
<Review
slabId={curFlow.slabId}
capture={curFlow.capture}
onPublish={() => {
const f = curFlow;
if (f.phase === 'review') enterPublish(f.slabId, f.capture);
}}
onBack={() => backToCapture()}
/>
{:else if cap.k === 'pickCalibration'}
<PickCalibration
photos={cap.photos}
onPick={session.pickCalibration}
onBack={session.back}
/>
{:else if cap.k === 'mask'}
<MaskPanel
state={cap}
{busy}
setSensitivity={session.setSensitivity}
setSheet={session.setSheet}
approveMask={session.approveMask}
retakes={session.retakes}
back={session.back}
/>
{:else if cap.k === 'axis'}
<AxisPanel
state={cap}
setAxisAngle={session.setAxisAngle}
approveAxis={session.approveAxis}
back={session.back}
{:else if curFlow.phase === 'publish'}
<Publish
slabId={curFlow.slabId}
onPublished={(wooId, draft) => markDone(curFlow.slabId, wooId, draft)}
onBack={() => {
const f = curFlow;
if (f.phase === 'publish') backToReview(f.slabId, f.capture);
}}
/>
{:else if curFlow.phase === 'done'}
<section class="panel done" aria-label="Published">
<h1>Published</h1>
<p class="ok">
{#if curFlow.draft}
Created as a UAT draft in the store.
{:else}
Created in the store.
{/if}
</p>
<p>Woo product #{curFlow.wooProductId} · photos and device draft cleared.</p>
<div class="row">
<button type="button" class="primary" data-testid="new-slab" on:click={startNewSlab}>
Start a new slab
</button>
</div>
</section>
{:else if curFlow.phase === 'saving'}
<section class="panel" aria-busy="true">
<h1>Saving slab…</h1>
</section>
{:else}
<MeasurePanel
state={cap}
setLength={session.setLength}
setThickness={session.setThickness}
calculate={session.calculate}
back={session.back}
{pngUrl}
/>
<!-- capture column (single slab, CAPTURE-25/30) -->
{#if cap.k === 'resume'}
<Resume onContinue={() => session.continueDraft()} onFresh={() => void session.startFresh()} />
{:else if cap.k === 'batch'}
<BatchPhotos
photos={cap.photos}
onAdd={(f) => void session.addToBatch(f)}
onRemove={session.removeFromBatch}
onContinue={session.continueBatch}
/>
{:else if cap.k === 'pickCalibration'}
<PickCalibration
photos={cap.photos}
onPick={session.pickCalibration}
onBack={session.back}
/>
{:else if cap.k === 'mask'}
<MaskPanel
state={cap}
{busy}
setSensitivity={session.setSensitivity}
setSheet={session.setSheet}
approveMask={session.approveMask}
retakes={session.retakes}
back={session.back}
/>
{: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}
onContinue={saving ? undefined : () => void continueToReview()}
/>
{/if}
{#if saving}
<div class="row"><span class="busy" aria-live="polite">Saving slab…</span></div>
{/if}
{#if saveError}
<div class="save-err" role="alert" data-testid="save-error">{saveError}</div>
{/if}
{/if}
<div style="margin-top:1rem;display:flex;gap:8px">
<button on:click={() => (showSettings = !showSettings)}>Settings (dev)</button>
<button on:click={() => (showSlabs = !showSlabs)}>Slabs (dev)</button>
<button on:click={() => (showPhotos = !showPhotos)}>Photos (dev)</button>
<button on:click={() => (showReview = !showReview)}>Review (dev)</button>
<button on:click={() => (showPublish = !showPublish)}>Publish (dev)</button>
<button on:click={() => (showGenerate = !showGenerate)}>Generate (dev)</button>
</div>
{#if showSettings}
<!-- 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 showSettings && curFlow.phase !== 'done'}
<Settings />
{/if}
{#if showSlabs}
<SlabList />
{/if}
{#if showPhotos}
<PhotoAttach />
{/if}
{#if showReview}
<Review />
{/if}
{#if showPublish}
<Publish />
{/if}
{#if showGenerate}
<GenerateText />
{/if}
</main>
{/if}
@@ -135,8 +254,24 @@ import GenerateText from './create/inference/GenerateText.svelte';
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: 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: 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>
+521 -18
View File
@@ -1,45 +1,133 @@
// Thin client for the S2 slabs endpoints. Svelte-agnostic.
// Thin client for the SlabUploader app API. Svelte-agnostic.
//
// Mirrors the LIVE backend routes (backend/*.py), which are the source of truth:
// slabs.py POST/GET/PUT/DELETE /api/v1/slabs[/{id}]
// 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 (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}]
//
// Every non-2xx is surfaced as an ApiError carrying the backend `code` and a plain
// owner-facing `message` (QUALITY-120: raw codes/stack traces never reach the owner).
export interface Slab {
id: string;
sku: string;
status: string;
price: number;
price: number | null;
price_source: string | null;
length_in: number;
thickness_in: number;
sqft: number;
bdft: number;
width_min_in: number;
width_max_in: number;
width_avg_in: number;
species_id: number | null;
wood_category_ids: number[];
edge_type_term_id: number | null;
figure_term_ids: number[];
grade_term_id: number | null;
thickness_term_id: number | null;
moisture_term_id: number | null;
fig_tag_ids: number[];
feat_tag_ids: number[];
title: string | null;
short_description: string | null;
description: string | null;
content_source: string | null;
species_confidence: number | null;
figure_confidence: number | null;
grade_confidence: number | null;
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;
}
// Fields a client may send on create/update (subset of the row).
export type SlabInput = Partial<
Omit<
Slab,
| 'id'
| 'status'
| 'woo_product_id'
| 'server_rev'
| 'created_at'
| 'updated_at'
| 'inference_status'
| 'species_confidence'
| 'figure_confidence'
| 'grade_confidence'
| 'calibration_photo_id'
>
>;
export interface SlabCreateInput extends SlabInput {
id: string; // client-generated UUIDv4 (APP-20)
client_rev: number; // 0 on create (APP-20)
}
export interface ApiErrorBody {
code: string | null;
message: string;
detail?: Record<string, unknown> | null;
}
export class ApiError extends Error {
constructor(public status: number, public code: string | null, message: string) {
constructor(
public status: number,
public code: string | null,
message: string,
public detail?: Record<string, unknown> | null,
) {
super(message);
this.name = 'ApiError';
}
}
async function parse<T>(res: Response): Promise<T> {
if (!res.ok) {
let code: string | null = null;
let message = res.statusText;
try {
const body = await res.json();
if (body?.error) {
code = body.error.code ?? null;
message = body.error.message ?? message;
}
} catch {
/* non-JSON body */
}
throw new ApiError(res.status, code, message);
}
if (res.status === 204) return undefined as T;
return (await res.json()) as T;
let body: unknown = null;
try {
body = await res.json();
} catch {
/* non-JSON body (e.g. 410 Gone) */
}
if (!res.ok) {
const e = (body as { error?: ApiErrorBody } | null)?.error;
throw new ApiError(
res.status,
e?.code ?? null,
e?.message ?? res.statusText,
e?.detail ?? null,
);
}
return body as T;
}
function jsonBody(v: unknown): BodyInit {
return JSON.stringify(v);
}
// ---------------------------------------------------------------------------
// Slabs
// ---------------------------------------------------------------------------
export function createSlab(input: SlabCreateInput): Promise<Slab> {
return fetch('/api/v1/slabs', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: jsonBody(input),
}).then((r) => parse<Slab>(r));
}
export function listSlabs(): Promise<Slab[]> {
@@ -49,3 +137,418 @@ export function listSlabs(): Promise<Slab[]> {
export function getSlab(id: string): Promise<Slab> {
return fetch(`/api/v1/slabs/${encodeURIComponent(id)}`).then((r) => parse<Slab>(r));
}
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' },
body: jsonBody(patch),
}).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),
);
}
// ---------------------------------------------------------------------------
// Photos (lineup order = seq; calibration is is_calibration=1)
// ---------------------------------------------------------------------------
export interface PhotoRow {
id: string;
seq: number;
is_calibration: number;
/** #90: lineup kind (clean cal / dimension-overlay copy / batch). */
role: 'calibration' | 'overlay' | 'batch';
width_px: number | null;
height_px: number | null;
has_original: number;
has_processed: number;
}
export interface UploadPhotoInput {
photoId: string; // client-generated UUIDv4
seq?: number;
isCalibration: boolean;
/** #90: lineup kind; defaults to batch. */
role?: 'calibration' | 'overlay' | 'batch';
original: Blob;
processed?: Blob | null;
}
export interface UploadPhotoResult {
id: string;
slab_id: string;
seq: number;
is_calibration: number;
role: 'calibration' | 'overlay' | 'batch';
original_retained: boolean;
processed_retained: boolean;
}
export function listPhotos(slabId: string): Promise<PhotoRow[]> {
return fetch(`/api/v1/slabs/${encodeURIComponent(slabId)}/photos`).then((r) =>
parse<PhotoRow[]>(r),
);
}
export function uploadPhoto(slabId: string, input: UploadPhotoInput): Promise<UploadPhotoResult> {
const form = new FormData();
form.append('photo_id', input.photoId);
if (input.seq != null) form.append('seq', String(input.seq));
form.append('is_calibration', input.isCalibration ? '1' : '0');
form.append('role', input.role ?? 'batch');
const origName = input.original instanceof File ? input.original.name : 'original.png';
form.append('original', input.original, origName);
if (input.processed) form.append('processed', input.processed, 'processed.png');
return fetch(`/api/v1/slabs/${encodeURIComponent(slabId)}/photos`, {
method: 'POST',
body: form,
}).then((r) => parse<UploadPhotoResult>(r));
}
// #90: drop a photo the owner removed in Review (keep/remove edit).
export function deletePhoto(slabId: string, photoId: string): Promise<void> {
return fetch(`/api/v1/slabs/${encodeURIComponent(slabId)}/photos/${encodeURIComponent(photoId)}`, {
method: 'DELETE',
}).then((r) => parse<void>(r));
}
/**
* #90: persist an edited Review photo lineup. `kept` is the owner's final
* order (leftmost = primary); each entry re-uploads with a fresh seq so the
* stored `seq` reflects the keep order. `removed` photos are dropped. Every
* kept photo's blob is re-sent (original + processed) because the server
* stores only the BLOB — there is no separate re-seq endpoint.
*/
export interface LineupPhoto {
id: string;
role: 'calibration' | 'overlay' | 'batch';
isCalibration: boolean;
original: Blob;
processed: Blob | null;
}
export async function persistLineup(
slabId: string,
kept: LineupPhoto[],
removed: string[],
): Promise<void> {
for (const id of removed) {
await deletePhoto(slabId, id);
}
for (let i = 0; i < kept.length; i++) {
const p = kept[i];
await uploadPhoto(slabId, {
photoId: p.id,
seq: i + 1,
isCalibration: p.isCalibration,
role: p.role,
original: p.original,
processed: p.processed,
});
}
}
export function photoUrl(
slabId: string,
photoId: string,
kind: 'original' | 'processed',
): string {
return `/api/v1/slabs/${encodeURIComponent(slabId)}/photos/${encodeURIComponent(photoId)}/${kind}`;
}
// ---------------------------------------------------------------------------
// Review (REVIEW-*)
// ---------------------------------------------------------------------------
export interface ReviewResult {
ready: boolean;
missing: string[];
title: string | null;
short_description: string | null;
price: number | null;
price_suggestion: number | null;
primary_wood_category_id: number | null;
}
export interface ReviewInput {
primary_wood_category_id?: number;
price?: number;
sku?: string;
title?: string;
short_description?: string;
description?: string;
price_source?: string;
content_source?: string;
}
export function reviewSlab(slabId: string, payload: ReviewInput = {}): Promise<ReviewResult> {
return fetch(`/api/v1/slabs/${encodeURIComponent(slabId)}/review`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: jsonBody(payload),
}).then((r) => parse<ReviewResult>(r));
}
// ---------------------------------------------------------------------------
// Publish (PUBLISH-*)
// ---------------------------------------------------------------------------
export interface PublishResult {
ok: true;
status: string;
woo_product_id: number;
draft: boolean;
}
export function publishSlab(slabId: string): Promise<PublishResult> {
return fetch(`/api/v1/slabs/${encodeURIComponent(slabId)}/publish`, {
method: 'POST',
}).then((r) => parse<PublishResult>(r));
}
// ---------------------------------------------------------------------------
// Inference (METADATA-*)
// ---------------------------------------------------------------------------
export interface Call1Suggestions {
species_id?: number | null;
species_confidence?: number | null;
wood_category_ids?: number[];
edge_type_term_id?: number | null;
figure_term_ids?: number[];
figure_confidence?: number | null;
feature_tag_ids?: number[];
feature_tag_confidence?: number | null;
}
export interface Call1Result {
ok: boolean;
suggestions: Call1Suggestions;
}
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',
}).then((r) => parse<Call1Result>(r));
}
export function inferContent(slabId: string): Promise<Call2Result> {
return fetch(`/api/v1/slabs/${encodeURIComponent(slabId)}/inference/call2`, {
method: 'POST',
}).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)
// ---------------------------------------------------------------------------
export interface TaxCategory {
id: number;
name: string;
slug: string;
parent_id: number | null;
woo_id: number | null;
}
export interface TaxTerm {
id: number;
name: string;
slug: string;
}
export interface TaxAttribute {
id: number;
name: string;
slug: string;
terms: TaxTerm[];
}
export interface TaxTag {
id: number;
name: string;
slug: string;
}
export interface Taxonomy {
categories: TaxCategory[];
attributes: TaxAttribute[];
tags: TaxTag[];
taxonomy_anchor: string | null;
taxonomy_last_sync_at: string | null;
}
export function getTaxonomy(): Promise<Taxonomy> {
return fetch('/api/v1/admin/taxonomy').then((r) => parse<Taxonomy>(r));
}
export function syncTaxonomy(): Promise<{
taxonomy_anchor: string;
taxonomy_last_sync_at: string;
}> {
return fetch('/api/v1/admin/taxonomy/sync', { method: 'POST' }).then((r) => parse(r));
}
// ---------------------------------------------------------------------------
// Pricing (SETTINGS-35)
// ---------------------------------------------------------------------------
export interface PriceRule {
id: number;
species_id: number;
price_per_bdft: number;
created_at: string;
updated_at: string;
}
export function listPricing(): Promise<PriceRule[]> {
return fetch('/api/v1/admin/pricing').then((r) => parse<PriceRule[]>(r));
}
export function createPricingRule(rule: {
species_id: number;
price_per_bdft: number;
}): Promise<PriceRule> {
return fetch('/api/v1/admin/pricing', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: jsonBody(rule),
}).then((r) => parse<PriceRule>(r));
}
export function updatePricingRule(
id: number,
patch: { price_per_bdft: number; species_id?: number },
): Promise<PriceRule> {
return fetch(`/api/v1/admin/pricing/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: jsonBody(patch),
}).then((r) => parse<PriceRule>(r));
}
export function deletePricingRule(id: number): Promise<void> {
return fetch(`/api/v1/admin/pricing/${id}`, { method: 'DELETE' }).then((r) => parse<void>(r));
}
// ---------------------------------------------------------------------------
// Settings (SETTINGS-*)
// ---------------------------------------------------------------------------
export interface SettingsView {
woo_base_url: string;
woo_wp_username: string | null;
woo_app_password: { configured: boolean } | string;
inference_base_url: { configured: boolean } | string;
inference_api_key: { configured: boolean } | string;
inference_model: string | null;
inference_enabled: boolean;
content_llm_enabled: boolean;
brand_voice: string | null;
geo_context: string | null;
woo_create_status: string;
taxonomy_last_sync_at: string | null;
taxonomy_anchor: string | null;
}
export function getSettings(): Promise<SettingsView> {
return fetch('/api/v1/settings').then((r) => parse<SettingsView>(r));
}
export function updateSettings(
patch: Partial<Record<keyof SettingsView, unknown>>,
): Promise<SettingsView> {
return fetch('/api/v1/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: jsonBody(patch),
}).then((r) => parse<SettingsView>(r));
}
export interface TestWooResult {
ok: boolean;
detail: string;
taxonomy_anchor: string | null;
taxonomy_last_sync_at: string | null;
}
export interface TestInferenceResult {
ok: boolean;
mode: 'stateful' | 'stateless' | 'fail';
vision_capable: boolean;
detail: string;
}
export function testWoo(): Promise<TestWooResult> {
return fetch('/api/v1/settings/test-woo', { method: 'POST' }).then((r) =>
parse<TestWooResult>(r),
);
}
export function testInference(): Promise<TestInferenceResult> {
return fetch('/api/v1/settings/test-inference', { method: 'POST' }).then((r) =>
parse<TestInferenceResult>(r),
);
}
+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;
},
);
});
+22
View File
@@ -24,3 +24,25 @@ export function supported(): string | null {
if (!isIOS && !isAndroid && (isChrome || isEdge || isFirefox)) return null;
return 'unsupported browser';
}
// v4 UUID. crypto.randomUUID() is only guaranteed on secure contexts
// (HTTPS/localhost); the UAT host is plain HTTP on a LAN IP, where it is
// undefined. Fall back to crypto.getRandomValues (available on HTTP) so the
// client-generated ids (APP-20) work in both contexts.
export function uuid(): string {
const c = globalThis.crypto;
if (c && typeof c.randomUUID === 'function') return c.randomUUID();
if (c && typeof c.getRandomValues === 'function') {
const b = new Uint8Array(16);
c.getRandomValues(b);
b[6] = (b[6] & 0x0f) | 0x40; // version 4
b[8] = (b[8] & 0x3f) | 0x80; // variant 10
const h = Array.from(b, (x) => x.toString(16).padStart(2, '0')).join('');
return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
}
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (ch) => {
const r = (Math.random() * 16) | 0;
const v = ch === 'x' ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
+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>
+302 -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,13 +20,63 @@
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;
/** C2: hand off the calibrated capture to Review (App builds the slab + uploads). */
export let onContinue: (() => void) | undefined = undefined;
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[] = [];
@@ -33,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) : '',
@@ -43,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;
@@ -64,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; }
};
@@ -81,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">
@@ -120,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
@@ -138,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">
@@ -157,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">
@@ -168,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>
@@ -177,33 +424,70 @@
<div class="row">
<button type="button" class="ghost" on:click={back}>Back</button>
{#if onContinue}
<button type="button" class="primary" data-testid="continue-to-review" on:click={onContinue}>
Continue to Review
</button>
{/if}
</div>
{/if}
</section>
<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]);
+204 -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,19 +45,24 @@ 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;
/** The current `measure` branch, or null while not on it (C2 handoff). */
currentMeasure(): Extract<Capture, { k: 'measure' }> | null;
dispose(): void;
}
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>;
}
@@ -204,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,
};
}
@@ -214,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,
@@ -310,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;
@@ -336,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);
@@ -469,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();
},
@@ -514,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
@@ -557,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
@@ -589,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 };
@@ -620,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 });
}
}
},
@@ -632,6 +801,10 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
return pngUrl;
},
currentMeasure() {
return state.k === 'measure' ? state : null;
},
dispose() {
teardownWorker();
},
+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, "'");
}
+54
View File
@@ -0,0 +1,54 @@
// Shared state for the one-column Create flow (UX.md §1): capture → review →
// publish. App.svelte owns it; MeasurePanel hands off the calibrated capture,
// Review hands to Publish, Publish clears the device draft on success.
//
// The flow always concerns a SINGLE slab — there is no list/picker in the
// owner path (CAPTURE-25/30).
import { writable } from 'svelte/store';
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;
sourceBelowBlock: boolean;
sourceBelowWarn: boolean;
lineup: PhotosReadyLineupItem[];
}
export type Flow =
| { phase: 'capture' }
| { phase: 'saving' }
| { phase: 'review'; slabId: string; capture: CalibratedCapture }
| { phase: 'publish'; slabId: string; capture: CalibratedCapture }
| { phase: 'done'; slabId: string; wooProductId: number; draft: boolean };
const initial: Flow = { phase: 'capture' };
export const flow = writable<Flow>(initial);
export function startSaving(): void {
flow.set({ phase: 'saving' });
}
export function enterReview(slabId: string, capture: CalibratedCapture): void {
flow.set({ phase: 'review', slabId, capture });
}
export function enterPublish(slabId: string, capture: CalibratedCapture): void {
flow.set({ phase: 'publish', slabId, capture });
}
export function markDone(slabId: string, wooProductId: number, draft: boolean): void {
flow.set({ phase: 'done', slabId, wooProductId, draft });
}
export function backToReview(slabId: string, capture: CalibratedCapture): void {
flow.set({ phase: 'review', slabId, capture });
}
export function backToCapture(): void {
flow.set({ phase: 'capture' });
}
@@ -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>
@@ -1,5 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte';
import { uuid } from '../../browser';
let slabId = '';
let photos: {
@@ -38,7 +39,7 @@
busy = true;
message = '';
const form = new FormData();
form.append('photo_id', photoId || crypto.randomUUID());
form.append('photo_id', photoId || uuid());
form.append('is_calibration', isCalibration ? '1' : '0');
form.append('original', originalFile, originalFile.name);
if (processedFile) form.append('processed', processedFile, processedFile.name);
+182 -59
View File
@@ -1,69 +1,192 @@
<script lang="ts">
let slabId = '';
let result: {
ok?: boolean;
status?: string;
woo_product_id?: number;
draft?: boolean;
error?: { code: string; message: string; detail?: Record<string, unknown> };
} | null = null;
let busy = false;
import { onMount, createEventDispatcher } from 'svelte';
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';
async function publish() {
if (!slabId) {
result = { error: { code: 'input', message: 'Enter a slab id.' } };
return;
}
busy = true;
result = null;
try {
const res = await fetch(`/api/v1/slabs/${encodeURIComponent(slabId)}/publish`, { method: 'POST' });
const body = await res.json();
if (res.ok) result = body;
else result = { ok: false, error: body.error };
} catch {
result = { ok: false, error: { code: 'network', message: 'network error' } };
}
busy = false;
// 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 = '';
interface PublishState {
phase: 'idle' | 'working' | 'success' | 'failure';
ok?: { wooProductId: number; draft: boolean };
fail?: {
code: string | null;
message: string;
recoverable: boolean;
partialMedia: boolean;
duplicateSku: boolean;
};
}
let st: PublishState = { phase: 'idle' };
// Map backend codes to the recovery branches (PUBLISH-30/31, QUALITY-120).
function classify(code: string | null, message: string, detail: Record<string, unknown> | null): PublishState['fail'] {
const d = detail ?? {};
return {
code,
message,
recoverable: d.recoverable === true,
partialMedia: d.partial_media === true,
duplicateSku: code === 'duplicate_sku',
};
}
function doPublish() {
st = { phase: 'working' };
publishSlab(slabId)
.then((r) => {
st = { phase: 'success', ok: { wooProductId: r.woo_product_id, draft: r.draft } };
// Mark the flow done (App switches to the Done panel) after a tick so the
// success state renders first.
onPublished(r.woo_product_id, r.draft);
})
.catch((e) => {
if (e instanceof ApiError) {
st = { phase: 'failure', fail: classify(e.code, e.message, e.detail ?? null) };
} else {
st = { phase: 'failure', fail: { code: 'network', message: 'The store is unreachable right now. You can retry.', recoverable: true, partialMedia: false, duplicateSku: false } };
}
});
}
function editSku() { dispatch('back'); }
function openExisting() { dispatch('open-existing'); }
function backToReview() { onBack(); }
onMount(async () => {
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>
<div class="publish">
<h2>Publish to WooCommerce</h2>
<div>
<label>Slab ID</label>
<input bind:value={slabId} placeholder="uuid" />
</div>
<button on:click={publish} disabled={busy}>{busy ? 'Publishing…' : 'Publish'}</button>
{#if loading}
<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 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 preview</h1>
<p class="lede">Confirm what’s about to publish to WooCommerce as a draft.</p>
{#if result}
{#if result.ok}
<p class="ok">
Published{result.draft ? ' (UAT draft)' : ''} → Woo product #{result.woo_product_id}
</p>
{:else}
<p class="err">
{result.error?.message}
{#if result.error?.detail?.recoverable === false}
<span class="tag">unrecoverable</span>
{:else if result.error?.detail?.recoverable === true}
<span class="tag">recoverable — retry</span>
{/if}
{#if result.error?.detail?.partial_media}
<span class="tag warn">orphan media — clean up manually</span>
{/if}
</p>
{#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}
</div>
{#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>
{/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 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">
<div class="x n">✕</div>
<h2>SKU already exists</h2>
<p class="meta">“{slab?.sku}” is already in the store.</p>
</div>
<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 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>
<h2>{st.fail.message}</h2>
{#if st.fail.recoverable}<span class="tag">recoverable</span>{/if}
{#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 full" data-testid="retry-publish" on:click={doPublish}>Retry</button>
{/if}
{/if}
</section>
{/if}
<style>
.publish { max-width: 520px; color: #eee; }
label { display: block; margin: 8px 0 4px; }
input { width: 100%; padding: 6px; }
button { margin-top: 12px; }
.ok { margin-top: 12px; color: #7fd37f; font-weight: 600; }
.err { margin-top: 12px; color: #e88; }
.tag { margin-left: 8px; font-size: 12px; padding: 1px 6px; border-radius: 4px; background: #444; }
.tag.warn { background: #6a4; color: #000; }
/* 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; }
.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; }
.x { display: flex; align-items: center; gap: 8px; }
.x .n { width: 22px; height: 22px; border-radius: 50%; background: #444; color: #fff; display: flex; align-items: center; justify-content: center; font-size: .8rem; }
.x .n.rec { background: #00e5ff; color: #002; }
.tag { margin-left: 8px; font-size: .64rem; padding: 1px 6px; border-radius: 2px; background: #444; color: #ccc; }
.tag.warn { background: #6a4; color: #000; } .tag.bad { background: #633; color: #ffb4a2; }
.err { color: #e88; } .busy { color: #aaa; }
</style>
@@ -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() || '—',
};
}
@@ -0,0 +1,196 @@
<script lang="ts">
// Searchable multi-select list (the approved paradigm replacing tap-bubbles).
// Squared/slight-radius, type-to-filter, select-all, live count badge, per-row
// AI bubble on AI-recommended options. Works for 6 items or 60.
//
// Two modes:
// - flat: pass `options` (single-level list).
// - grouped: pass `groups` (parent/child). Parents are non-selectable header rows
// (METADATA-10: "parents group only and are never assigned"); only the
// leaf rows are selectable. Filtering hides a parent group when none of
// its leaves match.
// #90 closed state shows selected NAMES (not only a count). Done dispatches
// `commit` so the parent can persist + re-review.
import { createEventDispatcher, onDestroy } from 'svelte';
import { selectedSummary } from './woodPrimary';
export interface Opt { id: number; name: string; ai: boolean }
export interface Group { parent: { id: number; name: string }; leaves: Opt[] }
export let label: string;
export let options: Opt[] = [];
// Optional grouped shape. When provided, `options` is ignored and the panel renders
// parent headers + selectable leaf rows.
export let groups: Group[] = [];
export let selected: number[];
export let placeholder = 'Choose…';
export let testid = '';
export let onToggle: (id: number) => void;
// #90: commit (persist + re-review) after a toggle settles. The parent wires this
// so multi-select changes flush to the slab immediately — no need to touch another
// field first. Debounced so a quick multi-tap doesn't fire a commit per click.
export let onCommit: (() => void) | undefined = undefined;
const dispatch = createEventDispatcher();
let open = false;
let filter = '';
let focused: number | null = null;
let commitTimer: ReturnType<typeof setTimeout> | undefined;
const grouped = groups.length > 0;
// Effective selectable set (leaves when grouped, options when flat).
$: allOpts = (grouped ? groups.flatMap((g) => g.leaves) : options) as Opt[];
$: nameById = new Map(allOpts.map((o) => [o.id, o.name] as [number, string]));
$: q = filter.trim().toLowerCase();
$: filteredFlat = q ? allOpts.filter((o) => o.name.toLowerCase().includes(q)) : allOpts;
// Grouped: keep a parent only if at least one of its leaves survives the filter.
$: filteredGroups = grouped
? groups
.map((g) => ({ parent: g.parent, leaves: q ? g.leaves.filter((o) => o.name.toLowerCase().includes(q)) : g.leaves }))
.filter((g) => g.leaves.length > 0)
: [];
$: count = selected.length;
$: summary = selectedSummary(selected, nameById, placeholder);
$: allFilteredSelected = filteredFlat.length > 0 && filteredFlat.every((o) => selected.includes(o.id));
function scheduleCommit() {
if (typeof onCommit !== 'function') return;
if (commitTimer) clearTimeout(commitTimer);
commitTimer = setTimeout(() => { commitTimer = undefined; onCommit(); }, 250);
}
function toggle(id: number) {
onToggle(id);
focused = id;
scheduleCommit(); // #90: persist + re-review without waiting for another field
}
function selectAll() {
for (const o of filteredFlat) if (!selected.includes(o.id)) onToggle(o.id);
scheduleCommit();
}
function clearAll() {
for (const o of [...filteredFlat]) if (selected.includes(o.id)) onToggle(o.id);
scheduleCommit();
}
function close() {
open = false;
filter = '';
if (commitTimer) { clearTimeout(commitTimer); commitTimer = undefined; }
if (typeof onCommit === 'function') onCommit(); // flush immediately on Done
dispatch('commit');
}
function onKey(e: KeyboardEvent) { if (e.key === 'Escape' && open) open = false; }
function destroy() { if (commitTimer) clearTimeout(commitTimer); }
onDestroy(destroy);
// Rows are focusable options (role=option): Enter/Space toggles, matching click.
function onRowKey(e: KeyboardEvent, id: number) {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle(id); }
}
</script>
<div class="ms" class:open role="group" aria-label={label} data-testid={testid} on:keydown={onKey}>
<button type="button" class="ms-btn" data-testid={testid ? `${testid}-btn` : 'ms-btn'} on:click={() => (open = !open)} aria-expanded={open}>
<span class="ms-label" data-testid={testid ? `${testid}-summary` : 'ms-summary'}>
{open
? 'Choose ' + label.toLowerCase()
: summary !== ''
? summary
: placeholder}
</span>
<span class="ms-c" data-testid={testid ? `${testid}-count` : 'ms-count'}>{count === 0 ? '' : (count === 1 ? '1 selected' : `${count} selected`)}</span>
<span class="ms-arrow" aria-hidden="true">{open ? '▴' : '▾'}</span>
</button>
{#if open}
<div class="ms-panel" role="listbox" aria-multiselectable="true">
<div class="ms-tools">
<input class="ms-search" type="text" placeholder="Filter…" bind:value={filter}
data-testid={testid ? `${testid}-filter` : 'ms-filter'} aria-label={`Filter ${label}`} />
<button type="button" class="ms-all" data-testid={testid ? `${testid}-all` : 'ms-all'}
on:click={allFilteredSelected ? clearAll : selectAll}>
{allFilteredSelected ? 'None' : 'All'}
</button>
</div>
{#if grouped}
<ul class="ms-list">
{#each filteredGroups as g (g.parent.id)}
<li class="ms-group" aria-hidden="true">{g.parent.name}</li>
{#each g.leaves as o (o.id)}
<li
class="ms-row leaf"
class:on={selected.includes(o.id)}
role="option"
tabindex="0"
aria-selected={selected.includes(o.id)}
data-testid={testid ? `${testid}-opt-${o.id}` : `ms-opt-${o.id}`}
on:click={() => toggle(o.id)}
on:keydown={(e) => onRowKey(e, o.id)}
>
<span class="ck" aria-hidden="true">{selected.includes(o.id) ? '✓' : ''}</span>
<span class="nm">{o.name}{#if o.ai}<span class="ai-tag">AI</span>{/if}</span>
</li>
{/each}
{:else}
<li class="ms-empty">No matches</li>
{/each}
</ul>
{:else}
<ul class="ms-list">
{#each filteredFlat as o (o.id)}
<li
class="ms-row"
class:on={selected.includes(o.id)}
role="option"
tabindex="0"
aria-selected={selected.includes(o.id)}
data-testid={testid ? `${testid}-opt-${o.id}` : `ms-opt-${o.id}`}
on:click={() => toggle(o.id)}
on:keydown={(e) => onRowKey(e, o.id)}
>
<span class="ck" aria-hidden="true">{selected.includes(o.id) ? '✓' : ''}</span>
<span class="nm">{o.name}{#if o.ai}<span class="ai-tag">AI</span>{/if}</span>
</li>
{:else}
<li class="ms-empty">No matches</li>
{/each}
</ul>
{/if}
<div class="ms-foot">
<span>{allOpts.length} synced{filter.trim() ? ` · ${filteredFlat.length} shown` : ''}</span>
<button type="button" on:click={close} data-testid={testid ? `${testid}-done` : 'ms-done'}>Done</button>
</div>
</div>
{/if}
</div>
<style>
.ms { border: 1px solid #444; border-radius: 3px; background: #1a1a1a; overflow: hidden; }
.ms-btn { box-sizing: border-box; width: 100%; display: flex; justify-content: space-between; align-items: center; gap: 8px;
padding: 9px 10px; background: #1d1d1d; border: 0; color: #eee; font-size: .9rem; cursor: pointer; text-align: left; }
.ms-btn .ms-label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #eee; }
.ms-btn .ms-c { color: #00e5ff; font-weight: 700; font-size: .8rem; white-space: nowrap; }
.ms-arrow { color: #777; }
.ms-panel { border-top: 1px solid #333; background: #161616; }
.ms-tools { display: flex; gap: 8px; padding: 8px; align-items: center; }
.ms-search { flex: 1; padding: 7px 9px; background: #0f0f0f; border: 1px solid #3a3a3a; border-radius: 3px; color: #eee; font-size: .85rem; }
.ms-all { font-size: .74rem; color: #00e5ff; background: none; border: 0; cursor: pointer; white-space: nowrap; padding: 4px; }
.ms-list { list-style: none; margin: 0; padding: 0; max-height: 200px; overflow-y: auto; }
.ms-row { display: flex; align-items: center; gap: 9px; padding: 9px 12px; font-size: .9rem; border-top: 1px solid #222; cursor: pointer; }
.ms-row:hover { background: #1f1f1f; }
.ms-row.on { background: #12262b; }
.ms-row.on .nm { color: #fff; }
.ms-row .nm { flex: 1; display: flex; align-items: center; gap: 6px; }
/* grouped: indented leaves under a non-selectable parent header */
.ms-group { padding: 8px 12px 3px; font-size: .72rem; font-weight: 700; letter-spacing: .4px;
text-transform: uppercase; color: #7f8a8a; background: #101010; border-top: 1px solid #222; }
.ms-row.leaf { padding-left: 26px; }
.ai-tag { font-size: .56rem; font-weight: 700; padding: 0 4px; border-radius: 2px; background: #00e5ff; color: #002; letter-spacing: .3px; }
.ck { width: 16px; height: 16px; border: 1px solid #555; border-radius: 2px; display: flex; align-items: center; justify-content: center; font-size: .72rem; color: #002; background: transparent; flex: none; }
.ms-row.on .ck { background: #00e5ff; border-color: #00e5ff; }
.ms-row:focus-visible { outline: 2px solid #00e5ff; outline-offset: -2px; }
.ms-empty { padding: 12px; color: #777; font-size: .82rem; }
.ms-foot { padding: 7px 12px; border-top: 1px solid #333; font-size: .74rem; color: #777; display: flex; justify-content: space-between; align-items: center; }
.ms-foot button { background: none; border: 0; color: #00e5ff; font-size: .78rem; cursor: pointer; padding: 0; }
</style>
File diff suppressed because it is too large Load Diff
@@ -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);
}
+83
View File
@@ -0,0 +1,83 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import {
keptInOrder,
moveItem,
missingMinimums,
lineupValid,
roleLabel,
resolvePhotoUrl,
type LineupItem,
} from './lineup';
const p = (id: string, role: LineupItem['role'], kept = true): LineupItem => ({
id,
role,
isCalibration: role === 'calibration',
kept,
});
test('lineup: keptInOrder returns kept photos in their current order', () => {
const items = [p('cal', 'calibration'), p('ov', 'overlay', false), p('b1', 'batch')];
assert.deepEqual(keptInOrder(items).map((x) => x.id), ['cal', 'b1']);
});
test('lineup: leftmost kept is the primary', () => {
const items = [p('b1', 'batch'), p('cal', 'calibration'), p('ov', 'overlay')];
// After a drag the owner re-ordered; primary follows position, not role.
assert.equal(keptInOrder(items)[0].id, 'b1');
});
test('lineup: primary auto-promotes when the leftmost kept is removed', () => {
const items = [p('cal', 'calibration', false), p('ov', 'overlay'), p('b1', 'batch')];
assert.equal(keptInOrder(items)[0].id, 'ov');
});
test('lineup: moveItem reorders (drag-to-front) and is a no-op on same index', () => {
const a = ['x', 'y', 'z'];
assert.deepEqual(moveItem(a, 2, 0), ['z', 'x', 'y']);
assert.deepEqual(moveItem(a, 0, 0), ['x', 'y', 'z']);
// Out-of-range is a no-op, never throws.
assert.deepEqual(moveItem(a, 0, 5), ['x', 'y', 'z']);
// Returns a new array, does not mutate the input.
const before = a.slice();
moveItem(a, 0, 2);
assert.deepEqual(a, before);
});
test('lineup: minimum requires a clean AND an overlay copy (REVIEW-5)', () => {
assert.deepEqual(missingMinimums([p('cal', 'calibration'), p('ov', 'overlay')]), []);
assert.deepEqual(missingMinimums([p('cal', 'calibration')]), ['overlay']);
assert.deepEqual(missingMinimums([p('ov', 'overlay')]), ['calibration']);
assert.deepEqual(missingMinimums([p('b1', 'batch')]), ['calibration', 'overlay']);
assert.deepEqual(missingMinimums([]), ['calibration', 'overlay']);
});
test('lineup: batch-only is not valid; cal+overlay+batch is', () => {
assert.equal(lineupValid([p('b1', 'batch')]), false);
assert.equal(lineupValid([p('cal', 'calibration'), p('ov', 'overlay'), p('b1', 'batch')]), true);
});
test('lineup: missingMinimums only counts KEPT photos', () => {
// Both roles present in the array, but the cal is not kept.
assert.deepEqual(
missingMinimums([p('cal', 'calibration', false), p('ov', 'overlay')]),
['calibration'],
);
});
test('lineup: roleLabel is owner-facing', () => {
assert.match(roleLabel('calibration'), /clean/);
assert.match(roleLabel('overlay'), /overlay/);
});
test('lineup: resolvePhotoUrl turns an in-memory blob into a blob: URL (bug #1)', () => {
// The first/Primary (calibration) photo carries a fresh capture blob. It must
// become a valid object URL — never a raw Blob, never String(blob)="object Blob".
const blob = new Blob(['png-bytes'], { type: 'image/png' });
const fake = (b: Blob) => `blob:fake-${b.size}`;
assert.equal(resolvePhotoUrl(blob, '/api/endpoints/photo', fake), 'blob:fake-9');
// Empty/absent blob falls back to the stored endpoint.
assert.equal(resolvePhotoUrl(new Blob(), '/api/endpoints/photo', fake), '/api/endpoints/photo');
assert.equal(resolvePhotoUrl(null, '/api/endpoints/photo', fake), '/api/endpoints/photo');
});
+82
View File
@@ -0,0 +1,82 @@
// #90 Review photo strip (REVIEW-5 / PUBLISH-45). Pure logic split out so the
// keep/remove + reorder + minimum enforcement is unit-testable without a browser.
//
// Model (approved in the wireframe): the owner taps a photo to keep/remove it,
// drags to reorder, and the LEFTMOST kept photo is the primary (Woo cover). The
// submission-ready minimum is at least one clean (calibration) photo AND one
// dimension-overlay copy (REVIEW-5 "clean first pic plus its dimension-overlay
// copy"). Batch photos are optional.
export type PhotoRole = 'calibration' | 'overlay' | 'batch';
export interface LineupItem {
id: string;
role: PhotoRole;
isCalibration: boolean;
/** True = the owner keeps this photo in the submission. */
kept: boolean;
}
/**
* The kept photos in their current (left→right) order. The first kept photo is
* the primary (Woo cover). Reordering is a plain array move; this helper just
* exposes the kept subset so the caller can re-seq on persist.
*/
export function keptInOrder(items: LineupItem[]): LineupItem[] {
return items.filter((it) => it.kept);
}
/** Move an item from one index to another (drag-reorder). Returns a new array. */
export function moveItem<T>(arr: T[], from: number, to: number): T[] {
if (from === to || from < 0 || to < 0 || from >= arr.length || to >= arr.length) {
return arr;
}
const next = arr.slice();
const [item] = next.splice(from, 1);
next.splice(to, 0, item);
return next;
}
/**
* Whether the kept lineup meets the submission minimum (REVIEW-5): at least one
* clean calibration photo and one dimension-overlay copy. Returns the missing
* role(s) so the UI can name them in a nudge.
*/
export function missingMinimums(items: LineupItem[]): PhotoRole[] {
const kept = items.filter((it) => it.kept);
const missing: PhotoRole[] = [];
if (!kept.some((it) => it.role === 'calibration')) missing.push('calibration');
if (!kept.some((it) => it.role === 'overlay')) missing.push('overlay');
return missing;
}
/** True when the kept lineup is publishable from a photo standpoint. */
export function lineupValid(kept: LineupItem[]): boolean {
return missingMinimums(kept).length === 0;
}
/** A short owner-facing label for a role (used in the minimum nudge). */
export function roleLabel(role: PhotoRole): string {
if (role === 'calibration') return 'a clean photo';
if (role === 'overlay') return 'its dimension-overlay copy';
return 'a batch photo';
}
/**
* Resolve the <img> src for a strip photo. The in-memory capture blob (fresh
* photo) must become a `blob:` object URL — assigning a raw Blob (or
* `String(blob)` = "object Blob") to `src` renders a broken image. That was
* bug #1: the first/Primary (calibration) photo is the one carrying a blob.
* When there is no usable blob, fall back to the stored photo endpoint.
*
* Pure + injectable: `createObjectUrl` defaults to `URL.createObjectURL` so it
* is unit-testable without a real DOM/Blob.
*/
export function resolvePhotoUrl(
blob: Blob | null,
endpoint: string,
createObjectUrl: (b: Blob) => string = (b) => URL.createObjectURL(b),
): string {
if (blob && blob.size > 0) return createObjectUrl(blob);
return endpoint;
}
@@ -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,57 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { deriveThicknessTermId, type ThicknessBand } from './thicknessBand';
// Synced-Woo-shaped bands (id + name). The e2e fixture uses a single "2-4 in" band.
const BANDS: ThicknessBand[] = [
{ id: 39, name: '2-4 in' },
];
test('TV-9 / QUALITY-95: 1 in rounds up into the enclosing synced band', () => {
const bands: ThicknessBand[] = [
{ id: 10, name: '1-2 in' },
{ id: 39, name: '2-4 in' },
];
assert.equal(deriveThicknessTermId(1, bands), 10);
assert.equal(deriveThicknessTermId(1.5, bands), 10);
// 2.0 is exactly the upper bound of the 1-2 band → belongs to it.
assert.equal(deriveThicknessTermId(2.0, bands), 10);
// 2.01 falls into 2-4.
assert.equal(deriveThicknessTermId(2.25, bands), 39);
assert.equal(deriveThicknessTermId(4, bands), 39);
});
test('e2e fixture: thickness_in 2 → the single "2-4 in" band (id 39)', () => {
assert.equal(deriveThicknessTermId(2, BANDS), 39);
});
test('above every band → empty (no invented band; upstream thickness fix resolves it)', () => {
assert.equal(deriveThicknessTermId(5, BANDS), '');
});
test('no thickness / non-finite → empty', () => {
assert.equal(deriveThicknessTermId(null, BANDS), '');
assert.equal(deriveThicknessTermId(undefined, BANDS), '');
assert.equal(deriveThicknessTermId(0, BANDS), '');
assert.equal(deriveThicknessTermId(NaN, BANDS), '');
assert.equal(deriveThicknessTermId(2, []), '');
});
test('band names are parsed from the synced names, never hardcoded (order-independent)', () => {
// Reverse the array: the derivation must still pick the right band by bound.
const bands: ThicknessBand[] = [
{ id: 39, name: '2-4 in' },
{ id: 10, name: '1-2 in' },
];
assert.equal(deriveThicknessTermId(1.5, bands), 10);
assert.equal(deriveThicknessTermId(3, bands), 39);
});
test('malformed band names are skipped without throwing', () => {
const bands: ThicknessBand[] = [
{ id: 1, name: 'N/A' },
{ id: 2, name: '4-2 in' }, // inverted, unusable
{ id: 39, name: '2-4 in' },
];
assert.equal(deriveThicknessTermId(3, bands), 39);
});
@@ -0,0 +1,58 @@
/**
* Deterministic thickness-band derivation (TV-9 / QUALITY-95, METADATA-15/25).
*
* The Woo "Thickness" attribute is a set of named bands (e.g. "2-4 in"). The
* owner enters a numeric thickness in 1/8-inch steps; the band is derived by
* rounding that thickness UP into the band whose numeric upper bound first
* encloses it. The Review UI never exposes a band picker — the band always
* follows from thickness_in (issue #88).
*
* Pure function so it is unit-testable without a browser/backend.
*/
export interface ThicknessBand {
id: number;
name: string;
}
/** Parse the leading numeric bounds ("2-4 in" → {min:2,max:4}). Returns null if the name
* carries no usable numeric upper bound (defensive — a band we can't place is skipped). */
function parseBounds(name: string): { min: number; max: number } | null {
// Match the first two numbers in the name, in order (handles "2-4 in", "1/8", "12in+").
const nums = name.match(/\d+(?:[./]\d+)?/g);
if (!nums) return null;
const asNum = (s: string) => Number(s);
const min = asNum(nums[0]);
const max = nums.length >= 2 ? asNum(nums[1]) : min;
if (!Number.isFinite(min) || !Number.isFinite(max)) return null;
if (max < min) return null; // malformed "4-2"; not a usable band
return { min, max };
}
/**
* Round `thicknessIn` (inches, 1/8" steps) up into the synced Woo thickness band.
* Returns the term id, or '' when no band encloses the value (no thickness known,
* empty synced set, or a value above every band — all leave the field empty so the
* owner's upstream fix, not a wrong band, resolves it).
*/
export function deriveThicknessTermId(
thicknessIn: number | null | undefined,
bands: ThicknessBand[],
): number | '' {
if (typeof thicknessIn !== 'number' || !Number.isFinite(thicknessIn) || thicknessIn <= 0) {
return '';
}
// Sort bands by upper bound so "first enclosing max" is well-defined regardless of
// the order Woo returned them in.
const ranked = bands
.map((b) => ({ b, bounds: parseBounds(b.name) }))
.filter((x): x is { b: ThicknessBand; bounds: { min: number; max: number } } => x.bounds !== null)
.sort((a, z) => a.bounds.max - z.bounds.max);
for (const { b, bounds } of ranked) {
// A value exactly on the band's max belongs to that band; above it, fall through.
if (thicknessIn <= bounds.max) {
return b.id;
}
}
return '';
}
@@ -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);
}
@@ -0,0 +1,43 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { nextPrimary, selectedSummary } from './woodPrimary';
test('nextPrimary: empty selection clears primary', () => {
assert.equal(nextPrimary([], 21), '');
assert.equal(nextPrimary([], ''), '');
});
test('nextPrimary: default is first selected (REVIEW-15)', () => {
assert.equal(nextPrimary([21, 22], ''), 21);
assert.equal(nextPrimary([21, 22], null), 21);
});
test('nextPrimary: keeps current when still selected', () => {
assert.equal(nextPrimary([21, 22], 22), 22);
assert.equal(nextPrimary([22], 22), 22);
});
test('nextPrimary: deselecting primary auto-promotes first remaining', () => {
assert.equal(nextPrimary([22, 23], 21), 22);
assert.equal(nextPrimary([23], 21), 23);
});
test('selectedSummary: placeholder when empty', () => {
assert.equal(selectedSummary([], new Map(), 'Choose…'), 'Choose…');
});
test('selectedSummary: shows names, then +N overflow', () => {
const m = new Map([
[1, 'Live Edge'],
[2, 'Book Match'],
[3, 'Cookie'],
]);
assert.equal(selectedSummary([1], m, 'x'), 'Live Edge');
assert.equal(selectedSummary([1, 2], m, 'x'), 'Live Edge, Book Match');
assert.equal(selectedSummary([1, 2, 3], m, 'x'), 'Live Edge, Book Match +1');
});
test('selectedSummary: falls back to count when names unknown', () => {
assert.equal(selectedSummary([9, 10], new Map(), 'x'), '2 selected');
}
);
+32
View File
@@ -0,0 +1,32 @@
/**
* REVIEW-15: owner declares one primary wood category among the selected set.
* Default primary = first selected. Deselecting the current primary promotes
* the first remaining selected id. Empty selection clears primary.
*/
export function nextPrimary(
selected: number[],
current: number | '' | null | undefined,
): number | '' {
if (!selected.length) return '';
const cur = typeof current === 'number' ? current : '';
if (cur !== '' && selected.includes(cur)) return cur;
return selected[0];
}
/** Closed MultiSelect summary: names when known, else a count. */
export function selectedSummary(
selected: number[],
nameById: Map<number, string>,
placeholder: string,
maxNames = 2,
): string {
if (!selected.length) return placeholder;
const names = selected
.map((id) => nameById.get(id))
.filter((n): n is string => !!n && n.length > 0);
if (!names.length) {
return selected.length === 1 ? '1 selected' : `${selected.length} selected`;
}
if (names.length <= maxNames) return names.join(', ');
return `${names.slice(0, maxNames).join(', ')} +${names.length - maxNames}`;
}
+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 } };

Some files were not shown because too many files have changed in this diff Show More