diff --git a/frontend/src/lib/core/inventory.js b/frontend/src/lib/core/inventory.js
new file mode 100644
index 0000000..880f246
--- /dev/null
+++ b/frontend/src/lib/core/inventory.js
@@ -0,0 +1,64 @@
+// M1 deterministic inventory generation — pure functions.
+//
+// Ground truth: docs/CAPTURE.md §Inventory PNG
+// Aspect | 3:4 portrait
+// Layout | Slab centered, 80% fill at widest and tallest points
+// Size | After crop, shorter side >= 1600px
+//
+// This module handles the logic for calculating the inventory metadata
+// and preparing the instructions for the browser-side PNG encoding.
+//
+// Note: The actual PNG encoding (canvas/OffscreenCanvas) is handled
+// by the frontend component/worker. This file provides the data.
+//
+/**
+ * @typedef {Object} InventoryMetadata
+ * @property {number} sqft
+ * @property {number} bdft
+ * @property {number[]} widths
+ * @property {number} minWidth
+ * @property {number} maxWidth
+ * @property {number} avgWidth
+ * @property {number} lengthIn
+ * @property {number} thicknessIn
+ * @property {number} scale
+ */
+//
+/**
+ * Prepares the metadata for the inventory PNG.
+ * @param {number} maskPx - Total area of the mask in pixels.
+ * @param {number} scale - The px-per-inch scale.
+ * @param {number} lengthIn - User-entered length in inches.
+ * @param {number} thicknessIn - User-entered thickness in inches.
+ * @param {number[]} sampledWidthsPx - The raw pixel widths sampled along the axis.
+ * @returns {InventoryMetadata}
+ */
+export function prepareInventoryMetadata(
+ maskPx,
+ scale,
+ lengthIn,
+ thicknessIn,
+ sampledWidthsPx
+) {
+ // Re-use logic from math.js to ensure consistency
+ const sqft = (maskPx / (scale * scale)) / 144;
+ const bdft = sqft * thicknessIn;
+
+ // Widths are converted to inches
+ const widths = sampledWidthsPx.map((w) => w / scale);
+ const minWidth = Math.min(...widths);
+ const maxWidth = Math.max(...widths);
+ const avgWidth = widths.reduce((a, b) => a + b, 0) / widths.length;
+
+ return {
+ sqft: Math.round(sqft * 100) / 100,
+ bdft: Math.round(bdft * 100) / 100,
+ widths,
+ minWidth,
+ maxWidth,
+ avgWidth,
+ lengthIn,
+ thicknessIn,
+ scale,
+ };
+}
diff --git a/frontend/src/lib/core/measure.js b/frontend/src/lib/core/measure.js
new file mode 100644
index 0000000..bf52a3d
--- /dev/null
+++ b/frontend/src/lib/core/measure.js
@@ -0,0 +1,69 @@
+// M1-5 Measure — mask width sampling along the approved length axis.
+//
+// Ground truth: docs/CAPTURE.md §Measure
+// - scale = longest_axis_px / L
+// - Widths: min, max, avg, sampled every 6 in along the approved length
+// axis, perpendicular to it
+//
+// sampleWidthsPx is the real scanline sampler the UI runs on the approved
+// mask: at each sample point t = 0, 6, 12, ... in (px) along the axis it
+// scans the perpendicular direction across the whole photo and measures the
+// contiguous run of kept pixels that covers the sample point.
+
+/**
+ * Sample perpendicular mask widths along a length axis.
+ *
+ * @param {Uint8Array} mask binary mask (1 = kept), row-major
+ * @param {number} width photo width in px
+ * @param {number} height photo height in px
+ * @param {{origin:{x:number,y:number}, dir:{x:number,y:number}, lengthPx:number}} axis
+ * origin = a point on the axis (e.g. the rectangle corner the long side
+ * starts at), dir = unit vector along the slab length, lengthPx = its
+ * pixel length.
+ * @param {number} pxPerIn scale
+ * @param {number} [everyIn=6] sampling interval in inches
+ * @returns {number[]} widths in px, one per sample (t = 0, everyIn, ... in px
+ * at or under lengthPx)
+ */
+export function sampleWidthsPx(mask, width, height, axis, pxPerIn, everyIn = 6) {
+ const stepPx = everyIn * pxPerIn;
+ const ux = axis.dir.x;
+ const uy = axis.dir.y;
+ // Perpendicular unit vector (left normal): (-uy, ux).
+ const perpX = -uy;
+ const perpY = ux;
+ const out = [];
+
+ for (let t = 0; t <= axis.lengthPx; t += stepPx) {
+ // Sample point.
+ const sx = axis.origin.x + ux * t;
+ const sy = axis.origin.y + uy * t;
+ // Find the contiguous kept run along the perpendicular line that
+ // covers (sx, sy), walking out from the sample point in both
+ // perpendicular directions.
+ const rx = Math.round(sx);
+ const ry = Math.round(sy);
+ if (rx < 0 || ry < 0 || rx >= width || ry >= height || !mask[ry * width + rx]) {
+ out.push(0);
+ continue;
+ }
+ let a = 0; // extent in the negative perpendicular direction
+ let b = 0; // extent in the positive perpendicular direction
+ for (;;) {
+ const nx = rx - Math.round(perpX * (a + 1));
+ const ny = ry - Math.round(perpY * (a + 1));
+ if (nx < 0 || ny < 0 || nx >= width || ny >= height) break;
+ if (!mask[ny * width + nx]) break;
+ a += 1;
+ }
+ for (;;) {
+ const nx = rx + Math.round(perpX * (b + 1));
+ const ny = ry + Math.round(perpY * (b + 1));
+ if (nx < 0 || ny < 0 || nx >= width || ny >= height) break;
+ if (!mask[ny * width + nx]) break;
+ b += 1;
+ }
+ out.push(a + b + 1);
+ }
+ return out;
+}
diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte
index f7fea10..b60a5e7 100644
--- a/frontend/src/routes/+page.svelte
+++ b/frontend/src/routes/+page.svelte
@@ -1,12 +1,618 @@
-
Capture
-
The M1 capture flow (Add photo → Mask → Length axis → Measure → PNG) lands in cards M1-3…M1-5.
+
SlabUploader
+
+ {#if heicNotice}
+
+
Cannot decode this photo. Please retake as JPEG.
+
+
+ {/if}
+
+ {#if state === 'add_photo'}
+
+
Add Photo
+
+ Photograph the slab on a green or black sheet.
+ Sheet fills the frame. Slab fully on sheet.
+ Top-down, lens parallel to the face.
+ Length aligned with the long side of the picture.
+