The Review screen now has an explicit 'Lock & Build Title' step. Until the
owner locks, title/short_description stay empty (no 'ready' copy from an
unlocked AI draft). On lock the server validates the taxonomy, sets
metadata_locked=1, builds + persists the deterministic title/short_description
(owner prose wins), and returns the ready state.
- schema.sql: add metadata_locked INTEGER DEFAULT 0 to slabs
- db/__init__.py: bump _SCHEMA_SENTINELS for metadata_locked
- slabs.py: expose metadata_locked in GET; reject it in PUT (server-managed)
- review.py: POST /{id}/review action=lock path; regular review reports
stored (empty) copy until locked
- test_review.py: 8 tests covering lock gate, owner-prose-wins, stale taxonomy
- frontend/api/slabs.ts: Slab.metadata_locked, ReviewResult.locked,
ReviewInput.action, lockSlab() helper
- Review.svelte: lockMetadata() with correct commit-token ordering (bump AFTER
inner commit so the inner PUT isn't superseded), Lock button, locked banner
- e2e/m3-create.spec.ts: pare to single golden path (capture→review→lock→
ready→publish→done); mock respects optimistic concurrency (client_rev) so
stale PUTs 409 like the real backend
Ship locked expert Call 1 seed: order cats before edge, species=best
confidence, mill-count edge rules, Wild for cookies/burl, multi figure,
feature_tag_ids only (no free-text bags, no grade). LISTS keys match the
seed; feat- prefix stripped for the model. Gate and review UI apply
feature_tag_ids. Seed auto-activates on deploy when still seed-authored.
- frontend: uuid() helper (crypto.randomUUID -> getRandomValues) for plain-HTTP LAN UAT
- slabs.py: empty SKU at create -> NULL (no-SKU-yet is transient, filled in Review)
- review.py: ready-gate enforces REVIEW-45 (SKU 3-24 alnum+hyphen) in the middle tier
- DB CHECK stays as backstop only (referential/structural), per layering principle
Owner-directed, LAN/HTTP dev-test only. When the env flag is set,
GET /api/v1/settings returns decrypted secret values (string) instead of
{configured: bool}; the UI prefills and shows them in clear text. Default
off -> SETTINGS-75 masking and the existing tests stay intact. Revert
before any non-UAT deployment.
Scope .settings styles to the panel: light #eee text on the dark
#121212 surface for labels, and white inputs/selects/options with dark
text. Both pairs exceed WCAG 4.5:1. Add settings-contrast e2e as the
regression guard for the Settings form plus the sibling dev panels.
- test_exit_inference_off.py (QUALITY-20/120): full create->photo->review->publish
happy path with inference OFF, asserts zero inference_log rows and no raw codes/
tracebacks reach the owner; not-ready publish 422 with plain field-level message.
- test_integration_woo_mock.py (QUALITY-35): Woo mock covering success, error,
duplicate SKU, stale field (no raw code), and taxonomy anchor bump on change.
- m3-create.spec.ts (QUALITY-50): Playwright E2E — all M3 dev screens render,
Review/Publish controls load, Generate control visible only when enabled+reachable.
- deploy/uat_walk.sh (QUALITY-110): ready-to-run human walk for the real SLAB-UAT-*
draft (inference off) against the live store — NOT a mock.
Verified: pytest 61 passed; frontend unit 88 passed; e2e 4 passed; gates exit 0.
Lower the axial body threshold while retaining fringe rejection, minimize length-only tip inset, and make sheet sensitivity curves testable and conservative at zero.
PhotoStage used new Image() while mask worker and calculate used bare
createImageBitmap; Orientation-8 matched-set JPEGs can disagree across
those paths so the approved pink axis is not the overlay (ux,uy).
One decodeOrientedBitmap helper always requests imageOrientation
from-image (with bare fallback), wired into PhotoStage, mask.worker,
and session calculate/siblings.
Root cause: 0.2%/99.8% axial percentiles still included dense fringe past
the visible body; round line caps and scale-tied font overshot edges and
clipped width labels. Use across-width tip stations, inset length/width
ends for stroke/arrowhead, butt caps, inventory-capped font, axis-right
length label, clamp labels to PNG.
Length dim already mask tip-to-tip (locked by test). Width labels move
axis-left off the wood via labelAt; font steps down to max(22, scale*1.0).
Inventory/overlay crop FILL 0.8→0.9; CAPTURE/PHOTOS docs match.
Lock length dim to mask tip-to-tip (not oversized bbox), require width
labelAt on axis-left off the wood, smaller font contract, and 90% crop
fill extent. Red before green.
Add buildPhotosReadyLineup (clean cal → overlay → batch inventory PNGs
in capture order, plus empty-mask/floor error slots). MeasurePanel
switches to a Photos ready end screen with Milestone 1 numbers and the
inventory PNG lineup; Back still returns to Length axis.
Failing TDD for M2 Photos-ready end screen: pure buildPhotosReadyLineup
orders clean calibration inventory PNG → dimension-overlay copy → other
batch inventory PNGs in capture order (skipping cal null), surfaces
batchApply empty-mask/floor errors, and MeasurePanel must show Photos
ready with Milestone 1 numbers (not source thumbs).
Add pure station/axis math and canvas burn-in for a second calibration
inventory PNG (results.overlayPng). Width stations start 6 in from the
EXIF-oriented photo bottom along the approved length axis (6, 18, … ≤
L−6); length graphic sits on axis-right outside the slab. Clean
inventoryPng and batchApply[cal]=null stay unchanged.
Failing TDD for M2 dimension-overlay copy of the calibration inventory PNG:
width stations every 12 in from EXIF bottom (6, 18, … ≤ L−6), axis-right
length graphic, and Calculate storing overlayPng without touching batchApply[cal].
After calibration Calculate, reuse approved sheet/sensitivity on non-calibration
photos via the mask worker + inventory compose. Persist per-photo ok/error on
measure.batchApply and the device draft for #64; leave Mask UI unopened and keep
scale/widths/sqft/bdft on the calibration results only.
Document single-source M1 path after pickCalibration: Mask/Axis/Measure/
Calculate use the chosen Source only; batch siblings stay draft-untouched
until #62 apply. Tag encode-fallback inventory Blob as image/png (CAPTURE.md).
Proving tests: after pick of index N≠0, Mask→Axis→Measure operate on that
blob only; Calculate yields inventory PNG + M1 numbers; other batch photos
stay in draft.batch untouched; resume through measure restores calibration
source. FakeWorker drives the path in Node. No production change yet (TDD).
Failing session/UI tests for M2-3: Continue → pickCalibration (not
first-photo Mask proxy), user pick enters Mask on that Source, draft
remembers calibrationIndex, Resume restores. No production change yet (TDD).
Replace single Add photo with Batch: accumulate 1–N photos, Continue
when ≥1 (M1 bridge: mask on first), persist batch blobs in the device
draft, Resume Continue restores. Keeps Mask/Axis/Measure intact.
Failing session/UI tests for M2-2: multi-photo batch state, Continue
when ≥1, device-draft persistence of batch blobs, Resume Continue
restores. No production change yet (TDD).
sampleWidths used floor(L/6)+1 stations from t=0, so tapered tips
dominated min/max. Sample only interior stations at 6, 12, … ≤ L−6.
When L≤12 there is no interior span: every6In stays empty and
min/max/avg stay 0. CAPTURE.md Widths bullet matches.
sqft, bdft, scale, and inventory PNG are unchanged.
Ty UAT: width min/max must describe bulk, not tips. Pin L=48 tapered
case (7 interior stations, no tip endpoints), uniform mid-board count,
and L≤12 empty/zero fallback before the sampleWidths change lands.
Product call for the WooCommerce redo. Calculate accepts
sources whose post-crop shorter side is at least 800px.
Warn copy and CAPTURE.md match. No upscale still holds.
Ty UAT rejected phone photos under the 1600px bar.
Lock the mid-band case (shorter side 1000) and floor
geometry at 800×1067 / 0.8×800=640 before the code change.
Why
CAPTURE.md Crop keeps slab pixels and makes removed pixels transparent.
encodeInventoryPng only drawImage-cropped the AABB from the raw photo.
Scope
composeInventoryFrame samples the source through approvedMask.bitset.
Non-slab pixels stay alpha 0. Drop the 8% MARGIN; fill 80% of mask
extents in a 3:4 frame. Floor rules unchanged.
Verification
cd frontend && npm test
12 pass, including alpha contract (no sheet green in opaque pixels).
Why
Calculate showed accurate numbers but the inventory PNG kept the sheet
background. Measure already uses approvedMask.bitset; encode did not.
Scope
Add composeInventoryFrame and inventory.test.ts alpha/crop proofs.
Wire encodeInventoryPng({ bitmap, mask, source }) at calculate().
This commit still paints the slabBounds AABB without the bitset gate so
the alpha contract fails until the follow-up fix.
Verification
node --import tsx --test src/png/inventory.test.ts
alpha contract fails (sheet green leaks); 3:4 / 80% / floor pass.