Commit Graph

269 Commits

Author SHA1 Message Date
Ty 350a849ab6 fix(fe): derive the title figure from the live selection
figureName read slab.figureTermIds (the stored prop) while regenerateCopy()
runs against the local figIds edit state, so a freshly picked figure never
reached the title - drafts published as 'Black Walnut Slab' instead of
'Quilted Black Walnut Slab'. Read the local selection instead.
2026-09-25 20:55:44 -07:00
Ty 104f29f5e3 fix(backend): clear a re-uploaded photo by id, not id+slab
slab_photos.id is a global primary key and photo ids are derived from the slab
id, so scoping the replace to the slab let a retried or concurrent upload of
the same draft hit UNIQUE(id) and 500. Clear the id unconditionally.
2026-09-25 20:41:00 -07:00
Ty dfe192fa8a fix(fe): make capture photo ids unique per slab
slab_photos.id is a global primary key, but photo ids were cap-<i>-<size> -
identical for the same photo set on a different slab, so the upload hit
UNIQUE constraint failed: slab_photos.id and returned 500. Key the id on the
slab id so it is unique across slabs.
2026-09-25 20:33:39 -07:00
Ty 23de433bdb fix(fe): attach the session's processed PNGs to the draft photos
onPhotosReady built the draft photos from the raw captured JPEGs and dropped
every PNG the session had already produced, so processedBlob was never set on
the M2 path (only the legacy PhotoImport path set it). photoUpload then omitted
the processed multipart field, slab_photos.processed_blob stayed NULL, and
publish.py - which selects WHERE processed_blob IS NOT NULL - uploaded zero
images to Woo. The verified SLAB-UAT-901 draft had images: 0.

Attach results.inventoryPng to the calibration photo and each successful
batchApply entry to its sibling, and add the dimension-overlay copy as its own
photo adjacent to the calibration one (REVIEW-5).
2026-09-25 20:23:29 -07:00
Ty e5afc7524b fix(fe): upload slab photos to the server before publish
ensureServerSlab sent calibration_photo_id at create, but slab_photos rows are
only created by POST /slabs/{id}/photos, which the frontend never called - so
the INSERT hit FOREIGN KEY (calibration_photo_id REFERENCES slab_photos(id))
and every create failed with a generic 400, blocking the whole create->publish
walk.

- stop sending calibration_photo_id at create
- upload every draft photo (original + processed PNG) right after the slab row
  exists, via new review/photoUpload.ts (multipart FormData, same error
  envelope so failures surface through fail(e))
- link calibration_photo_id in the existing review PUT, once the row exists
- extract the role rule into lineup.photoRole() so the review strip and the
  uploader cannot disagree
2026-09-25 12:14:35 -07:00
Ty 5c7e5c663e fix(backend): log create_slab driver errors server-side
QUALITY-120 keeps the driver stack out of the owner-facing response, but the
bare except logged nothing, so a failing create was undiagnosable in the field.
Log the exception server-side while keeping the generic owner message.
2026-09-25 12:04:53 -07:00
Ty fa54e113c3 fix(fe): match fig-/feat- tags by slug, and echo Figure selections to tags
Two fixes in the review form:
- figTags/featTags filtered on tag.name.startsWith('fig-'), but the live store
  marks the family on the SLUG (slug 'fig-chat', name 'Chatoyant'), so the
  figure/feature tag groups rendered empty. Match slug OR name.
- Echo Figure selections transparently: picking a Figure attribute term
  auto-selects the same-named fig-* tag and vice versa, so the Woo product
  carries the figure in both the pa_fig attribute and the fig-* tag without
  the owner picking it twice.
2026-09-25 11:56:28 -07:00
Ty bffbad8c4f fix(backend): keep fig-/feat- tags by slug, not just name
The live store marks the figure/feature tag families on the tag SLUG
(slug 'fig-chat', display name 'Chatoyant'), but _scoped_tags filtered on
name.startswith('fig-'), so all 11 fig-* tags were silently dropped at sync
and the figure-tag echo (METADATA-30) had nothing to draw from. Match the
prefix on slug OR name. Adds a test for the slug-prefixed bare-name shape.
2026-09-25 11:46:18 -07:00
Ty beab1b5bb7 fix(fe): case-insensitive attribute-name match in review form
attributeByName lowercased the store attribute name but compared it to the
raw lookup string, so 'figure' !== 'Figure' and Edge/Figure/Grade/Thickness/
Moisture all matched nothing - the Figure chip group (and the other attribute
pickers) rendered empty, so 'Mark Ready' could never enable against the live
store. Lowercase both sides so the synced attributes resolve.
2026-09-25 11:38:04 -07:00
Ty 1b32f71c91 fix(fe): axis preview reactive now depends on approvedMask
The axis-preview draw reactive only depended on the image being complete, so
it fired before approvedMask was populated (canvas stayed at the default
300x150 and nothing drew). Add cap.approvedMask to the reactive deps so the
sheet-removed photo + tape line render once the frozen mask is present.
2026-09-25 09:35:43 -07:00
Ty 039a5de960 fix(fe): show the sheet-removed image + length-axis line on the axis step
The axis step rendered only text and buttons - no image, so the owner had
nothing to confirm the length axis against and 'handing to step 4' felt like a
dead end. Render the calibration photo with the sheet checkerboarded (using the
frozen approvedMask) and draw the length-axis tape line in red from
axis.tapeLine.start to .end (source-pixel coords scaled to the canvas), so the
BG-removed slab and its axis are visible before approving.
2026-09-25 09:33:30 -07:00
Ty 564f03dcee feat(fe): keep only the largest slab component in BG removal
Small isolated artifacts (e.g. a room corner the photo caught past the black
sheet) survived the flood-fill as separate slab islands and carried into the
rest of the workflow. After the border flood-fill, label the 4-connected slab
components and keep only the largest, with a 0.5%-of-frame area floor that
also drops speckles. Adds keepLargestComponent + unit tests proving an
isolated blob and a corner artifact are dropped while the main slab is kept.
2026-09-25 09:26:52 -07:00
Ty 0bc86ecc6d fix(fe): debounce mask auto-run 500ms after slider/sheet changes
Rapid sensitivity drags triggered a flood of async mask runs; an in-flight
run could settle after the owner pressed Approve and clobber the state, so
the Approve click read a snapshot with overlay:null and early-returned.
Debounce 500ms after the user stops adjusting so the mask settles before any
interaction and the click reads a stable state.
2026-09-25 09:19:52 -07:00
Ty 703655a763 fix(fe): mask auto-run keyed on control, no settle loop
Key the auto-run off a controlKey (sheet:sensitivity) so it fires on mask
entry and on each sheet/sensitivity edit, but never re-fires when approveMask
settles the overlay - preventing both the empty-preview bug and an infinite
settle loop.
2026-09-25 09:15:20 -07:00
Ty e4a05dd468 fix(fe): auto-run the mask when the mask screen is active
The mask screen never populated its overlay: pickCalibration set overlay:null
and the only thing that computed it (approveMask) was gated behind the
Approve button, which is disabled until overlay.slabPx>0 - a chicken-and-egg,
so the preview stayed empty and Approve was permanently disabled. Auto-run
approveMask() whenever the mask screen is active (and on sheet/sensitivity
change) so the overlay and preview render immediately.
2026-09-25 09:11:54 -07:00
Ty 25c1f79fc1 fix(fe): downscale mask flood-fill to a working resolution
The border flood-fill on a full-res 2256x4000 (9M px) photo was too slow in
JS and the async mask pass timed out into the catch branch (overlay stayed
null, 'Mask settled' never rendered). Downscale to <=1200px for the fill,
then nearest-neighbor upsample the slab bitset back to full size so
minAreaRect/slabBounds stay in source-pixel coordinates.
2026-09-25 09:09:30 -07:00
Ty 1c58f12c5d feat(fe): replace threshold mask with client-side sheet BG removal (flood-fill)
The green/black-sheet luminance threshold could not separate the slab from
the 'black' sheet, which in real photos reads at lum ~50 and overlaps the
slab's bark/shadow tones (measured on the user's samples: 0% green, 60-80%
dark, no brightness gap) - it removed 100% of the image. Replace the mask
pass with connectivity-based background removal: sample the sheet tone from
the border, flood-fill from the border marking sheet-connected pixels as
background, keep the connected remainder as the slab. Robust to tonal
overlap. Adds bgRemoval.ts (removeBackground + sampleSheetTone) with unit
tests proving a sheet-toned slab pixel survives via enclosure.
2026-09-25 09:07:39 -07:00
Ty 673593c9b6 fix(fe): render the photo + mask overlay on the Mask step
The mask screen showed only controls and a pixel-count line - no photo, so
the owner had nothing to adjust the sheet/sensitivity against. Draw the
calibration photo to a canvas and paint removed (non-slab) pixels as a
light-gray checkerboard (CAPTURE-45/50), redrawing when the overlay settles.
Verified in Chromium: mask step now shows the photo with the checkerboard.
2026-09-25 08:26:20 -07:00
Ty b03939cf84 fix(fe): canvas fallback for decodeOrientedBitmap when createImageBitmap is absent
The LAN dev URL is a non-secure, sometimes restricted browser where
createImageBitmap may be undefined; the pick-calibration -> mask decode
would then reject. Fall back to a canvas + Image decode returning an
ImageBitmap. Contract unchanged (still returns ImageBitmap).
2026-09-25 08:03:10 -07:00
Ty 3321a7cfdf fix(fe): crypto.randomUUID not in non-secure contexts (LAN http) -> newId() fallback
The LAN dev URL (http://192.168.1.202) is not a secure context, so
crypto.randomUUID is undefined and adding a photo threw
'TypeError: crypto.randomUUID is not a function'. Add newId() (randomUUID
when available, else crypto.getRandomValues UUIDv4) and use it in draft()
and PhotoImport. Verified in Chromium.
2026-09-25 07:54:15 -07:00
Ty 7913e630d6 fix(fe): subscribe to capture-session store in components (was reading the store object)
`session.state` is a Svelte store; `$: state = session.state` bound the store
object (not its value), so state.capture was undefined and cap.k threw on
mount. Bind a local const to the store and use the $ prefix ($stateStore),
and use get() in App.onPhotosReady. Verified in Chromium: hero -> batch ->
sample uploads show thumbs.
2026-09-25 07:45:25 -07:00
Ty de2af5c6c7 fix(fe): hero 'Start capture (session)' enters the M2 session, not the chooser
The hero button called flow.set('capture') which renders the path-chooser
branch, so it stayed on the chooser and 'Choose photos' never appeared.
Point it at 'captureSession' so the hero action lands directly in the batch
screen. Verified in Chromium: hero button -> batch screen -> sample uploads
show thumbs.
2026-09-25 07:20:02 -07:00
Ty 88a2518cfa fix(fe): read capture-session store directly (no $ prefix on the object)
`createCaptureSession()` returns {state: Store, ...}; Svelte's $ prefix
calls .subscribe on the object, not the store, so $captureSession.state
threw 'subscribe is not a function' on App mount → black page. Reference the
store directly (auto-subscribes): captureSession.state / session.state /
session.lineup(). Verified: tsc clean, build green, 150/150 tests, and the
live app renders in Chromium.
2026-09-25 07:04:55 -07:00
Ty e09901b701 chore(fe): vite dev proxy for local FastAPI (dev-only, not in Docker build) 2026-09-25 07:01:20 -07:00
Ty 86091ed916 ops(greenfield): record isolated P5 deploy on ubudual5060 (10884/10885)
Standalone compose (project slab-greenfield) on ports 10884/10885 with
dedicated slab_greenfield_* volumes; live M3/M4 stacks (10881/10882/10883)
untouched. All smoke checks pass; Woo draft pending live creds (P5 exit).
2026-09-25 07:00:34 -07:00
Ty 49c22c9385 feat(greenfield): rebuild SlabUploader M1-M3 on clean foundation, all gates green
Clean re-implementation of the full product (not a port of the old M3 tree):
- P0 foundation: 13-table schema + per-call sqlite factory (thread-safe),
  FastAPI app (health, JSON logging + secret redaction, CORS, startup guard).
- P1 M1 Capture: mask/sheetKeep, geometry/rect (min-area rect), measure,
  png crop/inventory/overlay, EXIF decodeOrientedBitmap.
- P2 M2 Photos: stateful capture session (batch, pick-cal, apply mask/crop
  to batch) + photos-ready lineup.
- P3 M3 Create: real backend (crypto, woo client, taxonomy sync, pricing,
  inference Call1/Call2, review, publish, settings, slabs, photos) + real FE
  review/publish/settings screens consuming the API (no hardcoded Woo ids).
- P4 gate exit: check_requirements + check_coverage both exit 0
  (137/155 IDs, 0 gaps); backend 130/130, frontend 150/150, build green.

Cite: docs/REQUIREMENTS.md (155 rows), docs/MILESTONES.md M1-M3.
2026-09-25 06:57:29 -07:00
Ty 4ca2ef57a7 feat(capture): ship tape-line calibration (#99) 2026-09-24 14:47:13 -07:00
Ty 6fb92362a4 docs(m4): M4 goals + task breakdown
Goals: polish M3 + #99 tape-line, human-driven taxonomy review
(thumb-first UI, Call 1 deferred), Call 2 inference refinement,
real SLAB-UAT-* Woo draft exit.

Tasks: 9-task / 5-phase breakdown with detail bar (skills, starting
point, verification, sequence, stop condition, evidence boundary per
task). Kickoff decisions folded in: full polish wave (#91-#100 minus
#96), tape-line confined to calibration step, single-slab multi-photo
exit walk.
2026-09-24 13:44:53 -07:00
Ty 818874e65c feat(m4): crop_fill setting + tape-line prototype (#99)
Add global crop_fill percent (default 90, 1-100) as a settings key with
FastAPI 4xx validation. Wires the value through to the inventory PNG
encode (non-retro: stored PNGs untouched). Adds a throwaway
TapePrototype.svelte behind ?tape-prototype for the #99 design sandbox.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

- schema.sql: add metadata_locked INTEGER DEFAULT 0 to slabs
- db/__init__.py: bump _SCHEMA_SENTINELS for metadata_locked
- slabs.py: expose metadata_locked in GET; reject it in PUT (server-managed)
- review.py: POST /{id}/review action=lock path; regular review reports
  stored (empty) copy until locked
- test_review.py: 8 tests covering lock gate, owner-prose-wins, stale taxonomy
- frontend/api/slabs.ts: Slab.metadata_locked, ReviewResult.locked,
  ReviewInput.action, lockSlab() helper
- Review.svelte: lockMetadata() with correct commit-token ordering (bump AFTER
  inner commit so the inner PUT isn't superseded), Lock button, locked banner
- e2e/m3-create.spec.ts: pare to single golden path (capture→review→lock→
  ready→publish→done); mock respects optimistic concurrency (client_rev) so
  stale PUTs 409 like the real backend
2026-09-20 23:36:31 -07:00