[object Object]

← back to Dw Photo Capture

TK-12162: pre-shot 11-slider adjust panel + auto-save on all simple-* pages

1a2c398e3e088a177d1ff3a9ceb186b554d4aef0 · 2026-09-24 14:17:53 -0700 · Steve Abrams

New public/js/adjust-panel.js puts all 11 CapturePipeline adjustments (exposure,
contrast, highlights, shadows, warmth, tint, saturation, vibrance, hue, sharpness,
straighten) in a bottom sheet, draws the adjusted live preview over the camera, and
bakes the same tune into whichever photo is taken - live shutter or the full-res
native photo (up to 4096px). Settings persist between shots.

Auto-save (default on, switch in the panel): once the label read returns vendor +
mfr#, the draft saves with no extra tap; otherwise the page's manual form shows as
before. Minimal keeps its existing Auto-create switch.

Pro Booth: its 3 big sliders and the panel edit the same tune; straighten was
hard-coded to 0 in its preview and bake, now honoured.

Headless E2E (exposure +60): all 4 pages brighten the saved photo (live ~83->152,
full-res 170->253 at 4032x3024), auto-save fires without a tap, and the manual
save path still works with auto-save off.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014WCvaA6QQCJp2LMdcXyrUD

Files touched

Diff

commit 1a2c398e3e088a177d1ff3a9ceb186b554d4aef0
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu Sep 24 14:17:53 2026 -0700

    TK-12162: pre-shot 11-slider adjust panel + auto-save on all simple-* pages
    
    New public/js/adjust-panel.js puts all 11 CapturePipeline adjustments (exposure,
    contrast, highlights, shadows, warmth, tint, saturation, vibrance, hue, sharpness,
    straighten) in a bottom sheet, draws the adjusted live preview over the camera, and
    bakes the same tune into whichever photo is taken - live shutter or the full-res
    native photo (up to 4096px). Settings persist between shots.
    
    Auto-save (default on, switch in the panel): once the label read returns vendor +
    mfr#, the draft saves with no extra tap; otherwise the page's manual form shows as
    before. Minimal keeps its existing Auto-create switch.
    
    Pro Booth: its 3 big sliders and the panel edit the same tune; straighten was
    hard-coded to 0 in its preview and bake, now honoured.
    
    Headless E2E (exposure +60): all 4 pages brighten the saved photo (live ~83->152,
    full-res 170->253 at 4032x3024), auto-save fires without a tap, and the manual
    save path still works with auto-save off.
    
    Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_014WCvaA6QQCJp2LMdcXyrUD
---
 public/js/adjust-panel.js   | 185 ++++++++++++++++++++++++++++++++++++++++++++
 public/simple-instant.html  |  24 +++++-
 public/simple-minimal.html  |  21 ++---
 public/simple-probooth.html |  17 +++-
 public/simple-wizard.html   |  22 +++++-
 5 files changed, 248 insertions(+), 21 deletions(-)

diff --git a/public/js/adjust-panel.js b/public/js/adjust-panel.js
new file mode 100644
index 0000000..c2f7386
--- /dev/null
+++ b/public/js/adjust-panel.js
@@ -0,0 +1,185 @@
+/*
+ * adjust-panel.js — the pre-shot "Photoshop" panel for the simple-* capture pages (TK-12162).
+ *
+ * All 11 CapturePipeline adjustments as sliders in a bottom sheet, a live adjusted preview drawn
+ * over the camera <video>, and a bake() that applies the SAME tune to the captured photo — so what
+ * you set before the shot is what gets saved. Requires /js/capture-pipeline.js loaded first.
+ *
+ *   const panel = AdjustPanel.create({
+ *     key,              // localStorage key for the tune (ignored when getTune is given)
+ *     getTune,          // optional: page already owns a tune object (Pro Booth) — panel edits it in place
+ *     onChange,         // optional: called after any slider move / reset
+ *     toggleParent,     // optional element to put the "Adjust" button in (default: floating top-left)
+ *     toggleClass, toggleText,
+ *     showAutoSave      // default true — the "Auto-save after label read" switch
+ *   });
+ *   panel.attachPreview(videoEl)          // adjusted canvas drawn over the video (hidden when neutral)
+ *   panel.bake(source, maxEdge, {mirror}) // -> JPEG dataUrl (0.92), full tune incl. sharpness
+ *   panel.bakeDataUrl(url, maxEdge)       // -> Promise<dataUrl>; for the native full-res photo
+ *   panel.autoSave()                      // -> boolean
+ *   panel.sync()                          // re-read the tune into the sliders (after an outside reset)
+ */
+(function () {
+  var CP = window.CapturePipeline;
+  var AUTO_KEY = 'dwSimpleAutoSave';
+  var CONTROLS = [
+    ['exposure', 'Exposure', -100, 100], ['contrast', 'Contrast', -100, 100],
+    ['highlights', 'Highlights', -100, 100], ['shadows', 'Shadows', -100, 100],
+    ['temp', 'Warmth', -100, 100], ['tint', 'Tint', -100, 100],
+    ['saturation', 'Saturation', -100, 100], ['vibrance', 'Vibrance', -100, 100],
+    ['hue', 'Hue', -180, 180], ['sharpness', 'Sharpness', 0, 100], ['straighten', 'Straighten', -15, 15]
+  ];
+  var CSS =
+    '.ap-sheet{position:fixed;left:0;right:0;bottom:0;z-index:9000;max-height:46vh;overflow:auto;' +
+    'background:rgba(16,14,10,.94);color:#f2ede1;border-top:1px solid #3a3428;border-radius:18px 18px 0 0;' +
+    'padding:10px 16px calc(14px + env(safe-area-inset-bottom));font:14px/1.3 -apple-system,system-ui,sans-serif;' +
+    'transform:translateY(105%);transition:transform .22s ease}' +
+    '.ap-sheet.open{transform:none}' +
+    '.ap-head{display:flex;align-items:center;gap:10px;padding:4px 0 8px;position:sticky;top:-10px;background:inherit}' +
+    '.ap-head b{flex:1;font-size:13px;letter-spacing:.16em;text-transform:uppercase;color:#c8a24a}' +
+    '.ap-head button,.ap-toggle-float{border:1px solid #c8a24a;color:#c8a24a;background:rgba(0,0,0,.55);' +
+    'border-radius:999px;padding:8px 14px;font:600 13px/1 -apple-system,system-ui,sans-serif;cursor:pointer}' +
+    '.ap-row{display:grid;grid-template-columns:92px 1fr 40px;align-items:center;gap:10px;padding:0}' +
+    '.ap-row span{font-size:12px;color:#b3a98f}.ap-row output{text-align:right;color:#e2bd66;font-variant-numeric:tabular-nums}' +
+    '.ap-row input{width:100%;height:28px;accent-color:#c8a24a}' +
+    // host pages style bare button/input globally (Wizard: 70px-tall buttons) — keep the sheet immune
+    '.ap-sheet button{min-width:0!important;min-height:0!important;width:auto;flex:0 0 auto;box-shadow:none;' +
+    'font:600 13px/1 -apple-system,system-ui,sans-serif!important;padding:8px 14px!important;border-radius:999px!important}' +
+    '.ap-toggle,.ap-toggle-float{white-space:nowrap}' +
+    '.ap-auto{display:flex;align-items:center;gap:10px;padding:10px 0 2px;color:#b3a98f;font-size:13px}' +
+    '.ap-auto input{width:20px;height:20px;accent-color:#c8a24a}' +
+    '.ap-toggle-float{position:fixed;z-index:8999;left:12px;top:calc(env(safe-area-inset-top,0px) + 14px)}' +
+    '.ap-toggle.on,.ap-toggle-float.on{background:#c8a24a;color:#171308}' +
+    '.ap-preview{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;pointer-events:none;display:none}';
+
+  function isNeutral(t) {
+    for (var i = 0; i < CONTROLS.length; i++) if (t[CONTROLS[i][0]]) return false;
+    return true;
+  }
+  function readAuto() { try { var v = localStorage.getItem(AUTO_KEY); return v === null ? true : v === '1'; } catch (e) { return true; } }
+  function writeAuto(on) { try { localStorage.setItem(AUTO_KEY, on ? '1' : '0'); } catch (e) {} }
+
+  function create(opts) {
+    opts = opts || {};
+    var ownTune = opts.getTune ? null : CP.loadTune(opts.key || 'dwSimpleTune');
+    var getTune = opts.getTune || function () { return ownTune; };
+    var inputs = {}, outs = {};
+
+    if (!document.getElementById('ap-style')) {
+      var st = document.createElement('style'); st.id = 'ap-style'; st.textContent = CSS; document.head.appendChild(st);
+    }
+    var sheet = document.createElement('div'); sheet.className = 'ap-sheet';
+    var head = document.createElement('div'); head.className = 'ap-head';
+    head.innerHTML = '<b>Adjust before the shot</b>';
+    var resetB = document.createElement('button'); resetB.type = 'button'; resetB.textContent = '↺ Reset';
+    var doneB = document.createElement('button'); doneB.type = 'button'; doneB.textContent = 'Done';
+    head.appendChild(resetB); head.appendChild(doneB); sheet.appendChild(head);
+
+    CONTROLS.forEach(function (c) {
+      var row = document.createElement('label'); row.className = 'ap-row';
+      var name = document.createElement('span'); name.textContent = c[1];
+      var inp = document.createElement('input'); inp.type = 'range'; inp.min = c[2]; inp.max = c[3]; inp.step = 1;
+      var out = document.createElement('output');
+      inp.addEventListener('input', function () {
+        getTune()[c[0]] = parseInt(inp.value, 10) || 0; out.textContent = inp.value; changed();
+      });
+      inp.addEventListener('dblclick', function () { getTune()[c[0]] = 0; sync(); changed(); });
+      row.appendChild(name); row.appendChild(inp); row.appendChild(out); sheet.appendChild(row);
+      inputs[c[0]] = inp; outs[c[0]] = out;
+    });
+
+    var auto = null;
+    if (opts.showAutoSave !== false) {
+      var ar = document.createElement('label'); ar.className = 'ap-auto';
+      auto = document.createElement('input'); auto.type = 'checkbox'; auto.checked = readAuto();
+      auto.addEventListener('change', function () { writeAuto(auto.checked); });
+      ar.appendChild(auto); ar.appendChild(document.createTextNode('Auto-save as a draft once the label is read'));
+      sheet.appendChild(ar);
+    }
+    document.body.appendChild(sheet);
+
+    var toggle = document.createElement('button'); toggle.type = 'button';
+    toggle.textContent = opts.toggleText || '🎚 Adjust';
+    if (opts.toggleParent) { toggle.className = 'ap-toggle ' + (opts.toggleClass || ''); opts.toggleParent.appendChild(toggle); }
+    else { toggle.className = 'ap-toggle-float'; document.body.appendChild(toggle); }
+    toggle.addEventListener('click', function (e) { e.stopPropagation(); sheet.classList.toggle('open'); });
+    doneB.addEventListener('click', function () { sheet.classList.remove('open'); });
+    resetB.addEventListener('click', function () {
+      var t = getTune(); CONTROLS.forEach(function (c) { t[c[0]] = 0; }); sync(); changed();
+    });
+    sheet.addEventListener('click', function (e) { e.stopPropagation(); });   // pages close drawers on outside clicks
+
+    function sync() {
+      var t = getTune();
+      CONTROLS.forEach(function (c) { inputs[c[0]].value = t[c[0]] || 0; outs[c[0]].textContent = t[c[0]] || 0; });
+      toggle.classList.toggle('on', !isNeutral(t));
+    }
+    function changed() {
+      if (ownTune) CP.saveTune(opts.key || 'dwSimpleTune', ownTune);
+      toggle.classList.toggle('on', !isNeutral(getTune()));
+      if (opts.onChange) opts.onChange();
+    }
+    sync();
+
+    // ── live preview: a canvas over the <video>, same apply() as the bake (sharpness is capture-only
+    //    by the engine's contract). Hidden while the tune is neutral so the raw video shows. ──
+    var previews = [];
+    function tick() {
+      var t = getTune(), neutral = isNeutral(t);
+      previews.forEach(function (p) {
+        var v = p.video, cv = p.canvas;
+        var show = !neutral && v.videoWidth && v.offsetParent !== null;
+        cv.style.display = show ? 'block' : 'none';
+        if (!show) return;
+        var k = Math.min(1, 640 / Math.max(v.videoWidth, v.videoHeight));
+        var w = Math.round(v.videoWidth * k), h = Math.round(v.videoHeight * k);
+        if (cv.width !== w) cv.width = w; if (cv.height !== h) cv.height = h;
+        cv.className = 'ap-preview ' + v.className;   // carries e.g. the selfie .mirror transform
+        try {
+          var x = cv.getContext('2d', { willReadFrequently: true });
+          CP.drawSource(x, v, w, h, t.straighten);
+          CP.apply(x, w, h, t, { capture: false });
+        } catch (e) {}
+      });
+    }
+    var timer = null;
+    function attachPreview(video) {
+      var cv = document.createElement('canvas'); cv.className = 'ap-preview';
+      if (getComputedStyle(video.parentNode).position === 'static') video.parentNode.style.position = 'relative';
+      video.parentNode.insertBefore(cv, video.nextSibling);
+      previews.push({ video: video, canvas: cv });
+      if (!timer) timer = setInterval(tick, 80);
+    }
+
+    function bake(source, maxEdge, bo) {
+      var sw = source.videoWidth || source.naturalWidth || source.width;
+      var sh = source.videoHeight || source.naturalHeight || source.height;
+      var k = Math.min(1, (maxEdge || 2000) / Math.max(sw, sh));
+      var w = Math.round(sw * k), h = Math.round(sh * k), t = getTune();
+      var c = document.createElement('canvas'); c.width = w; c.height = h;
+      var x = c.getContext('2d', { willReadFrequently: true });
+      if (bo && bo.mirror) x.setTransform(-1, 0, 0, 1, w, 0);
+      CP.drawSource(x, source, w, h, t.straighten);
+      x.setTransform(1, 0, 0, 1, 0, 0);
+      if (!isNeutral(t)) CP.apply(x, w, h, t, { capture: true });
+      return c.toDataURL('image/jpeg', 0.92);
+    }
+    function bakeDataUrl(url, maxEdge) {
+      if (isNeutral(getTune())) return Promise.resolve(url);
+      return new Promise(function (res) {
+        var im = new Image();
+        im.onload = function () { try { res(bake(im, maxEdge)); } catch (e) { console.error('[adjust-panel] bake failed', e); res(url); } };
+        im.onerror = function () { res(url); };
+        im.src = url;
+      });
+    }
+
+    return {
+      attachPreview: attachPreview, bake: bake, bakeDataUrl: bakeDataUrl, sync: sync,
+      autoSave: function () { return auto ? auto.checked : false; },
+      toggle: toggle, tune: getTune, isNeutral: function () { return isNeutral(getTune()); }
+    };
+  }
+
+  window.AdjustPanel = { create: create, CONTROLS: CONTROLS };
+})();
diff --git a/public/simple-instant.html b/public/simple-instant.html
index c761f6a..588e821 100644
--- a/public/simple-instant.html
+++ b/public/simple-instant.html
@@ -248,6 +248,8 @@
 
 <script src="/js/field-clean.js"></script>
 <script src="/js/native-photo.js"></script>
+<script src="/js/capture-pipeline.js"></script>
+<script src="/js/adjust-panel.js"></script>
 <script>
 (function(){
   var $ = function(id){ return document.getElementById(id); };
@@ -265,6 +267,16 @@
   var busy = false, capturedDataUrl = null, extractDone = false, extractAbort = null;
   var vendorFromExtract = false, sessCount = 0;
 
+  // Pre-shot adjustments (all 11) + auto-save once the label read fills vendor + mfr#.
+  var panel = AdjustPanel.create({ key:'dwSimpleTune', toggleParent: $('fullResBtn').parentNode, toggleClass:'fullres-btn' });
+  panel.attachPreview(video);
+  var autoFired = false;
+  function maybeAutoSave(){
+    if (autoFired || !panel.autoSave() || !extractDone || chinFields.hidden || saveBtn.disabled) return;
+    if (!fVendor.value.trim() || !fMfr.value.trim()) return;
+    autoFired = true; saveBtn.click();
+  }
+
   function toast(msg, ms){
     toastEl.textContent = msg; toastEl.classList.add('show');
     clearTimeout(toast._t); toast._t = setTimeout(function(){ toastEl.classList.remove('show'); }, ms || 2600);
@@ -324,7 +336,7 @@
 
   async function doCapture(native){
     if (busy || (!stream && !native)) return;
-    busy = true; shutterBtn.disabled = true;
+    busy = true; shutterBtn.disabled = true; autoFired = false;
 
     if (native) {
       capturedDataUrl = native.full;
@@ -333,8 +345,7 @@
     var vw = video.videoWidth || 1280, vh = video.videoHeight || 960;
     var maxDim = 2000, scale = Math.min(1, maxDim / Math.max(vw, vh));
     canvas.width = Math.round(vw * scale); canvas.height = Math.round(vh * scale);
-    canvas.getContext('2d').drawImage(video, 0, 0, canvas.width, canvas.height);
-    capturedDataUrl = canvas.toDataURL('image/jpeg', 0.92);
+    capturedDataUrl = panel.bake(video, maxDim);   // pre-shot adjustments baked in
     }
 
     // ── SPEED: fire the REAL network call the instant we have pixels — before any
@@ -376,12 +387,14 @@
       else if (data && data.ok === false) tinyMeta.innerHTML = '🔎 checked in <b>' + fmt(elapsed) + '</b> · enter details by hand';
       else tinyMeta.innerHTML = '🔎 label read in <b>' + fmt(elapsed) + '</b>';
       updateSaveEnabled();
+      maybeAutoSave();
     });
   }
 
   function revealResult(){
     developingRow.hidden = true; chinFields.hidden = false;
     updateSaveEnabled();
+    maybeAutoSave();
     if (!extractDone) tinyMeta.innerHTML = '🔎 still reading the label…';
     fVendor.focus({ preventScroll:true });
   }
@@ -390,7 +403,10 @@
   // Full-res: the phone's own Camera app (real sensor photo); a 1600px copy feeds the label read.
   $('fullResBtn').addEventListener('click', function(){
     if (busy) return;
-    NativePhoto.pick().then(function(p){ if (p) doCapture(p); });
+    NativePhoto.pick().then(function(p){
+      if (!p) return;
+      panel.bakeDataUrl(p.full, NativePhoto.FULL_EDGE).then(function(full){ doCapture({ full: full, small: p.small }); });
+    });
   });
 
   retakeBtn.addEventListener('click', function(){
diff --git a/public/simple-minimal.html b/public/simple-minimal.html
index 5523fc5..48b5d82 100644
--- a/public/simple-minimal.html
+++ b/public/simple-minimal.html
@@ -36,6 +36,8 @@
 <title>DW Photo — Minimal Showroom</title>
 <script src="/js/field-clean.js"></script>
 <script src="/js/native-photo.js"></script>
+<script src="/js/capture-pipeline.js"></script>
+<script src="/js/adjust-panel.js"></script>
 <style>
   :root{ --bg:#0f0e0c; --panel:#1b1710; --card:#1b1916; --ink:#f3efe7; --muted:#9a9184;
          --line:#2e2a25; --gold:#c8a24a; --green:#3fa06a; --red:#c0563f; }
@@ -211,7 +213,10 @@
   var swAuto = $('swAuto'), swTiming = $('swTiming');
 
   // ── state ────────────────────────────────────────────────────────────────
-  var STATE = 'camera';          // camera | processing | result
+  var STATE = 'camera';
+  // pre-shot adjustments (all 11) — Minimal keeps its own Auto-create switch, so no second one here
+  var panel = AdjustPanel.create({ key:'dwSimpleTune', showAutoSave:false });
+  panel.attachPreview(camEl);          // camera | processing | result
   var stream = null, facing = 'environment';
   var AUTO_CREATE = true, SHOW_TIMING = false;
   var VENDORS = [];              // warmed speculatively, used only for the manual fallback
@@ -299,14 +304,8 @@
   // Another" needs no getUserMedia round trip either — just hide the frozen frame. ────────
   var shotCanvas = document.createElement('canvas');
   function captureFrame(){
-    var vw = camEl.videoWidth || 1280, vh = camEl.videoHeight || 960;
-    var k = Math.min(1, 2000 / Math.max(vw, vh));   // same 2000px max edge as Pro Booth
-    var w = Math.round(vw * k), h = Math.round(vh * k);
-    shotCanvas.width = w; shotCanvas.height = h;
-    var ctx = shotCanvas.getContext('2d');
-    if (facing === 'user') { ctx.translate(w,0); ctx.scale(-1,1); }
-    ctx.drawImage(camEl, 0, 0, w, h);
-    return shotCanvas.toDataURL('image/jpeg', 0.92);
+    // 2000px max edge (same as Pro Booth), with the pre-shot adjustments baked in
+    return panel.bake(camEl, 2000, { mirror: facing === 'user' });
   }
 
   var tStart = 0;
@@ -328,13 +327,15 @@
     if (STATE !== 'camera') return;
     NativePhoto.pick().then(function(p){
       if (!p) { return; }
+      return panel.bakeDataUrl(p.full, NativePhoto.FULL_EDGE).then(function(full){
       if (STATE !== 'camera') return;
       tStart = performance.now();
-      capturedDataUrl = p.full;
+      capturedDataUrl = full;
       shotEl.src = capturedDataUrl;
       shotEl.classList.add('on');
       toProcessing('Reading label…');
       runPipeline(capturedDataUrl, p.small);
+      });
     });
   });
 
diff --git a/public/simple-probooth.html b/public/simple-probooth.html
index 00cbb8f..391f056 100644
--- a/public/simple-probooth.html
+++ b/public/simple-probooth.html
@@ -66,6 +66,7 @@
   .bar .sp{flex:1}
   .chip{border:1px solid var(--line);border-radius:999px;padding:7px 14px;font-size:12.5px;color:var(--dim);background:rgba(0,0,0,.35)}
   .chip:active{color:var(--ink);border-color:var(--gold)}
+  .bar .chip{white-space:nowrap}
 
   /* ── LIVE ── */
   #live{background:#000}
@@ -247,6 +248,7 @@
 <script src="/js/acquire-camera.js"></script>
 <script src="/js/field-clean.js"></script>
 <script src="/js/native-photo.js"></script>
+<script src="/js/adjust-panel.js"></script>
 <script>
 'use strict';
 /* Pro Booth client — see the header comment for the speed architecture. */
@@ -267,18 +269,22 @@ function bindSliders(){
     el.addEventListener('input',()=>{
       tune[key]=parseInt(el.value,10)||0;
       $('#'+out).textContent=tune[key];
-      CapturePipeline.saveTune(TUNE_KEY,tune);
+      CapturePipeline.saveTune(TUNE_KEY,tune); panel.sync();
       hwNudge();
     });
   }
   $('#resetBtn').addEventListener('click',()=>{
     tune=CapturePipeline.defaultTune(); CapturePipeline.saveTune(TUNE_KEY,tune);
-    syncSliders(); hwNudge(); toast('Adjustments reset');
+    syncSliders(); panel.sync(); hwNudge(); toast('Adjustments reset');
   });
 }
 /* best-effort hardware assist (never authoritative — the software bake owns WYSIWYG) */
 let hwT=null;
 function hwNudge(){ clearTimeout(hwT); hwT=setTimeout(()=>{ try{ const tr=acq.getTrack(); if(tr) CapturePipeline.Hardware.apply(tr,tune); }catch(e){} },250); }
+/* all 11 adjustments: the panel edits the SAME tune object the 3 big sliders, preview and bake use */
+const panel = AdjustPanel.create({ getTune:()=>tune, toggleText:'🎚 All 11', toggleParent: document.querySelector('#live .bar'), toggleClass:'chip',
+  onChange:()=>{ CapturePipeline.saveTune(TUNE_KEY,tune); syncSliders(); hwNudge(); } });
+$('#resetBtn').before(panel.toggle);
 
 /* ── camera (shared hardened acquirer — bounded getUserMedia + play, generation-guarded) ── */
 const acq = AcquireCamera.createCameraAcquirer();
@@ -328,7 +334,7 @@ function previewTick(){
   const pv=$('#pv'); if(pv.width!==pw)pv.width=pw; if(pv.height!==ph)pv.height=ph;
   const x=pv.getContext('2d',{willReadFrequently:true});
   try{
-    CapturePipeline.drawSource(x,v,pw,ph,0);
+    CapturePipeline.drawSource(x,v,pw,ph,tune.straighten);
     CapturePipeline.apply(x,pw,ph,tune,{capture:false});
   }catch(e){}
 }
@@ -346,7 +352,7 @@ function bakeFrom(source, maxEdge){
   const ctx=c.getContext('2d',{willReadFrequently:true});
   const t0=performance.now();
   try{
-    CapturePipeline.drawSource(ctx,source,w,h,0);
+    CapturePipeline.drawSource(ctx,source,w,h,tune.straighten);
     CapturePipeline.apply(ctx,w,h,tune,{capture:true});   // ← the ONE full-res bake
   }catch(e){ return null; }
   const url=c.toDataURL('image/jpeg',0.92);
@@ -382,6 +388,9 @@ function fireExtract(url){
     extracted=f;
     st.textContent='✓ Label read — check the fields, then Save.'; st.classList.add('on');
     showTiming();
+    if(panel.autoSave() && $('#fVendor').value.trim() && $('#fMfr').value.trim() && !$('#saveBtn').disabled){
+      st.textContent='✓ Label read — saving draft…'; $('#saveBtn').click();
+    }
   });
 }
 let extracted={};
diff --git a/public/simple-wizard.html b/public/simple-wizard.html
index 11bc255..7011333 100644
--- a/public/simple-wizard.html
+++ b/public/simple-wizard.html
@@ -11,6 +11,8 @@
 <link rel="icon" href="/icon-192.png">
 <script src="/js/field-clean.js"></script>
 <script src="/js/native-photo.js"></script>
+<script src="/js/capture-pipeline.js"></script>
+<script src="/js/adjust-panel.js"></script>
 <style>
   /* ============================================================================================
      GUIDED WIZARD — TK-12162 concept-guided-wizard
@@ -352,9 +354,17 @@
   // ---------------------------------------------------------------------------------------------
   // STEP 1 → 2 : "I'm Ready"
   // ---------------------------------------------------------------------------------------------
+  // Pre-shot adjustments (all 11): live on both camera views, baked into whichever photo is taken.
+  const panel = AdjustPanel.create({ key:'dwSimpleTune', toggleParent: document.querySelector('#screen-1 .actions'), toggleClass:'btn-secondary' });
+  panel.attachPreview($('video')); panel.attachPreview($('video2'));
+
   // Full-res: the phone's own Camera app (real sensor photo). Works even if the live camera failed.
   $('btnNative').addEventListener('click', () => {
-    NativePhoto.pick().then(p => { if (!p) return; mark('captureTap'); grabFrame(p); });
+    NativePhoto.pick().then(p => {
+      if (!p) return;
+      mark('captureTap');
+      panel.bakeDataUrl(p.full, NativePhoto.FULL_EDGE).then(full => grabFrame({ full, small: p.small }));
+    });
   });
 
   $('btnReady').addEventListener('click', () => {
@@ -400,8 +410,7 @@
     const vw = v.videoWidth || 1280, vh = v.videoHeight || 960;
     const scale = Math.min(1, MAXW / Math.max(vw, vh));
     canvas.width = Math.round(vw * scale); canvas.height = Math.round(vh * scale);
-    canvas.getContext('2d').drawImage(v, 0, 0, canvas.width, canvas.height);
-    capturedDataUrl = canvas.toDataURL('image/jpeg', 0.92);
+    capturedDataUrl = panel.bake(v, MAXW);   // pre-shot adjustments baked in
     }
     mark('frameGrabbed');
 
@@ -416,6 +425,7 @@
     // already done, so only ONE round trip (/api/create-item) remains on the critical path
     // instead of two SEQUENTIAL ones. This mirrors Jev's "one request per decision, no idle
     // network gaps" — we just move the decision point earlier because we can predict it.
+    const shotRef = capturedDataUrl;
     $('analyzingPill').classList.add('show');
     mark('extractFired');
     extractPromise = fetch('/api/extract', {
@@ -425,6 +435,12 @@
       mark('extractResolved');
       extractResult = j;
       $('analyzingPill').classList.remove('show');
+      // auto-save: label read gave vendor + mfr# -> confirm for the guest (same path as the Use tap)
+      const jf = (j && j.fields) || {};
+      if (panel.autoSave() && capturedDataUrl === shotRef && currentStep === 3 && j && j.ok !== false
+          && (j.vendor_matched || cleanField(jf.vendor)) && cleanField(jf.mfr_sku) && !$('btnUse').disabled) {
+        setTimeout(() => { if (currentStep === 3 && capturedDataUrl === shotRef) $('btnUse').click(); }, 0);
+      }
       return j;
     }).catch(e => {
       mark('extractResolved');

← 4d1311b TK-12162: full-res native-camera capture on all simple-* pag  ·  back to Dw Photo Capture  ·  TK-12090: verify script resolves dwphoto by name, not stale 7e1353c →