Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e13bbedda8 | |||
| 6c62b506d4 | |||
| b4a702318d | |||
| c62f0329f6 | |||
| f966fbe690 | |||
| 68d236961a | |||
| 5019b10584 | |||
| 82219fe5ad | |||
| e5514fe3bf | |||
| d5d8c49e04 | |||
| 34dbfa5d7d | |||
| f7040f5947 | |||
| 08c463b8d8 | |||
| 55d66b9644 | |||
| 3c2f8871a8 |
@@ -1,7 +0,0 @@
|
||||
ts phase decision why evidence result
|
||||
2026-09-09T19:43:56Z harness ran check-structure.sh on untouched main tip capture red baseline before structure edits docs/checks/check-structure.sh FAILED as expected (red baseline)
|
||||
2026-09-09T19:43:56Z frame done predicate is check-structure.sh exit 0 with lock phrases intact falsifiable gate for structure pass .audit/docs-structure-pass.tsv open
|
||||
2026-09-09T19:45:25Z unit AGENTS ownership complete; Gates table removed; UX-DESIGN demoted; IMPL-PLAN owns gate meanings subtract calendar restatement and close ownership gap AGENTS.md,docs/UX-DESIGN.md,docs/IMPL-PLAN.md check-structure PASSED
|
||||
2026-09-09T19:45:25Z unit TEST-SPEC and deploy docs label missing tree as Phase 0 targets stop agents inventing harness paths that are not on main docs/TEST-SPEC.md,docs/DEPLOYMENT.md,deploy/docker-compose.yml,README.md check-structure PASSED
|
||||
2026-09-09T19:45:25Z verify ran docs/checks/check-structure.sh after all edits prove done predicate on real artifact docs/checks/check-structure.sh exit 0 VERIFIED
|
||||
2026-09-09T19:47:17Z attention-fix restored Gate B meaning to original AGENTS wording; hardened check-structure for both deploy files and fuller math lock phrase Attention review flagged Gate B parenthetical as lock drift and weak deploy grep docs/IMPL-PLAN.md,docs/checks/check-structure.sh check-structure PASSED
|
||||
|
@@ -1,10 +0,0 @@
|
||||
.git
|
||||
backend
|
||||
frontend/node_modules
|
||||
frontend/dist
|
||||
frontend/.svelte-kit
|
||||
**/.DS_Store
|
||||
*.md
|
||||
!deploy/README.md
|
||||
.audit
|
||||
docs/archive
|
||||
+1
-8
@@ -35,11 +35,4 @@ images/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# Playwright e2e (#65) — keep README; ignore churn
|
||||
frontend/tests/e2e/artifacts/*.png
|
||||
frontend/tests/e2e/artifacts/run-log.txt
|
||||
frontend/tests/e2e/test-results/
|
||||
frontend/tests/e2e/playwright-report/
|
||||
frontend/blob-report/
|
||||
frontend/playwright/.cache/
|
||||
deploy/certs/
|
||||
|
||||
@@ -1,34 +1,197 @@
|
||||
# AGENTS.md
|
||||
# AGENTS.md — SlabUploader
|
||||
|
||||
## Agent context
|
||||
Working contract for coding agents. Read before writing code.
|
||||
|
||||
Read this file, `docs/CAPTURE.md`, and `docs/PHOTOS.md`.
|
||||
Implement Milestone 2 only. Specification: `docs/PHOTOS.md`.
|
||||
PRD: `docs/PRODUCT.md` for destination and field names. Do not implement Milestone 3 or 4.
|
||||
Do not widen Milestone 2 into Complete-listing taxonomy, SKU, price, copy, or Woo create.
|
||||
Status: **Gate A freeze** · signed 2026-08-31 PT · docs cut to `main` 2026-09-06 · SoT: Gitea `Ty_Tech/SlabUploader` on **gitea-atd** (not mirrors).
|
||||
|
||||
## Milestone 2
|
||||
---
|
||||
|
||||
Photos.
|
||||
Acceptance: on a phone, batch of 1–N photos, user-picked calibration photo through Milestone 1 mask/axis/measure, every successful shot as a 3:4 transparent inventory PNG lined up, Milestone 1 numbers on screen, dimension-overlay copy of the calibration PNG present.
|
||||
## 0. Where each doc lives
|
||||
|
||||
Milestone 2 is inference-free. Batch mask/crop reuse and dimension overlays run in the browser as ordinary code. There is no inference URL and no model call in this milestone.
|
||||
| Doc | Owns |
|
||||
|---|---|
|
||||
| `docs/TECH-SPEC-PIPELINE.md` | Deterministic math, image prep, constants, test vectors |
|
||||
| `docs/ARCHITECTURE.md` | Hybrid topology, what-runs-where, secrets why, status machine |
|
||||
| `AGENTS.md` | Hard rules for coding agents (locks below) |
|
||||
| `docs/DATA-MODEL.md` | Store row / schema facts |
|
||||
| `docs/OPENAPI.md` | Wire / HTTP contracts |
|
||||
| `docs/CONTENT-WOO.md` | Listing copy and Woo payload |
|
||||
| `docs/PROMPTS.md` | Call 1 / Call 2 prompt files |
|
||||
| `docs/DEPLOYMENT.md` | Compose, Caddy, secrets placement, ops |
|
||||
| `docs/UX.md` | Presentation: what the mill owner sees and does |
|
||||
| `docs/TEST-SPEC.md` | Engineer test bar: pyramid cases, phased by gate, through PROD |
|
||||
| `docs/UAT-PRACTICE.md` | Walkable practice UAT for `SLAB-UAT-*` |
|
||||
| `docs/IMPL-PLAN.md` | Phases 0–6. Phase 2 is a deferred stub (offline PWA / OCR / ruler). |
|
||||
| `README.md` | Orientation only |
|
||||
| `docs/archive/PRD-2026-08.txt` | Frozen archive. Not a source for new work. |
|
||||
|
||||
## Milestone 1 (historical)
|
||||
Live docs agree. If two live files disagree, that is a docs bug: fix the files, then continue.
|
||||
|
||||
Capture. Spec: `docs/CAPTURE.md`. Remains the calibration math and PNG contract Milestone 2 reuses.
|
||||
### Known open gaps
|
||||
|
||||
## Invariants
|
||||
- Issue #4: exact minimum browser version pins and deeper load-bearing API inventory. Phone + PC families are already locked in `docs/UX.md` / TECH-SPEC.
|
||||
|
||||
- Quantity per listing is one.
|
||||
- User enters length, thickness, and SKU.
|
||||
- App proposes remaining listing fields. User reviews before create.
|
||||
- Non-math fields remain editable until create.
|
||||
- `bdft = sqft × thickness_in`
|
||||
- Price proposal is `bdft × species $/bdft`. User may replace it. Missing rate leaves price empty.
|
||||
- User-entered length, thickness, SKU, price, and taxonomy override computed or inferred values.
|
||||
- SKUs matching `SLAB-UAT-*` create as Woo draft.
|
||||
- Create works with inference off.
|
||||
- Source below the PNG size floor requires a new photo.
|
||||
- Existing store SKU blocks create.
|
||||
- Dimension overlay orientation follows the user-approved length axis from Milestone 1.
|
||||
---
|
||||
|
||||
## 0b. Gates (human map)
|
||||
|
||||
| Label | Human name | What it means |
|
||||
|---|---|---|
|
||||
| Gate A | Docs freeze | Product locks written into the repo |
|
||||
| Gate B | Run-host setup | Inventory the real host, deploy skeleton |
|
||||
| Gate C | First draft listing | One Woo draft listing with inference OFF |
|
||||
|
||||
Phase map: **Phase 0 = Gates A+B.** **Gate C = early Phase 1** claimable publish slice. Call 1/2 after Gate C, still inside POC. Depth: `docs/IMPL-PLAN.md`.
|
||||
|
||||
### Freeze provenance (Gate A)
|
||||
|
||||
| SHA | What locked |
|
||||
|---|---|
|
||||
| `a94d81d` | gitea-atd `Ty_Tech/SlabUploader` as remotes SoT |
|
||||
| `457711b` | AGENTS.md + hybrid IMPL Phase 0 |
|
||||
| `4ec478f` | Contradiction patches (README/PRD/Woo/DEPLOYMENT) |
|
||||
| `4d9f307` / `4e97770` | Inference review + hybrid capture pipeline (pre-freeze baseline) |
|
||||
|
||||
Docs refactor is on `main` (cut 2026-09-06). Product locks above are unchanged.
|
||||
|
||||
### Known stale PRD lines
|
||||
|
||||
`docs/archive/PRD-2026-08.txt` is frozen history. These PRD lines are superseded by live docs:
|
||||
FR17 (fig-* 1+, feat-* 0+), FR19 (species $/bdft only), FR40 (client-side, not server-side
|
||||
processing), NFR server normalization timing, FR42 (env-agnostic DEPLOYMENT + deploy/INVENTORY.md).
|
||||
|
||||
---
|
||||
|
||||
## 1. Topology (locked — hybrid)
|
||||
|
||||
**Client (SvelteKit) owns the happy path:**
|
||||
|
||||
- Capture 1–5 photos
|
||||
- Sheet detect + chroma-key / black threshold + **four knobs inline on capture** (sheet, sensitivity, edge offset, feather) with **live re-run** and mask overlay on the source photo
|
||||
- Mask overlay + **user confirm** before measurement
|
||||
- Length axis overlay (rotate / confirm)
|
||||
- sqft, bdft, 6" width samples
|
||||
- 3:4 transparent PNG, 80% fill at extremes, shorter side ≥ 1600 when source allows
|
||||
- Draft payload: originals + processed PNGs + length/thickness/SKU/sqft/bdft/widths
|
||||
|
||||
**Server (FastAPI) owns:**
|
||||
|
||||
- Store drafts / settings / taxonomy cache
|
||||
- U2Net "Try harder" — **deferred from POC**, not deleted. Architecture keeps the one-photo server path for awkward backgrounds post-POC. POC mask tuning is **client-side only** (sheet + four knobs, live re-run).
|
||||
- WooCommerce (taxonomy sync, SKU check, create product) — browser never calls Woo
|
||||
- Inference **proxy** (Call 1 / Call 2) — keys never in browser
|
||||
- Prompt files (server text files, read fresh)
|
||||
|
||||
Happy path never leaves the phone until the user continues after confirmed mask + numbers.
|
||||
|
||||
Deferred work and non-goals are in `docs/ARCHITECTURE.md` and `docs/IMPL-PLAN.md`.
|
||||
|
||||
Depth: `docs/ARCHITECTURE.md`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Deferred work
|
||||
|
||||
The MVP is online-only. Measuring lives in the browser. Manual SKU entry, manual length, and client-side measure/crop are the source of truth. Undersized source warns and requires retake. The server stores client-sent numbers; there is no server happy-path pipeline for the deterministic core.
|
||||
|
||||
Phase 2 is a deferred stub for offline PWA, OCR, and ruler detection. Post-POC items: LoRA / fine-tune, figure-/category-weighted pricing (v1.5), auto-dimension overlay (v1.5), bulk publish, staff roles, analytics, SKU update path. Depth: `docs/ARCHITECTURE.md` §4 and `docs/IMPL-PLAN.md` Phase 2.
|
||||
|
||||
---
|
||||
|
||||
## 3. Deterministic core (non-negotiable)
|
||||
|
||||
Encode TECH-SPEC exactly:
|
||||
|
||||
- **bdft = sqft × thickness_in** (identity: 1 sqft @ 1" = 1 bdft).
|
||||
- Units: inches in 1/8" steps; sqft/bdft 2 decimals; price half-up 2 decimals.
|
||||
- Manual length, thickness, SKU, price always win.
|
||||
- Image prep: sheet/sliders → mask confirm → length axis confirm → 3:4 PNG / 80% fill / ≥1600px when source allows.
|
||||
- Undersized source warns and requires retake. Photo is not publishable until retake.
|
||||
- Pricing: species `$/bdft` from Settings, seeded by Woo species sync; `rec = bdft × price_per_bdft`; user override sticks; missing rule leaves rec empty, manual allowed.
|
||||
- Port TECH-SPEC **TV-1 through TV-10** (including TV-10 Rounding). Client (or shared pure modules the client uses) is authoritative for math; server **stores** client-sent numbers.
|
||||
|
||||
Depth: `docs/TECH-SPEC-PIPELINE.md`.
|
||||
|
||||
---
|
||||
|
||||
## 4. Secrets and settings
|
||||
|
||||
**Server-side only:**
|
||||
|
||||
- Woo WordPress username + application password (dedicated low-privilege WP user for product create/edit + media)
|
||||
- Inference API key and endpoint (treat inference URL as sensitive)
|
||||
- AES master key (`SLAB_AES_KEY` env only; never in DB or repo)
|
||||
|
||||
**`WOO_BASE_URL`:** compose / host `.env` only (`https://` required). Settings shows it read-only. Not a SQLite settings row. FastAPI rejects `http://` at startup.
|
||||
|
||||
**Server:** AES-GCM at rest in SQLite for Woo Application Password + inference secrets. Settings UI POSTs username + application password to FastAPI; server encrypts. All Woo and inference calls are server-side. Rotate by revoking the application password in WP Users → Profile and re-entering in Settings. WooCommerce consumer keys (`ck_`/`cs_`) are not implemented.
|
||||
|
||||
**Browser may persist (non-secret):** sheet mode, sensitivity, edge offset, feather (localStorage OK; reset-to-default required).
|
||||
|
||||
Why/flow: `docs/ARCHITECTURE.md` §5. Ciphertext key inventory: `docs/DATA-MODEL.md` settings table.
|
||||
|
||||
---
|
||||
|
||||
## 5. Woo UAT and create status (Ty locked 2026-08-31)
|
||||
|
||||
- Store URL: **`WOO_BASE_URL`** from Docker Compose (`https://` only). Intended UAT/POC store is Whidbey Wood Store production; the compose value is the SoT, not a Settings field.
|
||||
- Mid-phase UAT creates: Woo status **draft**, SKU prefix **`SLAB-UAT-*`**
|
||||
- Settings field **`woo_create_status`**: **`draft | publish`**, default **`draft`**
|
||||
- Hard rule: if SKU matches `SLAB-UAT-*`, force **Woo draft** even if Settings say publish
|
||||
- Final POC success: one real (non-UAT) SKU → **Woo publish** only after Ty review
|
||||
- Duplicate SKU → stop; UI maps **409** to the SKU field (edit SKU or open existing)
|
||||
- After successful publish: purge originals + processed images on server
|
||||
|
||||
Woo draft/publish status is separate from slab lifecycle status (§7).
|
||||
|
||||
Payload and publish sequence depth: `docs/CONTENT-WOO.md`. Key only: DATA-MODEL `woo_create_status`.
|
||||
|
||||
---
|
||||
|
||||
## 6. Inference (inside POC, sequenced)
|
||||
|
||||
- Feature-flagged; **full suite must pass with inference OFF**.
|
||||
- **Gate C** claimable path: inference **OFF**; manual taxonomy + deterministic numbers + Woo draft path.
|
||||
- **Call 1 (vision) / Call 2 (text) only after Gate C is green**, still inside overall POC before "done."
|
||||
- Call 1 auto only if `inference_enabled`; Call 2 never auto-fires (user taps Generate text).
|
||||
- Call 1 body: all inventory photos @1024. Taxonomy lists sent to Call 1 are **exactly the Woo-synced species / attribute sets**.
|
||||
- **Confidence 0.7 (default):** ≥ 0.7 → pre-populate species, wood categories, edge, figure, grade, and feat-* from Call 1 (**mutable**). < 0.7 → those fields stay **empty**. Ready still requires the full named set in §7. Presentation: `docs/UX.md`.
|
||||
- **Taxonomy cache:** `taxonomy_last_sync_at` vs `taxonomy_anchor` (bump only on stored-subset hash change, including deletions). Client re-pulls iff the anchor advanced. `test-woo` always syncs; publish if last_sync >1h. No per-nav sync. No Woo taxonomy timestamp.
|
||||
- Call 2 portable context: default full resend; never require `previous_response_id`. `test-inference` two-turn probe `stateful|stateless|fail`. Fail blocks **Call 2 enablement only**.
|
||||
- Templates inject deterministic numbers. LLM does not invent dimensions.
|
||||
|
||||
Prompt file mechanics: `docs/PROMPTS.md`.
|
||||
|
||||
---
|
||||
|
||||
## 7. Slab status machine (app-internal only)
|
||||
|
||||
`draft → calibrated → ready → publishing → published` (or `failed` with detail).
|
||||
|
||||
- **calibrated:** mask + axis confirmed; length/thickness/SKU entered; client computed sqft/bdft/widths; draft uploaded.
|
||||
- **ready:** SKU, length, thickness, client sqft/bdft/widths, ≥1 inventory PNG, exactly one species, ≥1 wood category, 1 edge type, ≥1 figure term, ≥1 fig-* tag, ≥1 grade, thickness store band (round-up), 1 moisture (default kiln-dried; not inferred), price. feat-* 0+. Title/description may be typed, generated, or templated at publish. Vision may prefill the taxonomy subset only when confidence ≥ 0.7.
|
||||
- Inference does not gate publish. Below-threshold Call 1 does not prefill; empty mandatory fields block ready.
|
||||
|
||||
Slab `draft` is separate from Woo draft (`woo_create_status`).
|
||||
|
||||
---
|
||||
|
||||
## 8. Test and delivery expectations
|
||||
|
||||
- No feature without a passing test and shown execution output (PR/commit message).
|
||||
- Deterministic core unit-tested before UI consumes it.
|
||||
- One phase = reviewable, shippable increment; Ty exit-gates phases.
|
||||
- Phase naming: Phase 0–6 stay delivery chapters. **Phase 0 = Gates A+B.** **Gate C = early Phase 1** claimable publish slice.
|
||||
- Secrets, real Woo application passwords, and AES keys stay out of the repo.
|
||||
|
||||
---
|
||||
|
||||
## 9. Deploy sketch
|
||||
|
||||
- Target: inventoried Docker host. Docker Compose (`frontend` + `fastapi`) + Caddy → `https://${APP_HOSTNAME}` (LAN/Tailscale).
|
||||
- Fill `deploy/INVENTORY.md` before binding ports or merging Caddy (co-resident apps can collide; bolt.diy is one example).
|
||||
- Health: `GET https://${APP_HOSTNAME}/api/health`.
|
||||
- `WOO_BASE_URL` in compose (HTTPS). Settings displays it read-only.
|
||||
- Inference is a remote configurable endpoint, not on the app host by default.
|
||||
|
||||
Depth: `docs/DEPLOYMENT.md`.
|
||||
|
||||
@@ -1,14 +1,56 @@
|
||||
# SlabUploader
|
||||
|
||||
Lists one-of-a-kind wood slabs on Whidbey Wood Store.
|
||||
Browser app for listing one-of-a-kind wood slabs on the Whidbey Wood Store.
|
||||
|
||||
| Artifact | File |
|
||||
You photograph a slab, confirm the cut and the numbers, review the listing, and create the WooCommerce product. The browser does the measuring and crop. The server stores the draft, talks to the store, and (later) runs vision. Runs on phones and PCs.
|
||||
|
||||
Owner: Ty (Whidbey). Store: www.whidbeywoodstore.com. Runs in Docker Compose behind Caddy on a host you inventory first (`APP_HOSTNAME`).
|
||||
|
||||
## Where we are
|
||||
|
||||
Gate A freeze signed 2026-08-31. All live docs sit on `main`. Read `AGENTS.md` before writing code.
|
||||
|
||||
The bar for this first version: a real slab, photographed on a phone or a PC, listed on the real store. Practice listings are WooCommerce **drafts** with SKUs that start `SLAB-UAT-`. After Ty reviews, one real SKU goes **live**. Online only for now; offline capture is deferred.
|
||||
|
||||
Build order is in `docs/IMPL-PLAN.md`. Empty app on the real host first, then the first draft listing, then vision.
|
||||
|
||||
## Documentation
|
||||
|
||||
| File | What |
|
||||
|---|---|
|
||||
| PRD | `docs/PRODUCT.md` |
|
||||
| Invariants and agent context | `AGENTS.md` |
|
||||
| Milestone 1 | `docs/CAPTURE.md` |
|
||||
| Milestone 2 | `docs/PHOTOS.md` |
|
||||
| Hosting | `deploy/` |
|
||||
| `AGENTS.md` | Hard rules. Read first. |
|
||||
| `docs/ARCHITECTURE.md` | Hybrid topology, what runs where, secrets, status machine |
|
||||
| `docs/TECH-SPEC-PIPELINE.md` | Math, image pipeline, constants, test vectors TV-1–TV-10 |
|
||||
| `docs/DATA-MODEL.md` | SQLite tables, slab statuses, settings |
|
||||
| `docs/OPENAPI.md` | Browser to server API |
|
||||
| `docs/CONTENT-WOO.md` | Titles, descriptions, WooCommerce payload |
|
||||
| `docs/PROMPTS.md` | Call 1 / Call 2 prompt files (server text files) |
|
||||
| `docs/DEPLOYMENT.md` | Compose, Caddy, secrets placement, ops |
|
||||
| `docs/UX.md` | Presentation: what the mill owner sees and does |
|
||||
| `docs/UAT-PRACTICE.md` | Walkable practice UAT for `SLAB-UAT-*` |
|
||||
| `docs/IMPL-PLAN.md` | Phases 0–6, exit gates, deferred stub (Phase 2) |
|
||||
| `deploy/INVENTORY.md` | Host worksheet before binding ports |
|
||||
| `docs/archive/PRD-2026-08.txt` | Frozen history, not a source for new work |
|
||||
|
||||
Owner: Ty. Store: https://www.whidbeywoodstore.com/
|
||||
Repo: `Ty_Tech/SlabUploader`
|
||||
## Short map
|
||||
|
||||
- Client (SvelteKit) owns the happy path: mask knobs, sqft, bdft, 3:4 PNG. Server (FastAPI) stores drafts, talks to WooCommerce, and proxies vision. Server matting ("Try harder") is deferred from POC; POC mask tuning is client-side only.
|
||||
- Board feet = square feet × thickness in inches. No divide-by-12. Test vectors TV-1 through TV-10 run in the browser.
|
||||
- Secrets never sit in the browser. Encrypted on the server. Store login is a WordPress application password on a low-privilege user; WooCommerce consumer keys are not implemented.
|
||||
- Practice SKUs `SLAB-UAT-*` are always drafts, even if Settings say publish.
|
||||
- Vision stays off until the first draft listing works. Species suggestion and Generate text come after, still inside this project.
|
||||
- Two containers (`frontend`, `fastapi`) plus Caddy. One SQLite, image volumes. Inventory the host first.
|
||||
|
||||
Where deferred items live: `docs/ARCHITECTURE.md` §4 and `docs/IMPL-PLAN.md` Phase 2 (offline PWA, OCR, ruler — deferred stub).
|
||||
|
||||
## Repo
|
||||
|
||||
Source of truth: Gitea `Ty_Tech/SlabUploader` on gitea-atd
|
||||
(`https://gitea.vps1.afterthedemo.com/Ty_Tech/SlabUploader`). TrueNAS and GitHub copies are not the source of truth for this work.
|
||||
|
||||
## Getting started
|
||||
|
||||
1. Read `AGENTS.md` and Phase 0 in `docs/IMPL-PLAN.md`.
|
||||
2. Build the client math modules and a thin FastAPI health/settings app. Measuring lives in the browser.
|
||||
3. Ty signs each phase.
|
||||
4. Live docs on `main` are the source of truth. The archive PRD is frozen history only.
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
FROM python:3.13-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY backend/requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY backend/ .
|
||||
|
||||
# Create data and images directories
|
||||
RUN mkdir -p /data /images /app/prompts
|
||||
|
||||
# Bind mount for prompts (read-only)
|
||||
# Volumes for data and images are handled by docker-compose
|
||||
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
ENV PYTHONDONTWRITEBYTECODE=1
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["uvicorn", "app.main:create_app", "--factory", "--host", "0.0.0.0", "--port", "8000"]
|
||||
@@ -0,0 +1,45 @@
|
||||
# Alembic configuration for SlabUploader
|
||||
|
||||
[alembic]
|
||||
script_location = alembic
|
||||
sqlalchemy.url = sqlite:///data/slab.db
|
||||
|
||||
# Templates used to generate migration files
|
||||
file_template = %%(year)s_%%(month)s_%%(day)s_%%(rev)s_%%(slug)s
|
||||
|
||||
# Timezone for file timestamps
|
||||
timezone = UTC
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = INFO
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(asctime)s %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %Y-%m-%dT%H:%M:%S%z
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Alembic environment configuration.
|
||||
|
||||
Sets up sys.path so that app packages are importable when running
|
||||
alembic from the backend directory.
|
||||
|
||||
Alembic uses sync connections for migrations even with an async app.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Ensure the backend root is on the path so app.* is importable
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from logging.config import fileConfig
|
||||
|
||||
from alembic import context
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
|
||||
# Import models so that Base.metadata includes all tables
|
||||
from app.db.models import Base
|
||||
|
||||
config = context.config
|
||||
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
"""Run migrations in 'online' mode using sync engine."""
|
||||
connectable = engine_from_config(
|
||||
config.get_section(config.config_ini_section),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
|
||||
with connectable.connect() as connection:
|
||||
context.configure(
|
||||
connection=connection,
|
||||
target_metadata=Base.metadata,
|
||||
render_as_batch=True,
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
connectable.dispose()
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
"""Run migrations in 'offline' mode."""
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=Base.metadata,
|
||||
render_as_batch=True,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "batch"},
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
@@ -0,0 +1,178 @@
|
||||
"""initial schema.
|
||||
|
||||
Revision ID: initial
|
||||
Revises:
|
||||
Create Date: 2026-09-07
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "initial"
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# slabs
|
||||
op.create_table(
|
||||
"slabs",
|
||||
sa.Column("id", sa.Text(), nullable=False, primary_key=True),
|
||||
sa.Column("sku", sa.String(length=24), nullable=False),
|
||||
sa.Column("status", sa.Text(), nullable=False, server_default="draft"),
|
||||
sa.Column("length_in", sa.Float(), nullable=False),
|
||||
sa.Column("thickness_in", sa.Float(), nullable=False),
|
||||
sa.Column("sqft", sa.Float(), nullable=True),
|
||||
sa.Column("bdft", sa.Float(), nullable=True),
|
||||
sa.Column("width_min_in", sa.Float(), nullable=True),
|
||||
sa.Column("width_max_in", sa.Float(), nullable=True),
|
||||
sa.Column("width_avg_in", sa.Float(), nullable=True),
|
||||
sa.Column("species_id", sa.Integer(), nullable=True),
|
||||
sa.Column("wood_category_ids", sa.JSON(), nullable=True),
|
||||
sa.Column("edge_type_term_id", sa.Integer(), nullable=True),
|
||||
sa.Column("figure_term_ids", sa.JSON(), nullable=True),
|
||||
sa.Column("grade_term_ids", sa.JSON(), nullable=True),
|
||||
sa.Column("thickness_term_id", sa.Integer(), nullable=True),
|
||||
sa.Column("moisture_term_id", sa.Integer(), nullable=True),
|
||||
sa.Column("fig_tag_ids", sa.JSON(), nullable=True),
|
||||
sa.Column("feat_tag_ids", sa.JSON(), nullable=True),
|
||||
sa.Column("price_per_bdft", sa.Float(), nullable=True),
|
||||
sa.Column("price", sa.Float(), nullable=False),
|
||||
sa.Column("price_source", sa.Text(), nullable=True),
|
||||
sa.Column("title", sa.Text(), nullable=True),
|
||||
sa.Column("short_title", sa.Text(), nullable=True),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("content_source", sa.Text(), nullable=True),
|
||||
sa.Column("client_rev", sa.Integer(), nullable=False, server_default="1"),
|
||||
sa.Column("server_rev", sa.Integer(), nullable=False, server_default="1"),
|
||||
sa.Column("created_at", sa.Text(), nullable=False, server_default=sa.text("datetime('now')")),
|
||||
sa.Column("updated_at", sa.Text(), nullable=False, server_default=sa.text("datetime('now')")),
|
||||
sa.Column("inference_thread_id", sa.Text(), nullable=True),
|
||||
sa.CheckConstraint("length_in >= 0"),
|
||||
sa.CheckConstraint("thickness_in >= 0"),
|
||||
sa.CheckConstraint("price >= 0"),
|
||||
sa.UniqueConstraint("sku"),
|
||||
)
|
||||
op.create_index("idx_slabs_status", "slabs", ["status"])
|
||||
op.create_index("idx_slabs_updated_at", "slabs", ["updated_at"])
|
||||
|
||||
# slab_photos
|
||||
op.create_table(
|
||||
"slab_photos",
|
||||
sa.Column("id", sa.Text(), nullable=False, primary_key=True),
|
||||
sa.Column("slab_id", sa.Text(), nullable=False),
|
||||
sa.Column("kind", sa.Text(), nullable=False),
|
||||
sa.Column("role", sa.Text(), nullable=True),
|
||||
sa.Column("seq", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("original_path", sa.Text(), nullable=True),
|
||||
sa.Column("processed_path", sa.Text(), nullable=True),
|
||||
sa.Column("created_at", sa.Text(), server_default=sa.text("datetime('now')")),
|
||||
sa.ForeignKeyConstraint(["slab_id"], ["slabs.id"], ondelete="CASCADE"),
|
||||
)
|
||||
|
||||
# pricing_rules
|
||||
op.create_table(
|
||||
"pricing_rules",
|
||||
sa.Column("id", sa.Text(), nullable=False, primary_key=True),
|
||||
sa.Column("species", sa.Text(), nullable=False),
|
||||
sa.Column("price_per_bdft", sa.Float(), nullable=False),
|
||||
sa.Column("created_at", sa.Text(), server_default=sa.text("datetime('now')")),
|
||||
sa.Column("updated_at", sa.Text(), server_default=sa.text("datetime('now')")),
|
||||
sa.UniqueConstraint("species"),
|
||||
)
|
||||
|
||||
# woo_categories
|
||||
op.create_table(
|
||||
"woo_categories",
|
||||
sa.Column("woo_id", sa.Integer(), nullable=False, primary_key=True),
|
||||
sa.Column("name", sa.Text(), nullable=True),
|
||||
sa.Column("slug", sa.Text(), nullable=True),
|
||||
sa.Column("parent_id", sa.Integer(), nullable=True),
|
||||
sa.Column("synced_at", sa.Text(), nullable=True),
|
||||
sa.UniqueConstraint("woo_id"),
|
||||
)
|
||||
|
||||
# woo_attributes
|
||||
op.create_table(
|
||||
"woo_attributes",
|
||||
sa.Column("woo_id", sa.Integer(), nullable=False, primary_key=True),
|
||||
sa.Column("name", sa.Text(), nullable=True),
|
||||
sa.Column("slug", sa.Text(), nullable=True),
|
||||
sa.Column("type", sa.Text(), server_default="select"),
|
||||
sa.Column("synced_at", sa.Text(), nullable=True),
|
||||
sa.UniqueConstraint("woo_id"),
|
||||
)
|
||||
|
||||
# woo_attribute_terms
|
||||
op.create_table(
|
||||
"woo_attribute_terms",
|
||||
sa.Column("woo_id", sa.Integer(), nullable=False, primary_key=True),
|
||||
sa.Column("attribute_id", sa.Integer(), nullable=False),
|
||||
sa.Column("name", sa.Text(), nullable=True),
|
||||
sa.Column("slug", sa.Text(), nullable=True),
|
||||
sa.Column("synced_at", sa.Text(), nullable=True),
|
||||
sa.ForeignKeyConstraint(["attribute_id"], ["woo_attributes.woo_id"]),
|
||||
sa.UniqueConstraint("woo_id"),
|
||||
)
|
||||
|
||||
# woo_tags
|
||||
op.create_table(
|
||||
"woo_tags",
|
||||
sa.Column("woo_id", sa.Integer(), nullable=False, primary_key=True),
|
||||
sa.Column("name", sa.Text(), nullable=True),
|
||||
sa.Column("slug", sa.Text(), nullable=True),
|
||||
sa.Column("synced_at", sa.Text(), nullable=True),
|
||||
sa.UniqueConstraint("woo_id"),
|
||||
)
|
||||
|
||||
# settings
|
||||
op.create_table(
|
||||
"settings",
|
||||
sa.Column("key", sa.Text(), nullable=False, primary_key=True),
|
||||
sa.Column("value_enc", sa.LargeBinary(), nullable=True),
|
||||
sa.Column("updated_at", sa.Text(), server_default=sa.text("datetime('now')")),
|
||||
)
|
||||
|
||||
# sync_log
|
||||
op.create_table(
|
||||
"sync_log",
|
||||
sa.Column("id", sa.Integer(), nullable=False, autoincrement=True, primary_key=True),
|
||||
sa.Column("slab_id", sa.Text(), nullable=True),
|
||||
sa.Column("sku", sa.Text(), nullable=True),
|
||||
sa.Column("woo_product_id", sa.Integer(), nullable=True),
|
||||
sa.Column("status", sa.Text(), nullable=True),
|
||||
sa.Column("http_status", sa.Integer(), nullable=True),
|
||||
sa.Column("error", sa.Text(), nullable=True),
|
||||
sa.Column("detail", sa.Text(), nullable=True),
|
||||
sa.Column("payload_hash", sa.Text(), nullable=True),
|
||||
sa.Column("created_at", sa.Text(), server_default=sa.text("datetime('now')")),
|
||||
)
|
||||
|
||||
# inference_log
|
||||
op.create_table(
|
||||
"inference_log",
|
||||
sa.Column("id", sa.Integer(), nullable=False, autoincrement=True, primary_key=True),
|
||||
sa.Column("slab_id", sa.Text(), nullable=True),
|
||||
sa.Column("call", sa.Text(), nullable=True),
|
||||
sa.Column("request_payload", sa.Text(), nullable=True),
|
||||
sa.Column("response_payload", sa.Text(), nullable=True),
|
||||
sa.Column("created_at", sa.Text(), server_default=sa.text("datetime('now')")),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("inference_log")
|
||||
op.drop_table("sync_log")
|
||||
op.drop_table("settings")
|
||||
op.drop_table("woo_tags")
|
||||
op.drop_table("woo_attribute_terms")
|
||||
op.drop_table("woo_attributes")
|
||||
op.drop_table("woo_categories")
|
||||
op.drop_table("pricing_rules")
|
||||
op.drop_table("slab_photos")
|
||||
op.drop_index("idx_slabs_updated_at", table_name="slabs")
|
||||
op.drop_index("idx_slabs_status", table_name="slabs")
|
||||
op.drop_table("slabs")
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Health endpoint router."""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/api/health")
|
||||
async def health_check() -> dict:
|
||||
"""Deploy health and smoke path.
|
||||
|
||||
Returns:
|
||||
Health status with woo_reachable flag. When Woo credentials
|
||||
are not configured, woo_reachable is False.
|
||||
|
||||
Contract (OPENAPI):
|
||||
GET /api/health → { "status": "ok", "version": "...", "woo_reachable": bool }
|
||||
"""
|
||||
return {
|
||||
"status": "ok",
|
||||
"version": "0.1.0",
|
||||
"woo_reachable": False,
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
"""Settings endpoint: GET returns masked settings, PUT stores encrypted secrets.
|
||||
|
||||
Per OPENAPI.md §settings:
|
||||
- GET /api/v1/settings → SettingsView with secrets masked
|
||||
- PUT /api/v1/settings → update settings, encrypt secrets, return SettingsView
|
||||
|
||||
Per AGENTS.md §4:
|
||||
- Woo credentials encrypted AES-GCM at rest
|
||||
- Ciphertext never appears in GET settings
|
||||
- Missing SLAB_AES_KEY refuses startup
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.config import Settings
|
||||
from app.db.engine import get_session
|
||||
from app.db.models import Setting
|
||||
from app.security.aes import decrypt_setting, encrypt_setting, get_aes_key
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class SettingsUpdate(BaseModel):
|
||||
"""Partial settings update payload.
|
||||
|
||||
Woo consumer keys and woo_base_url are rejected (woo_base_url is compose-only).
|
||||
Consumer keys are included as fields so they can be explicitly rejected.
|
||||
"""
|
||||
|
||||
woo_base_url: str | None = None
|
||||
woo_wp_username: str | None = None
|
||||
woo_app_password: str | None = None
|
||||
woo_consumer_key: str | None = None
|
||||
woo_consumer_secret: str | None = None
|
||||
inference_base_url: str | None = None
|
||||
inference_api_key: str | None = None
|
||||
inference_model: str | None = None
|
||||
inference_enabled: bool | None = None
|
||||
content_llm_enabled: bool | None = None
|
||||
brand_voice: str | None = None
|
||||
geo_context: str | None = None
|
||||
woo_create_status: str | None = Field(default=None, description="'draft' | 'publish'")
|
||||
aspect_ratio: str | None = None
|
||||
output_px: int | None = None
|
||||
output_px_min: int | None = None
|
||||
fill_target: float | None = None
|
||||
|
||||
|
||||
class SettingsView(BaseModel):
|
||||
"""Settings read model. Secrets masked."""
|
||||
|
||||
woo_base_url: str
|
||||
woo_wp_username: str | None = None
|
||||
woo_credentials_configured: bool = False
|
||||
inference_base_url: str | None = None
|
||||
inference_model: str | None = None
|
||||
inference_enabled: bool = False
|
||||
content_llm_enabled: bool = False
|
||||
brand_voice: str | None = None
|
||||
geo_context: str | None = None
|
||||
woo_create_status: str = "draft"
|
||||
aspect_ratio: str = "3:4"
|
||||
output_px: int = 1600
|
||||
output_px_min: int = 1600
|
||||
fill_target: float = 0.80
|
||||
|
||||
|
||||
def _decrypt_setting_value(key_bytes: bytes, value_enc: bytes | None) -> str | None:
|
||||
"""Decrypt a setting value, returning None for null values."""
|
||||
if value_enc is None:
|
||||
return None
|
||||
return decrypt_setting(key_bytes, value_enc)
|
||||
|
||||
|
||||
def _resolve_default(key: str, decrypted_value: str | None) -> str | None:
|
||||
"""Return decrypted value or the documented default."""
|
||||
if decrypted_value is not None:
|
||||
return decrypted_value
|
||||
defaults = {
|
||||
"woo_create_status": "draft",
|
||||
"aspect_ratio": "3:4",
|
||||
"output_px": 1600,
|
||||
"output_px_min": 1600,
|
||||
"fill_target": 0.80,
|
||||
}
|
||||
return defaults.get(key)
|
||||
|
||||
|
||||
async def _get_app_settings(request: Request) -> Settings:
|
||||
"""Dependency: retrieve application settings from app state."""
|
||||
return request.app.state.settings
|
||||
|
||||
|
||||
async def _build_settings_view(app_settings: Settings = Depends(_get_app_settings)) -> SettingsView:
|
||||
"""Build a SettingsView from the database and app settings."""
|
||||
key_bytes = get_aes_key(app_settings.aes_key)
|
||||
|
||||
session = await get_session()
|
||||
rows = await session.execute(text("SELECT key, value_enc FROM settings"))
|
||||
all_rows = rows.all()
|
||||
await session.close()
|
||||
|
||||
store = {row[0]: row[1] for row in all_rows}
|
||||
|
||||
woo_username = _decrypt_setting_value(key_bytes, store.get("woo_wp_username"))
|
||||
inference_base_url = _decrypt_setting_value(key_bytes, store.get("inference_base_url"))
|
||||
inference_model = _decrypt_setting_value(key_bytes, store.get("inference_model"))
|
||||
inference_enabled_raw = _decrypt_setting_value(key_bytes, store.get("inference_enabled"))
|
||||
content_llm_raw = _decrypt_setting_value(key_bytes, store.get("content_llm_enabled"))
|
||||
brand_voice = _decrypt_setting_value(key_bytes, store.get("brand_voice"))
|
||||
geo_context = _decrypt_setting_value(key_bytes, store.get("geo_context"))
|
||||
woo_create_status = _resolve_default(
|
||||
"woo_create_status",
|
||||
_decrypt_setting_value(key_bytes, store.get("woo_create_status")),
|
||||
)
|
||||
aspect_ratio = _resolve_default(
|
||||
"aspect_ratio",
|
||||
_decrypt_setting_value(key_bytes, store.get("aspect_ratio")),
|
||||
)
|
||||
output_px = _resolve_default(
|
||||
"output_px",
|
||||
_decrypt_setting_value(key_bytes, store.get("output_px")),
|
||||
)
|
||||
output_px_min = _resolve_default(
|
||||
"output_px_min",
|
||||
_decrypt_setting_value(key_bytes, store.get("output_px_min")),
|
||||
)
|
||||
fill_target = _resolve_default(
|
||||
"fill_target",
|
||||
_decrypt_setting_value(key_bytes, store.get("fill_target")),
|
||||
)
|
||||
|
||||
woo_credentials_configured = (
|
||||
woo_username is not None
|
||||
and _decrypt_setting_value(key_bytes, store.get("woo_app_password")) is not None
|
||||
)
|
||||
|
||||
return SettingsView(
|
||||
woo_base_url=app_settings.woo_base_url,
|
||||
woo_wp_username=woo_username,
|
||||
woo_credentials_configured=woo_credentials_configured,
|
||||
inference_base_url=inference_base_url,
|
||||
inference_model=inference_model,
|
||||
inference_enabled=(
|
||||
inference_enabled_raw == "true" if inference_enabled_raw is not None else False
|
||||
),
|
||||
content_llm_enabled=(
|
||||
content_llm_raw == "true" if content_llm_raw is not None else False
|
||||
),
|
||||
brand_voice=brand_voice,
|
||||
geo_context=geo_context,
|
||||
woo_create_status=woo_create_status,
|
||||
aspect_ratio=aspect_ratio,
|
||||
output_px=output_px,
|
||||
output_px_min=output_px_min,
|
||||
fill_target=fill_target,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/v1/settings", response_model=SettingsView)
|
||||
async def get_settings(app_settings: Settings = Depends(_get_app_settings)) -> SettingsView:
|
||||
"""GET settings. Secrets (app password, inference key) are never returned.
|
||||
|
||||
Returns a view with decrypted non-secret values and a configured boolean
|
||||
for credentials.
|
||||
"""
|
||||
return await _build_settings_view(app_settings)
|
||||
|
||||
|
||||
@router.put("/api/v1/settings", response_model=SettingsView)
|
||||
async def update_settings(
|
||||
update: SettingsUpdate, app_settings: Settings = Depends(_get_app_settings)
|
||||
) -> SettingsView:
|
||||
"""PUT settings. Encrypt secrets, store non-secrets. Return updated view.
|
||||
|
||||
Rejects consumer key fields. Returns 422 for invalid woo_create_status.
|
||||
"""
|
||||
# Reject consumer keys and woo_base_url (compose-only, read-only in UI)
|
||||
payload = update.model_dump(exclude_unset=True)
|
||||
if "woo_consumer_key" in payload or "woo_consumer_secret" in payload:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="WooCommerce consumer keys are not supported. Use WordPress application passwords.",
|
||||
)
|
||||
if "woo_base_url" in payload:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="woo_base_url is set via compose environment, not editable via settings.",
|
||||
)
|
||||
|
||||
# Validate woo_create_status
|
||||
if update.woo_create_status is not None and update.woo_create_status not in (
|
||||
"draft",
|
||||
"publish",
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="woo_create_status must be 'draft' or 'publish'",
|
||||
)
|
||||
|
||||
key_bytes = get_aes_key(app_settings.aes_key)
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
session = await get_session()
|
||||
|
||||
for field, value in payload.items():
|
||||
if value is None:
|
||||
continue
|
||||
|
||||
ct = encrypt_setting(key_bytes, str(value))
|
||||
setting = await session.execute(
|
||||
text("SELECT key FROM settings WHERE key = :key"), {"key": field}
|
||||
)
|
||||
existing = setting.fetchone()
|
||||
if existing:
|
||||
await session.execute(
|
||||
text("UPDATE settings SET value_enc = :val, updated_at = :now WHERE key = :key"),
|
||||
{"val": ct, "now": now, "key": field},
|
||||
)
|
||||
else:
|
||||
await session.execute(
|
||||
text("INSERT INTO settings (key, value_enc, updated_at) VALUES (:key, :val, :now)"),
|
||||
{"key": field, "val": ct, "now": now},
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
|
||||
# Return updated view
|
||||
return await _build_settings_view(app_settings)
|
||||
@@ -0,0 +1,432 @@
|
||||
"""Slabs API: create calibrated draft from multipart upload.
|
||||
|
||||
Per OPENAPI.md §slabs:
|
||||
- POST /api/v1/slabs accepts multipart/form-data with:
|
||||
- data: JSON SlabCreate payload
|
||||
- files[]: original image files
|
||||
- meta[]: photo metadata JSON objects
|
||||
- Returns 201 with status: calibrated and server_rev
|
||||
- Server stores client-sent numbers only; no server-side recompute.
|
||||
|
||||
Per DATA-MODEL.md:
|
||||
- slabs table: client-generated UUID, sku unique, status calibrated
|
||||
- slab_photos table: slab_id FK, kind inventory, role, seq, original_path
|
||||
- server_rev = 1 on create
|
||||
"""
|
||||
|
||||
import logging
|
||||
import tempfile
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.config import Settings
|
||||
from app.db.engine import get_session
|
||||
from app.db.models import Slab, SlabPhoto, SlabStatus
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pydantic models
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class PhotoMeta(BaseModel):
|
||||
"""Photo metadata from the multipart request."""
|
||||
|
||||
kind: str = Field(default="inventory")
|
||||
role: str | None = None
|
||||
seq: int = 0
|
||||
|
||||
@field_validator("kind")
|
||||
@classmethod
|
||||
def validate_kind(cls, v: str) -> str:
|
||||
if v != "inventory":
|
||||
raise ValueError("kind must be 'inventory'")
|
||||
return v
|
||||
|
||||
@field_validator("role")
|
||||
@classmethod
|
||||
def validate_role(cls, v: str | None) -> str | None:
|
||||
if v is not None and v not in ("topdown", "extra"):
|
||||
raise ValueError("role must be 'topdown' or 'extra'")
|
||||
return v
|
||||
|
||||
|
||||
class SlabCreateData(BaseModel):
|
||||
"""Client-sent slab data (the `data` multipart field).
|
||||
|
||||
Mirrors the OPENAPI.md SlabCreate schema. All numeric fields are
|
||||
client-computed; the server stores them as-is without recomputation.
|
||||
"""
|
||||
|
||||
id: str = Field(description="Client-generated UUID")
|
||||
sku: str = Field(min_length=3, max_length=24, pattern=r"^[A-Z0-9-]+$")
|
||||
length_in: float = Field(ge=0)
|
||||
thickness_in: float = Field(ge=0)
|
||||
sqft: float | None = Field(default=None, ge=0)
|
||||
bdft: float | None = Field(default=None, ge=0)
|
||||
width_min_in: float | None = Field(default=None, ge=0)
|
||||
width_max_in: float | None = Field(default=None, ge=0)
|
||||
width_avg_in: float | None = Field(default=None, ge=0)
|
||||
species_id: int | None = None
|
||||
wood_category_ids: list[int] | None = None
|
||||
edge_type_term_id: int | None = None
|
||||
figure_term_ids: list[int] | None = None
|
||||
grade_term_ids: list[int] | None = None
|
||||
thickness_term_id: int | None = None
|
||||
moisture_term_id: int | None = None
|
||||
fig_tag_ids: list[int] | None = None
|
||||
feat_tag_ids: list[int] | None = None
|
||||
price_per_bdft: float | None = Field(default=None, ge=0)
|
||||
price: float = Field(ge=0)
|
||||
price_source: str | None = Field(default=None, pattern=r"^(recommendation|override)$")
|
||||
title: str | None = None
|
||||
short_title: str | None = None
|
||||
description: str | None = None
|
||||
content_source: str | None = Field(default=None, pattern=r"^(manual|llm)$")
|
||||
client_rev: int = Field(default=1, ge=1)
|
||||
|
||||
|
||||
class PhotoRead(BaseModel):
|
||||
"""Photo read model returned in slab responses."""
|
||||
|
||||
id: str
|
||||
kind: str = "inventory"
|
||||
role: str | None = None
|
||||
seq: int = 0
|
||||
original_url: str
|
||||
processed_url: str | None = None
|
||||
|
||||
|
||||
class SlabRead(BaseModel):
|
||||
"""Slab read model — all fields from create plus server state."""
|
||||
|
||||
id: str
|
||||
sku: str
|
||||
status: str
|
||||
length_in: float
|
||||
thickness_in: float
|
||||
sqft: float | None = None
|
||||
bdft: float | None = None
|
||||
width_min_in: float | None = None
|
||||
width_max_in: float | None = None
|
||||
width_avg_in: float | None = None
|
||||
species_id: int | None = None
|
||||
wood_category_ids: list[int] | None = None
|
||||
edge_type_term_id: int | None = None
|
||||
figure_term_ids: list[int] | None = None
|
||||
grade_term_ids: list[int] | None = None
|
||||
thickness_term_id: int | None = None
|
||||
moisture_term_id: int | None = None
|
||||
fig_tag_ids: list[int] | None = None
|
||||
feat_tag_ids: list[int] | None = None
|
||||
price_per_bdft: float | None = None
|
||||
price: float
|
||||
price_source: str | None = None
|
||||
title: str | None = None
|
||||
short_title: str | None = None
|
||||
description: str | None = None
|
||||
content_source: str | None = None
|
||||
client_rev: int
|
||||
server_rev: int = 1
|
||||
created_at: str
|
||||
updated_at: str
|
||||
inference_thread_id: str | None = None
|
||||
species_confidence: float | None = None
|
||||
woo_product_id: int | None = None
|
||||
published_at: str | None = None
|
||||
photos: list[PhotoRead] = []
|
||||
inference_status: str | None = None
|
||||
inference_error: str | None = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_IMAGES_DIR = Path("/data/images")
|
||||
|
||||
|
||||
def _get_images_dir() -> Path:
|
||||
"""Return the images storage directory, creating it if needed.
|
||||
|
||||
For tests, falls back to a temp directory.
|
||||
"""
|
||||
try:
|
||||
_IMAGES_DIR.mkdir(parents=True, exist_ok=True)
|
||||
return _IMAGES_DIR
|
||||
except (PermissionError, OSError):
|
||||
# Fallback: use temp directory (e.g., in tests)
|
||||
return Path(tempfile.mkdtemp(prefix="slab_"))
|
||||
|
||||
|
||||
def _save_photo_file(photo_id: str, file_bytes: bytes, filename: str) -> str:
|
||||
"""Save an uploaded photo file to disk and return the relative path.
|
||||
|
||||
Stores as images/{photo_id}/{filename}. In test environments,
|
||||
uses a temp directory.
|
||||
"""
|
||||
base_dir = _get_images_dir()
|
||||
photo_dir = base_dir / photo_id
|
||||
photo_dir.mkdir(parents=True, exist_ok=True)
|
||||
file_path = photo_dir / filename
|
||||
file_path.write_bytes(file_bytes)
|
||||
return str(f"images/{photo_id}/{filename}")
|
||||
|
||||
|
||||
def _slab_read_from_model(slab: Slab, photos: list[SlabPhoto] | None = None) -> SlabRead:
|
||||
"""Convert a Slab ORM model to SlabRead response."""
|
||||
photos_read = []
|
||||
if photos:
|
||||
for p in photos:
|
||||
original_url = (
|
||||
f"/api/v1/slabs/{slab.id}/photos/{p.id}/original"
|
||||
if p.original_path
|
||||
else None
|
||||
)
|
||||
photos_read.append(
|
||||
PhotoRead(
|
||||
id=p.id,
|
||||
kind=p.kind,
|
||||
role=p.role,
|
||||
seq=p.seq,
|
||||
original_url=original_url or "",
|
||||
processed_url=p.processed_path,
|
||||
)
|
||||
)
|
||||
return SlabRead(
|
||||
id=slab.id,
|
||||
sku=slab.sku,
|
||||
status=slab.status.value,
|
||||
length_in=slab.length_in,
|
||||
thickness_in=slab.thickness_in,
|
||||
sqft=slab.sqft,
|
||||
bdft=slab.bdft,
|
||||
width_min_in=slab.width_min_in,
|
||||
width_max_in=slab.width_max_in,
|
||||
width_avg_in=slab.width_avg_in,
|
||||
species_id=slab.species_id,
|
||||
wood_category_ids=slab.wood_category_ids,
|
||||
edge_type_term_id=slab.edge_type_term_id,
|
||||
figure_term_ids=slab.figure_term_ids,
|
||||
grade_term_ids=slab.grade_term_ids,
|
||||
thickness_term_id=slab.thickness_term_id,
|
||||
moisture_term_id=slab.moisture_term_id,
|
||||
fig_tag_ids=slab.fig_tag_ids,
|
||||
feat_tag_ids=slab.feat_tag_ids,
|
||||
price_per_bdft=slab.price_per_bdft,
|
||||
price=slab.price,
|
||||
price_source=slab.price_source,
|
||||
title=slab.title,
|
||||
short_title=slab.short_title,
|
||||
description=slab.description,
|
||||
content_source=slab.content_source,
|
||||
client_rev=slab.client_rev,
|
||||
server_rev=slab.server_rev,
|
||||
created_at=slab.created_at,
|
||||
updated_at=slab.updated_at,
|
||||
inference_thread_id=slab.inference_thread_id,
|
||||
photos=photos_read,
|
||||
inference_status=slab.inference_thread_id,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post("/api/v1/slabs", status_code=201)
|
||||
async def create_slab(request: Request) -> JSONResponse:
|
||||
"""Create a calibrated draft from a multipart upload.
|
||||
|
||||
The request body must be multipart/form-data with these fields:
|
||||
- `data` — JSON-encoded SlabCreate payload
|
||||
- `files[]` — original image files (1–5 photos)
|
||||
- `meta[]` — JSON-encoded photo metadata objects
|
||||
|
||||
Returns 201 with the created slab (status: calibrated, server_rev: 1).
|
||||
Server stores client-sent numbers only; no server-side recompute.
|
||||
|
||||
Per AGENTS.md §3: client is authoritative for deterministic math;
|
||||
server stores client-sent numbers.
|
||||
"""
|
||||
# Parse multipart form data
|
||||
form = await request.form()
|
||||
|
||||
# --- Extract and validate `data` field ---
|
||||
data_json = form.get("data")
|
||||
if not data_json:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={
|
||||
"code": "validation",
|
||||
"message": "Missing 'data' field in multipart upload.",
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
create_data = SlabCreateData.model_validate_json(data_json)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={
|
||||
"code": "validation",
|
||||
"message": f"Invalid data JSON: {e}",
|
||||
},
|
||||
)
|
||||
|
||||
# --- Extract `files[]` and `meta[]` ---
|
||||
files: list[request.file_uploads] = form.getlist("files[]")
|
||||
metas: list[str] = form.getlist("meta[]")
|
||||
|
||||
# Require at least one photo
|
||||
if not files:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={
|
||||
"code": "validation",
|
||||
"message": "At least one photo file is required.",
|
||||
},
|
||||
)
|
||||
|
||||
# Pair files with metadata; extras get default meta
|
||||
if len(metas) < len(files):
|
||||
# Pad with default meta for files without explicit metadata
|
||||
default_meta = PhotoMeta(kind="inventory", role=None, seq=0)
|
||||
metas = list(metas) + [default_meta.model_dump_json()] * (len(files) - len(metas))
|
||||
|
||||
if len(files) != len(metas):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={
|
||||
"code": "validation",
|
||||
"message": "files[] and meta[] counts must match.",
|
||||
},
|
||||
)
|
||||
|
||||
# --- Validate meta objects and resolve photo files ---
|
||||
photo_metas = []
|
||||
for i, meta_json in enumerate(metas):
|
||||
try:
|
||||
meta = PhotoMeta.model_validate_json(meta_json)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={
|
||||
"code": "validation",
|
||||
"message": f"Invalid meta[{i}] JSON: {e}",
|
||||
},
|
||||
)
|
||||
photo_metas.append(meta)
|
||||
|
||||
# --- Database: create slab and photos ---
|
||||
session = await get_session()
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
try:
|
||||
# Check for duplicate SKU
|
||||
existing = await session.execute(
|
||||
text("SELECT id FROM slabs WHERE sku = :sku"), {"sku": create_data.sku}
|
||||
)
|
||||
if existing.fetchone():
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={
|
||||
"code": "duplicate_sku",
|
||||
"message": f"SKU '{create_data.sku}' already exists.",
|
||||
"actions": ["edit_sku", "open_existing"],
|
||||
},
|
||||
)
|
||||
|
||||
# Create slab record — status starts as calibrated
|
||||
slab = Slab(
|
||||
id=create_data.id,
|
||||
sku=create_data.sku,
|
||||
status=SlabStatus.CALIBRATED,
|
||||
length_in=create_data.length_in,
|
||||
thickness_in=create_data.thickness_in,
|
||||
sqft=create_data.sqft,
|
||||
bdft=create_data.bdft,
|
||||
width_min_in=create_data.width_min_in,
|
||||
width_max_in=create_data.width_max_in,
|
||||
width_avg_in=create_data.width_avg_in,
|
||||
species_id=create_data.species_id,
|
||||
wood_category_ids=create_data.wood_category_ids,
|
||||
edge_type_term_id=create_data.edge_type_term_id,
|
||||
figure_term_ids=create_data.figure_term_ids,
|
||||
grade_term_ids=create_data.grade_term_ids,
|
||||
thickness_term_id=create_data.thickness_term_id,
|
||||
moisture_term_id=create_data.moisture_term_id,
|
||||
fig_tag_ids=create_data.fig_tag_ids,
|
||||
feat_tag_ids=create_data.feat_tag_ids,
|
||||
price_per_bdft=create_data.price_per_bdft,
|
||||
price=create_data.price,
|
||||
price_source=create_data.price_source,
|
||||
title=create_data.title,
|
||||
short_title=create_data.short_title,
|
||||
description=create_data.description,
|
||||
content_source=create_data.content_source,
|
||||
client_rev=create_data.client_rev,
|
||||
server_rev=1,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
inference_thread_id=None,
|
||||
)
|
||||
session.add(slab)
|
||||
|
||||
# Create photo records and store files
|
||||
photos = []
|
||||
for file_upload, meta in zip(files, photo_metas):
|
||||
file_bytes = await file_upload.read()
|
||||
filename = file_upload.filename or f"photo_{meta.seq}.jpg"
|
||||
|
||||
photo_id = str(uuid.uuid4())
|
||||
original_path = _save_photo_file(photo_id, file_bytes, filename)
|
||||
|
||||
photo = SlabPhoto(
|
||||
id=photo_id,
|
||||
slab_id=slab.id,
|
||||
kind=meta.kind,
|
||||
role=meta.role,
|
||||
seq=meta.seq,
|
||||
original_path=original_path,
|
||||
processed_path=None,
|
||||
created_at=now,
|
||||
)
|
||||
session.add(photo)
|
||||
photos.append(photo)
|
||||
|
||||
await session.commit()
|
||||
|
||||
# Build response
|
||||
result = _slab_read_from_model(slab, photos)
|
||||
|
||||
return JSONResponse(
|
||||
status_code=201,
|
||||
content=result.model_dump(mode="json"),
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
await session.rollback()
|
||||
raise
|
||||
except Exception as exc:
|
||||
await session.rollback()
|
||||
logger.exception("Failed to create slab")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail={
|
||||
"code": "internal",
|
||||
"message": f"Failed to create slab: {exc}",
|
||||
},
|
||||
)
|
||||
finally:
|
||||
await session.close()
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Application settings loaded from environment."""
|
||||
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""FastAPI application settings.
|
||||
|
||||
WOO_BASE_URL is the deploy-time SoT for the WooCommerce store.
|
||||
It must be HTTPS. FastAPI refuses startup when missing or HTTP.
|
||||
|
||||
SLAB_AES_KEY is the AES-GCM master key (32-byte base64). Required.
|
||||
Missing key refuses startup.
|
||||
|
||||
All Woo credentials and inference secrets are stored AES-GCM encrypted
|
||||
in the settings table at runtime, not passed as env vars.
|
||||
"""
|
||||
|
||||
model_config = SettingsConfigDict(env_prefix="SLAB_", env_file=".env", env_file_encoding="utf-8")
|
||||
|
||||
aes_key: str | None = None
|
||||
woo_base_url: str | None = None
|
||||
app_hostname: str | None = None
|
||||
|
||||
# Image output constants (from TECH-SPEC)
|
||||
aspect_ratio: str = "3:4"
|
||||
output_px: int = 1600
|
||||
output_px_min: int = 1600
|
||||
fill_target: float = 0.80
|
||||
|
||||
def validate(self) -> None:
|
||||
"""Refuse startup on invalid config."""
|
||||
if self.aes_key is None:
|
||||
raise ValueError("SLAB_AES_KEY environment variable is required")
|
||||
if len(self.aes_key) < 32:
|
||||
raise ValueError("SLAB_AES_KEY must be at least 32 bytes base64")
|
||||
|
||||
if self.woo_base_url is None:
|
||||
raise ValueError("WOO_BASE_URL environment variable is required")
|
||||
if not self.woo_base_url.startswith("https://"):
|
||||
raise ValueError("WOO_BASE_URL must be HTTPS")
|
||||
|
||||
@property
|
||||
def woo_credentials_configured(self) -> bool:
|
||||
"""Whether Woo application password has been stored (checked via DB at runtime)."""
|
||||
return False # Always false from config alone; checked via settings table at runtime
|
||||
@@ -0,0 +1,84 @@
|
||||
"""SQLite engine and session management.
|
||||
|
||||
Uses SQLAlchemy async engine with aiosqlite driver.
|
||||
|
||||
For tests, pass `test_db_url` via the settings or use a test fixture
|
||||
to point at an in-memory SQLite database.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
from app.db.models import Base
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Default DB path for production
|
||||
DEFAULT_DB_URL = "sqlite+aiosqlite:////data/slab.db"
|
||||
|
||||
_engine: object = None
|
||||
_session_factory: object = None
|
||||
|
||||
_test_url: str | None = None
|
||||
|
||||
|
||||
def set_test_url(url: str) -> None:
|
||||
"""Override the database URL for testing. Must be called before create_app."""
|
||||
global _test_url
|
||||
_test_url = url
|
||||
|
||||
|
||||
def get_db_url() -> str:
|
||||
"""Return the database URL, preferring test URL if set."""
|
||||
if _test_url:
|
||||
return _test_url
|
||||
return DEFAULT_DB_URL
|
||||
|
||||
|
||||
async def init_db() -> None:
|
||||
"""Initialize the async engine, session factory, and create tables.
|
||||
|
||||
For in-memory SQLite tests, creates tables directly.
|
||||
For file-based SQLite, attempts alembic migrations first, falling
|
||||
back to direct table creation.
|
||||
"""
|
||||
global _engine, _session_factory
|
||||
|
||||
db_url = get_db_url()
|
||||
logger.info("Connecting to %s", db_url)
|
||||
|
||||
_engine = create_async_engine(
|
||||
db_url,
|
||||
echo=False,
|
||||
connect_args={"check_same_thread": False},
|
||||
)
|
||||
|
||||
_session_factory = async_sessionmaker(
|
||||
_engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
autocommit=False,
|
||||
autoflush=False,
|
||||
)
|
||||
|
||||
# Create all tables (works for both in-memory and file-based)
|
||||
async with _engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
logger.info("Database initialized")
|
||||
|
||||
|
||||
async def close_db_connection() -> None:
|
||||
"""Dispose of the engine (closes all connections)."""
|
||||
global _engine
|
||||
if _engine is not None:
|
||||
await _engine.dispose()
|
||||
_engine = None
|
||||
|
||||
|
||||
async def get_session() -> AsyncSession:
|
||||
"""Get a database session."""
|
||||
if _session_factory is None:
|
||||
raise RuntimeError("Database not initialized. Call init_db() first.")
|
||||
return _session_factory()
|
||||
@@ -0,0 +1,185 @@
|
||||
"""SQLAlchemy ORM models for the SlabUploader database.
|
||||
|
||||
These models map to the schema defined in docs/DATA-MODEL.md.
|
||||
"""
|
||||
|
||||
import enum
|
||||
|
||||
from sqlalchemy import (
|
||||
Index,
|
||||
JSON,
|
||||
CheckConstraint,
|
||||
Enum,
|
||||
Float,
|
||||
ForeignKey,
|
||||
Integer,
|
||||
LargeBinary,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
)
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
# SQLite doesn't have native JSON or enum types; we use TEXT/JSON via SQLAlchemy's
|
||||
# dialect-specific types.
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
class SlabStatus(str, enum.Enum):
|
||||
DRAFT = "draft"
|
||||
CALIBRATED = "calibrated"
|
||||
READY = "ready"
|
||||
PUBLISHING = "publishing"
|
||||
PUBLISHED = "published"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
class Slab(Base):
|
||||
__tablename__ = "slabs"
|
||||
|
||||
id: Mapped[str] = mapped_column(Text, primary_key=True)
|
||||
sku: Mapped[str] = mapped_column(String(24), unique=True, nullable=False)
|
||||
status: Mapped[SlabStatus] = mapped_column(
|
||||
Enum(SlabStatus, native_enum=False, values_callable=lambda x: [e.value for e in x]),
|
||||
nullable=False,
|
||||
default=SlabStatus.DRAFT,
|
||||
)
|
||||
length_in: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
thickness_in: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
sqft: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
bdft: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
width_min_in: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
width_max_in: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
width_avg_in: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
species_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
wood_category_ids: Mapped[list[int] | None] = mapped_column(JSON, nullable=True)
|
||||
edge_type_term_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
figure_term_ids: Mapped[list[int] | None] = mapped_column(JSON, nullable=True)
|
||||
grade_term_ids: Mapped[list[int] | None] = mapped_column(JSON, nullable=True)
|
||||
thickness_term_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
moisture_term_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
fig_tag_ids: Mapped[list[int] | None] = mapped_column(JSON, nullable=True)
|
||||
feat_tag_ids: Mapped[list[int] | None] = mapped_column(JSON, nullable=True)
|
||||
price_per_bdft: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
price: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
price_source: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
title: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
short_title: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
content_source: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
client_rev: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||
server_rev: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||
created_at: Mapped[str] = mapped_column(Text, nullable=False, server_default=func.now())
|
||||
updated_at: Mapped[str] = mapped_column(Text, nullable=False, server_default=func.now())
|
||||
inference_thread_id: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
CheckConstraint("length_in >= 0"),
|
||||
CheckConstraint("thickness_in >= 0"),
|
||||
CheckConstraint("price >= 0"),
|
||||
Index("idx_slabs_status", "status"),
|
||||
Index("idx_slabs_updated_at", "updated_at"),
|
||||
)
|
||||
|
||||
|
||||
class SlabPhoto(Base):
|
||||
__tablename__ = "slab_photos"
|
||||
|
||||
id: Mapped[str] = mapped_column(Text, primary_key=True)
|
||||
slab_id: Mapped[str] = mapped_column(
|
||||
Text, ForeignKey("slabs.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
kind: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
role: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
seq: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
original_path: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
processed_path: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[str] = mapped_column(Text, server_default=func.now())
|
||||
|
||||
|
||||
class PricingRule(Base):
|
||||
__tablename__ = "pricing_rules"
|
||||
|
||||
id: Mapped[str] = mapped_column(Text, primary_key=True)
|
||||
species: Mapped[str] = mapped_column(Text, nullable=False, unique=True)
|
||||
price_per_bdft: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
created_at: Mapped[str] = mapped_column(Text, server_default=func.now())
|
||||
updated_at: Mapped[str] = mapped_column(Text, server_default=func.now())
|
||||
|
||||
|
||||
class WooCategory(Base):
|
||||
__tablename__ = "woo_categories"
|
||||
|
||||
woo_id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
name: Mapped[str] = mapped_column(Text)
|
||||
slug: Mapped[str] = mapped_column(Text)
|
||||
parent_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
synced_at: Mapped[str] = mapped_column(Text)
|
||||
|
||||
|
||||
class WooAttribute(Base):
|
||||
__tablename__ = "woo_attributes"
|
||||
|
||||
woo_id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
name: Mapped[str] = mapped_column(Text)
|
||||
slug: Mapped[str] = mapped_column(Text)
|
||||
type: Mapped[str] = mapped_column(Text, default="select")
|
||||
synced_at: Mapped[str] = mapped_column(Text)
|
||||
|
||||
|
||||
class WooAttributeTerm(Base):
|
||||
__tablename__ = "woo_attribute_terms"
|
||||
|
||||
woo_id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
attribute_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("woo_attributes.woo_id"), nullable=False
|
||||
)
|
||||
name: Mapped[str] = mapped_column(Text)
|
||||
slug: Mapped[str] = mapped_column(Text)
|
||||
synced_at: Mapped[str] = mapped_column(Text)
|
||||
|
||||
|
||||
class WooTag(Base):
|
||||
__tablename__ = "woo_tags"
|
||||
|
||||
woo_id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
name: Mapped[str] = mapped_column(Text)
|
||||
slug: Mapped[str] = mapped_column(Text)
|
||||
synced_at: Mapped[str] = mapped_column(Text)
|
||||
|
||||
|
||||
class Setting(Base):
|
||||
__tablename__ = "settings"
|
||||
|
||||
key: Mapped[str] = mapped_column(Text, primary_key=True)
|
||||
value_enc: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True)
|
||||
updated_at: Mapped[str] = mapped_column(Text, server_default=func.now())
|
||||
|
||||
|
||||
class SyncLog(Base):
|
||||
__tablename__ = "sync_log"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
slab_id: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
sku: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
woo_product_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
status: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
http_status: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
detail: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
payload_hash: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[str] = mapped_column(Text, server_default=func.now())
|
||||
|
||||
|
||||
class InferenceLog(Base):
|
||||
__tablename__ = "inference_log"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
slab_id: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
call: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
request_payload: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
response_payload: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[str] = mapped_column(Text, server_default=func.now())
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Logging configuration."""
|
||||
|
||||
import logging
|
||||
import sys
|
||||
|
||||
|
||||
def configure_logging() -> None:
|
||||
"""Configure root logger with structured formatting.
|
||||
|
||||
Format: ISO-8601 timestamp, level, logger name, message.
|
||||
Secrets (AES keys, Woo passwords, inference keys) are never logged.
|
||||
"""
|
||||
root = logging.getLogger()
|
||||
root.setLevel(logging.INFO)
|
||||
|
||||
handler = logging.StreamHandler(sys.stdout)
|
||||
handler.setLevel(logging.INFO)
|
||||
|
||||
formatter = logging.Formatter(
|
||||
"%(asctime)s %(levelname)s %(name)s: %(message)s",
|
||||
datefmt="%Y-%m-%dT%H:%M:%S%z",
|
||||
)
|
||||
handler.setFormatter(formatter)
|
||||
|
||||
if not root.handlers:
|
||||
root.addHandler(handler)
|
||||
@@ -0,0 +1,56 @@
|
||||
"""App factory and lifespan."""
|
||||
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
from app.api import health as health_router
|
||||
from app.api import settings as settings_router
|
||||
from app.api import slabs as slabs_router
|
||||
from app.config import Settings
|
||||
from app.db.engine import close_db_connection, init_db
|
||||
from app.logging_config import configure_logging
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Application lifespan: startup and shutdown hooks.
|
||||
|
||||
Handles database initialization, logging configuration,
|
||||
and cleanup on shutdown.
|
||||
"""
|
||||
configure_logging()
|
||||
logging.info("SlabUploader starting up")
|
||||
await init_db()
|
||||
yield
|
||||
logging.info("SlabUploader shutting down")
|
||||
await close_db_connection()
|
||||
|
||||
|
||||
def create_app(app_settings: Settings | None = None) -> FastAPI:
|
||||
"""Create a fresh FastAPI app instance.
|
||||
|
||||
Each call produces an independent app with its own database connection
|
||||
and cleared dependency overrides. This is the factory used by tests.
|
||||
|
||||
Args:
|
||||
app_settings: Application settings. Uses env vars if None.
|
||||
|
||||
Returns:
|
||||
Configured FastAPI application.
|
||||
"""
|
||||
if app_settings is None:
|
||||
app_settings = Settings()
|
||||
app_settings.validate()
|
||||
|
||||
app = FastAPI(title="SlabUploader", version="0.1.0", lifespan=lifespan)
|
||||
|
||||
# Store settings on app state for dependency injection
|
||||
app.state.settings = app_settings
|
||||
|
||||
app.include_router(health_router.router)
|
||||
app.include_router(settings_router.router)
|
||||
app.include_router(slabs_router.router)
|
||||
|
||||
return app
|
||||
@@ -0,0 +1,9 @@
|
||||
# Call 1 prompt — species detection
|
||||
# Read fresh each invocation. Edit on host without restart.
|
||||
|
||||
Identify the slab species, figure, edge type, and grade from the provided images.
|
||||
|
||||
Return results constrained to the provided taxonomy only. Do not invent species,
|
||||
figure terms, or grade terms that are not in the taxonomy list.
|
||||
|
||||
Confidence threshold: 0.7. Below threshold, return empty for that field.
|
||||
@@ -0,0 +1,7 @@
|
||||
# Call 2 prompt — content generation
|
||||
# Read fresh each invocation. Edit on host without restart.
|
||||
|
||||
Generate a title, short title, and description for the slab using the provided
|
||||
deterministic dimensions, species, and figure information.
|
||||
|
||||
Do not invent dimensions. Use the provided length, thickness, widths, sqft, and bdft.
|
||||
@@ -0,0 +1,94 @@
|
||||
"""AES-GCM settings encryption and decryption.
|
||||
|
||||
Per AGENTS.md §4 and DATA-MODEL.md settings table:
|
||||
- AES master key is SLAB_AES_KEY env var (32 bytes base64).
|
||||
- value_enc = nonce(12) || ciphertext.
|
||||
- Fresh nonce per encrypt.
|
||||
- Tampered ciphertext raises authentication failure.
|
||||
- No plaintext fallback.
|
||||
"""
|
||||
|
||||
import base64
|
||||
|
||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||
|
||||
|
||||
class AuthenticationError(Exception):
|
||||
"""Raised when ciphertext integrity check fails (tamper or wrong key)."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
def get_aes_key(key_input: str | bytes) -> bytes:
|
||||
"""Decode the AES master key to 32 bytes.
|
||||
|
||||
Accepts either a base64-encoded string or raw bytes.
|
||||
|
||||
Args:
|
||||
key_input: Base64-encoded string or raw bytes (must be >= 32 bytes when decoded).
|
||||
|
||||
Returns:
|
||||
32 raw bytes suitable for AES-256-GCM.
|
||||
|
||||
Raises:
|
||||
ValueError: if key is too short or not valid base64.
|
||||
"""
|
||||
if isinstance(key_input, bytes):
|
||||
key_bytes = key_input
|
||||
else:
|
||||
key_bytes = base64.b64decode(key_input)
|
||||
|
||||
if len(key_bytes) < 32:
|
||||
raise ValueError(
|
||||
f"SLAB_AES_KEY must be at least 32 bytes base64, got {len(key_bytes)} bytes"
|
||||
)
|
||||
return key_bytes[:32]
|
||||
|
||||
|
||||
def encrypt_setting(key: bytes, plaintext: str) -> bytes:
|
||||
"""Encrypt a plaintext setting value using AES-256-GCM.
|
||||
|
||||
Args:
|
||||
key: 32-byte AES key (decoded from SLAB_AES_KEY).
|
||||
plaintext: The secret value to encrypt (e.g. woo_app_password).
|
||||
|
||||
Returns:
|
||||
Bytes of form nonce(12) || ciphertext. Each call generates a
|
||||
fresh 12-byte nonce.
|
||||
"""
|
||||
import os
|
||||
aesgcm = AESGCM(key)
|
||||
nonce = os.urandom(12)
|
||||
ct = aesgcm.encrypt(nonce, plaintext.encode("utf-8"), None)
|
||||
return nonce + ct
|
||||
|
||||
|
||||
def decrypt_setting(key: bytes, ciphertext: bytes) -> str:
|
||||
"""Decrypt a setting ciphertext back to plaintext.
|
||||
|
||||
Args:
|
||||
key: 32-byte AES key (decoded from SLAB_AES_KEY).
|
||||
ciphertext: Bytes of form nonce(12) || ciphertext from encrypt_setting.
|
||||
|
||||
Returns:
|
||||
Decrypted plaintext string.
|
||||
|
||||
Raises:
|
||||
AuthenticationError: if ciphertext is tampered, truncated,
|
||||
or decrypted with the wrong key.
|
||||
"""
|
||||
if len(ciphertext) < 12:
|
||||
raise AuthenticationError("Ciphertext too short to contain nonce")
|
||||
|
||||
nonce = ciphertext[:12]
|
||||
ct = ciphertext[12:]
|
||||
|
||||
try:
|
||||
aesgcm = AESGCM(key)
|
||||
plaintext = aesgcm.decrypt(nonce, ct, None)
|
||||
except Exception as exc:
|
||||
raise AuthenticationError(
|
||||
f"Decryption failed: integrity check failed"
|
||||
) from exc
|
||||
|
||||
return plaintext.decode("utf-8")
|
||||
@@ -0,0 +1,7 @@
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
pythonpath = ["."]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py313"
|
||||
@@ -0,0 +1,3 @@
|
||||
pytest>=8.3,<9
|
||||
httpx>=0.28,<1
|
||||
respx>=0.22,<1
|
||||
@@ -0,0 +1,11 @@
|
||||
fastapi>=0.115,<1
|
||||
uvicorn[standard]>=0.34,<1
|
||||
sqlalchemy>=2.0,<3
|
||||
aiosqlite>=0.20,<1
|
||||
alembic>=1.14,<2
|
||||
httpx>=0.28,<1
|
||||
pydantic>=2.9,<3
|
||||
pydantic-settings>=2.7,<3
|
||||
python-dotenv>=1.0,<2
|
||||
cryptography>=43.0,<45
|
||||
python-multipart>=0.0.30,<1
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Shared test fixtures for all backend tests.
|
||||
|
||||
App factory pattern (FUND-8): each test gets a fresh app instance.
|
||||
FUND-9: TestClient manages lifespan.
|
||||
FUND-10: dependency_overrides cleared after every test.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from app.config import Settings
|
||||
from app.db import engine
|
||||
from app.main import create_app
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _test_db() -> None:
|
||||
"""Set up a fresh in-memory SQLite database for each test."""
|
||||
engine.set_test_url("sqlite+aiosqlite:///:memory:")
|
||||
yield
|
||||
engine._test_url = None
|
||||
engine._engine = None
|
||||
engine._session_factory = None
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def settings() -> Settings:
|
||||
"""Create test settings with in-memory SQLite."""
|
||||
return Settings(
|
||||
aes_key="dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk", # 32 bytes base64
|
||||
woo_base_url="https://test.example.com",
|
||||
app_hostname="test.local",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app(settings: Settings) -> None:
|
||||
"""Yield a fresh FastAPI app with test settings, then clean up.
|
||||
|
||||
Each test gets its own app instance (FUND-8).
|
||||
TestClient manages the lifespan (FUND-9).
|
||||
dependency_overrides are cleared after each test (FUND-10).
|
||||
"""
|
||||
app_instance = create_app(settings)
|
||||
yield app_instance
|
||||
app_instance.dependency_overrides.clear()
|
||||
@@ -0,0 +1,113 @@
|
||||
"""H-1: Health endpoint contract test.
|
||||
|
||||
Tests via httpx TestClient. Each test gets a fresh app instance (FUND-8).
|
||||
|
||||
H-1: GET /api/health returns 200 {status: ok} with woo_reachable false
|
||||
when credentials are not configured.
|
||||
|
||||
FUND-8: App factory creates fresh instance per test.
|
||||
FUND-9: TestClient context manager manages lifespan.
|
||||
FUND-10: dependency_overrides cleared after every test.
|
||||
"""
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import create_app
|
||||
|
||||
|
||||
class TestHealthEndpoint:
|
||||
"""GET /api/health contract tests."""
|
||||
|
||||
def test_returns_200(self) -> None:
|
||||
"""H-1: Health endpoint returns 200."""
|
||||
from app.config import Settings
|
||||
|
||||
settings = Settings(
|
||||
aes_key="dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk",
|
||||
woo_base_url="https://test.example.com",
|
||||
)
|
||||
app = create_app(settings)
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get("/api/health")
|
||||
assert response.status_code == 200
|
||||
|
||||
client.close()
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
def test_returns_ok_status(self) -> None:
|
||||
"""H-1: Health endpoint returns status ok."""
|
||||
from app.config import Settings
|
||||
|
||||
settings = Settings(
|
||||
aes_key="dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk",
|
||||
woo_base_url="https://test.example.com",
|
||||
)
|
||||
app = create_app(settings)
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get("/api/health")
|
||||
data = response.json()
|
||||
assert data["status"] == "ok"
|
||||
|
||||
client.close()
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
def test_woo_reachable_false_when_credentials_not_configured(self) -> None:
|
||||
"""H-1: woo_reachable is false when Woo credentials not configured."""
|
||||
from app.config import Settings
|
||||
|
||||
settings = Settings(
|
||||
aes_key="dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk",
|
||||
woo_base_url="https://test.example.com",
|
||||
)
|
||||
app = create_app(settings)
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get("/api/health")
|
||||
data = response.json()
|
||||
assert data["woo_reachable"] is False
|
||||
|
||||
client.close()
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
def test_response_shape(self) -> None:
|
||||
"""H-1: Response contains exactly status, version, and woo_reachable."""
|
||||
from app.config import Settings
|
||||
|
||||
settings = Settings(
|
||||
aes_key="dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk",
|
||||
woo_base_url="https://test.example.com",
|
||||
)
|
||||
app = create_app(settings)
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get("/api/health")
|
||||
data = response.json()
|
||||
|
||||
assert "status" in data
|
||||
assert "version" in data
|
||||
assert "woo_reachable" in data
|
||||
assert isinstance(data["status"], str)
|
||||
assert isinstance(data["version"], str)
|
||||
assert isinstance(data["woo_reachable"], bool)
|
||||
|
||||
client.close()
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
def test_with_test_client_lifespan(self) -> None:
|
||||
"""FUND-9: TestClient with app manages lifespan correctly."""
|
||||
from app.config import Settings
|
||||
|
||||
settings = Settings(
|
||||
aes_key="dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk",
|
||||
woo_base_url="https://test.example.com",
|
||||
)
|
||||
app = create_app(settings)
|
||||
|
||||
with TestClient(app) as client:
|
||||
response = client.get("/api/health")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "ok"
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
@@ -0,0 +1,520 @@
|
||||
"""D-1: Draft store stub — POST /api/v1/slabs contract test.
|
||||
|
||||
Tests via httpx TestClient. Each test gets a fresh app instance (FUND-8).
|
||||
|
||||
D-1: POST /api/v1/slabs accepts multipart with data JSON + files + metadata;
|
||||
returns 201 with status: calibrated and server_rev.
|
||||
|
||||
FUND-8: App factory creates fresh instance per test.
|
||||
FUND-9: TestClient context manager manages lifespan.
|
||||
FUND-10: dependency_overrides cleared after every test.
|
||||
"""
|
||||
|
||||
import json
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.config import Settings
|
||||
from app.main import create_app
|
||||
|
||||
|
||||
class TestCreateSlab:
|
||||
"""D-1: POST /api/v1/slabs multipart contract."""
|
||||
|
||||
def test_returns_201(self) -> None:
|
||||
"""D-1: POST /api/v1/slabs returns 201 on valid multipart upload."""
|
||||
settings = Settings(
|
||||
aes_key="dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk",
|
||||
woo_base_url="https://test.example.com",
|
||||
)
|
||||
app = create_app(settings)
|
||||
|
||||
slab_id = str(uuid.uuid4())
|
||||
data_json = json.dumps({
|
||||
"id": slab_id,
|
||||
"sku": "BW-0042",
|
||||
"length_in": 96.0,
|
||||
"thickness_in": 1.5,
|
||||
"sqft": 0.64,
|
||||
"bdft": 0.96,
|
||||
"width_min_in": 14.0,
|
||||
"width_max_in": 22.0,
|
||||
"width_avg_in": 18.2,
|
||||
"species_id": 42,
|
||||
"wood_category_ids": [15, 23],
|
||||
"edge_type_term_id": 8,
|
||||
"figure_term_ids": [3],
|
||||
"grade_term_ids": [7],
|
||||
"thickness_term_id": 12,
|
||||
"moisture_term_id": 5,
|
||||
"fig_tag_ids": [99],
|
||||
"feat_tag_ids": [42],
|
||||
"price_per_bdft": 12.50,
|
||||
"price": 12.00,
|
||||
"price_source": "recommendation",
|
||||
"title": None,
|
||||
"short_title": None,
|
||||
"description": None,
|
||||
"content_source": None,
|
||||
"client_rev": 1,
|
||||
})
|
||||
|
||||
with TestClient(app) as client:
|
||||
response = client.post(
|
||||
"/api/v1/slabs",
|
||||
files=[
|
||||
("files[]", ("topdown.jpg", b"\xff\xd8\xff\xe0", "image/jpeg")),
|
||||
],
|
||||
data={
|
||||
"data": data_json,
|
||||
"meta[]": json.dumps({
|
||||
"kind": "inventory",
|
||||
"role": "topdown",
|
||||
"seq": 1,
|
||||
}),
|
||||
},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
def test_returns_status_calibrated(self) -> None:
|
||||
"""D-1: Response contains status: calibrated."""
|
||||
settings = Settings(
|
||||
aes_key="dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk",
|
||||
woo_base_url="https://test.example.com",
|
||||
)
|
||||
app = create_app(settings)
|
||||
|
||||
slab_id = str(uuid.uuid4())
|
||||
data_json = json.dumps({
|
||||
"id": slab_id,
|
||||
"sku": "BW-0099",
|
||||
"length_in": 96.0,
|
||||
"thickness_in": 1.5,
|
||||
"sqft": 0.64,
|
||||
"bdft": 0.96,
|
||||
"width_min_in": 14.0,
|
||||
"width_max_in": 22.0,
|
||||
"width_avg_in": 18.2,
|
||||
"species_id": 42,
|
||||
"wood_category_ids": [15, 23],
|
||||
"edge_type_term_id": 8,
|
||||
"figure_term_ids": [3],
|
||||
"grade_term_ids": [7],
|
||||
"thickness_term_id": 12,
|
||||
"moisture_term_id": 5,
|
||||
"fig_tag_ids": [99],
|
||||
"feat_tag_ids": [42],
|
||||
"price_per_bdft": 12.50,
|
||||
"price": 12.00,
|
||||
"price_source": "recommendation",
|
||||
"title": None,
|
||||
"short_title": None,
|
||||
"description": None,
|
||||
"content_source": None,
|
||||
"client_rev": 1,
|
||||
})
|
||||
|
||||
with TestClient(app) as client:
|
||||
response = client.post(
|
||||
"/api/v1/slabs",
|
||||
files=[
|
||||
("files[]", ("topdown.jpg", b"\xff\xd8\xff\xe0", "image/jpeg")),
|
||||
],
|
||||
data={
|
||||
"data": data_json,
|
||||
"meta[]": json.dumps({
|
||||
"kind": "inventory",
|
||||
"role": "topdown",
|
||||
"seq": 1,
|
||||
}),
|
||||
},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
body = response.json()
|
||||
assert body["status"] == "calibrated"
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
def test_returns_server_rev(self) -> None:
|
||||
"""D-1: Response contains server_rev."""
|
||||
settings = Settings(
|
||||
aes_key="dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk",
|
||||
woo_base_url="https://test.example.com",
|
||||
)
|
||||
app = create_app(settings)
|
||||
|
||||
slab_id = str(uuid.uuid4())
|
||||
data_json = json.dumps({
|
||||
"id": slab_id,
|
||||
"sku": "BW-0077",
|
||||
"length_in": 96.0,
|
||||
"thickness_in": 1.5,
|
||||
"sqft": 0.64,
|
||||
"bdft": 0.96,
|
||||
"width_min_in": 14.0,
|
||||
"width_max_in": 22.0,
|
||||
"width_avg_in": 18.2,
|
||||
"species_id": 42,
|
||||
"wood_category_ids": [15, 23],
|
||||
"edge_type_term_id": 8,
|
||||
"figure_term_ids": [3],
|
||||
"grade_term_ids": [7],
|
||||
"thickness_term_id": 12,
|
||||
"moisture_term_id": 5,
|
||||
"fig_tag_ids": [99],
|
||||
"feat_tag_ids": [42],
|
||||
"price_per_bdft": 12.50,
|
||||
"price": 12.00,
|
||||
"price_source": "recommendation",
|
||||
"title": None,
|
||||
"short_title": None,
|
||||
"description": None,
|
||||
"content_source": None,
|
||||
"client_rev": 1,
|
||||
})
|
||||
|
||||
with TestClient(app) as client:
|
||||
response = client.post(
|
||||
"/api/v1/slabs",
|
||||
files=[
|
||||
("files[]", ("topdown.jpg", b"\xff\xd8\xff\xe0", "image/jpeg")),
|
||||
],
|
||||
data={
|
||||
"data": data_json,
|
||||
"meta[]": json.dumps({
|
||||
"kind": "inventory",
|
||||
"role": "topdown",
|
||||
"seq": 1,
|
||||
}),
|
||||
},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
body = response.json()
|
||||
assert "server_rev" in body
|
||||
assert body["server_rev"] == 1
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
def test_response_shape(self) -> None:
|
||||
"""D-1: Response contains required fields."""
|
||||
settings = Settings(
|
||||
aes_key="dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk",
|
||||
woo_base_url="https://test.example.com",
|
||||
)
|
||||
app = create_app(settings)
|
||||
|
||||
slab_id = str(uuid.uuid4())
|
||||
data_json = json.dumps({
|
||||
"id": slab_id,
|
||||
"sku": "BW-0033",
|
||||
"length_in": 96.0,
|
||||
"thickness_in": 1.5,
|
||||
"sqft": 0.64,
|
||||
"bdft": 0.96,
|
||||
"width_min_in": 14.0,
|
||||
"width_max_in": 22.0,
|
||||
"width_avg_in": 18.2,
|
||||
"species_id": 42,
|
||||
"wood_category_ids": [15, 23],
|
||||
"edge_type_term_id": 8,
|
||||
"figure_term_ids": [3],
|
||||
"grade_term_ids": [7],
|
||||
"thickness_term_id": 12,
|
||||
"moisture_term_id": 5,
|
||||
"fig_tag_ids": [99],
|
||||
"feat_tag_ids": [42],
|
||||
"price_per_bdft": 12.50,
|
||||
"price": 12.00,
|
||||
"price_source": "recommendation",
|
||||
"title": None,
|
||||
"short_title": None,
|
||||
"description": None,
|
||||
"content_source": None,
|
||||
"client_rev": 1,
|
||||
})
|
||||
|
||||
with TestClient(app) as client:
|
||||
response = client.post(
|
||||
"/api/v1/slabs",
|
||||
files=[
|
||||
("files[]", ("topdown.jpg", b"\xff\xd8\xff\xe0", "image/jpeg")),
|
||||
],
|
||||
data={
|
||||
"data": data_json,
|
||||
"meta[]": json.dumps({
|
||||
"kind": "inventory",
|
||||
"role": "topdown",
|
||||
"seq": 1,
|
||||
}),
|
||||
},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
body = response.json()
|
||||
|
||||
# Required fields per OPENAPI.md Slab read model
|
||||
required_fields = [
|
||||
"id", "sku", "status", "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", "grade_term_ids", "thickness_term_id",
|
||||
"moisture_term_id", "fig_tag_ids", "feat_tag_ids",
|
||||
"price_per_bdft", "price", "price_source", "title",
|
||||
"short_title", "description", "content_source",
|
||||
"client_rev", "server_rev", "created_at", "updated_at",
|
||||
"photos", "inference_status",
|
||||
]
|
||||
for field in required_fields:
|
||||
assert field in body, f"Missing field: {field}"
|
||||
|
||||
# Verify stored values match sent values (server stores as-is)
|
||||
assert body["id"] == slab_id
|
||||
assert body["sku"] == "BW-0033"
|
||||
assert body["sqft"] == 0.64
|
||||
assert body["bdft"] == 0.96
|
||||
assert body["width_min_in"] == 14.0
|
||||
assert body["width_max_in"] == 22.0
|
||||
assert body["width_avg_in"] == 18.2
|
||||
assert body["price"] == 12.00
|
||||
assert body["price_source"] == "recommendation"
|
||||
assert body["species_id"] == 42
|
||||
assert body["wood_category_ids"] == [15, 23]
|
||||
assert body["edge_type_term_id"] == 8
|
||||
assert body["figure_term_ids"] == [3]
|
||||
assert body["grade_term_ids"] == [7]
|
||||
assert body["thickness_term_id"] == 12
|
||||
assert body["moisture_term_id"] == 5
|
||||
assert body["fig_tag_ids"] == [99]
|
||||
assert body["feat_tag_ids"] == [42]
|
||||
assert body["price_per_bdft"] == 12.50
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
def test_multiple_photos(self) -> None:
|
||||
"""D-1: Multiple files[] and meta[] pairs are accepted."""
|
||||
settings = Settings(
|
||||
aes_key="dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk",
|
||||
woo_base_url="https://test.example.com",
|
||||
)
|
||||
app = create_app(settings)
|
||||
|
||||
slab_id = str(uuid.uuid4())
|
||||
data_json = json.dumps({
|
||||
"id": slab_id,
|
||||
"sku": "BW-0055",
|
||||
"length_in": 96.0,
|
||||
"thickness_in": 1.5,
|
||||
"sqft": 0.64,
|
||||
"bdft": 0.96,
|
||||
"width_min_in": 14.0,
|
||||
"width_max_in": 22.0,
|
||||
"width_avg_in": 18.2,
|
||||
"species_id": 42,
|
||||
"wood_category_ids": [15, 23],
|
||||
"edge_type_term_id": 8,
|
||||
"figure_term_ids": [3],
|
||||
"grade_term_ids": [7],
|
||||
"thickness_term_id": 12,
|
||||
"moisture_term_id": 5,
|
||||
"fig_tag_ids": [99],
|
||||
"feat_tag_ids": [42],
|
||||
"price_per_bdft": 12.50,
|
||||
"price": 12.00,
|
||||
"price_source": "recommendation",
|
||||
"title": None,
|
||||
"short_title": None,
|
||||
"description": None,
|
||||
"content_source": None,
|
||||
"client_rev": 1,
|
||||
})
|
||||
|
||||
with TestClient(app) as client:
|
||||
response = client.post(
|
||||
"/api/v1/slabs",
|
||||
files=[
|
||||
("files[]", ("topdown.jpg", b"\xff\xd8\xff\xe0", "image/jpeg")),
|
||||
("files[]", ("extra1.jpg", b"\xff\xd8\xff\xe0", "image/jpeg")),
|
||||
],
|
||||
data={
|
||||
"data": data_json,
|
||||
"meta[]": json.dumps({
|
||||
"kind": "inventory",
|
||||
"role": "topdown",
|
||||
"seq": 1,
|
||||
}),
|
||||
},
|
||||
)
|
||||
# Note: httpx TestClient with multiple files[] + single meta[]
|
||||
# will pair them. The meta[] for the second file defaults.
|
||||
assert response.status_code == 201
|
||||
body = response.json()
|
||||
assert len(body["photos"]) >= 2
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
def test_missing_data_field_returns_400(self) -> None:
|
||||
"""D-1: Missing data field returns 400."""
|
||||
settings = Settings(
|
||||
aes_key="dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk",
|
||||
woo_base_url="https://test.example.com",
|
||||
)
|
||||
app = create_app(settings)
|
||||
|
||||
with TestClient(app) as client:
|
||||
response = client.post(
|
||||
"/api/v1/slabs",
|
||||
files=[
|
||||
("files[]", ("topdown.jpg", b"\xff\xd8\xff\xe0", "image/jpeg")),
|
||||
],
|
||||
data={
|
||||
"meta[]": json.dumps({
|
||||
"kind": "inventory",
|
||||
"role": "topdown",
|
||||
"seq": 1,
|
||||
}),
|
||||
},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
body = response.json()
|
||||
assert body["detail"]["code"] == "validation"
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
def test_duplicate_sku_returns_409(self) -> None:
|
||||
"""D-1: Duplicate SKU returns 409."""
|
||||
settings = Settings(
|
||||
aes_key="dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk",
|
||||
woo_base_url="https://test.example.com",
|
||||
)
|
||||
app = create_app(settings)
|
||||
|
||||
slab_id = str(uuid.uuid4())
|
||||
sku = "DUPLICATE-TEST"
|
||||
data_json = json.dumps({
|
||||
"id": slab_id,
|
||||
"sku": sku,
|
||||
"length_in": 96.0,
|
||||
"thickness_in": 1.5,
|
||||
"sqft": 0.64,
|
||||
"bdft": 0.96,
|
||||
"width_min_in": 14.0,
|
||||
"width_max_in": 22.0,
|
||||
"width_avg_in": 18.2,
|
||||
"species_id": 42,
|
||||
"wood_category_ids": [15, 23],
|
||||
"edge_type_term_id": 8,
|
||||
"figure_term_ids": [3],
|
||||
"grade_term_ids": [7],
|
||||
"thickness_term_id": 12,
|
||||
"moisture_term_id": 5,
|
||||
"fig_tag_ids": [99],
|
||||
"feat_tag_ids": [42],
|
||||
"price_per_bdft": 12.50,
|
||||
"price": 12.00,
|
||||
"price_source": "recommendation",
|
||||
"title": None,
|
||||
"short_title": None,
|
||||
"description": None,
|
||||
"content_source": None,
|
||||
"client_rev": 1,
|
||||
})
|
||||
|
||||
with TestClient(app) as client:
|
||||
# Create first slab
|
||||
response1 = client.post(
|
||||
"/api/v1/slabs",
|
||||
files=[
|
||||
("files[]", ("topdown.jpg", b"\xff\xd8\xff\xe0", "image/jpeg")),
|
||||
],
|
||||
data={
|
||||
"data": data_json,
|
||||
"meta[]": json.dumps({
|
||||
"kind": "inventory",
|
||||
"role": "topdown",
|
||||
"seq": 1,
|
||||
}),
|
||||
},
|
||||
)
|
||||
assert response1.status_code == 201
|
||||
|
||||
# Try to create second slab with same SKU
|
||||
response2 = client.post(
|
||||
"/api/v1/slabs",
|
||||
files=[
|
||||
("files[]", ("topdown.jpg", b"\xff\xd8\xff\xe0", "image/jpeg")),
|
||||
],
|
||||
data={
|
||||
"data": data_json,
|
||||
"meta[]": json.dumps({
|
||||
"kind": "inventory",
|
||||
"role": "topdown",
|
||||
"seq": 1,
|
||||
}),
|
||||
},
|
||||
)
|
||||
assert response2.status_code == 409
|
||||
body = response2.json()
|
||||
assert body["detail"]["code"] == "duplicate_sku"
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
def test_with_test_client_lifespan(self) -> None:
|
||||
"""FUND-9: TestClient with app manages lifespan correctly for slabs."""
|
||||
settings = Settings(
|
||||
aes_key="dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk",
|
||||
woo_base_url="https://test.example.com",
|
||||
)
|
||||
app = create_app(settings)
|
||||
|
||||
slab_id = str(uuid.uuid4())
|
||||
data_json = json.dumps({
|
||||
"id": slab_id,
|
||||
"sku": "BW-0011",
|
||||
"length_in": 96.0,
|
||||
"thickness_in": 1.5,
|
||||
"sqft": 0.64,
|
||||
"bdft": 0.96,
|
||||
"width_min_in": 14.0,
|
||||
"width_max_in": 22.0,
|
||||
"width_avg_in": 18.2,
|
||||
"species_id": 42,
|
||||
"wood_category_ids": [15, 23],
|
||||
"edge_type_term_id": 8,
|
||||
"figure_term_ids": [3],
|
||||
"grade_term_ids": [7],
|
||||
"thickness_term_id": 12,
|
||||
"moisture_term_id": 5,
|
||||
"fig_tag_ids": [99],
|
||||
"feat_tag_ids": [42],
|
||||
"price_per_bdft": 12.50,
|
||||
"price": 12.00,
|
||||
"price_source": "recommendation",
|
||||
"title": None,
|
||||
"short_title": None,
|
||||
"description": None,
|
||||
"content_source": None,
|
||||
"client_rev": 1,
|
||||
})
|
||||
|
||||
with TestClient(app) as client:
|
||||
response = client.post(
|
||||
"/api/v1/slabs",
|
||||
files=[
|
||||
("files[]", ("topdown.jpg", b"\xff\xd8\xff\xe0", "image/jpeg")),
|
||||
],
|
||||
data={
|
||||
"data": data_json,
|
||||
"meta[]": json.dumps({
|
||||
"kind": "inventory",
|
||||
"role": "topdown",
|
||||
"seq": 1,
|
||||
}),
|
||||
},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
assert response.json()["status"] == "calibrated"
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
@@ -0,0 +1,140 @@
|
||||
"""GC-4/GC-5: WOO_BASE_URL contract tests.
|
||||
|
||||
GC-4: WOO_BASE_URL is compose-only HTTPS; Settings GET returns it read-only;
|
||||
PUT rejects write attempts with 422.
|
||||
GC-5: FastAPI refuses to start when WOO_BASE_URL is missing or http://.
|
||||
|
||||
FUND-8: App factory creates fresh instance per test.
|
||||
FUND-9: TestClient context manager manages lifespan.
|
||||
FUND-10: dependency_overrides cleared after every test.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.config import Settings
|
||||
from app.main import create_app
|
||||
|
||||
|
||||
class TestWooBaseUrlValidation:
|
||||
"""GC-5: FastAPI refuses invalid WOO_BASE_URL at startup."""
|
||||
|
||||
def test_refuses_http_url(self) -> None:
|
||||
"""GC-5: Settings.validate() raises ValueError for http:// URL."""
|
||||
settings = Settings(
|
||||
aes_key="dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk",
|
||||
woo_base_url="http://insecure.example.com",
|
||||
)
|
||||
with pytest.raises(ValueError, match="(?i)https"):
|
||||
settings.validate()
|
||||
|
||||
def test_refuses_missing_url(self) -> None:
|
||||
"""GC-5: Settings.validate() raises ValueError when woo_base_url is None."""
|
||||
settings = Settings(
|
||||
aes_key="dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk",
|
||||
)
|
||||
with pytest.raises(ValueError):
|
||||
settings.validate()
|
||||
|
||||
def test_accepts_https_url(self) -> None:
|
||||
"""GC-5: Settings.validate() accepts HTTPS URL."""
|
||||
settings = Settings(
|
||||
aes_key="dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk",
|
||||
woo_base_url="https://secure.example.com",
|
||||
)
|
||||
settings.validate() # Should not raise
|
||||
|
||||
|
||||
class TestWooBaseUrlReadOnly:
|
||||
"""GC-4: Settings endpoints handle woo_base_url correctly."""
|
||||
|
||||
def test_get_settings_returns_woo_base_url(self) -> None:
|
||||
"""GC-4: GET /api/v1/settings returns woo_base_url from compose env."""
|
||||
settings = Settings(
|
||||
aes_key="dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk",
|
||||
woo_base_url="https://store.example.com",
|
||||
)
|
||||
app = create_app(settings)
|
||||
|
||||
with TestClient(app) as client:
|
||||
response = client.get("/api/v1/settings")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["woo_base_url"] == "https://store.example.com"
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
def test_put_settings_rejects_woo_base_url(self) -> None:
|
||||
"""GC-4: PUT /api/v1/settings rejects woo_base_url field with 422."""
|
||||
settings = Settings(
|
||||
aes_key="dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk",
|
||||
woo_base_url="https://store.example.com",
|
||||
)
|
||||
app = create_app(settings)
|
||||
|
||||
with TestClient(app) as client:
|
||||
response = client.put(
|
||||
"/api/v1/settings",
|
||||
json={"woo_base_url": "https://hacked.example.com"},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
data = response.json()
|
||||
assert "detail" in data
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
def test_put_settings_allows_other_fields(self) -> None:
|
||||
"""GC-4: PUT /api/v1/settings still accepts other settings fields."""
|
||||
settings = Settings(
|
||||
aes_key="dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk",
|
||||
woo_base_url="https://store.example.com",
|
||||
)
|
||||
app = create_app(settings)
|
||||
|
||||
with TestClient(app) as client:
|
||||
response = client.put(
|
||||
"/api/v1/settings",
|
||||
json={"woo_create_status": "publish"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["woo_create_status"] == "publish"
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
def test_get_settings_never_returns_app_password(self) -> None:
|
||||
"""GC-4: GET /api/v1/settings never returns woo_app_password."""
|
||||
settings = Settings(
|
||||
aes_key="dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk",
|
||||
woo_base_url="https://store.example.com",
|
||||
)
|
||||
app = create_app(settings)
|
||||
|
||||
with TestClient(app) as client:
|
||||
response = client.get("/api/v1/settings")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "woo_app_password" not in data
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
def test_put_settings_rejects_consumer_keys(self) -> None:
|
||||
"""GC-4: PUT /api/v1/settings rejects consumer key fields with 422."""
|
||||
settings = Settings(
|
||||
aes_key="dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk",
|
||||
woo_base_url="https://store.example.com",
|
||||
)
|
||||
app = create_app(settings)
|
||||
|
||||
with TestClient(app) as client:
|
||||
response = client.put(
|
||||
"/api/v1/settings",
|
||||
json={
|
||||
"woo_consumer_key": "ck_test",
|
||||
"woo_consumer_secret": "cs_test",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
@@ -0,0 +1,196 @@
|
||||
"""AES-GCM settings encryption unit tests.
|
||||
|
||||
AES-1: Encrypt then decrypt round-trips for woo_wp_username and woo_app_password;
|
||||
ciphertext never appears in GET settings.
|
||||
AES-2: Missing SLAB_AES_KEY environment variable at startup: app refuses to start;
|
||||
no fallback to plaintext.
|
||||
|
||||
FUND-17: Fresh nonce per encrypt; tampered ciphertext raises authentication failure.
|
||||
FUND-18: Ciphertext never appears in any GET response.
|
||||
FUND-19: Missing SLAB_AES_KEY refuses startup; no fallback to plaintext storage.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from app.security.aes import (
|
||||
AuthenticationError,
|
||||
decrypt_setting,
|
||||
encrypt_setting,
|
||||
get_aes_key,
|
||||
)
|
||||
|
||||
|
||||
class TestAESRoundTrip:
|
||||
"""AES-1: Encrypt then decrypt round-trip for woo_wp_username and woo_app_password."""
|
||||
|
||||
@pytest.fixture
|
||||
def aes_key_bytes(self) -> bytes:
|
||||
"""Test AES key (32 bytes)."""
|
||||
return get_aes_key("dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk")
|
||||
|
||||
def test_roundtrip_username(self, aes_key_bytes: bytes) -> None:
|
||||
"""AES-1: Encrypt and decrypt woo_wp_username round-trips."""
|
||||
plaintext = "slab-uploader"
|
||||
ciphertext = encrypt_setting(aes_key_bytes, plaintext)
|
||||
decrypted = decrypt_setting(aes_key_bytes, ciphertext)
|
||||
assert decrypted == plaintext
|
||||
|
||||
def test_roundtrip_password(self, aes_key_bytes: bytes) -> None:
|
||||
"""AES-1: Encrypt and decrypt woo_app_password round-trips."""
|
||||
plaintext = "W00rdP@ssw0rd!2026"
|
||||
ciphertext = encrypt_setting(aes_key_bytes, plaintext)
|
||||
decrypted = decrypt_setting(aes_key_bytes, ciphertext)
|
||||
assert decrypted == plaintext
|
||||
|
||||
def test_roundtrip_empty_string(self, aes_key_bytes: bytes) -> None:
|
||||
"""AES-1: Empty string encrypts and decrypts correctly."""
|
||||
plaintext = ""
|
||||
ciphertext = encrypt_setting(aes_key_bytes, plaintext)
|
||||
decrypted = decrypt_setting(aes_key_bytes, ciphertext)
|
||||
assert decrypted == plaintext
|
||||
|
||||
def test_roundtrip_unicode(self, aes_key_bytes: bytes) -> None:
|
||||
"""AES-1: Unicode plaintext round-trips."""
|
||||
plaintext = "тест-ключ-пароль"
|
||||
ciphertext = encrypt_setting(aes_key_bytes, plaintext)
|
||||
decrypted = decrypt_setting(aes_key_bytes, ciphertext)
|
||||
assert decrypted == plaintext
|
||||
|
||||
def test_roundtrip_special_chars(self, aes_key_bytes: bytes) -> None:
|
||||
"""AES-1: Special characters in plaintext round-trip."""
|
||||
plaintext = '!@#$%^&*()_+-={}[]|:;<>?/~`'
|
||||
ciphertext = encrypt_setting(aes_key_bytes, plaintext)
|
||||
decrypted = decrypt_setting(aes_key_bytes, ciphertext)
|
||||
assert decrypted == plaintext
|
||||
|
||||
def test_ciphertext_is_bytes(self, aes_key_bytes: bytes) -> None:
|
||||
"""AES-1: encrypt_setting returns bytes."""
|
||||
plaintext = "test-password"
|
||||
ciphertext = encrypt_setting(aes_key_bytes, plaintext)
|
||||
assert isinstance(ciphertext, bytes)
|
||||
|
||||
def test_ciphertext_starts_with_nonce(self, aes_key_bytes: bytes) -> None:
|
||||
"""AES-1: Ciphertext starts with 12-byte nonce."""
|
||||
plaintext = "test-password"
|
||||
ciphertext = encrypt_setting(aes_key_bytes, plaintext)
|
||||
assert len(ciphertext) >= 12
|
||||
nonce = ciphertext[:12]
|
||||
assert len(nonce) == 12
|
||||
|
||||
def test_ciphertext_different_from_plaintext(self, aes_key_bytes: bytes) -> None:
|
||||
"""AES-1: Ciphertext is not the plaintext."""
|
||||
plaintext = "secret"
|
||||
ciphertext = encrypt_setting(aes_key_bytes, plaintext)
|
||||
assert ciphertext != plaintext.encode("utf-8")
|
||||
|
||||
def test_ciphertext_changes_each_call(self, aes_key_bytes: bytes) -> None:
|
||||
"""AES-1: Ciphertext changes on each encrypt call (different nonce+IV)."""
|
||||
plaintext = "same-plaintext"
|
||||
ct1 = encrypt_setting(aes_key_bytes, plaintext)
|
||||
ct2 = encrypt_setting(aes_key_bytes, plaintext)
|
||||
assert ct1 != ct2
|
||||
|
||||
|
||||
class TestFreshNonce:
|
||||
"""FUND-17: Fresh nonce per encrypt; nonces differ between calls."""
|
||||
|
||||
@pytest.fixture
|
||||
def aes_key_bytes(self) -> bytes:
|
||||
return get_aes_key("dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk")
|
||||
|
||||
def test_nonce_differs_across_encryptions(self, aes_key_bytes: bytes) -> None:
|
||||
"""FUND-17: Encrypting the same plaintext twice produces different nonces."""
|
||||
plaintext = "repeated-plaintext"
|
||||
ct1 = encrypt_setting(aes_key_bytes, plaintext)
|
||||
ct2 = encrypt_setting(aes_key_bytes, plaintext)
|
||||
nonce1 = ct1[:12]
|
||||
nonce2 = ct2[:12]
|
||||
assert nonce1 != nonce2
|
||||
|
||||
def test_multiple_encryptions_all_unique_nonces(self, aes_key_bytes: bytes) -> None:
|
||||
"""FUND-17: Multiple encryptions all have unique nonces."""
|
||||
nonces = []
|
||||
for _ in range(5):
|
||||
ct = encrypt_setting(aes_key_bytes, "test")
|
||||
nonces.append(ct[:12])
|
||||
assert len(set(nonces)) == 5
|
||||
|
||||
|
||||
class TestTamperedCiphertext:
|
||||
"""FUND-17: Tampered ciphertext raises authentication failure."""
|
||||
|
||||
@pytest.fixture
|
||||
def aes_key_bytes(self) -> bytes:
|
||||
return get_aes_key("dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk")
|
||||
|
||||
def test_tampered_body_raises_authentication_error(self, aes_key_bytes: bytes) -> None:
|
||||
"""FUND-17: Tampering with ciphertext body raises AuthenticationError."""
|
||||
plaintext = "tamper-me"
|
||||
ciphertext = encrypt_setting(aes_key_bytes, plaintext)
|
||||
tampered = bytearray(ciphertext)
|
||||
# Flip a byte in the ciphertext portion (after nonce)
|
||||
if len(tampered) > 12:
|
||||
tampered[12] ^= 0xFF
|
||||
else:
|
||||
tampered[0] ^= 0xFF
|
||||
tampered_ct = bytes(tampered)
|
||||
|
||||
with pytest.raises(AuthenticationError):
|
||||
decrypt_setting(aes_key_bytes, tampered_ct)
|
||||
|
||||
def test_truncated_ciphertext_raises_error(self, aes_key_bytes: bytes) -> None:
|
||||
"""FUND-17: Truncated ciphertext raises authentication error."""
|
||||
plaintext = "truncate-me"
|
||||
ciphertext = encrypt_setting(aes_key_bytes, plaintext)
|
||||
truncated = ciphertext[:-4]
|
||||
|
||||
with pytest.raises(AuthenticationError):
|
||||
decrypt_setting(aes_key_bytes, truncated)
|
||||
|
||||
def test_wrong_key_raises_error(self, aes_key_bytes: bytes) -> None:
|
||||
"""FUND-17: Decrypting with wrong key raises authentication error."""
|
||||
wrong_key = get_aes_key("d3JvbmdrZXlmb3J0ZXN0aW5nc3lzdGVtY2hldmkgdGhpc2tleWlzYmFk")
|
||||
plaintext = "wrong-key-test"
|
||||
ciphertext = encrypt_setting(aes_key_bytes, plaintext)
|
||||
|
||||
with pytest.raises(AuthenticationError):
|
||||
decrypt_setting(wrong_key, ciphertext)
|
||||
|
||||
|
||||
class TestMissingKey:
|
||||
"""AES-2: Missing SLAB_AES_KEY environment variable at startup."""
|
||||
|
||||
def test_missing_key_refuses_startup(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""AES-2: App refuses to start when SLAB_AES_KEY is missing."""
|
||||
monkeypatch.delenv("SLAB_AES_KEY", raising=False)
|
||||
from app.config import Settings
|
||||
settings = Settings()
|
||||
with pytest.raises(ValueError, match="SLAB_AES_KEY"):
|
||||
settings.validate()
|
||||
|
||||
def test_too_short_key_refuses_startup(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""AES-2: Too-short key refuses startup."""
|
||||
monkeypatch.setenv("SLAB_AES_KEY", "d290") # "wot" = too short
|
||||
from app.config import Settings
|
||||
settings = Settings()
|
||||
with pytest.raises(ValueError, match="at least 32 bytes"):
|
||||
settings.validate()
|
||||
|
||||
def test_get_aes_key_returns_bytes(self) -> None:
|
||||
"""AES-2: get_aes_key returns bytes from base64 env var."""
|
||||
settings = get_aes_key("dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk")
|
||||
assert isinstance(settings, bytes)
|
||||
assert len(settings) == 32
|
||||
|
||||
|
||||
class TestNoPlaintextFallback:
|
||||
"""AES-2: No fallback to plaintext storage."""
|
||||
|
||||
def test_decrypt_returns_decrypted_value(self) -> None:
|
||||
"""AES-2: No plaintext fallback; decrypt returns decrypted value, not raw storage."""
|
||||
key = get_aes_key("dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk")
|
||||
plaintext = "test-password"
|
||||
ct = encrypt_setting(key, plaintext)
|
||||
result = decrypt_setting(key, ct)
|
||||
# Should return the original plaintext, not some fallback
|
||||
assert result == plaintext
|
||||
+13
-8
@@ -1,11 +1,16 @@
|
||||
# Placeholders only. Copy to deploy/.env (chmod 600). Never commit real values.
|
||||
# Generate SLAB_AES_KEY: openssl rand -base64 32
|
||||
# SlabUploader deploy environment — placeholders only.
|
||||
# Copy to deploy/.env and fill real values. Do not commit real values.
|
||||
# chmod 600 deploy/.env
|
||||
|
||||
SLAB_AES_KEY=
|
||||
APP_HOSTNAME=slab.example.local
|
||||
# AES-GCM master key: 32 bytes base64. Generated via: openssl rand -base64 32
|
||||
SLAB_AES_KEY=CHANGE_ME_32_BYTE_BASE64_KEY
|
||||
|
||||
# WooCommerce store URL (HTTPS only). Deploy-time SoT, not editable in app.
|
||||
WOO_BASE_URL=https://your-store.example.com
|
||||
|
||||
# Hostname for Caddy TLS (LAN/Tailscale). Used in Caddyfile.fragment.
|
||||
APP_HOSTNAME=slab.local
|
||||
|
||||
# Bind ports (loopback only — Caddy is the TLS edge).
|
||||
FRONTEND_PORT=18080
|
||||
FASTAPI_PORT=18081
|
||||
|
||||
# Plan of record: Woo store URL. HTTPS only. Not editable in Settings.
|
||||
# Server must reject http:// at startup.
|
||||
WOO_BASE_URL=https://www.whidbeywoodstore.com
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
# Interim HTTP-only Caddy config for Gate B UAT.
|
||||
# Serves frontend + API over plain HTTP on all hostnames/IPs.
|
||||
# No TLS, no HTTPS redirect. Phone CA trust is not required.
|
||||
#
|
||||
# Post-POC: restore slab.local with proper TLS and ACME.
|
||||
|
||||
:80 {
|
||||
handle /api/* {
|
||||
reverse_proxy fastapi:8000
|
||||
}
|
||||
handle {
|
||||
reverse_proxy frontend:80 {
|
||||
header_up X-Real-IP {remote_host}
|
||||
}
|
||||
}
|
||||
}
|
||||
+63
-26
@@ -1,35 +1,72 @@
|
||||
# Host
|
||||
# Deploy inventory worksheet
|
||||
|
||||
## Overnight Capture UAT (primary — when Tailscale is up)
|
||||
Fill this at **Gate B** on the designated host before any port bind or Caddy merge.
|
||||
The host must be inventoried before any deployment. See `docs/DEPLOYMENT.md`.
|
||||
|
||||
- Host: `ubudual5060` / Tailscale `100.64.0.9`
|
||||
- Container: `slabuploader-m2-overnight` (image `slabuploader-m2:overnight`) — or salvage rebuild of the same role
|
||||
- Port: **10881** → 80
|
||||
- URL: `http://100.64.0.9:10881`
|
||||
- Replaces prior overnight container `slabuploader-m1-overnight` (same port).
|
||||
## Host
|
||||
|
||||
## Fallback UAT — Grok Bot computer (when Tailscale to 5060 is down)
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Inventory date | 2026-09-06 PT |
|
||||
| Operator | Ty / Main Cut (SSH from cursor Tailscale node) |
|
||||
| Machine name / role | ubuDual5060 — practice / UAT app host (locked) |
|
||||
| `APP_HOST` (LAN or Tailscale IP/name used in DNS) | Tailscale preferred: `100.64.0.9` (`ubudual5060`). LAN also: `192.168.1.202` |
|
||||
| `APP_HOSTNAME` (HTTPS site name, e.g. `slab.example.local`) | `slab.local` (LAN-first; Pi-hole or hosts → `192.168.1.202`) |
|
||||
| OS / Docker version | Ubuntu 26.04.1 LTS; Docker 29.8.0; Compose v5.5.1 |
|
||||
| Notes | SSH as `ty` from Grok Bot (`cursor` Tailscale) with key `grokbot-cursor@ubudual5060-slabuploader`. User `ty` is in `docker` group. Disk ~15G free of 172G (92% used) — watch volume growth. Gitea is code SoT only; runtime is this host. |
|
||||
| `WOO_BASE_URL` (`https://` store; not editable in app) | `https://www.whidbeywoodstore.com` (practice drafts via `SLAB-UAT-*`) |
|
||||
|
||||
Use this path for salvage / Capture UAT until the box can reach `100.64.0.9` again.
|
||||
## Ports and collisions
|
||||
|
||||
- Host: Grok Bot computer (slab-m1 box)
|
||||
- Serve: static Capture build (`frontend` → `npm run build`, then `vite preview` or equivalent static server)
|
||||
- Port: **18181** (chosen so it never collides with Ty local on 5060)
|
||||
- Bind: `0.0.0.0:18181`
|
||||
- URL (from this box): `http://127.0.0.1:18181` — Ty reaches it via the bot computer / whatever path the app exposes; Tailscale IP of this box when mesh returns
|
||||
- Branch for salvage: `salvage-one-woo-draft` (cut from `m2-ds`)
|
||||
- Do **not** require Docker on the box for this fallback.
|
||||
| Check | Result |
|
||||
|---|---|
|
||||
| `docker ps` summary (names + published ports) | Running: `whisperx-stt` `0.0.0.0:9000->9000`. Host also has `llama-server` on `0.0.0.0:8082`. Firecrawl stack present but exited. |
|
||||
| Free HTTP port for frontend bind (`FRONTEND_PORT`, default 18080) | **18080 free** (not in `ss` listen list) |
|
||||
| Free port for fastapi bind (`FASTAPI_PORT`, default 18081) | **18081 free** |
|
||||
| Caddy public HTTPS port in use by other sites | No host `caddy.service`. No Caddy container found. HTTPS front still to be added. |
|
||||
| Co-resident apps that own ports (example: bolt.diy on 8443-class) | 9000 (whisperx-stt), 8082 (llama-server), 22 (sshd). Avoid those. |
|
||||
|
||||
When Tailscale is restored, prefer primary overnight `:10881` again and stop advertising the fallback as the main UAT URL.
|
||||
## Caddy
|
||||
|
||||
## Ty local Node/SvelteKit on 5060 (do not overwrite)
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Caddy config path (host file or container) | `/home/ty/SlabUploader/deploy/Caddyfile` mounted to `/etc/caddy/Caddyfile` in container |
|
||||
| Existing site blocks that share this host | none found |
|
||||
| TLS mode (`internal` CA vs other) | Self-signed RSA 2048 cert (`deploy/certs/slab.crt`), TLS 1.2 + 1.3. **TBD**: HTTPS handshake fails on host due to Go/OpenSSL 3.5.5 TLS compatibility issue — HTTP used as interim. |
|
||||
| Phone CA trust verified? (yes/no + how) | TBD (HTTPS path blocked by TLS compat issue) |
|
||||
|
||||
- Host: `ubudual5060` / `100.64.0.9`
|
||||
- Container: `slabuploader-m1`
|
||||
- Port: **18080** → 80
|
||||
- URL: `http://100.64.0.9:18080`
|
||||
- **Never** recreate or remap this container for overnight or box-fallback UAT.
|
||||
- **Never** bind overnight or fallback UAT to **10880** either (legacy Ty local).
|
||||
## DNS / reachability
|
||||
|
||||
Future overnight Capture deploys use a **separate** container name and an open **108XX** port other than Ty’s local ports.
|
||||
Do not put a host name in the product spec.
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| DNS source (Pi-hole / AdGuard / MagicDNS / hosts) | LAN-first: Pi-hole or hosts for `slab.local` → `192.168.1.202`. Tailscale MagicDNS remains for SSH (`ubudual5060`). |
|
||||
| A record: `APP_HOSTNAME` → `APP_HOST` | `slab.local` → `192.168.1.202` (LAN). Optional Tailscale alias later. |
|
||||
| Reachable from phone on LAN? | **HTTP OK** on `192.168.1.202:18080` (frontend), `192.168.1.202:18081` (FastAPI `/api/health → ok`). Caddy `192.168.1.202:80` → 308 redirect to HTTPS. All bound to `0.0.0.0`. |
|
||||
| Reachable from phone on Tailscale? | TBD after deploy (path intended) |
|
||||
|
||||
## Volumes and backup
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Docker volume dir convention | TBD on first compose up (prefer under a dedicated path; disk pressure noted) |
|
||||
| Backup target path (local) | TBD |
|
||||
| Off-box mirror (if any) | TBD |
|
||||
|
||||
## Smoke after first up
|
||||
|
||||
| Check | Pass? |
|
||||
|---|---|
|
||||
| `curl http://${APP_HOST}/api/health` (LAN) | **PASS** — `curl http://192.168.1.202:18081/api/health` returns `{"status":"ok","version":"0.1.0","woo_reachable":false}` from LAN. Binds changed from `127.0.0.1` to `0.0.0.0`. |
|
||||
| Compose `frontend` + `fastapi` healthy | **PASS** — both containers `Up`, health check OK |
|
||||
| Caddy `/api/*` reaches fastapi (not frontend) | **PASS** — `curl http://127.0.0.1/api/health` → `308` redirect to HTTPS; HTTP direct to `127.0.0.1:18081` works |
|
||||
| AES settings round-trip (PUT + GET) | **PASS** — woo credentials persist, woo_create_status persists |
|
||||
| Draft store stub (`POST /api/v1/slabs`) | **PASS** — endpoint exists, returns validation error for non-multipart |
|
||||
| Woo connectivity (woo_reachable) | **false** — no WP credentials configured yet (expected for Gate B) |
|
||||
|
||||
## Sign-off
|
||||
|
||||
| Role | Name | Date |
|
||||
|---|---|---|
|
||||
| Inventory draft (SSH + ports + hostname + store) | Main Cut | 2026-09-06 PT |
|
||||
| Inventory complete (Gate B execute) | grokbot_ty | 2026-09-07 PT · bind fix pushed b4a7023 |
|
||||
| Ty Gate B (optional) | | |
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
# Deploy
|
||||
|
||||
Milestone 1 ships as a Docker image that serves the capture app.
|
||||
Where the container runs is chosen at runtime.
|
||||
|
||||
No FastAPI and no Woo client in this milestone.
|
||||
|
||||
`docker-compose.yml`, `Caddyfile.fragment`, and `.env.example` describe an older two-service stack. They are not the Milestone 1 contract.
|
||||
@@ -1,4 +0,0 @@
|
||||
# Release
|
||||
|
||||
Build the Docker image. Run it on the host chosen for that test.
|
||||
Milestone 3 adds the Woo service and a tagged UAT path.
|
||||
@@ -1,12 +1,9 @@
|
||||
# Implementable compose for SlabUploader.
|
||||
# Phase 0 deliverable targets: frontend/Dockerfile, backend/Dockerfile,
|
||||
# backend/app/prompts/, and deploy/.env.example are not yet in the tree on
|
||||
# docs-only main. This file pins their paths so scaffolding does not invent them.
|
||||
# From repo root (after those scaffolds exist):
|
||||
# From repo root:
|
||||
# cp deploy/.env.example deploy/.env && chmod 600 deploy/.env
|
||||
# docker compose -f deploy/docker-compose.yml --env-file deploy/.env up -d
|
||||
# Bind loopback only. Caddy is the TLS edge. Services on the compose network
|
||||
# are named `frontend` and `fastapi` (must match Caddyfile.fragment).
|
||||
# are named `frontend` and `fastapi` (must match Caddyfile).
|
||||
#
|
||||
# WOO_BASE_URL is deploy-time SoT, HTTPS only. FastAPI must refuse http://
|
||||
# at startup. Settings UI displays the URL read-only.
|
||||
@@ -16,7 +13,7 @@ services:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: frontend/Dockerfile
|
||||
ports: ["127.0.0.1:${FRONTEND_PORT:-18080}:80"]
|
||||
ports: ["0.0.0.0:${FRONTEND_PORT:-18080}:80"]
|
||||
depends_on: [fastapi]
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -28,17 +25,28 @@ services:
|
||||
- .env
|
||||
environment:
|
||||
SLAB_AES_KEY: ${SLAB_AES_KEY}
|
||||
WOO_BASE_URL: ${WOO_BASE_URL}
|
||||
SLAB_WOO_BASE_URL: ${WOO_BASE_URL}
|
||||
APP_HOSTNAME: ${APP_HOSTNAME:-}
|
||||
# Woo Application Password + inference keys: AES-GCM in SQLite via Settings UI
|
||||
volumes:
|
||||
- slab_db:/data
|
||||
- slab_images:/images
|
||||
- ../backend/app/prompts:/app/prompts:ro
|
||||
ports: ["127.0.0.1:${FASTAPI_PORT:-18081}:8000"]
|
||||
ports: ["0.0.0.0:${FASTAPI_PORT:-18081}:8000"]
|
||||
mem_limit: 2g
|
||||
restart: unless-stopped
|
||||
|
||||
caddy:
|
||||
image: caddy:2.8.4
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "0.0.0.0:80:80"
|
||||
volumes:
|
||||
- ./Caddyfile:/etc/caddy/Caddyfile
|
||||
depends_on:
|
||||
- frontend
|
||||
- fastapi
|
||||
|
||||
volumes:
|
||||
slab_db:
|
||||
slab_images:
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# SPA: fall back to index.html for any path
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location ~* \.(js|css|png|jpg|jpeg|svg|woff2?)$ {
|
||||
expires 7d;
|
||||
add_header Cache-Control "public";
|
||||
try_files $uri =404;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
#!/usr/bin/env bash
|
||||
# OPS-1: Compose health smoke test.
|
||||
# Usage: run from repo root: bash deploy/tests/test-compose-health.sh
|
||||
# Expected: compose up, wait for services, curl health endpoint, assert 200 + ok.
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||
COMPOSE_FILE="$REPO_ROOT/deploy/docker-compose.yml"
|
||||
ENV_FILE="$REPO_ROOT/deploy/.env"
|
||||
APP_HOSTNAME="${APP_HOSTNAME:-}"
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m'
|
||||
|
||||
pass() { echo -e "${GREEN}PASS${NC} $*"; }
|
||||
fail() { echo -e "${RED}FAIL${NC} $*"; exit 1; }
|
||||
warn() { echo -e "${YELLOW}WARN${NC} $*"; }
|
||||
|
||||
# ── Prerequisites ──────────────────────────────────────────────────────────────
|
||||
if ! command -v docker &>/dev/null; then
|
||||
warn "docker not found — skipping compose up, continuing with validation-only tests"
|
||||
SKIP_COMPOSE=1
|
||||
else
|
||||
SKIP_COMPOSE=0
|
||||
fi
|
||||
|
||||
if ! command -v curl &>/dev/null; then
|
||||
fail "curl is required for health check"
|
||||
fi
|
||||
|
||||
# ── Compose up ─────────────────────────────────────────────────────────────────
|
||||
if [ "$SKIP_COMPOSE" -eq 0 ]; then
|
||||
pass "Docker available, proceeding with compose up"
|
||||
if [ ! -f "$ENV_FILE" ]; then
|
||||
fail ".env file not found at $ENV_FILE. Copy .env.example and fill values."
|
||||
fi
|
||||
pass "Starting compose stack..."
|
||||
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" up -d --build 2>&1 || fail "docker compose up failed"
|
||||
|
||||
# Wait for services to be healthy
|
||||
pass "Waiting for services to be ready (up to 120s)..."
|
||||
MAX_WAIT=120
|
||||
WAITED=0
|
||||
while [ $WAITED -lt $MAX_WAIT ]; do
|
||||
STATUS=$(docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" ps --format json 2>/dev/null | grep -c '"status":"running"' || true)
|
||||
if [ "$STATUS" -eq 2 ]; then
|
||||
pass "Both frontend and fastapi running"
|
||||
break
|
||||
fi
|
||||
sleep 5
|
||||
WAITED=$((WAITED + 5))
|
||||
done
|
||||
if [ "$STATUS" -ne 2 ]; then
|
||||
warn "Services did not reach running state within ${MAX_WAIT}s — continuing with direct health check"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Health check ───────────────────────────────────────────────────────────────
|
||||
HEALTH_URL="https://${APP_HOSTNAME}/api/health"
|
||||
if [ -z "$APP_HOSTNAME" ]; then
|
||||
HEALTH_URL="http://127.0.0.1:18081/api/health"
|
||||
warn "APP_HOSTNAME not set — using direct fastapi port for health check"
|
||||
fi
|
||||
|
||||
pass "Checking health endpoint: $HEALTH_URL"
|
||||
HTTP_CODE=$(curl -sk -o /dev/null -w '%{http_code}' --max-time 15 "$HEALTH_URL" 2>/dev/null || echo "000")
|
||||
|
||||
if [ "$HTTP_CODE" = "200" ]; then
|
||||
BODY=$(curl -sk --max-time 15 "$HEALTH_URL" 2>/dev/null)
|
||||
STATUS=$(echo "$BODY" | python3 -c "import sys,json; print(json.load(sys.stdin).get('status',''))" 2>/dev/null || echo "")
|
||||
if [ "$STATUS" = "ok" ]; then
|
||||
pass "OPS-1: Health check passed (HTTP 200, status=ok)"
|
||||
else
|
||||
fail "OPS-1: Health check returned unexpected status: $STATUS"
|
||||
fi
|
||||
else
|
||||
fail "OPS-1: Health check failed with HTTP $HTTP_CODE"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=== Compose health test complete ==="
|
||||
exit 0
|
||||
@@ -0,0 +1,178 @@
|
||||
#!/usr/bin/env bash
|
||||
# GC-4/5, FUND-25: WOO_BASE_URL validation tests.
|
||||
# Tests WOO_BASE_URL is compose-only HTTPS, startup refusal of http://,
|
||||
# and settings read-only behavior.
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||
BACKEND_DIR="$REPO_ROOT/backend"
|
||||
APP_HOSTNAME="${APP_HOSTNAME:-slab.local}"
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m'
|
||||
|
||||
pass() { echo -e "${GREEN}PASS${NC} $*"; }
|
||||
fail() { echo -e "${RED}FAIL${NC} $*"; exit 1; }
|
||||
warn() { echo -e "${YELLOW}WARN${NC} $*"; }
|
||||
|
||||
TOTAL=0
|
||||
PASSED=0
|
||||
FAILED=0
|
||||
|
||||
check() {
|
||||
local label="$1"
|
||||
shift
|
||||
TOTAL=$((TOTAL + 1))
|
||||
if "$@"; then
|
||||
PASSED=$((PASSED + 1))
|
||||
pass "$label"
|
||||
else
|
||||
FAILED=$((FAILED + 1))
|
||||
fail "$label"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── GC-5: FastAPI refuses http:// WOO_BASE_URL ────────────────────────────────
|
||||
# Test via Python: import and validate Settings
|
||||
test_gc5_http_rejected() {
|
||||
cd "$BACKEND_DIR"
|
||||
python3 -c "
|
||||
from app.config import Settings
|
||||
import sys
|
||||
try:
|
||||
s = Settings(aes_key='dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk', woo_base_url='http://test.example.com')
|
||||
s.validate()
|
||||
sys.exit(1) # Should have raised ValueError
|
||||
except ValueError as e:
|
||||
if 'https' in str(e).lower():
|
||||
sys.exit(0)
|
||||
sys.exit(1)
|
||||
"
|
||||
}
|
||||
|
||||
# ── GC-5: FastAPI refuses missing WOO_BASE_URL ────────────────────────────────
|
||||
test_gc5_missing_rejected() {
|
||||
cd "$BACKEND_DIR"
|
||||
python3 -c "
|
||||
from app.config import Settings
|
||||
import sys
|
||||
try:
|
||||
s = Settings(aes_key='dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk')
|
||||
s.validate()
|
||||
sys.exit(1)
|
||||
except ValueError as e:
|
||||
if 'required' in str(e).lower() or 'missing' in str(e).lower():
|
||||
sys.exit(0)
|
||||
sys.exit(1)
|
||||
"
|
||||
}
|
||||
|
||||
# ── GC-5: HTTPS WOO_BASE_URL is accepted ──────────────────────────────────────
|
||||
test_gc5_https_accepted() {
|
||||
cd "$BACKEND_DIR"
|
||||
python3 -c "
|
||||
from app.config import Settings
|
||||
s = Settings(aes_key='dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk', woo_base_url='https://test.example.com')
|
||||
s.validate()
|
||||
assert s.woo_base_url == 'https://test.example.com'
|
||||
print('OK')
|
||||
"
|
||||
}
|
||||
|
||||
# ── GC-4: Settings GET returns woo_base_url ───────────────────────────────────
|
||||
# Test via FastAPI TestClient (requires compose or local server).
|
||||
# If no server is running, we test via Python directly.
|
||||
test_gc4_settings_get_returns_url() {
|
||||
cd "$BACKEND_DIR"
|
||||
python3 -c "
|
||||
from app.config import Settings
|
||||
from app.main import create_app
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
settings = Settings(
|
||||
aes_key='dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk',
|
||||
woo_base_url='https://test.example.com',
|
||||
)
|
||||
app = create_app(settings)
|
||||
with TestClient(app) as client:
|
||||
r = client.get('/api/v1/settings')
|
||||
assert r.status_code == 200, f'Expected 200, got {r.status_code}'
|
||||
data = r.json()
|
||||
assert 'woo_base_url' in data, 'Missing woo_base_url in settings response'
|
||||
assert data['woo_base_url'] == 'https://test.example.com', f'Unexpected woo_base_url: {data[\"woo_base_url\"]}'
|
||||
app.dependency_overrides.clear()
|
||||
print('OK')
|
||||
"
|
||||
}
|
||||
|
||||
# ── GC-4: Settings PUT rejects woo_base_url ───────────────────────────────────
|
||||
test_gc4_put_rejects_url() {
|
||||
cd "$BACKEND_DIR"
|
||||
python3 -c "
|
||||
from app.config import Settings
|
||||
from app.main import create_app
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
settings = Settings(
|
||||
aes_key='dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk',
|
||||
woo_base_url='https://test.example.com',
|
||||
)
|
||||
app = create_app(settings)
|
||||
with TestClient(app) as client:
|
||||
r = client.put('/api/v1/settings', json={'woo_base_url': 'https://hacked.example.com'})
|
||||
assert r.status_code == 422, f'Expected 422, got {r.status_code}'
|
||||
body = r.json()
|
||||
assert 'detail' in body or 'errors' in body, 'Missing error detail'
|
||||
app.dependency_overrides.clear()
|
||||
print('OK')
|
||||
"
|
||||
}
|
||||
|
||||
# ── FUND-25: Port collision detection (compose-only check) ────────────────────
|
||||
# Check that FRONTEND_PORT and FASTAPI_PORT defaults don't collide with known services.
|
||||
test_fund25_port_free() {
|
||||
FRONTEND_PORT="${FRONTEND_PORT:-18080}"
|
||||
FASTAPI_PORT="${FASTAPI_PORT:-18081}"
|
||||
if [ "$SKIP_COMPOSE" -eq 0 ]; then
|
||||
# On a real host, check if ports are free
|
||||
for PORT in "$FRONTEND_PORT" "$FASTAPI_PORT"; do
|
||||
if ss -tlnp 2>/dev/null | grep -q ":$PORT "; then
|
||||
echo "Port $PORT is in use"
|
||||
return 1
|
||||
fi
|
||||
done
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# ── Run tests ──────────────────────────────────────────────────────────────────
|
||||
SKIP_COMPOSE=0
|
||||
if ! command -v docker &>/dev/null; then
|
||||
SKIP_COMPOSE=1
|
||||
warn "Docker not available — skipping compose-dependent tests"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=== WOO_BASE_URL validation tests ==="
|
||||
echo ""
|
||||
|
||||
check "GC-5: HTTP WOO_BASE_URL rejected" test_gc5_http_rejected
|
||||
check "GC-5: Missing WOO_BASE_URL rejected" test_gc5_missing_rejected
|
||||
check "GC-5: HTTPS WOO_BASE_URL accepted" test_gc5_https_accepted
|
||||
check "GC-4: Settings GET returns woo_base_url" test_gc4_settings_get_returns_url
|
||||
check "GC-4: Settings PUT rejects woo_base_url" test_gc4_put_rejects_url
|
||||
|
||||
if [ "$SKIP_COMPOSE" -eq 0 ]; then
|
||||
check "FUND-25: Port collision check" test_fund25_port_free
|
||||
else
|
||||
pass "FUND-25: Port collision check (skipped — no Docker)"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=== Results: $PASSED passed, $FAILED failed, $TOTAL total ==="
|
||||
if [ "$FAILED" -gt 0 ]; then
|
||||
exit 1
|
||||
fi
|
||||
exit 0
|
||||
@@ -0,0 +1,230 @@
|
||||
# Architecture
|
||||
|
||||
Status: SPEC · hybrid topology locked at Gate A freeze (2026-08-31) · SlabUploader
|
||||
|
||||
Normative for hybrid split, swimlane, what-runs-where, secrets **why**, and status
|
||||
transitions. Ops depth lives in DEPLOYMENT. Schema enums live in DATA-MODEL.
|
||||
Agent-facing hard rules live in AGENTS.md (verbatim locks stay there).
|
||||
|
||||
Companion: `docs/TECH-SPEC-PIPELINE.md`. Historical PRD: `docs/archive/PRD-2026-08.txt` (frozen; not a source for new work). Agent locks: `AGENTS.md`.
|
||||
|
||||
## 1. Topology (hybrid)
|
||||
|
||||
Phone does the happy-path pipeline (mask knobs, length axis, sqft/bdft, 3:4 PNG).
|
||||
FastAPI stores drafts, proxies inference, talks to Woo. reverse proxy terminates HTTPS.
|
||||
Server U2Net (“Try harder”) is **deferred from POC**, not deleted.
|
||||
|
||||
```
|
||||
phone (SvelteKit)
|
||||
capture, four knobs inline, live mask overlay on the source photo,
|
||||
length axis, sqft/bdft, 3:4 PNG
|
||||
(POC: retake if mask still bad — no U2Net control)
|
||||
|
||||
server
|
||||
reverse proxy → frontend (static)
|
||||
→ fastapi : store, settings, taxonomy cache, Woo, inference proxy, prompt files
|
||||
→ U2Net endpoint reserved post-POC
|
||||
```
|
||||
|
||||
- Happy path never leaves the phone.
|
||||
- Mask knobs live on capture; changing a knob re-runs client BG removal immediately.
|
||||
- Woo + LLM keys stay on the server. Browser does not call Woo or the vision endpoint directly.
|
||||
|
||||
## 2. Swimlane
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph Phone["Phone — SvelteKit"]
|
||||
P1[New Wood: 1–5 photos]
|
||||
P2[BG overlay + sheet / sensitivity / edge / feather]
|
||||
P3{Edge OK?}
|
||||
P4[Length axis overlay — rotate / confirm]
|
||||
P5[User length + thickness + SKU]
|
||||
P6[sqft, bdft, 6in widths, 3:4 PNGs]
|
||||
P7[Review: Call 1 pre-fill or manual taxonomy]
|
||||
P8[Tap Generate text]
|
||||
P9[Review title / desc — publish]
|
||||
end
|
||||
|
||||
subgraph API["server — FastAPI"]
|
||||
A1[U2Net reserved post-POC]
|
||||
A2[Store draft: originals + PNGs + numbers]
|
||||
A3[Call 1 proxy — vision]
|
||||
A4[Call 2 proxy — text]
|
||||
A5[Woo: taxonomy, SKU check, create product]
|
||||
end
|
||||
|
||||
subgraph LLM["Your OpenAI-compatible endpoint"]
|
||||
L1[Wood expert — all photos at 1024]
|
||||
L2[Title / desc / short desc]
|
||||
end
|
||||
|
||||
subgraph Woo["WooCommerce store"]
|
||||
W1[Categories / attributes / fig-* tags]
|
||||
W2[New product live or draft per setting]
|
||||
end
|
||||
|
||||
P1 --> P2 --> P3
|
||||
P3 -->|no — retune knobs or retake| P2
|
||||
P3 -->|no — Retake| P1
|
||||
P3 -->|yes| P4 --> P5 --> P6
|
||||
P6 --> A2
|
||||
A2 --> A3 --> L1 --> A3 --> P7
|
||||
P7 --> P8 --> A4 --> L2 --> A4 --> P9
|
||||
P9 --> A5
|
||||
A5 --> W1
|
||||
A5 --> W2
|
||||
```
|
||||
|
||||
## 3. Clarifications
|
||||
|
||||
1. **Happy path** stays on the phone through PNG + numbers. FastAPI is idle until the user has a confirmed mask.
|
||||
2. **Mask knobs** live on capture; each change re-runs client BG removal. Server U2Net “Try harder” is deferred from POC. The one-photo contract stays in the architecture; the control is not shipped until post-POC.
|
||||
3. **Draft upload** sends **originals + processed PNGs + length/thickness/SKU/sqft/bdft/widths**. Originals are needed if Call 1 or a later retry must not depend on the tab still being open. After successful Woo publish, server deletes both.
|
||||
4. **Call 1** is server-side so the vision key never sits in the browser. Auto-run
|
||||
on slab create **only if** `inference_enabled` is true. If disabled, Call 1
|
||||
is skipped; user can manually fill taxonomy and still trigger Call 2. Body:
|
||||
all originals downscaled to 1024 + **Woo-synced taxonomy only** + SKU/length/thickness.
|
||||
Timeout 45s → error + retry on the phone. ≥0.7 confidence **pre-populates**
|
||||
species, wood categories, edge/figure/grade, feat-* (mutable). Below 0.7 those
|
||||
fields stay **empty**. Manual entry stays authoritative. Presentation: `docs/UX.md`.
|
||||
5. **Call 2** does not auto-fire. User taps Generate text. Server must send
|
||||
**portable Call 1 context** (default: full conversation resend). Stateful
|
||||
`previous_response_id` only after `test-inference` proves the provider supports
|
||||
it. Call 2 inputs distinguish confirmed user values from inferred ones. Per-slab
|
||||
thread; delete on submit or abandon.
|
||||
6. **Woo** is only FastAPI. Browser never sees the WordPress application
|
||||
password. Duplicate SKU → 409 mapped on the SKU field (edit SKU or open existing).
|
||||
Stale value → 422 on that field. Draft stays on the phone.
|
||||
7. **Settings** (species $/bdft, prompts, inference endpoint, Woo credentials,
|
||||
publish status) live on the server. Store URL is env, displayed read-only.
|
||||
|
||||
## 4. What runs where
|
||||
|
||||
| Job | Where |
|
||||
|---|---|
|
||||
| Camera, 1-5 photos | Client |
|
||||
| Sheet detect, chroma-key / black threshold, four knobs, live re-run, flood-fill | Client |
|
||||
| U2Net “Try harder” | Server, **deferred from POC** |
|
||||
| Length axis overlay, 6" widths, sqft, bdft | Client |
|
||||
| 3:4 PNG, 80% fill (configurable aspect) | Client |
|
||||
| Draft + originals + processed PNGs | Server (after user continues) |
|
||||
| Species $/bdft, Woo creds, prompts, brand/GEO | Server |
|
||||
| Call 1 (vision, all photos @1024) | Server proxy |
|
||||
| Call 2 (title/desc) | Server proxy, after Call 1 curation |
|
||||
| Woo taxonomy sync + publish | Server |
|
||||
|
||||
## 5. Secrets and settings
|
||||
|
||||
**Server-side only (never in browser JS):**
|
||||
1. WordPress username + **application password** for a dedicated low-privilege
|
||||
WP user. Server uses HTTP Basic Auth (`username:application_password`) against
|
||||
`/wp-json/wc/v3/`. Stored encrypted at rest in SQLite (AES-GCM). Not Woo
|
||||
consumer keys. Not the account login password.
|
||||
2. Inference endpoint base URL + API key. Stored encrypted. Server proxies all
|
||||
inference calls.
|
||||
3. AES master key. Env var on server (`SLAB_AES_KEY`), never in the DB.
|
||||
4. `WOO_BASE_URL` env (compose). HTTPS only. Not user-editable; Settings displays it read-only.
|
||||
|
||||
**Browser-side (no secrets):**
|
||||
1. User preferences: sheet mode, sensitivity, edge offset, feather. Persisted in localStorage, reset-to-default available.
|
||||
2. Species list with $/bdft. Read from server settings sync.
|
||||
3. Taxonomy snapshot (categories, attributes, tags). Read from server. No auth needed.
|
||||
|
||||
**Flow:**
|
||||
1. Deploy sets `WOO_BASE_URL` (`https://`) in compose. Operator creates a
|
||||
low-privilege WP user on that store (HTTPS required for Application
|
||||
Passwords UI). Generates an application password under Users → Profile.
|
||||
2. User enters that username + application password in Settings UI
|
||||
(store URL shown read-only from env).
|
||||
3. Browser POSTs them to `/api/v1/settings` on FastAPI. Server encrypts with
|
||||
AES-GCM and stores ciphertext only.
|
||||
4. Server syncs taxonomy from Woo, caches in SQLite.
|
||||
5. Client reads taxonomy from `/api/v1/admin/taxonomy` — no auth needed.
|
||||
6. All Woo calls go server-to-Woo. Browser never calls Woo REST directly.
|
||||
|
||||
Browser-to-Woo REST would need CORS on the store, which this design does not use.
|
||||
|
||||
## 6. Status machine
|
||||
|
||||
```
|
||||
draft → calibrated → ready → publishing → published
|
||||
│ │ ↑
|
||||
└→ failed ────┴────────────────┘ (woo_product_id set)
|
||||
any → failed (with error)
|
||||
```
|
||||
|
||||
1. **draft** — user took photos, no mask confirmed yet.
|
||||
2. **calibrated** — user confirmed the BG edge, confirmed length axis, entered length/thickness/SKU. Client computed sqft/bdft/widths. Draft uploaded to server.
|
||||
3. **ready** — SKU, length, thickness, client sqft/bdft/widths, ≥1 inventory PNG,
|
||||
exactly one species, ≥1 wood category, 1 edge type, ≥1 figure term, ≥1 fig-* tag,
|
||||
≥1 grade, thickness store band (round-up), 1 moisture (default kiln-dried), price.
|
||||
feat-* 0+. Title/description may be typed, generated, or templated at publish.
|
||||
Values can come from Call 1 (≥ 0.7 pre-fill the user kept), manual entry, or a mix.
|
||||
**Review gate:** below-threshold Call 1 leaves species, wood category, figure,
|
||||
edge, grade, and feat-* empty. That subset is not the submit list. Inline nudges
|
||||
on every still-required control, not submit-only. Presentation: `docs/UX.md`. Inference is
|
||||
optional; with inference OFF the user fills everything by hand.
|
||||
4. **publishing** — Woo create in flight. Poll for result.
|
||||
5. **published** — Woo product created, woo_product_id stored. Images purged.
|
||||
6. **failed** — pipeline, inference, or publish error. Error detail stored. Retry available.
|
||||
|
||||
Call 1 and Call 2 are assist-only. They do not gate publish. Low-confidence Call 1
|
||||
results do not auto-fill. If the user skips inference, the path is: calibrated →
|
||||
ready → publishing → published.
|
||||
|
||||
## 7. Taxonomy cache (manufactured last-modified)
|
||||
|
||||
Woo REST exposes **no usable last-modified** on categories, attributes, or tags
|
||||
(only products carry `date_modified`). The client never talks to Woo. FastAPI is
|
||||
the sole freshness source. It persists two UTC stamps: `taxonomy_last_sync_at`
|
||||
(every successful Woo pull) and `taxonomy_anchor` (bumps only when the stored-subset
|
||||
hash changes, including deletions).
|
||||
|
||||
- Anchor bumps **only** when a row-level change is detected in the **stored
|
||||
subset** of cache fields (whatever columns the cache tables actually keep:
|
||||
species/wood-category leaves, five attributes + terms, `fig-*`/`feat-*` tags).
|
||||
Scope is schema-driven: adding a cache column later automatically includes it.
|
||||
- Spelling/name edits, additions, and deletions all bump. Diff must catch missing
|
||||
rows (deletions), not only changed rows.
|
||||
- A no-op sync (stored subset unchanged) **does not** bump, so the client does
|
||||
not churn.
|
||||
- Client reads the anchor. If it advanced past the client's last value, re-pull
|
||||
the **full** taxonomy cache. One timestamp governs categories, attributes, and
|
||||
tags together.
|
||||
- `taxonomy_anchor` is app-managed; no Woo-side timestamp or per-table anchor is used.
|
||||
|
||||
**Triggers**
|
||||
|
||||
1. `POST /api/v1/settings/test-woo` **always** resyncs taxonomy after a successful
|
||||
connection test (unlocks the species list).
|
||||
2. Publish / product submit: opportunistic resync if the last successful sync is
|
||||
older than **1 hour**; bump anchor only if the stored subset changed.
|
||||
3. Optional: app launch check (sync only if stale) and a Settings “Refresh
|
||||
taxonomy” control. Not required for POC.
|
||||
4. Do **not** sync on every screen navigation.
|
||||
|
||||
Species pickers and Call 1 option lists are **exactly** this cached set. No
|
||||
hardcoded, invented, or fallback species list. A new Woo species is selectable
|
||||
only after the next sync that lands it in the cache.
|
||||
|
||||
## 8. Shared validation module
|
||||
|
||||
One FastAPI module is source of truth (`GET /api/v1/validation/module` → `{ hash, rules[] }`).
|
||||
Two consumers: review UI and Call 2 prompt builder. Client caches the module.
|
||||
|
||||
- SKU charset: `[A-Z0-9-]{3,24}` (printable ASCII subset). Title / short_title / description: UTF-8 within Woo `maxLength`. Not “ASCII-only” and not “extended ASCII”.
|
||||
- Field-exit: local check against the cache. Empty optional is valid.
|
||||
- Length: `maxLength` is a hard input cap; server still enforces.
|
||||
- Submit carries `validation_hash`. Match → validate. Mismatch → HTTP **412** + fresh module; UI “Updating rules”; re-validate; retry. Never show 412. 409 is duplicate SKU only. Woo-shaped 422 is store rejection (#18).
|
||||
|
||||
## 9. Call 1 → Call 2 context
|
||||
|
||||
Providers differ. Default portable path: **resend the full Call 1 turn** (prompt + images metadata + assistant JSON) with Call 2. Opt into `previous_response_id` only after probe.
|
||||
|
||||
`POST /api/v1/settings/test-inference` two-turn probe records `stateful | stateless | fail`.
|
||||
**Fail blocks Call 2 enablement only** (Gate C inference-OFF still works). Call 2 always carries the full Call 1 context; `previous_response_id` is optional after probe.
|
||||
|
||||
Per-slab thread on the **server** (client does not hold provider IDs). Best-effort delete on publish / abandon / fail; log and continue.
|
||||
|
||||
Presentation: `docs/UX.md`.
|
||||
-140
@@ -1,140 +0,0 @@
|
||||
# Milestone 1 — Capture
|
||||
|
||||
Runs in the browser on phone and PC as ordinary client code.
|
||||
No inference URL. No model. No backend. Mask, rectangle, measurements, crop, and PNG encode are deterministic functions of the photo and user inputs.
|
||||
|
||||
Milestone 1 uses one reference photo. Further angles are later milestones.
|
||||
Milestone 1 ends when the user sees the inventory PNG and measurements. Milestone 2 is Review. Milestone 3 is Create. This file does not create a WooCommerce product.
|
||||
|
||||
## Setup
|
||||
|
||||
Photograph the slab on a green or black sheet.
|
||||
|
||||
- Sheet fills the frame around the slab.
|
||||
- Slab is fully on the sheet.
|
||||
- First photo is top-down, lens parallel to the face.
|
||||
- Length of the slab is aligned with the long side of the picture.
|
||||
|
||||
The app expects that sheet. Background removal keys green or black. Other backgrounds are out of Milestone 1.
|
||||
|
||||
## Use case
|
||||
|
||||
If a device draft exists:
|
||||
|
||||
- App opens Resume.
|
||||
- User chooses Continue or Start fresh.
|
||||
- Continue: app restores the last approved state.
|
||||
- Start fresh: app discards the draft and opens Add photo.
|
||||
|
||||
Otherwise the app opens Add photo.
|
||||
|
||||
1. User adds one photo taken under Setup.
|
||||
2. App decodes the photo and opens Mask. App keys green or black and shows an overlay: slab stays the photograph; removed pixels are a light gray checkerboard.
|
||||
3. User sets sheet Auto / Green / Black and sensitivity 0–100 (slider or minus / plus). App updates the control immediately. App recomputes the overlay 2.0 seconds after the last sensitivity change. User adjusts until only the slab remains, then approves. App blocks approve if the mask has no slab pixels; user retakes. The approved mask is the footprint for every later step.
|
||||
4. App draws the minimum-area bounding rectangle of the mask and the length axis on the long side of that rectangle. User rotates the axis if it does not follow the slab length, then approves the axis.
|
||||
5. User enters length `L` in inches (1/8 in steps) and thickness (1/8 in steps), then presses Calculate.
|
||||
6. App sets `scale = longest_axis_px / L` (mask/wood span along the approved direction ÷ user `L`; rectangle is direction only).
|
||||
7. App computes widths, sqft, and bdft from the mask. App crops the masked slab into a 3:4 portrait frame: slab centered, 90% fill at the widest and tallest points, background transparent. App writes that frame as an inventory PNG (`image/png`, 8-bit RGBA).
|
||||
8. User sees the transparent PNG and the numbers. Milestone 1 stops here.
|
||||
|
||||
Calculate does not create a WooCommerce product.
|
||||
|
||||
## Browsers
|
||||
|
||||
Phone: iOS Safari, Android Chrome.
|
||||
PC: Chrome, Edge, Firefox.
|
||||
Other clients get a hard stop.
|
||||
|
||||
## States
|
||||
|
||||
One photo component is reused. Each state advances on approve. Back returns to the previous state. A device draft restores the last approved state.
|
||||
|
||||
1. Resume — only when a device draft exists. Continue or Start fresh.
|
||||
2. Add photo — camera or picker. One photo. Setup rules visible.
|
||||
3. Mask — background removal on that photo.
|
||||
4. Length axis — bounding rectangle and long-side axis on the same photo.
|
||||
5. Measure — length, thickness, Calculate, crop, inventory PNG, widths, sqft, bdft. Milestone 1 stops here.
|
||||
|
||||
## Display
|
||||
|
||||
Preview fits the device width. Height is capped so Mask and Measure controls stay on screen. Phone and PC use the same states.
|
||||
|
||||
The preview is display only. Mask, rectangle, crop, `scale`, sqft, and PNG encode use the source photo pixels, not the CSS size of the preview.
|
||||
|
||||
## Runtime
|
||||
|
||||
Browser APIs: `createImageBitmap`, `canvas` / `OffscreenCanvas`, Web Worker, `convertToBlob({ type: "image/png" })`, IndexedDB.
|
||||
`idb-keyval` may wrap IndexedDB.
|
||||
No OpenCV, ONNX, U2Net, or image-editor kit.
|
||||
Sensitivity 0–100 maps to green chroma tightness and black brightness cutoff in the worker.
|
||||
|
||||
## Mask
|
||||
|
||||
Overlay: slab remains the photograph. Removed pixels are a light gray checkerboard.
|
||||
|
||||
Controls on this state, with the photo visible:
|
||||
|
||||
- Sheet: Auto, Green, or Black
|
||||
- Sensitivity: 0–100, step 1. Slider with minus and plus on each side. Button hit targets sized for a thumb.
|
||||
|
||||
The control value updates immediately. The overlay recomputes 2.0 seconds after the last sensitivity change. A sheet change may recompute on the change.
|
||||
|
||||
Mask work runs in the worker. The last good overlay stays visible while a pass runs. Show a busy mark only if a pass lasts more than a fraction of a second. If a pass fails, keep the last overlay; the user may retake.
|
||||
|
||||
Approve is blocked when the mask has no slab pixels. User retakes.
|
||||
|
||||
User approves the mask when only the slab remains. That mask is the footprint for Length axis, crop, measurements, and the inventory PNG.
|
||||
|
||||
## Length axis
|
||||
|
||||
On the same photo, keep the approved mask in view and draw the minimum-area bounding rectangle of the mask with the length axis on the long side of that rectangle. User rotates the rectangle (slider with minus and plus) until that direction follows the slab length, then approves the axis.
|
||||
|
||||
## Measure
|
||||
|
||||
User enters length `L` in inches (1/8 in steps) and thickness (1/8 in steps), then presses Calculate.
|
||||
|
||||
- `scale = longest_axis_px / L` where `longest_axis_px` is the mask/wood span along the approved length direction (slab pixels end-to-end on that axis), not the rectangle edge length. The rectangle sets direction only; user-entered `L` is wood end→end along that direction.
|
||||
- Widths: min, max, avg from samples taken every 6 in along the approved length axis, perpendicular to it, after dropping the first and last 6 in of the span (bulk widths, not tips). If `L ≤ 12 in` there are no interior stations and all three are 0.
|
||||
- `sqft = (mask_pixels / scale²) / 144` (2 decimals)
|
||||
- `bdft = sqft × thickness_in` (2 decimals)
|
||||
- Crop: 3:4 portrait, slab centered, 90% fill at the widest and tallest points, transparent background
|
||||
- File: inventory PNG, `image/png`, 8-bit RGBA, alpha kept
|
||||
|
||||
User sees the transparent PNG and those numbers. Stop. Calculate does not create a WooCommerce product.
|
||||
|
||||
## Crop
|
||||
|
||||
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.
|
||||
5. Encode as PNG with alpha.
|
||||
|
||||
## Device draft
|
||||
|
||||
One in-progress slab is stored on the device. This is not a WooCommerce draft.
|
||||
|
||||
On open or refresh, if that draft exists, the app opens in Resume.
|
||||
|
||||
- Continue restores the last approved state, including Measure.
|
||||
- Start fresh discards the device draft and opens Add photo.
|
||||
|
||||
Milestone 3 clears the device draft after Woo create.
|
||||
|
||||
## Inventory PNG
|
||||
|
||||
Role: `kind=inventory`. Filename is an implementation detail.
|
||||
The first inventory photo is the measurement reference. Later inventory photos use the same file properties.
|
||||
|
||||
| Property | Value |
|
||||
|---|---|
|
||||
| Format | PNG, 8-bit RGBA (`image/png`) |
|
||||
| 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 |
|
||||
|
||||
Source below that floor: warn and require a new photo. No upscale from an undersized source.
|
||||
Editor preview of alpha is a light gray checkerboard. The storefront uses the theme background.
|
||||
@@ -0,0 +1,198 @@
|
||||
# Listing copy and the store
|
||||
|
||||
How titles and descriptions are built, how we talk to WooCommerce, and the exact create payload. Templates always run. Generate text is optional and never publishes on its own.
|
||||
|
||||
## 1. Titles and descriptions
|
||||
|
||||
### 1.1 Templates (always on)
|
||||
Templates live in Settings. Defaults below. Placeholders use `{field}`.
|
||||
|
||||
**title** (default):
|
||||
```
|
||||
{species} {length_ft}ft {length_in_remainder}in × {width_avg_in}in Wood Slab — {character}
|
||||
```
|
||||
Example: `Black Walnut 16ft 0in × 18.2in Wood Slab — Cathedral`
|
||||
- `length_ft` = floor(length_in / 12), `length_in_remainder` = length_in mod 12 (1 dp).
|
||||
|
||||
**short_title** (default):
|
||||
```
|
||||
{species} Slab — {character}
|
||||
```
|
||||
Example: `Black Walnut Slab — Cathedral`
|
||||
|
||||
**description** (default, multi-paragraph):
|
||||
```
|
||||
A one-of-a-one {character} {species} slab, {length_ft}ft {length_in_remainder}in long with an
|
||||
average width of {width_avg_in}in and {thickness_in}in thickness ({bdft} bdft).
|
||||
|
||||
Each slab is naturally shaped — width varies along the length at {width_min_in}in to
|
||||
{width_max_in}in. Sold by the slab; dimensions as measured. {geo_sentence}
|
||||
```
|
||||
`geo_sentence` is the geography sentence from Settings, e.g.
|
||||
`"Locally milled and shipped from the Pacific Northwest."` (default if unset: none).
|
||||
|
||||
All template math fields come from the deterministic pipeline (never from the LLM).
|
||||
|
||||
### 1.2 Generate text (optional)
|
||||
The mill owner taps Generate text on review. It never runs by itself. It is off unless `content_llm_enabled` is true.
|
||||
|
||||
The model writes prose only (character, color, grain, uses). Measured length, width, thickness, square feet, and board feet are facts in the prompt. The model does not invent numbers. Templates inject those numbers into the title and description after the prose comes back.
|
||||
|
||||
The mill owner can always edit the result. `content_source` is `llm` when this path ran.
|
||||
|
||||
If the text endpoint is missing or down, disable the button. Templates still work. Publish is never blocked because Generate text failed.
|
||||
|
||||
### 1.3 Audit
|
||||
Every content generation (template or LLM) is logged with inputs (template version,
|
||||
slab facts), the LLM request/response payload when used (inference calls logged for audit),
|
||||
and the final stored content.
|
||||
|
||||
## 2. WooCommerce
|
||||
|
||||
### 2.1 Auth & client
|
||||
- REST v3 surface unchanged: `GET/POST/PATCH/DELETE {WOO_BASE_URL}/wp-json/wc/v3/…`
|
||||
- Auth is **WordPress Application Passwords**, not WooCommerce consumer keys
|
||||
(`ck_`/`cs_`) and not the user's login password.
|
||||
- Create a **dedicated low-privilege WordPress user** (shop manager, or a custom
|
||||
role with product create/edit + media upload). A dedicated low-privilege user, not the admin account, is used.
|
||||
- Generate the application password under **Users → Profile → Application
|
||||
Passwords**. WordPress requires **HTTPS** for that UI; the store must already
|
||||
be on TLS (Caddy enforces HTTPS on the app side too).
|
||||
- Basic auth shape: `woo_wp_username:woo_app_password` (username + application
|
||||
password). Spaces in the generated password are ignored by WordPress; store
|
||||
the value as entered.
|
||||
- FastAPI is the sole Woo caller. Credentials live encrypted at rest in SQLite
|
||||
(AES-GCM, `SLAB_AES_KEY` env). The browser never holds them.
|
||||
- Rotation: revoke the application password in the WP user profile, enter a new
|
||||
one in Settings UI, then `POST /api/v1/settings/test-woo`.
|
||||
- Client: `httpx` with timeouts (connect 10s, read 60s for image-heavy create).
|
||||
- Store URL is **`WOO_BASE_URL`** from Docker Compose / host `.env` (HTTPS only).
|
||||
FastAPI refuses `http://` at startup. Not a Settings field; UI shows it read-only.
|
||||
|
||||
### 2.2 Store lists (species, tags, attributes)
|
||||
`POST /api/v1/admin/taxonomy/sync` pulls, in order:
|
||||
1. `GET /products/categories?per_page=100` (paginate) → species + wood-category leaves
|
||||
2. `GET /products/tags?per_page=100` → `fig-*` / `feat-*` only (other tags ignored)
|
||||
3. `GET /products/attributes` (+ terms) → five locked attributes
|
||||
|
||||
Rebuilds cache tables in one transaction. Failure → `502 woo_error`; previous
|
||||
cache retained (never delete-then-fail).
|
||||
|
||||
**Manufactured stamps.** Woo REST has **no** usable last-modified on categories,
|
||||
attributes, or tags. FastAPI persists `taxonomy_last_sync_at` (every successful
|
||||
pull) and `taxonomy_anchor` (bump only when stored-subset hash changes, including
|
||||
deletions). Client re-pulls the full taxonomy iff the anchor advanced. No Woo-side
|
||||
taxonomy timestamp; no per-navigation sync.
|
||||
|
||||
**Triggers:** (1) successful `test-woo` **always** resyncs; (2) publish if
|
||||
`now - taxonomy_last_sync_at > 1 hour` then reconcile (bump only on real change);
|
||||
(3) optional app launch / Settings refresh. If a picked id vanished after a refresh, show that field as no longer valid. The selection is not silently swapped.
|
||||
|
||||
**Species exclusivity.** UI pickers, pricing seeds, and Call 1 species lists are
|
||||
**exactly** the synced species-category leaves. No hardcoded, invented, or fallback
|
||||
species set. A species new in Woo is selectable only after the next sync stores it.
|
||||
|
||||
### 2.3 What goes on the listing
|
||||
|
||||
When vision is on and it is at least 0.7 sure, it fills species, wood categories,
|
||||
edge, figure, grade, and feature tags (character, inclusions, voids, checks map
|
||||
to existing `feat-*` tags only). The mill owner can change every filled value.
|
||||
Typed values always win.
|
||||
|
||||
If it is less sure, those fields stay empty. That empty-start subset is not
|
||||
the submit list. Review will not continue until the full ready set is filled:
|
||||
SKU, length, thickness, price, species, wood category, edge type, figure,
|
||||
grade, thickness band, moisture, fig-* tags, and inventory PNG(s). Species
|
||||
plus one figure is not enough. Presentation: `docs/UX.md`.
|
||||
Low-confidence guesses are not filled.
|
||||
|
||||
Names from vision or from typing match store ids case-insensitive, trimmed. No
|
||||
match leaves the field empty so they can pick from the list. The store will still
|
||||
reject a missing required field at publish.
|
||||
|
||||
|
||||
Field rules:
|
||||
- **Categories**: species leaf (1 required) plus wood-category leaf ids (1+).
|
||||
- **Tags**: `fig-*` (1+ required) and `feat-*` (0+). Only documented
|
||||
character or species product tags are used. Woo is authoritative for tag definitions.
|
||||
- **Attributes**: the five locked Woo attributes only — Edge Type, Figure, Grade,
|
||||
Thickness, Moisture — using term ids from the cache. Species,
|
||||
character, length, or free-text thickness as product attributes. Missing
|
||||
attributes are skipped, not created (v1: no attribute creation).
|
||||
|
||||
Vision only suggests. It never blocks publish. With vision off, everything is typed.
|
||||
|
||||
### 2.4 Create payload
|
||||
`POST /products` — body (only non-null fields sent):
|
||||
```jsonc
|
||||
{
|
||||
"name": "{title}",
|
||||
"type": "simple",
|
||||
"status": "{woo_create_status}", // draft | publish; default draft; SLAB-UAT-* forced draft
|
||||
"sku": "{sku}",
|
||||
"regular_price": "{price}", // 2 dp string, e.g. "2730.00"
|
||||
"description": "{description}",
|
||||
"short_description": "{short_title}",
|
||||
"categories": [ { "id": <species category id> } ],
|
||||
"tags": [ { "id": <fig-* tag id> }, { "id": <feat-* tag id> }, … ],
|
||||
"images": [
|
||||
{ "src": "<data URL or pre-uploaded attachment URL>", "alt": "{species} {character} slab" }
|
||||
],
|
||||
"attributes": [ … if present … ],
|
||||
"virtual": false,
|
||||
"downloadable": false,
|
||||
"catalog_visibility": "visible"
|
||||
}
|
||||
```
|
||||
**Image upload strategy** (Woo v3): `images[].src` accepts a URL or a data URL.
|
||||
For reliability the server:
|
||||
1. For each customer-facing inventory photo in `seq` order (processed transparent PNGs
|
||||
from the client TECH-SPEC path): `POST /media` with the **PNG bytes** → get attachment
|
||||
`source_url`. Woo accepts `image/*`; keep alpha. JPEG re-encoding drops alpha and must not happen.
|
||||
2. Reference those `source_url` values in `images` (≤5 inventory photos).
|
||||
3. Only `kind=inventory` photos. Calibration photos do not exist in this design.
|
||||
|
||||
**Status** comes from Settings `woo_create_status` (`draft` or `publish`, default
|
||||
`draft`). Practice SKUs `SLAB-UAT-*` are always created as drafts, even if Settings
|
||||
say publish. Existing SKUs are not edited in this version. After a successful create,
|
||||
the server deletes the original and processed photos. A slab marked published in the
|
||||
app means the store create succeeded. That is not the same as WooCommerce "publish."
|
||||
The force-draft rule is in `AGENTS.md`. This file only owns the payload and the steps.
|
||||
|
||||
### 2.5 Duplicate SKU
|
||||
Before create: `GET /products?sku={sku}` (any store status).
|
||||
- Found: stop. The phone shows the SKU field with Edit SKU and Open existing listing. This version does not update the other product.
|
||||
- Not found: create. Still do the check every time.
|
||||
|
||||
### 2.6 After a failed publish
|
||||
|
||||
The mill owner sees a message on the field, not a status number or a stack. The form stays on the phone. The server marks the slab failed so they can try again.
|
||||
|
||||
If the SKU already exists: under SKU, "This SKU already exists in the store (any status)." Edit SKU, or open the existing listing (admin link when we have it).
|
||||
|
||||
If a category, attribute, or price is no longer valid: under that field, "This value is no longer valid. Pick from the current list," with the current store options.
|
||||
|
||||
The server still writes `sync_log`. Success still sets `woo_product_id`, marks published locally, and deletes photos.
|
||||
|
||||
- **Partial-failure note** (risk, see IMPL-PLAN): if media uploads succeed but the
|
||||
final product POST fails, orphaned Woo attachments may exist. v1: log it; surface
|
||||
a manual cleanup note. Not auto-deleted.
|
||||
|
||||
## 3. End-to-end publish sequence (server side)
|
||||
```
|
||||
1. check the listing against the shared rules (current copy of the rules on submit)
|
||||
2. look up the SKU in the store (any status). Stop if it already exists.
|
||||
3. refresh store lists if the last successful pull is older than 1 hour. Only bump the client list-version if something actually changed.
|
||||
4. resolve categories, tags, and attributes from the synced lists (vision names or typed names)
|
||||
5. upload processed PNG inventory photos to media, keep the URLs
|
||||
6. POST /products with draft/publish per Settings, always draft for SLAB-UAT-* SKUs
|
||||
7. on success: log, mark published locally, delete originals and processed photos
|
||||
on failure: log, mark failed, keep photos, put the error on the field (§2.6)
|
||||
```
|
||||
|
||||
## 4. What the model may and may not do
|
||||
|
||||
Templates, numbers, matching names to store ids, payload, dedupe, logging: code.
|
||||
Generate text may write description prose only. It must not invent dimensions. It may only pick species and attributes that exist in the synced store lists.
|
||||
|
||||
Presentation: `docs/UX.md`. The wire uses `{ field, code, message, actions[] }`. The mill owner never sees those codes. If the phone's copy of the rules is stale, refresh the copy and retry. That is not a duplicate-SKU conflict.
|
||||
@@ -0,0 +1,241 @@
|
||||
# Data Model
|
||||
|
||||
Status: SPEC · 2026-08-27 · SlabUploader
|
||||
Storage: single SQLite file. Schema portable to Postgres.
|
||||
|
||||
Conventions:
|
||||
- IDs: client-generated UUIDv4 for slabs so the SvelteKit client can reference a slab before it exists server-side.
|
||||
- Timestamps: UTC ISO-8601 TEXT.
|
||||
- Soft state via status column; no hard deletes for slabs (audit).
|
||||
- Money: float64 USD, rounded half-up to cents at write.
|
||||
- All rounded values are what gets stored. No full-precision internal storage.
|
||||
- Inches stored as 1/8" increments. Sqft/bdft to 2 decimals.
|
||||
- Multi-select fields stored as JSON arrays of Woo IDs.
|
||||
|
||||
---
|
||||
|
||||
## slabs
|
||||
|
||||
Draft-only. Deleted on successful publish. WooCommerce is the source of truth for product data.
|
||||
|
||||
| Column | Type | Notes |
|
||||
|---|---|---|
|
||||
| `id` | TEXT PK | client-generated UUID |
|
||||
| `sku` | TEXT UNIQUE NOT NULL | `[A-Z0-9-]{3,24}` printable ASCII subset |
|
||||
| `status` | TEXT NOT NULL | draft \| calibrated \| ready \| publishing \| published \| failed |
|
||||
| `length_in` | REAL NOT NULL | 1/8" step |
|
||||
| `thickness_in` | REAL NOT NULL | 1/8" step |
|
||||
| `sqft` | REAL | 2 decimals |
|
||||
| `bdft` | REAL | 2 decimals |
|
||||
| `width_min_in` | REAL | min width at 6" stations, 2 decimals |
|
||||
| `width_max_in` | REAL | max width at 6" stations, 2 decimals |
|
||||
| `width_avg_in` | REAL | avg width at 6" stations, 2 decimals |
|
||||
| `species_id` | INTEGER | → woo_categories (leaf only, 1 required) |
|
||||
| `wood_category_ids` | INTEGER[] JSON | → woo_categories (leaf, 1+) |
|
||||
| `edge_type_term_id` | INTEGER | → woo_attribute_terms (1 required) |
|
||||
| `figure_term_ids` | INTEGER[] JSON | → woo_attribute_terms (1+) |
|
||||
| `grade_term_ids` | INTEGER[] JSON | → woo_attribute_terms (1+) |
|
||||
| `thickness_term_id` | INTEGER | → woo_attribute_terms (round-up) |
|
||||
| `moisture_term_id` | INTEGER | → woo_attribute_terms (1 required, defaults to kiln-dried term; user overrides; not inferred) |
|
||||
| `fig_tag_ids` | INTEGER[] JSON | → woo_tags (fig-*, 1+) |
|
||||
| `feat_tag_ids` | INTEGER[] JSON | → woo_tags (feat-*, 0+) |
|
||||
| `price_per_bdft` | REAL | from pricing_rules for species |
|
||||
| `price` | REAL NOT NULL | 2 decimals |
|
||||
| `price_source` | TEXT | 'recommendation' \| 'override' |
|
||||
| `title` | TEXT | |
|
||||
| `short_title` | TEXT | |
|
||||
| `description` | TEXT | |
|
||||
| `content_source` | TEXT | 'manual' \| 'llm' |
|
||||
| `client_rev` | INTEGER NOT NULL DEFAULT 1 | |
|
||||
| `server_rev` | INTEGER NOT NULL DEFAULT 1 | |
|
||||
| `created_at` | TEXT NOT NULL | |
|
||||
| `updated_at` | TEXT NOT NULL | |
|
||||
| `inference_thread_id` | TEXT | optional stateful provider id; delete on publish or abandon |
|
||||
|
||||
Indexes: `sku` (unique), `status`, `updated_at`.
|
||||
|
||||
### Status state machine
|
||||
|
||||
```
|
||||
draft → calibrated → ready → publishing → published
|
||||
│ │ ↑
|
||||
└→ failed ────┴────────────────┘ (woo_product_id in sync_log)
|
||||
any → failed (with error)
|
||||
```
|
||||
|
||||
1. **draft** — photos taken, no mask confirmed.
|
||||
2. **calibrated** — BG edge confirmed, length axis confirmed, length/thickness/SKU entered. Client computed sqft/bdft/widths. Draft uploaded to server.
|
||||
3. **ready** — SKU, length_in, thickness_in, client sqft/bdft/widths, ≥1 inventory PNG, exactly one species, ≥1 wood category, 1 edge type, ≥1 figure term, ≥1 fig-* tag, ≥1 grade, thickness_term_id (round-up from measured thickness), 1 moisture (default kiln-dried; user override; not inferred), price. feat-* 0+. Title/short_title/description may be typed, generated, or written by templates at publish. Call 1 may prefill only the vision subset when confidence ≥ 0.7. Inference optional. Publishable.
|
||||
4. **publishing** — Woo create in flight. Poll for result.
|
||||
5. **published** — Woo product created. This row + all photos deleted. sync_log retained.
|
||||
6. **failed** — pipeline, inference, or publish error. Error detail stored. Retry available.
|
||||
|
||||
---
|
||||
|
||||
## slab_photos
|
||||
|
||||
| Column | Type | Notes |
|
||||
|---|---|---|
|
||||
| `id` | TEXT PK | |
|
||||
| `slab_id` | TEXT FK → slabs.id ON DELETE CASCADE | |
|
||||
| `kind` | TEXT NOT NULL | 'inventory' |
|
||||
| `role` | TEXT | 'topdown' \| 'extra' |
|
||||
| `seq` | INTEGER | display order |
|
||||
| `original_path` | TEXT | relative to images volume |
|
||||
| `processed_path` | TEXT | relative to images volume |
|
||||
| `created_at` | TEXT | |
|
||||
|
||||
Rule: only inventory photos. Calibration photos do not exist in this design.
|
||||
|
||||
---
|
||||
|
||||
## pricing_rules
|
||||
|
||||
| Column | Type | Notes |
|
||||
|---|---|---|
|
||||
| `id` | TEXT PK UUID | |
|
||||
| `species` | TEXT NOT NULL | matches woo_categories.name (leaf) |
|
||||
| `price_per_bdft` | REAL NOT NULL | flat, no tiers |
|
||||
| `created_at` | TEXT | |
|
||||
| `updated_at` | TEXT | |
|
||||
|
||||
`UNIQUE(species)`. Seeded from synced Woo categories. User sets price per species.
|
||||
|
||||
---
|
||||
|
||||
## woo_categories
|
||||
|
||||
| Column | Type | Notes |
|
||||
|---|---|---|
|
||||
| `woo_id` | INTEGER PK | |
|
||||
| `name` | TEXT | |
|
||||
| `slug` | TEXT | |
|
||||
| `parent_id` | INTEGER | NULL for root |
|
||||
| `synced_at` | TEXT | |
|
||||
|
||||
Only species + wood category. Sync pulls all Woo categories; the app filters to the two we use.
|
||||
Species leaves from this table (after filter) are the **only** species the UI and Call 1 may offer.
|
||||
|
||||
`UNIQUE(woo_id)`.
|
||||
|
||||
---
|
||||
|
||||
## woo_attributes
|
||||
|
||||
| Column | Type | Notes |
|
||||
|---|---|---|
|
||||
| `woo_id` | INTEGER PK | |
|
||||
| `name` | TEXT | |
|
||||
| `slug` | TEXT | |
|
||||
| `type` | TEXT | 'select' |
|
||||
| `synced_at` | TEXT | |
|
||||
|
||||
Only the five first-class attributes: Edge Type, Figure, Grade, Thickness, Moisture.
|
||||
|
||||
`UNIQUE(woo_id)`.
|
||||
|
||||
---
|
||||
|
||||
## woo_attribute_terms
|
||||
|
||||
| Column | Type | Notes |
|
||||
|---|---|---|
|
||||
| `woo_id` | INTEGER PK | |
|
||||
| `attribute_id` | INTEGER FK → woo_attributes.woo_id | |
|
||||
| `name` | TEXT | |
|
||||
| `slug` | TEXT | |
|
||||
| `synced_at` | TEXT | |
|
||||
|
||||
The actual selectable values. E.g., "Cathedral" under Figure, "1\"–1 1/2\"" under Thickness.
|
||||
|
||||
`UNIQUE(woo_id)`.
|
||||
|
||||
---
|
||||
|
||||
## woo_tags
|
||||
|
||||
| Column | Type | Notes |
|
||||
|---|---|---|
|
||||
| `woo_id` | INTEGER PK | |
|
||||
| `name` | TEXT | |
|
||||
| `slug` | TEXT | |
|
||||
| `synced_at` | TEXT | |
|
||||
|
||||
Only `fig-*` and `feat-*` tags. Other Woo tags ignored. WooCommerce is the
|
||||
authoritative source for tag definitions; this table caches synced Woo tags.
|
||||
|
||||
`UNIQUE(woo_id)`.
|
||||
|
||||
---
|
||||
|
||||
## settings
|
||||
|
||||
| Column | Type | Notes |
|
||||
|---|---|---|
|
||||
| `key` | TEXT PK | |
|
||||
| `value_enc` | BLOB | AES-GCM ciphertext |
|
||||
| `updated_at` | TEXT | |
|
||||
|
||||
Keys (server settings; secrets encrypted at rest):
|
||||
- `woo_wp_username`, `woo_app_password`
|
||||
(username + WordPress application password for a dedicated low-priv WP user;
|
||||
not WooCommerce consumer keys)
|
||||
- Store URL is **not** a settings row: `WOO_BASE_URL` env (compose), HTTPS only
|
||||
- `inference_base_url`, `inference_api_key`, `inference_model`
|
||||
- `inference_enabled`, `content_llm_enabled`
|
||||
- `brand_voice`, `geo_context`
|
||||
- `woo_create_status` ('draft' | 'publish'), default `draft` — Woo product create status key only; safety policy lives in AGENTS.md §5
|
||||
- `taxonomy_last_sync_at` — UTC ISO; every successful Woo taxonomy pull
|
||||
- `taxonomy_anchor` — UTC ISO; bump only when stored-subset hash changes (incl. deletions). No-op sync does not bump. Not Woo `date_modified`.
|
||||
- `aspect_ratio` ('3:4'), `output_px` (1600), `output_px_min` (1600), `fill_target` (0.80)
|
||||
|
||||
|
||||
Sheet/slider prefs (`sheet_mode`, sensitivity, edge offset, feather) persist in client
|
||||
localStorage only. Client localStorage is source of truth (AGENTS §4 / ARCHITECTURE §5).
|
||||
Server sync of these prefs is deferred post-POC.
|
||||
|
||||
**Prompt files** — not in the settings table. Shipped as text files in
|
||||
`backend/app/prompts/` (repo), bind-mounted read-only to `/app/prompts/` in the
|
||||
FastAPI container. Read fresh on each invocation. Default prompts shipped with the
|
||||
app; owner edits on the host; no restart needed. A reset-to-default script
|
||||
restores originals from the app bundle.
|
||||
|
||||
This table is the ciphertext **key inventory** and schema only. The never-browser /
|
||||
AES-GCM / `SLAB_AES_KEY` env policy lives in AGENTS.md §4; flow lives in ARCHITECTURE §5.
|
||||
|
||||
AES master key is an env var (`SLAB_AES_KEY`, 32 bytes base64). Never in the DB.
|
||||
Each `value_enc` = `nonce(12) || ciphertext`.
|
||||
|
||||
---
|
||||
|
||||
## sync_log
|
||||
|
||||
Append-only history. Not deleted on publish.
|
||||
|
||||
| Column | Type | Notes |
|
||||
|---|---|---|
|
||||
| `id` | INTEGER PK AUTOINCREMENT | |
|
||||
| `slab_id` | TEXT FK | |
|
||||
| `sku` | TEXT | |
|
||||
| `woo_product_id` | INTEGER | |
|
||||
| `status` | TEXT | 'success' \| 'failed' |
|
||||
| `http_status` | INTEGER | |
|
||||
| `error` | TEXT | machine-readable code |
|
||||
| `detail` | TEXT | Woo error body |
|
||||
| `payload_hash` | TEXT | sha256 of Woo payload sent |
|
||||
| `created_at` | TEXT | |
|
||||
|
||||
---
|
||||
|
||||
## inference_log
|
||||
|
||||
Append-only audit.
|
||||
|
||||
| Column | Type | Notes |
|
||||
|---|---|---|
|
||||
| `id` | INTEGER PK AUTOINCREMENT | |
|
||||
| `slab_id` | TEXT FK | |
|
||||
| `call` | TEXT | '1' \| '2' |
|
||||
| `request_payload` | TEXT | JSON of what was sent |
|
||||
| `response_payload` | TEXT | JSON of what came back |
|
||||
| `created_at` | TEXT | |
|
||||
@@ -0,0 +1,196 @@
|
||||
# Deployment Playbook (Docker Compose + Caddy)
|
||||
|
||||
Status: SPEC · env-agnostic · 2026-09-01 · SlabUploader
|
||||
|
||||
**Target:** any inventoried Docker host. Fill `deploy/INVENTORY.md` before bind.
|
||||
Variables: `APP_HOST`, `APP_HOSTNAME`, `FRONTEND_PORT`, `FASTAPI_PORT`.
|
||||
Health URL: `https://${APP_HOSTNAME}/api/health`.
|
||||
|
||||
> Gate A freeze (2026-08-31): hybrid client owns measure/crop SoT. Server-side measure/crop scaffolding is not done.
|
||||
> server ruler/pipeline as authoritative. Woo UAT = draft + `SLAB-UAT-*` on PROD.
|
||||
> Inference is **not** on the app host by default. It is a remote configurable endpoint.
|
||||
|
||||
**Historical lab example (non-normative):** TyUBUMini at `192.168.1.201` with hostname
|
||||
`slab.tyubumini.local` was one early candidate. The inventory worksheet determines the host.
|
||||
Use the inventory worksheet.
|
||||
|
||||
## 1. Host inventory (required before deployment)
|
||||
|
||||
Before deploying, fill `deploy/INVENTORY.md` (hard rule: inventory before execute):
|
||||
|
||||
1. `docker ps` — existing containers, published ports (avoid collisions with
|
||||
co-resident apps; bolt.diy is one known example of 8443-class port use).
|
||||
2. Caddy config location (host file or container) and existing site blocks.
|
||||
3. Free port choice for the compose stack (proposal: `18080` http, `18443` https —
|
||||
verify free on **this** host).
|
||||
4. Docker volume dir convention on the host.
|
||||
5. Reachability: confirm `${APP_HOSTNAME}` is reachable from the phone network
|
||||
(same LAN or Tailscale). Add DNS (Pi-hole/AdGuard or Tailscale MagicDNS) if needed:
|
||||
one A record → `${APP_HOST}` (LAN and/or Tailscale name).
|
||||
|
||||
Write the chosen values into `deploy/INVENTORY.md` and the host `.env` before compose up.
|
||||
|
||||
## 2. Repository layout (at scaffold)
|
||||
|
||||
```
|
||||
SlabUploader/
|
||||
├── AGENTS.md
|
||||
├── README.md
|
||||
├── docs/ # this plan set
|
||||
├── deploy/
|
||||
│ ├── docker-compose.yml
|
||||
│ ├── Caddyfile.fragment # slab site block (merged into host Caddy)
|
||||
│ ├── INVENTORY.md # filled at Gate B on the designated host
|
||||
│ ├── .env.example # SLAB_AES_KEY, APP_*, WOO_BASE_URL (https only)
|
||||
│ └── backup.sh # sqlite .backup + images tar, with retention
|
||||
├── frontend/ # SvelteKit app
|
||||
└── backend/ # FastAPI app
|
||||
├── app/
|
||||
│ ├── main.py
|
||||
│ ├── (no happy-path pipeline SoT here) # hybrid: TECH-SPEC modules live in frontend/ or shared pure TS; backend = store/Woo/inference/U2Net stub
|
||||
│ ├── woo/ # Woo client, taxonomy, publish
|
||||
│ ├── inference/ # vision + content wrappers (toggleable)
|
||||
│ ├── prompts/ # call1.txt, call2.txt (shipped defaults)
|
||||
│ ├── api/ # routers (slabs, photos, pricing, taxonomy, settings)
|
||||
│ ├── db/ # schema.sql, models, migrations (alembic)
|
||||
│ └── security/ # AES-GCM settings encryption
|
||||
├── tests/ # pytest — unit (deterministic) + integration (woo)
|
||||
└── Dockerfile
|
||||
```
|
||||
|
||||
## 3. docker-compose.yml (checked in)
|
||||
|
||||
Canonical file: `deploy/docker-compose.yml`. Placeholders: `deploy/.env.example`.
|
||||
From **repo root**:
|
||||
|
||||
```
|
||||
cp deploy/.env.example deploy/.env && chmod 600 deploy/.env
|
||||
# fill SLAB_AES_KEY, APP_HOSTNAME, WOO_BASE_URL (https:// only)
|
||||
docker compose -f deploy/docker-compose.yml --env-file deploy/.env up -d
|
||||
```
|
||||
|
||||
Caddy site block: `deploy/Caddyfile.fragment` (merge into host Caddy). Compose
|
||||
service names `frontend` and `fastapi` must match that fragment.
|
||||
|
||||
**Store URL (`WOO_BASE_URL`):**
|
||||
|
||||
- Set in `deploy/.env` and injected into the FastAPI container at start.
|
||||
- **HTTPS only.** FastAPI must refuse missing / `http://` values at startup.
|
||||
- Not a Settings input and **not** a SQLite settings row.
|
||||
- Settings UI shows the URL as **read-only** text (which store this deploy hits).
|
||||
- Change store = edit `.env` + recreate the fastapi container.
|
||||
|
||||
Woo Application Password stays out of compose. Enter it in Settings; FastAPI stores it AES-GCM encrypted.
|
||||
|
||||
Conventions (house rules):
|
||||
|
||||
- `mem_limit` on services (not host-level tuning).
|
||||
- Services bind to 127.0.0.1 only; Caddy is the only public surface.
|
||||
- No `latest` tags on base images — pin versions.
|
||||
- `restart: unless-stopped`; compose is the supervisor (no systemd units).
|
||||
|
||||
## 4. Caddy (fragment)
|
||||
|
||||
Canonical file: `deploy/Caddyfile.fragment`. API must win over the frontend
|
||||
catch-all. Put `handle /api/*` **before** the frontend `handle` block:
|
||||
|
||||
```caddyfile
|
||||
{$APP_HOSTNAME} {
|
||||
tls internal # Caddy internal CA (or host-appropriate TLS)
|
||||
handle /api/* {
|
||||
reverse_proxy fastapi:8000
|
||||
}
|
||||
handle {
|
||||
reverse_proxy frontend:80 {
|
||||
header_up X-Real-IP {remote_host}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- HTTPS enforced (http→https redirect automatic when using Caddy TLS).
|
||||
- If the host Caddy already serves a wildcard for this domain family, add this as a
|
||||
site block. Verify the cert is trusted on the phone.
|
||||
- LAN/Tailscale only — no public exposure for POC.
|
||||
|
||||
## 5. Secrets
|
||||
|
||||
| Secret | Where | Notes |
|
||||
|---|---|---|
|
||||
| `SLAB_AES_KEY` | `deploy/.env` (mode 600) → compose env | 32-byte base64; NEVER in the repo |
|
||||
| `WOO_BASE_URL` | `deploy/.env` → compose → FastAPI env | `https://` only; Settings **read-only** |
|
||||
| Woo WP username + application password | SQLite `settings` (AES-GCM) via Settings UI | dedicated low-priv WP user; not consumer keys |
|
||||
| Inference API key | SQLite `settings` (AES-GCM) via Settings UI | usually empty for local llama.cpp |
|
||||
| `.env.example` | repo | placeholders only, committed |
|
||||
|
||||
**Woo credential setup (once per store):**
|
||||
|
||||
1. Store must already be on **HTTPS**. WordPress will not offer Application
|
||||
Passwords over plain HTTP.
|
||||
2. Create a dedicated low-privilege WordPress user (shop manager, or custom role
|
||||
with product create/edit + media upload). Admin logins are not reused.
|
||||
3. Sign in as that user → **Users → Profile → Application Passwords** → generate
|
||||
a password for "SlabUploader".
|
||||
4. Enter the username + application password in the app Settings UI. FastAPI
|
||||
stores them AES-GCM encrypted. Browser never keeps them.
|
||||
5. Rotate by revoking the old application password in the WP profile and
|
||||
re-entering a new one in Settings, then `test-woo`.
|
||||
|
||||
Key rotation (AES): `SLAB_AES_KEY` change requires re-encrypting settings —
|
||||
provide `python -m app.security.rekey NEW_KEY` (reads old key from env
|
||||
`SLAB_AES_KEY_OLD`, decrypts, re-encrypts, updates). Documented in playbook §7.
|
||||
|
||||
## 6. Backup and restore
|
||||
|
||||
`deploy/backup.sh` (run via cron on the app host, e.g. daily 03:00):
|
||||
|
||||
```
|
||||
1. sqlite3 /data/slab.db ".backup /backups/slab-YYYYMMDD.db" # WAL-safe
|
||||
2. tar czf /backups/slab-images-YYYYMMDD.tgz -C /images .
|
||||
3. retain: 7 daily + 4 weekly; prune older
|
||||
4. log result to /var/log/slab-backup.log; alert (Hermes) on failure
|
||||
```
|
||||
|
||||
Prompt files live in the git repo (`backend/app/prompts/`) and are version-controlled;
|
||||
they do not need a separate backup. The `reset-to-default` script restores shipped
|
||||
defaults from the app bundle.
|
||||
|
||||
- Backup target: local dir first; mirror to an off-box share for safety (same pattern
|
||||
as other stacks on the host).
|
||||
- **Restore**: stop compose → restore DB file + untar images → start compose.
|
||||
Tested at least once during Phase 6 (UAT) before declaring backup done.
|
||||
|
||||
## 7. Operations runbook
|
||||
|
||||
| Task | Command / how |
|
||||
|---|---|
|
||||
| Deploy (first) | inventory §1 → `docker compose -f deploy/docker-compose.yml build` → `up -d` → Caddy reload → smoke §8 |
|
||||
| Update | `git pull` → `docker compose build fastapi frontend` → `up -d` (DB migrations run on start via alembic) |
|
||||
| Logs | `docker compose logs -f fastapi` / `frontend` |
|
||||
| Health | `curl -k https://${APP_HOSTNAME}/api/health` |
|
||||
| Rekey AES | §5 |
|
||||
| Rotate Woo creds | WP user profile → revoke old application password → new one → Settings UI → `POST /api/v1/settings/test-woo` |
|
||||
| Rotate inference key | Settings UI (if external) |
|
||||
| Edit a prompt | Edit the text file on host, no restart needed (read fresh each time) |
|
||||
| Rollback image | `git checkout <tag>` → rebuild → up -d; DB down-migrations provided for schema changes |
|
||||
| Reset one slab | delete its photos dir + `UPDATE slabs SET status='draft'` (admin script) |
|
||||
|
||||
## 8. Smoke test (after every deploy)
|
||||
|
||||
1. `GET https://${APP_HOSTNAME}/api/health` → ok (from phone over HTTPS once CA trust is proven)
|
||||
2. Settings: WP application password + `test-woo` ok; `woo_create_status=draft`; inference OFF for early gates
|
||||
3. Create a slab → upload test photos (fixture set) → poll to `ready`
|
||||
4. Verify bdft/price match the fixture's expected values (golden test; bdft = sqft × thickness)
|
||||
5. Publish test slab with SKU `SLAB-UAT-…` → slab may show local `published`; Woo product must be **draft**
|
||||
6. Leave or delete the Woo draft UAT product (never customer-visible). Final live publish is a non-UAT SKU after Ty review.
|
||||
|
||||
For the full human-run walk (host check → App Password → one `SLAB-UAT-*` prove → confirm →
|
||||
cleanup), see `docs/UAT-PRACTICE.md`.
|
||||
|
||||
## 9. Scaling path (not v1)
|
||||
|
||||
- Postgres when SQLite write contention appears (schema already portable).
|
||||
- Redis+arq when in-process queue shows lag.
|
||||
- GPU on the app host only if on-demand U2Net latency becomes a bottleneck. Keep CPU
|
||||
first. Never lower `output_px_min` below 1600; undersized source means retake, not a
|
||||
smaller min. RemBG is not part of the hybrid happy path.
|
||||
@@ -0,0 +1,336 @@
|
||||
# Implementation Plan
|
||||
|
||||
Status: SPEC · Gate A freeze 2026-08-31 · hybrid rewrite complete 2026-09-01 · SlabUploader
|
||||
|
||||
Phases 0–6 below are hybrid-aligned. Phase 2 remains a deferred stub only; offline PWA, OCR, ruler, and server happy-path measure/crop as SoT are not implemented.
|
||||
|
||||
The build plan a professional (or a fresh Hermes session) executes against. Each phase
|
||||
has: goal, deliverables, acceptance criteria (testable), and exit gate. Ordering is by
|
||||
dependency. Definition of done for every phase: code + tests + real
|
||||
execution output in the PR/commit message + docs updated.
|
||||
|
||||
Ground rules (from AGENTS.md):
|
||||
|
||||
- Deterministic core is unit-tested before any UI consumes it.
|
||||
- No feature without a passing test and shown output.
|
||||
- Inference stays toggleable; the full suite must pass with it disabled.
|
||||
- One phase = a reviewable, shippable increment.
|
||||
|
||||
Test specification: `docs/TEST-SPEC.md` — pyramid cases, phased by gate, affirmative only.
|
||||
|
||||
---
|
||||
|
||||
## Phase 0 — Hybrid skeleton and client deterministic core (= Gates A+B)
|
||||
|
||||
Goal: scaffold the hybrid app and ship a **tested client-authoritative** TECH-SPEC
|
||||
pipeline (mask → axis → sqft/bdft → 3:4 PNG) plus a minimal FastAPI store/settings
|
||||
shell. No Woo publish, no inference, no offline/OCR/ruler.
|
||||
|
||||
**Gate A (docs):** AGENTS.md + this Phase 0 rewrite + review package locks (signed
|
||||
2026-08-31). **Gate B (infra/code):** items below.
|
||||
|
||||
Scope — in:
|
||||
|
||||
- Repo layout: `frontend/` (SvelteKit), `backend/` (FastAPI), `deploy/` stubs; docs stay SoT.
|
||||
- Client pure modules implementing TECH-SPEC: constants, sheet/chroma/black + sliders,
|
||||
mask confirm hooks, length axis, scale, 6" widths, sqft, **bdft = sqft × thickness_in**,
|
||||
pricing rec from species $/bdft, 3:4 PNG / 80% fill / ≥1600 guard (**no upscale**).
|
||||
- FastAPI: app factory, logging, config, `GET /api/health`, SQLite + alembic skeleton,
|
||||
settings stub with AES-GCM round-trip (secrets never returned on GET), draft store stub
|
||||
(accept client numbers + files — store only, no server-side recompute of SoT).
|
||||
- `deploy/docker-compose.yml`, `Caddyfile.fragment`, `.env.example` (placeholders only).
|
||||
- Unit tests for TECH-SPEC **TV-1 through TV-10** (esp. TV-3 bdft, TV-6/7 crop/no-upscale,
|
||||
TV-8 pricing, TV-10 rounding).
|
||||
- Fixture photo set for Ty Gate B review.
|
||||
|
||||
Phase 0 covers:
|
||||
|
||||
- Server happy-path pipeline / CLI that recomputes mask/crop/bdft as SoT: client is authoritative; server stores client-sent numbers only.
|
||||
- Real U2Net: stub OK; on-demand later. The one-photo server path stays in architecture.
|
||||
- Woo create/publish and live taxonomy sync: Phase 1. Species offered in UI, pricing seeds, and Call 1 are **exactly** Woo-synced species-category leaves (CONTENT-WOO §2.2 / AGENTS §6 / DATA-MODEL `woo_categories`).
|
||||
- Inference Call 1/2, LoRA: Phase 5.
|
||||
- Offline PWA, IndexedDB sync, OCR, ruler: Phase 2 (deferred stub).
|
||||
- Fake upscale / inventing pixels: undersized source warns and requires retake.
|
||||
|
||||
Deliverables:
|
||||
|
||||
- `frontend/` scaffold + deterministic modules + unit tests TV-1…TV-10.
|
||||
- `backend/` FastAPI health + settings encryption + draft persistence stub + schema migration baseline.
|
||||
- `deploy/` compose + `.env.example` + Caddy fragment.
|
||||
- Shown output in PR/commit: printed TV-3, TV-6, TV-7 (no-upscale/retake), and TV-10 on **real fixtures**.
|
||||
|
||||
Acceptance:
|
||||
|
||||
- Gate B: client (or shared pure) tests green for TECH-SPEC vectors; bdft identity holds
|
||||
(no `/12`); undersized source warns and refuses fake upscale; rounding matches TV-10.
|
||||
- `GET https://${APP_HOSTNAME}/api/health` up in compose after `deploy/INVENTORY.md` is filled and Caddy is merged.
|
||||
- Settings AES round-trip; secret fields never appear in GET.
|
||||
- Draft can store client-uploaded originals + PNGs + numbers without server recompute.
|
||||
- Coverage target: deterministic modules ≥ 95% where practical; property checks
|
||||
(bdft monotonic in thickness/sqft; fill band; no upscale path).
|
||||
|
||||
Exit gate: Ty confirms Gate B math/image prep shown output on real fixtures; health +
|
||||
encrypted settings + draft store stub demonstrated (curl or thin UI).
|
||||
|
||||
**Next:** Gate C = early Phase 1 claimable publish — online hybrid capture + Woo **draft**
|
||||
+ `SLAB-UAT-*` on the configured store, inference OFF. Call 1/2 after Gate C, still inside POC.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Gate C claimable path, then full API surface (rewritten)
|
||||
|
||||
### Early Phase 1 / Gate C slice (claimable first draft listing)
|
||||
|
||||
Goal: one Woo **draft** listing Ty can open in store admin, with inference **OFF**.
|
||||
Online hybrid capture only. No OCR, ruler, or offline.
|
||||
|
||||
Deliverables:
|
||||
|
||||
- Online capture of 1–5 inventory photos (no airplane-mode requirement).
|
||||
- Client sheet + **four knobs inline** on capture (live re-run, mask overlay on
|
||||
source photo); user confirm; length axis overlay + confirm. No U2Net control.
|
||||
- Client sqft / bdft / 6" widths; 3:4 transparent PNG prep per TECH-SPEC.
|
||||
- Manual SKU, length, thickness; manual taxonomy as needed for one listing.
|
||||
- Calibrated draft upload: originals + processed PNGs + client-sent numbers. Server
|
||||
stores only (OPENAPI POST returns `status: calibrated`).
|
||||
- Woo **draft** create with SKU prefix `SLAB-UAT-*` on the configured PROD store.
|
||||
Honor AGENTS Woo safety: `SLAB-UAT-*` forces draft even if Settings say publish.
|
||||
- Inference remains OFF for this slice. Full suite still green with inference disabled.
|
||||
|
||||
Acceptance:
|
||||
|
||||
- Ty captures a real slab on phone (online), adjusts any mask knob and sees a fresh
|
||||
overlay without leaving the screen, confirms mask + axis, sees client numbers.
|
||||
- Species picker offers **only** Woo-synced species (after test-woo / sync).
|
||||
- One `SLAB-UAT-*` product appears in Woo admin as **draft** with correct SKU/price/images
|
||||
enough for Ty to open and inspect.
|
||||
- No Call 1/Call 2 required. No offline, OCR, ruler, or U2Net path exercised.
|
||||
- Duplicate SKU → stop with field-level recovery; no second product.
|
||||
|
||||
Exit gate: Ty opens one draft listing in Woo admin. Gate C is green.
|
||||
|
||||
### Phase 1 remainder (API / settings / taxonomy cache)
|
||||
|
||||
Goal: full slab CRUD + photo upload + settings + taxonomy cache read, still hybrid.
|
||||
No server happy-path math. No `processing` slab status.
|
||||
|
||||
Deliverables:
|
||||
|
||||
- Routers per OPENAPI: slabs (CRUD + upload + poll), photos (serve), pricing-rules,
|
||||
settings (AES-GCM + test stubs), taxonomy (cache read/write + manufactured
|
||||
`taxonomy_anchor` / `taxonomy_last_sync_at`).
|
||||
- Optimistic concurrency (`client_rev` / `server_rev`, 409 paths).
|
||||
- Status machine only: `draft → calibrated → ready → publishing → published | failed`.
|
||||
Job queue only for publish and inference (later). U2Net-on-demand is deferred from POC. Never for measure/crop.
|
||||
- Integration tests (httpx TestClient): create calibrated → poll → ready (manual fields);
|
||||
revision conflict; duplicate SKU; settings encryption round-trip (secret never returned);
|
||||
taxonomy anchor bumps on stored-subset change and stays put on no-op sync.
|
||||
|
||||
Acceptance:
|
||||
|
||||
- OPENAPI.md endpoints implemented; a generated `openapi.yaml` matches the doc.
|
||||
- Integration tests green; secrets never leak into any GET response (asserted).
|
||||
- `docker compose up` (backend + a throwaway frontend stub) serves the API.
|
||||
- PUT stores client-sent derived fields; does not recompute sqft/bdft/widths as SoT.
|
||||
- Taxonomy: anchor advances only on real stored-subset change; client re-pulls only
|
||||
when anchor is newer; `test-woo` always resyncs; publish respects the 1h threshold.
|
||||
|
||||
Exit gate: Ty drives the API with curl through a full calibrated → ready cycle (manual).
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Deferred: offline PWA / OCR / ruler (stub only)
|
||||
|
||||
**Deferred post-POC.** The online-only MVP does not implement offline capture.
|
||||
|
||||
Capture UX for POC lives in early Phase 1 / Gate C (online hybrid). See AGENTS.md §2.
|
||||
|
||||
Offline PWA, OCR, and ruler detection are deferred to a later product decision:
|
||||
|
||||
- Workbox service worker, IndexedDB draft sync, airplane-mode capture
|
||||
- Tesseract.js OCR for SKU
|
||||
- OpenCV.js ruler / scale detection
|
||||
|
||||
No airplane-mode acceptance. No OCR or ruler exit gate in POC.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Frontend: enrich, review, publish UI (rewritten)
|
||||
|
||||
Goal: online review + publish UI on top of Gate C calibrated drafts. Hybrid only.
|
||||
Client already owns mask/numbers/PNGs. This phase does not re-measure on the server.
|
||||
|
||||
Deliverables:
|
||||
|
||||
- Review screen after calibrated upload: all fields editable (length, thickness, SKU,
|
||||
widths/sqft/bdft shown as client-computed with manual override paths, species and
|
||||
wood categories, five attributes, fig-*/feat-* tags, price with species $/bdft
|
||||
recommendation, title/short/description).
|
||||
- Show client-processed transparent PNGs (and originals while retained). No server
|
||||
re-crop UI as SoT.
|
||||
- Inference UI (only meaningful after Gate C and when `inference_enabled`): Call 1
|
||||
results with confidence. **≥ 0.7** (configurable default) → pre-fill mutable
|
||||
fields. **< 0.7** → leave empty; inline nudges. Continue/ready is blocked until
|
||||
the **full ready set** is filled — not species + wood category + figure alone.
|
||||
Canonical lock (AGENTS §7 / DATA-MODEL slabs **ready**): SKU, length, thickness,
|
||||
client sqft/bdft/widths, ≥1 inventory PNG, exactly one species, ≥1 wood category,
|
||||
1 edge type, ≥1 figure term, ≥1 fig-* tag, ≥1 grade, thickness store band
|
||||
(round-up), 1 moisture (default kiln-dried; not inferred), price. feat-* 0+.
|
||||
Title/description may be typed, generated, or templated at publish. Presentation:
|
||||
`docs/UX.md`. "Generate text" triggers Call 2; never auto.
|
||||
- Capture: four knobs on the mask screen with live re-run. No U2Net control in POC.
|
||||
- Publish button (online-only), poll `publishing` → `published` | `failed`, surface
|
||||
error detail from sync_log.
|
||||
- Settings UI: WP username + application password + test-woo, inference endpoint +
|
||||
test (vision + portable-context probe), pricing rules editor, brand voice/GEO,
|
||||
`woo_create_status`. Store URL read-only from `WOO_BASE_URL`. Sheet/slider prefs
|
||||
stay in client localStorage. Prompts are server files, not UI settings.
|
||||
- Secrets never appear in GET settings responses (masked `*_configured` only).
|
||||
|
||||
Acceptance:
|
||||
|
||||
- Browser round-trip: online capture → mask/axis confirm → calibrated upload →
|
||||
review → edit → publish → local `published` (Woo mock in tests; real store Phase 6).
|
||||
- Inference OFF → full manual taxonomy + content path works; no inference network calls.
|
||||
- Field overrides persist across poll/refresh (asserted).
|
||||
- No offline/IndexedDB requirement. No OCR/ruler UI.
|
||||
|
||||
Exit gate: Ty publishes a test slab to the real store as a **Woo draft** (`SLAB-UAT-*`).
|
||||
(Gate A lock: not pending/published for UAT.)
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — WooCommerce integration (rewritten)
|
||||
|
||||
Goal: production Woo sync, taxonomy, dedupe, logging. Browser never calls Woo.
|
||||
Media bytes are client-processed **PNGs**. Safety policy from AGENTS §5.
|
||||
|
||||
Deliverables:
|
||||
|
||||
- Woo client (httpx, Basic auth with WP username + application password, timeouts),
|
||||
taxonomy sync into cache tables, auto-assign per CONTENT-WOO §2.3 (categories +
|
||||
five attributes + fig-*/feat-* tags only). No WooCommerce consumer keys.
|
||||
- Media upload of processed PNG inventory photos; product create via `woo_create_status`
|
||||
(default **draft**). If SKU matches `SLAB-UAT-*`, force Woo **draft** even when
|
||||
Settings say publish.
|
||||
- Dedupe by SKU before create (any Woo status); `409 duplicate_sku` with field recovery
|
||||
paths (edit SKU / open existing); no edit-same-SKU path in MVP.
|
||||
- `422 stale_field` recovery with current options from cache (CONTENT-WOO §2.6).
|
||||
- `sync_log` with payload_hash; on success set local `published`, purge originals +
|
||||
processed images on server.
|
||||
- `POST /api/v1/admin/taxonomy/sync` against the real store; manufactured anchor.
|
||||
- Integration tests against a Woo mock (respx) for success + error + duplicate +
|
||||
stale-field paths.
|
||||
- Partial-failure handling (orphaned media noted, per CONTENT-WOO §2.6).
|
||||
|
||||
Acceptance:
|
||||
|
||||
- Real UAT publish: product appears in store admin as **draft** under `SLAB-UAT-*`,
|
||||
correct SKU/price/PNG images/category/tags/attributes; `woo_product_id` stored;
|
||||
`sync_log` written with payload_hash.
|
||||
- Duplicate SKU publish → 409 mapped to SKU field, no second product created.
|
||||
- Stale taxonomy value → 422 on that field with current options; draft retained.
|
||||
- Woo error (bad creds / 500) → slab `failed` with detail; retry path works.
|
||||
- All Woo integration tests green (mock + one live smoke).
|
||||
|
||||
Exit gate: Ty verifies a real **Woo draft** `SLAB-UAT-*` product in the store is correct.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5 — Inference (vision + optional content) (rewritten)
|
||||
|
||||
Goal: scoped, toggleable inference **after Gate C is green**, still inside POC.
|
||||
Does not gate publish. Full suite must pass with inference OFF.
|
||||
|
||||
Deliverables:
|
||||
|
||||
- Call 1 (vision) proxy: **all inventory photos** downscaled to 1024 + Woo taxonomy
|
||||
snapshot + user metadata; OpenAI-compatible endpoint; model constrained to cached
|
||||
taxonomy **from the Woo cache only**; confidence threshold **0.7** (configurable
|
||||
default; not a POC Settings slider). Auto-run on calibrated create only if
|
||||
`inference_enabled`. Not top-down-only.
|
||||
- Apply AGENTS §6 prefill: ≥ threshold → pre-fill mutable species/categories/attributes/
|
||||
tags; < threshold → those fields stay empty. Call 1 is assist only and does not
|
||||
define ready. Ready/publish requires the **full ready set** in AGENTS §7 /
|
||||
DATA-MODEL (SKU, length, thickness, client sqft/bdft/widths, ≥1 inventory PNG,
|
||||
exactly one species, ≥1 wood category, 1 edge type, ≥1 figure term, ≥1 fig-* tag,
|
||||
≥1 grade, thickness store band round-up, 1 moisture default kiln-dried not
|
||||
inferred, price; feat-* 0+). Species + wood category + figure is not the gate.
|
||||
Presentation: `docs/UX.md`.
|
||||
- Call 2 (text) proxy: never auto. Portable Call 1 context (full resend default).
|
||||
LLM prose only; templates inject deterministic numbers. See `docs/PROMPTS.md`.
|
||||
- `test-inference` endpoint (vision + portable vs stateful probe). Fail closed if
|
||||
neither Call 1→Call 2 path works.
|
||||
- Feature flags; `inference_log` audit rows; prompt files read fresh each call.
|
||||
- No numeric/dimension invention by the LLM. LoRA deferred.
|
||||
|
||||
Acceptance:
|
||||
|
||||
- Local vision model configured: ≥ 0.7 fields pre-fill and remain editable; user
|
||||
override sticks.
|
||||
- Below 0.7: fields stay empty; UI blocks continue/ready until the full AGENTS §7
|
||||
/ DATA-MODEL ready set is filled (asserted). Species + wood category + figure
|
||||
is not the ready definition.
|
||||
- No doc/code path auto-fills below-threshold guesses or treats inference as a
|
||||
publish gate.
|
||||
- Inference OFF: app works end-to-end; tests assert zero calls to the inference endpoint.
|
||||
- Audit payloads present after a call; prompt file edits apply without server restart.
|
||||
- Gate C path remains green with inference OFF.
|
||||
|
||||
Exit gate: Ty runs species detection on 3 real slabs; results acceptable or manually
|
||||
corrected (that is the design).
|
||||
|
||||
---
|
||||
|
||||
## Phase 6 — Deployment, UAT, hardening (rewritten, online only)
|
||||
|
||||
Goal: production deploy on the **inventoried** host + online user acceptance tests.
|
||||
|
||||
Deliverables:
|
||||
|
||||
- Host inventory pass: complete `deploy/INVENTORY.md` per DEPLOYMENT §1 → final compose + Caddyfile.
|
||||
- Deploy to `https://${APP_HOSTNAME}` from inventory; backup/restore runbook executed once (restore tested).
|
||||
- **UAT suite** for the hybrid online path: scripted real slabs (≥3 species, ≥1 cathedral,
|
||||
≥1 manual-width path, ≥1 duplicate-SKU attempt) with expected bdft/price/image-fill
|
||||
asserted. **No** offline capture, OCR, or ruler cases in POC UAT.
|
||||
- Performance: client measure/crop budgets on device; optional U2Net-on-demand latency.
|
||||
No server happy-path “normalization 3–5s” budget. No OCR 1–2s budget.
|
||||
- Docs: README, runbook, key-rotation verified.
|
||||
|
||||
Acceptance (measured):
|
||||
|
||||
- E2E capture→publish on iPhone, Android, and PC browsers (desktop Chrome/Edge/Firefox) works (online).
|
||||
- Manual length override recalculates widths/sqft/bdft correctly (UAT asserts).
|
||||
- Client crop: slab ~80% frame in ≥95% of UAT photos; shorter side ≥1600 when source allows.
|
||||
- Woo product created with processed PNG images + correct taxonomy.
|
||||
- Deterministic functions unit-tested; inference calls logged for audit when used.
|
||||
- Backup restore demonstrated once.
|
||||
|
||||
Exit gate: Ty signs off on UAT results; v1 declared done.
|
||||
|
||||
---
|
||||
|
||||
## Risks and mitigations
|
||||
|
||||
| Risk | Impact | Mitigation |
|
||||
|---|---|---|
|
||||
| Green-sheet color varies | bad mask, wrong sqft | chroma-key / black threshold + inline knobs + live re-run; retake; U2Net deferred from POC; never drop `output_px_min` below 1600 |
|
||||
| No vision model available at deploy | species inference off | inference is toggleable; manual species/character fully functional — v1 not blocked |
|
||||
| Woo partial publish (media up, product POST fails) | orphaned images, confusing state | sync_log + retained images for retry; manual cleanup note; no auto-delete |
|
||||
| Local vision endpoint not up | inference off | configurable endpoint; deploy-time config; app works without it |
|
||||
| SQLite contention under concurrent polls | slowdown | single owner, low volume; Postgres path documented |
|
||||
| Host port/Caddy collision with co-resident apps (e.g. bolt.diy) | deploy conflict | Phase 0/6 inventory worksheet before any port bind |
|
||||
|
||||
Ruler detection is **deferred** with offline/OCR (Phase 2 stub). Manual length is
|
||||
authoritative for POC. Ruler confidence is not a POC risk row.
|
||||
|
||||
---
|
||||
|
||||
## Definition of Done (v1, project-level)
|
||||
|
||||
- All 6 phases exit-gated by Ty (Phase 2 remains deferred stub unless product reopens it).
|
||||
- Hybrid online acceptance met (measured in Phase 6 UAT). Gates A/B/C meanings match AGENTS §0b.
|
||||
- `pytest` + integration + UAT green; inference-off path green.
|
||||
- Deployed on the inventoried host (`APP_HOSTNAME` from `deploy/INVENTORY.md`), HTTPS, backed up, restore tested, runbook + key-rotation verified.
|
||||
- Repo on gitea-atd (`Ty_Tech/SlabUploader`) is source of truth; docs current.
|
||||
- `docs/archive/PRD-2026-08.txt` is frozen history only; live docs are the implementation SoT.
|
||||
+328
@@ -0,0 +1,328 @@
|
||||
# OpenAPI Contract — SvelteKit Client ↔ FastAPI
|
||||
|
||||
Status: SPEC · 2026-08-27 · SlabUploader
|
||||
Base path: `/api/v1`. All requests/responses JSON unless noted.
|
||||
All timestamps UTC ISO-8601.
|
||||
|
||||
## Conventions
|
||||
|
||||
### Auth
|
||||
No client auth. Trusted LAN/Tailscale + HTTPS.
|
||||
|
||||
### Errors
|
||||
Uniform envelope:
|
||||
```json
|
||||
{ "error": { "code": "string", "message": "string", "detail": "object|null" } }
|
||||
```
|
||||
- `400 validation` — payload failed validation.
|
||||
- `404 not_found` — unknown slab/photo/setting.
|
||||
- `409 revision_conflict` — client_rev/server_rev mismatch. `detail` carries `current_server_rev`.
|
||||
- `409 duplicate_sku` — SKU already exists in WooCommerce, any status (draft, private, publish, etc.).
|
||||
- `422 unpublishable` — slab not in a publishable state or missing required field.
|
||||
- `502 woo_error` — WooCommerce rejected the request. `detail` carries Woo error body.
|
||||
- `500 internal` — unexpected.
|
||||
|
||||
### Revisions
|
||||
Create/update carry `client_rev`. Server echoes `server_rev`. Client stores `server_rev` and sends it back on the next update. Mismatch → `409 revision_conflict`.
|
||||
|
||||
### Photos
|
||||
Uploaded as `multipart/form-data`. Max 40 MB per request. Two fields per photo:
|
||||
- `files[]` — the original image file
|
||||
- `meta[]` — JSON `{ kind: "inventory", role: "topdown"|"extra", seq: 1 }`
|
||||
|
||||
### Pagination
|
||||
Lists are small (single owner, slab-by-slab). Lists return full arrays.
|
||||
|
||||
### API Version
|
||||
URL is `/api/v1`. Breaking changes bump to `/api/v2`.
|
||||
|
||||
---
|
||||
|
||||
## Schemas
|
||||
|
||||
### SlabCreate
|
||||
```jsonc
|
||||
{
|
||||
"id": "uuid (client-generated)",
|
||||
"sku": "string 3..24 [A-Z0-9-]",
|
||||
"length_in": 96.0, // 1/8" step
|
||||
"thickness_in": 1.5, // 1/8" step
|
||||
"sqft": 0.64, // computed by client
|
||||
"bdft": 0.96, // computed by client
|
||||
"width_min_in": 14.0,
|
||||
"width_max_in": 22.0,
|
||||
"width_avg_in": 18.2,
|
||||
"species_id": 42, // → woo_categories (leaf)
|
||||
"wood_category_ids": [15, 23], // JSON array → woo_categories (leaf, 1+)
|
||||
"edge_type_term_id": 8, // → woo_attribute_terms
|
||||
"figure_term_ids": [3], // JSON array → woo_attribute_terms (1+)
|
||||
"grade_term_ids": [7], // JSON array → woo_attribute_terms (1+)
|
||||
"thickness_term_id": 12, // → woo_attribute_terms (round-up)
|
||||
"moisture_term_id": 5, // → woo_attribute_terms (defaults kiln-dried)
|
||||
"fig_tag_ids": [99], // JSON array → woo_tags (fig-*, 1+)
|
||||
"feat_tag_ids": [42], // JSON array → woo_tags (feat-*, 0+)
|
||||
"price_per_bdft": 12.50, // from pricing_rules
|
||||
"price": 12.00, // 2 decimals
|
||||
"price_source": "recommendation|override|null",
|
||||
"title": "string|null",
|
||||
"short_title": "string|null",
|
||||
"description": "string|null",
|
||||
"content_source": "manual|llm|null",
|
||||
"client_rev": 1
|
||||
}
|
||||
```
|
||||
Photos come as multipart `files[]` + `meta[]` alongside this JSON (wrapped in a multipart request where one part is the JSON and the others are the files).
|
||||
|
||||
### Slab (read model)
|
||||
All `SlabCreate` fields (resolved) plus:
|
||||
`status`, `species_confidence`, `woo_product_id`, `published_at`,
|
||||
`server_rev`, `created_at`, `updated_at`, `photos: [PhotoRead]`,
|
||||
`inference_status: null|inferring|done|failed`, `inference_error: string|null`.
|
||||
|
||||
### PhotoRead
|
||||
```jsonc
|
||||
{ "id": "uuid", "kind": "inventory", "role": "topdown|extra",
|
||||
"seq": 1, "original_url": "/api/v1/slabs/{id}/photos/{pid}/original",
|
||||
"processed_url": "/api/v1/slabs/{id}/photos/{pid}/processed|null" }
|
||||
```
|
||||
|
||||
### InferenceCall1Result (returned by infer-taxon)
|
||||
|
||||
Taxonomy fields are Woo IDs from the synced cache. Character/inclusions/voids/checks
|
||||
are free-text observations; the server maps them to Woo `feat-*` product tags after
|
||||
the call returns. WooCommerce is the authoritative source for `feat-*` definitions.
|
||||
|
||||
```jsonc
|
||||
{ "species_id": 42, "species_confidence": 0.92,
|
||||
"wood_category_ids": [15, 23],
|
||||
"edge_type_term_id": 8,
|
||||
"figure_term_ids": [3], "figure_confidence": 0.87,
|
||||
"grade_term_ids": [7], "grade_confidence": 0.75,
|
||||
"character": { "type": "knot", "severity": "minor" },
|
||||
"inclusions": { "type": "bark pocket", "severity": "none" },
|
||||
"voids": "none",
|
||||
"checks": "none",
|
||||
"wood_categories": [ { "name": "Live edge", "confidence": 0.95 },
|
||||
{ "name": "Bowl blank", "confidence": 0.82 },
|
||||
{ "name": "Table slab", "confidence": 0.61 } ] }
|
||||
```
|
||||
|
||||
### InferenceCall2Result (returned by infer-content)
|
||||
```jsonc
|
||||
{ "title": "string", "short_title": "string", "description": "string" }
|
||||
```
|
||||
|
||||
### PriceRule
|
||||
```jsonc
|
||||
{ "id": "uuid", "species": "string", "price_per_bdft": 12.50,
|
||||
"created_at": "timestamp", "updated_at": "timestamp" }
|
||||
```
|
||||
|
||||
### SettingsView (read; secrets masked)
|
||||
```jsonc
|
||||
{ "woo_base_url": "https://www.whidbeywoodstore.com", // WOO_BASE_URL env; read-only
|
||||
"woo_wp_username": "slab-uploader", // non-secret identity; password never returned
|
||||
"woo_credentials_configured": true,
|
||||
"inference_base_url": "http://192.168.1.202:8080/v1",
|
||||
"inference_model": "<model>", "inference_enabled": true,
|
||||
"content_llm_enabled": false,
|
||||
"brand_voice": "...", "geo_context": "...",
|
||||
"woo_create_status": "draft",
|
||||
"aspect_ratio": "3:4", "output_px": 1600, "output_px_min": 1600,
|
||||
"fill_target": 0.80 }
|
||||
```
|
||||
Secrets (application password, inference API key) are never returned. Only a
|
||||
`*_configured` boolean. Woo auth is WordPress Application Passwords
|
||||
(`woo_wp_username` + `woo_app_password` write-only), not consumer keys.
|
||||
Sheet/slider prefs are client localStorage only in POC (not in SettingsView).
|
||||
|
||||
---
|
||||
|
||||
## Endpoints
|
||||
|
||||
### Health & meta
|
||||
- `GET /api/health` → `{ "status":"ok", "version":"...", "woo_reachable": bool }` (canonical deploy/smoke path; may also be exposed as `/api/v1/health`)
|
||||
- `GET /api/v1/meta` → `{ "version":"...", "templates_configured": bool }`
|
||||
|
||||
### Slabs
|
||||
- `POST /api/v1/slabs` — Create a calibrated draft. Body: multipart with `data` (SlabCreate JSON) + `files[]` (photos) + `meta[]` (photo metadata).
|
||||
- Returns `201` `Slab` with `status: "calibrated"` (mask + axis confirmed; client-sent sqft/bdft/widths stored).
|
||||
- Server auto-runs Call 1 **only if** `inference_enabled` is true. `inference_status` becomes `inferring`, then `done`/`failed`.
|
||||
- After Call 1, persist inferred values onto the slab **only** for fields with confidence ≥ 0.7. Below 0.7 leave those columns null so GET does not prefill.
|
||||
- `409 duplicate_sku` if SKU already exists in Woo, any status (payload as PUT).
|
||||
- `GET /api/v1/slabs` → `200` `[Slab]` (most recent first).
|
||||
- `GET /api/v1/slabs/{id}` → `200` `Slab` / `404`.
|
||||
- Also used as the poll endpoint for inference and publish. Long-running ops set status to `inferring`/`publishing`; client backs off (1s, 2s, 4s, cap 10s).
|
||||
- `PUT /api/v1/slabs/{id}` — Update draft fields. Body: partial `SlabCreate` (any subset of editable fields + `client_rev`).
|
||||
- Server **stores** client-sent derived fields (`sqft`, `bdft`, widths). It may validate shape and ranges. It must **not** recompute mask, sqft, bdft, or widths as source of truth. Client re-runs TECH-SPEC math and PUTs the new numbers.
|
||||
- `409 revision_conflict`.
|
||||
- `409 duplicate_sku` → `{ "field":"sku", "code":"duplicate_sku", "message":"...", "actions":["edit_sku","open_existing"], "woo_product_id": "...?", "woo_admin_url": "...?" }`. `open_existing` is required even if lookup failed (then omit id/url). Never show the code. Presentation: `docs/UX.md`.
|
||||
- `422` stale/invalid field → `{ "field":"species_id", "code":"stale_field", "message":"...", "actions":[], "options": [...] }` after a submission-time taxonomy refresh. Draft: phone form + server slab `failed` (retryable).
|
||||
- `412` validation module stale → `{ "code":"validation_module_stale", "module": { "hash":"...", "rules":[] } }`. Not a 409.
|
||||
- `DELETE /api/v1/slabs/{id}` → `204`. Soft: only unpublished drafts. Published slabs cannot be deleted from the app.
|
||||
|
||||
### Photo operations
|
||||
- `POST /api/v1/slabs/{id}/u2net/{pid}` — **post-POC.** "Try harder." Server runs U2Net on this one photo, returns a better mask. The contract is preserved but not implemented or exposed in Gate C / first listing.
|
||||
- Returns `202` `{ mask_url: "..." }`. Client uses the mask to re-compute area/widths locally.
|
||||
- `404` if photo not found.
|
||||
- `GET /api/v1/slabs/{id}/photos/{pid}/original` → `image/*` (original, while retained).
|
||||
- `GET /api/v1/slabs/{id}/photos/{pid}/processed` → `image/*` (processed PNG, while retained).
|
||||
- After publish, both → `410 Gone`.
|
||||
|
||||
### Inference
|
||||
- `POST /api/v1/slabs/{id}/infer-taxon` — Trigger or retry Call 1 (vision).
|
||||
- Server reads the Call 1 prompt file fresh, uses cached taxonomy + original
|
||||
photos downscaled to 1024px + user metadata, and sends to the configured
|
||||
vision endpoint.
|
||||
- Returns `202` `{ "status": "inferring" }`. Client polls `GET /api/v1/slabs/{id}` for results.
|
||||
- Timeout > 45s → `504 gateway_timeout` with error detail.
|
||||
- `POST /api/v1/slabs/{id}/infer-content` — Trigger Call 2 (text).
|
||||
- Server reads the Call 2 prompt file fresh. Default: **resend full Call 1
|
||||
context** (prompt + assistant JSON + confirmed vs inferred labels). Use
|
||||
`previous_response_id` only if test-inference reported stateful support.
|
||||
Per-slab thread; delete on publish or abandon.
|
||||
- Taxonomy + deterministic numbers + brand/GEO; LLM prose then templates.
|
||||
- Requires `test-inference` `ok: true`. Else `422` (not connected / not portable).
|
||||
- Returns `202` `{ "status": "inferring" }`. Client polls for results.
|
||||
- Timeout > 45s → `504` with error detail.
|
||||
|
||||
### Publish
|
||||
- `POST /api/v1/slabs/{id}/publish` — Publish to WooCommerce.
|
||||
- Pre-conditions: status `ready` with SKU, length_in, thickness_in, client sqft/bdft/widths, ≥1 inventory PNG, exactly one species, ≥1 wood category, 1 edge type, ≥1 figure term, ≥1 fig-* tag, ≥1 grade, thickness_term_id, 1 moisture, price. Else `422 unpublishable` with field keys. UI maps, no raw code.
|
||||
- Dedupe: if SKU already exists in Woo under any status → `409 duplicate_sku` (same payload as PUT).
|
||||
- Stale Woo value → `422 stale_field` as above. Draft stays on the phone.
|
||||
- Returns `202` `{ "status": "publishing" }`. Client polls.
|
||||
- Woo failure → slab `failed`, `sync_log` written, poll returns `502 woo_error`.
|
||||
- Success → slab `published`, `woo_product_id` set, images purged, draft row deleted.
|
||||
|
||||
### Taxonomy
|
||||
- `GET /api/v1/admin/taxonomy` → `200` taxonomy grouped by kind:
|
||||
```jsonc
|
||||
{ "categories": [...], "attributes": [{ name, slug, terms: [...] }],
|
||||
"tags": [...],
|
||||
"taxonomy_anchor": "ISO-8601",
|
||||
"taxonomy_last_sync_at": "ISO-8601" }
|
||||
```
|
||||
Client re-pulls iff `taxonomy_anchor` advanced. Species/attribute/tag
|
||||
lists here are the **only** legal options for UI pickers and Call 1.
|
||||
- `POST /api/v1/admin/taxonomy/sync` → `200` `{ "categories": n, "attributes": n, "tags": n, "taxonomy_anchor": "...", "taxonomy_last_sync_at": "..." }`.
|
||||
- Pulls from Woo, rebuilds the cache. Always set `taxonomy_last_sync_at` on success. Bump `taxonomy_anchor` **only** if stored-subset hash changed. `502 woo_error` on failure; previous cache retained.
|
||||
|
||||
### Pricing
|
||||
- `GET /api/v1/admin/pricing` → `[PriceRule]`
|
||||
- `POST /api/v1/admin/pricing` → `201` `PriceRule`
|
||||
- `PUT /api/v1/admin/pricing/{id}` → `200` `PriceRule`
|
||||
- `DELETE /api/v1/admin/pricing/{id}` → `204`
|
||||
|
||||
### Settings
|
||||
- `GET /api/v1/settings` → `200` `SettingsView`
|
||||
- `PUT /api/v1/settings` — Update settings. Body: partial `{ key: value }`.
|
||||
- `woo_base_url` is **not** writable (ignore or `422`). SoT is `WOO_BASE_URL`
|
||||
env (HTTPS, compose). GET still returns it for read-only Settings text.
|
||||
- Woo credential keys: `woo_wp_username` (plain) and `woo_app_password`
|
||||
(write-only; AES-GCM at rest). Reject any payload that still sends
|
||||
`woo_consumer_key` / `woo_consumer_secret`.
|
||||
- Secrets re-encrypted server-side. `200` `SettingsView` returned (password
|
||||
never echoed).
|
||||
- `POST /api/v1/settings/test-woo` → `200` `{ "ok": bool, "detail": "...", "taxonomy_anchor": "...", "taxonomy_last_sync_at": "..." }`
|
||||
- Uses stored username + application password over HTTPS Basic Auth against
|
||||
`WOO_BASE_URL` REST v3. Fails closed if credentials missing or URL is not https.
|
||||
- On success, **always** run a taxonomy resync (set last_sync; bump anchor only if hash changed).
|
||||
- `POST /api/v1/settings/test-inference` → `200` `{ "ok": bool, "mode": "stateful"|"stateless"|"fail", "vision_capable": bool, "detail": "..." }`
|
||||
- Two-turn probe. Default Call 1→Call 2 path is full resend; never require `previous_response_id`.
|
||||
- `mode=fail` **blocks Call 2 enablement only**. Gate C inference-OFF still works.
|
||||
- `GET /api/v1/validation/module` → `200` `{ "hash": "...", "rules": [ ... ] }`
|
||||
- Shared FastAPI module (UI + Call 2). Client caches it. Submit sends `validation_hash`.
|
||||
- Match → validate. Mismatch → **412** + fresh module. SKU `[A-Z0-9-]{3,24}`; titles UTF-8 + Woo maxLength.
|
||||
|
||||
---
|
||||
|
||||
## Request/response examples
|
||||
|
||||
### Create a slab (multipart)
|
||||
```
|
||||
POST /api/v1/slabs
|
||||
Content-Type: multipart/form-data; boundary=---
|
||||
|
||||
---
|
||||
Content-Disposition: form-data; name="data"
|
||||
Content-Type: application/json
|
||||
|
||||
{ "id":"9f1c...","sku":"BW-0042","length_in":96.0,"thickness_in":1.5,
|
||||
"sqft":0.64,"bdft":0.96,"width_min_in":14.0,"width_max_in":22.0,
|
||||
"width_avg_in":18.2,"species_id":42,"wood_category_ids":[15,23],
|
||||
"edge_type_term_id":8,"figure_term_ids":[3],"grade_term_ids":[7],
|
||||
"thickness_term_id":12,"moisture_term_id":5,"fig_tag_ids":[99],
|
||||
"price_per_bdft":12.50,"price":12.00,"price_source":"recommendation",
|
||||
"title":null,"short_title":null,"description":null,
|
||||
"content_source":null,"client_rev":1 }
|
||||
|
||||
---
|
||||
Content-Disposition: form-data; name="files[]"; filename="topdown.jpg"
|
||||
Content-Type: image/jpeg
|
||||
|
||||
<binary>
|
||||
---
|
||||
Content-Disposition: form-data; name="meta[]"
|
||||
Content-Type: application/json
|
||||
|
||||
{ "kind": "inventory", "role": "topdown", "seq": 1 }
|
||||
---
|
||||
```
|
||||
Response: `201`
|
||||
```json
|
||||
{ "id":"9f1c...","sku":"BW-0042","status":"calibrated",
|
||||
"inference_status":"inferring","server_rev":1,"photos":[...],... }
|
||||
```
|
||||
|
||||
### Call 1 completes (poll)
|
||||
```
|
||||
GET /api/v1/slabs/9f1c...
|
||||
→ 200 { "status":"calibrated","inference_status":"done",
|
||||
"species_id":42,"species_confidence":0.92,
|
||||
"wood_category_ids":[15],"edge_type_term_id":8,
|
||||
"figure_term_ids":[3],"grade_term_ids":[7],
|
||||
"inference_error":null, ... }
|
||||
```
|
||||
|
||||
### Call 2 (user taps Generate)
|
||||
```
|
||||
POST /api/v1/slabs/9f1c.../infer-content
|
||||
→ 202 { "status":"inferring" }
|
||||
GET /api/v1/slabs/9f1c...
|
||||
→ 200 { "title":"Black Walnut 8ft 0in × 18.2in Slab — Cathedral",
|
||||
"short_title":"Black Walnut Slab — Cathedral",
|
||||
"description":"A one-of-a-one cathedral black walnut slab...",
|
||||
"content_source":"llm", ... }
|
||||
```
|
||||
|
||||
### Publish
|
||||
```
|
||||
POST /api/v1/slabs/9f1c.../publish
|
||||
→ 202 { "status":"publishing" }
|
||||
GET /api/v1/slabs/9f1c...
|
||||
→ 200 { "status":"published","woo_product_id":1042,"published_at":"..." }
|
||||
```
|
||||
|
||||
### Taxonomy sync
|
||||
```
|
||||
POST /api/v1/admin/taxonomy/sync
|
||||
→ 200 { "categories": 24, "attributes": 5, "tags": 12, "synced_at": "..." }
|
||||
```
|
||||
|
||||
### Test connections
|
||||
```
|
||||
POST /api/v1/settings/test-woo
|
||||
→ 200 { "ok": true, "detail": "Connected to Whidbey Wood Store" }
|
||||
|
||||
POST /api/v1/settings/test-inference
|
||||
→ 200 { "ok": true, "vision_capable": true, "detail": "Model Qwen3.8-27B confirmed" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Versioning
|
||||
URL is `/api/v1`. Breaking changes bump to `/api/v2`. The SvelteKit client pins the version it was built against.
|
||||
|
||||
## Out of contract (v1)
|
||||
- Bulk endpoints, staff/role endpoints, webhook push, analytics.
|
||||
@@ -1,75 +0,0 @@
|
||||
# Milestone 2 — Photos
|
||||
|
||||
Builds on Milestone 1. Spec for calibration math and inventory PNG properties: `docs/CAPTURE.md`.
|
||||
This milestone gets every photo of one slab background-removed and cropped store-ready.
|
||||
It does not fill Complete-listing taxonomy, SKU, price, or copy. It does not create a WooCommerce product.
|
||||
No inference URL. No model. Mask and crop for non-calibration shots reuse the approved calibration params as ordinary client code.
|
||||
|
||||
## Goal
|
||||
|
||||
Phone capture of one slab → a lined-up set of Woo-compatible inventory PNGs, plus Milestone 1 calibration numbers on the reference shot, plus one annotated copy of the calibration PNG with dimension overlays.
|
||||
|
||||
## Use case
|
||||
|
||||
1. User adds a batch of one or more photos of the same slab (Setup rules from Milestone 1: green or black sheet, top-down reference expectations still apply to the calibration choice).
|
||||
2. User selects which photo in the batch is the calibration photo.
|
||||
3. App runs the Milestone 1 workflow on that calibration photo only: Mask → Length axis → Measure (length `L`, thickness, Calculate).
|
||||
4. After Calculate, the app applies the approved mask sheet and sensitivity, and the same crop rules (3:4 portrait, 90% fill, transparent PNG, shorter-side floor), to every other photo in the batch.
|
||||
5. App adds a second copy of the calibration inventory PNG with dimension overlays burned in (see Dimension overlay).
|
||||
6. User sees Photos ready: inventory PNGs lined up in this order — clean calibration PNG, then the dimension-overlay copy of the calibration PNG, then the other batch photos in capture order — plus the Milestone 1 calibration details (widths, sqft, bdft, scale) presented as in Measure.
|
||||
|
||||
A batch of one photo still works: that photo is the calibration photo; the end set is the clean PNG plus the dimension-overlay copy.
|
||||
|
||||
## States (additions over Milestone 1)
|
||||
|
||||
1. Batch — add one or more photos; continue when the batch has at least one.
|
||||
2. Pick calibration — user chooses which batch photo is the reference.
|
||||
3. Mask / Length axis / Measure — Milestone 1 states on the calibration photo only.
|
||||
4. Photos ready — lineup order: clean calibration PNG → dimension-overlay copy → other batch photos in capture order; plus Milestone 1 numbers.
|
||||
|
||||
Device draft restores the last approved state, including the batch and which photo was calibration when those exist.
|
||||
|
||||
## Apply to non-calibration photos
|
||||
|
||||
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.
|
||||
|
||||
Scale, widths, sqft, and bdft stay tied to the calibration photo and user `L` / thickness. Non-calibration photos are visual inventory only.
|
||||
|
||||
## Dimension overlay
|
||||
|
||||
A second copy of the **calibration** inventory PNG is added to the collection. Overlays are black, large enough to read on phone and storefront.
|
||||
|
||||
Orientation follows the **user-approved length axis** from Milestone 1 (not the upright edges of the photo).
|
||||
|
||||
- **Length:** dimension graphic beside the wood on the right in axis-space (outside the slab edge along the length direction).
|
||||
- **Widths:** stations every 12 in along the approved length axis, starting 6 in from the **bottom** of the slab span, continuing while the station is not within 6 in of the top (stations at 6, 18, 30, … in inches along the axis while ≤ `L − 6`). Each width is drawn across the wood perpendicular to the length axis, with arrow tips on the left and right slab edges and the measurement text on that line.
|
||||
- **Bottom:** after the calibration photo’s EXIF orientation is applied, the bottom edge of that oriented photo establishes which end of the slab is bottom for width stations. Length-axis direction still follows the user-approved Milestone 1 axis; EXIF only picks which end is bottom vs top along that span.
|
||||
|
||||
## Photos-ready order
|
||||
|
||||
1. Clean calibration inventory PNG
|
||||
2. Dimension-overlay copy of the calibration PNG
|
||||
3. Remaining batch inventory PNGs in capture order
|
||||
|
||||
The clean calibration PNG remains in the set. The annotated copy is extra.
|
||||
|
||||
## Acceptance
|
||||
|
||||
On a phone UAT build:
|
||||
|
||||
- User can capture 1–N photos, pick calibration, complete Milestone 1 on that shot, and see every successful batch photo as a 3:4 transparent inventory PNG lined up.
|
||||
- Calibration measures match Milestone 1 presentation.
|
||||
- Dimension-overlay copy of the calibration PNG is present with black length and width graphics oriented to the approved axis; width stations start 6 in from EXIF-established bottom.
|
||||
- Lineup order is clean calibration → dimension overlay → other batch photos in capture order.
|
||||
- No Woo create. No inference URL.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Complete-listing fields (SKU, species, category, edge, figure, grade, thickness band, moisture, `fig-*` / `feat-*`, title, short title, description, price)
|
||||
- WooCommerce create (Milestone 3)
|
||||
- Inference / Assist (Milestone 4)
|
||||
- Changing Milestone 1 mask/axis/measure math except to reuse its outputs for batch apply and overlays
|
||||
@@ -1,77 +0,0 @@
|
||||
# Product requirements
|
||||
|
||||
SlabUploader creates one WooCommerce listing per wood slab for Whidbey Wood Store.
|
||||
|
||||
Each slab is one unit and one listing. Photos and metadata are the inventory record.
|
||||
|
||||
Photographs are taken of the slab on a green or black sheet. That is the capture starting state.
|
||||
|
||||
## Complete listing
|
||||
|
||||
- Inventory photo(s), uniform 3:4 transparent PNG
|
||||
- SKU, length, thickness, price
|
||||
- Species, wood category, edge type, figure, grade
|
||||
- Thickness band, moisture
|
||||
- One or more `fig-*` tags
|
||||
- Title, short title, description
|
||||
- Zero or more `feat-*` tags
|
||||
|
||||
Store categories, attributes, and `fig-*` / `feat-*` tags are the filters. Measured values feed title and description templates.
|
||||
|
||||
## Decision rights
|
||||
|
||||
| Field | Source |
|
||||
|---|---|
|
||||
| Length, thickness | User |
|
||||
| SKU | User |
|
||||
| Mask and crop | App proposal, user approve |
|
||||
| Widths, sqft, bdft | App, from approved reference photo + length + thickness |
|
||||
| Species, wood category, edge, figure, grade, tags | App may propose; user may change |
|
||||
| Price | App proposal `bdft × species $/bdft`; user may replace |
|
||||
| Title, short title, description | Templates from listing facts; user may edit |
|
||||
|
||||
## System seams
|
||||
|
||||
Milestones 1 and 2 run in the browser. No backend and no inference URL.
|
||||
Milestone 1 handoff is the device draft: source photo, approved mask, inventory PNG, length, thickness, widths, sqft, bdft.
|
||||
Milestone 2 extends the draft with the photo batch, calibration choice, per-photo inventory PNGs, and the dimension-overlay calibration copy.
|
||||
Milestone 3 uses FastAPI as the Woo client. Store credentials stay on the server.
|
||||
|
||||
## Create
|
||||
|
||||
`SLAB-UAT-*` creates as a WooCommerce draft (store status).
|
||||
Duplicate SKU blocks create.
|
||||
A listing can be created with inference off.
|
||||
A device draft in the app is separate from a WooCommerce draft.
|
||||
|
||||
## Milestones
|
||||
|
||||
Work is split into four milestones. Each milestone is done when its acceptance can be demonstrated.
|
||||
|
||||
### Milestone 1 — Capture
|
||||
|
||||
Spec: `docs/CAPTURE.md`. Done historically; handoff into Milestone 2.
|
||||
|
||||
User stories:
|
||||
- Photograph a slab on a green or black sheet and remove the background.
|
||||
- Set length from the bounding rectangle and get widths, sqft, and bdft.
|
||||
- Leave and later Continue or Start fresh.
|
||||
|
||||
Acceptance: inventory PNG and measurements visible on a phone. No Woo create.
|
||||
|
||||
### Milestone 2 — Photos
|
||||
|
||||
Spec: `docs/PHOTOS.md`. Current work.
|
||||
|
||||
Batch photos of one slab, pick calibration, run Milestone 1 on that reference, apply the same mask params and crop to the rest, preview all inventory PNGs lined up, plus a dimension-overlay copy of the calibration PNG.
|
||||
Acceptance: on a phone, 1–N store-ready inventory PNGs for the slab (including dimension overlay on the calibration copy) with Milestone 1 measures visible. No Woo create. No Complete-listing taxonomy in this milestone.
|
||||
|
||||
### Milestone 3 — Create
|
||||
|
||||
WooCommerce draft for a practice SKU. Then clear the device draft.
|
||||
Acceptance: `SLAB-UAT-*` exists in the store as draft.
|
||||
|
||||
### Milestone 4 — Assist
|
||||
|
||||
Optional vision and generated copy on the manual path from Milestones 1–3.
|
||||
Acceptance: create still works with inference off.
|
||||
+201
@@ -0,0 +1,201 @@
|
||||
# Inference Prompts
|
||||
|
||||
Status: SPEC · 2026-08-27 · SlabUploader
|
||||
Prompts are server-side text files, not settings values. Read fresh on each
|
||||
invocation. Shipped defaults live in `backend/app/prompts/`.
|
||||
|
||||
## Management
|
||||
|
||||
- **Location:** `/app/prompts/` inside the FastAPI container, mounted read-only
|
||||
from host `backend/app/prompts/`.
|
||||
- **Editing:** Edit the text files on the host. No server restart needed.
|
||||
- **Versioning:** The inference_log stores the prompt content (or hash) with each
|
||||
inference call, so you can trace which prompt version produced which result.
|
||||
- **Reset-to-default:** `python -m app.prompts.reset` restores shipped defaults
|
||||
from `backend/app/prompts/`.
|
||||
|
||||
## Call 1 — Vision (species/character classification)
|
||||
|
||||
File: `/app/prompts/call1.txt`
|
||||
|
||||
### Purpose
|
||||
Classify the slab's species, edge type, figure, and grade by analyzing
|
||||
inventory photos. Constrained to choose from the synced Woo taxonomy lists.
|
||||
|
||||
### Inputs assembled by the server
|
||||
- 1–5 inventory photos, downscaled to 1024px max
|
||||
- Species leaf categories from Woo (list with IDs and brief definitions)
|
||||
- Edge Type attribute terms with IDs
|
||||
- Figure attribute terms with IDs
|
||||
- Grade attribute terms with IDs
|
||||
- User metadata: SKU, length, thickness
|
||||
|
||||
### System prompt (template)
|
||||
```
|
||||
You are an expert wood slab classifier. Analyze the provided slab photos and
|
||||
return taxonomy data. Only use the values provided in the taxonomy lists — do
|
||||
not invent categories or attribute values.
|
||||
```
|
||||
|
||||
### User prompt (assembled at runtime)
|
||||
```
|
||||
Analyze the following wood slab photos and classify the species, edge type,
|
||||
figure, and grade.
|
||||
|
||||
Species options (choose ONLY from this list):
|
||||
1. [species name] — [brief definition] (woo_id: <id>)
|
||||
2. ...
|
||||
|
||||
Edge Type options:
|
||||
- Live Edge — natural, uncut bark edge
|
||||
- Flat Sawn — squared, milled edges
|
||||
...
|
||||
|
||||
Figure options:
|
||||
- Cathedral — U-shaped grain patterns
|
||||
- Burl — swirling, knotted grain
|
||||
...
|
||||
|
||||
Grade options:
|
||||
- Premium — minimal defects, clean grain
|
||||
- Standard — some minor characteristics
|
||||
...
|
||||
|
||||
Slab metadata: SKU={sku}, Length={length}in, Thickness={thickness}in
|
||||
|
||||
Return valid JSON matching this schema:
|
||||
{
|
||||
"species_id": <woo_id or null>,
|
||||
"species_confidence": 0.0-1.0,
|
||||
"wood_category_ids": [<woo_id>, ...],
|
||||
"edge_type_term_id": <woo_id or null>,
|
||||
"figure_term_ids": [<woo_id>, ...],
|
||||
"figure_confidence": 0.0-1.0,
|
||||
"grade_term_ids": [<woo_id>, ...],
|
||||
"grade_confidence": 0.0-1.0,
|
||||
"character": { "type": "string", "severity": "string" },
|
||||
"inclusions": { "type": "string", "severity": "string" },
|
||||
"voids": "string",
|
||||
"checks": "string",
|
||||
"wood_categories": [
|
||||
{ "name": "string", "confidence": 0.0-1.0 },
|
||||
...
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Notes
|
||||
- The taxonomy list (species, wood categories, edge type, figure, grade) is
|
||||
pulled from the **synced Woo cache only**. No hardcoded mill species list. A term
|
||||
not in the cache cannot be suggested or selected.
|
||||
- Brief definitions help the model distinguish ambiguous cases
|
||||
(e.g., "quarter-sawn" vs "cathedral").
|
||||
- Character/inclusions/voids/checks are free-text observations. The server
|
||||
maps them to Woo `feat-*` product tags after the call returns. WooCommerce
|
||||
is the authoritative source for feat-* tag definitions; the design does not
|
||||
duplicate them.
|
||||
- **Pre-population (when `inference_enabled`):** Call 1 results are the primary
|
||||
source that fills the review screen for species, wood categories, edge type,
|
||||
figure, grade, and mapped `feat-*` tags from free-text observations.
|
||||
- **Confidence gate (default threshold 0.7):**
|
||||
- Field confidence **≥ threshold** → pre-populate that field (mutable;
|
||||
user may clear or change it).
|
||||
- Field confidence **< threshold** → leave empty. The model's
|
||||
low-confidence guess as a default.
|
||||
- Call 1 does not define the ready gate. Species + figure (or any vision
|
||||
subset) is assist/prefill only. Ready is the **full named set** in AGENTS §7
|
||||
/ DATA-MODEL (filled manually if Call 1 left fields empty). Species + one
|
||||
figure is not enough.
|
||||
- Manual entry always wins. Call 1 is assist-only and does not gate publish.
|
||||
Full suite must pass with inference OFF. Canonical lock: AGENTS §6–7.
|
||||
|
||||
---
|
||||
|
||||
## Call 2 — Content (description prose)
|
||||
|
||||
File: `/app/prompts/call2.txt`
|
||||
|
||||
### Purpose
|
||||
Generate compelling product description prose for the slab. The model writes
|
||||
only the descriptive portion — dimensions, species, and measurements are
|
||||
injected by deterministic templates.
|
||||
|
||||
### Inputs assembled by the server
|
||||
- Portable Call 1 context (default: **full history resend** of Call 1 turn +
|
||||
images metadata + assistant JSON). `previous_response_id` only if
|
||||
`test-inference` recorded stateful support.
|
||||
- Explicit **confirmed vs inferred** labels for species, dimensions, and other
|
||||
attributes (user overrides marked confirmed).
|
||||
- Deterministic measurements: length, widths, sqft, bdft, thickness (never invented)
|
||||
- Brand voice, GEO context, title/short templates
|
||||
- Shared validation module rules so Call 2 assembly rejects the same invalid
|
||||
values the UI would
|
||||
|
||||
### System prompt (template)
|
||||
```
|
||||
You are a professional product copywriter for premium, one-of-a-one wood
|
||||
slabs. Write compelling, natural-sounding descriptions. Use the brand voice
|
||||
provided. Measurements are not invented; only the provided data is used.
|
||||
```
|
||||
|
||||
### User prompt (assembled at runtime)
|
||||
```
|
||||
Write product copy for this wood slab.
|
||||
|
||||
Species: {species}
|
||||
Figure: {figure}
|
||||
Character: {character description from Call 1}
|
||||
Length: {length_ft}ft {length_in_remainder}in
|
||||
Average width: {width_avg_in}in
|
||||
Width range: {width_min_in}in to {width_max_in}in
|
||||
Thickness: {thickness_in}in
|
||||
Board feet: {bdft} bdft
|
||||
Grade: {grade}
|
||||
|
||||
Brand voice: {brand_voice}
|
||||
GEO context: {geo_context}
|
||||
|
||||
Write:
|
||||
1. A compelling opening sentence about the slab
|
||||
2. A paragraph describing the character, grain, color, and use cases
|
||||
3. A call to action
|
||||
|
||||
Keep the tone appropriate for a premium lumber buyer. The
|
||||
dimensions — those are handled by the template. Focus on the beauty and
|
||||
versatility of the slab.
|
||||
|
||||
Return JSON: { "prose": "..." }
|
||||
```
|
||||
|
||||
### Template assembly (server-side, after LLM returns)
|
||||
The server assembles the final title and description from templates with
|
||||
deterministic measurements, inserting the LLM prose where appropriate.
|
||||
|
||||
Example title: `{species} {length_ft}ft {length_in_remainder}in × {width_avg_in}in Wood Slab — {figure}`
|
||||
Example description: `{llm_prose}. Each slab is naturally shaped — width varies
|
||||
along the length at {width_min_in}in to {width_max_in}in. Sold by the slab;
|
||||
dimensions as measured. {geo_sentence}`
|
||||
|
||||
### Notes
|
||||
- The LLM never touches dimensions. Templates handle all numbers.
|
||||
- No numeric-accuracy guardrail needed — dimensions never pass through the LLM.
|
||||
- If `content_llm_enabled` is false, the LLM call is skipped and templates
|
||||
are used with a generic description paragraph.
|
||||
- **Portable Call 1 context (issue #2):** default is full resend of Call 1 (user
|
||||
prompt + assistant JSON). Label each taxonomy value **confirmed** (user-edited)
|
||||
vs **inferred** (still from Call 1). Stateful `previous_response_id` is opt-in
|
||||
after `test-inference`. Fail closed if neither path works.
|
||||
|
||||
---
|
||||
|
||||
## Tuning workflow
|
||||
|
||||
1. Deploy the default prompts
|
||||
2. Capture a real slab, let Call 1 run
|
||||
3. Review the results against your expert judgment
|
||||
4. Edit `call1.txt` to improve classification accuracy
|
||||
5. Re-run Call 1 via `POST /api/v1/slabs/{id}/infer-taxon` to compare
|
||||
6. Iterate until results are consistently accurate
|
||||
|
||||
The inference_log stores each iteration's input/output so you can track
|
||||
progress across prompt versions.
|
||||
@@ -0,0 +1,253 @@
|
||||
# Technical Spec: Deterministic Image Pipeline & Area → Bdft
|
||||
|
||||
Status: SPEC · 2026-08-27 · Gate A freeze 2026-08-31 · TyTech / SlabUploader
|
||||
Scope: algorithms and test vectors for the deterministic core (client-authoritative).
|
||||
No inference, no LLM. Everything here is a pure function of (pixels, user inputs) → outputs.
|
||||
Inference, Woo payload, API, and schema live in other docs.
|
||||
|
||||
---
|
||||
|
||||
## 1. Invariants
|
||||
|
||||
1. **Determinism** — same inputs → bit-identical outputs. No randomness, no timestamps in math, no network calls for the math.
|
||||
2. **Manual entry is authoritative** — user-entered length, thickness, SKU, and price always win over any computed value.
|
||||
3. **Fallback chain is explicit** — every computed value has a defined fallback; the system never silently guesses.
|
||||
- BG removal: chroma-key / threshold → four knobs (sheet, sensitivity, edge offset, feather) live on capture → retake. U2Net last-resort is deferred from POC; the contract stays in the architecture.
|
||||
- Length axis: min-area bounding rect → user rotate/confirm → retake if the photo is unusable.
|
||||
- Area: requires a single contiguous slab mask; broken mask → flag and retake.
|
||||
4. **Units** — inches in 1/8" steps; sqft and bdft to 2 decimals; price half-up to 2 decimals. Those rounded values are what get stored. No sqin in the UI, tests, or product copy.
|
||||
5. **Client is authoritative** — all deterministic processing runs in the browser. The server stores the numbers and files the client sends, and handles Woo sync.
|
||||
|
||||
|
||||
## 1b. Browser compatibility floor
|
||||
|
||||
Client processing runs in the browser on phones and PCs. Ty lock 2026-09-06 PT: the
|
||||
compatibility matrix includes PC browsers; this is not a mobile-only app.
|
||||
|
||||
Supported families:
|
||||
- Phone: iOS Safari, Android Chrome
|
||||
- PC: desktop Chrome, desktop Edge, desktop Firefox (Windows and macOS at minimum)
|
||||
|
||||
Exact minimum version pins remain issue #4. Unsupported clients get a hard-stop message with an
|
||||
update / switch link. No polyfill strategy in POC.
|
||||
|
||||
Load-bearing client APIs for the deterministic path include canvas (or OffscreenCanvas where used)
|
||||
for mask overlay and knob re-run, image decode/crop/encode for 3:4 transparent PNG prep, and
|
||||
standard fetch to the app API. Only APIs listed here are used; update this section before adding new ones.
|
||||
|
||||
## 2. Units and rounding
|
||||
|
||||
| Quantity | Source | Stored / displayed |
|
||||
|---|---|---|
|
||||
| Length | user, 1/8" steps | nearest 1/8" |
|
||||
| Thickness | user pick, 1/8" steps | that pick (used in math and copy) |
|
||||
| Thickness Woo attribute | same pick, rounded **up** into a Woo range | Woo term, not the 1/8" value |
|
||||
| sqft | computed from mask | 2 decimals |
|
||||
| bdft | sqft × thickness (inches) | 2 decimals |
|
||||
| Price | species $/bdft × bdft, or override | 2 decimals, half-up |
|
||||
|
||||
- bdft is never rounded up to whole board feet.
|
||||
- Pixel→inch scale is an implementation detail. Users never see pixels.
|
||||
|
||||
## 3. Background removal (load-bearing)
|
||||
|
||||
Background removal is the **load-bearing phase** of the image pipeline. The mask
|
||||
defines the slab's pixel footprint. Length axis, 6" width samples, sqft, and bdft
|
||||
all derive from that footprint. A clipped or over-inclusive mask silently shifts
|
||||
the bounding rectangle and width samples with no later numeric check to catch it.
|
||||
The user's eye on the overlay is the only validator. There is no confidence score
|
||||
and no auto-gate on mask quality.
|
||||
|
||||
### 3.1 Auto-detect sheet
|
||||
1. Sample a 2–5% strip on all four borders.
|
||||
2. Average HSV per side.
|
||||
3. Hue in [35, 85] and saturation ≥ 40 → green.
|
||||
4. Else value ≤ 30 and saturation ≤ 20 → black.
|
||||
5. Else prompt: pick green or black.
|
||||
|
||||
### 3.2 Visual feedback (required)
|
||||
- Overlay the mask on the **source photo** (included vs excluded pixels), not only
|
||||
a cropped result.
|
||||
- Show the slab edge clearly. That edge is the measurement boundary.
|
||||
- User confirms the edge before length / area run.
|
||||
|
||||
### 3.3 Capture knobs (inline, live, persisted)
|
||||
Factory values live in code. If the user changes them, the new values persist in
|
||||
localStorage until reset-to-default.
|
||||
|
||||
All four knobs are **on the capture screen next to the overlay**, not behind a
|
||||
Settings navigation. Changing any knob **immediately re-runs** §3.4 on the current
|
||||
photo and refreshes the overlay on the source image. No apply/save step. No server
|
||||
round trip for mask tuning in POC.
|
||||
|
||||
1. **Sheet** — auto / green / black.
|
||||
2. **Sensitivity** — one slider; tighter vs looser detection (green: HSV range width; black: brightness cutoff).
|
||||
3. **Edge offset** — pull the mask in or out a few pixels so the cut sits on the wood, not in the sheet or into the grain.
|
||||
4. **Feather** — hard cut vs a couple of pixels of alpha blend.
|
||||
|
||||
### 3.4 Algorithms
|
||||
- **Green:** chroma-key in HSV. Border flood-fill so only sheet pixels connected to the image edge are removed (protects green-ish grain inside the slab).
|
||||
- **Black:** grayscale threshold. Same flood-fill (protects dark grain).
|
||||
- **POC path:** client-side only (sheet detect + four knobs). If the edge is still
|
||||
wrong after tuning, **retake**.
|
||||
- **Post-POC fallback (deferred, contract preserved):** U2Net matting on one photo via
|
||||
FastAPI. Mask returns; knobs still apply on the client. The U2Net control is not
|
||||
shipped in Gate C / first listing. The contract stays in the architecture for post-POC.
|
||||
- Morphological close, then feather per the setting.
|
||||
|
||||
### 3.5 Edge quality
|
||||
- The mask must be one contiguous slab contour.
|
||||
- If the user cannot get a clean edge with knobs, retake is the POC resolution.
|
||||
|
||||
## 4. Length axis, widths, sqft, bdft
|
||||
|
||||
### 4.1 Contour and length axis
|
||||
1. Binary mask M from §3.
|
||||
2. Largest contour → convex hull.
|
||||
3. Minimum-area bounding rectangle on the hull.
|
||||
4. Long side = length axis; short side = width axis.
|
||||
This follows the **prevailing** length, not a diagonal cut on one end.
|
||||
|
||||
### 4.2 Visual feedback (required)
|
||||
- Overlay the bounding rectangle and the length axis.
|
||||
- Width sample lines drawn perpendicular to that axis.
|
||||
- User confirms the axis matches the slab’s prevailing length.
|
||||
- **Rotate axis** if it is skewed; samples and sqft scale update live.
|
||||
- Nearly square slabs: user says which axis is length.
|
||||
|
||||
### 4.3 Scale
|
||||
User length L (inches, 1/8") is authoritative.
|
||||
|
||||
```
|
||||
scale = longest_axis_px / L
|
||||
```
|
||||
|
||||
### 4.4 Widths (listing only, not bdft)
|
||||
- Sample every **6"** along the confirmed length axis, perpendicular to it.
|
||||
- Report min, max, and average width for the description.
|
||||
- Auto-dimension overlay on the photo is **v1.5**, out of this spec.
|
||||
|
||||
### 4.5 sqft
|
||||
Projected top-down area of the mask. Live-edge thickness and the underside are not in the area.
|
||||
|
||||
```
|
||||
sqft = (mask_pixel_count / scale²) / 144
|
||||
```
|
||||
|
||||
Round to 2 decimals.
|
||||
|
||||
### 4.6 bdft
|
||||
|
||||
```
|
||||
bdft = sqft × thickness_in
|
||||
```
|
||||
|
||||
Round to 2 decimals. Identity: 1 sqft at 1" thick = 1 bdft.
|
||||
|
||||
## 5. Photo normalization
|
||||
|
||||
Applies to every inventory photo of the slab (first photo and the rest).
|
||||
|
||||
- Crop aspect: **3:4 portrait** (default; matches the WooCommerce template;
|
||||
configurable in settings).
|
||||
- Transparent **PNG only**.
|
||||
- Slab centered. **80% fill at the extremes** (widest and tallest points of the slab). Extra margin where the slab is narrower.
|
||||
- After crop, if the source has enough pixels, shorter side ≥ **1600 px**. If it cannot, warn and ask for a retake. The source must supply enough pixels.
|
||||
- Original + processed kept while draft/retry; both purged after successful publish (server storage; see Data Model).
|
||||
|
||||
## 6. Pricing
|
||||
|
||||
- Settings: sync **species** from Woo; user sets **$/bdft per species**.
|
||||
- Capture: `recommendation = bdft × species.price_per_bdft`, pre-fills price.
|
||||
- User may override. Override sticks for that slab.
|
||||
- No rule for that species → empty price; user types it. Publish still allowed.
|
||||
- Stored price: half-up to 2 decimals.
|
||||
- Figure- or category-weighted price is **v1.5**.
|
||||
|
||||
## 7. Constants
|
||||
|
||||
### 7.1 Code (not in Settings)
|
||||
```
|
||||
FILL_TARGET = 0.80
|
||||
ASPECT_W, ASPECT_H = 3, 4
|
||||
OUTPUT_PX_MIN = 1600
|
||||
WIDTH_SAMPLE_IN = 6
|
||||
INCH_STEP = 0.125
|
||||
AREA_DECIMALS = 2
|
||||
BDFT_DECIMALS = 2
|
||||
PRICE_DECIMALS = 2
|
||||
```
|
||||
|
||||
### 7.2 Factory defaults for persisted BG settings
|
||||
```
|
||||
SHEET = auto
|
||||
GREEN_HUE_LO/HI = 35, 85
|
||||
GREEN_SAT_MIN = 40
|
||||
GREEN_VAL_MIN = 40
|
||||
BLACK_THRESH = 30
|
||||
```
|
||||
Sensitivity, edge offset, and feather map onto these at runtime. Users interact with the knobs, not the raw HSV numbers.
|
||||
|
||||
## 8. Test vectors
|
||||
|
||||
Users think in inches, sqft, and bdft. Pixels are only how the computer gets there.
|
||||
|
||||
### TV-1 Green sheet
|
||||
Synthetic: green border, wood in the center. Interior green-ish patch that does not touch the border stays. Coverage enough that U2Net does not run.
|
||||
|
||||
### TV-2 Black sheet
|
||||
Same layout, black border, mid-tone wood. Dark interior wood that does not touch the border stays.
|
||||
|
||||
### TV-3 Scale, sqft, bdft
|
||||
Slab **96" long**, **1.5" thick**. Mask measures **0.64 sqft**.
|
||||
bdft = 0.64 × 1.5 = **0.96**.
|
||||
|
||||
### TV-4 Width samples
|
||||
96" length axis, stations every 6". Widths perpendicular to the confirmed axis. Listing gets min, max, avg.
|
||||
|
||||
### TV-5 Angled end
|
||||
One square end, one 45° cut. Length axis follows prevailing length, not the diagonal. Widths stay perpendicular after user confirm.
|
||||
|
||||
### TV-6 3:4 crop, 80% at extremes
|
||||
Slab centered in a 3:4 frame. At the widest and tallest points, fill = 0.80. PNG with alpha. Shorter side ≥ 1600 when the source allows.
|
||||
|
||||
### TV-7 Too-small source
|
||||
After 80% crop, shorter side would be below 1600 and the source cannot supply it. Warn; no upscaling from nothing. That photo is not publishable until retake.
|
||||
|
||||
### TV-8 Pricing
|
||||
bdft 0.96, species $12.50/bdft → rec **12.00**.
|
||||
Override **150.00** stored as 150.00.
|
||||
No rule → rec empty; manual 99.99 stored as 99.99.
|
||||
|
||||
### TV-9 Thickness → Woo range
|
||||
User pick **1"** stored as 1". Attribute bucket is the Woo range that 1" rounds **up** into (e.g. `1"–1 1/2"`). Exact range labels come from the synced Woo attribute, not this file.
|
||||
|
||||
### TV-10 Rounding
|
||||
Length 96.04" → **96"** (nearest 1/8").
|
||||
sqft 0.641 → **0.64**.
|
||||
bdft 11.525 → **11.53**.
|
||||
Price 12.345 → **12.35**.
|
||||
|
||||
## 9. Assumptions
|
||||
|
||||
1. The first photo is top-down, lens parallel to the broad face. Tilted shots make sqft/bdft wrong; retake is the fix.
|
||||
2. User length is the prevailing length of that face, in 1/8". The bounding-box axis is confirmed in the UI.
|
||||
3. Thickness is the declared 1/8" pick, not measured from any photo.
|
||||
4. sqft is the projected top-down area of the mask. Live-edge thickness and underside are not in the area.
|
||||
5. bdft = sqft × thickness (inches). Not rounded up to whole board feet.
|
||||
6. Sheet is uniform green or black. Odd colors → user picks sheet mode or retakes.
|
||||
7. 3:4 portrait is the PIP crop, configurable in settings.
|
||||
8. Client does all of this math. Server stores the numbers the client sends.
|
||||
|
||||
## 10. Cross-reference to other docs
|
||||
|
||||
| Topic | Depth doc(s) |
|
||||
|---|---|
|
||||
| Call 1 / Call 2 prompts, taxonomy mapping | PROMPTS, CONTENT-WOO |
|
||||
| Portable Call 1→Call 2 context (full resend default) | ARCHITECTURE §9, PROMPTS |
|
||||
| Woo payload, publish, 409/422 recovery | CONTENT-WOO, OPENAPI |
|
||||
| SQLite tables, status machine | DATA-MODEL |
|
||||
| HTTP contract, taxonomy `taxonomy_anchor` | OPENAPI |
|
||||
| Client vs server topology, validation module | ARCHITECTURE |
|
||||
| Auto-dimension overlay, figure-weighted price | IMPL-PLAN (v1.5) |
|
||||
| Online-only capture (offline deferred) | AGENTS §2, ARCHITECTURE §4 |
|
||||
@@ -0,0 +1,441 @@
|
||||
# TEST-SPEC — Engineer Test Specification
|
||||
|
||||
Status: SPEC · engineer bar · SoT gitea-atd main · 2026-09-07
|
||||
|
||||
This document defines every affirmative test case for the SlabUploader, from Phase 0 through production. Each case states **what must happen**, **where the rule lives**, and **how it is verified**. The human walkable practice script is at `docs/UAT-PRACTICE.md` and is left untouched by this file.
|
||||
|
||||
---
|
||||
|
||||
## 1. Case ID naming convention
|
||||
|
||||
Case IDs follow the pattern `<prefix>-<number>`. The prefix identifies the test layer or gate:
|
||||
|
||||
| Prefix | Meaning | Examples |
|
||||
|---|---|---|
|
||||
| `TV` | Test Vector from `docs/TECH-SPEC-PIPELINE.md` §8 | `TV-1`, `TV-10` |
|
||||
| `V` | Validation rule (shared validation module) | `V-1`, `V-3` |
|
||||
| `AES` | AES-GCM encryption / decryption | `AES-1`, `AES-2` |
|
||||
| `H` | Health endpoint | `H-1` |
|
||||
| `D` | Draft store (slab creation / persistence) | `D-1` |
|
||||
| `GC` | Gate C case (early Phase 1, claimable draft listing) | `GC-1`, `GC-5` |
|
||||
| `W` | WooCommerce mock integration | `W-1`, `W-5` |
|
||||
| `INF` | Inference endpoint probe / toggle | `INF-1`, `INF-4` |
|
||||
| `UI` | UI behavior (capture, knobs, overlay, ready gates) | `UI-1`, `UI-5` |
|
||||
| `UIE` | UI end-to-end (review, publish, settings, full flow) | `UIE-1`, `UIE-10` |
|
||||
| `API` | API contract (routes, concurrency, error responses) | `API-1`, `API-10` |
|
||||
| `TAX` | Taxonomy sync / anchor / species cache | `TAX-1`, `TAX-3` |
|
||||
| `C1` | Call 1 (vision) inference call | `C1-1`, `C1-3` |
|
||||
| `C2` | Call 2 (content/text) inference call | `C2-1`, `C2-4` |
|
||||
| `OFF` | Inference OFF path (full suite without inference) | `OFF-1`, `OFF-2` |
|
||||
| `OPS` | Operations / deployment / backup | `OPS-1`, `OPS-5` |
|
||||
| `INS` | Instrumentation / observability | `INS-1`, `INS-4` |
|
||||
| `E2E` | End-to-end browser test (full user flow) | `E2E-1`, `E2E-5` |
|
||||
| `FUND` | Fundamentals (cross-cutting dependency bar) | `FUND-1`, `FUND-25` |
|
||||
|
||||
Other abbreviations used in this document:
|
||||
|
||||
- **sqft**: square feet
|
||||
- **bdft**: board feet
|
||||
- **U2Net**: Universal 2 Network, a background-removal model (deferred from the proof of concept)
|
||||
- **API**: application programming interface
|
||||
- **E2E**: end-to-end
|
||||
- **UI**: user interface
|
||||
- **POC**: proof of concept
|
||||
- **SKU**: stock keeping unit
|
||||
- **Woo**: WooCommerce, the WordPress e-commerce plugin
|
||||
- **HTTP/HTTPS**: Hypertext Transfer Protocol (plain / secure)
|
||||
- **AES-GCM**: Advanced Encryption Standard in Galois/Counter Mode
|
||||
- **UUID**: universally unique identifier
|
||||
- **JSON**: JavaScript Object Notation
|
||||
- **LLM**: large language model
|
||||
|
||||
---
|
||||
|
||||
## 2. How cases are executed
|
||||
|
||||
Test automation lives in the repo under `backend/tests/` and `frontend/e2e/`. The harness uses:
|
||||
|
||||
- **Unit tests**: `backend/tests/unit/` (pytest) and `frontend/src/__tests__/` (Vitest). These run against pure modules with no network calls.
|
||||
- **API contract tests**: `backend/tests/contract/` (pytest + `httpx` TestClient). These hit the FastAPI app through its test client, asserting request/response shape, status codes, and revision handling.
|
||||
- **Integration tests**: `backend/tests/integration/` (pytest + `httpx` TestClient + `respx` WooCommerce mock). These verify Woo create, duplicate SKU, taxonomy sync, stale-field recovery, and settings encryption round-trips without touching a real store.
|
||||
- **UI end-to-end tests**: `frontend/e2e/` (Playwright). These exercise the full browser flow: capture, mask knobs, axis confirm, numbers entry, review, and publish. They run against a started Docker Compose stack with the FastAPI test server and mocked Woo endpoints.
|
||||
- **Ops / deployment tests**: `deploy/tests/` (shell scripts). These verify compose health, backup/restore, and Caddy routing.
|
||||
|
||||
Test dependencies are pinned in `backend/tests/requirements-test.txt` and `frontend/package.json` (devDependencies). CI runs the full suite before any merge; the suite must pass with inference disabled.
|
||||
|
||||
---
|
||||
|
||||
## Results recording & report-out
|
||||
|
||||
Every report maps to named case IDs from this spec. Allowed results only: `pass`, `fail`, `not run`, and optionally `blocked` with a blocker pointer. Never invent pass.
|
||||
|
||||
Evidence is required for every `pass` or `fail`: a path or paste of real runner output (pytest, httpx, compose logs, curl). No evidence → `not run`. Report table columns are: case id · result · evidence pointer · blocker (if fail).
|
||||
|
||||
Gitea issues are the only mechanism for reporting issues found during a test pass. Chat, Slack, and PR comments are not the bug log. On fail, open or update a Gitea issue that includes the case id and evidence pointer; the report table's blocker column links that issue. Do not invent issues that lack runner evidence.
|
||||
|
||||
The Test Bar may only summarize what runners printed. Ty also reads raw results and owns gate exit. Gate or phase exit evidence in the Gates section still requires Ty confirmation where IMPL-PLAN mandates it — bots never declare Gate B or Gate C green.
|
||||
|
||||
When code does not exist yet, Phase 0 and earlier cases stay `not run`. Do not speculate.
|
||||
|
||||
---
|
||||
|
||||
## 3. Test pyramid overview
|
||||
|
||||
Each row states the test layer, when tests in that layer are written, and what document drives the expected behavior.
|
||||
|
||||
| Layer | When written | Primary source of truth |
|
||||
|---|---|---|
|
||||
| Unit | TDD: unit tests written before the feature code exists | `docs/TECH-SPEC-PIPELINE.md`, `docs/AGENTS.md` |
|
||||
| API / contract | Alongside the backend feature code | `docs/OPENAPI.md`, `docs/DATA-MODEL.md` |
|
||||
| Integration | Alongside the backend feature code | `docs/CONTENT-WOO.md`, `docs/ARCHITECTURE.md` |
|
||||
| UI / E2E | Alongside the frontend feature code | `docs/UX.md`, `docs/TECH-SPEC-PIPELINE.md` |
|
||||
| Ops / deployment | Alongside deployment configuration | `docs/DEPLOYMENT.md`, `docs/IMPL-PLAN.md` Phase 6 |
|
||||
|
||||
**Test-driven development (TDD)**: write the failing unit test first, then write the minimum code to pass it. This applies to all deterministic core functions: bdft math, pricing, rounding, image crop, and validation rules.
|
||||
|
||||
**Tests-with-code**: API contract tests, integration tests, and UI end-to-end tests are written in the same commit that implements the feature. Each feature commit includes its tests and shown execution output in the commit message.
|
||||
|
||||
---
|
||||
|
||||
## 4. Fundamentals — cross-cutting bar
|
||||
|
||||
These cases are the white-box bar for every dependency that touches the product. They run across all phases.
|
||||
|
||||
### SvelteKit / Vitest (frontend/src/__tests__/)
|
||||
|
||||
| Case | What must happen | Where the rule lives | Verified by |
|
||||
|---|---|---|---|
|
||||
| FUND-1 | Pure logic tests have zero browser, DOM, or network dependencies | SvelteKit/Vitest convention | Vitest unit test: `vi.mock` any runtime import, assert no browser globals |
|
||||
| FUND-2 | Component/browser tests use semantic `getByRole` selectors; class or data-testid alone is not sufficient | SvelteKit testing-library convention | Vitest component test: assert role-based query succeeds |
|
||||
| FUND-3 | Real Tab key navigates focus; click-only tests are not sufficient for keyboard paths | UX.md §Browser compatibility | Playwright E2E: `page.keyboard.press('Tab')` followed by `toBeFocused` |
|
||||
| FUND-4 | Shared rune/state changes flush before DOM assertion; stale store values do not pass | Svelte rune reactivity | Vitest component test: flush/store tick before `expect` |
|
||||
|
||||
### Playwright (frontend/e2e/)
|
||||
|
||||
| Case | What must happen | Where the rule lives | Verified by |
|
||||
|---|---|---|---|
|
||||
| FUND-5 | Tab order: press Tab, assert `toBeFocused` after each stop; no keyboard trap | Playwright convention; UX.md §Browser compatibility | Playwright E2E: sequential `keyboard.press('Tab')` + `toBeFocused` |
|
||||
| FUND-6 | Viewport resize/reflow at ~320 px and phone/PC sizes: layout does not clip, overlap, or trap content | UX.md §Browser compatibility | Playwright E2E: `setViewportSize` then assert no clip/overlap |
|
||||
| FUND-7 | Focus ring visible on touch and keyboard; focus ring is not hidden by `outline: none` on interactive elements | Playwright convention; WCAG 2.4.7 | Playwright E2E: assert focus ring CSS present on focused interactive element |
|
||||
|
||||
### FastAPI / pytest TestClient (backend/tests/)
|
||||
|
||||
| Case | What must happen | Where the rule lives | Verified by |
|
||||
|---|---|---|---|
|
||||
| FUND-8 | App factory pattern creates a fresh app instance per test; no shared mutable state between tests | FastAPI testing convention | pytest TestClient: each test calls the factory, not a module-level app |
|
||||
| FUND-9 | `with TestClient(app)` manages lifespan; startup and shutdown hooks run and clean up | FastAPI testing convention | pytest TestClient: context manager exits with clean lifespan |
|
||||
| FUND-10 | Clear `dependency_overrides` after every test; no override bleeds to the next test | FastAPI testing convention | pytest TestClient: assert `dependency_overrides` empty after each test |
|
||||
| FUND-11 | Override Woo/inference dependencies with mocks; no real network call leaks | FastAPI testing convention | pytest TestClient: assert zero HTTP calls to real Woo/inference endpoints |
|
||||
|
||||
### SQLite + Alembic (backend/tests/)
|
||||
|
||||
| Case | What must happen | Where the rule lives | Verified by |
|
||||
|---|---|---|---|
|
||||
| FUND-12 | `PRAGMA foreign_keys=ON` enforced in every test database; FK violation rejected | SQLite testing convention; DATA-MODEL.md | pytest + SQLite: insert orphan row, assert IntegrityError |
|
||||
| FUND-13 | Unique and check constraints enforced; duplicate or out-of-range values rejected | SQLite testing convention; DATA-MODEL.md | pytest + SQLite: insert violating row, assert IntegrityError |
|
||||
| FUND-14 | Concurrent poll / WAL stale-read risk handled; WAL mode with proper isolation level prevents stale reads | SQLite WAL convention; DATA-MODEL.md | pytest + SQLite: concurrent reader/writer, assert no stale read |
|
||||
| FUND-15 | Illegal status transitions rejected by the application layer; status machine enforced | AGENTS.md §7; DATA-MODEL.md slabs | pytest TestClient: attempt illegal transition, assert 422 |
|
||||
| FUND-16 | Migrations must not silently drop integrity indexes or constraints; post-migration assertions verify schema integrity | Alembic convention; DATA-MODEL.md | pytest + SQLite: run migration, assert indexes/constraints preserved |
|
||||
|
||||
### AES-GCM (settings)
|
||||
|
||||
| Case | What must happen | Where the rule lives | Verified by |
|
||||
|---|---|---|---|
|
||||
| FUND-17 | Fresh nonce per encrypt; tampered ciphertext or wrong key/nonce raises authentication failure | AES-GCM specification; AGENTS.md §4 | Unit test: tamper ciphertext, assert auth failure; encrypt twice, assert nonces differ |
|
||||
| FUND-18 | Ciphertext never appears in any GET response; only the configured username and `woo_credentials_configured` boolean returned | OPENAPI.md §settings; DATA-MODEL.md settings | API contract test: assert `woo_app_password` absent from GET /api/v1/settings |
|
||||
| FUND-19 | Missing `SLAB_AES_KEY` refuses startup; no fallback to plaintext storage | AGENTS.md §4; DATA-MODEL.md settings | Unit test: unset env var, assert startup rejection |
|
||||
|
||||
### Woo REST client (httpx + App Passwords over HTTPS)
|
||||
|
||||
| Case | What must happen | Where the rule lives | Verified by |
|
||||
|---|---|---|---|
|
||||
| FUND-20 | Basic Auth with App Passwords over HTTPS; consumer keys not implemented | AGENTS.md §4; DEPLOYMENT.md §3; OPENAPI.md §settings | Unit test: assert HTTP Basic header, HTTPS-only URL |
|
||||
| FUND-21 | 401/400/404 response shapes handled; server maps to actionable error, not raw passthrough | Woo REST convention; CONTENT-WOO.md §2.6 | Integration test with respx: mock 401/400/404, assert mapped error response |
|
||||
| FUND-22 | Pre-create SKU lookup mandatory; Woo concurrent duplicate-SKU race handled by checking before create | AGENTS.md §5; CONTENT-WOO.md §2.5 | Integration test with respx: create duplicate SKU, assert 409 before create |
|
||||
| FUND-23 | Timeouts and network errors handled gracefully; server returns actionable error, not stack trace | Woo REST convention; CONTENT-WOO.md §2.6 | Integration test with respx: mock timeout/connection error, assert graceful error |
|
||||
| FUND-24 | Secrets redacted in logs; no Woo username/password, inference key, or AES key in any log output | AGENTS.md §4; ARCHITECTURE.md §5 | Unit test: assert log output contains no secret material |
|
||||
|
||||
### Compose / Caddy (deploy/tests/)
|
||||
|
||||
| Case | What must happen | Where the rule lives | Verified by |
|
||||
|---|---|---|---|
|
||||
| FUND-25 | HTTPS-only `WOO_BASE_URL`; health through proxy; inventory/port collision checked at deploy time | DEPLOYMENT.md §3; AGENTS.md §9 | Shell script: assert HTTPS, health through Caddy, port conflict detection |
|
||||
|
||||
---
|
||||
|
||||
## Phase 0 / Gate B — Hybrid skeleton + deterministic core
|
||||
|
||||
**Exit evidence**: Ty confirms TV-3, TV-6, TV-7, and TV-10 output on real fixtures; health returns 200; AES round-trip passes; draft store stub persists and retrieves; unit and API cases automated.
|
||||
|
||||
### Unit — deterministic core (test-driven)
|
||||
|
||||
| Case | What must happen | Where the rule lives | Verified by |
|
||||
|---|---|---|---|
|
||||
| TV-1 | Green-sheet synthetic photo: interior green-ish patch that does not touch the border stays after chroma-key; U2Net is not invoked | `docs/TECH-SPEC-PIPELINE.md` §3.1, §3.4 | Unit test with synthetic image fixture |
|
||||
| TV-2 | Black-sheet synthetic photo: dark interior wood that does not touch the border stays after threshold; flood-fill isolates border-connected pixels only | `docs/TECH-SPEC-PIPELINE.md` §3.1, §3.4 | Unit test with synthetic image fixture |
|
||||
| TV-3 | Slab 96" long, 1.5" thick, mask 0.64 square feet → bdft = 0.96. Identity: 1 square foot at 1" thick equals 1 bdft. No divide-by-12. | `docs/TECH-SPEC-PIPELINE.md` §4.6 | Unit test, shown output on real fixtures |
|
||||
| TV-4 | Width samples at 6" stations on a 96" length axis report min, max, and average perpendicular to the confirmed axis | `docs/TECH-SPEC-PIPELINE.md` §4.4 | Unit test with fixture mask |
|
||||
| TV-5 | Slab with one square end and one 45° cut: length axis follows prevailing length; widths stay perpendicular after user confirm | `docs/TECH-SPEC-PIPELINE.md` §4.1, §4.2 | Unit test with angled-end fixture |
|
||||
| TV-6 | 3:4 crop, slab centered, 80 percent fill at extremes, transparent PNG, shorter side ≥ 1600 when source allows | `docs/TECH-SPEC-PIPELINE.md` §5, §7.1 | Unit test, shown output on real fixtures |
|
||||
| TV-7 | Undersized source: after 80 percent crop shorter side would be below 1600 and source cannot supply it → warn, no upscaling, photo not publishable until retake | `docs/TECH-SPEC-PIPELINE.md` §5, §7.1 | Unit test, shown output on real fixtures |
|
||||
| TV-8 | Pricing: bdft 0.96, species $12.50/bdft → recommended price 12.00; override 150.00 stored as 150.00; no rule → recommended price empty, manual 99.99 stored | `docs/TECH-SPEC-PIPELINE.md` §6, §8 TV-8 | Unit test with pricing rules |
|
||||
| TV-9 | Thickness 1" → Woo range rounds up (for example 1"–1 1/2") using synced attribute term, not a hardcoded label | `docs/TECH-SPEC-PIPELINE.md` §8 TV-9, `docs/DATA-MODEL.md` slabs | Unit test with mock taxonomy |
|
||||
| TV-10 | Rounding: 96.04" → 96"; square feet 0.641 → 0.64; bdft 11.525 → 11.53; price 12.345 → 12.35. Half-up to 2 decimals. | `docs/TECH-SPEC-PIPELINE.md` §8 TV-10 | Unit test, shown output on real fixtures |
|
||||
|
||||
### Unit — shared validation
|
||||
|
||||
| Case | What must happen | Where the rule lives | Verified by |
|
||||
|---|---|---|---|
|
||||
| V-1 | SKU charset `[A-Z0-9-]{3,24}` accepted; values outside the charset rejected | `docs/TECH-SPEC-PIPELINE.md` §2, issue #17 | Unit test with valid and invalid SKU strings |
|
||||
| V-2 | Title, short title, description: UTF-8 within WooCommerce maxLength enforced at field exit and at submit | `docs/TECH-SPEC-PIPELINE.md` §2, issue #17 | Unit test with boundary-length strings |
|
||||
| V-3 | Validation module version hash: client caches module; submit carries hash; mismatch returns 412 with fresh module, not 409 | `docs/OPENAPI.md` §validation, issue #17 | Unit test of validation module hash comparison |
|
||||
|
||||
### Unit — AES-GCM settings
|
||||
|
||||
| Case | What must happen | Where the rule lives | Verified by |
|
||||
|---|---|---|---|
|
||||
| AES-1 | Encrypt then decrypt round-trips for woo_wp_username and woo_app_password; ciphertext never appears in GET settings | `docs/ARCHITECTURE.md` §5, issue #5 | Unit test with round-trip encryption |
|
||||
| AES-2 | Missing SLAB_AES_KEY environment variable at startup: app refuses to start; no fallback to plaintext | `docs/AGENTS.md` §4, `docs/DATA-MODEL.md` settings | Unit test with missing env var |
|
||||
|
||||
### API / contract — health + draft store stub
|
||||
|
||||
| Case | What must happen | Where the rule lives | Verified by |
|
||||
|---|---|---|---|
|
||||
| H-1 | `GET /api/health` returns 200 `{ "status":"ok" }` with `woo_reachable` false when credentials are not configured | `docs/OPENAPI.md` §health, `docs/IMPL-PLAN.md` Phase 0 | API contract test via httpx TestClient |
|
||||
| D-1 | `POST /api/v1/slabs` accepts multipart with data JSON + files + metadata; returns 201 with `status: calibrated` and `server_rev` | `docs/OPENAPI.md` §slabs, `docs/DATA-MODEL.md` slabs | API contract test via httpx TestClient |
|
||||
|
||||
---
|
||||
|
||||
## Gate C — Early Phase 1 (first draft listing, inference disabled)
|
||||
|
||||
**Exit evidence**: Ty opens one `SLAB-UAT-*` WooCommerce draft listing in the real store admin; inference OFF; mocked-store automation is not Gate C exit.
|
||||
|
||||
### API / contract — create + draft
|
||||
|
||||
| Case | What must happen | Where the rule lives | Verified by |
|
||||
|---|---|---|---|
|
||||
| GC-1 | `POST /api/v1/slabs` with `SLAB-UAT-*` SKU creates a WooCommerce product with `status: "draft"` regardless of the `woo_create_status` setting | `docs/AGENTS.md` §5, `docs/CONTENT-WOO.md` §2.4 | API contract test with mocked Woo |
|
||||
| GC-2 | Duplicate SKU before create: `GET /products?sku={sku}` finds existing → 409 `duplicate_sku` with `{ field:"sku", actions:["edit_sku","open_existing"] }` | `docs/CONTENT-WOO.md` §2.5, `docs/OPENAPI.md` §slabs, issue #3 | API contract test with pre-existing SKU in mock |
|
||||
| GC-3 | `POST /api/v1/settings/test-woo` returns ok with populated taxonomy; always resyncs taxonomy on success | `docs/OPENAPI.md` §settings, issue #14 | API contract test with mocked Woo taxonomy |
|
||||
| GC-4 | `WOO_BASE_URL` is compose-only HTTPS; Settings GET returns it read-only; PUT rejects write attempts with 422 | `docs/AGENTS.md` §5, issue #11 | API contract test with PUT attempt |
|
||||
| GC-5 | FastAPI refuses to start when `WOO_BASE_URL` environment variable is missing or set to `http://` | `docs/AGENTS.md` §4, `docs/DEPLOYMENT.md` §3 | Unit test for startup config validation |
|
||||
|
||||
### Integration — WooCommerce mock (draft create, SLAB-UAT-*, duplicate SKU, taxonomy)
|
||||
|
||||
| Case | What must happen | Where the rule lives | Verified by |
|
||||
|---|---|---|---|
|
||||
| W-1 | Woo mock returns a new product with status draft; server stores `woo_product_id` and purges images | `docs/CONTENT-WOO.md` §2.4, `docs/IMPL-PLAN.md` Phase 4 | Integration test with respx mock |
|
||||
| W-2 | Woo mock returns 409 for duplicate SKU; server maps to `{ field:"sku", actions:["edit_sku","open_existing"] }`; draft retained | `docs/OPENAPI.md` §slabs, issue #3 | Integration test with respx mock returning 409 |
|
||||
| W-3 | Taxonomy sync returns cached data; anchor stays unchanged on no-op sync; anchor bumps only when stored-subset hash changes | `docs/ARCHITECTURE.md` §7, issue #14 | Integration test with taxonomy mock |
|
||||
| W-4 | `test-woo` always runs taxonomy resync after successful connection test | `docs/ARCHITECTURE.md` §7, issue #14 | Integration test: assert taxonomy resync call |
|
||||
| W-5 | Failed publish: slab status `failed`, photos retained, sync_log written; retry from same screen without re-photographing → success → photos purged | `docs/CONTENT-WOO.md` §2.6, `docs/TECH-SPEC-PIPELINE.md` §5, `docs/DATA-MODEL.md` slabs | Integration test with retryable mock failure |
|
||||
|
||||
### Integration — inference disabled path + test-inference probe (issue #2 substance)
|
||||
|
||||
| Case | What must happen | Where the rule lives | Verified by |
|
||||
|---|---|---|---|
|
||||
| INF-1 | With `inference_enabled: false`: slab create skips Call 1; `inference_status` is null; full suite passes end-to-end with no inference network calls | `docs/AGENTS.md` §6, `docs/IMPL-PLAN.md` Phase 5 | Integration test asserting zero inference calls |
|
||||
| INF-2 | `POST /api/v1/settings/test-inference` returns `{ ok: true, mode: "stateful", vision_capable: true }` on a stateful provider | `docs/OPENAPI.md` §settings, issue #2 | Integration test with mocked inference endpoint |
|
||||
| INF-3 | `test-inference` returns `{ ok: true, mode: "stateless", vision_capable: true }` on a stateless provider; Call 2 uses full resend | `docs/OPENAPI.md` §settings, issue #2 | Integration test with mocked inference endpoint |
|
||||
| INF-4 | `test-inference` returns `{ ok: false, mode: "fail" }`: Call 2 enablement blocked; Gate C path remains green with inference disabled | `docs/OPENAPI.md` §settings, issue #2 | Integration test: assert Call 2 blocked |
|
||||
|
||||
### UI end-to-end — capture knobs, mask, axis, ready gates (phone + PC)
|
||||
|
||||
| Case | What must happen | Where the rule lives | Verified by |
|
||||
|---|---|---|---|
|
||||
| UI-1 | Four inline knobs (sheet, sensitivity, edge offset, feather) on capture screen: changing any knob immediately re-runs mask and refreshes overlay without leaving the screen | `docs/TECH-SPEC-PIPELINE.md` §3.3, `docs/UX.md` §Capture, issue #15 | Playwright E2E test: knob change → overlay update, no navigation |
|
||||
| UI-2 | Mask overlay on source photo: user confirms edge; below-threshold edge after tuning → retake; no U2Net control in the proof of concept | `docs/TECH-SPEC-PIPELINE.md` §3.2, §3.5, `docs/UX.md` §Capture | Playwright E2E test: mask overlay visible, confirm/retake paths |
|
||||
| UI-3 | Length axis overlay: user rotates if skewed; samples and square feet update live; nearly-square slab: user picks length direction | `docs/TECH-SPEC-PIPELINE.md` §4.2, `docs/UX.md` §Capture | Playwright E2E test: axis rotation, live number update |
|
||||
| UI-4 | Numbers entry: length 1/8" steps, thickness pick, SKU typed; computed square feet/bdft/widths displayed | `docs/TECH-SPEC-PIPELINE.md` §4, `docs/UX.md` §Capture | Playwright E2E test: type inputs, assert computed output |
|
||||
| UI-5 | Ready-set gates: slab cannot reach `ready` until full named set is filled (SKU, length, thickness, square feet/bdft/widths, ≥1 PNG, species, wood category, edge, figure, grade, thickness band, moisture, price); inline nudges per empty required field, not a submit-time list | `docs/AGENTS.md` §7, `docs/UX.md` §Review, issue #9 | Playwright E2E test: attempt ready with missing fields, assert nudges |
|
||||
|
||||
### Ops — compose health
|
||||
|
||||
| Case | What must happen | Where the rule lives | Verified by |
|
||||
|---|---|---|---|
|
||||
| OPS-1 | `GET https://${APP_HOSTNAME}/api/health` returns ok after compose up with filled inventory | `docs/DEPLOYMENT.md` §8, `docs/IMPL-PLAN.md` Phase 0 | Shell script: compose up → curl health endpoint |
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 remainder — Full API surface
|
||||
|
||||
**Exit evidence**: agent-written test automation drives a full calibrated to ready cycle via the API; all OPENAPI endpoints match expected responses; revision conflict handled; taxonomy anchor logic verified.
|
||||
|
||||
### API / contract — all routes, concurrency, secrets redaction
|
||||
|
||||
| Case | What must happen | Where the rule lives | Verified by |
|
||||
|---|---|---|---|
|
||||
| API-1 | `PUT /api/v1/slabs/{id}` updates fields with `client_rev`; server echoes new `server_rev`; mismatch returns 409 `revision_conflict` with `current_server_rev` | `docs/OPENAPI.md` §conventions, `docs/DATA-MODEL.md` slabs | API contract test with httpx TestClient |
|
||||
| API-2 | `PUT /api/v1/slabs/{id}` stores client-sent derived fields (square feet, bdft, widths); server does not recompute them as source of truth | `docs/OPENAPI.md` §slabs PUT, `docs/ARCHITECTURE.md` §3 | API contract test: assert no server-side recompute |
|
||||
| API-3 | `GET /api/v1/settings` returns `SettingsView` with `woo_credentials_configured` boolean; `woo_app_password` never returned | `docs/OPENAPI.md` §settings, issue #5 | API contract test: assert password absent from response |
|
||||
| API-4 | `POST /api/v1/slabs/{id}/infer-taxon` returns 202 `{ status: "inferring" }`; client polls `GET /api/v1/slabs/{id}` for results; timeout greater than 45 seconds returns 504 | `docs/OPENAPI.md` §inference, issue #8 | API contract test with mocked inference |
|
||||
| API-5 | `POST /api/v1/slabs/{id}/infer-content` returns 202; requires `test-inference` ok; below-threshold Call 1 (confidence below 0.7): fields stay empty | `docs/OPENAPI.md` §inference, `docs/AGENTS.md` §6, issue #8/#9 | API contract test with confidence-gated mock |
|
||||
| API-6 | `POST /api/v1/slabs/{id}/publish`: pre-condition check returns 422 with field keys for missing required fields; UI maps fields, no raw code | `docs/OPENAPI.md` §publish, issue #18 | API contract test with incomplete slab |
|
||||
| API-7 | `POST /api/v1/admin/taxonomy/sync`: pulls categories/attributes/tags; rebuilds cache; anchor bumps only on stored-subset hash change; 502 retains previous cache | `docs/OPENAPI.md` §taxonomy, `docs/ARCHITECTURE.md` §7, issue #14 | Integration test with taxonomy mock |
|
||||
| API-8 | `GET /api/v1/validation/module` returns hash and rules; client caches; submit mismatch returns 412 with fresh module | `docs/OPENAPI.md` §validation, issue #17 | API contract test: assert 412 on stale hash |
|
||||
| API-9 | `DELETE /api/v1/slabs/{id}`: soft-delete returns 204; unpublished drafts only; published slabs undeletable | `docs/OPENAPI.md` §slabs DELETE | API contract test with published slab |
|
||||
| API-10 | `test-woo` fails closed: missing credentials or non-HTTPS URL returns `{ ok: false, detail: "..." }` without crashing | `docs/OPENAPI.md` §settings test-woo | API contract test with bad credentials |
|
||||
|
||||
### Integration — taxonomy sync + anchor
|
||||
|
||||
| Case | What must happen | Where the rule lives | Verified by |
|
||||
|---|---|---|---|
|
||||
| TAX-1 | Sync on publish when `now - taxonomy_last_sync_at > 1 hour`; bump anchor only on real change; stale picked id returns 422 with current options | `docs/ARCHITECTURE.md` §7, `docs/CONTENT-WOO.md` §2.2, issue #14 | Integration test with time-skewed sync |
|
||||
| TAX-2 | Species picker offers only WooCommerce-synced species; no hardcoded fallback; new species selectable only after sync stores it | `docs/ARCHITECTURE.md` §7, issue #12 | Integration test: assert species list matches cache |
|
||||
| TAX-3 | Client reads taxonomy anchor; re-pulls full taxonomy only when anchor advances past client's last value; no-per-navigation sync | `docs/ARCHITECTURE.md` §7, `docs/OPENAPI.md` §taxonomy | Integration test with anchor versioning |
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Frontend review + publish UI
|
||||
|
||||
**Exit evidence**: Ty publishes a test slab to the real store as a WooCommerce draft `SLAB-UAT-*`; automation covers UI cases; Ty is the exit.
|
||||
|
||||
### UI end-to-end — review, field recovery, publish outcome (phone + PC)
|
||||
|
||||
| Case | What must happen | Where the rule lives | Verified by |
|
||||
|---|---|---|---|
|
||||
| UIE-1 | Review screen: all fields editable; client-computed square feet/bdft/widths shown with manual override; typed override persists across poll and refresh | `docs/IMPL-PLAN.md` Phase 3, `docs/UX.md` §Review | Playwright E2E test: edit, poll, refresh, assert persistence |
|
||||
| UIE-2 | Call 1 prefill at or above 0.7 confidence: fields pre-filled and mutable; user override sticks; below 0.7 fields empty; inline nudges for every still-required control | `docs/AGENTS.md` §6, issue #8/#9, `docs/UX.md` §Review | Playwright E2E test with mocked Call 1 results |
|
||||
| UIE-3 | Duplicate SKU at publish: under SKU field, "This SKU already exists in the store (any status)"; edit SKU or open existing; draft retained; no raw 409 | `docs/UX.md` §Publish, `docs/CONTENT-WOO.md` §2.5, `docs/AGENTS.md` §5, issue #3/#18 | Playwright E2E test with pre-existing SKU |
|
||||
| UIE-4 | Stale field at publish: field shows "This value is no longer valid. Pick from the current list"; draft retained; no raw 422 | `docs/CONTENT-WOO.md` §2.6, issue #18 | Playwright E2E test with removed taxonomy item |
|
||||
| UIE-5 | Publish success: screen shows listing created, `woo_product_id` when available; local draft and photos gone; slab status `published` | `docs/CONTENT-WOO.md` §2.6, `docs/DATA-MODEL.md` slabs | Playwright E2E test: assert published state and cleanup |
|
||||
| UIE-6 | Publish failure: plain message, no status number, no stack; retry from same screen without re-photographing | `docs/CONTENT-WOO.md` §2.6, issue #18 | Playwright E2E test: assert retry path preserves state |
|
||||
| UIE-7 | Inference disabled: full manual taxonomy + content path works; no inference network calls; Generate text disabled; templates still work | `docs/IMPL-PLAN.md` Phase 3, `docs/UX.md` §Review | Playwright E2E test with inference flag off |
|
||||
| UIE-8 | Settings UI: WordPress username + application password + test-woo; inference endpoint + test; pricing rules; brand voice/GEO; `woo_create_status`; store URL read-only | `docs/IMPL-PLAN.md` Phase 3, `docs/OPENAPI.md` §settings, issue #11 | Playwright E2E test: settings form and test buttons |
|
||||
| UIE-9 | Changing sensitivity slider immediately refreshes mask overlay without leaving screen or server round trip | `docs/TECH-SPEC-PIPELINE.md` §3.3, issue #15, `docs/UX.md` §Capture | Playwright E2E test: slider → overlay update, no HTTP call |
|
||||
| UIE-10 | Changing edge offset pulls cut in/out; feather changes alpha blend; sheet switch (auto/green/black) re-runs; all persisted in localStorage until reset | `docs/TECH-SPEC-PIPELINE.md` §3.3, issue #15 | Playwright E2E test: knob changes, persist, reset |
|
||||
|
||||
---
|
||||
|
||||
## Phase 5 — Inference (after Gate C is green)
|
||||
|
||||
**Exit evidence**: Ty runs species detection on 3 real slabs with judgment on acceptance; automation covers Call 1/2 cases; full inference-disabled path green.
|
||||
|
||||
### Integration — Call 1/2, portable context, confidence gating
|
||||
|
||||
| Case | What must happen | Where the rule lives | Verified by |
|
||||
|---|---|---|---|
|
||||
| C1-1 | Call 1 sends all inventory photos at 1024px + WooCommerce taxonomy only + SKU/length/thickness; model constrained to cached taxonomy from WooCommerce cache | `docs/IMPL-PLAN.md` Phase 5, issue #12 | Integration test: inspect Call 1 request payload |
|
||||
| C1-2 | Confidence at or above 0.7: species, wood categories, edge, figure, grade, feat-* pre-filled and mutable; user override wins | `docs/AGENTS.md` §6, issue #8/#9 | Integration test with high-confidence mock response |
|
||||
| C1-3 | Confidence below 0.7: those fields stay empty; ready blocked until full named set is filled; species plus one figure is not the gate | `docs/AGENTS.md` §6, issue #8/#9 | Integration test with low-confidence mock response |
|
||||
| C2-1 | Call 2 triggered only by user tap; portable Call 1 context: full resend default; confirmed versus inferred labels carried | `docs/ARCHITECTURE.md` §9, issue #2 | Integration test: assert no auto-trigger, full resend |
|
||||
| C2-2 | Test-inference stateful mode: `previous_response_id` used when available; stateless mode: full resend; fail mode: Call 2 blocked | `docs/OPENAPI.md` §settings, issue #2 | Integration test with three probe modes |
|
||||
| C2-3 | LLM does not invent dimensions; templates inject deterministic numbers into title and description | `docs/PROMPTS.md` §Call 2, `docs/AGENTS.md` §6 | Integration test: inspect Call 2 response for invented numbers |
|
||||
| C2-4 | Audit: inference_log stores request_payload and response_payload per call; prompt file edits apply without restart | `docs/DATA-MODEL.md` inference_log, `docs/PROMPTS.md` §Management | Integration test: verify log entry, edit prompt file |
|
||||
|
||||
### UI end-to-end — inference disabled path
|
||||
|
||||
| Case | What must happen | Where the rule lives | Verified by |
|
||||
|---|---|---|---|
|
||||
| OFF-1 | Full suite (calibrated to ready to publish to success) passes with `inference_enabled: false`; zero calls to inference endpoint asserted | `docs/AGENTS.md` §6, `docs/IMPL-PLAN.md` Phase 5 | Playwright E2E test with network assertion for zero inference calls |
|
||||
| OFF-2 | `test-inference` fail mode blocks Call 2 enablement only; Gate C inference-disabled path unaffected | `docs/OPENAPI.md` §settings, issue #2 | Playwright E2E test: assert Call 2 disabled, full suite passes |
|
||||
|
||||
---
|
||||
|
||||
## Phase 6 / Ops — Deployment, user acceptance, hardening
|
||||
|
||||
**Exit evidence**: deployment smoke automated; Ty walks `docs/UAT-PRACTICE.md` and signs off on UAT results; v1 declared done.
|
||||
|
||||
### Ops — compose health, deploy path, backup/restore
|
||||
|
||||
| Case | What must happen | Where the rule lives | Verified by |
|
||||
|---|---|---|---|
|
||||
| OPS-2 | INVENTORY.md filled: APP_HOST, APP_HOSTNAME, ports verified free, Caddy location confirmed, DNS reachable from phone on LAN/Tailscale | `docs/DEPLOYMENT.md` §1, `docs/IMPL-PLAN.md` Phase 6 | Shell script: port scan, Caddy check, DNS resolve |
|
||||
| OPS-3 | Compose health: `GET https://${APP_HOSTNAME}/api/health` ok; `test-woo` ok with species populated; `woo_create_status=draft`; inference disabled for early gates | `docs/DEPLOYMENT.md` §8, `docs/UAT-PRACTICE.md` §1 | Shell script: curl health + test-woo endpoints |
|
||||
| OPS-4 | Backup: `deploy/backup.sh` creates WAL-safe `.backup` of slab.db and tar of images volume; retention 7 daily + 4 weekly | `docs/DEPLOYMENT.md` §6 | Shell script: run backup, verify files and retention |
|
||||
| OPS-5 | Restore: stop compose → restore DB + untar images → start compose → health ok; tested at least once before declaring backup done | `docs/DEPLOYMENT.md` §6, `docs/IMPL-PLAN.md` Phase 6 | Shell script: full restore cycle with health assertion |
|
||||
|
||||
### Ops — instrumentation (issue #6 substance)
|
||||
|
||||
| Case | What must happen | Where the rule lives | Verified by |
|
||||
|---|---|---|---|
|
||||
| INS-1 | Health non-200 on database or volume failure; `woo_reachable` false on WooCommerce unreachable | `docs/OPENAPI.md` §health, issue #6 | Unit test with simulated DB/volume failure |
|
||||
| INS-2 | Request ID logged on every server request | issue #6 | Unit test: assert request ID in log output |
|
||||
| INS-3 | Inference request_payload and response_payload recorded in inference_log; payloads contain model name and confidence data | `docs/DATA-MODEL.md` inference_log, issue #6 | Integration test: assert log entry after inference call |
|
||||
| INS-4 | Sync_log written with payload_hash on every publish attempt; retains success and failure | `docs/DATA-MODEL.md` sync_log, issue #6 | Integration test: assert sync_log entries for success and failure |
|
||||
|
||||
### Ops — end-to-end on all target browsers
|
||||
|
||||
| Case | What must happen | Where the rule lives | Verified by |
|
||||
|---|---|---|---|
|
||||
| E2E-1 | Capture to mask/axis confirm to calibrated upload to review to publish to WooCommerce draft `SLAB-UAT-*` works on iOS Safari (phone) | `docs/UX.md` §Browser compatibility, `docs/IMPL-PLAN.md` Phase 6 | Playwright E2E on iOS Safari |
|
||||
| E2E-2 | Same flow works on Android Chrome (phone) | `docs/UX.md` §Browser compatibility, `docs/IMPL-PLAN.md` Phase 6 | Playwright E2E on Android Chrome |
|
||||
| E2E-3 | Same flow works on desktop Chrome, Edge, Firefox (PC) | `docs/UX.md` §Browser compatibility, `docs/IMPL-PLAN.md` Phase 6 | Playwright E2E on desktop browsers |
|
||||
| E2E-4 | Manual length override recalculates widths/square feet/bdft correctly on all three browser families | `docs/TECH-SPEC-PIPELINE.md` §4.3, `docs/IMPL-PLAN.md` Phase 6 | Playwright E2E: override length, assert recalculation |
|
||||
| E2E-5 | Client crop: slab approximately 80 percent frame; shorter side ≥ 1600 when source allows; undersized source warns and requests retake | `docs/TECH-SPEC-PIPELINE.md` §5, `docs/IMPL-PLAN.md` Phase 6 | Playwright E2E: inspect crop output dimensions |
|
||||
|
||||
---
|
||||
|
||||
## When tests are written
|
||||
|
||||
**Deterministic core** (TV-1 through TV-10, validation, AES): test-driven development. Unit tests written before any user interface consumes the functions. Property checks: bdft monotonic in thickness and square feet; fill band; no upscale path.
|
||||
|
||||
**User interface, FastAPI, WooCommerce/inference adapters**: tests-with-code. Each feature commit includes its test and shown execution output.
|
||||
|
||||
**Integration tests**: httpx TestClient with a respx WooCommerce mock covering success, error, duplicate, stale-field, and taxonomy-anchor paths.
|
||||
|
||||
**Full suite must pass with inference disabled.** Call 1/Call 2 cases are gated after Gate C is green.
|
||||
|
||||
bdft equals square feet times thickness in inches (no divide-by-12). TV-3 proves the identity. Gate B requires TV-3, TV-6, TV-7, and TV-10 to show output on real fixtures.
|
||||
|
||||
---
|
||||
|
||||
## Cross-reference
|
||||
|
||||
| These cases | Source of truth document(s) |
|
||||
|---|---|
|
||||
| TV-1 through TV-10 | `docs/TECH-SPEC-PIPELINE.md` §8 |
|
||||
| V-1 through V-3 | `docs/TECH-SPEC-PIPELINE.md` §2, `docs/OPENAPI.md` §validation |
|
||||
| AES-1 through AES-2 | `docs/ARCHITECTURE.md` §5, `docs/AGENTS.md` §4 |
|
||||
| GC-1 through GC-5, W-1 through W-5 | `docs/CONTENT-WOO.md` §2.4–2.6, `docs/OPENAPI.md` §slabs/§settings, `docs/DEPLOYMENT.md` §3 |
|
||||
| INF-1 through INF-4 | `docs/OPENAPI.md` §settings, `docs/AGENTS.md` §6 |
|
||||
| UI-1 through UI-5 | `docs/TECH-SPEC-PIPELINE.md` §3–§4, `docs/UX.md` §Capture/§Review |
|
||||
| UIE-1 through UIE-10 | `docs/UX.md` §Review/§Publish, `docs/CONTENT-WOO.md` §2.5–2.6, `docs/IMPL-PLAN.md` Phase 3 |
|
||||
| C1-1 through C2-4 | `docs/AGENTS.md` §6–§7, `docs/PROMPTS.md` §Call 1/2 |
|
||||
| OPS-1 through OPS-5, E2E-1 through E2E-5 | `docs/DEPLOYMENT.md` §1–§8, `docs/IMPL-PLAN.md` Phase 6 |
|
||||
| INS-1 | `docs/OPENAPI.md` §health, `docs/AGENTS.md` §6 |
|
||||
| INS-2 | issue #6 |
|
||||
| INS-3 through INS-4 | `docs/DATA-MODEL.md` §inference_log/sync_log, `docs/AGENTS.md` §6 |
|
||||
| FUND-1 through FUND-25 | `docs/AGENTS.md` §1–§9, `docs/ARCHITECTURE.md` §5, `docs/OPENAPI.md`, `docs/DATA-MODEL.md`, `docs/CONTENT-WOO.md`, `docs/DEPLOYMENT.md` |
|
||||
|
||||
---
|
||||
|
||||
## Issue #19 mapping
|
||||
|
||||
This spec folds affirmative case substance from issue #19 where the rule exists in a live doc. Issue #19 is not closed by this work.
|
||||
|
||||
| Issue #19 item | Cases | Source of truth document(s) |
|
||||
|---|---|---|
|
||||
| #2 portable multi-turn | INF-2, INF-3, INF-4, C2-1, C2-2 | `docs/OPENAPI.md` §settings, `docs/ARCHITECTURE.md` §9 |
|
||||
| #3 duplicate SKU wording | GC-2, W-2, UIE-3 | `docs/OPENAPI.md` §slabs, `docs/CONTENT-WOO.md` §2.5 |
|
||||
| #5 Application Passwords | AES-1, AES-2, API-3 | `docs/ARCHITECTURE.md` §5, `docs/DATA-MODEL.md` settings |
|
||||
| #6 instrumentation | INS-1, INS-2, INS-3, INS-4 | `docs/DATA-MODEL.md` sync_log/inference_log, issue #6 |
|
||||
| #8/#9 Call 1 prefill + 0.7 | UIE-2, C1-2, C1-3 | `docs/AGENTS.md` §6, `docs/PROMPTS.md` §Call 1 |
|
||||
| #11 WOO_BASE_URL | GC-4, UIE-8 | `docs/AGENTS.md` §5, `docs/DEPLOYMENT.md` §3 |
|
||||
| #12 synced-species-only | C1-1, TAX-2 | `docs/ARCHITECTURE.md` §7, `docs/CONTENT-WOO.md` §2.2 |
|
||||
| #14 taxonomy_anchor | TAX-1, W-3, W-4, API-7 | `docs/ARCHITECTURE.md` §7, `docs/OPENAPI.md` §taxonomy |
|
||||
| #15 four inline knobs | UI-1, UIE-9, UIE-10 | `docs/TECH-SPEC-PIPELINE.md` §3.3, `docs/UX.md` §Capture |
|
||||
| #17 shared validation | V-1, V-2, V-3, API-8 | `docs/OPENAPI.md` §validation |
|
||||
| #18 409/422 recovery | UIE-3, UIE-4, UIE-5, UIE-6 | `docs/CONTENT-WOO.md` §2.5–2.6, `docs/OPENAPI.md` §slabs |
|
||||
|
||||
---
|
||||
|
||||
## Gates and phase map
|
||||
|
||||
| Phase / Gate | Cases | Exit evidence |
|
||||
|---|---|---|
|
||||
| Phase 0 / Gate B | TV-1 through TV-10, V-1 through V-3, AES-1 through AES-2, H-1, D-1, OPS-1, GC-5 | Ty confirms TV-3, TV-6, TV-7, TV-10 on real fixtures; health up; AES round-trip; draft store; unit/API automated |
|
||||
| Gate C (early Phase 1) | GC-1 through GC-4, W-1 through W-5, INF-1 through INF-4, UI-1 through UI-5 | Ty opens one `SLAB-UAT-*` WooCommerce draft in real store admin; inference OFF; mocked-store not exit |
|
||||
| Phase 1 remainder | API-1 through API-10, TAX-1, TAX-2, TAX-3 | Agent-written test automation drives full calibrated to ready cycle; OPENAPI matches; revision and taxonomy green |
|
||||
| Phase 3 | UIE-1 through UIE-10 | Ty publishes test slab as Woo draft `SLAB-UAT-*`; automation UI cases; Ty is exit |
|
||||
| Phase 5 | C1-1 through C2-4, OFF-1, OFF-2 | Ty runs species detection on 3 real slabs with judgment on acceptance; automation Call 1/2 cases; inference disabled path green |
|
||||
| Phase 6 / production | OPS-2 through OPS-5, INS-1 through INS-4, E2E-1 through E2E-5 | Deployment smoke automated; Ty walks `docs/UAT-PRACTICE.md` and signs off on UAT; v1 done |
|
||||
|
||||
---
|
||||
|
||||
## Rules that do not change
|
||||
|
||||
- Inference is optional. The full suite passes with inference disabled. Call 1/Call 2 cases are gated after Gate C.
|
||||
- bdft equals square feet times thickness in inches. No divide-by-12. TV-3 proves the identity.
|
||||
- Client is authoritative for deterministic math; server stores client-sent numbers.
|
||||
- No new product locks in this document. All rules reference existing live docs or issue #19 substance.
|
||||
@@ -0,0 +1,227 @@
|
||||
# UAT Practice Walk: one `SLAB-UAT-*` listing
|
||||
|
||||
Status: WALK · for Ty to run · SlabUploader
|
||||
|
||||
This is the walkable practice plan. It proves one listing end to end without touching a live product.
|
||||
Follow the sections in order. Each one names what you do, what you should see, and where the rule
|
||||
lives.
|
||||
|
||||
**Inference is OFF for this walk.** The optional ON path is only valid after Gate C is green and
|
||||
the inference endpoint is already configured. Inference stays off here.
|
||||
|
||||
---
|
||||
|
||||
## 1. Host and deploy preconditions
|
||||
|
||||
Before this walk, the app must already be on the inventoried host. This section only checks that.
|
||||
Host IPs and hostnames come from `deploy/INVENTORY.md`.
|
||||
|
||||
1. **Inventory is filled.** `deploy/INVENTORY.md` has `APP_HOST`, `APP_HOSTNAME`, ports, Caddy
|
||||
location, and the smoke results. It is the only source for what runs where on this host.
|
||||
See `docs/DEPLOYMENT.md` §1 for the worksheet rules.
|
||||
2. **Reachable over LAN/Tailscale.** Your phone reaches `https://${APP_HOSTNAME}` on the same
|
||||
network (LAN or Tailscale). No public exposure. Verify with a browser, not `curl`.
|
||||
3. **HTTPS only.** The URL is `https://`. The browser shows a valid certificate. If the phone
|
||||
warns, the Caddy TLS trust is not set up yet. That is a deploy problem, not a listing problem.
|
||||
4. **Health passes.** In a browser on the phone, open `https://${APP_HOSTNAME}/api/health`.
|
||||
You should see an ok response. This is the same check in `docs/DEPLOYMENT.md` §8 step 1.
|
||||
5. **Store URL is set.** `WOO_BASE_URL` is in the host `.env` and points at the store over
|
||||
`https://`. It is not something you type in the app. See `docs/DEPLOYMENT.md` §3 and §5.
|
||||
|
||||
If any of these fail, fix the deploy before continuing. This walk does not cover Gate B host work.
|
||||
It assumes the app is up and reachable.
|
||||
|
||||
---
|
||||
|
||||
## 2. WordPress Application Password setup
|
||||
|
||||
You do this once per store, in the store's WordPress admin. Do it before you open the app's
|
||||
Settings.
|
||||
|
||||
1. **Store is on HTTPS.** WordPress only offers Application Passwords over HTTPS. The store must
|
||||
already be on TLS. If it is not, stop and fix the store first.
|
||||
2. **Dedicated low-privilege user.** Create or use a WordPress user that can create and edit
|
||||
products and upload media. The store admin login is not reused. A shop manager role or a custom
|
||||
role with product create/edit plus media upload is enough.
|
||||
3. **Generate the password.** Sign in as that user. Go to **Users → Profile → Application
|
||||
Passwords**. Generate a password and label it "SlabUploader". Copy the password. WordPress
|
||||
shows it once.
|
||||
4. **Enter it in the app.** Open the app's Settings on the phone. Enter the WordPress username and
|
||||
the application password. The server stores them encrypted. The browser does not keep them.
|
||||
5. **Run `test-woo`.** In Settings, tap the test. It should report ok. This also resyncs the
|
||||
store's species, categories, tags, and attributes into the app. If it fails, the username or
|
||||
password is wrong, or the store is unreachable.
|
||||
|
||||
Rules: this is a WordPress Application Password, not WooCommerce consumer keys (`ck_`/`cs_`).
|
||||
The app never calls the store from the browser. FastAPI is the only caller. Rotate by revoking
|
||||
the old password in the WP profile and entering a new one in Settings, then re-running
|
||||
`test-woo`. Full lock: `AGENTS.md` §4 and §5, `docs/DEPLOYMENT.md` §5.
|
||||
|
||||
---
|
||||
|
||||
## 3. Practice build rules
|
||||
|
||||
These rules keep practice runs invisible to customers. They are locked. Changes are not made for
|
||||
the walk.
|
||||
|
||||
- **SKU prefix `SLAB-UAT-*`.** Every practice listing uses a SKU that starts with `SLAB-UAT-`.
|
||||
Pick a unique one each time, for example `SLAB-UAT-001`.
|
||||
- **Always a Woo draft.** A `SLAB-UAT-*` SKU creates a WooCommerce draft even if Settings say
|
||||
publish. Customers cannot open it. This is hard rule in `AGENTS.md` §5.
|
||||
- **`woo_create_status` defaults to draft.** The Settings field that controls create status
|
||||
defaults to `draft`. Practice uses the default. If it is `publish`, the
|
||||
`SLAB-UAT-*` rule still forces a draft.
|
||||
- **Customers never see practice listings.** A Woo draft is hidden from the storefront. If you
|
||||
can see the product on the live store, something is wrong.
|
||||
|
||||
If a practice SKU is not `SLAB-UAT-*`, it is not a practice run. Real SKUs are not published in
|
||||
this walk.
|
||||
|
||||
---
|
||||
|
||||
## 4. Prove one listing end to end
|
||||
|
||||
This is the main walk. Run it on a phone browser first, then repeat on a PC browser. Both are in
|
||||
the signed compatibility matrix (`docs/UX.md`, signed 2026-09-06).
|
||||
|
||||
Supported browsers: iOS Safari, Android Chrome on phones; desktop Chrome, Edge, Firefox on PC.
|
||||
Exact version pins are tracked under issue #4. If the browser is not supported, the app shows a
|
||||
hard stop. Switch to a supported browser.
|
||||
|
||||
### 4.1 Capture
|
||||
|
||||
1. Open the app on the phone. Start a new slab.
|
||||
2. Take 1 to 5 photos of the slab. The first should be top-down, lens parallel to the broad
|
||||
face. A tilted shot makes the numbers wrong. Retake if it is tilted.
|
||||
3. **Mask overlay and four knobs.** The app draws a mask over the photo. You see which pixels are
|
||||
slab and which are not. Tune the four knobs: **Sheet** (auto/green/black), **Sensitivity**,
|
||||
**Edge offset**, **Feather**. Each change re-runs the mask immediately. Look at the edge
|
||||
against the real wood. If it is still off, retake the photo.
|
||||
4. **Confirm the mask.** Tap confirm.
|
||||
5. **Length axis.** The app draws the bounding rectangle and the length axis. Confirm it follows
|
||||
the slab's long direction. Rotate if it is skewed. On a nearly square slab, you say which way
|
||||
is length.
|
||||
6. **Enter numbers by hand.** Length in 1/8" steps. Thickness as a 1/8" pick. SKU: type
|
||||
`SLAB-UAT-001` (or your chosen practice SKU).
|
||||
7. **Check the computed numbers.** The browser shows square feet, board feet, and width samples.
|
||||
Board feet = square feet × thickness in inches. No divide-by-12. If the numbers look wrong,
|
||||
go back and fix the mask or the length.
|
||||
8. **Continue.** This uploads the originals, the processed PNGs, and the numbers to the server.
|
||||
The slab status becomes `calibrated`.
|
||||
|
||||
### 4.2 Review
|
||||
|
||||
1. You are on the review screen. Fill every required field. Inference is OFF, so everything starts
|
||||
empty.
|
||||
2. **Species.** Pick exactly one from the synced store list. The list came from `test-woo`.
|
||||
Only species in the list may be typed.
|
||||
3. **Wood categories.** Pick at least one from the synced list.
|
||||
4. **Edge type, figure, grade.** Pick from the synced lists. At least one figure, at least one
|
||||
grade.
|
||||
5. **Thickness band.** Pick the Woo band that the measured thickness rounds up into.
|
||||
6. **Moisture.** Keep the default (kiln-dried) or change it.
|
||||
7. **fig-* tags.** Pick at least one.
|
||||
8. **feat-* tags.** Zero or more. Only pick existing `feat-*` tags.
|
||||
9. **Price.** A recommendation fills from the species $/bdft rule times board feet. You can
|
||||
override it. If there is no rule for the species, the price starts empty. Type it. Publish
|
||||
is still allowed with a manual price.
|
||||
10. **Title and description.** You can type them, or leave them for templates at publish.
|
||||
Templates inject the deterministic numbers. Generate text is OFF for this walk.
|
||||
|
||||
Every empty required field shows an inline nudge where it sits. The slab cannot reach `ready`
|
||||
until the full set is filled.
|
||||
|
||||
### 4.3 Publish
|
||||
|
||||
1. Tap **Publish**.
|
||||
2. The server checks the SKU against the store. If `SLAB-UAT-001` is new, it creates the
|
||||
product.
|
||||
3. **What you should see:** a success message on the screen. The local draft and its photos are
|
||||
gone from the app. The slab status in the app is `published`.
|
||||
4. **What that does NOT mean:** the product is not live in the store. It is a WooCommerce draft.
|
||||
The app's "published" means the store create succeeded and the local row was cleaned up.
|
||||
|
||||
### 4.4 Confirm in the store
|
||||
|
||||
1. Open the store's WooCommerce admin.
|
||||
2. Find the product by SKU `SLAB-UAT-001`.
|
||||
3. **It must be a draft.** Status is draft, not published.
|
||||
4. **Check the fields:** SKU, price, category (species), tags (fig-*, feat-*), attributes
|
||||
(Edge Type, Figure, Grade, Thickness, Moisture), and images (the processed transparent PNGs).
|
||||
5. **Open the live storefront.** The product must not appear. A draft is invisible to customers.
|
||||
|
||||
### 4.5 Repeat on a PC browser
|
||||
|
||||
Repeat 4.1 through 4.4 on a desktop browser (Chrome, Edge, or Firefox). Use a different
|
||||
practice SKU, for example `SLAB-UAT-002`. The flow must work the same way. This is the signed
|
||||
PC-browser compatibility check.
|
||||
|
||||
---
|
||||
|
||||
## 5. Pass and fail checks
|
||||
|
||||
You can see all of these without reading code.
|
||||
|
||||
| Check | Where | Pass looks like |
|
||||
|---|---|---|
|
||||
| Health endpoint | Phone browser, `https://${APP_HOSTNAME}/api/health` | ok response |
|
||||
| `test-woo` ok | App Settings | ok; species list is populated |
|
||||
| `woo_create_status` is draft | App Settings | shows `draft` (or `publish`, but `SLAB-UAT-*` forces draft anyway) |
|
||||
| Slab reaches `calibrated` | App, after capture | status is `calibrated` |
|
||||
| Slab reaches `ready` | App, after review | no empty required fields; inline nudges gone |
|
||||
| Publish succeeds | App, after Publish | success message; local draft is gone |
|
||||
| Woo product exists as draft | Store admin, products | SKU `SLAB-UAT-*`, status draft |
|
||||
| SKU prefix correct | Store admin | starts with `SLAB-UAT-` |
|
||||
| Store not live | Live storefront URL | product does not appear |
|
||||
| Board feet identity | App review screen, or a fixture | bdft = sqft × thickness in inches; no divide-by-12 |
|
||||
| Images are processed PNGs | Store admin, product images | transparent PNGs, not the original JPEGs |
|
||||
|
||||
### Duplicate SKU recovery
|
||||
|
||||
If you publish the same SKU twice, the second time the app stops before creating a second product.
|
||||
Under the SKU field you see: "This SKU already exists in the store (any status)." Two paths:
|
||||
edit the SKU to a new value, or open the existing listing. The draft stays in the app. Nothing is
|
||||
lost. This is the expected behavior, not a bug. See `docs/CONTENT-WOO.md` §2.5.
|
||||
|
||||
### What a fail looks like
|
||||
|
||||
- **No success message after Publish.** The app shows a plain message that the listing could not
|
||||
be created. No status number, no stack trace. The server kept a log. You can retry from the
|
||||
same screen without re-photographing.
|
||||
- **A value is no longer valid.** If a category, attribute, or price was removed from the store
|
||||
between sync and publish, that field shows "This value is no longer valid. Pick from the
|
||||
current list." Re-pick and retry.
|
||||
- **Store unreachable.** A plain message says the store could not be reached. Check the network
|
||||
and retry.
|
||||
|
||||
---
|
||||
|
||||
## 6. Cleanup
|
||||
|
||||
After the walk, decide what to do with the Woo draft UAT product.
|
||||
|
||||
- **Leave it or delete it.** Both are fine. It is a draft. Customers cannot see it. If you delete
|
||||
it, the store admin is the place to do it. The app does not delete store products.
|
||||
- **Never customer-visible.** If the product is visible on the live storefront, it is not a
|
||||
draft. That is a bug. Stop and report it.
|
||||
|
||||
**Real live publish is a separate step.** It is not part of this walk. A real product uses a
|
||||
non-UAT SKU (no `SLAB-UAT-` prefix) and is published only after you review it. That is a
|
||||
different decision. That step is not run here. The name of that step is **final POC publish**, and it
|
||||
is gated on your review of one real listing.
|
||||
|
||||
---
|
||||
|
||||
## Where each rule lives
|
||||
|
||||
| Rule | Source |
|
||||
|---|---|
|
||||
| Practice SKU forces draft | `AGENTS.md` §5 |
|
||||
| `woo_create_status` default draft | `AGENTS.md` §5, `docs/CONTENT-WOO.md` §2.4 |
|
||||
| App Password setup, not consumer keys | `AGENTS.md` §4, `docs/DEPLOYMENT.md` §5 |
|
||||
| Host inventory before deploy | `docs/DEPLOYMENT.md` §1, `deploy/INVENTORY.md` |
|
||||
| Capture → review → publish flow | `docs/UX.md` (SIGNED 2026-09-06) |
|
||||
| Board feet identity | `docs/TECH-SPEC-PIPELINE.md` |
|
||||
| Duplicate SKU recovery | `docs/CONTENT-WOO.md` §2.5, `docs/UX.md` Publish |
|
||||
| PC browser compatibility | `docs/UX.md` Browser compatibility |
|
||||
| Gate C = early Phase 1 | `docs/IMPL-PLAN.md` Phase 1, `AGENTS.md` §0b |
|
||||
@@ -0,0 +1,486 @@
|
||||
# UX Design — SlabUploader Phone App
|
||||
|
||||
Status: DESIGN · 2026-09-03 · SlabUploader
|
||||
Screens for the online-only POC flow. Mobile-first, 375px viewport.
|
||||
|
||||
Functional rules live in `docs/UX.md`. Confidence gating: `AGENTS.md` §6.
|
||||
Data model and status machine: `docs/DATA-MODEL.md`.
|
||||
|
||||
---
|
||||
|
||||
## Screen inventory
|
||||
|
||||
1. Settings (once)
|
||||
2. First-run guidance (once per device)
|
||||
3. Capture
|
||||
4. Length axis
|
||||
5. Type values
|
||||
6. Review
|
||||
7. Title / Description
|
||||
8. Publish
|
||||
9. Error states
|
||||
|
||||
---
|
||||
|
||||
## 1. Settings
|
||||
|
||||
One-time setup. All screen. Back to home after save.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ Settings │
|
||||
├─────────────────────────────────────┤
|
||||
│ Store URL │
|
||||
│ https://www.whidbeywoodstore.com │
|
||||
│ │
|
||||
│ WP Username │
|
||||
│ [ slab-uploader ] │
|
||||
│ Application Password │
|
||||
│ [ •••••••••••••••••••• ] │
|
||||
│ [ Test Connection ] │
|
||||
│ │
|
||||
│ Inference │
|
||||
│ Endpoint: [ http://192.168.x.x:v1 ]│
|
||||
│ API Key: [ •••••••••••••••••••• ]│
|
||||
│ [ Test Connection ] │
|
||||
│ [ Enable Vision ] │
|
||||
│ [ Enable Content ] │
|
||||
│ │
|
||||
│ Pricing Rules │
|
||||
│ [+ Add Rule] [Edit] │
|
||||
│ │
|
||||
│ Publish Status: [ Draft ▼ ] │
|
||||
│ Brand Voice: [ Premium lumber... ]│
|
||||
│ GEO Context: [ Locally milled... ]│
|
||||
│ │
|
||||
│ [ Reset Capture Knobs ] │
|
||||
│ [ Refresh Store Lists ] │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Interaction:
|
||||
- Test Connection (WP): attempts REST v3 auth, shows green check or red error. Always pulls fresh taxonomy on success.
|
||||
- Test Connection (Inference): two-turn probe. Shows stateful/stateless/fail.
|
||||
- Enable Vision: feature flag for Call 1. Defaults off.
|
||||
- Enable Content: feature flag for Call 2. Defaults off.
|
||||
- Publish Status: dropdown, default Draft.
|
||||
|
||||
---
|
||||
|
||||
## 2. First-run guidance
|
||||
|
||||
One short card shown on first launch. Dismissed via swipe or tap.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ │
|
||||
│ First run on the floor │
|
||||
│ │
|
||||
│ Lay a green or black sheet │
|
||||
│ under the slab. │
|
||||
│ │
|
||||
│ Shoot from above with the lens │
|
||||
│ parallel to the face. │
|
||||
│ │
|
||||
│ Put a tape measure in the shot │
|
||||
│ so you can type the length later. │
|
||||
│ │
|
||||
│ ────────────────────────────────── │
|
||||
│ [ Got it ] │
|
||||
│ │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Capture
|
||||
|
||||
Camera view. Source photo on screen. Mask overlay draws on the source.
|
||||
Four knobs sit next to the overlay.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ Capture [R] │
|
||||
│ ┌─────────────────────────────────┐ │
|
||||
│ │ │ │
|
||||
│ │ [ SOURCE PHOTO ] │ │
|
||||
│ │ │ │
|
||||
│ │ ~ mask overlay ~ │ │
|
||||
│ │ │ │
|
||||
│ └─────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Sheet: [ Auto ▼ ] │
|
||||
│ Sensitivity: [◄─────●───►] │
|
||||
│ Edge Offset: [◄───●───►] │
|
||||
│ Feather: [◄─────●───►] │
|
||||
│ │
|
||||
│ [ Retake ] [ Confirm ] │
|
||||
└─────────────────────────────────────┘
|
||||
|
||||
R = retake current photo (new shot, same knobs)
|
||||
```
|
||||
|
||||
Interaction:
|
||||
- Moving any knob immediately re-runs background removal on the current photo.
|
||||
- The overlay refreshes on the source image. No apply/save step. No server round-trip.
|
||||
- Confirm moves to length axis. Retake takes a new photo.
|
||||
- Knobs persist in localStorage until reset-to-default in Settings.
|
||||
|
||||
---
|
||||
|
||||
## 4. Length axis
|
||||
|
||||
Bounding rectangle and length axis drawn over the confirmed mask.
|
||||
Width sample lines every 6 inches.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ Length Axis │
|
||||
│ ┌─────────────────────────────────┐ │
|
||||
│ │ │ │
|
||||
│ │ ┌───────────────┐ │ │
|
||||
│ │ │ SLAB MASK │ │ │
|
||||
│ │ │ ──── AXIS ── │ │ │
|
||||
│ │ │ ┌──┐ ┌──┐ ┌──┐ │ │
|
||||
│ │ │ │ 6"│ │6"│ │6"│ │ │
|
||||
│ │ └───────────────┘ │ │
|
||||
│ │ │ │
|
||||
│ └─────────────────────────────────┘ │
|
||||
│ │
|
||||
│ [ Rotate ↻ ] [ Confirm ] │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Interaction:
|
||||
- Rotate adjusts the axis. Width samples and sqft update live.
|
||||
- On nearly square slabs, user picks which side is length.
|
||||
- Confirm moves to type values.
|
||||
|
||||
---
|
||||
|
||||
## 5. Type values
|
||||
|
||||
Manual values win. Phone computes sqft, bdft, widths.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ Type Values │
|
||||
│ │
|
||||
│ SKU │
|
||||
│ [ BW-0042 ] │
|
||||
│ │
|
||||
│ Length (inches) │
|
||||
│ [ 96.0 ] │
|
||||
│ │
|
||||
│ Thickness (inches) │
|
||||
│ [ 1.5 ] │
|
||||
│ │
|
||||
│ Computed (read-only) │
|
||||
│ sqft: 0.64 bdft: 0.96 │
|
||||
│ widths: 14.0 — 22.0 (avg 18.2) │
|
||||
│ │
|
||||
│ [ Done ] │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Interaction:
|
||||
- SKU: `[A-Z0-9-]{3,24}`. Field-exit check against validation rules.
|
||||
- Length: 1/8" step. Typing recalculates widths/sqft/bdft live.
|
||||
- Thickness: 1/8" step pick.
|
||||
- Done moves to review screen. Draft uploads, status → calibrated.
|
||||
|
||||
---
|
||||
|
||||
## 6. Review
|
||||
|
||||
All store fields. Per-field nudges for missing mandatory fields.
|
||||
Vision pre-populates when confidence ≥ 0.7. Empty when below.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ Review [X]│
|
||||
│ ────────────────────────────────── │
|
||||
│ Photos │
|
||||
│ [ ▢ ] [ ▢ ] [ ▢ ] [ ▢ ] [ ▢ ] │
|
||||
│ (processed PNG thumbnails) │
|
||||
│ ────────────────────────────────── │
|
||||
│ SKU: BW-0042 │
|
||||
│ Length: 96.0" Thickness: 1.5" │
|
||||
│ sqft: 0.64 bdft: 0.96 │
|
||||
│ Widths: 14.0 — 22.0 (avg 18.2) │
|
||||
│ ────────────────────────────────── │
|
||||
│ Species [ Black Walnut ▼ ] │
|
||||
│ Wood Cat [ Live edge ▼ ] │
|
||||
│ Edge Type [ Live Edge ▼ ] │
|
||||
│ Figure [ Cathedral ▼ ] │
|
||||
│ Grade [ Premium ▼ ] │
|
||||
│ Fig Tags [ Cathedral ▼ ] │
|
||||
│ Feat Tags [ None ▼ ] │
|
||||
│ ────────────────────────────────── │
|
||||
│ Thickness Band: [ 1"–1 1/2" ▼ ] │
|
||||
│ Moisture: [ Kiln-dried ▼ ] │
|
||||
│ ────────────────────────────────── │
|
||||
│ Price: [ $12.00 ] (rec $12.00) │
|
||||
│ ────────────────────────────────── │
|
||||
│ [ Review Title / Desc ] [ Publish ]│
|
||||
└─────────────────────────────────────┘
|
||||
|
||||
X = exit review, returns to calibrated draft
|
||||
```
|
||||
|
||||
Per-field gating:
|
||||
- Each missing mandatory field shows an inline empty state as the user works.
|
||||
- Below-threshold Call 1 leaves taxonomy fields empty. The user sees the empty
|
||||
field with a nudge: "Select species" or "Select a figure type".
|
||||
- Below-threshold leaves taxonomy fields empty. The user must manually fill species, wood
|
||||
category, edge type, figure, grade, fig-* tags, and grade to reach ready.
|
||||
- Submit-time validation only fires after all fields are filled.
|
||||
|
||||
Pre-populated field appearance:
|
||||
- Filled by Call 1: field shows the value with a small "AI" badge. User can
|
||||
tap to change. Badge disappears on edit.
|
||||
- User-filled: no badge.
|
||||
|
||||
Gating details:
|
||||
- Mandatory: exactly one species, ≥1 wood category, 1 edge type, ≥1 figure,
|
||||
≥1 fig-* tag, ≥1 grade, thickness band, moisture, price, SKU, length,
|
||||
thickness, sqft/bdft/widths, ≥1 inventory PNG.
|
||||
- Optional: feat-* tags (zero is fine).
|
||||
- Publish button is disabled until all mandatory fields are filled.
|
||||
|
||||
---
|
||||
|
||||
## 7. Title / Description
|
||||
|
||||
Type or generate. Generate text is user-initiated.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ Title & Description │
|
||||
│ ────────────────────────────────── │
|
||||
│ Title │
|
||||
│ [ Black Walnut 8ft × 18.2in Slab... │
|
||||
│ ────────────────────────────────── │
|
||||
│ Short Title │
|
||||
│ [ Black Walnut Slab — Cathedral ] │
|
||||
│ ────────────────────────────────── │
|
||||
│ Description │
|
||||
│ [ A one-of-a-one cathedral black... │
|
||||
│ ────────────────────────────────── │
|
||||
│ [ Generate Text ] [ Done ] │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Interaction:
|
||||
- Generate Text: triggers Call 2. Button disabled if content LLM is off or
|
||||
inference endpoint unreachable. LLM writes prose only. Templates inject
|
||||
deterministic numbers. User can always edit the result.
|
||||
- Done: returns to review. Title/description stored on the slab.
|
||||
|
||||
---
|
||||
|
||||
## 8. Publish
|
||||
|
||||
Status polling. Result shown inline.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ Publishing... │
|
||||
│ │
|
||||
│ ──────── ● ──────── │
|
||||
│ │
|
||||
│ Creating store listing... │
|
||||
│ Please wait. │
|
||||
│ │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Success:
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ Published! │
|
||||
│ │
|
||||
│ Listing created as draft. │
|
||||
│ SKU: BW-0042 │
|
||||
│ Store product ID: 1042 │
|
||||
│ │
|
||||
│ [ Open in Store ] [ Done ] │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Failed:
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ Publish Failed │
|
||||
│ │
|
||||
│ The store rejected the listing. │
|
||||
│ Error shown inline on the field. │
|
||||
│ │
|
||||
│ [ Retry ] [ Back to Review ] │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Error and empty states
|
||||
|
||||
### 9.1 Mask failure
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ Mask Quality │
|
||||
│ ────────────────────────────────── │
|
||||
│ Cannot isolate the slab edge. │
|
||||
│ Adjust the knobs or retake. │
|
||||
│ ────────────────────────────────── │
|
||||
│ [ Retake ] │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 9.2 Axis misalignment
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ Axis Check │
|
||||
│ ────────────────────────────────── │
|
||||
│ Rotate the axis to match the │
|
||||
│ prevailing length of the slab. │
|
||||
│ ────────────────────────────────── │
|
||||
│ [ Rotate ↻ ] │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 9.3 Source too small after crop
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ Photo Quality │
|
||||
│ ────────────────────────────────── │
|
||||
│ Photo will be too small after │
|
||||
│ cropping. Please retake with a │
|
||||
│ closer shot. │
|
||||
│ ────────────────────────────────── │
|
||||
│ [ Retake ] │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 9.4 SKU duplicate
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ SKU Check │
|
||||
│ ────────────────────────────────── │
|
||||
│ This SKU already exists in the │
|
||||
│ store (any status). │
|
||||
│ ────────────────────────────────── │
|
||||
│ [ Edit SKU ] [ Open Existing ] │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 9.5 Stale taxonomy value
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ Field Check │
|
||||
│ ────────────────────────────────── │
|
||||
│ This value is no longer valid. │
|
||||
│ Pick from the current list. │
|
||||
│ ────────────────────────────────── │
|
||||
│ [ Refresh Store Lists ] │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 9.6 Store unreachable
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ Connection │
|
||||
│ ────────────────────────────────── │
|
||||
│ Cannot reach the store. Check your │
|
||||
│ connection and settings. │
|
||||
│ ────────────────────────────────── │
|
||||
│ [ Retry ] [ Open Settings ] │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 9.7 Vision off or below threshold
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ Review (vision off) │
|
||||
│ ────────────────────────────────── │
|
||||
│ Species [ Select ▼ ] ◉ Select species
|
||||
│ Wood Cat [ Select ▼ ] ◉ Select category
|
||||
│ Edge Type [ Select ▼ ] ◉ Select edge
|
||||
│ Figure [ Select ▼ ] ◉ Select figure
|
||||
│ ────────────────────────────────── │
|
||||
└─────────────────────────────────────┘
|
||||
|
||||
◉ = inline nudge (per-field)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Interaction patterns
|
||||
|
||||
### Per-field gating (review screen)
|
||||
- Each mandatory field that is empty shows an inline nudge as the user works.
|
||||
- The nudge is a small text hint to the right of the field.
|
||||
- Below-threshold Call 1 leaves fields empty with nudges visible.
|
||||
- Fill the field to dismiss the nudge.
|
||||
- Publish button stays disabled until all mandatory fields are filled.
|
||||
|
||||
### Pre-populated field state
|
||||
- Fields filled by Call 1 show the value with an "AI" badge.
|
||||
- User can tap the field to change it. The badge disappears on edit.
|
||||
- User-filled fields have no badge.
|
||||
|
||||
### Knob interaction (capture screen)
|
||||
- All four knobs are on the capture screen next to the overlay.
|
||||
- Moving a knob immediately re-runs background removal.
|
||||
- No apply/save step. No server round-trip for mask tuning.
|
||||
|
||||
### Axis rotation
|
||||
- Drag or tap to rotate the axis. Width samples and sqft update live.
|
||||
- Confirm locks the axis for measurement.
|
||||
|
||||
### Generate text
|
||||
- Button only appears if content LLM is enabled and inference is reachable.
|
||||
- Disabled if the text service is down.
|
||||
- Never runs automatically. User taps it.
|
||||
|
||||
### Publish flow
|
||||
- Tap Publish. Status moves to publishing. Poll for result.
|
||||
- Success: show result, offer to open in store.
|
||||
- Failed: show error detail, keep draft and photos, allow retry.
|
||||
- SLAB-UAT-* SKUs always create as Woo draft regardless of settings.
|
||||
|
||||
---
|
||||
|
||||
## Browser compatibility gate
|
||||
|
||||
Shown on load if browser lacks required APIs.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ Browser Update │
|
||||
│ ────────────────────────────────── │
|
||||
│ This app requires a recent browser. │
|
||||
│ Please update Safari or Chrome, │
|
||||
│ then try again. │
|
||||
│ ────────────────────────────────── │
|
||||
│ [ Update Browser ] │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## UAT test cases (derived from wireframes)
|
||||
|
||||
1. **Settings once**: enter WP credentials, test connection, taxonomy syncs.
|
||||
2. **First-run guidance**: card shows and dismisses.
|
||||
3. **Capture**: take photo, mask overlays, adjust knob, overlay refreshes live, confirm.
|
||||
4. **Length axis**: bounding rect draws, rotate axis, confirm, widths update.
|
||||
5. **Type values**: enter SKU/length/thickness, computed values appear, done.
|
||||
6. **Review — vision on, ≥ 0.7**: taxonomy fields pre-filled with AI badge, editable.
|
||||
7. **Review — vision on, < 0.7**: taxonomy fields empty, per-field nudges visible, user fills manually.
|
||||
8. **Review — vision off**: all taxonomy fields empty, per-field nudges visible, user fills manually.
|
||||
9. **Publish — success**: draft created, product ID stored, images purged.
|
||||
10. **Publish — duplicate SKU**: error on SKU field, edit SKU or open existing.
|
||||
11. **Publish — stale taxonomy**: error on field, refresh store lists, pick current option.
|
||||
12. **Publish — SLAB-UAT-***: always creates as draft regardless of settings.
|
||||
13. **Generate text**: button visible when enabled, disabled when service down, LLM prose injected.
|
||||
14. **Retake paths**: mask failure → retake, source too small → retake.
|
||||
+235
@@ -0,0 +1,235 @@
|
||||
# Mill-owner UX — phone and PC browsers
|
||||
|
||||
Status: SIGNED · 2026-09-06 PT · Ty approved with PC browsers in the compatibility matrix · SlabUploader
|
||||
|
||||
This is the presentation spec for the mill-owner flow. It says what a mill owner sees and does, in the
|
||||
order they do it. The rules behind each screen live elsewhere: `AGENTS.md` locks the product
|
||||
decisions, `docs/TECH-SPEC-PIPELINE.md` owns the math and image prep, `docs/DATA-MODEL.md` owns
|
||||
the store row and its statuses, `docs/CONTENT-WOO.md` owns listing copy and the Woo payload,
|
||||
and `docs/OPENAPI.md` owns the wire. This file names what shows up on screen; it does not restate
|
||||
those rules.
|
||||
|
||||
The reader is a mill owner photographing one slab at a time, online, from a supported browser on a
|
||||
phone or a PC. The app is a single column of screens, top to bottom: capture, then review, then
|
||||
publish. There is no dashboard and no batch view in this version. Capture often starts on a phone
|
||||
camera; the same flow must also run on a PC browser.
|
||||
|
||||
## Jobs
|
||||
|
||||
Five jobs cover the whole flow. Each section below names what the owner does, what they see, and
|
||||
the lock that governs it.
|
||||
|
||||
1. **Phone photo to a store listing.** Photograph the slab, confirm the cut and the numbers on
|
||||
the browser, review the listing, create the WooCommerce product. The happy path stays in the
|
||||
browser until the owner confirms the mask and the numbers.
|
||||
2. **Practice drafts customers never see.** A practice run creates a WooCommerce draft with an SKU
|
||||
that starts `SLAB-UAT-`. Customers cannot open it. It is always a draft, even when Settings say
|
||||
publish.
|
||||
3. **Retake when the overlay cut looks wrong.** The mask overlay on the source photo is the only
|
||||
quality check. If the edge is off after tuning the four knobs, the owner retakes the photo.
|
||||
4. **Fill or override store fields.** On review, the owner fills every required field by hand, or
|
||||
keeps what vision filled when it was confident enough. Typed values always win.
|
||||
5. **Publish.** The owner taps publish. The server creates the product in the store and reports the
|
||||
result back on the same screen.
|
||||
|
||||
## Capture: photo to numbers
|
||||
|
||||
The capture flow is one continuous screen with a few confirmations, not separate pages. The owner
|
||||
stays on it from first photo through confirmed mask and axis.
|
||||
|
||||
### Photos
|
||||
|
||||
The owner takes 1 to 5 inventory photos of the slab. The first should be top-down, lens parallel
|
||||
to the broad face; a tilted shot makes square feet and board feet wrong, and retake is the fix.
|
||||
There are no calibration photos in this design. Only inventory photos exist.
|
||||
|
||||
### Mask overlay and four knobs
|
||||
|
||||
The app removes the background sheet and draws the mask as an overlay on the source photo, showing
|
||||
which pixels count as slab and which do not. The owner reviews that edge against the real wood.
|
||||
This is the only quality check in the pipeline. There is no confidence score, no auto-gate, and no
|
||||
photo-quality checker. A clipped or over-inclusive mask shifts every downstream number with nothing
|
||||
later to catch it, so the eye on the overlay is what stands behind the measurement.
|
||||
|
||||
Four knobs sit on this same screen, next to the overlay:
|
||||
|
||||
- **Sheet** — auto, green, or black. Auto samples the borders and picks; the owner overrides when
|
||||
the sheet is an odd color.
|
||||
- **Sensitivity** — one slider from tighter to looser detection.
|
||||
- **Edge offset** — pulls the cut in or out a few pixels so it sits on the wood, not in the sheet
|
||||
or into the grain.
|
||||
- **Feather** — hard edge versus a couple of pixels of blend.
|
||||
|
||||
Changing any knob re-runs background removal immediately and refreshes the overlay. There is no
|
||||
apply step and no server round trip for mask tuning in this version. The four values persist on the
|
||||
browser until reset to default; they are not Settings fields.
|
||||
|
||||
If the edge still looks wrong after tuning, the owner retakes the photo. That is the resolution in
|
||||
this version. A server-side matting path exists in the architecture for awkward backgrounds later,
|
||||
but it is deferred and has no control here.
|
||||
|
||||
### Length axis
|
||||
|
||||
After the mask is confirmed, the app draws the bounding rectangle and the length axis over the slab,
|
||||
with width sample lines perpendicular to that axis. The owner confirms the axis follows the
|
||||
slab's prevailing length. If it is skewed, they rotate it; samples and square feet update live. On a
|
||||
nearly square slab the owner says which way is length.
|
||||
|
||||
### Numbers
|
||||
|
||||
The owner enters length (in 1/8" steps), thickness (a 1/8" pick), and the SKU by hand. The browser
|
||||
then computes square feet, board feet, and width samples from the confirmed mask and axis:
|
||||
|
||||
- Board feet = square feet × thickness in inches. One square foot at one inch thick is one board
|
||||
foot. There is no divide-by-12.
|
||||
- Widths are sampled every 6" along the length axis; the listing carries min, max, and average.
|
||||
- Square feet and board feet show to two decimals.
|
||||
|
||||
These numbers are computed in the browser and stored as sent. The server does not recompute them.
|
||||
|
||||
### Photo prep
|
||||
|
||||
Each inventory photo is cropped to a 3:4 portrait frame with the slab centered at 80% fill, saved as
|
||||
a transparent PNG. If the source has enough pixels, the shorter side lands at 1600 or more after
|
||||
crop. If it cannot reach that, the app warns and asks for a retake; it does not invent pixels. The
|
||||
owner sees the processed result alongside the original while the draft is retained.
|
||||
|
||||
### Continue
|
||||
|
||||
When the mask, axis, length, thickness, and SKU are set, the owner continues. That uploads the
|
||||
originals plus the processed PNGs plus the numbers to the server as a calibrated draft, and moves to
|
||||
review. The slab status becomes `calibrated`.
|
||||
|
||||
## Review: fill or override store fields
|
||||
|
||||
Review is where the listing row gets filled. Every field below maps to a column in the `slabs` table
|
||||
in `docs/DATA-MODEL.md`. The owner can type any of them; vision may have pre-filled some, but typed
|
||||
values always win and every value stays editable.
|
||||
|
||||
### What vision fills, and when
|
||||
|
||||
When inference is on, Call 1 runs after the calibrated draft uploads. It sends all inventory photos
|
||||
downscaled to 1024 plus the store's synced species and attribute lists, and returns a guess
|
||||
with a confidence per field. The threshold is 0.7 by default:
|
||||
|
||||
- A field at or above 0.7 pre-fills on review. It stays editable; the owner can change or clear it.
|
||||
- A field below 0.7 starts empty. The app does not show the model's low-confidence guess as a
|
||||
default, and an empty start is not the same as a finished listing.
|
||||
|
||||
With inference off, every field starts empty and the owner fills all of them by hand. Inference
|
||||
never blocks publish and never auto-publishes. Call 2 (Generate text) does not run on its own; it
|
||||
fires only when the owner taps it.
|
||||
|
||||
### The fields
|
||||
|
||||
The required set for a list-ready slab is the full store row, not species plus one figure:
|
||||
|
||||
- **SKU** — `[A-Z0-9-]{3,24}`. Practice runs use `SLAB-UAT-*`.
|
||||
- **Length and thickness** — from capture; still editable here.
|
||||
- **Square feet, board feet, widths** — shown as the client-computed values, with a manual override
|
||||
path. The owner's typed number wins over the computed one.
|
||||
- **Species** — exactly one leaf category from the synced store list. No hardcoded or invented
|
||||
species; a new store species appears only after the next sync.
|
||||
- **Wood categories** — at least one, from the synced list.
|
||||
- **Edge type** — one term from the synced attributes.
|
||||
- **Figure** — at least one term.
|
||||
- **Grade** — at least one term.
|
||||
- **Thickness band** — the Woo range that the measured thickness rounds up into (for example, a 1"
|
||||
pick lands in the `1"–1 1/2"` band). The exact labels come from the synced store attribute.
|
||||
- **Moisture** — one term, defaulting to kiln-dried. It is never inferred; the owner sets or keeps it.
|
||||
- **fig-* tags** — at least one.
|
||||
- **feat-* tags** — zero or more (character, inclusions, voids, checks map to existing `feat-*`
|
||||
tags only).
|
||||
- **Price** — a recommendation pre-fills from the species $/bdft rule times board feet; the owner may
|
||||
override and that sticks. With no rule for the species, the price starts empty and the owner types
|
||||
it. Publish is still allowed with a manual price.
|
||||
|
||||
Title, short title, and description are not required to reach ready. They can be typed by hand,
|
||||
generated from templates at publish, or written by Generate text (Call 2) when enabled. Templates
|
||||
always run; they inject the deterministic numbers so the model never touches dimensions.
|
||||
|
||||
### How missing fields show up
|
||||
|
||||
A field that is still empty and required shows its nudge inline, in place, the moment it is empty.
|
||||
The owner sees each gap where it sits rather than learning about a problem after scrolling past it.
|
||||
There is no single submit-time check that lists everything at once; the nudges are per-field as the
|
||||
owner works. The slab cannot reach `ready` until the full set above is filled, whether by hand or by
|
||||
a confident Call 1 the owner kept.
|
||||
|
||||
### Generate text
|
||||
|
||||
When content generation is enabled, a **Generate text** button runs Call 2 and fills title, short
|
||||
title, and description with model prose plus template numbers. The owner can edit the result freely.
|
||||
If the endpoint is missing or down, the button is disabled; templates still work and publish is never
|
||||
blocked by a failed Generate text.
|
||||
|
||||
## Publish: create the store listing
|
||||
|
||||
The owner taps **Publish** when review is complete. What happens next depends on the SKU and Settings.
|
||||
|
||||
### Draft versus live
|
||||
|
||||
- A normal SKU publishes according to the `woo_create_status` setting, which defaults to draft.
|
||||
- Any SKU matching `SLAB-UAT-*` always creates a WooCommerce draft, even if Settings say publish.
|
||||
That is how practice runs stay invisible to customers.
|
||||
|
||||
The app's "published" means the store create succeeded and the local row plus photos were cleaned up.
|
||||
That is not the same as the product being live in the store; a draft is created but hidden until Ty
|
||||
promotes it.
|
||||
|
||||
### What the owner sees on each outcome
|
||||
|
||||
- **Success.** The screen shows that the listing was created, with the store's product reference when
|
||||
available. The local draft and its photos are gone from the app.
|
||||
- **SKU already exists.** Under the SKU field: "This SKU already exists in the store (any status)."
|
||||
Two paths appear: edit the SKU to a new value, or open the existing listing. This version does not
|
||||
update the other product in place. The draft stays in the browser; nothing is lost.
|
||||
- **A value no longer valid.** If a category, attribute, or price was removed from the store between
|
||||
sync and publish, that field shows "This value is no longer valid. Pick from the current list," with
|
||||
the current options. The owner re-picks and retries.
|
||||
- **Store unreachable or other failure.** A plain message on the screen says the listing could not be
|
||||
created and to try again. No status number, no stack trace, no raw error code reaches the owner.
|
||||
|
||||
The server keeps a log of every attempt either way, so a failed publish can be retried from the same
|
||||
screen without re-photographing.
|
||||
|
||||
|
||||
## Browser compatibility
|
||||
|
||||
Ty signed this walk on 2026-09-06 PT with one amendment: the compatibility matrix includes PC
|
||||
browsers as well as phones. This app is not mobile-only.
|
||||
|
||||
Supported families (matrix):
|
||||
|
||||
- **Phone:** iOS Safari, Android Chrome
|
||||
- **PC:** desktop Chrome, desktop Edge, desktop Firefox (Windows and macOS at minimum)
|
||||
|
||||
Exact minimum version pins are still tracked under issue #4. The matrix above is the product lock
|
||||
for which classes of client are in scope.
|
||||
|
||||
Unsupported browser behavior: hard stop. Show a clear message that this browser is not supported,
|
||||
with a link to update or switch to a supported browser. No polyfill
|
||||
strategy in this version.
|
||||
|
||||
Load-bearing client surfaces that must remain: canvas-based mask overlay and live knob re-run,
|
||||
image crop/prep to 3:4 transparent PNG, file/photo intake for inventory shots, and the online API
|
||||
calls for draft upload, review, and publish. Depth for APIs stays in `docs/TECH-SPEC-PIPELINE.md`
|
||||
and `docs/OPENAPI.md`.
|
||||
|
||||
## Deferred features
|
||||
|
||||
Photo quality, OCR, ruler detection, server-side measuring, U2Net control, and offline capture are
|
||||
deferred post-POC. The overlay edge plus the owner's eye is the only quality gate. SKU is typed by
|
||||
hand. Length is entered by hand; the browser derives its own pixel-to-inch scale. The browser owns
|
||||
the happy path for measuring and cropping. The U2Net one-photo contract stays in the architecture
|
||||
for later. Capture is online only.
|
||||
|
||||
## Where each job lives
|
||||
|
||||
| Job | Screen(s) | Governing lock |
|
||||
|---|---|---|
|
||||
| Photo to listing | Capture through review to publish | AGENTS §1, TECH-SPEC, ARCHITECTURE swimlane |
|
||||
| Practice drafts (`SLAB-UAT-*`) | Review (SKU field), Publish outcome | AGENTS §5, CONTENT-WOO payload status |
|
||||
| Retake on bad cut | Capture mask overlay | TECH-SPEC §3.4–3.5, issue #15 |
|
||||
| Fill or override fields | Review | DATA-MODEL `slabs`, AGENTS §6/7, issue #9 |
|
||||
| Publish and recovery | Publish outcome states | CONTENT-WOO §2.5–2.6, OPENAPI 409/422, issue #18 |
|
||||
@@ -1,17 +0,0 @@
|
||||
# Archived specs
|
||||
|
||||
The previous live doc pile was removed from this branch so agents stop implementing from it.
|
||||
|
||||
Full prior text is on `main` at commit `13bfa792`. That includes ARCHITECTURE, CONTENT-WOO, DATA-MODEL, DEPLOYMENT, IMPL-PLAN, OPENAPI, PROMPTS, TECH-SPEC-PIPELINE, TEST-SPEC, UAT-PRACTICE, UX, UX-DESIGN.
|
||||
|
||||
`PRD-2026-08.txt` in this folder was already archive before this branch.
|
||||
|
||||
Do not implement from those files.
|
||||
|
||||
Live spec on this branch:
|
||||
|
||||
- `README.md`
|
||||
- `AGENTS.md`
|
||||
- `docs/PRODUCT.md`
|
||||
- `docs/CAPTURE.md`
|
||||
- `deploy/` for hosting only
|
||||
@@ -1,713 +0,0 @@
|
||||
<!doctype html>
|
||||
<!--
|
||||
Design artifact for SlabUploader issue #99 — "Tape-line overlay replaces axis
|
||||
rectangle (calibration length + crop)".
|
||||
Owner-approved design proposal. This is a WIREFRAME, not shippable code.
|
||||
|
||||
What it settles: the black ~2pt line is a FIXED entity in the MIDDLE of the
|
||||
3:4 rectangle; the SLAB alone pans / zooms / tilts so its long axis lands on
|
||||
the line; the ticks belong to the line, slide along it, and L = tick to tick;
|
||||
the crop % moves only the overlay's two ends — (1 - fill)/2 of the long axis
|
||||
in from the top and the bottom — and never changes the aspect ratio.
|
||||
|
||||
SCRUB BEFORE SHIPPING — wireframe scaffolding, none of this belongs in the
|
||||
production workflow:
|
||||
- the viewport switcher (412 / 390 / 720 / 1440) and the flowbar step nav
|
||||
- the requirements-coverage board below the stage
|
||||
- the "was / now" pair of mini diagrams
|
||||
- the PROPOSAL / OPEN markers and the implementation-notes card
|
||||
- in-stage tutorial labels ("crop 90% — overlay 5.0% in from the top", …)
|
||||
- the alignment read-out box (a proposal, not in the issue; nothing gated on it)
|
||||
- the synthetic slab drawing + grain, and the sample values (88.0" tape, 2.125 thickness)
|
||||
- the window.__wf debug hook
|
||||
What stays: the fixed middle line + its two end ticks, the slab transform set
|
||||
(pan / zoom / tilt) with the visible zoom row and Fit, the tick drag + nudge
|
||||
semantics, and the single shared crop_fill.
|
||||
-->
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>SlabUploader — tape-line overlay wireframe (issue #99)</title>
|
||||
<style>
|
||||
:root{
|
||||
--bg:#121212; --card:#1c1c1c; --card2:#1d1d1d; --line:#333;
|
||||
--fg:#eee; --mut:#aaa; --acc:#00e5ff; --acc2:#002;
|
||||
--ok:#7fd37f; --warn:#ffb4a2; --r:3px;
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
html,body{margin:0;background:var(--bg);color:var(--fg);font-family:system-ui,-apple-system,"Segoe UI",sans-serif;font-size:16px}
|
||||
.top{max-width:900px;margin:0 auto;padding:14px 12px 4px}
|
||||
.top h1{font-size:1rem;margin:0 0 8px;color:var(--acc);letter-spacing:.04em;text-transform:uppercase}
|
||||
.flowbar{display:flex;flex-wrap:wrap;gap:6px}
|
||||
.fstep{font-size:.72rem;padding:5px 8px;border-radius:var(--r);border:1px solid var(--line);background:var(--card2);color:var(--mut);cursor:pointer}
|
||||
.fstep.on{background:var(--acc);color:var(--acc2);font-weight:700;border-color:var(--acc)}
|
||||
.vp{display:flex;align-items:center;gap:8px;margin-top:10px;font-size:.72rem;color:var(--mut)}
|
||||
.vp button{border:1px solid var(--line);background:var(--card2);color:var(--fg);font-size:.72rem;padding:4px 8px;border-radius:var(--r);cursor:pointer}
|
||||
.vp button.on{background:var(--acc);color:var(--acc2);font-weight:700}
|
||||
.stage{display:flex;justify-content:center;padding:18px 10px 40px}
|
||||
#phone{width:412px;max-width:100%;background:#0c0c0c;border:1px solid #2a2a2a;border-radius:18px;padding:14px;min-height:640px}
|
||||
.panel{padding:10px 4px;color:var(--fg)}
|
||||
h1{font-size:1.3rem;margin:0 0 10px}
|
||||
h3{font-size:.95rem;margin:0 0 8px}
|
||||
.hint,.sub{color:var(--mut);font-size:.82rem;line-height:1.5;margin:0 0 12px}
|
||||
.row{display:flex;gap:10px;margin-top:16px}
|
||||
.primary{flex:1;padding:9px;font-size:.95rem;border-radius:var(--r);border:0;background:var(--acc);color:var(--acc2);font-weight:700;cursor:pointer;align-self:flex-start}
|
||||
.ghost{padding:9px 18px;border-radius:var(--r);border:1px solid #444;background:transparent;color:#ccc;cursor:pointer}
|
||||
.nudge{color:#e8a;font-size:.74rem;margin:6px 0 0}
|
||||
/* ---- the stage: fixed line + movable slab ---- */
|
||||
.tw{position:relative;width:100%;aspect-ratio:3/4;background:#111;border:1px solid var(--line);border-radius:var(--r);overflow:hidden}
|
||||
.tw svg{display:block;width:100%;height:100%;touch-action:none;cursor:grab;user-select:none}
|
||||
.tw.dragging svg{cursor:grabbing}
|
||||
.tickg{cursor:ns-resize}
|
||||
.rotg{cursor:ew-resize}
|
||||
.controls{margin-top:16px;display:flex;flex-direction:column;gap:14px}
|
||||
.angle,.croprow{display:flex;align-items:center;gap:10px}
|
||||
.thumbbtn{width:44px;height:44px;border-radius:50%;font-size:1.4rem;border:1px solid #444;background:var(--card2);color:var(--fg);flex:none;cursor:pointer}
|
||||
input[type=range]{flex:1}
|
||||
.val{min-width:4ch;text-align:right;font-variant-numeric:tabular-nums}
|
||||
.ctl-label{font-size:.78rem;color:var(--mut);margin:0 0 6px}
|
||||
.nums{display:flex;flex-direction:column;gap:8px;margin-top:8px}
|
||||
.nums div{display:flex;justify-content:space-between;border-bottom:1px solid #2a2a2a;padding-bottom:6px;font-size:.84rem;gap:10px}
|
||||
.nums span:first-child{color:var(--mut)}
|
||||
.nums .tab{font-variant-numeric:tabular-nums;text-align:right}
|
||||
.nums .dim{color:#777}
|
||||
.status{margin-top:12px;padding:10px 12px;border-radius:var(--r);font-size:.88rem;font-weight:600;background:#2a1512;color:var(--warn)}
|
||||
.status.ok{background:#12210f;color:var(--ok)}
|
||||
.plan{margin-top:18px;padding:10px 12px;border:1px solid var(--line);border-radius:var(--r);background:var(--card)}
|
||||
.plan h3{font-size:.76rem;text-transform:uppercase;letter-spacing:.04em;color:var(--acc);margin:0 0 8px}
|
||||
.plan ul{margin:0;padding-left:16px;color:var(--mut);font-size:.76rem;line-height:1.65}
|
||||
.plan code{color:#ddd;font-size:.72rem}
|
||||
.proposal{display:inline-block;font-size:.6rem;font-weight:800;letter-spacing:.03em;background:#3a2a00;color:#ffc96b;border-radius:2px;padding:0 4px;margin-left:5px;vertical-align:1px}
|
||||
/* replacement two-up */
|
||||
.swap{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:14px}
|
||||
.swap figure{margin:0;background:var(--card2);border:1px solid var(--line);border-radius:var(--r);padding:6px;text-align:center}
|
||||
.swap svg{width:100%;display:block;background:#0f0f0f;border-radius:2px}
|
||||
.swap figcaption{font-size:.68rem;color:var(--mut);margin-top:6px;line-height:1.4}
|
||||
.swap .gone{color:#ff9d8a}
|
||||
/* scope lineup */
|
||||
.lineup{list-style:none;padding:0;margin:12px 0 0;display:grid;grid-template-columns:repeat(3,1fr);gap:8px}
|
||||
.lineup-item{text-align:center;background:var(--card2);border:1px solid var(--line);border-radius:var(--r);padding:6px}
|
||||
.lineup-item svg{width:100%;display:block;border-radius:2px;background:#0f0f0f}
|
||||
.role{display:block;margin-top:6px;font-size:.72rem;color:var(--fg);font-weight:600}
|
||||
.rot-badge{display:inline-block;margin-top:3px;font-size:.6rem;padding:0 4px;border-radius:2px;font-weight:700}
|
||||
.rot-badge.yes{background:var(--acc);color:var(--acc2)}
|
||||
.rot-badge.no{background:#3a3a3a;color:#bbb}
|
||||
/* fields + settings */
|
||||
.fields{display:flex;gap:16px;margin-top:16px}
|
||||
.field{flex:1}
|
||||
.label{display:block;font-size:.85rem;color:var(--mut);margin-bottom:8px}
|
||||
.inp{box-sizing:border-box;width:100%;min-height:44px;padding:10px;font-size:.95rem;border-radius:var(--r);border:1px solid #444;background:var(--card2);color:var(--fg);font-variant-numeric:tabular-nums}
|
||||
.settings label{display:block;margin:10px 0 4px;font-size:.85rem;color:var(--mut)}
|
||||
.settings .section{margin-top:20px;padding-top:14px;border-top:1px solid var(--line)}
|
||||
.settings button{padding:8px 12px;font-size:.85rem;border-radius:var(--r);border:1px solid #444;background:var(--card2);color:#ddd;cursor:pointer;margin-top:10px}
|
||||
.settings .msg{font-size:.8rem;margin:8px 0 0;color:var(--mut)}
|
||||
.settings .msg.ok{color:var(--ok)}
|
||||
/* requirement coverage board (outside the phone) */
|
||||
.cov{max-width:900px;margin:0 auto 40px;padding:0 12px}
|
||||
.cov-inner{border:1px solid var(--line);border-radius:var(--r);background:var(--card);padding:12px 14px}
|
||||
.cov h2{font-size:.86rem;text-transform:uppercase;letter-spacing:.04em;color:var(--acc);margin:0 0 10px}
|
||||
.cov table{width:100%;border-collapse:collapse;font-size:.76rem}
|
||||
.cov th,.cov td{text-align:left;padding:6px 8px;border-bottom:1px solid #2a2a2a;vertical-align:top;color:var(--mut)}
|
||||
.cov th{color:var(--fg);font-size:.72rem;text-transform:uppercase;letter-spacing:.03em}
|
||||
.cov td:first-child{color:var(--fg);white-space:nowrap}
|
||||
.cov .where{color:#ddd}
|
||||
/* narrow screens: stack the table instead of forcing its 400px min width */
|
||||
@media (max-width:760px){
|
||||
.cov thead{display:none}
|
||||
.cov table,.cov tbody,.cov tr,.cov td{display:block;width:auto}
|
||||
.cov tr{border-bottom:1px solid #2a2a2a;padding:8px 0}
|
||||
.cov tr:last-child{border-bottom:0}
|
||||
.cov td{border:0;padding:1px 0}
|
||||
.cov td:first-child{white-space:normal;color:var(--acc);font-weight:700}
|
||||
.cov td:nth-child(3)::before{content:"→ ";color:#666}
|
||||
}
|
||||
.hidden{display:none}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="top">
|
||||
<h1>SlabUploader · tape-line overlay — issue #99</h1>
|
||||
<div class="flowbar" id="flowbar"></div>
|
||||
<div class="vp">
|
||||
<span>Viewport</span>
|
||||
<span id="vpval">412</span>
|
||||
<button data-w="390">390</button>
|
||||
<button data-w="412" class="on">412</button>
|
||||
<button data-w="720">720</button>
|
||||
<button data-w="1440">1440</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stage"><div id="phone"></div></div>
|
||||
<div class="cov"><div class="cov-inner">
|
||||
<h2>Issue #99 — requirement coverage in this wireframe</h2>
|
||||
<table>
|
||||
<thead><tr><th>Req</th><th>What it says</th><th>Where it shows up</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>1 Overlay</td><td>Black ~2pt line + two rifle-scope end ticks; ticks draggable; replaces the cyan bbox + red arrow </td><td class="where">Tape line step: the black line, its two end ticks, the "was / now" pair below the stage, and the hint copy</td></tr>
|
||||
<tr><td>2 Controls</td><td>Pan / zoom / tilt the photo; ticks ≥44×44px, draggable, arrow + Shift+arrow fine nudge; one pointer path</td><td class="where">Zoom now has a visible row (− / slider / + / Fit) beside the tilt row, plus wheel, pinch, <b>+</b>/<b>−</b> keys and double-click to fit; pan = drag, tilt = handle + arrows</td></tr>
|
||||
<tr><td>3 Fixed line</td><td>The reference line is fixed in the MIDDLE of the rectangle, and it IS the crop line: its ends are the crop insets</td><td class="where">The line's x is computed from the frame, never from the slab's transform; the crop % moves only its two ends, (1 − fill)/2 of the long axis in from the top and the bottom</td></tr>
|
||||
<tr><td>4 Crop %</td><td>Global <code>settings.crop_fill</code> (default 90) replaces both <code>FILL = 0.9</code> copies, read live, non-retro. <b>It does not change the aspect ratio</b></td><td class="where">Crop fill control on the tape step and the Settings step: 90% puts the overlay 5% in from the top and the bottom; the 3:4 rectangle itself never changes</td></tr>
|
||||
<tr><td>5 Crop content</td><td>Post-BG-removal, slab pixels only, transparent background, bounds = slab extent</td><td class="where">Mask step note + the crop frame outline around the stage</td></tr>
|
||||
<tr><td>6 Transform scope</td><td>Tilt persists only to the calibration pic and its dimension-overlay clone; siblings keep their own orientation</td><td class="where">Photos-ready step lineup: calibration + dimensioned copy tilted, backside sibling untouched</td></tr>
|
||||
<tr><td>7 Doc fix</td><td><code>capture/types.ts:46</code> says "80%-fill"; live value is 90%</td><td class="where">Noted in the Implementation notes card on the Settings step</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div></div>
|
||||
<script>
|
||||
// ===========================================================================
|
||||
// issue #99 — the tape line is a FIXED ENTITY.
|
||||
//
|
||||
// * The black ~2pt line and its two end ticks never move: not on tilt, not
|
||||
// on pan, not on zoom. It is fixed in the MIDDLE of the rectangle.
|
||||
// * The SLAB is the only thing the operator moves (pan / zoom / tilt) until
|
||||
// its long axis lies on the line, tick to tick.
|
||||
// * The ticks belong to the line - they slide along it, never with the photo.
|
||||
// * L = the distance between the two ticks (the owner's tape reading).
|
||||
// ===========================================================================
|
||||
const S = {
|
||||
screen: "tape",
|
||||
// slab transform (the only movable entity)
|
||||
tilt: 14, // degrees
|
||||
zoom: 1,
|
||||
panX: 60, panY: 0,
|
||||
// the line's ticks: y positions along the fixed line. They rest at the crop
|
||||
// insets - (1 - fill)/2 of the rectangle's LONG axis in from the top and the
|
||||
// bottom - and the operator drags them onto their tape marks from there.
|
||||
tickA: 32,
|
||||
tickB: 608,
|
||||
focus: "A",
|
||||
fill: 90, // settings.crop_fill - global, default 90
|
||||
// declared values (entered on Measure, shown on both steps)
|
||||
L: "88",
|
||||
thickness: "2.125",
|
||||
savedMsg: "",
|
||||
};
|
||||
|
||||
// The stage IS the crop frame: 3:4, the same aspect composeInventoryFrame emits.
|
||||
const VB = { w: 480, h: 640 };
|
||||
// Slab drawn in stage coordinates. What has to be aligned is the slab's LONG
|
||||
// AXIS - the centreline running down the middle of the board, which is where
|
||||
// the tape is laid. (Not a physical side edge.)
|
||||
const SLAB = {
|
||||
path: "M 166 40 L 161 150 L 168 300 L 162 455 L 166 600 L 330 608 L 396 596 L 400 320 L 396 96 L 330 34 Z",
|
||||
axis: [{ x: 282, y: 40 }, { x: 282, y: 600 }],
|
||||
};
|
||||
const TICK_LEN = 26; // rifle-scope end tick, total
|
||||
const TICK_GAP = 40; // min separation between ticks
|
||||
// Grab radius in stage units. The stage renders ~352-354px wide for a 480-unit
|
||||
// viewBox, so 68 units is ~50px on screen - comfortably over the required 44x44.
|
||||
const HIT_R = 34;
|
||||
const ALIGN_TOL = 3; // stage units
|
||||
|
||||
const rad = (a) => a * Math.PI / 180;
|
||||
// The fixed line sits in the MIDDLE of the rectangle: the centre of the 3:4
|
||||
// crop frame, which is where the crop centres the slab's long axis.
|
||||
const lineX = () => VB.w / 2;
|
||||
// Crop % = (1 - fill)/2 of the rectangle's LONG axis in from the top and the
|
||||
// bottom (cropMarginFraction() in png/crop.ts). It never touches the aspect
|
||||
// ratio: the rectangle stays 3:4 and only the overlay's two ends move.
|
||||
const cropInset = () => VB.h * (1 - S.fill / 100) / 2;
|
||||
const cropTicks = () => ({ a: cropInset(), b: VB.h - cropInset() });
|
||||
const spanPx = () => Math.abs(S.tickB - S.tickA);
|
||||
const pxPerIn = () => spanPx() / (parseFloat(S.L) || 1);
|
||||
|
||||
// slab transform: zoom + tilt about the stage centre, then pan
|
||||
function mapPoint(p) {
|
||||
const cx = VB.w / 2, cy = VB.h / 2, a = rad(S.tilt), z = S.zoom;
|
||||
const x = (p.x - cx) * z, y = (p.y - cy) * z;
|
||||
return { x: cx + x * Math.cos(a) - y * Math.sin(a) + S.panX,
|
||||
y: cy + x * Math.sin(a) + y * Math.cos(a) + S.panY };
|
||||
}
|
||||
function slabTransform() {
|
||||
return `translate(${S.panX} ${S.panY}) translate(${VB.w / 2} ${VB.h / 2}) rotate(${S.tilt}) scale(${S.zoom}) translate(${-VB.w / 2} ${-VB.h / 2})`;
|
||||
}
|
||||
// where the slab's long axis crosses a given stage y
|
||||
function axisXAt(y) {
|
||||
const a = mapPoint(SLAB.axis[0]), b = mapPoint(SLAB.axis[1]);
|
||||
if (Math.abs(b.y - a.y) < 1e-6) return a.x;
|
||||
return a.x + ((y - a.y) / (b.y - a.y)) * (b.x - a.x);
|
||||
}
|
||||
const axisAngle = () => {
|
||||
const a = mapPoint(SLAB.axis[0]), b = mapPoint(SLAB.axis[1]);
|
||||
return Math.atan2(b.y - a.y, b.x - a.x) * 180 / Math.PI;
|
||||
};
|
||||
// the axis is vertical when its angle points straight down (or up) the screen
|
||||
const axisVertical = () => { const d = ((axisAngle() % 180) + 180) % 180; return Math.abs(d - 90) < 1.5; };
|
||||
const axisOffset = () => axisXAt((S.tickA + S.tickB) / 2) - lineX();
|
||||
const aligned = () => axisVertical() && Math.abs(axisOffset()) <= ALIGN_TOL;
|
||||
|
||||
// ---------------------------------------------------------------- the stage
|
||||
function slabSvg() {
|
||||
return `
|
||||
<g id="slab" transform="${slabTransform()}">
|
||||
<clipPath id="slabClip"><path d="${SLAB.path}"/></clipPath>
|
||||
<path d="${SLAB.path}" fill="hsl(28,36%,31%)" stroke="#000" stroke-opacity=".25"/>
|
||||
<g clip-path="url(#slabClip)" opacity=".8">
|
||||
<path d="M 176 90 Q 290 74 400 96" stroke="hsl(28,34%,36%)" stroke-width="4" fill="none"/>
|
||||
<path d="M 174 200 Q 290 190 400 210" stroke="hsl(28,34%,35%)" stroke-width="4" fill="none"/>
|
||||
<path d="M 172 320 Q 290 306 400 326" stroke="hsl(28,34%,37%)" stroke-width="4" fill="none"/>
|
||||
<path d="M 172 440 Q 290 432 400 452" stroke="hsl(28,34%,34%)" stroke-width="4" fill="none"/>
|
||||
<path d="M 174 550 Q 290 540 400 560" stroke="hsl(28,34%,36%)" stroke-width="4" fill="none"/>
|
||||
</g>
|
||||
</g>`;
|
||||
}
|
||||
// the FIXED entity: black 2pt line + two rifle-scope end ticks. No transform.
|
||||
function fixtureSvg() {
|
||||
const x = lineX();
|
||||
const tick = (y, id) => {
|
||||
const on = S.focus === id;
|
||||
return `
|
||||
<line x1="${x - TICK_LEN / 2}" y1="${y}" x2="${x + TICK_LEN / 2}" y2="${y}" stroke="rgba(255,255,255,.92)" stroke-width="6" stroke-linecap="round"/>
|
||||
<line x1="${x - TICK_LEN / 2}" y1="${y}" x2="${x + TICK_LEN / 2}" y2="${y}" stroke="#000" stroke-width="2.4" stroke-linecap="round"/>
|
||||
<circle cx="${x}" cy="${y}" r="5" fill="${on ? "#ff3d71" : "#000"}" stroke="#fff" stroke-width="1.5"/>
|
||||
<circle class="tickg" data-tick="${id}" cx="${x}" cy="${y}" r="${HIT_R}" fill="transparent"/>`;
|
||||
};
|
||||
return `
|
||||
<line x1="${x}" y1="${S.tickA}" x2="${x}" y2="${S.tickB}" stroke="rgba(255,255,255,.28)" stroke-width="6" stroke-linecap="round"/>
|
||||
<line id="tapeLine" x1="${x}" y1="${S.tickA}" x2="${x}" y2="${S.tickB}" stroke="#000" stroke-width="2.4" stroke-linecap="round"/>
|
||||
${tick(S.tickA, "A")}${tick(S.tickB, "B")}`;
|
||||
}
|
||||
// The crop frame: the rectangle's ASPECT NEVER CHANGES (3:4, from crop.ts).
|
||||
// The crop % only moves the overlay's ends: (1 - fill)/2 of the long axis in
|
||||
// from the top and the bottom, shaded here as the two bands it leaves outside.
|
||||
function cropFrameSvg() {
|
||||
const m = cropInset();
|
||||
return `
|
||||
<rect x="0" y="0" width="${VB.w}" height="${m}" fill="#000" opacity=".30"/>
|
||||
<rect x="0" y="${VB.h - m}" width="${VB.w}" height="${m}" fill="#000" opacity=".30"/>
|
||||
<rect id="cropframe" x=".5" y=".5" width="${VB.w - 1}" height="${VB.h - 1}" fill="none" stroke="#555" stroke-width="1" stroke-dasharray="6 5"/>
|
||||
<text x="6" y="${m / 2 + 4}" fill="#8a8a8a" font-family="system-ui" font-size="11">crop ${S.fill}% — overlay ${((1 - S.fill / 100) / 2 * 100).toFixed(1)}% in from the top</text>
|
||||
<text x="6" y="${VB.h - m / 2 + 4}" fill="#8a8a8a" font-family="system-ui" font-size="11">${((1 - S.fill / 100) / 2 * 100).toFixed(1)}% in from the bottom</text>`;
|
||||
}
|
||||
function rotateHandleSvg() {
|
||||
return `
|
||||
<g class="rotg" data-rot="1">
|
||||
<circle cx="${VB.w / 2}" cy="${VB.h - 26}" r="17" fill="rgba(0,0,0,.6)" stroke="var(--acc)" stroke-width="1.5"/>
|
||||
<text x="${VB.w / 2}" y="${VB.h - 21}" fill="var(--acc)" font-size="15" font-family="system-ui" text-anchor="middle">↻</text>
|
||||
<text x="${VB.w / 2 + 24}" y="${VB.h - 22}" fill="#aaa" font-size="10.5" font-family="system-ui">drag to tilt the slab</text>
|
||||
</g>`;
|
||||
}
|
||||
function stageSvg() {
|
||||
return `<svg viewBox="0 0 ${VB.w} ${VB.h}" id="tsvg" aria-label="fixed tape line over a movable slab">
|
||||
<rect width="${VB.w}" height="${VB.h}" fill="#16281c"/>
|
||||
${cropFrameSvg()}
|
||||
${slabSvg()}
|
||||
${fixtureSvg()}
|
||||
${rotateHandleSvg()}
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ screens
|
||||
const screens = {
|
||||
batch() {
|
||||
return `<section class="panel">
|
||||
<h1>Batch photos</h1>
|
||||
<p class="hint">Unchanged by #99 — shown for context. Sheet is green or black and fills the frame around the slab.</p>
|
||||
<div class="row"><button class="primary" data-act="to-mask">Pick calibration photo</button></div>
|
||||
</section>`;
|
||||
},
|
||||
mask() {
|
||||
return `<section class="panel">
|
||||
<h1>Mask</h1>
|
||||
<p class="hint">Unchanged by #99. The approved mask is still what the crop bounds come from.</p>
|
||||
<div class="nums">
|
||||
<div><span>Crop bounds</span><span class="tab">slab extent (kept pixels only)</span></div>
|
||||
<div><span>Crop contents</span><span class="tab">slab pixels only, transparent background</span></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<button class="ghost" data-act="to-batch">Back</button>
|
||||
<button class="primary" style="flex:2" data-act="to-tape">Approve mask</button>
|
||||
</div>
|
||||
</section>`;
|
||||
},
|
||||
tape() {
|
||||
const off = axisOffset(), ok = aligned();
|
||||
return `<section class="panel">
|
||||
<h1>Tape line</h1>
|
||||
<p class="hint">Drag each tick to where your tape started and ended.</p>
|
||||
<div class="tw" id="tw">${stageSvg()}</div>
|
||||
<p class="nudge">The black line is <b>fixed</b>: tilting, dragging and zooming the slab never move it. It stays in the middle of the rectangle.</p>
|
||||
<p class="nudge">Move the <b>slab</b> instead: drag it to pan, wheel or pinch to zoom, tilt it with the ↻ handle or the arrow keys — until the board's long axis (the line down the middle of the slab) lies on the fixed line, tick to tick.</p>
|
||||
|
||||
<div class="controls">
|
||||
<div>
|
||||
<p class="ctl-label">Tilt the slab</p>
|
||||
<div class="angle">
|
||||
<button class="thumbbtn" data-act="tilt" data-d="-1" aria-label="tilt left">−</button>
|
||||
<input type="range" min="0" max="359" value="${S.tilt}" data-act="tilt-range" aria-label="slab tilt"/>
|
||||
<button class="thumbbtn" data-act="tilt" data-d="1" aria-label="tilt right">+</button>
|
||||
<span class="val" id="tiltVal">${S.tilt}°</span>
|
||||
</div>
|
||||
<p class="nudge">← → tilt 1° · Shift + ← → tilt 0.1° · or drag the ↻ handle</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="ctl-label">Zoom the slab <span class="sub" style="font-size:.7rem">wheel or pinch over the photo</span></p>
|
||||
<div class="angle">
|
||||
<button class="thumbbtn" data-act="zoom" data-d="-1" aria-label="zoom out">−</button>
|
||||
<input type="range" min="50" max="300" value="${Math.round(S.zoom * 100)}" data-act="zoom-range" aria-label="slab zoom"/>
|
||||
<button class="thumbbtn" data-act="zoom" data-d="1" aria-label="zoom in">+</button>
|
||||
<span class="val" id="zoomVal">${Math.round(S.zoom * 100)}%</span>
|
||||
</div>
|
||||
<p class="nudge">Wheel or pinch over the photo zooms to the cursor · <b>+</b> / <b>−</b> keys step 10% · double-click the photo to fit</p>
|
||||
<div class="row" style="margin-top:8px"><button class="ghost" data-act="fit">Fit — 100%, centred</button></div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="ctl-label">Tape end ticks <span class="sub" style="font-size:.7rem">on the line; drag along it</span></p>
|
||||
<p class="nudge">Grab a tick anywhere in its 44×44px target and drag it along the line. The tick you last grabbed takes the arrow keys: <b>↑ ↓</b> nudge 1px, Shift = 0.1px — its centre dot turns red to show which one.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="ctl-label">Crop fill % <span class="sub" style="font-size:.7rem">global setting</span></p>
|
||||
<div class="croprow">
|
||||
<input type="range" min="1" max="100" value="${S.fill}" data-act="fill-range" aria-label="crop fill percent"/>
|
||||
<span class="val" id="fillVal">${S.fill}%</span>
|
||||
</div>
|
||||
<p class="nudge">settings.crop_fill (default 90). It sets how much of the rectangle the overlay covers: at ${S.fill}% the overlay's ends sit ${((1 - S.fill / 100) / 2 * 100).toFixed(1)}% in from the top and the bottom. <b>The rectangle keeps its 3:4 aspect</b> — only the ends move, never the middle. Existing stored PNGs are never re-cropped.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="status ${ok ? "ok" : ""}" id="alignstatus">${ok
|
||||
? "The board's long axis is on the line ✓"
|
||||
: `The board's long axis is ${off > 0 ? "right" : "left"} of the line by ${Math.abs(off).toFixed(0)} px${axisVertical() ? "" : `, and tilted ${S.tilt}°`}.`}</div>
|
||||
|
||||
<div class="nums">
|
||||
<div><span>Fixed line x <span class="dim">(middle of the 3:4 frame)</span></span><span class="tab" id="lineReadout">${lineX().toFixed(1)} of ${VB.w}</span></div>
|
||||
<div><span>Overlay insets <span class="dim">(from crop fill)</span></span><span class="tab" id="insetReadout">${((1 - S.fill / 100) / 2 * 100).toFixed(1)}% in from top and bottom</span></div>
|
||||
<div><span>Tape span between ticks</span><span class="tab" id="spanReadout">${spanPx().toFixed(0)} px</span></div>
|
||||
<div><span>Your tape length <span class="dim">(entered on Measure)</span></span><span class="tab">${(parseFloat(S.L) || 0).toFixed(1)}″</span></div>
|
||||
<div><span>Scale from your ticks</span><span class="tab" id="scaleReadout">${pxPerIn().toFixed(2)} px/in</span></div>
|
||||
<div><span>Mask span projection</span><span class="tab dim">not used — sanity check only</span></div>
|
||||
</div>
|
||||
|
||||
<p class="nudge" style="margin-top:10px">Slab tilt ${S.tilt}° · zoom ${S.zoom.toFixed(2)}× <span class="dim">(moving these never changes the line)</span></p>
|
||||
|
||||
<div class="swap">
|
||||
<figure>${oldOverlayMini()}<figcaption class="gone">✗ removed — cyan axis rectangle + red arrow <span class="dim">(PhotoStage.svelte:56-82)</span></figcaption></figure>
|
||||
<figure>${newOverlayMini()}<figcaption>this step — fixed black 2pt line + two end ticks</figcaption></figure>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<button class="ghost" data-act="to-mask">Back</button>
|
||||
<button class="primary" style="flex:2" data-act="to-measure">Approve tape line</button>
|
||||
</div>
|
||||
|
||||
<div class="plan">
|
||||
<h3>Two things on this screen are my proposal, not the issue</h3>
|
||||
<ul>
|
||||
<li>The alignment read-out above <span class="proposal">PROPOSAL</span> — the issue says "rotate until the taped line sits on the fixed reference line" and doesn't specify how the operator knows. Tell me to drop it and it's gone; nothing is gated on it.</li>
|
||||
<li>The shaded crop bands <span class="proposal">PROPOSAL</span> — drawn only to make the crop fill visible: the two strips the overlay leaves outside itself at the top and the bottom.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>`;
|
||||
},
|
||||
measure() {
|
||||
const mini = (tilt, marks) => `
|
||||
<svg viewBox="0 0 480 640">
|
||||
<rect width="480" height="640" fill="#16281c"/>
|
||||
<g transform="translate(240 320) rotate(${tilt}) translate(-240 -320)">
|
||||
<path d="M 168 60 L 300 52 L 392 80 L 400 300 L 388 560 L 300 596 L 168 588 Z" fill="hsl(28,36%,31%)"/>
|
||||
${marks ? `<line x1="168" y1="100" x2="168" y2="548" stroke="#000" stroke-width="2.4"/>
|
||||
<line x1="155" y1="100" x2="181" y2="100" stroke="#000" stroke-width="2.4"/>
|
||||
<line x1="155" y1="548" x2="181" y2="548" stroke="#000" stroke-width="2.4"/>` : ""}
|
||||
</g>
|
||||
</svg>`;
|
||||
return `<section class="panel">
|
||||
<h1>Photos ready</h1>
|
||||
<div class="fields">
|
||||
<div class="field"><span class="label">Length L (tape reading)</span><input class="inp" type="number" step="0.125" data-act="length" value="${S.L}"/></div>
|
||||
<div class="field"><span class="label">Thickness</span><input class="inp" type="number" step="0.125" data-act="thickness" value="${S.thickness}"/></div>
|
||||
</div>
|
||||
<div class="nums">
|
||||
<div><span>L maps to</span><span class="tab">tick-to-tick span (${spanPx().toFixed(0)} px)</span></div>
|
||||
<div><span>Scale used</span><span class="tab">${pxPerIn().toFixed(2)} px/in</span></div>
|
||||
<div><span>Mask span projection</span><span class="tab dim">not used</span></div>
|
||||
</div>
|
||||
<div class="plan">
|
||||
<h3>Rotation scope — req. 6</h3>
|
||||
<ul><li>The aligning tilt reaches the calibration pic and its dimension-overlay clone only.</li>
|
||||
<li>A backside sibling keeps its own orientation; rotation is never propagated through the shared mask/crop settings.</li></ul>
|
||||
</div>
|
||||
<ol class="lineup">
|
||||
<li class="lineup-item">${mini(S.tilt, true)}<span class="role">Clean calibration</span><span class="rot-badge yes">tilted ${S.tilt}°</span></li>
|
||||
<li class="lineup-item">${mini(S.tilt, false)}<span class="role">Dimension overlay</span><span class="rot-badge yes">same ${S.tilt}°</span></li>
|
||||
<li class="lineup-item">${mini(0, false)}<span class="role">Backside sibling</span><span class="rot-badge no">not re-tilted</span></li>
|
||||
</ol>
|
||||
<div class="row">
|
||||
<button class="ghost" data-act="to-tape">Back</button>
|
||||
<button class="primary" style="flex:2" data-act="to-settings">Continue</button>
|
||||
</div>
|
||||
</section>`;
|
||||
},
|
||||
settings() {
|
||||
return `<section class="panel settings">
|
||||
<h1>Settings</h1>
|
||||
<div class="section">
|
||||
<h3>Crop — global</h3>
|
||||
<label for="crop-fill">Crop Fill % (default 90) — how much of the rectangle the overlay covers</label>
|
||||
<div class="croprow">
|
||||
<input type="range" id="crop-fill" min="1" max="100" data-act="fill-range" value="${S.fill}"/>
|
||||
<span class="val" id="fillVal">${S.fill}%</span>
|
||||
</div>
|
||||
<p class="sub" style="margin-top:8px">One shared value replaces the two duplicated <code>FILL = 0.9</code> constants. Both the overlay's ends and the PNG crop read it live, so they can never disagree. At 90% the overlay's ends sit ${((1 - S.fill / 100) / 2 * 100).toFixed(1)}% in from the top and the bottom; <b>the 3:4 aspect ratio never changes</b>. Applies to slabs processed from now on; stored PNGs are not re-cropped.</p>
|
||||
<button data-act="save-fill">Save Changes</button>
|
||||
<p class="msg ${S.savedMsg ? "ok" : ""}">${S.savedMsg}</p>
|
||||
</div>
|
||||
<div class="section">
|
||||
<h3>Implementation notes</h3>
|
||||
<ul class="plan" style="border:0;padding:0;background:none">
|
||||
<li><code>capture/types.ts:46</code> says "80%-fill PNG crop" — stale; the live value is 90%. Comment gets fixed (req. 7).</li>
|
||||
<li>Replaces the cyan bbox + red arrow in <code>PhotoStage.svelte:56-82</code>.</li>
|
||||
<li><code>maskSpanAlong</code> is demoted to a sanity check.</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="row"><button class="ghost" data-act="to-tape">Back to tape line</button></div>
|
||||
</section>`;
|
||||
},
|
||||
};
|
||||
|
||||
// the two mini overlays for the was/now pair
|
||||
function oldOverlayMini() {
|
||||
return `<svg viewBox="0 0 120 160">
|
||||
<rect width="120" height="160" fill="#141414"/>
|
||||
<rect x="26" y="18" width="68" height="124" fill="hsl(28,30%,30%)"/>
|
||||
<rect x="20" y="12" width="80" height="136" fill="none" stroke="#00e5ff" stroke-width="3" stroke-dasharray="8 5"/>
|
||||
<line x1="60" y1="150" x2="60" y2="26" stroke="#ff3d71" stroke-width="4"/>
|
||||
<path d="M 60 20 L 52 34 L 68 34 Z" fill="#ff3d71"/>
|
||||
</svg>`;
|
||||
}
|
||||
function newOverlayMini() {
|
||||
return `<svg viewBox="0 0 120 160">
|
||||
<rect width="120" height="160" fill="#141414"/>
|
||||
<rect x="34" y="18" width="52" height="124" fill="hsl(28,30%,30%)"/>
|
||||
<line x1="60" y1="30" x2="60" y2="132" stroke="#000" stroke-width="2.4"/>
|
||||
<line x1="48" y1="30" x2="72" y2="30" stroke="#000" stroke-width="2.4"/>
|
||||
<line x1="48" y1="132" x2="72" y2="132" stroke="#000" stroke-width="2.4"/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------- plumbing
|
||||
const phone = document.getElementById('phone');
|
||||
const flowbar = document.getElementById('flowbar');
|
||||
const STEPS = [
|
||||
["batch", "Batch photos"], ["mask", "Mask"], ["tape", "Tape line (#99)"],
|
||||
["measure", "Photos ready"], ["settings", "⚙ Settings"],
|
||||
];
|
||||
function render() {
|
||||
phone.innerHTML = screens[S.screen]();
|
||||
flowbar.innerHTML = STEPS.map(([k, label]) =>
|
||||
`<button class="fstep ${S.screen === k ? "on" : ""}" data-goto="${k}">${label}</button>`).join('');
|
||||
if (S.screen === "tape") { bindStage(); refreshStage(); }
|
||||
}
|
||||
flowbar.addEventListener('click', (e) => {
|
||||
const b = e.target.closest('[data-goto]');
|
||||
if (!b) return;
|
||||
S.screen = b.dataset.goto;
|
||||
render();
|
||||
});
|
||||
document.querySelector('.vp').addEventListener('click', (e) => {
|
||||
const b = e.target.closest('[data-w]');
|
||||
if (!b) return;
|
||||
document.querySelectorAll('.vp [data-w]').forEach((x) => x.classList.remove('on'));
|
||||
b.classList.add('on');
|
||||
phone.style.width = (b.dataset.w > 720 ? '412px' : b.dataset.w + 'px');
|
||||
document.getElementById('vpval').textContent = b.dataset.w;
|
||||
});
|
||||
|
||||
// Replace only the stage + the live readouts, so an in-progress drag survives
|
||||
// (a full panel re-render would destroy the element under the pointer).
|
||||
function refreshStage() {
|
||||
const host = document.getElementById('tw');
|
||||
if (!host) return;
|
||||
host.innerHTML = stageSvg();
|
||||
const set = (id, txt) => { const el = document.getElementById(id); if (el) el.textContent = txt; };
|
||||
set('tiltVal', S.tilt + '\u00B0');
|
||||
set('zoomVal', Math.round(S.zoom * 100) + '%');
|
||||
set('fillVal', S.fill + '%');
|
||||
set('insetReadout', `${((1 - S.fill / 100) / 2 * 100).toFixed(1)}% in from top and bottom`);
|
||||
set('lineReadout', `${lineX().toFixed(1)} of ${VB.w}`);
|
||||
set('spanReadout', `${spanPx().toFixed(0)} px`);
|
||||
set('scaleReadout', `${pxPerIn().toFixed(2)} px/in`);
|
||||
const st = document.getElementById('alignstatus');
|
||||
if (st) {
|
||||
const ok = aligned(), off = axisOffset();
|
||||
st.className = 'status' + (ok ? ' ok' : '');
|
||||
st.innerHTML = ok
|
||||
? "The board's long axis is on the line \u2713"
|
||||
: `The board's long axis is ${off > 0 ? 'right' : 'left'} of the line by ${Math.abs(off).toFixed(0)} px`
|
||||
+ `${axisVertical() ? '' : `, and tilted ${S.tilt}\u00B0`}.`;
|
||||
}
|
||||
window.__wf = { tilt: S.tilt, zoom: S.zoom, panX: S.panX, panY: S.panY,
|
||||
tickA: S.tickA, tickB: S.tickB, focus: S.focus, fill: S.fill,
|
||||
lineX: lineX(), spanPx: spanPx(), axisOffset: axisOffset(),
|
||||
axisXAt: axisXAt((S.tickA + S.tickB) / 2), axisVertical: axisVertical(),
|
||||
axisAngle: axisAngle(), aligned: aligned() };
|
||||
}
|
||||
|
||||
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
|
||||
const norm360 = (a) => ((a % 360) + 360) % 360;
|
||||
function tiltBy(d) { S.tilt = norm360(S.tilt + d); render(); }
|
||||
// Visible zoom path. #99 names wheel / trackpad pinch; this is the discoverable
|
||||
// twin, stepping about the stage centre so the slab stays where it is.
|
||||
function stepZoom(dir) {
|
||||
S.zoom = clamp(S.zoom * (dir > 0 ? 1.1 : 1 / 1.1), 0.5, 3);
|
||||
refreshStage();
|
||||
}
|
||||
function fitSlab() { S.zoom = 1; S.panX = 0; S.panY = 0; refreshStage(); }
|
||||
// no tick picker: the tick you last grabbed is the focused one (set on pointerdown)
|
||||
function nudgeTick(dir, mult) {
|
||||
const step = (mult === undefined ? 1 : mult);
|
||||
if (S.focus === "A") S.tickA = clamp(S.tickA + dir * step, 20, S.tickB - TICK_GAP);
|
||||
else S.tickB = clamp(S.tickB + dir * step, S.tickA + TICK_GAP, VB.h - 20);
|
||||
render();
|
||||
}
|
||||
|
||||
function bindStage() {
|
||||
const host = document.getElementById('tw');
|
||||
if (!host) return;
|
||||
const toVB = (e) => {
|
||||
const r = (host.querySelector('svg') || host).getBoundingClientRect();
|
||||
return { x: (e.clientX - r.left) * (VB.w / r.width), y: (e.clientY - r.top) * (VB.h / r.height) };
|
||||
};
|
||||
// zoom-to-cursor — moves the SLAB only; the line ignores it entirely
|
||||
function zoomAt(p, factor) {
|
||||
const cx = VB.w / 2, cy = VB.h / 2;
|
||||
const z0 = S.zoom, z1 = clamp(z0 * factor, 0.5, 3);
|
||||
if (z1 === z0) return;
|
||||
const qx = cx + (p.x - cx - S.panX) / z0;
|
||||
const qy = cy + (p.y - cy - S.panY) / z0;
|
||||
S.zoom = z1;
|
||||
S.panX = p.x - cx - z1 * (qx - cx);
|
||||
S.panY = p.y - cy - z1 * (qy - cy);
|
||||
refreshStage();
|
||||
}
|
||||
host.addEventListener('wheel', (e) => {
|
||||
e.preventDefault();
|
||||
// No single event may slam the clamp: a PC ctrl+wheel notch arrives at
|
||||
// deltaY ~300 and a trackpad pinch arrives as ctrlKey too, so cap both the
|
||||
// delta and the per-event factor. Plain wheel stays ~16% per notch.
|
||||
const dy = clamp(e.deltaY, -200, 200);
|
||||
const k = e.ctrlKey ? 0.01 : 0.0015;
|
||||
zoomAt(toVB(e), clamp(Math.exp(-dy * k), 0.8, 1.25));
|
||||
}, { passive: false });
|
||||
// belt-and-braces fit: Chromium suppresses this after our pointerdown
|
||||
// preventDefault (see the tap tracking in end()), some engines still emit it
|
||||
host.addEventListener('dblclick', (e) => { e.preventDefault(); fitSlab(); });
|
||||
|
||||
let drag = null, tb = null, moved = false, downP = null;
|
||||
const off = { x: 0, y: 0 };
|
||||
host.addEventListener('pointerdown', (e) => {
|
||||
const tick = e.target.closest('[data-tick]');
|
||||
const rot = e.target.closest('[data-rot]');
|
||||
const p = toVB(e);
|
||||
moved = false; downP = p;
|
||||
if (tick) {
|
||||
S.focus = tick.dataset.tick;
|
||||
drag = { kind: "tick", id: S.focus };
|
||||
} else if (rot) {
|
||||
drag = { kind: "rot", start: p.x, tilt0: S.tilt };
|
||||
} else {
|
||||
drag = { kind: "slab" };
|
||||
off.x = p.x - S.panX; off.y = p.y - S.panY;
|
||||
}
|
||||
host.setPointerCapture(e.pointerId);
|
||||
host.classList.add('dragging');
|
||||
refreshStage();
|
||||
e.preventDefault();
|
||||
});
|
||||
host.addEventListener('pointermove', (e) => {
|
||||
if (!drag) return;
|
||||
const p = toVB(e);
|
||||
if (downP && (Math.abs(p.x - downP.x) > 3 || Math.abs(p.y - downP.y) > 3)) moved = true;
|
||||
if (drag.kind === "tick") {
|
||||
// ticks slide ALONG the fixed line — x is ignored on purpose
|
||||
const y = clamp(p.y, 20, VB.h - 20);
|
||||
if (drag.id === "A") S.tickA = clamp(y, 20, S.tickB - TICK_GAP);
|
||||
else S.tickB = clamp(y, S.tickA + TICK_GAP, VB.h - 20);
|
||||
} else if (drag.kind === "rot") {
|
||||
S.tilt = norm360(drag.tilt0 + (p.x - drag.start) * 0.6);
|
||||
} else {
|
||||
S.panX = clamp(p.x - off.x, -400, 400);
|
||||
S.panY = clamp(p.y - off.y, -400, 400);
|
||||
}
|
||||
refreshStage();
|
||||
});
|
||||
const end = (e) => {
|
||||
if (!drag) return;
|
||||
const wasSlab = drag.kind === "slab";
|
||||
const startP = toVB(e);
|
||||
drag = null;
|
||||
host.classList.remove('dragging');
|
||||
try { host.releasePointerCapture(e.pointerId); } catch (_) {}
|
||||
// double-tap to fit. We cannot use a 'dblclick' listener: preventDefault on
|
||||
// pointerdown suppresses the whole compatibility mouse sequence (click and
|
||||
// dblclick both stop arriving), so track the taps ourselves.
|
||||
if (wasSlab && !moved) {
|
||||
const now = performance.now();
|
||||
const near = tb && Math.abs(startP.x - tb.x) < 12 && Math.abs(startP.y - tb.y) < 12;
|
||||
if (near && now - tb.t < 350) { tb = null; fitSlab(); return; }
|
||||
tb = { t: now, x: startP.x, y: startP.y };
|
||||
}
|
||||
refreshStage();
|
||||
};
|
||||
host.addEventListener('pointerup', end);
|
||||
host.addEventListener('pointercancel', end);
|
||||
}
|
||||
|
||||
// keyboard: arrows = Adobe nudge model (1 / 0.1 with Shift), +/- = zoom steps
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (S.screen !== "tape") return;
|
||||
if (e.key === "+" || e.key === "=" || e.key === "-" || e.key === "_") {
|
||||
stepZoom(e.key === "+" || e.key === "=" ? 1 : -1);
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (!e.key.startsWith("Arrow")) return;
|
||||
const fine = e.shiftKey ? 0.1 : 1;
|
||||
if (e.key === "ArrowLeft") { S.tilt = norm360(S.tilt - fine); render(); }
|
||||
else if (e.key === "ArrowRight") { S.tilt = norm360(S.tilt + fine); render(); }
|
||||
else if (e.key === "ArrowUp") { nudgeTick(-1, fine); }
|
||||
else if (e.key === "ArrowDown") { nudgeTick(1, fine); }
|
||||
else return;
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
phone.addEventListener('click', (e) => {
|
||||
const t = e.target.closest('[data-act]');
|
||||
if (!t) return;
|
||||
const a = t.dataset.act;
|
||||
if (a === "tilt") tiltBy(+t.dataset.d);
|
||||
else if (a === "zoom") stepZoom(+t.dataset.d);
|
||||
else if (a === "fit") fitSlab();
|
||||
else if (a === "to-mask") { S.screen = "mask"; render(); }
|
||||
else if (a === "to-batch") { S.screen = "batch"; render(); }
|
||||
else if (a === "to-tape") { S.screen = "tape"; render(); }
|
||||
else if (a === "to-measure") { S.screen = "measure"; render(); }
|
||||
else if (a === "to-settings") { S.screen = "settings"; render(); }
|
||||
else if (a === "save-fill") { S.savedMsg = `Saved crop_fill = ${S.fill}. The overlay's ends and every future PNG crop follow it; the rectangle keeps its 3:4 aspect and stored PNGs are untouched.`; render(); }
|
||||
});
|
||||
phone.addEventListener('input', (e) => {
|
||||
const t = e.target;
|
||||
if (t.dataset.act === "tilt-range") { S.tilt = +t.value; refreshStage(); }
|
||||
else if (t.dataset.act === "zoom-range") { S.zoom = clamp(+t.value / 100, 0.5, 3); refreshStage(); }
|
||||
else if (t.dataset.act === "fill-range") {
|
||||
// crop % moves the overlay's ENDS in/out from the top and bottom; the
|
||||
// rectangle's aspect never changes and the line's x never moves.
|
||||
S.fill = +t.value;
|
||||
const ct = cropTicks();
|
||||
S.tickA = ct.a; S.tickB = ct.b;
|
||||
refreshStage();
|
||||
}
|
||||
});
|
||||
phone.addEventListener('change', (e) => {
|
||||
const t = e.target;
|
||||
if (t.dataset.act === "length") { S.L = t.value; render(); }
|
||||
else if (t.dataset.act === "thickness") { S.thickness = t.value; render(); }
|
||||
});
|
||||
|
||||
render();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+15
-11
@@ -1,13 +1,17 @@
|
||||
# Build the static app, then serve only the built artifacts.
|
||||
# Build context is the repo root: `docker build -f frontend/Dockerfile .`
|
||||
FROM node:20-slim AS build
|
||||
WORKDIR /app
|
||||
COPY frontend/package.json frontend/package-lock.json* ./
|
||||
RUN npm install --no-audit --no-fund
|
||||
COPY frontend/ ./
|
||||
RUN npm run build
|
||||
FROM node:20-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY frontend/package*.json ./
|
||||
RUN npm ci --ignore-scripts
|
||||
|
||||
COPY frontend/ .
|
||||
|
||||
# Build the SvelteKit app (stub for Gate B — will be updated when SvelteKit scaffolds)
|
||||
# For now, copy a stub HTML page. Vite build runs in production mode later.
|
||||
RUN mkdir -p build && \
|
||||
echo '<!DOCTYPE html><html><head><meta charset="utf-8"><title>SlabUploader</title></head><body><h1>SlabUploader — coming soon</h1><p>SvelteKit app stub. Update this Dockerfile when the SvelteKit app is scaffolded.</p></body></html>' > build/index.html
|
||||
|
||||
FROM nginx:1.27-alpine
|
||||
COPY deploy/nginx-m1.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["node", "-e", "const h=require('http'),fs=require('fs'),url=require('url');h.createServer((req,res)=>{const f='build/index.html';res.writeHead(200,{'Content-Type':'text/html'}).end(fs.readFileSync(f))}).listen(80);console.log('frontend on :80')"]
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<title>SlabUploader — Capture</title>
|
||||
<style>
|
||||
html, body { margin: 0; padding: 0; background: #111; }
|
||||
#app { display: block; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+419
-1096
File diff suppressed because it is too large
Load Diff
+5
-17
@@ -1,26 +1,14 @@
|
||||
{
|
||||
"name": "slabuploader-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "node --import tsx --test src/**/*.test.ts",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:headed": "playwright test --headed"
|
||||
},
|
||||
"dependencies": {
|
||||
"idb-keyval": "^6.3.0",
|
||||
"svelte": "^4.2.20"
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.63.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^3.1.2",
|
||||
"svelte-preprocess": "^5.1.4",
|
||||
"tsx": "^4.23.13",
|
||||
"typescript": "^5.7.3",
|
||||
"vite": "^5.4.19"
|
||||
"typescript": "^5.6.3",
|
||||
"vitest": "^2.1.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* M2 #65 — Playwright visual evidence against local vite preview (not ubudual).
|
||||
* Artifacts land in tests/e2e/artifacts/ (png churn gitignored; README committed).
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: './tests/e2e',
|
||||
fullyParallel: false,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: 0,
|
||||
workers: 1,
|
||||
timeout: 180_000,
|
||||
expect: { timeout: 30_000 },
|
||||
reporter: [['list'], ['html', { open: 'never', outputFolder: 'tests/e2e/playwright-report' }]],
|
||||
use: {
|
||||
baseURL: 'http://127.0.0.1:4173',
|
||||
trace: 'retain-on-failure',
|
||||
screenshot: 'only-on-failure',
|
||||
video: 'off',
|
||||
...devices['Pixel 7'],
|
||||
},
|
||||
webServer: {
|
||||
command: 'npm run build && npm run preview -- --host 127.0.0.1 --port 4173',
|
||||
url: 'http://127.0.0.1:4173',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 180_000,
|
||||
},
|
||||
outputDir: 'tests/e2e/test-results',
|
||||
});
|
||||
@@ -1,105 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { supported } 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';
|
||||
|
||||
const gate = typeof window !== 'undefined' ? supported() : 'window unavailable';
|
||||
const session: CaptureSession = createCaptureSession();
|
||||
|
||||
let cap: Capture = { k: 'batch', photos: [] };
|
||||
let busy = false;
|
||||
let pngUrl: string | null = null;
|
||||
|
||||
const unsubCap = session.capture.subscribe((c) => {
|
||||
cap = c;
|
||||
pngUrl = c.k === 'measure' ? session.getInventoryUrl() : null;
|
||||
});
|
||||
const unsubBusy = session.busy.subscribe((b) => {
|
||||
busy = b;
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
void session.resumeProbe();
|
||||
});
|
||||
onDestroy(() => {
|
||||
unsubCap();
|
||||
unsubBusy();
|
||||
session.dispose();
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if gate}
|
||||
<main class="hardstop">
|
||||
<h1>Unsupported browser</h1>
|
||||
<p>
|
||||
SlabUploader needs a modern browser with canvas, workers, and IndexedDB support:
|
||||
iOS Safari, Android Chrome, or desktop Chrome, Edge, or Firefox.
|
||||
</p>
|
||||
<p class="detail">Missing: {gate}</p>
|
||||
</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}
|
||||
/>
|
||||
{: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}
|
||||
setTape={session.setTape}
|
||||
approveAxis={session.approveAxis}
|
||||
back={session.back}
|
||||
/>
|
||||
{:else}
|
||||
<MeasurePanel
|
||||
state={cap}
|
||||
setLength={session.setLength}
|
||||
setThickness={session.setThickness}
|
||||
calculate={session.calculate}
|
||||
back={session.back}
|
||||
{pngUrl}
|
||||
/>
|
||||
{/if}
|
||||
</main>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
:global(body) {
|
||||
margin: 0;
|
||||
background: #121212;
|
||||
font-family: system-ui, -apple-system, Segoe UI, sans-serif;
|
||||
}
|
||||
.app, .hardstop { max-width: 720px; margin: 0 auto; min-height: 100vh; }
|
||||
.hardstop { padding: 48px 24px; color: #eee; text-align: center; }
|
||||
.hardstop h1 { font-size: 1.6rem; }
|
||||
.detail { color: #888; font-size: 0.85rem; }
|
||||
</style>
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { MaskBuffer } from "../../lib/pipeline/types.js";
|
||||
|
||||
function blankMask(width: number, height: number): MaskBuffer {
|
||||
return new Uint8Array(width * height);
|
||||
}
|
||||
|
||||
function fillMaskRect(
|
||||
mask: MaskBuffer,
|
||||
width: number,
|
||||
x0: number,
|
||||
y0: number,
|
||||
x1: number,
|
||||
y1: number,
|
||||
): void {
|
||||
for (let y = y0; y < y1; y++) {
|
||||
for (let x = x0; x < x1; x++) {
|
||||
mask[y * width + x] = 255;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* TV-4: axis-aligned slab 96×24 px → with lengthIn=96, scale=1 px/in.
|
||||
* Constant width 24" at every 6" station.
|
||||
*/
|
||||
export function tv4RectMask(): {
|
||||
mask: MaskBuffer;
|
||||
width: number;
|
||||
height: number;
|
||||
lengthIn: number;
|
||||
expectedWidthIn: number;
|
||||
expectedStations: number[];
|
||||
} {
|
||||
const width = 120;
|
||||
const height = 48;
|
||||
const mask = blankMask(width, height);
|
||||
fillMaskRect(mask, width, 12, 12, 108, 36);
|
||||
const expectedStations: number[] = [];
|
||||
for (let s = 6; s < 96; s += 6) expectedStations.push(s);
|
||||
return {
|
||||
mask,
|
||||
width,
|
||||
height,
|
||||
lengthIn: 96,
|
||||
expectedWidthIn: 24,
|
||||
expectedStations,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* TV-5: 96×24 slab with square left end and 45° cut on the right end.
|
||||
* Prevailing length remains horizontal; diagonal of the cut must not win.
|
||||
*/
|
||||
export function tv5AngledEndMask(): {
|
||||
mask: MaskBuffer;
|
||||
width: number;
|
||||
height: number;
|
||||
lengthIn: number;
|
||||
bodyWidthIn: number;
|
||||
} {
|
||||
const width = 120;
|
||||
const height = 48;
|
||||
const mask = blankMask(width, height);
|
||||
const x0 = 12;
|
||||
const x1 = 108; // 96 px long
|
||||
const y0 = 12;
|
||||
const y1 = 36; // 24 px wide
|
||||
const cutStart = x1 - 24; // 45° cut over last 24" of length
|
||||
for (let y = y0; y < y1; y++) {
|
||||
for (let x = x0; x < x1; x++) {
|
||||
if (x >= cutStart) {
|
||||
const cutY = y0 + (x - cutStart);
|
||||
if (y < cutY) continue;
|
||||
}
|
||||
mask[y * width + x] = 255;
|
||||
}
|
||||
}
|
||||
return {
|
||||
mask,
|
||||
width,
|
||||
height,
|
||||
lengthIn: 96,
|
||||
bodyWidthIn: 24,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import type { MaskBuffer, RgbaImage, Vec2 } from "../../../lib/pipeline/types.js";
|
||||
|
||||
function blankImage(width: number, height: number): RgbaImage {
|
||||
return { width, height, data: new Uint8ClampedArray(width * height * 4) };
|
||||
}
|
||||
|
||||
function blankMask(width: number, height: number): MaskBuffer {
|
||||
return new Uint8Array(width * height);
|
||||
}
|
||||
|
||||
function fillImageRect(
|
||||
img: RgbaImage,
|
||||
x0: number, y0: number, x1: number, y1: number,
|
||||
r: number, g: number, b: number, a: number = 255,
|
||||
): void {
|
||||
for (let y = y0; y < y1; y++) {
|
||||
for (let x = x0; x < x1; x++) {
|
||||
const i = (y * img.width + x) * 4;
|
||||
img.data[i] = r;
|
||||
img.data[i + 1] = g;
|
||||
img.data[i + 2] = b;
|
||||
img.data[i + 3] = a;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function fillMaskRect(mask: MaskBuffer, w: number, x0: number, y0: number, x1: number, y1: number): void {
|
||||
for (let y = y0; y < y1; y++) {
|
||||
for (let x = x0; x < x1; x++) {
|
||||
mask[y * w + x] = 255;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* TV-6 fixture: large source with a tall slab so the 3:4 crop shorter side
|
||||
* meets OUTPUT_PX_MIN (1600).
|
||||
*
|
||||
* Slab 1281×3000, aspect 0.427 < 0.75 → height limiting.
|
||||
* computeCropTarget → frameW=1601, frameH=2135, shorter=1601 ≥ 1600.
|
||||
*/
|
||||
export function tv6LargeSourceFixture(): {
|
||||
image: RgbaImage;
|
||||
mask: MaskBuffer;
|
||||
width: number;
|
||||
height: number;
|
||||
slabLeft: number;
|
||||
slabTop: number;
|
||||
slabRight: number;
|
||||
slabBottom: number;
|
||||
expectedFrameW: number;
|
||||
expectedFrameH: number;
|
||||
expectedMeetsMinPx: boolean;
|
||||
} {
|
||||
const width = 4000;
|
||||
const height = 3200;
|
||||
const image = blankImage(width, height);
|
||||
const mask = blankMask(width, height);
|
||||
|
||||
const slabW = 1281;
|
||||
const slabH = 3000;
|
||||
const sl = Math.round((width - slabW) / 2);
|
||||
const st = Math.round((height - slabH) / 2);
|
||||
|
||||
fillImageRect(image, 0, 0, width, height, 50, 200, 50, 255);
|
||||
fillImageRect(image, sl, st, sl + slabW, st + slabH, 160, 100, 60, 255);
|
||||
fillMaskRect(mask, width, sl, st, sl + slabW, st + slabH);
|
||||
|
||||
return {
|
||||
image,
|
||||
mask,
|
||||
width,
|
||||
height,
|
||||
slabLeft: sl,
|
||||
slabTop: st,
|
||||
slabRight: sl + slabW,
|
||||
slabBottom: st + slabH,
|
||||
expectedFrameW: 1601,
|
||||
expectedFrameH: 2135,
|
||||
expectedMeetsMinPx: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* TV-7 fixture: small source that cannot supply 1600 px after crop.
|
||||
* Slab 400×300, aspect 1.33 > 0.75 → width limiting.
|
||||
* computeCropTarget → frameW=281, frameH=375, shorter=281 < 1600.
|
||||
*/
|
||||
export function tv7SmallSourceFixture(): {
|
||||
image: RgbaImage;
|
||||
mask: MaskBuffer;
|
||||
width: number;
|
||||
height: number;
|
||||
slabLeft: number;
|
||||
slabTop: number;
|
||||
slabRight: number;
|
||||
slabBottom: number;
|
||||
expectedFrameW: number;
|
||||
expectedFrameH: number;
|
||||
expectedMeetsMinPx: boolean;
|
||||
} {
|
||||
const width = 800;
|
||||
const height = 640;
|
||||
const image = blankImage(width, height);
|
||||
const mask = blankMask(width, height);
|
||||
|
||||
const slabW = 400;
|
||||
const slabH = 300;
|
||||
const sl = Math.round((width - slabW) / 2);
|
||||
const st = Math.round((height - slabH) / 2);
|
||||
|
||||
fillImageRect(image, 0, 0, width, height, 50, 200, 50, 255);
|
||||
fillImageRect(image, sl, st, sl + slabW, st + slabH, 160, 100, 60, 255);
|
||||
fillMaskRect(mask, width, sl, st, sl + slabW, st + slabH);
|
||||
|
||||
return {
|
||||
image,
|
||||
mask,
|
||||
width,
|
||||
height,
|
||||
slabLeft: sl,
|
||||
slabTop: st,
|
||||
slabRight: sl + slabW,
|
||||
slabBottom: st + slabH,
|
||||
expectedFrameW: 281,
|
||||
expectedFrameH: 375,
|
||||
expectedMeetsMinPx: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a simple axis for testing, pointing right, centered in the slab.
|
||||
* lengthIn = widthPx so scale is 1 px/in.
|
||||
*/
|
||||
export function simpleAxis(widthPx: number, heightPx: number): {
|
||||
axis: { center: Vec2; direction: Vec2; lengthPx: number; widthPx: number; corners: [Vec2, Vec2, Vec2, Vec2] };
|
||||
lengthIn: number;
|
||||
} {
|
||||
const center = { x: widthPx / 2, y: heightPx / 2 };
|
||||
const direction = { x: 1, y: 0 };
|
||||
return {
|
||||
axis: {
|
||||
center,
|
||||
direction,
|
||||
lengthPx: widthPx,
|
||||
widthPx: heightPx,
|
||||
corners: [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: widthPx, y: 0 },
|
||||
{ x: widthPx, y: heightPx },
|
||||
{ x: 0, y: heightPx },
|
||||
],
|
||||
},
|
||||
lengthIn: widthPx,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/** TECH-SPEC §8 TV-8 pricing fixture. */
|
||||
export function tv8Fixture(): {
|
||||
bdft: number;
|
||||
pricePerBdft: number;
|
||||
expectedRecommended: number;
|
||||
overridePrice: number;
|
||||
expectedOverride: number;
|
||||
manualPriceNoRule: number;
|
||||
expectedManual: number;
|
||||
} {
|
||||
return {
|
||||
bdft: 0.96,
|
||||
pricePerBdft: 12.50,
|
||||
expectedRecommended: 12.00,
|
||||
overridePrice: 150.00,
|
||||
expectedOverride: 150.00,
|
||||
manualPriceNoRule: 99.99,
|
||||
expectedManual: 99.99,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import type { RgbaImage } from "../../lib/pipeline/types.js";
|
||||
|
||||
function hsvToRgb(h: number, s: number, v: number): [number, number, number] {
|
||||
const ss = s / 100;
|
||||
const vv = v / 100;
|
||||
const c = vv * ss;
|
||||
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
|
||||
const m = vv - c;
|
||||
let rp = 0;
|
||||
let gp = 0;
|
||||
let bp = 0;
|
||||
if (h < 60) {
|
||||
rp = c;
|
||||
gp = x;
|
||||
} else if (h < 120) {
|
||||
rp = x;
|
||||
gp = c;
|
||||
} else if (h < 180) {
|
||||
gp = c;
|
||||
bp = x;
|
||||
} else if (h < 240) {
|
||||
gp = x;
|
||||
bp = c;
|
||||
} else if (h < 300) {
|
||||
rp = x;
|
||||
bp = c;
|
||||
} else {
|
||||
rp = c;
|
||||
bp = x;
|
||||
}
|
||||
return [
|
||||
Math.round((rp + m) * 255),
|
||||
Math.round((gp + m) * 255),
|
||||
Math.round((bp + m) * 255),
|
||||
];
|
||||
}
|
||||
|
||||
function fillRect(
|
||||
img: RgbaImage,
|
||||
x0: number,
|
||||
y0: number,
|
||||
x1: number,
|
||||
y1: number,
|
||||
rgb: [number, number, number],
|
||||
): void {
|
||||
const [r, g, b] = rgb;
|
||||
for (let y = y0; y < y1; y++) {
|
||||
for (let x = x0; x < x1; x++) {
|
||||
const i = (y * img.width + x) * 4;
|
||||
img.data[i] = r;
|
||||
img.data[i + 1] = g;
|
||||
img.data[i + 2] = b;
|
||||
img.data[i + 3] = 255;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function blankImage(width: number, height: number): RgbaImage {
|
||||
return { width, height, data: new Uint8ClampedArray(width * height * 4) };
|
||||
}
|
||||
|
||||
/**
|
||||
* TV-1 fixture: green sheet border, brown wood slab, interior green-ish patch
|
||||
* that does not touch the image border.
|
||||
*/
|
||||
export function greenSheetFixture(size = 64): {
|
||||
image: RgbaImage;
|
||||
interiorGreen: { x: number; y: number };
|
||||
woodSample: { x: number; y: number };
|
||||
borderSample: { x: number; y: number };
|
||||
} {
|
||||
const image = blankImage(size, size);
|
||||
const green = hsvToRgb(60, 80, 80);
|
||||
const wood = hsvToRgb(30, 55, 55);
|
||||
const interiorGreen = hsvToRgb(55, 70, 70);
|
||||
fillRect(image, 0, 0, size, size, green);
|
||||
// Wood slab inset so it does not touch border.
|
||||
fillRect(image, 12, 12, size - 12, size - 12, wood);
|
||||
// Interior green-ish patch fully inside wood (no border contact).
|
||||
fillRect(image, 28, 28, 36, 36, interiorGreen);
|
||||
return {
|
||||
image,
|
||||
interiorGreen: { x: 32, y: 32 },
|
||||
woodSample: { x: 20, y: 20 },
|
||||
borderSample: { x: 2, y: 2 },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* TV-2 fixture: black sheet border, mid-tone wood, dark interior wood patch
|
||||
* that does not touch the border.
|
||||
*/
|
||||
export function blackSheetFixture(size = 64): {
|
||||
image: RgbaImage;
|
||||
darkInterior: { x: number; y: number };
|
||||
woodSample: { x: number; y: number };
|
||||
borderSample: { x: number; y: number };
|
||||
} {
|
||||
const image = blankImage(size, size);
|
||||
const black: [number, number, number] = [8, 8, 8];
|
||||
const wood: [number, number, number] = [140, 110, 80];
|
||||
const darkWood: [number, number, number] = [18, 14, 10];
|
||||
fillRect(image, 0, 0, size, size, black);
|
||||
fillRect(image, 12, 12, size - 12, size - 12, wood);
|
||||
fillRect(image, 28, 28, 36, 36, darkWood);
|
||||
return {
|
||||
image,
|
||||
darkInterior: { x: 32, y: 32 },
|
||||
woodSample: { x: 20, y: 20 },
|
||||
borderSample: { x: 2, y: 2 },
|
||||
};
|
||||
}
|
||||
|
||||
export function maskAt(mask: Uint8Array, width: number, x: number, y: number): number {
|
||||
return mask[y * width + x]!;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/** TECH-SPEC §8 TV-9 mock taxonomy fixture for thickness attribute. */
|
||||
import type { ThicknessRange } from "../../../lib/pipeline/thicknessBand.js";
|
||||
|
||||
/** Mock Woo thickness attribute terms (synced from store). */
|
||||
export function mockThicknessRanges(): ThicknessRange[] {
|
||||
return [
|
||||
{ wooId: 101, label: '3/4"–1"', minIn: 0.75, maxIn: 1.0 },
|
||||
{ wooId: 102, label: '1"–1 1/2"', minIn: 1.0, maxIn: 1.5 },
|
||||
{ wooId: 103, label: '1 1/2"–2"', minIn: 1.5, maxIn: 2.0 },
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/** TECH-SPEC §8 TV-3 numeric fixture (client-authoritative sqft input). */
|
||||
export function tv3Fixture(): {
|
||||
lengthIn: number;
|
||||
thicknessIn: number;
|
||||
sqft: number;
|
||||
expectedBdft: number;
|
||||
} {
|
||||
return {
|
||||
lengthIn: 96,
|
||||
thicknessIn: 1.5,
|
||||
sqft: 0.64,
|
||||
expectedBdft: 0.96,
|
||||
};
|
||||
}
|
||||
|
||||
/** TECH-SPEC §8 TV-10 rounding vectors. */
|
||||
export function tv10Fixture(): {
|
||||
lengthRaw: number;
|
||||
lengthExpected: number;
|
||||
sqftRaw: number;
|
||||
sqftExpected: number;
|
||||
bdftRaw: number;
|
||||
bdftExpected: number;
|
||||
priceRaw: number;
|
||||
priceExpected: number;
|
||||
} {
|
||||
return {
|
||||
lengthRaw: 96.04,
|
||||
lengthExpected: 96,
|
||||
sqftRaw: 0.641,
|
||||
sqftExpected: 0.64,
|
||||
bdftRaw: 11.525,
|
||||
bdftExpected: 11.53,
|
||||
priceRaw: 12.345,
|
||||
priceExpected: 12.35,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
/** Boundary strings for shared validation unit tests (V-1, V-2, V-3). */
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// V-1: SKU [A-Z0-9-]{3,24}
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Valid SKUs spanning the charset and length boundaries. */
|
||||
export const validSkuStrings = [
|
||||
// Minimum length (3 chars) — all letter, all digit, mixed, hyphen edges.
|
||||
"ABC",
|
||||
"012",
|
||||
"A0A",
|
||||
"A-B",
|
||||
"0-1",
|
||||
// Mid-range typical.
|
||||
"BW-0042",
|
||||
"SLAB-UAT-001",
|
||||
"A9Z",
|
||||
// Maximum length (24 chars).
|
||||
"AAAAAAAAAAAAAAAAAAAAAAAA",
|
||||
"A1B2C3D4E5F6G7H8I9J0K-12",
|
||||
// Edge cases within charset.
|
||||
"A-A-A-A-A-A",
|
||||
"1-2-3-4-5-6",
|
||||
"ABCDEF",
|
||||
];
|
||||
|
||||
/** Invalid SKUs: wrong charset, too short, too long. */
|
||||
export const invalidSkuStrings = [
|
||||
// Too short (1–2 chars).
|
||||
"A",
|
||||
"AB",
|
||||
// Too long (25+ chars).
|
||||
"AAAAAAAAAAAAAAAAAAAAAAAAA",
|
||||
// Lowercase letters (outside charset).
|
||||
"abc",
|
||||
"bw-0042",
|
||||
"aB3",
|
||||
// Special characters outside charset.
|
||||
"ABC!",
|
||||
"A B",
|
||||
"A_B",
|
||||
"ABC.D",
|
||||
"ABC#1",
|
||||
"ABC/1",
|
||||
"ABC\\1",
|
||||
// Empty.
|
||||
"",
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// V-2: Title / short_title / description maxLength (UTF-8)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import {
|
||||
MAX_TITLE_LENGTH,
|
||||
MAX_SHORT_TITLE_LENGTH,
|
||||
MAX_DESCRIPTION_LENGTH,
|
||||
} from "../../lib/validation/constants.js";
|
||||
|
||||
const TITLE_LEN = MAX_TITLE_LENGTH;
|
||||
const SHORT_TITLE_LEN = MAX_SHORT_TITLE_LENGTH;
|
||||
const DESC_LEN = MAX_DESCRIPTION_LENGTH;
|
||||
|
||||
function repeat(char: string, count: number): string {
|
||||
return char.repeat(count);
|
||||
}
|
||||
|
||||
function buildTitle(): { title: string; length: number } {
|
||||
return { title: repeat("A", TITLE_LEN), length: TITLE_LEN };
|
||||
}
|
||||
|
||||
function buildShortTitle(): { shortTitle: string; length: number } {
|
||||
return { shortTitle: repeat("B", SHORT_TITLE_LEN), length: SHORT_TITLE_LEN };
|
||||
}
|
||||
|
||||
function buildDescription(): { description: string; length: number } {
|
||||
return { description: repeat("C", DESC_LEN), length: DESC_LEN };
|
||||
}
|
||||
|
||||
export function validTitleFixture(): { title: string; length: number } {
|
||||
return buildTitle();
|
||||
}
|
||||
|
||||
export function invalidTitleFixture(): { title: string; length: number } {
|
||||
return { title: repeat("A", TITLE_LEN + 1), length: TITLE_LEN + 1 };
|
||||
}
|
||||
|
||||
export function validShortTitleFixture(): { shortTitle: string; length: number } {
|
||||
return buildShortTitle();
|
||||
}
|
||||
|
||||
export function invalidShortTitleFixture(): { shortTitle: string; length: number } {
|
||||
return { shortTitle: repeat("B", SHORT_TITLE_LEN + 1), length: SHORT_TITLE_LEN + 1 };
|
||||
}
|
||||
|
||||
export function validDescriptionFixture(): { description: string; length: number } {
|
||||
return buildDescription();
|
||||
}
|
||||
|
||||
export function invalidDescriptionFixture(): { description: string; length: number } {
|
||||
return { description: repeat("C", DESC_LEN + 1), length: DESC_LEN + 1 };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// V-3: Validation module version hash
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Expected hash for the current module (constants v1). */
|
||||
export const expectedHash = "v1";
|
||||
|
||||
/** A stale hash to test mismatch detection. */
|
||||
export const staleHash = "v0";
|
||||
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
confirmMask,
|
||||
createMaskSession,
|
||||
defaultKnobs,
|
||||
removeSheetBackground,
|
||||
reRunMask,
|
||||
requestRetake,
|
||||
} from "../lib/pipeline/mask.js";
|
||||
import {
|
||||
blackSheetFixture,
|
||||
greenSheetFixture,
|
||||
maskAt,
|
||||
} from "./fixtures/synthetic.js";
|
||||
|
||||
describe("TV-1 green-sheet chroma-key", () => {
|
||||
it("keeps interior green-ish patch that does not touch the border", () => {
|
||||
const { image, interiorGreen, woodSample, borderSample } = greenSheetFixture();
|
||||
const knobs = { ...defaultKnobs(), sheet: "green" as const };
|
||||
const result = removeSheetBackground(image, knobs);
|
||||
|
||||
expect(result.u2netInvoked).toBe(false);
|
||||
expect(result.sheetUsed).toBe("green");
|
||||
expect(maskAt(result.mask, result.width, borderSample.x, borderSample.y)).toBe(0);
|
||||
expect(maskAt(result.mask, result.width, woodSample.x, woodSample.y)).toBeGreaterThan(0);
|
||||
expect(
|
||||
maskAt(result.mask, result.width, interiorGreen.x, interiorGreen.y),
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("re-runs live when knobs change and still skips U2Net", () => {
|
||||
const { image } = greenSheetFixture();
|
||||
const first = removeSheetBackground(image, { ...defaultKnobs(), sheet: "green" });
|
||||
const second = reRunMask(image, {
|
||||
...defaultKnobs(),
|
||||
sheet: "green",
|
||||
sensitivity: 0.7,
|
||||
edgeOffset: 1,
|
||||
feather: 1,
|
||||
});
|
||||
expect(first.u2netInvoked).toBe(false);
|
||||
expect(second.u2netInvoked).toBe(false);
|
||||
expect(second.knobs.sensitivity).toBe(0.7);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TV-2 black-sheet threshold + border flood-fill", () => {
|
||||
it("keeps dark interior wood that does not touch the border", () => {
|
||||
const { image, darkInterior, woodSample, borderSample } = blackSheetFixture();
|
||||
const knobs = { ...defaultKnobs(), sheet: "black" as const };
|
||||
const result = removeSheetBackground(image, knobs);
|
||||
|
||||
expect(result.u2netInvoked).toBe(false);
|
||||
expect(result.sheetUsed).toBe("black");
|
||||
expect(maskAt(result.mask, result.width, borderSample.x, borderSample.y)).toBe(0);
|
||||
expect(maskAt(result.mask, result.width, woodSample.x, woodSample.y)).toBeGreaterThan(0);
|
||||
expect(
|
||||
maskAt(result.mask, result.width, darkInterior.x, darkInterior.y),
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("flood-fill removes only border-connected dark pixels", () => {
|
||||
const { image, darkInterior, borderSample } = blackSheetFixture();
|
||||
const result = removeSheetBackground(image, { ...defaultKnobs(), sheet: "black" });
|
||||
// Border sheet gone; isolated dark island kept.
|
||||
expect(maskAt(result.mask, result.width, borderSample.x, borderSample.y)).toBe(0);
|
||||
expect(
|
||||
maskAt(result.mask, result.width, darkInterior.x, darkInterior.y),
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mask confirm hooks (pure)", () => {
|
||||
it("starts pending, confirm and retake transition without DOM", () => {
|
||||
const { image } = greenSheetFixture();
|
||||
const result = removeSheetBackground(image, { ...defaultKnobs(), sheet: "green" });
|
||||
const session = createMaskSession(result);
|
||||
expect(session.state).toBe("pending");
|
||||
expect(confirmMask(session).state).toBe("confirmed");
|
||||
expect(requestRetake(session).state).toBe("retake");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
computeBdft,
|
||||
roundBdft,
|
||||
roundLengthIn,
|
||||
roundPrice,
|
||||
roundSqft,
|
||||
} from "../lib/pipeline/units.js";
|
||||
import { tv3Fixture, tv10Fixture } from "./fixtures/units.js";
|
||||
|
||||
describe("TV-3 Scale, sqft, bdft", () => {
|
||||
it("bdft = sqft × thickness_in with no divide-by-12", () => {
|
||||
const { lengthIn, thicknessIn, sqft, expectedBdft } = tv3Fixture();
|
||||
const bdft = computeBdft(sqft, thicknessIn);
|
||||
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
case: "TV-3",
|
||||
lengthIn,
|
||||
thicknessIn,
|
||||
sqft,
|
||||
bdft,
|
||||
expectedBdft,
|
||||
identity_1sqft_1in: computeBdft(1, 1),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(bdft).toBe(expectedBdft);
|
||||
expect(bdft).toBe(0.96);
|
||||
expect(computeBdft(1, 1)).toBe(1);
|
||||
expect(bdft).not.toBeCloseTo((sqft * thicknessIn) / 12, 5);
|
||||
});
|
||||
|
||||
it("bdft is monotonic in thickness and sqft", () => {
|
||||
const baseSqft = 0.64;
|
||||
const baseThick = 1.5;
|
||||
expect(computeBdft(baseSqft, baseThick + 0.125)).toBeGreaterThan(
|
||||
computeBdft(baseSqft, baseThick),
|
||||
);
|
||||
expect(computeBdft(baseSqft + 0.01, baseThick)).toBeGreaterThan(
|
||||
computeBdft(baseSqft, baseThick),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TV-10 Rounding", () => {
|
||||
it("half-up / nearest-step matches TECH-SPEC vectors", () => {
|
||||
const fx = tv10Fixture();
|
||||
|
||||
const length = roundLengthIn(fx.lengthRaw);
|
||||
const sqft = roundSqft(fx.sqftRaw);
|
||||
const bdft = roundBdft(fx.bdftRaw);
|
||||
const price = roundPrice(fx.priceRaw);
|
||||
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
case: "TV-10",
|
||||
length: { raw: fx.lengthRaw, rounded: length, expected: fx.lengthExpected },
|
||||
sqft: { raw: fx.sqftRaw, rounded: sqft, expected: fx.sqftExpected },
|
||||
bdft: { raw: fx.bdftRaw, rounded: bdft, expected: fx.bdftExpected },
|
||||
price: { raw: fx.priceRaw, rounded: price, expected: fx.priceExpected },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(length).toBe(fx.lengthExpected);
|
||||
expect(sqft).toBe(fx.sqftExpected);
|
||||
expect(bdft).toBe(fx.bdftExpected);
|
||||
expect(price).toBe(fx.priceExpected);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
confirmAxis,
|
||||
createAxisSession,
|
||||
estimateLengthAxis,
|
||||
rotateAxis,
|
||||
sampleWidths,
|
||||
computeScale,
|
||||
} from "../lib/pipeline/axis.js";
|
||||
import { WIDTH_SAMPLE_IN } from "../lib/pipeline/constants.js";
|
||||
import { tv4RectMask, tv5AngledEndMask } from "./fixtures/axis.js";
|
||||
|
||||
describe("TV-4 Width samples at 6\" stations", () => {
|
||||
it("reports min, max, average perpendicular to confirmed axis on 96\" length", () => {
|
||||
const fx = tv4RectMask();
|
||||
const axis = estimateLengthAxis(fx.mask, fx.width, fx.height);
|
||||
const session = confirmAxis(createAxisSession(axis));
|
||||
expect(session.state).toBe("confirmed");
|
||||
|
||||
const scale = computeScale(session.axis.lengthPx, fx.lengthIn);
|
||||
const stats = sampleWidths(
|
||||
fx.mask,
|
||||
fx.width,
|
||||
fx.height,
|
||||
session.axis,
|
||||
fx.lengthIn,
|
||||
);
|
||||
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
case: "TV-4",
|
||||
lengthPx: session.axis.lengthPx,
|
||||
widthPx: session.axis.widthPx,
|
||||
scale,
|
||||
stations: stats.samples.map((s) => s.stationIn),
|
||||
widths: stats.samples.map((s) => s.widthIn),
|
||||
min: stats.min,
|
||||
max: stats.max,
|
||||
average: stats.average,
|
||||
expectedWidthIn: fx.expectedWidthIn,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(WIDTH_SAMPLE_IN).toBe(6);
|
||||
expect(scale).toBeCloseTo(1, 5);
|
||||
expect(stats.samples.map((s) => s.stationIn)).toEqual(fx.expectedStations);
|
||||
expect(stats.samples.length).toBe(15);
|
||||
for (const s of stats.samples) {
|
||||
expect(s.widthIn).toBeCloseTo(fx.expectedWidthIn, 0);
|
||||
}
|
||||
expect(stats.min).toBeCloseTo(fx.expectedWidthIn, 0);
|
||||
expect(stats.max).toBeCloseTo(fx.expectedWidthIn, 0);
|
||||
expect(stats.average).toBeCloseTo(fx.expectedWidthIn, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TV-5 Angled-end length axis", () => {
|
||||
it("follows prevailing length; widths stay perpendicular after confirm", () => {
|
||||
const fx = tv5AngledEndMask();
|
||||
const axis = estimateLengthAxis(fx.mask, fx.width, fx.height);
|
||||
let session = createAxisSession(axis);
|
||||
expect(session.state).toBe("pending");
|
||||
|
||||
expect(Math.abs(session.axis.direction.x)).toBeGreaterThan(
|
||||
Math.abs(session.axis.direction.y),
|
||||
);
|
||||
expect(session.axis.lengthPx).toBeGreaterThan(session.axis.widthPx);
|
||||
|
||||
session = confirmAxis(session);
|
||||
expect(session.state).toBe("confirmed");
|
||||
|
||||
const stats = sampleWidths(
|
||||
fx.mask,
|
||||
fx.width,
|
||||
fx.height,
|
||||
session.axis,
|
||||
fx.lengthIn,
|
||||
);
|
||||
|
||||
const bodySamples = stats.samples.filter((s) => s.stationIn <= 72);
|
||||
expect(bodySamples.length).toBeGreaterThan(0);
|
||||
for (const s of bodySamples) {
|
||||
expect(s.widthIn).toBeCloseTo(fx.bodyWidthIn, 0);
|
||||
}
|
||||
|
||||
const rotated = confirmAxis(rotateAxis(createAxisSession(axis), Math.PI / 2));
|
||||
expect(Math.abs(rotated.axis.direction.y)).toBeGreaterThan(
|
||||
Math.abs(rotated.axis.direction.x) - 1e-9,
|
||||
);
|
||||
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
case: "TV-5",
|
||||
direction: session.axis.direction,
|
||||
lengthPx: session.axis.lengthPx,
|
||||
widthPx: session.axis.widthPx,
|
||||
bodySampleCount: bodySamples.length,
|
||||
bodyWidths: bodySamples.map((s) => s.widthIn),
|
||||
min: stats.min,
|
||||
max: stats.max,
|
||||
average: stats.average,
|
||||
rotatedDirection: rotated.axis.direction,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,210 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
ASPECT_H,
|
||||
ASPECT_W,
|
||||
FILL_TARGET,
|
||||
OUTPUT_PX_MIN,
|
||||
} from "../lib/pipeline/constants.js";
|
||||
import {
|
||||
computeCropTarget,
|
||||
cropTo34,
|
||||
findSlabBounds,
|
||||
} from "../lib/pipeline/crop.js";
|
||||
import {
|
||||
simpleAxis,
|
||||
tv6LargeSourceFixture,
|
||||
tv7SmallSourceFixture,
|
||||
} from "./fixtures/crop.js";
|
||||
|
||||
describe("TV-6 3:4 crop, 80% fill, transparent PNG, ≥1600px", () => {
|
||||
it("computes crop target dimensions preserving 3:4 aspect and 80% fill at extremes", () => {
|
||||
// Slab 1281×3000 — height limiting, width gets extra margin
|
||||
const t1 = computeCropTarget(1281, 3000);
|
||||
expect(Math.abs(t1.width / t1.height - ASPECT_W / ASPECT_H)).toBeLessThan(0.01);
|
||||
|
||||
// Width-limiting case: slab 2000×1600
|
||||
const t2 = computeCropTarget(2000, 1600);
|
||||
expect(Math.abs(t2.width / t2.height - ASPECT_W / ASPECT_H)).toBeLessThan(0.01);
|
||||
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
case: "TV-6",
|
||||
target1: { slabW: 1281, slabH: 3000, frame: t1, aspect: t1.width / t1.height },
|
||||
target2: { slabW: 2000, slabH: 1600, frame: t2, aspect: t2.width / t2.height },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("finds slab bounding box correctly", () => {
|
||||
const fx = tv6LargeSourceFixture();
|
||||
const bounds = findSlabBounds(fx.mask, fx.width, fx.height);
|
||||
// findSlabBounds returns inclusive coordinates; fixture uses exclusive.
|
||||
expect(bounds.left).toBe(fx.slabLeft);
|
||||
expect(bounds.top).toBe(fx.slabTop);
|
||||
expect(bounds.right).toBe(fx.slabRight - 1);
|
||||
expect(bounds.bottom).toBe(fx.slabBottom - 1);
|
||||
// WidthPx should match slab dimensions
|
||||
expect(bounds.widthPx).toBe(fx.slabRight - fx.slabLeft);
|
||||
expect(bounds.heightPx).toBe(fx.slabBottom - fx.slabTop);
|
||||
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
case: "TV-6",
|
||||
bounds: {
|
||||
left: bounds.left,
|
||||
top: bounds.top,
|
||||
right: bounds.right,
|
||||
bottom: bounds.bottom,
|
||||
widthPx: bounds.widthPx,
|
||||
heightPx: bounds.heightPx,
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("crops large source to 3:4 with 80% fill, shorter side ≥ 1600, transparent PNG", () => {
|
||||
const fx = tv6LargeSourceFixture();
|
||||
const { axis, lengthIn } = simpleAxis(fx.slabRight - fx.slabLeft, fx.slabBottom - fx.slabTop);
|
||||
|
||||
const result = cropTo34(fx.image, fx.mask, axis, lengthIn);
|
||||
|
||||
expect(result.width).toBe(fx.expectedFrameW);
|
||||
expect(result.height).toBe(fx.expectedFrameH);
|
||||
expect(result.meetsMinPx).toBe(true);
|
||||
|
||||
// Verify 3:4 aspect ratio
|
||||
expect(Math.abs(result.width / result.height - ASPECT_W / ASPECT_H)).toBeLessThan(0.01);
|
||||
|
||||
// Verify shorter side ≥ OUTPUT_PX_MIN
|
||||
expect(Math.min(result.width, result.height)).toBeGreaterThanOrEqual(OUTPUT_PX_MIN);
|
||||
|
||||
// Verify transparency: margin pixels have alpha = 0
|
||||
const marginAlpha = result.image.data[3];
|
||||
expect(marginAlpha).toBe(0);
|
||||
|
||||
// Verify slab pixels are opaque (alpha = 255)
|
||||
const slabCenterX = Math.round(result.width / 2);
|
||||
const slabCenterY = Math.round(result.height / 2);
|
||||
const slabCenterAlpha = result.image.data[(slabCenterY * result.width + slabCenterX) * 4 + 3]!;
|
||||
expect(slabCenterAlpha).toBe(255);
|
||||
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
case: "TV-6",
|
||||
frameW: result.width,
|
||||
frameH: result.height,
|
||||
shorterSide: Math.min(result.width, result.height),
|
||||
meetsMinPx: result.meetsMinPx,
|
||||
marginAlpha: marginAlpha,
|
||||
slabCenterAlpha: slabCenterAlpha,
|
||||
aspect: result.width / result.height,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("property: fill at extremes equals FILL_TARGET at limiting dimension", () => {
|
||||
const fx = tv6LargeSourceFixture();
|
||||
const slabW = fx.slabRight - fx.slabLeft;
|
||||
const slabH = fx.slabBottom - fx.slabTop;
|
||||
const target = computeCropTarget(slabW, slabH);
|
||||
const slabAspect = slabW / slabH;
|
||||
const targetAspect = ASPECT_W / ASPECT_H;
|
||||
|
||||
// At the extremes, fill should be FILL_TARGET
|
||||
const fillAtLimitingDim = slabAspect >= targetAspect
|
||||
? slabH / target.height
|
||||
: slabW / target.width;
|
||||
|
||||
expect(fillAtLimitingDim).toBeCloseTo(FILL_TARGET, 3);
|
||||
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
case: "TV-6",
|
||||
fillAtLimitingDim,
|
||||
expectedFill: FILL_TARGET,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TV-7 Too-small source — warn, no upscale, not publishable", () => {
|
||||
it("detects undersized source and refuses upscale", () => {
|
||||
const fx = tv7SmallSourceFixture();
|
||||
const bounds = findSlabBounds(fx.mask, fx.width, fx.height);
|
||||
const target = computeCropTarget(bounds.widthPx, bounds.heightPx);
|
||||
|
||||
expect(Math.min(target.width, target.height)).toBeLessThan(OUTPUT_PX_MIN);
|
||||
expect(target.width).toBe(fx.expectedFrameW);
|
||||
expect(target.height).toBe(fx.expectedFrameH);
|
||||
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
case: "TV-7",
|
||||
slabW: bounds.widthPx,
|
||||
slabH: bounds.heightPx,
|
||||
frameW: target.width,
|
||||
frameH: target.height,
|
||||
shorterSide: Math.min(target.width, target.height),
|
||||
outputPxMin: OUTPUT_PX_MIN,
|
||||
undersized: Math.min(target.width, target.height) < OUTPUT_PX_MIN,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("cropTo34 returns meetsMinPx = false for undersized source", () => {
|
||||
const fx = tv7SmallSourceFixture();
|
||||
const { axis, lengthIn } = simpleAxis(fx.slabRight - fx.slabLeft, fx.slabBottom - fx.slabTop);
|
||||
|
||||
const result = cropTo34(fx.image, fx.mask, axis, lengthIn);
|
||||
|
||||
expect(result.meetsMinPx).toBe(false);
|
||||
expect(Math.min(result.width, result.height)).toBeLessThan(OUTPUT_PX_MIN);
|
||||
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
case: "TV-7",
|
||||
frameW: result.width,
|
||||
frameH: result.height,
|
||||
shorterSide: Math.min(result.width, result.height),
|
||||
meetsMinPx: result.meetsMinPx,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("property: no upscale path — output is never larger than source", () => {
|
||||
// For any source, the crop output dimensions should never exceed
|
||||
// the source dimensions. This ensures no fake upscaling.
|
||||
const fx = tv7SmallSourceFixture();
|
||||
const { axis, lengthIn } = simpleAxis(fx.slabRight - fx.slabLeft, fx.slabBottom - fx.slabTop);
|
||||
const result = cropTo34(fx.image, fx.mask, axis, lengthIn);
|
||||
|
||||
expect(result.width).toBeLessThanOrEqual(fx.width);
|
||||
expect(result.height).toBeLessThanOrEqual(fx.height);
|
||||
|
||||
// Also verify for the large source
|
||||
const fxLarge = tv6LargeSourceFixture();
|
||||
const { axis: axis2, lengthIn: lengthIn2 } = simpleAxis(
|
||||
fxLarge.slabRight - fxLarge.slabLeft,
|
||||
fxLarge.slabBottom - fxLarge.slabTop,
|
||||
);
|
||||
const result2 = cropTo34(fxLarge.image, fxLarge.mask, axis2, lengthIn2);
|
||||
expect(result2.width).toBeLessThanOrEqual(fxLarge.width);
|
||||
expect(result2.height).toBeLessThanOrEqual(fxLarge.height);
|
||||
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
case: "TV-7",
|
||||
noUpscaleCheck: {
|
||||
small: {
|
||||
source: `${fx.width}x${fx.height}`,
|
||||
output: `${result.width}x${result.height}`,
|
||||
},
|
||||
large: {
|
||||
source: `${fxLarge.width}x${fxLarge.height}`,
|
||||
output: `${result2.width}x${result2.height}`,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { computeRecommendedPrice, computeSlabPrice } from "../lib/pipeline/pricing.js";
|
||||
import { tv8Fixture } from "./fixtures/pricing.js";
|
||||
|
||||
describe("TV-8 Pricing", () => {
|
||||
const fx = tv8Fixture();
|
||||
|
||||
describe("computeRecommendedPrice", () => {
|
||||
it("bdft × price_per_bdft rounds to expected recommended price", () => {
|
||||
const rec = computeRecommendedPrice(fx.bdft, fx.pricePerBdft);
|
||||
expect(rec).toBe(fx.expectedRecommended);
|
||||
expect(rec).toBe(12.00);
|
||||
});
|
||||
|
||||
it("0 bdft yields 0", () => {
|
||||
expect(computeRecommendedPrice(0, 99.99)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeSlabPrice — with pricing rule", () => {
|
||||
it("recommended price pre-fills when no override", () => {
|
||||
const result = computeSlabPrice(fx.bdft, {
|
||||
species: "Walnut",
|
||||
pricePerBdft: fx.pricePerBdft,
|
||||
});
|
||||
|
||||
expect(result.recommendedPrice).toBe(fx.expectedRecommended);
|
||||
expect(result.finalPrice).toBe(fx.expectedRecommended);
|
||||
expect(result.priceSource).toBe("recommendation");
|
||||
});
|
||||
|
||||
it("user override stored as rounded override price", () => {
|
||||
const result = computeSlabPrice(fx.bdft, {
|
||||
species: "Walnut",
|
||||
pricePerBdft: fx.pricePerBdft,
|
||||
}, fx.overridePrice);
|
||||
|
||||
expect(result.recommendedPrice).toBe(fx.expectedRecommended);
|
||||
expect(result.finalPrice).toBe(fx.expectedOverride);
|
||||
expect(result.priceSource).toBe("override");
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeSlabPrice — no pricing rule", () => {
|
||||
it("recommended price is null, manual price stored", () => {
|
||||
const result = computeSlabPrice(fx.bdft, undefined, fx.manualPriceNoRule);
|
||||
|
||||
expect(result.recommendedPrice).toBeNull();
|
||||
expect(result.finalPrice).toBe(fx.expectedManual);
|
||||
expect(result.priceSource).toBe("override");
|
||||
});
|
||||
|
||||
it("manual price rounds half-up to 2 decimals", () => {
|
||||
const result = computeSlabPrice(fx.bdft, undefined, 12.345);
|
||||
|
||||
expect(result.finalPrice).toBe(12.35);
|
||||
expect(result.priceSource).toBe("override");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { findThicknessBand } from "../lib/pipeline/thicknessBand.js";
|
||||
import { mockThicknessRanges } from "./fixtures/thicknessBand.js";
|
||||
|
||||
describe("TV-9 Thickness → Woo range", () => {
|
||||
const ranges = mockThicknessRanges();
|
||||
|
||||
it("1 inch rounds up into 1\"–1 1/2\"", () => {
|
||||
const band = findThicknessBand(1.0, ranges);
|
||||
|
||||
expect(band).toBeDefined();
|
||||
expect(band!.wooId).toBe(102);
|
||||
expect(band!.label).toBe('1"–1 1/2"');
|
||||
expect(band!.minIn).toBe(1.0);
|
||||
expect(band!.maxIn).toBe(1.5);
|
||||
});
|
||||
|
||||
it("0.75 inches maps to 3/4\"–1\"", () => {
|
||||
const band = findThicknessBand(0.75, ranges);
|
||||
|
||||
expect(band).toBeDefined();
|
||||
expect(band!.wooId).toBe(101);
|
||||
expect(band!.label).toBe('3/4"–1"');
|
||||
});
|
||||
|
||||
it("0.875 inches maps to 3/4\"–1\"", () => {
|
||||
const band = findThicknessBand(0.875, ranges);
|
||||
|
||||
expect(band).toBeDefined();
|
||||
expect(band!.wooId).toBe(101);
|
||||
});
|
||||
|
||||
it("1.5 inches maps to 1 1/2\"–2\"", () => {
|
||||
const band = findThicknessBand(1.5, ranges);
|
||||
|
||||
expect(band).toBeDefined();
|
||||
expect(band!.wooId).toBe(103);
|
||||
expect(band!.label).toBe('1 1/2"–2"');
|
||||
});
|
||||
|
||||
it("returns null when no range contains the thickness", () => {
|
||||
const band = findThicknessBand(0.5, ranges);
|
||||
expect(band).toBeNull();
|
||||
|
||||
const band2 = findThicknessBand(2.5, ranges);
|
||||
expect(band2).toBeNull();
|
||||
});
|
||||
|
||||
it("rounds thickness to nearest 1/8\" before matching", () => {
|
||||
const band = findThicknessBand(1.01, ranges);
|
||||
|
||||
expect(band).toBeDefined();
|
||||
expect(band!.wooId).toBe(102);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,284 @@
|
||||
/**
|
||||
* Shared validation module — V-1, V-2, V-3.
|
||||
*
|
||||
* V-1 SKU charset [A-Z0-9-]{3,24}.
|
||||
* V-2 Title / short_title / description UTF-8 within WooCommerce maxLength.
|
||||
* V-3 Validation module version hash — client caches module, submit carries hash,
|
||||
* mismatch returns 412 with fresh module (not 409).
|
||||
*
|
||||
* Pure-unit tests; zero browser, DOM, or network dependencies (FUND-1).
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
VALIDATION_MODULE_VERSION,
|
||||
MAX_TITLE_LENGTH,
|
||||
MAX_SHORT_TITLE_LENGTH,
|
||||
MAX_DESCRIPTION_LENGTH,
|
||||
} from "../lib/validation/constants.js";
|
||||
import { validateSku, isSkuValid } from "../lib/validation/sku.js";
|
||||
import {
|
||||
validateContentField,
|
||||
isContentValid,
|
||||
validateTitle,
|
||||
validateShortTitle,
|
||||
validateDescription,
|
||||
} from "../lib/validation/content.js";
|
||||
import {
|
||||
computeModuleHash,
|
||||
validateModuleHash,
|
||||
} from "../lib/validation/hash.js";
|
||||
import {
|
||||
validSkuStrings,
|
||||
invalidSkuStrings,
|
||||
validTitleFixture,
|
||||
invalidTitleFixture,
|
||||
validShortTitleFixture,
|
||||
invalidShortTitleFixture,
|
||||
validDescriptionFixture,
|
||||
invalidDescriptionFixture,
|
||||
expectedHash,
|
||||
staleHash,
|
||||
} from "./fixtures/validation.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// V-1: SKU validation — charset [A-Z0-9-]{3,24}
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("V-1 SKU validation", () => {
|
||||
it("accepts all valid SKU boundary strings", () => {
|
||||
for (const sku of validSkuStrings) {
|
||||
const result = validateSku(sku);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.errors.length).toBe(0);
|
||||
console.log(`V-1 valid: "${sku}" → length=${sku.length}, valid=${result.valid}`);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects all invalid SKU boundary strings", () => {
|
||||
for (const sku of invalidSkuStrings) {
|
||||
const result = validateSku(sku);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.length).toBeGreaterThan(0);
|
||||
console.log(`V-1 invalid: "${sku}" → valid=${result.valid}, errors=${result.errors.join(", ")}`);
|
||||
}
|
||||
});
|
||||
|
||||
it("isSkuValid returns true for valid SKUs and false for invalid SKUs", () => {
|
||||
for (const sku of validSkuStrings) {
|
||||
expect(isSkuValid(sku)).toBe(true);
|
||||
}
|
||||
for (const sku of invalidSkuStrings) {
|
||||
expect(isSkuValid(sku)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects SKU with lowercase letters", () => {
|
||||
expect(isSkuValid("abc")).toBe(false);
|
||||
expect(isSkuValid("bw-0042")).toBe(false);
|
||||
expect(isSkuValid("aB3")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects SKU that is too short or too long", () => {
|
||||
expect(isSkuValid("")).toBe(false);
|
||||
expect(isSkuValid("A")).toBe(false);
|
||||
expect(isSkuValid("AB")).toBe(false);
|
||||
// 25 chars
|
||||
expect(isSkuValid("AAAAAAAAAAAAAAAAAAAAAAAAA")).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts SKU at exact boundaries (3 and 24 characters)", () => {
|
||||
expect(isSkuValid("ABC")).toBe(true);
|
||||
expect(isSkuValid("AAAAAAAAAAAAAAAAAAAAAAAA")).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts hyphens anywhere in the SKU including edges", () => {
|
||||
expect(isSkuValid("A-B")).toBe(true);
|
||||
expect(isSkuValid("-A-")).toBe(true);
|
||||
expect(isSkuValid("A-A-A-A-A-A")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects SKU with spaces, underscores, periods, slashes, backslashes, or other special chars", () => {
|
||||
expect(isSkuValid("A B")).toBe(false);
|
||||
expect(isSkuValid("A_B")).toBe(false);
|
||||
expect(isSkuValid("ABC.D")).toBe(false);
|
||||
expect(isSkuValid("ABC#1")).toBe(false);
|
||||
expect(isSkuValid("ABC/1")).toBe(false);
|
||||
expect(isSkuValid("ABC\\1")).toBe(false);
|
||||
expect(isSkuValid("ABC!")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns specific error codes for different failure reasons", () => {
|
||||
const result = validateSku("");
|
||||
expect(result.errors).toContain("too_short");
|
||||
|
||||
const result2 = validateSku("AAAAAAAAAAAAAAAAAAAAAAAAA");
|
||||
expect(result2.errors).toContain("too_long");
|
||||
|
||||
const result3 = validateSku("abc");
|
||||
expect(result3.errors).toContain("invalid_charset");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// V-2: Title / short_title / description maxLength (UTF-8)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("V-2 content field maxLength validation", () => {
|
||||
it("accepts title at exactly maxLength", () => {
|
||||
const { title, length } = validTitleFixture();
|
||||
expect(length).toBe(MAX_TITLE_LENGTH);
|
||||
const result = validateTitle(title);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.errors.length).toBe(0);
|
||||
console.log(`V-2 title: length=${length}, valid=${result.valid}`);
|
||||
});
|
||||
|
||||
it("rejects title that exceeds maxLength by 1 character", () => {
|
||||
const { title, length } = invalidTitleFixture();
|
||||
expect(length).toBe(MAX_TITLE_LENGTH + 1);
|
||||
const result = validateTitle(title);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.length).toBeGreaterThan(0);
|
||||
console.log(`V-2 title invalid: length=${length}, valid=${result.valid}, errors=${result.errors.join(", ")}`);
|
||||
});
|
||||
|
||||
it("accepts short_title at exactly maxLength", () => {
|
||||
const { shortTitle, length } = validShortTitleFixture();
|
||||
expect(length).toBe(MAX_SHORT_TITLE_LENGTH);
|
||||
const result = validateShortTitle(shortTitle);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.errors.length).toBe(0);
|
||||
console.log(`V-2 short_title: length=${length}, valid=${result.valid}`);
|
||||
});
|
||||
|
||||
it("rejects short_title that exceeds maxLength by 1 character", () => {
|
||||
const { shortTitle, length } = invalidShortTitleFixture();
|
||||
expect(length).toBe(MAX_SHORT_TITLE_LENGTH + 1);
|
||||
const result = validateShortTitle(shortTitle);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.length).toBeGreaterThan(0);
|
||||
console.log(`V-2 short_title invalid: length=${length}, valid=${result.valid}, errors=${result.errors.join(", ")}`);
|
||||
});
|
||||
|
||||
it("accepts description at exactly maxLength", () => {
|
||||
const { description, length } = validDescriptionFixture();
|
||||
expect(length).toBe(MAX_DESCRIPTION_LENGTH);
|
||||
const result = validateDescription(description);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.errors.length).toBe(0);
|
||||
console.log(`V-2 description: length=${length}, valid=${result.valid}`);
|
||||
});
|
||||
|
||||
it("rejects description that exceeds maxLength by 1 character", () => {
|
||||
const { description, length } = invalidDescriptionFixture();
|
||||
expect(length).toBe(MAX_DESCRIPTION_LENGTH + 1);
|
||||
const result = validateDescription(description);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.length).toBeGreaterThan(0);
|
||||
console.log(`V-2 description invalid: length=${length}, valid=${result.valid}, errors=${result.errors.join(", ")}`);
|
||||
});
|
||||
|
||||
it("accepts empty strings for optional content fields", () => {
|
||||
expect(isContentValid({ title: "", shortTitle: "", description: "" })).toBe(true);
|
||||
expect(isContentValid({ title: null, shortTitle: null, description: null })).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts multi-byte UTF-8 characters and counts Unicode code points, not UTF-16 units", () => {
|
||||
const emoji = "🪵";
|
||||
// Each emoji is 1 Unicode char but 2 UTF-16 code units; spread counts code points
|
||||
const longUtf8 = [...emoji.repeat(MAX_TITLE_LENGTH)];
|
||||
const longUtf8Str = longUtf8.join("");
|
||||
const result = validateTitle(longUtf8Str);
|
||||
expect(longUtf8.length).toBe(MAX_TITLE_LENGTH);
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects content exceeding maxLength even with multi-byte UTF-8", () => {
|
||||
const longUtf8 = "🪵".repeat(MAX_TITLE_LENGTH + 1);
|
||||
const result = validateTitle(longUtf8);
|
||||
expect(result.valid).toBe(false);
|
||||
});
|
||||
|
||||
it("isContentValid rejects any field that exceeds its maxLength", () => {
|
||||
const valid = isContentValid({
|
||||
title: validTitleFixture().title,
|
||||
shortTitle: validShortTitleFixture().shortTitle,
|
||||
description: validDescriptionFixture().description,
|
||||
});
|
||||
expect(valid).toBe(true);
|
||||
|
||||
const invalidTitle = isContentValid({
|
||||
title: invalidTitleFixture().title,
|
||||
shortTitle: validShortTitleFixture().shortTitle,
|
||||
description: validDescriptionFixture().description,
|
||||
});
|
||||
expect(invalidTitle).toBe(false);
|
||||
|
||||
const invalidShort = isContentValid({
|
||||
title: validTitleFixture().title,
|
||||
shortTitle: invalidShortTitleFixture().shortTitle,
|
||||
description: validDescriptionFixture().description,
|
||||
});
|
||||
expect(invalidShort).toBe(false);
|
||||
|
||||
const invalidDesc = isContentValid({
|
||||
title: validTitleFixture().title,
|
||||
shortTitle: validShortTitleFixture().shortTitle,
|
||||
description: invalidDescriptionFixture().description,
|
||||
});
|
||||
expect(invalidDesc).toBe(false);
|
||||
});
|
||||
|
||||
it("validateContentField returns field-specific error codes", () => {
|
||||
const { title } = invalidTitleFixture();
|
||||
const result = validateContentField("title", title);
|
||||
expect(result.errors).toContain("exceeds_max_length");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// V-3: Validation module version hash
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("V-3 validation module version hash", () => {
|
||||
it("computes a stable hash from current validation rules", () => {
|
||||
const hash1 = computeModuleHash();
|
||||
const hash2 = computeModuleHash();
|
||||
expect(hash1).toBe(hash2);
|
||||
console.log(`V-3 module hash: "${hash1}"`);
|
||||
expect(hash1).toBe(expectedHash);
|
||||
});
|
||||
|
||||
it("validateModuleHash returns true when client hash matches current module", () => {
|
||||
const currentHash = computeModuleHash();
|
||||
const result = validateModuleHash(currentHash);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.errors.length).toBe(0);
|
||||
expect(result.freshModule).toBeUndefined();
|
||||
console.log(`V-3 match: hash="${currentHash}", valid=${result.valid}`);
|
||||
});
|
||||
|
||||
it("validateModuleHash returns 412 with fresh module when client hash mismatches", () => { {
|
||||
const result = validateModuleHash(staleHash);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.length).toBeGreaterThan(0);
|
||||
expect(result.errors).toContain("validation_module_stale");
|
||||
expect(result.freshModule).toBeDefined();
|
||||
expect(result.freshModule!.hash).toBe(computeModuleHash());
|
||||
expect(result.freshModule!.hash).not.toBe(staleHash);
|
||||
console.log(`V-3 mismatch: clientHash="${staleHash}", serverHash="${computeModuleHash()}", errors=${result.errors.join(", ")}`);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns the correct validation module version", () => {
|
||||
expect(VALIDATION_MODULE_VERSION).toBe("v1");
|
||||
});
|
||||
|
||||
it("fresh module includes current version hash and rules", () => {
|
||||
const result = validateModuleHash(staleHash);
|
||||
expect(result.freshModule!.version).toBe(VALIDATION_MODULE_VERSION);
|
||||
expect(result.freshModule!.rules.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -1,26 +0,0 @@
|
||||
// Supported clients: iOS Safari, Android Chrome, PC Chrome/Edge/Firefox.
|
||||
// Gated on required API presence; anything missing → hard stop.
|
||||
export function supported(): string | null {
|
||||
if (typeof window === 'undefined') return 'window unavailable';
|
||||
if (typeof createImageBitmap !== 'function') return 'createImageBitmap';
|
||||
if (typeof Worker === 'undefined') return 'Worker';
|
||||
if (typeof indexedDB === 'undefined') return 'IndexedDB';
|
||||
const hasOffscreen =
|
||||
typeof OffscreenCanvas !== 'undefined' &&
|
||||
typeof OffscreenCanvas.prototype.convertToBlob === 'function';
|
||||
const hasCanvasBlob =
|
||||
typeof HTMLCanvasElement !== 'undefined' &&
|
||||
typeof HTMLCanvasElement.prototype.toBlob === 'function';
|
||||
if (!hasOffscreen && !hasCanvasBlob) return 'canvas PNG encode';
|
||||
const ua = navigator.userAgent;
|
||||
const isIOS = /iPad|iPhone|iPod/.test(ua) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
|
||||
const isAndroid = /Android/i.test(ua);
|
||||
const isSafari = /Safari/i.test(ua) && !/Chrome|CriOS|Edg|Firefox|FxiOS/i.test(ua);
|
||||
const isChrome = /Chrome|CriOS/i.test(ua) && !/Edg/i.test(ua);
|
||||
const isEdge = /Edg/i.test(ua);
|
||||
const isFirefox = /Firefox|FxiOS/i.test(ua);
|
||||
if (isIOS && isSafari) return null;
|
||||
if (isAndroid && isChrome) return null;
|
||||
if (!isIOS && !isAndroid && (isChrome || isEdge || isFirefox)) return null;
|
||||
return 'unsupported browser';
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { Capture } from './capture.types';
|
||||
import type { TapeTransform } from './tapeGeometry';
|
||||
import { 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;
|
||||
$: tape = state.axis.tape;
|
||||
$: tiltShown = Math.round(tape.tiltDeg * 10) / 10;
|
||||
|
||||
const onTapeChange = (t: TapeTransform) => setTape(t);
|
||||
|
||||
const nudgeTilt = (delta: number) => {
|
||||
setTape({ ...tape, tiltDeg: tape.tiltDeg + delta });
|
||||
};
|
||||
|
||||
const setTilt = (deg: number) => {
|
||||
setTape({ ...tape, tiltDeg: deg });
|
||||
// Keep legacy path warm for any callers still using setAxisAngle.
|
||||
setAxisAngle(deg);
|
||||
};
|
||||
|
||||
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>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}
|
||||
maskBitset={state.approvedMask.bitset}
|
||||
{maskW}
|
||||
{maskH}
|
||||
{tape}
|
||||
{cropFill}
|
||||
onTapeChange={onTapeChange}
|
||||
/>
|
||||
|
||||
<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="0" max="359" step="0.1"
|
||||
value={((tiltShown % 360) + 360) % 360}
|
||||
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">← → tilt 1° · Shift+← → 0.1° · or drag the ↻ handle</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 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 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: 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>
|
||||
@@ -1,102 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy } from 'svelte';
|
||||
import type { Source } from './capture.types';
|
||||
|
||||
export let photos: Source[];
|
||||
export let onAdd: (f: File) => void;
|
||||
export let onRemove: (index: number) => void;
|
||||
export let onContinue: () => void;
|
||||
|
||||
let urls: string[] = [];
|
||||
|
||||
$: {
|
||||
for (const u of urls) URL.revokeObjectURL(u);
|
||||
urls = photos.map((p) => URL.createObjectURL(p.blob));
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
for (const u of urls) URL.revokeObjectURL(u);
|
||||
});
|
||||
|
||||
const pick = (e: Event) => {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const list = input.files;
|
||||
if (!list || list.length === 0) return;
|
||||
// Support multi-select from gallery when the browser allows it.
|
||||
for (let i = 0; i < list.length; i++) onAdd(list[i]);
|
||||
input.value = '';
|
||||
};
|
||||
|
||||
$: canContinue = photos.length >= 1;
|
||||
</script>
|
||||
|
||||
<section class="panel">
|
||||
<h1>Batch photos</h1>
|
||||
<ul class="setup">
|
||||
<li>Sheet is green or black, fills the frame around the slab.</li>
|
||||
<li>Slab fully on the sheet, nothing on top of it.</li>
|
||||
<li>Top-down reference shot: lens parallel to the face.</li>
|
||||
<li>Slab length along the long side of the picture.</li>
|
||||
<li>Add one or more photos of the same slab, then Continue.</li>
|
||||
</ul>
|
||||
|
||||
{#if photos.length > 0}
|
||||
<ul class="thumbs" aria-label="Batch photos">
|
||||
{#each photos as _p, i (urls[i])}
|
||||
<li class="thumb">
|
||||
<img src={urls[i]} alt={`Batch photo ${i + 1}`} />
|
||||
<button type="button" class="rm" aria-label={`Remove photo ${i + 1}`} on:click={() => onRemove(i)}>Remove</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
<p class="empty">No photos yet. Add at least one to continue.</p>
|
||||
{/if}
|
||||
|
||||
<div class="pick">
|
||||
<label class="pick-btn">Take photo
|
||||
<input type="file" accept="image/*" capture="environment" on:change={pick} />
|
||||
</label>
|
||||
<label class="pick-btn">Choose from gallery
|
||||
<input type="file" accept="image/*" multiple on:change={pick} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="continue"
|
||||
type="button"
|
||||
disabled={!canContinue}
|
||||
on:click={onContinue}
|
||||
>Continue</button>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.panel { padding: 24px 16px; color: #eee; }
|
||||
h1 { font-size: 1.4rem; }
|
||||
.setup { color: #bbb; line-height: 1.6; font-size: 0.95rem; }
|
||||
.empty { color: #888; margin: 16px 0; }
|
||||
.thumbs {
|
||||
list-style: none; padding: 0; margin: 16px 0;
|
||||
display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 12px;
|
||||
}
|
||||
.thumb {
|
||||
background: #1e1e1e; border-radius: 8px; overflow: hidden;
|
||||
display: flex; flex-direction: column;
|
||||
}
|
||||
.thumb img { width: 100%; aspect-ratio: 3 / 4; object-fit: cover; display: block; background: #000; }
|
||||
.rm {
|
||||
border: 0; background: #333; color: #ccc; padding: 8px; font-size: 0.85rem; cursor: pointer;
|
||||
}
|
||||
.pick { margin-top: 12px; display: flex; flex-direction: column; gap: 12px; }
|
||||
.pick-btn {
|
||||
background: #00e5ff; color: #002; border: 0; border-radius: 8px;
|
||||
padding: 12px 20px; font-size: 1rem; font-weight: 600;
|
||||
text-align: center; cursor: pointer;
|
||||
}
|
||||
.pick-btn input[type='file'] { display: none; }
|
||||
.continue {
|
||||
margin-top: 20px; width: 100%; padding: 14px; font-size: 1.05rem; border-radius: 10px; border: 0;
|
||||
background: #00e5ff; color: #002; font-weight: 700; cursor: pointer;
|
||||
}
|
||||
.continue:disabled { opacity: 0.35; cursor: not-allowed; }
|
||||
</style>
|
||||
@@ -1,93 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { Capture, Sheet } from './capture.types';
|
||||
import PhotoStage from './PhotoStage.svelte';
|
||||
|
||||
export let state: Extract<Capture, { k: 'mask' }>;
|
||||
export let busy: boolean;
|
||||
export let setSensitivity: (n: number) => void;
|
||||
export let setSheet: (s: Sheet) => void;
|
||||
export let approveMask: () => void;
|
||||
export let retakes: () => void;
|
||||
export let back: () => void;
|
||||
|
||||
const sheets: Sheet[] = ['auto', 'green', 'black'];
|
||||
$: empty = state.overlay !== null && state.overlay.slabPx === 0;
|
||||
$: maskW = state.overlay && state.source.widthPx > 0 ? state.source.widthPx : 0;
|
||||
$: maskH = maskW > 0 && state.overlay ? state.overlay.bitset.length / maskW : 0;
|
||||
$: canApprove = state.overlay !== null && state.overlay.slabPx > 0;
|
||||
</script>
|
||||
|
||||
<section class="panel">
|
||||
<h1>Mask</h1>
|
||||
<div class="stage-wrap">
|
||||
<PhotoStage
|
||||
blob={state.source.blob}
|
||||
maskBitset={state.overlay?.bitset ?? null}
|
||||
{maskW}
|
||||
{maskH}
|
||||
/>
|
||||
{#if busy}<div class="busy">Working…</div>{/if}
|
||||
{#if state.failed}<div class="warn">Mask pass failed. Adjust and retry, or retake the photo.</div>{/if}
|
||||
{#if empty}<div class="warn">No slab pixels in the mask. Adjust sensitivity or retake.</div>{/if}
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<div class="seg" role="group" aria-label="Sheet">
|
||||
{#each sheets as s (s)}
|
||||
<button type="button" class:active={state.control.sheet === s} on:click={() => setSheet(s)}>
|
||||
{s[0].toUpperCase() + s.slice(1)}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="sens">
|
||||
<button type="button" class="thumb" aria-label="Decrease sensitivity" on:click={() => setSensitivity(state.control.sensitivity - 1)}>−</button>
|
||||
<input
|
||||
type="range" min="0" max="100" step="1"
|
||||
value={state.control.sensitivity}
|
||||
on:input={(e) => setSensitivity(Number(e.currentTarget.value))}
|
||||
/>
|
||||
<button type="button" class="thumb" aria-label="Increase sensitivity" on:click={() => setSensitivity(state.control.sensitivity + 1)}>+</button>
|
||||
<span class="val">{state.control.sensitivity}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<button type="button" class="ghost" on:click={back}>Back</button>
|
||||
<button type="button" class="ghost" on:click={retakes}>Retake</button>
|
||||
<button type="button" class="primary" disabled={!canApprove} on:click={approveMask}>
|
||||
Approve mask
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.panel { padding: 16px; color: #eee; }
|
||||
h1 { font-size: 1.3rem; margin: 0 0 12px; }
|
||||
.stage-wrap { position: relative; }
|
||||
.busy {
|
||||
position: absolute; top: 8px; right: 8px; background: rgba(0,0,0,0.7);
|
||||
color: #fff; padding: 4px 10px; border-radius: 6px; font-size: 0.85rem;
|
||||
}
|
||||
.warn { color: #ffb4a2; margin-top: 8px; font-size: 0.9rem; }
|
||||
.controls { margin-top: 16px; display: flex; flex-direction: column; gap: 14px; }
|
||||
.seg { display: flex; gap: 8px; }
|
||||
.seg button {
|
||||
flex: 1; padding: 12px; font-size: 1rem; border-radius: 8px;
|
||||
border: 1px solid #444; background: #1d1d1d; color: #ccc;
|
||||
}
|
||||
.seg button.active { background: #00e5ff; color: #002; font-weight: 700; border-color: #00e5ff; }
|
||||
.sens { display: flex; align-items: center; gap: 10px; }
|
||||
.thumb {
|
||||
width: 48px; height: 48px; border-radius: 50%; font-size: 1.6rem;
|
||||
border: 1px solid #444; background: #1d1d1d; color: #eee; flex: none;
|
||||
}
|
||||
input[type='range'] { flex: 1; }
|
||||
.val { min-width: 2.2ch; text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.row { display: flex; gap: 12px; margin-top: 20px; }
|
||||
.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; }
|
||||
</style>
|
||||
@@ -1,229 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy } from 'svelte';
|
||||
import type { Capture } from './capture.types';
|
||||
import { inToEighths, eighthsToIn } from './capture.types';
|
||||
import { fmtEighths } from './session';
|
||||
import PhotoStage from './PhotoStage.svelte';
|
||||
import {
|
||||
buildPhotosReadyLineup,
|
||||
photosReadyPngAlt,
|
||||
type PhotosReadyLineupItem,
|
||||
} from './photosReadyLineup';
|
||||
|
||||
export let state: Extract<Capture, { k: 'measure' }>;
|
||||
export let setLength: (e: number) => void;
|
||||
export let setThickness: (e: number) => void;
|
||||
export let calculate: () => Promise<void>;
|
||||
export let back: () => void;
|
||||
export let pngUrl: string | null;
|
||||
|
||||
let calcBusy = false;
|
||||
let lengthIn = state.lengthEighths === 0 ? '' : String(eighthsToIn(state.lengthEighths));
|
||||
let thicknessIn = state.thicknessEighths === 0 ? '' : String(eighthsToIn(state.thicknessEighths));
|
||||
|
||||
let lineupUrls: string[] = [];
|
||||
let lineupItems: PhotosReadyLineupItem[] = [];
|
||||
|
||||
$: photosReady = !!state.results;
|
||||
$: {
|
||||
for (const u of lineupUrls) URL.revokeObjectURL(u);
|
||||
lineupUrls = [];
|
||||
if (state.results) {
|
||||
lineupItems = buildPhotosReadyLineup({
|
||||
inventoryPng: state.results.inventoryPng,
|
||||
overlayPng: state.results.overlayPng,
|
||||
batchApply: state.batchApply,
|
||||
sourceBelowFloor: state.results.sourceBelowFloor,
|
||||
});
|
||||
lineupUrls = lineupItems.map((it) =>
|
||||
it.kind === 'png' ? URL.createObjectURL(it.blob) : '',
|
||||
);
|
||||
} else {
|
||||
lineupItems = [];
|
||||
}
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
for (const u of lineupUrls) URL.revokeObjectURL(u);
|
||||
});
|
||||
|
||||
const positiveInches = (raw: string): number | null => {
|
||||
const inches = Number.parseFloat(raw);
|
||||
return Number.isFinite(inches) && inches > 0 ? inches : null;
|
||||
};
|
||||
|
||||
const commitLength = () => {
|
||||
const inches = positiveInches(String(lengthIn));
|
||||
const eighths = inches === null ? 0 : inToEighths(inches);
|
||||
setLength(eighths);
|
||||
lengthIn = eighths === 0 ? '' : String(eighthsToIn(eighths));
|
||||
};
|
||||
const commitThickness = () => {
|
||||
const inches = positiveInches(String(thicknessIn));
|
||||
const eighths = inches === null ? 0 : inToEighths(inches);
|
||||
setThickness(eighths);
|
||||
thicknessIn = eighths === 0 ? '' : String(eighthsToIn(eighths));
|
||||
};
|
||||
|
||||
$: canCalc = positiveInches(String(lengthIn)) !== null && positiveInches(String(thicknessIn)) !== null;
|
||||
|
||||
const doCalc = async () => {
|
||||
calcBusy = true;
|
||||
try {
|
||||
commitLength();
|
||||
commitThickness();
|
||||
await calculate();
|
||||
} finally { calcBusy = false; }
|
||||
};
|
||||
</script>
|
||||
|
||||
<section class="panel">
|
||||
<h1>{photosReady ? 'Photos ready' : 'Measure'}</h1>
|
||||
|
||||
{#if !photosReady}
|
||||
<PhotoStage
|
||||
mode="tape"
|
||||
interactive={false}
|
||||
blob={state.source.blob}
|
||||
tape={state.axis.tape}
|
||||
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}
|
||||
/>
|
||||
|
||||
<div class="fields">
|
||||
<div class="field">
|
||||
<span class="label">Length L</span>
|
||||
<input
|
||||
type="number"
|
||||
aria-label="Length L"
|
||||
inputmode="decimal"
|
||||
step="0.125"
|
||||
min="0"
|
||||
placeholder="0"
|
||||
bind:value={lengthIn}
|
||||
on:blur={commitLength}
|
||||
on:change={commitLength}
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="label">Thickness</span>
|
||||
<input
|
||||
type="number"
|
||||
aria-label="Thickness"
|
||||
inputmode="decimal"
|
||||
step="0.125"
|
||||
min="0"
|
||||
placeholder="0"
|
||||
bind:value={thicknessIn}
|
||||
on:blur={commitThickness}
|
||||
on:change={commitThickness}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<button type="button" class="ghost" on:click={back}>Back</button>
|
||||
<button
|
||||
type="button"
|
||||
class="primary"
|
||||
disabled={calcBusy || !canCalc}
|
||||
on:click={doCalc}
|
||||
>
|
||||
{calcBusy ? 'Calculating…' : 'Calculate'}
|
||||
</button>
|
||||
</div>
|
||||
{:else if state.results}
|
||||
<div class="results" aria-label="Photos ready">
|
||||
<div class="nums">
|
||||
<div><span>Widths</span><span>{fmtEighths(state.results.widths.min)} – {fmtEighths(state.results.widths.max)} (avg {fmtEighths(state.results.widths.avg)})</span></div>
|
||||
<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>
|
||||
{#if state.results.sourceBelowFloor}
|
||||
<div class="warn">Source is below the 800px floor for the inventory PNG. Retake a larger photo.</div>
|
||||
{/if}
|
||||
|
||||
<ol class="lineup" aria-label="Inventory PNG lineup">
|
||||
{#each lineupItems as item, i (i)}
|
||||
{#if item.kind === 'png'}
|
||||
<li class="lineup-item png">
|
||||
<img src={lineupUrls[i]} alt={photosReadyPngAlt(item)} />
|
||||
<span class="role">
|
||||
{#if item.role === 'calibration'}
|
||||
Clean calibration
|
||||
{:else if item.role === 'overlay'}
|
||||
Dimension overlay
|
||||
{:else}
|
||||
Batch photo {(item.batchIndex ?? 0) + 1}
|
||||
{/if}
|
||||
</span>
|
||||
</li>
|
||||
{:else}
|
||||
<li class="lineup-item error" role="alert">
|
||||
<span class="err-title">Photo {item.batchIndex + 1} failed</span>
|
||||
<span class="err-msg">{item.message}</span>
|
||||
</li>
|
||||
{/if}
|
||||
{/each}
|
||||
</ol>
|
||||
|
||||
<!-- Legacy single-PNG fallback only when lineup helper yielded nothing but pngUrl exists -->
|
||||
{#if lineupItems.length === 0 && pngUrl && !state.results.sourceBelowFloor}
|
||||
<div class="png-wrap">
|
||||
<img src={pngUrl} alt="Inventory PNG" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<button type="button" class="ghost" on:click={back}>Back</button>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.panel { padding: 16px; color: #eee; }
|
||||
h1 { font-size: 1.3rem; margin: 0 0 12px; }
|
||||
.fields { display: flex; gap: 16px; margin-top: 16px; }
|
||||
.field { flex: 1; }
|
||||
.label { display: block; font-size: 0.9rem; color: #aaa; margin-bottom: 8px; }
|
||||
.field input {
|
||||
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;
|
||||
}
|
||||
.row { display: flex; gap: 10px; margin-top: 20px; }
|
||||
.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 span:first-child { color: #aaa; }
|
||||
.warn { color: #ffb4a2; margin-top: 12px; font-size: 0.9rem; }
|
||||
.lineup {
|
||||
list-style: none; padding: 0; margin: 16px 0 0;
|
||||
display: flex; flex-direction: column; gap: 14px;
|
||||
}
|
||||
.lineup-item.png {
|
||||
text-align: center;
|
||||
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;
|
||||
background-color: #eee; border-radius: 8px; padding: 8px;
|
||||
}
|
||||
.lineup-item.png img { max-width: 100%; border-radius: 4px; display: block; margin: 0 auto; }
|
||||
.role { display: block; margin-top: 8px; font-size: 0.85rem; color: #222; font-weight: 600; }
|
||||
.lineup-item.error {
|
||||
border: 1px solid #8a3a2a; background: #2a1512; border-radius: 8px;
|
||||
padding: 12px 14px; display: flex; flex-direction: column; gap: 4px;
|
||||
}
|
||||
.err-title { color: #ffb4a2; font-weight: 700; font-size: 0.95rem; }
|
||||
.err-msg { color: #e8cfc8; font-size: 0.9rem; }
|
||||
.png-wrap { margin-top: 16px; text-align: center; 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; background-color: #eee; border-radius: 8px; padding: 8px; }
|
||||
.png-wrap img { max-width: 100%; border-radius: 4px; }
|
||||
</style>
|
||||
@@ -1,396 +0,0 @@
|
||||
<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, 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.
|
||||
// Geometry must use the same EXIF-oriented bitmap space as the mask worker (#72).
|
||||
export let blob: Blob;
|
||||
export let maxH = 420;
|
||||
export let bbox: BBox | null = null;
|
||||
export let maskBitset: Uint8Array | null = null;
|
||||
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;
|
||||
|
||||
let canvas: HTMLCanvasElement | undefined;
|
||||
let stage: HTMLDivElement | undefined;
|
||||
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;
|
||||
canvas.style.width = `${fit.fitW}px`;
|
||||
canvas.style.height = `${fit.fitH}px`;
|
||||
};
|
||||
|
||||
const paintOverlays = (ctx: CanvasRenderingContext2D) => {
|
||||
if (!canvas || mode === 'tape') return;
|
||||
if (maskBitset && maskW > 0 && maskH > 0) {
|
||||
const off = document.createElement('canvas');
|
||||
off.width = maskW;
|
||||
off.height = maskH;
|
||||
const octx = off.getContext('2d')!;
|
||||
const d = octx.createImageData(maskW, maskH);
|
||||
const tile = 8;
|
||||
for (let p = 0; p < maskW * maskH; p++) {
|
||||
if (!maskBitset[p]) {
|
||||
const x = p % maskW;
|
||||
const y = (p / maskW) | 0;
|
||||
const light = ((x / tile) | 0) % 2 === ((y / tile) | 0) % 2;
|
||||
d.data[p * 4] = light ? 220 : 180;
|
||||
d.data[p * 4 + 1] = light ? 220 : 180;
|
||||
d.data[p * 4 + 2] = light ? 220 : 180;
|
||||
d.data[p * 4 + 3] = 220;
|
||||
}
|
||||
}
|
||||
octx.putImageData(d, 0, 0);
|
||||
ctx.drawImage(off, 0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
if (bbox && bbox.corners.length === 4) {
|
||||
const c = bbox.corners;
|
||||
ctx.strokeStyle = '#00e5ff';
|
||||
ctx.lineWidth = Math.max(2, canvas.width / 400);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(c[0][0], c[0][1]);
|
||||
for (let i = 1; i < 4; i++) ctx.lineTo(c[i][0], c[i][1]);
|
||||
ctx.closePath();
|
||||
ctx.stroke();
|
||||
const a = c[3];
|
||||
const b = c[0];
|
||||
ctx.strokeStyle = '#ff3d71';
|
||||
ctx.lineWidth = Math.max(3, canvas.width / 300);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(a[0], a[1]);
|
||||
ctx.lineTo(b[0], b[1]);
|
||||
ctx.stroke();
|
||||
ctx.fillStyle = '#ff3d71';
|
||||
ctx.beginPath();
|
||||
const ang = Math.atan2(b[1] - a[1], b[0] - a[0]);
|
||||
const r = Math.max(10, canvas.width / 60);
|
||||
ctx.moveTo(b[0], b[1]);
|
||||
ctx.lineTo(b[0] - r * Math.cos(ang - 0.4), b[1] - r * Math.sin(ang - 0.4));
|
||||
ctx.lineTo(b[0] - r * Math.cos(ang + 0.4), b[1] - r * Math.sin(ang + 0.4));
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
}
|
||||
};
|
||||
|
||||
const redraw = async () => {
|
||||
if (!blob || !canvas) return;
|
||||
const gen = ++decodeGen;
|
||||
decoding = true;
|
||||
let bmp: ImageBitmap | null = null;
|
||||
try {
|
||||
bmp = await decodeOrientedBitmap(blob);
|
||||
if (gen !== decodeGen || !canvas) return;
|
||||
imgW = bmp.width;
|
||||
imgH = bmp.height;
|
||||
canvas.width = imgW;
|
||||
canvas.height = imgH;
|
||||
applyFit();
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.drawImage(bmp, 0, 0);
|
||||
paintOverlays(ctx);
|
||||
} finally {
|
||||
if (bmp) bmp.close();
|
||||
if (gen === decodeGen) decoding = false;
|
||||
}
|
||||
};
|
||||
|
||||
$: if (blob || bbox || maskBitset || mode) void tick().then(redraw);
|
||||
|
||||
// ---- tape pointer interactions ----
|
||||
type DragKind = null | 'pan' | 'tickA' | 'tickB' | 'rot';
|
||||
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;
|
||||
|
||||
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 hitRot = (x: number, y: number): boolean => {
|
||||
const cx = STAGE_W / 2;
|
||||
const cy = STAGE_H - 26;
|
||||
return Math.hypot(x - cx, y - cy) <= 22;
|
||||
};
|
||||
|
||||
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()];
|
||||
pinchStartDist = Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y) || 1;
|
||||
pinchStartZoom = live.zoom;
|
||||
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;
|
||||
}
|
||||
if (hitRot(p.x, p.y)) {
|
||||
drag = { kind: 'rot', x0: p.x, y0: p.y, panX0: live.panX, panY0: live.panY, tilt0: live.tiltDeg };
|
||||
return;
|
||||
}
|
||||
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 dist = Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y) || 1;
|
||||
const z = Math.min(3, Math.max(0.5, pinchStartZoom * (dist / pinchStartDist)));
|
||||
emit({ ...live, zoom: z });
|
||||
return;
|
||||
}
|
||||
if (!drag) return;
|
||||
const p = stagePoint(e.clientX, e.clientY);
|
||||
if (drag.kind === 'pan') {
|
||||
emit({ ...live, panX: drag.panX0 + (p.x - drag.x0), panY: drag.panY0 + (p.y - drag.y0) });
|
||||
} 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 });
|
||||
} else if (drag.kind === 'rot') {
|
||||
const cx = STAGE_W / 2;
|
||||
const cy = STAGE_H / 2;
|
||||
const a0 = Math.atan2(drag.y0 - cy, drag.x0 - cx);
|
||||
const a1 = Math.atan2(p.y - cy, p.x - cx);
|
||||
const delta = ((a1 - a0) * 180) / Math.PI;
|
||||
emit({ ...live, tiltDeg: drag.tilt0 + delta });
|
||||
}
|
||||
};
|
||||
|
||||
const onPointerUp = (e: PointerEvent) => {
|
||||
pointers.delete(e.pointerId);
|
||||
if (pointers.size < 2) drag = null;
|
||||
};
|
||||
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
if (!interactive || mode !== 'tape') 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') {
|
||||
e.preventDefault();
|
||||
const step = fine ? 0.1 : 1;
|
||||
const dir = e.key === 'ArrowLeft' ? -1 : 1;
|
||||
emit({ ...live, tiltDeg: 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 === '=') {
|
||||
e.preventDefault();
|
||||
emit({ ...live, zoom: Math.min(3, live.zoom * 1.1) });
|
||||
} else if (e.key === '-' || e.key === '_') {
|
||||
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(() => {
|
||||
decodeGen += 1;
|
||||
});
|
||||
</script>
|
||||
|
||||
{#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}
|
||||
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}
|
||||
{#if interactive}
|
||||
<g class="rot-handle">
|
||||
<circle cx={STAGE_W / 2} cy={STAGE_H - 26} r="17" fill="rgba(0,0,0,.6)" stroke="#00e5ff" stroke-width="1.5" />
|
||||
<text x={STAGE_W / 2} y={STAGE_H - 21} fill="#00e5ff" font-size="15" text-anchor="middle">↻</text>
|
||||
</g>
|
||||
{/if}
|
||||
</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; 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; }
|
||||
.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; }
|
||||
.rot-handle { pointer-events: auto; cursor: ew-resize; }
|
||||
</style>
|
||||
@@ -1,66 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy } from 'svelte';
|
||||
import type { Source } from './capture.types';
|
||||
|
||||
export let photos: Source[];
|
||||
export let onPick: (index: number) => void;
|
||||
export let onBack: () => void;
|
||||
|
||||
let urls: string[] = [];
|
||||
|
||||
$: {
|
||||
for (const u of urls) URL.revokeObjectURL(u);
|
||||
urls = photos.map((p) => URL.createObjectURL(p.blob));
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
for (const u of urls) URL.revokeObjectURL(u);
|
||||
});
|
||||
</script>
|
||||
|
||||
<section class="panel">
|
||||
<h1>Pick calibration photo</h1>
|
||||
<p class="hint">
|
||||
Choose which photo is the top-down reference for length and thickness.
|
||||
Mask, length axis, and Measure run on that photo only.
|
||||
</p>
|
||||
|
||||
<ul class="thumbs" aria-label="Calibration candidates">
|
||||
{#each photos as _p, i (urls[i])}
|
||||
<li class="thumb">
|
||||
<img src={urls[i]} alt={`Photo ${i + 1}`} />
|
||||
<button
|
||||
type="button"
|
||||
class="pick"
|
||||
aria-label={`Use photo ${i + 1} as calibration`}
|
||||
on:click={() => onPick(i)}
|
||||
>Use as calibration</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
<button class="back" type="button" on:click={onBack}>Back</button>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.panel { padding: 24px 16px; color: #eee; }
|
||||
h1 { font-size: 1.4rem; }
|
||||
.hint { color: #bbb; line-height: 1.5; font-size: 0.95rem; }
|
||||
.thumbs {
|
||||
list-style: none; padding: 0; margin: 16px 0;
|
||||
display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 12px;
|
||||
}
|
||||
.thumb {
|
||||
background: #1e1e1e; border-radius: 8px; overflow: hidden;
|
||||
display: flex; flex-direction: column;
|
||||
}
|
||||
.thumb img { width: 100%; aspect-ratio: 3 / 4; object-fit: cover; display: block; background: #000; }
|
||||
.pick {
|
||||
border: 0; background: #00e5ff; color: #002; padding: 10px 8px;
|
||||
font-size: 0.9rem; font-weight: 700; cursor: pointer;
|
||||
}
|
||||
.back {
|
||||
margin-top: 8px; width: 100%; padding: 12px; font-size: 1rem; border-radius: 10px;
|
||||
border: 1px solid #444; background: transparent; color: #ccc; cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
@@ -1,28 +0,0 @@
|
||||
<script lang="ts">
|
||||
export let onContinue: () => void;
|
||||
export let onFresh: () => void;
|
||||
</script>
|
||||
|
||||
<section class="panel">
|
||||
<h1>Resume?</h1>
|
||||
<p>You have an in-progress slab on this device.</p>
|
||||
<div class="row">
|
||||
<button class="primary" type="button" on:click={onContinue}>Continue</button>
|
||||
<button class="ghost" type="button" on:click={onFresh}>Start fresh</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.panel { padding: 24px 16px; text-align: center; color: #eee; }
|
||||
h1 { font-size: 1.4rem; }
|
||||
p { color: #aaa; }
|
||||
.row { display: flex; gap: 12px; justify-content: center; margin-top: 24px; }
|
||||
.primary {
|
||||
flex: 1; max-width: 200px; padding: 14px; font-size: 1.05rem; border-radius: 10px; border: 0;
|
||||
background: #00e5ff; color: #002; font-weight: 700;
|
||||
}
|
||||
.ghost {
|
||||
flex: 1; max-width: 200px; padding: 14px; border-radius: 10px; border: 1px solid #444;
|
||||
background: transparent; color: #ccc;
|
||||
}
|
||||
</style>
|
||||
@@ -1,182 +0,0 @@
|
||||
// Domain model for the capture flow (M1 + M2 batch).
|
||||
// The Capture tagged union is the state machine: each branch carries exactly the
|
||||
// data its state can hold, so an illegal state is a type error.
|
||||
|
||||
// Integer eighths of an inch; divide by 8 only at display.
|
||||
export type Eighths = number;
|
||||
|
||||
export const inToEighths = (inches: number): Eighths => Math.round(inches * 8);
|
||||
export const eighthsToIn = (e: Eighths): number => e / 8;
|
||||
|
||||
// Source: all geometry, measurement, and PNG work uses source pixels, never the
|
||||
// CSS size of the preview.
|
||||
export interface Source {
|
||||
widthPx: number;
|
||||
heightPx: number;
|
||||
blob: Blob;
|
||||
}
|
||||
|
||||
export type Sheet = 'auto' | 'green' | 'black';
|
||||
export interface MaskParams {
|
||||
sheet: Sheet;
|
||||
sensitivity: number; // 0–100, step 1
|
||||
}
|
||||
|
||||
// Axis-aligned box, source px.
|
||||
export interface ABox {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
// 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 prefers tape tick span (#99)
|
||||
angleDeg: number; // long-side angle, degrees, [0, 180)
|
||||
}
|
||||
|
||||
// The approved mask is the footprint for axis, measurement, and the inventory PNG.
|
||||
export interface Mask {
|
||||
params: MaskParams;
|
||||
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 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;
|
||||
}
|
||||
|
||||
export interface Axis {
|
||||
bbox: BBox;
|
||||
tape: TapeLine;
|
||||
}
|
||||
|
||||
export interface Widths {
|
||||
min: Eighths;
|
||||
max: Eighths;
|
||||
avg: Eighths;
|
||||
every6In: Eighths[];
|
||||
}
|
||||
|
||||
export interface Measures {
|
||||
lengthEighths: Eighths;
|
||||
thicknessEighths: Eighths;
|
||||
scale: number; // px per inch
|
||||
widths: Widths;
|
||||
sqft: number; // 2 dp
|
||||
bdft: number; // 2 dp
|
||||
inventoryPng: Blob;
|
||||
/** Dimension-overlay copy of the calibration inventory PNG (#63). */
|
||||
overlayPng?: Blob;
|
||||
sourceBelowFloor: 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: 'error'; message: string };
|
||||
|
||||
// The machine. 'resume' exists only while a draft exists and has not been chosen.
|
||||
// M2: 'batch' + 'pickCalibration' replace M1 'addPhoto' — accumulate, then user picks calibration.
|
||||
export type Capture =
|
||||
| { k: 'resume'; draft: Draft }
|
||||
| { k: 'batch'; photos: Source[] }
|
||||
| { k: 'pickCalibration'; photos: Source[] }
|
||||
| {
|
||||
k: 'mask';
|
||||
source: Source;
|
||||
control: MaskParams; // immediate control value (what the UI shows)
|
||||
overlay: Mask | null; // last settled mask; null until first pass
|
||||
failed: boolean;
|
||||
}
|
||||
| {
|
||||
k: 'axis';
|
||||
source: Source;
|
||||
approvedMask: Mask; // frozen footprint
|
||||
axis: Axis; // live axis.bbox being nudged
|
||||
}
|
||||
| {
|
||||
k: 'measure';
|
||||
source: Source;
|
||||
approvedMask: Mask;
|
||||
axis: Axis; // frozen
|
||||
lengthEighths: Eighths;
|
||||
thicknessEighths: Eighths;
|
||||
results: Measures | null;
|
||||
/** Capture-order sibling results from the post-Calculate batch pass. */
|
||||
batchApply?: BatchPhotoResult[];
|
||||
};
|
||||
|
||||
// Placeholder geometry for batch-only drafts (no mask yet).
|
||||
export const EMPTY_BBOX: BBox = {
|
||||
corners: [
|
||||
[0, 0],
|
||||
[0, 0],
|
||||
[0, 0],
|
||||
[0, 0],
|
||||
],
|
||||
longSidePx: 0,
|
||||
angleDeg: 0,
|
||||
};
|
||||
export const EMPTY_ABOX: ABox = { x: 0, y: 0, w: 0, h: 0 };
|
||||
export const DEFAULT_MASK_PARAMS: MaskParams = { sheet: 'auto', sensitivity: 50 };
|
||||
|
||||
// Device draft (IndexedDB): one in-progress slab. Small by design — the bitset
|
||||
// and PNG are re-derived from stored data, never stored second-hand.
|
||||
// M2 extends with `batch` (capture-order photo blobs). Legacy M1 drafts omit it.
|
||||
export interface Draft {
|
||||
version: 1;
|
||||
stage: 'batch' | 'pickCalibration' | 'mask' | 'axis' | 'measure'; // last approved / persisted state
|
||||
/** Capture-order batch. Absent on legacy M1 drafts ⇒ treat as [source]. */
|
||||
batch?: Source[];
|
||||
/** Index into batch of the user-picked calibration photo. Set once picked. */
|
||||
calibrationIndex?: number;
|
||||
source: Source; // calibration / active photo; for stage==='batch'|'pickCalibration' = photos[0] placeholder
|
||||
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;
|
||||
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). */
|
||||
overlayPng?: Blob | null;
|
||||
/** Capture-order batch apply results; blobs are retained for the #64 lineup. */
|
||||
batchApply?: BatchPhotoResult[];
|
||||
}
|
||||
|
||||
// Worker protocol. Job ids increase; the worker drops anything older than the
|
||||
// current id, so a stale result can never blank a newer one.
|
||||
export type WorkerIn =
|
||||
| { id: number; kind: 'init'; blob: Blob }
|
||||
| { id: number; kind: 'mask'; sheet: Sheet; sensitivity: number }
|
||||
| { id: number; kind: 'invalidate' };
|
||||
|
||||
export type WorkerOut =
|
||||
| { id: number; kind: 'decoded'; w: number; h: number }
|
||||
| {
|
||||
id: number;
|
||||
kind: 'ready';
|
||||
slabPx: number;
|
||||
bitset: Uint8Array;
|
||||
bbox: BBox;
|
||||
slabBounds: ABox;
|
||||
}
|
||||
| { id: number; kind: 'failed'; reason: string };
|
||||
@@ -1,149 +0,0 @@
|
||||
import { test, afterEach } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
import {
|
||||
decodeOrientedBitmap,
|
||||
ORIENTED_BITMAP_OPTIONS,
|
||||
} from '../decodeOrientedBitmap.ts';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const fixturePath = join(here, '../../tests/fixtures/set1-48x2/face-a.jpeg');
|
||||
|
||||
const RealCreateImageBitmap = globalThis.createImageBitmap;
|
||||
|
||||
afterEach(() => {
|
||||
if (RealCreateImageBitmap === undefined) {
|
||||
delete (globalThis as unknown as { createImageBitmap?: unknown }).createImageBitmap;
|
||||
} else {
|
||||
globalThis.createImageBitmap = RealCreateImageBitmap;
|
||||
}
|
||||
});
|
||||
|
||||
/** Minimal EXIF Orientation + largest SOF size for the orient-8 fixture. */
|
||||
function readJpegOrientAndSof(buf: Buffer): { orientation: number; sofW: number; sofH: number } {
|
||||
let orientation = 1;
|
||||
const exif = buf.indexOf(Buffer.from('Exif\0\0'));
|
||||
if (exif >= 0) {
|
||||
const tiff = exif + 6;
|
||||
const le = buf[tiff] === 0x49;
|
||||
const u16 = (o: number) => (le ? buf.readUInt16LE(o) : buf.readUInt16BE(o));
|
||||
const u32 = (o: number) => (le ? buf.readUInt32LE(o) : buf.readUInt32BE(o));
|
||||
const ifd = tiff + u32(tiff + 4);
|
||||
const n = u16(ifd);
|
||||
for (let k = 0; k < n; k++) {
|
||||
const e = ifd + 2 + k * 12;
|
||||
const tag = u16(e);
|
||||
const typ = u16(e + 2);
|
||||
if (tag === 0x0112) {
|
||||
orientation = typ === 3 ? u16(e + 8) : u32(e + 8);
|
||||
}
|
||||
}
|
||||
}
|
||||
let sofW = 0;
|
||||
let sofH = 0;
|
||||
let i = 0;
|
||||
while (i < buf.length - 9) {
|
||||
if (buf[i] === 0xff && buf[i + 1] >= 0xc0 && buf[i + 1] <= 0xc3) {
|
||||
const h = buf.readUInt16BE(i + 5);
|
||||
const w = buf.readUInt16BE(i + 7);
|
||||
if (w * h > sofW * sofH) {
|
||||
sofW = w;
|
||||
sofH = h;
|
||||
}
|
||||
const segLen = buf.readUInt16BE(i + 2);
|
||||
i += 2 + segLen;
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
return { orientation, sofW, sofH };
|
||||
}
|
||||
|
||||
/** Orientation 5–8 swap width/height for display. */
|
||||
function orientedSize(sofW: number, sofH: number, orientation: number): { w: number; h: number } {
|
||||
if (orientation >= 5 && orientation <= 8) return { w: sofH, h: sofW };
|
||||
return { w: sofW, h: sofH };
|
||||
}
|
||||
|
||||
test('fixture face-a.jpeg is EXIF Orientation 8 (stored landscape → oriented portrait) (#72)', () => {
|
||||
const buf = readFileSync(fixturePath);
|
||||
const { orientation, sofW, sofH } = readJpegOrientAndSof(buf);
|
||||
assert.equal(orientation, 8, 'matched-set JPEGs carry Orientation 8');
|
||||
assert.equal(sofW, 4000);
|
||||
assert.equal(sofH, 2256);
|
||||
const { w, h } = orientedSize(sofW, sofH, orientation);
|
||||
assert.equal(w, 2256);
|
||||
assert.equal(h, 4000);
|
||||
});
|
||||
|
||||
test('decodeOrientedBitmap always requests imageOrientation from-image (#72)', async () => {
|
||||
let seen: ImageBitmapOptions | undefined;
|
||||
(globalThis as unknown as { createImageBitmap: typeof createImageBitmap }).createImageBitmap =
|
||||
async (_src: ImageBitmapSource, opts?: ImageBitmapOptions) => {
|
||||
seen = opts;
|
||||
return { width: 2256, height: 4000, close() {} } as ImageBitmap;
|
||||
};
|
||||
|
||||
const blob = new Blob([readFileSync(fixturePath)], { type: 'image/jpeg' });
|
||||
const bmp = await decodeOrientedBitmap(blob);
|
||||
assert.deepEqual(seen, ORIENTED_BITMAP_OPTIONS);
|
||||
assert.equal(ORIENTED_BITMAP_OPTIONS.imageOrientation, 'from-image');
|
||||
assert.equal(bmp.width, 2256);
|
||||
assert.equal(bmp.height, 4000);
|
||||
});
|
||||
|
||||
test('decodeOrientedBitmap falls back to bare createImageBitmap if option rejected (#72)', async () => {
|
||||
let calls = 0;
|
||||
(globalThis as unknown as { createImageBitmap: typeof createImageBitmap }).createImageBitmap =
|
||||
async (_src: ImageBitmapSource, opts?: ImageBitmapOptions) => {
|
||||
calls += 1;
|
||||
if (opts && 'imageOrientation' in opts) {
|
||||
throw new TypeError('imageOrientation unsupported');
|
||||
}
|
||||
return { width: 90, height: 160, close() {} } as ImageBitmap;
|
||||
};
|
||||
|
||||
const bmp = await decodeOrientedBitmap(new Blob([new Uint8Array([1, 2, 3])], { type: 'image/jpeg' }));
|
||||
assert.equal(calls, 2, 'retry bare after option rejection');
|
||||
assert.equal(bmp.width, 90);
|
||||
assert.equal(bmp.height, 160);
|
||||
});
|
||||
|
||||
test('decodeOrientedBitmap: Chromium orients fixture to 2256×4000 (#72)', async (t) => {
|
||||
let chromium: typeof import('@playwright/test').chromium;
|
||||
try {
|
||||
({ chromium } = await import('@playwright/test'));
|
||||
} catch {
|
||||
t.skip('playwright not available');
|
||||
return;
|
||||
}
|
||||
const browser = await chromium.launch();
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
const buf = readFileSync(fixturePath);
|
||||
const b64 = buf.toString('base64');
|
||||
// Inline the same contract as decodeOrientedBitmap (worker-safe, no bundler).
|
||||
const dims = await page.evaluate(async (b64jpeg: string) => {
|
||||
const bin = atob(b64jpeg);
|
||||
const arr = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i);
|
||||
const blob = new Blob([arr], { type: 'image/jpeg' });
|
||||
const opts = { imageOrientation: 'from-image' as const };
|
||||
let bmp: ImageBitmap;
|
||||
try {
|
||||
bmp = await createImageBitmap(blob, opts);
|
||||
} catch {
|
||||
bmp = await createImageBitmap(blob);
|
||||
}
|
||||
const out = { w: bmp.width, h: bmp.height };
|
||||
bmp.close();
|
||||
return out;
|
||||
}, b64);
|
||||
assert.equal(dims.w, 2256, `oriented width (got ${dims.w})`);
|
||||
assert.equal(dims.h, 4000, `oriented height (got ${dims.h})`);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
});
|
||||
@@ -1,26 +0,0 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { fitDisplay } from './fitDisplay.ts';
|
||||
|
||||
test('parentW=0 → null (no size until laid out)', () => {
|
||||
assert.equal(fitDisplay(0, 1000, 750, 420), null);
|
||||
assert.equal(fitDisplay(-5, 1000, 750, 420), null);
|
||||
});
|
||||
|
||||
test('280 wide, 4:3 landscape → contain, under the height cap', () => {
|
||||
const fit = fitDisplay(280, 1600, 1200, 420);
|
||||
assert.ok(fit);
|
||||
assert.equal(fit.fitW, 280);
|
||||
assert.ok(Math.abs(fit.fitH - 210) < 1e-9);
|
||||
assert.ok(fit.fitH <= 420);
|
||||
});
|
||||
|
||||
test('portrait under maxH never stretches: height capped, width shrinks', () => {
|
||||
// 1000 wide source, 3000 tall. Full-width fit would be 900 tall > 420.
|
||||
const fit = fitDisplay(400, 1000, 3000, 420);
|
||||
assert.ok(fit);
|
||||
assert.equal(fit.fitH, 420);
|
||||
assert.ok(Math.abs(fit.fitW - 140) < 1e-9);
|
||||
// aspect preserved: fitW/fitH === imgW/imgH
|
||||
assert.ok(Math.abs(fit.fitW / fit.fitH - 1000 / 3000) < 1e-9);
|
||||
});
|
||||
@@ -1,17 +0,0 @@
|
||||
// Contain-fit of an image into a parent width with a height cap. Pure and
|
||||
// display-only; all measurement math stays on source pixels.
|
||||
// Null when the parent is not laid out yet (clientWidth 0) — the caller must
|
||||
// not finalize CSS size until it is, to avoid a wrong-aspect flash.
|
||||
export function fitDisplay(
|
||||
parentW: number, imgW: number, imgH: number, maxH: number,
|
||||
): { fitW: number; fitH: number } | null {
|
||||
if (parentW <= 0 || imgW <= 0 || imgH <= 0) return null;
|
||||
const fitW = parentW;
|
||||
let fitH = (imgH / imgW) * fitW;
|
||||
if (fitH > maxH) {
|
||||
// Cap height and shrink width to preserve aspect; never stretch.
|
||||
fitH = maxH;
|
||||
return { fitW: (fitH * imgW) / imgH, fitH };
|
||||
}
|
||||
return { fitW, fitH };
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
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';
|
||||
import { inToEighths } from './capture.types';
|
||||
|
||||
const panelSrc = readFileSync(
|
||||
join(dirname(fileURLToPath(import.meta.url)), 'MeasurePanel.svelte'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
test('MeasurePanel exposes typed number inputs for Length L and Thickness', () => {
|
||||
const inputs = panelSrc.match(/<input\b[^>]*>/g) ?? [];
|
||||
assert.ok(inputs.length >= 2, 'expected at least two <input> fields');
|
||||
assert.ok(
|
||||
inputs.some((el) => /type="number"/.test(el) && /aria-label="Length L"/.test(el)),
|
||||
'Length L must be a type=number input',
|
||||
);
|
||||
assert.ok(
|
||||
inputs.some((el) => /type="number"/.test(el) && /aria-label="Thickness"/.test(el)),
|
||||
'Thickness must be a type=number input',
|
||||
);
|
||||
assert.equal(
|
||||
panelSrc.includes('class="stepper"'),
|
||||
false,
|
||||
'stepper-only UI is the UAT fail; primary control must be typed input',
|
||||
);
|
||||
assert.equal(
|
||||
/<span class="v">\{fmtEighths\(state\.lengthEighths\)\}<\/span>/.test(panelSrc),
|
||||
false,
|
||||
'Length must not be a read-only fmtEighths span',
|
||||
);
|
||||
});
|
||||
|
||||
test('typed inches snap to nearest 1/8 via inToEighths', () => {
|
||||
assert.equal(inToEighths(12), 96);
|
||||
assert.equal(inToEighths(12.1), 97);
|
||||
assert.equal(inToEighths(0.06), 0);
|
||||
assert.equal(inToEighths(0.07), 1);
|
||||
assert.equal(inToEighths(1.999), 16);
|
||||
});
|
||||
@@ -1,207 +0,0 @@
|
||||
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';
|
||||
import type { BatchPhotoResult } from './capture.types';
|
||||
import {
|
||||
buildPhotosReadyLineup,
|
||||
type PhotosReadyLineupItem,
|
||||
} from './photosReadyLineup';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
function png(label: string): Blob {
|
||||
return new Blob([label], { type: 'image/png' });
|
||||
}
|
||||
|
||||
function jpeg(label: string): Blob {
|
||||
return new Blob([label], { type: 'image/jpeg' });
|
||||
}
|
||||
|
||||
function roles(items: PhotosReadyLineupItem[]): string[] {
|
||||
return items.map((it) =>
|
||||
it.kind === 'png' ? `png:${it.role}` : `error:${it.batchIndex}`,
|
||||
);
|
||||
}
|
||||
|
||||
// --- pure helper: lineup order + error slots (#64) ---
|
||||
|
||||
test('test(photos): lineup is clean cal → overlay → other batch PNGs in capture order (#64)', () => {
|
||||
const clean = png('clean-cal');
|
||||
const overlay = png('overlay-cal');
|
||||
const sib0 = png('sib-0');
|
||||
const sib2 = png('sib-2');
|
||||
const batchApply: BatchPhotoResult[] = [
|
||||
{ status: 'ok', inventoryPng: sib0 },
|
||||
null, // calibration at index 1
|
||||
{ status: 'ok', inventoryPng: sib2 },
|
||||
];
|
||||
|
||||
const lineup = buildPhotosReadyLineup({
|
||||
inventoryPng: clean,
|
||||
overlayPng: overlay,
|
||||
batchApply,
|
||||
});
|
||||
|
||||
assert.deepEqual(roles(lineup), [
|
||||
'png:calibration',
|
||||
'png:overlay',
|
||||
'png:batch',
|
||||
'png:batch',
|
||||
]);
|
||||
assert.equal(lineup[0].kind, 'png');
|
||||
if (lineup[0].kind === 'png') {
|
||||
assert.equal(lineup[0].blob, clean);
|
||||
assert.equal(lineup[0].role, 'calibration');
|
||||
}
|
||||
assert.equal(lineup[1].kind, 'png');
|
||||
if (lineup[1].kind === 'png') {
|
||||
assert.equal(lineup[1].blob, overlay);
|
||||
assert.equal(lineup[1].role, 'overlay');
|
||||
}
|
||||
assert.equal(lineup[2].kind, 'png');
|
||||
if (lineup[2].kind === 'png') {
|
||||
assert.equal(lineup[2].blob, sib0);
|
||||
assert.equal(lineup[2].batchIndex, 0);
|
||||
}
|
||||
assert.equal(lineup[3].kind, 'png');
|
||||
if (lineup[3].kind === 'png') {
|
||||
assert.equal(lineup[3].blob, sib2);
|
||||
assert.equal(lineup[3].batchIndex, 2);
|
||||
}
|
||||
});
|
||||
|
||||
test('test(photos): single-photo batch lineup is clean + overlay only (#64)', () => {
|
||||
const clean = png('only-clean');
|
||||
const overlay = png('only-overlay');
|
||||
const lineup = buildPhotosReadyLineup({
|
||||
inventoryPng: clean,
|
||||
overlayPng: overlay,
|
||||
batchApply: [null],
|
||||
});
|
||||
assert.deepEqual(roles(lineup), ['png:calibration', 'png:overlay']);
|
||||
assert.ok(lineup.every((it) => it.kind === 'png'));
|
||||
});
|
||||
|
||||
test('test(photos): batchApply errors become clear error slots (empty mask / floor) (#64)', () => {
|
||||
const clean = png('clean');
|
||||
const overlay = png('overlay');
|
||||
const ok = png('ok-sib');
|
||||
const batchApply: BatchPhotoResult[] = [
|
||||
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' },
|
||||
];
|
||||
|
||||
const lineup = buildPhotosReadyLineup({
|
||||
inventoryPng: clean,
|
||||
overlayPng: overlay,
|
||||
batchApply,
|
||||
});
|
||||
|
||||
assert.deepEqual(roles(lineup), [
|
||||
'png:calibration',
|
||||
'png:overlay',
|
||||
'error:1',
|
||||
'png:batch',
|
||||
'error:3',
|
||||
]);
|
||||
|
||||
const err1 = lineup[2];
|
||||
assert.equal(err1.kind, 'error');
|
||||
if (err1.kind === 'error') {
|
||||
assert.match(err1.message, /empty mask/i);
|
||||
assert.equal(err1.batchIndex, 1);
|
||||
}
|
||||
const err3 = lineup[4];
|
||||
assert.equal(err3.kind, 'error');
|
||||
if (err3.kind === 'error') {
|
||||
assert.match(err3.message, /floor|800/i);
|
||||
}
|
||||
|
||||
// Successful sibling still appears after cal+overlay, in capture order among siblings
|
||||
const okItem = lineup[3];
|
||||
assert.equal(okItem.kind, 'png');
|
||||
if (okItem.kind === 'png') {
|
||||
assert.equal(okItem.blob, ok);
|
||||
assert.equal(okItem.batchIndex, 2);
|
||||
}
|
||||
});
|
||||
|
||||
test('test(photos): lineup never uses source JPEG thumbs as inventory PNGs (#64)', () => {
|
||||
const clean = png('clean');
|
||||
const overlay = png('overlay');
|
||||
const thumb = jpeg('source-thumb');
|
||||
// Dishonest proxy would pass source thumbs; helper must only emit image/png blobs for png slots
|
||||
const lineup = buildPhotosReadyLineup({
|
||||
inventoryPng: clean,
|
||||
overlayPng: overlay,
|
||||
batchApply: [
|
||||
null,
|
||||
{ status: 'ok', inventoryPng: png('sib') },
|
||||
],
|
||||
});
|
||||
for (const it of lineup) {
|
||||
if (it.kind === 'png') {
|
||||
assert.equal(it.blob.type, 'image/png', 'lineup PNG slots must be inventory PNGs');
|
||||
assert.notEqual(it.blob, thumb);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('test(photos): skip clean cal when sourceBelowFloor; keep overlay + siblings (#64)', () => {
|
||||
const lineup = buildPhotosReadyLineup({
|
||||
inventoryPng: png('below'),
|
||||
overlayPng: png('overlay'),
|
||||
batchApply: [null, { status: 'ok', inventoryPng: png('sib') }],
|
||||
sourceBelowFloor: true,
|
||||
});
|
||||
assert.deepEqual(roles(lineup), ['png:overlay', 'png:batch']);
|
||||
});
|
||||
|
||||
test('test(photos): empty/missing results yield empty lineup (#64)', () => {
|
||||
assert.deepEqual(buildPhotosReadyLineup({}), []);
|
||||
assert.deepEqual(
|
||||
buildPhotosReadyLineup({ inventoryPng: null, overlayPng: null, batchApply: undefined }),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
// --- light UI / session wiring (#64) ---
|
||||
|
||||
test('test(photos): MeasurePanel shows Photos ready lineup + M1 numbers + batch errors (#64)', () => {
|
||||
const panel = readFileSync(join(here, 'MeasurePanel.svelte'), 'utf8');
|
||||
assert.match(panel, /Photos ready|photos.?ready/i, 'end screen title after Calculate');
|
||||
assert.match(panel, /buildPhotosReadyLineup/, 'must use pure lineup helper');
|
||||
assert.match(panel, /Widths/, 'M1 widths still visible');
|
||||
assert.match(panel, /Sqft/, 'M1 sqft still visible');
|
||||
assert.match(panel, /Bdft/, 'M1 bdft still visible');
|
||||
assert.match(panel, /Scale/, 'M1 scale still visible');
|
||||
// Dishonest proxies
|
||||
assert.ok(
|
||||
!/lineup[\s\S]{0,200}state\.source\.blob/.test(panel),
|
||||
'must not lineup source thumbs',
|
||||
);
|
||||
assert.match(
|
||||
panel,
|
||||
/error|batchApply|message/i,
|
||||
'per-photo batchApply errors must surface in the panel',
|
||||
);
|
||||
assert.match(panel, /Back/, 'Back must still work from Photos ready');
|
||||
});
|
||||
|
||||
test('test(photos): App still routes measure results through Measure (or PhotosReady) with Back (#64)', () => {
|
||||
const app = readFileSync(join(here, '..', 'App.svelte'), 'utf8');
|
||||
// Stay on Measure with results, or dedicated panel — either is fine
|
||||
assert.ok(
|
||||
/MeasurePanel/.test(app) || /PhotosReady/.test(app),
|
||||
'App must render Photos-ready end screen',
|
||||
);
|
||||
// Must not invent a Mask loop for the lineup
|
||||
assert.ok(
|
||||
!/\{\s*#each\s+[^}]*photos[^}]*\}[\s\S]*MaskPanel/i.test(app),
|
||||
'App must not #each batch into Mask for Photos ready',
|
||||
);
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user