← back to Dw Photo Capture

public/index.html

2026 lines

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, maximum-scale=1">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="DW SKU Photos">
<meta name="theme-color" content="#0f0e0c">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
<link rel="icon" type="image/png" href="/icon-180.png">
<link rel="manifest" href="/manifest.webmanifest">
<title>Mobile DW SKU Photos</title>
<style>
  :root{ --cols: 1; --bg:#0f0e0c; --card:#1b1916; --ink:#f3efe7; --muted:#9a9184; --line:#2e2a25;
         --gold:#c8a24a; --green:#3fa06a; --red:#c0563f; }
  *{box-sizing:border-box}
  body{margin:0;background:var(--bg);color:var(--ink);font:16px/1.4 -apple-system,BlinkMacSystemFont,"SF Pro Text",Segoe UI,Roboto,sans-serif;-webkit-text-size-adjust:100%}
  header{position:sticky;top:0;z-index:10;background:rgba(15,14,12,.96);backdrop-filter:blur(8px);border-bottom:1px solid var(--line);padding:max(10px,env(safe-area-inset-top)) 14px 10px}
  h1{font:600 17px/1.1 "SF Pro Display",sans-serif;letter-spacing:.04em;margin:0 0 8px;text-transform:uppercase}
  h1 span{color:var(--gold)}
  h1 .ver{font:600 10px/1 ui-monospace,Menlo,monospace;text-decoration:none;color:#1b1407;background:var(--green);border-radius:99px;padding:3px 7px;letter-spacing:.04em;vertical-align:middle;text-transform:none;font-style:normal;margin-left:6px}
  .bar{height:8px;border-radius:99px;background:#2a261f;overflow:hidden;margin:6px 0}
  .bar i{display:block;height:100%;background:linear-gradient(90deg,var(--gold),#e7c878);width:0;transition:width .3s}
  .meta{display:flex;justify-content:space-between;font-size:13px;color:var(--muted)}
  .controls{display:flex;gap:8px;align-items:center;margin-top:10px;flex-wrap:wrap}
  input[type=search],select{background:#16140f;border:1px solid var(--line);color:var(--ink);border-radius:10px;padding:9px 11px;font-size:15px}
  input[type=search]{flex:1;min-width:140px}
  .scanbtn{position:relative;overflow:hidden;background:#16140f;border:1px solid var(--gold);color:var(--gold);border-radius:10px;padding:9px 12px;font-size:14px;font-weight:600;white-space:nowrap;cursor:pointer}
  .scanbtn input{position:absolute;inset:0;opacity:0;font-size:120px;cursor:pointer}
  .chips{display:flex;gap:6px}
  .chip{padding:7px 13px;border-radius:99px;border:1px solid var(--line);background:#16140f;color:var(--muted);font-size:13px;cursor:pointer}
  .chip.on{background:var(--gold);color:#1b1407;border-color:var(--gold);font-weight:600}
  .recog{grid-column:1/-1;background:var(--card);border:1px solid var(--gold);border-radius:14px;padding:12px 14px;margin-bottom:4px}
  .recog .rg-d{font-size:14px;color:var(--ink);margin-bottom:8px}
  .recog .rg-d b{color:var(--gold)}
  .recog .rg-tags{display:flex;flex-wrap:wrap;gap:6px}
  .recog .rg-tags span{font-size:12px;padding:4px 9px;border-radius:99px;background:#16140f;border:1px solid var(--line);color:var(--muted)}
  .recog .rg-tags span.sw{padding-left:7px;display:inline-flex;align-items:center;gap:5px}
  .recog .rg-tags i{width:11px;height:11px;border-radius:99px;display:inline-block;border:1px solid rgba(255,255,255,.25)}
  .recog .rg-code{color:var(--gold);font-weight:600;cursor:pointer}
  .dens{display:flex;align-items:center;gap:6px;color:var(--muted);font-size:12px}
  input[type=range]{accent-color:var(--gold)}
  main{padding:14px;display:grid;grid-template-columns:repeat(var(--cols),1fr);gap:12px;max-width:1200px;margin:0 auto}
  .card{background:var(--card);border:1px solid var(--line);border-radius:16px;overflow:hidden;display:flex;flex-direction:column}
  .card.done{border-color:var(--green)}
  .thumb{aspect-ratio:4/3;background:#0c0b09 center/cover no-repeat;display:flex;align-items:center;justify-content:center;color:#4a443b;font-size:34px;position:relative}
  .thumb.tap{cursor:pointer} .thumb.tap:hover{filter:brightness(1.08)}
  .badge{position:absolute;top:8px;right:8px;font-size:11px;padding:3px 8px;border-radius:99px;font-weight:700;letter-spacing:.03em}
  .fav-btn{position:absolute;top:6px;left:6px;width:32px;height:32px;border:none;border-radius:50%;background:rgba(8,7,6,.55);color:#f3efe7;font-size:18px;line-height:1;cursor:pointer;display:flex;align-items:center;justify-content:center;backdrop-filter:blur(2px)}
  .fav-btn.on{color:var(--gold)}
  .fav-btn:active{transform:scale(.88)}
  .b-need{background:#3a2a14;color:#e7c878} .b-done{background:var(--green);color:#fff} .b-err{background:var(--red);color:#fff}
  /* Discontinued SKU → red card with live successor link */
  .disco-card{grid-column:1/-1;background:#2a1512;border:1px solid #c0392b;border-left:5px solid #c0392b;border-radius:16px;padding:16px 18px;display:flex;flex-direction:column;gap:10px}
  .disco-card .dhead{font:800 16px/1.3 "SF Pro Display",sans-serif;color:#e8776a;display:flex;align-items:center;gap:8px}
  .disco-card .dsku{font:600 12px/1 ui-monospace,Menlo,monospace;color:#e8a79e;letter-spacing:.03em}
  .disco-card .dstatus{font-size:10.5px;font-weight:700;letter-spacing:.05em;color:#fff;background:#c0392b;border-radius:99px;padding:2px 9px;text-transform:uppercase}
  .disco-succ{background:#16140f;border:1px solid #3a5a44;border-radius:12px;padding:12px 14px;display:flex;flex-direction:column;gap:6px}
  .disco-succ .slabel{font-size:11px;letter-spacing:.06em;text-transform:uppercase;color:#7fbf9a;font-weight:700}
  .disco-succ .stitle{font:600 15px/1.3 "SF Pro Display",sans-serif;color:var(--ink)}
  .disco-succ .ssku{font:600 12px/1 ui-monospace,Menlo,monospace;color:var(--green)}
  .disco-succ a{color:var(--green);text-decoration:none;font-weight:700}
  .disco-succ .go{align-self:flex-start;margin-top:4px;background:var(--green);color:#fff;border:none;border-radius:10px;padding:8px 15px;font-size:13.5px;font-weight:700;cursor:pointer;text-decoration:none;display:inline-block}
  .disco-nosucc{font-size:13px;color:var(--muted)}
  .b-live{background:#1f7a48;color:#fff;box-shadow:0 0 0 2px rgba(63,160,106,.4)}
  .b-new{background:#2a2350;color:#cbb8ff;box-shadow:0 0 0 1px #5a4aa0}
  .body{padding:11px 12px 13px;display:flex;flex-direction:column;gap:6px;flex:1}
  .ttl{font:600 15px/1.25 "SF Pro Display",sans-serif}
  .model{display:inline-flex;align-items:center;gap:6px;background:#22201a;border:1px solid var(--line);border-radius:8px;padding:5px 9px;align-self:flex-start;cursor:pointer}
  .model small{font-size:9.5px;letter-spacing:.12em;color:var(--muted);text-transform:uppercase}
  .model b{font:800 16px/1 ui-monospace,Menlo,monospace;color:var(--ink);letter-spacing:.03em}
  .sku{font:600 12px/1 ui-monospace,Menlo,monospace;color:var(--gold);letter-spacing:.02em}
  .sub{font-size:11.5px;color:var(--muted);display:flex;gap:8px;flex-wrap:wrap;align-items:center}
  .cols{display:flex;gap:5px;flex-wrap:wrap}
  .colchip{font-size:10px;color:#bcd;background:#16201c;border:1px solid #2a3a33;border-radius:99px;padding:2px 7px;white-space:nowrap}
  .price{color:var(--green);font-weight:700}
  .price.no{color:var(--muted)}
  .when{font-size:11px;color:var(--muted)}
  /* "make this the only image" — destructive opt-in, only shown when an existing image would be wiped */
  .onlyimg{display:flex;align-items:center;gap:7px;font-size:11.5px;color:var(--muted);margin-top:2px;cursor:pointer;user-select:none}
  .onlyimg input{accent-color:var(--red);width:15px;height:15px;flex:0 0 auto}
  .onlyimg.armed{color:var(--red);font-weight:600}
  .actions{display:flex;gap:8px;margin-top:auto;padding-top:6px}
  .shoot{flex:1;position:relative;overflow:hidden;background:var(--gold);color:#1b1407;border:none;border-radius:12px;padding:13px;font-weight:700;font-size:15px;text-align:center;cursor:pointer}
  .shoot input{position:absolute;inset:0;opacity:0;font-size:200px;cursor:pointer}
  .skip{background:#16140f;border:1px solid var(--line);color:var(--muted);border-radius:12px;padding:13px 14px;font-size:14px;cursor:pointer}
  .enh{background:#16140f;border:1px solid var(--gold);color:var(--gold);border-radius:12px;padding:13px 14px;font-size:14px;font-weight:600;cursor:pointer;white-space:nowrap}
  .gal{background:#16140f;border:1px solid var(--line);color:var(--ink);border-radius:12px;padding:13px 12px;font-size:14px;font-weight:600;cursor:pointer;white-space:nowrap}
  .card.done .shoot{background:#16140f;color:var(--gold);border:1px solid var(--gold)}
  .empty{grid-column:1/-1;text-align:center;color:var(--muted);padding:40px 10px}
  .toast{position:fixed;left:50%;bottom:24px;transform:translateX(-50%);background:#16140f;border:1px solid var(--gold);color:var(--ink);padding:11px 18px;border-radius:12px;font-size:14px;z-index:50;opacity:0;transition:opacity .2s;pointer-events:none}
  .toast.show{opacity:1}
  /* live SKU scanner */
  .scanlive{position:fixed;inset:0;z-index:110;background:#000;display:flex;align-items:center;justify-content:center}
  .scanlive[hidden]{display:none}
  #scanvideo{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}
  .scan-frame{position:relative;width:78%;max-width:520px;aspect-ratio:5/2;border:2px solid rgba(255,255,255,.85);border-radius:16px;box-shadow:0 0 0 4000px rgba(0,0,0,.45);overflow:hidden}
  .scan-line{position:absolute;left:0;right:0;height:2px;background:var(--gold);box-shadow:0 0 10px var(--gold);animation:scan 1.6s ease-in-out infinite}
  @keyframes scan{0%{top:8%}50%{top:90%}100%{top:8%}}
  .scan-status{position:absolute;bottom:max(90px,22%);left:50%;transform:translateX(-50%);background:rgba(8,7,6,.8);color:#fff;font:700 18px/1 ui-monospace,Menlo,monospace;letter-spacing:.04em;padding:11px 18px;border-radius:99px;border:1px solid rgba(255,255,255,.2)}
  /* LOCKED: the SKU is confirmed — the whole frame turns red, then the scanner goes */
  .scanlive.locked .scan-frame{border-color:var(--red);box-shadow:0 0 0 4000px rgba(0,0,0,.55),0 0 30px 4px var(--red) inset;animation:lockpulse .42s ease}
  .scanlive.locked .scan-line{display:none}
  .scanlive.locked .scan-status{background:var(--red);border-color:#fff;color:#fff;box-shadow:0 0 18px rgba(192,86,63,.85)}
  @keyframes lockpulse{0%{box-shadow:0 0 0 4000px rgba(0,0,0,.45),0 0 0 0 var(--red) inset}100%{box-shadow:0 0 0 4000px rgba(0,0,0,.55),0 0 30px 4px var(--red) inset}}
  .scan-fields{position:absolute;left:0;right:0;bottom:calc(84px + env(safe-area-inset-bottom,0px));display:flex;flex-wrap:wrap;gap:6px;justify-content:center;padding:0 12px;z-index:112;pointer-events:none}
  .sf-chip{display:inline-flex;align-items:center;gap:6px;font-size:12px;padding:5px 9px;border-radius:999px;background:rgba(20,20,20,.68);color:#9aa0a6;border:1px solid rgba(255,255,255,.16);backdrop-filter:blur(4px);max-width:46vw;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;transition:background .18s,color .18s,box-shadow .18s}
  .sf-chip b{font-weight:700;opacity:.6;font-size:10px;letter-spacing:.03em;text-transform:uppercase}
  .sf-chip i{font-style:normal;font-weight:600;max-width:34vw;overflow:hidden;text-overflow:ellipsis}
  .sf-chip.on{background:rgba(34,150,70,.94);color:#fff;border-color:#7CFFB0;box-shadow:0 0 10px rgba(46,200,90,.6)}
  .sf-chip.on b{opacity:.85}
  .scan-cancel{position:absolute;bottom:max(28px,env(safe-area-inset-bottom));left:50%;transform:translateX(-50%);background:#16140f;border:1px solid var(--line);color:var(--ink);border-radius:12px;padding:13px 26px;font-size:15px;font-weight:600;cursor:pointer}
  .scan-flip{position:absolute;bottom:max(28px,env(safe-area-inset-bottom));right:18px;background:#16140f;border:1px solid var(--line);color:var(--ink);border-radius:12px;padding:13px 18px;font-size:15px;font-weight:600;cursor:pointer}
  .scan-logo{position:absolute;bottom:max(28px,env(safe-area-inset-bottom));left:18px;background:#16140f;border:1px solid var(--gold);color:var(--gold);border-radius:12px;padding:13px 16px;font-size:15px;font-weight:600;cursor:pointer}
  .scan-pat{position:absolute;top:max(20px,env(safe-area-inset-top));right:18px;background:#16140f;border:1px solid var(--gold);color:var(--gold);border-radius:12px;padding:11px 15px;font-size:15px;font-weight:600;cursor:pointer}
  /* photo gallery */
  .gallery{position:fixed;inset:0;z-index:90;background:rgba(10,9,7,.98);display:flex;flex-direction:column}
  .gallery[hidden]{display:none}
  .gal-head{position:sticky;top:0;display:flex;align-items:center;gap:10px;padding:max(12px,env(safe-area-inset-top)) 14px 12px;border-bottom:1px solid var(--line);background:rgba(10,9,7,.98)}
  #galAnswer{margin:10px 14px;padding:12px 14px;border-radius:12px;background:rgba(212,175,107,.12);border:1px solid var(--line);color:#f3efe7;font-size:15px;line-height:1.45}
  #galAnswer .qq{color:var(--muted);font-size:13px;display:block;margin-bottom:5px}
  .gal-x{background:#16140f;border:1px solid var(--line);color:var(--ink);border-radius:10px;padding:9px 13px;font-size:14px;cursor:pointer}
  .gal-title{flex:1;min-width:0;line-height:1.2} .gal-title b{display:block;font:600 15px/1.2 "SF Pro Display",sans-serif} .gal-title span{font:600 12px/1 ui-monospace,Menlo,monospace;color:var(--gold)}
  .gal-add{position:relative;overflow:hidden;background:var(--gold);color:#1b1407;border-radius:10px;padding:9px 14px;font-weight:700;font-size:14px;cursor:pointer}
  .gal-add input{position:absolute;inset:0;opacity:0;font-size:120px;cursor:pointer}
  .gal-add-multi{background:#16140f;border:1px solid var(--gold);color:var(--gold)}
  .gal-grid{flex:1;overflow-y:auto;padding:14px;display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:12px;align-content:start}
  .gtile{position:relative;border:1px solid var(--line);border-radius:12px;overflow:hidden;background:#0c0b09;aspect-ratio:1}
  .gtile img{width:100%;height:100%;object-fit:cover;display:block}
  .gtile.feat{border-color:var(--gold);box-shadow:0 0 0 2px rgba(200,162,74,.4)}
  .gfeat-badge{position:absolute;top:6px;left:6px;background:var(--gold);color:#1b1407;font:700 10px/1 sans-serif;padding:3px 7px;border-radius:99px}
  .gtile-acts{position:absolute;bottom:0;left:0;right:0;display:flex;gap:1px}
  .gtile-acts button{flex:1;border:none;padding:9px 4px;font-size:12px;font-weight:600;cursor:pointer;background:rgba(15,14,12,.92);color:var(--ink)}
  .gtile-acts .g-feat{color:var(--gold)} .gtile-acts .g-del{color:var(--red)}
  .gal-empty{grid-column:1/-1;text-align:center;color:var(--muted);padding:40px 10px}

  /* ===== Photo editor (opens after capture, before upload) ===== */
  .editor{position:fixed;inset:0;z-index:100;background:rgba(8,7,6,.97);display:flex;flex-direction:column}
  .editor[hidden]{display:none}
  .ed-stage{flex:1;min-height:0;display:flex;align-items:center;justify-content:center;padding:12px 12px 0;overflow:hidden}
  #edcanvas{max-width:100%;max-height:100%;border-radius:12px;box-shadow:0 8px 40px rgba(0,0,0,.6);background:#000}
  /* sample-request sticker popup */
  .samp{position:fixed;inset:0;z-index:130;background:rgba(0,0,0,.6);display:flex;align-items:center;justify-content:center;padding:18px}
  .samp[hidden]{display:none}
  .samp-card{position:relative;width:100%;max-width:420px;background:#16140f;border:1px solid var(--line);border-radius:18px;padding:20px;box-shadow:0 20px 60px rgba(0,0,0,.6)}
  .samp-x{position:absolute;top:12px;right:12px;background:none;border:none;color:var(--ink);font-size:20px;cursor:pointer;opacity:.7}
  .samp-head{display:flex;gap:14px;align-items:center;margin-bottom:14px}
  #sampImg{width:72px;height:72px;object-fit:cover;border-radius:12px;flex-shrink:0;background:#000}
  .samp-pat{font-size:18px;font-weight:700;line-height:1.2}
  .samp-meta{font-size:13px;color:#b7ad98;margin-top:3px}
  .samp-client{border-top:1px solid var(--line);border-bottom:1px solid var(--line);padding:10px 0;margin-bottom:14px}
  .samp-row{display:flex;justify-content:space-between;gap:12px;padding:4px 0;font-size:14px}
  .samp-row span{color:#9a917d}
  .samp-row b{font-weight:600;text-align:right}
  .samp-btns{display:flex;flex-direction:column;gap:10px}
  .samp-print{padding:14px;border-radius:12px;border:none;font-size:16px;font-weight:700;cursor:pointer;background:#2b7a3f;color:#fff}
  .samp-print.samp-addr{background:#2d5b86}
  .samp-print:disabled{opacity:.5;cursor:default}
  .samp-note{font-size:12px;color:#9a917d;margin-top:10px;text-align:center;min-height:14px}
  .fb-tiles{display:flex;gap:12px;margin-bottom:14px}
  .fb-tile{flex:1;display:flex;flex-direction:column;align-items:center;gap:6px;background:#0f0e0b;border:1px dashed var(--line);border-radius:14px;padding:12px 6px;color:#b7ad98;font-size:13px;cursor:pointer}
  .fb-tile.set{border-style:solid;border-color:#2b7a3f;color:#fff}
  .fb-tile.need{border-style:solid;border-color:#b8902f;color:#fff;box-shadow:0 0 0 2px rgba(184,144,47,.35);animation:micpulse 1.1s infinite}
  #fbIncoming:empty{display:none}
  .inc-hd{font-size:12px;color:#b7ad98;margin-bottom:7px}.inc-hd b{color:#e7ddc7}
  .inc-strip{display:flex;gap:8px;overflow-x:auto;padding-bottom:6px;margin-bottom:12px;-webkit-overflow-scrolling:touch}
  .inc-card{flex:none;width:104px;border:1px solid var(--line);border-radius:10px;overflow:hidden;background:#0f0e0b;cursor:pointer}
  .inc-card.hit{border-color:#2bd06a;box-shadow:0 0 0 2px rgba(43,208,106,.4)}
  .inc-thumb{width:104px;height:82px;background:#000 center/cover no-repeat;display:flex;align-items:center;justify-content:center;font-size:22px;color:#5a544a}
  .inc-meta{padding:5px 6px}.inc-pat{font-size:11px;color:#e7ddc7;line-height:1.25;max-height:28px;overflow:hidden}
  .inc-sub{font-size:10px;color:#9a917d;margin-top:2px}
  .inc-recv{margin-top:5px;width:100%;padding:5px 4px;font-size:11px;font-weight:600;border:1px solid #2bd06a;background:rgba(43,208,106,.12);color:#2bd06a;border-radius:7px;cursor:pointer}
  .inc-recv:hover{background:rgba(43,208,106,.22)}.inc-recv:disabled{opacity:.55;cursor:default}
  .wp-d-card{margin-top:10px;border:1px solid var(--line);border-radius:12px;padding:11px 12px;background:#0f0e0b}
  .wp-d-hd{font-size:12px;font-weight:600;color:#c8bfa8;margin-bottom:8px}.wp-d-empty{color:#7d7663;font-weight:400}
  .wp-d-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:5px 14px}
  .wp-d-row{display:flex;justify-content:space-between;gap:8px;font-size:12px;border-bottom:1px dotted #2a2721;padding-bottom:3px}
  .wp-d-lbl{color:#8f887a}.wp-d-val{color:#eadfc6;font-weight:600;text-align:right}
  .wp-d-desc{margin-top:8px;font-size:11px;color:#b3aa93;line-height:1.4}
  .fb-read{margin-top:6px;font-size:11px;color:#8f887a;font-family:ui-monospace,Menlo,monospace;word-break:break-word}
  .fb-search{display:flex;gap:7px;margin:10px 0 2px}
  .fb-search input{flex:1;min-width:0;padding:9px 11px;font-size:14px;border:1px solid var(--line);border-radius:9px;background:#0f0e0b;color:#eadfc6}
  .fb-search input::placeholder{color:#6f6857}
  .fb-search button{flex:none;padding:9px 14px;font-size:13px;font-weight:600;border:1px solid var(--line);border-radius:9px;background:#1a1813;color:#c8bfa8;cursor:pointer}
  .fb-search button:hover{background:#242017}
  .wp-c-list{display:flex;flex-direction:column;gap:6px;margin-top:8px}
  .wp-c{border:1px solid var(--line);border-radius:10px;padding:8px 10px;background:#0f0e0b;cursor:pointer}
  .wp-c:hover{border-color:#c8bfa8;background:#161410}
  .wp-c-top{display:flex;justify-content:space-between;gap:8px;align-items:baseline}
  .wp-c-top b{font-size:13px;color:#eadfc6}.wp-c-sku{font-size:11px;color:#8f887a}
  .wp-c-sub{font-size:11px;color:#9a917d;margin-top:2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
  .fb-thumb{width:100%;aspect-ratio:1/1;border-radius:10px;background:#000 center/cover no-repeat;display:flex;align-items:center;justify-content:center;font-size:30px}
  #fbResult{margin-top:12px}
  .fb-hit{display:flex;gap:10px;align-items:center;padding:10px;border:1px solid var(--line);border-radius:12px;margin-top:8px}
  .fb-hit img{width:54px;height:54px;object-fit:cover;border-radius:8px;background:#000;flex-shrink:0}
  .fb-conf{display:inline-block;font-size:11px;font-weight:700;padding:2px 8px;border-radius:999px;margin-left:6px}
  .fb-conf.veryhigh,.fb-conf.high{background:#2b7a3f;color:#fff}.fb-conf.medium{background:#b8902f;color:#fff}.fb-conf.low{background:#7a3b2b;color:#fff}
  .add-field{margin-bottom:10px;flex:1}.add-field label{display:block;font-size:11px;color:#9a917d;margin-bottom:3px}
  .add-field input,.add-field select{width:100%;box-sizing:border-box;background:#0f0e0b;border:1px solid var(--line);border-radius:8px;padding:9px;color:var(--ink);font-size:15px}
  .add-row{display:flex;gap:10px}
  .add-field input.need,.add-field select.need{border-color:#b8902f;box-shadow:0 0 0 2px rgba(184,144,47,.35)}
  .in-mic{display:flex;gap:6px;align-items:stretch}.in-mic input,.in-mic select{flex:1;min-width:0}
  .mic-btn{flex:none;width:42px;border:1px solid var(--line);border-radius:8px;background:#151310;color:#e7ddc7;font-size:17px;cursor:pointer;line-height:1}
  .mic-btn:active{transform:scale(.94)}.mic-btn.on{background:#7a2b2b;border-color:#b84a4a;color:#fff;animation:micpulse 1s infinite}
  @keyframes micpulse{50%{background:#a53a3a}}
  .add-voice{display:block;width:100%;box-sizing:border-box;margin:0 0 12px;padding:11px;border:1px solid var(--line);border-radius:10px;background:#151310;color:#e7ddc7;font-size:14px;font-weight:600;cursor:pointer}
  .add-voice:active{transform:scale(.99)}
  .media-sub{font-size:12px;color:#b7ad98;margin-bottom:10px;min-height:0}
  .media-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:7px;margin-bottom:10px}
  .media-grid:empty{display:none}
  .media-cell{position:relative;aspect-ratio:1/1;border-radius:10px;background:#000 center/cover no-repeat;overflow:hidden;border:1px solid var(--line)}
  .media-cell video{width:100%;height:100%;object-fit:cover}
  .media-cell .badge{position:absolute;left:4px;bottom:4px;font-size:15px;filter:drop-shadow(0 1px 2px #000)}
  .media-cell .rm{position:absolute;top:2px;right:2px;width:22px;height:22px;border:none;border-radius:50%;background:rgba(0,0,0,.65);color:#fff;font-size:14px;line-height:22px;cursor:pointer;padding:0}
  .media-add{display:flex;gap:10px;margin-bottom:12px}
  .media-btn{flex:1;padding:14px 6px;border:1px dashed var(--line);border-radius:12px;background:#0f0e0b;color:#e7ddc7;font-size:14px;font-weight:600;cursor:pointer}
  .media-btn:active{transform:scale(.98)}.media-btn:disabled{opacity:.4;cursor:not-allowed}
  .media-btn span{opacity:.65;font-weight:400;font-size:12px}
  .vid-cands{display:flex;flex-wrap:wrap;gap:6px;margin-top:6px}
  .vid-chip{padding:6px 10px;border:1px solid var(--line);border-radius:999px;background:#151310;color:#e7ddc7;font-size:12px;cursor:pointer}
  .vid-chip:active{transform:scale(.96)}
  #measureModal{position:fixed;inset:0;z-index:400;background:#000}
  #measureModal[hidden]{display:none}
  #mVideo{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}
  .mbox{position:absolute;border:2px solid;border-radius:6px;touch-action:none;min-width:34px;min-height:34px}
  .mbox.mcard{border-color:#ff5a5a;box-shadow:0 0 0 9999px rgba(0,0,0,.02)}
  .mbox.mgoods{border-color:#2bd06a}
  .mlbl{position:absolute;top:-9px;left:6px;font-size:11px;background:#000;padding:0 5px;color:#fff}
  .mcard .mlbl{color:#ff9a9a}.mgoods .mlbl{color:#7cf0a6}
  .mrz{position:absolute;right:-13px;bottom:-13px;width:26px;height:26px;background:rgba(255,255,255,.9);border-radius:50%;touch-action:none;cursor:nwse-resize}
  .mreadout{position:absolute;top:14px;left:0;right:0;text-align:center;color:#fff;font-size:17px;text-shadow:0 1px 4px #000;pointer-events:none}
  .mreadout b{color:#ffe08a}
  .mbar{position:absolute;bottom:22px;left:0;right:0;display:flex;gap:12px;justify-content:center}
  .mbar button{padding:13px 20px;border:none;border-radius:999px;font-size:15px;font-weight:700;cursor:pointer}
  #mUse{background:#2bd06a;color:#032}#mClose{background:#222;color:#fff}
  .add-spec{display:flex;justify-content:space-between;gap:12px;font-size:12.5px;padding:4px 0;border-bottom:1px solid rgba(255,255,255,.05)}
  .add-spec span{color:#9a917d}.add-spec b{color:var(--ink);text-align:right}
  /* onload chooser — three white pills on black */
  #homeScreen{position:fixed;inset:0;z-index:200;background:#000;display:flex;align-items:center;justify-content:center}
  #homeScreen[hidden]{display:none}
  .home-inner{display:flex;flex-direction:column;gap:16px;align-items:center;width:100%;max-width:340px;padding:24px}
  .home-title{color:#fff;font-size:14px;letter-spacing:.1em;text-transform:uppercase;opacity:.55;margin-bottom:16px}
  .home-pill{width:100%;background:#fff;color:#000;border:none;border-radius:999px;padding:20px;font-size:19px;font-weight:700;cursor:pointer}
  .home-pill:active{transform:scale(.98)}
  .home-skip{margin-top:10px;background:none;border:none;color:#fff;opacity:.4;font-size:14px;cursor:pointer}
  #homeBtn{background:none;border:none;color:var(--ink);opacity:.5;font-size:18px;cursor:pointer;float:right}
  .ed-panel{flex:0 0 auto;background:var(--card);border-top:1px solid var(--line);padding:12px 14px max(14px,env(safe-area-inset-bottom));max-height:54vh;overflow-y:auto}
  .ed-head{display:flex;align-items:center;gap:10px;margin-bottom:10px}
  .ed-head .t{font:600 13px/1 "SF Pro Display",sans-serif;letter-spacing:.05em;text-transform:uppercase;color:var(--gold)}
  .ed-head .s{font:600 12px/1 ui-monospace,Menlo,monospace;color:var(--muted)}
  .roll-toggle{margin-left:auto;background:#16140f;border:1px solid var(--line);color:var(--ink);border-radius:99px;padding:7px 14px;font-size:13px;font-weight:600;cursor:pointer}
  .roll-toggle.on{background:var(--gold);color:#1b1407;border-color:var(--gold)}
  .ed-grid{display:grid;grid-template-columns:auto 1fr;gap:14px;align-items:start}
  .ed-wheel-wrap{display:flex;flex-direction:column;align-items:center;gap:6px}
  .wheel-box{position:relative;width:128px;height:128px;touch-action:none}
  #wheel{width:128px;height:128px;border-radius:50%;cursor:crosshair;box-shadow:inset 0 0 0 1px var(--line)}
  .wheel-thumb{position:absolute;width:16px;height:16px;border-radius:50%;border:2px solid #fff;box-shadow:0 0 0 1px rgba(0,0,0,.5);transform:translate(-50%,-50%);left:50%;top:50%;pointer-events:none}
  .wheel-lbl{font-size:10.5px;letter-spacing:.1em;color:var(--muted);text-transform:uppercase;text-align:center}
  .wheel-lbl b{display:block;color:var(--ink);font-size:11px;letter-spacing:.02em;text-transform:none;margin-top:2px}
  .sliders{display:flex;flex-direction:column;gap:9px}
  .sl{display:grid;grid-template-columns:74px 1fr 44px;align-items:center;gap:10px}
  .sl label{font-size:12px;color:var(--muted)}
  .sl input[type=range]{width:100%;accent-color:var(--gold)}
  .sl .v{font:600 12px/1 ui-monospace,Menlo,monospace;color:var(--ink);text-align:right}
  .ed-crop{background:#16140f;border:1px solid var(--gold);color:var(--gold)}
  /* crop overlay */
  .cropov{position:fixed;inset:0;z-index:120}
  .cropov[hidden]{display:none}
  .cropbox{position:fixed;border:1px solid #fff;box-shadow:0 0 0 4000px rgba(0,0,0,.55);cursor:move;touch-action:none;
    background-image:linear-gradient(rgba(255,255,255,.25) 1px,transparent 1px),linear-gradient(90deg,rgba(255,255,255,.25) 1px,transparent 1px);background-size:33.33% 33.33%}
  .cropbox i{position:absolute;width:26px;height:26px;border:3px solid var(--gold);background:rgba(0,0,0,.25);box-sizing:border-box;touch-action:none}
  .cropbox i[data-h=nw]{left:-3px;top:-3px;border-right:0;border-bottom:0;cursor:nwse-resize}
  .cropbox i[data-h=ne]{right:-3px;top:-3px;border-left:0;border-bottom:0;cursor:nesw-resize}
  .cropbox i[data-h=sw]{left:-3px;bottom:-3px;border-right:0;border-top:0;cursor:nesw-resize}
  .cropbox i[data-h=se]{right:-3px;bottom:-3px;border-left:0;border-top:0;cursor:nwse-resize}
  .crop-presets{position:fixed;left:50%;top:max(14px,env(safe-area-inset-top));transform:translateX(-50%);display:flex;gap:7px;z-index:121}
  .crop-presets button{background:rgba(8,7,6,.75);border:1px solid var(--line);color:var(--ink);border-radius:99px;padding:8px 13px;font-size:13px;font-weight:600;cursor:pointer}
  .crop-presets button.on{background:var(--gold);color:#1b1407;border-color:var(--gold)}
  .crop-bar{position:fixed;left:50%;bottom:max(24px,env(safe-area-inset-bottom));transform:translateX(-50%);display:flex;gap:12px;z-index:121}
  .crop-bar button{border:none;border-radius:12px;padding:14px 26px;font-weight:700;font-size:15px;cursor:pointer}
  #cropCancel{background:#16140f;border:1px solid var(--red);color:var(--red)}
  #cropApply{background:var(--gold);color:#1b1407}
  .ed-actions{display:flex;gap:10px;margin-top:14px}
  .ed-actions button{flex:1;border:none;border-radius:12px;padding:14px;font-weight:700;font-size:15px;cursor:pointer}
  .ed-reset{background:#16140f;border:1px solid var(--line);color:var(--muted)}
  .ed-cancel{background:#16140f;border:1px solid var(--red);color:var(--red)}
  .ed-use{background:var(--gold);color:#1b1407;flex:1.6}
</style>
</head>
<body>
<!-- onload chooser: three simple pills, white on black, centered -->
<div id="homeScreen">
  <div class="home-inner">
    <div class="home-title">DW Sample Station</div>
    <button class="home-pill" data-act="add">Add New SKU</button>
    <button class="home-pill" data-act="update">Update SKU</button>
    <button class="home-pill" data-act="lookup">Look Up SKU</button>
    <button class="home-pill" data-act="check">Check Sample In</button>
    <button class="home-skip" data-act="skip">skip →</button>
  </div>
</div>
<header>
  <h1>Mobile DW SKU <span>Photos</span> <a href="/selfcheck" class="ver" title="tap for self-check">__VER__</a> <button id="homeBtn" title="home">⌂</button></h1>
  <div class="bar"><i id="prog"></i></div>
  <div class="meta"><span id="count">…</span><span id="remain"></span></div>
  <div class="controls">
    <input type="search" id="q" placeholder="🔎 Search name or model #…" autocapitalize="characters">
    <button class="scanbtn" id="scanBtn" title="Scan a printed SKU/model #">🔢 Scan</button>
    <button class="scanbtn" id="micBtn" title="Voice search — say a vendor, SKU, or what to look for">🎤</button>
    <button class="scanbtn" id="camBtn" title="Choose camera (back / front)">📷 Back</button>
    <button class="scanbtn" id="simBtn" title="Photograph a pattern/swatch → find this + similar across the whole catalog">🎨 Similar</button>
    <button class="scanbtn" id="fbBtn" title="Identify by photos of the BACK label + FRONT pattern (best ID: code + visual)">🔎 ID F+B</button>
    <button class="scanbtn" id="addBtn" title="Add a NEW item to the catalog + Shopify (draft)">➕ New item</button>
    <input type="file" accept="image/*" capture="environment" id="scanInput" hidden>
    <input type="file" accept="image/*" capture="environment" id="simInput" hidden>
    <input type="file" accept="image/*" capture="environment" id="fbBackInput" hidden>
    <input type="file" accept="image/*" capture="environment" id="fbFrontInput" hidden>
    <select id="sort">
      <option value="ready">Priced first</option>
      <option value="title">Title A→Z</option>
      <option value="sku">SKU A→Z</option>
    </select>
  </div>
  <div class="controls">
    <div class="chips">
      <div class="chip" data-f="need">Needs photo</div>
      <div class="chip" data-f="live">🟢 Live</div>
      <div class="chip" data-f="done">Done</div>
      <div class="chip" data-f="new" id="chipNew">✨ New</div>
      <div class="chip on" data-f="twil" id="chipTwil">🧵 All TWIL</div>
      <div class="chip" data-f="all">All</div>
      <div class="chip" data-f="any">🔎 Any SKU</div>
      <div class="chip" data-f="shop">🌐 All Shopify</div>
      <div class="chip" data-f="similar" id="chipSim">🎨 Similar</div>
      <div class="chip" data-f="fav">⭐ Favorites</div>
      <div class="chip" data-f="recent">🕘 Recent</div>
    </div>
    <div class="dens">Size <input type="range" id="dens" min="1" max="4" value="1"></div>
  </div>
</header>
<main id="grid"></main>
<div class="toast" id="toast"></div>

<!-- ===== Live SKU scanner: camera auto-locks on a printed SKU, then looks it up ===== -->
<div class="scanlive" id="scanlive" hidden>
  <video id="scanvideo" playsinline muted></video>
  <div class="scan-frame"><div class="scan-line"></div></div>
  <div class="scan-status" id="scanStatus">Point at the printed SKU…</div>
  <div class="scan-fields" id="scanFields">
    <span class="sf-chip" data-k="sku"><b>SKU#</b> <i>—</i></span>
    <span class="sf-chip" data-k="model"><b>Model#</b> <i>—</i></span>
    <span class="sf-chip" data-k="name"><b>Name</b> <i>—</i></span>
    <span class="sf-chip" data-k="color"><b>Color</b> <i>—</i></span>
  </div>
  <button class="scan-flip" id="scanFlip">🔄 Flip camera</button>
  <button class="scan-logo" id="scanLogo" title="Identify the brand by its logo / typeface (local AI)">🏷 Logo</button>
  <button class="scan-pat" id="scanPat" title="Recognize the PATTERN → find this + similar across the whole catalog (local AI)">🎨 Pattern</button>
  <button class="scan-cancel" id="scanCancel">✕ Cancel</button>
</div>

<!-- ===== Photo gallery (all photos for a SKU: keep / remove / feature / add) ===== -->
<div class="gallery" id="gallery" hidden>
  <div class="gal-head">
    <button class="gal-x" id="galClose">‹ Back</button>
    <div class="gal-title"><b id="galName"></b><span id="galSku"></span></div>
    <label class="gal-add gal-add-multi">➕ Multiple<input type="file" accept="image/*" multiple id="galAddMulti"></label>
    <label class="gal-add">📷 Add<input type="file" accept="image/*" capture="environment" id="galAdd"></label>
    <label class="gal-add gal-add-multi" title="Upload a product video">🎥 Video<input type="file" accept="video/*" capture="environment" id="galVid"></label>
    <button class="gal-add" id="galAsk" title="Ask a question about this SKU (local AI)">🎤 Ask</button>
  </div>
  <div id="galAnswer" hidden></div>
  <div class="gal-grid" id="galGrid"></div>
</div>

<!-- ===== Photo editor modal (review before upload) ===== -->
<div class="editor" id="editor" hidden>
  <div class="ed-stage"><canvas id="edcanvas"></canvas></div>
  <div class="ed-panel">
    <div class="ed-head"><span class="t">Enhance</span><span class="s" id="ed-sku"></span><button class="roll-toggle" id="edRoll">🗞 Roll</button></div>
    <div class="ed-grid">
      <div class="ed-wheel-wrap">
        <div class="wheel-box" id="wheelBox">
          <canvas id="wheel" width="128" height="128"></canvas>
          <div class="wheel-thumb" id="wheelThumb"></div>
        </div>
        <div class="wheel-lbl">Color Balance<b id="wheelVal">neutral</b></div>
      </div>
      <div class="sliders">
        <div class="sl"><label>Brightness</label><input type="range" id="edB" min="0.4" max="1.6" step="0.01" value="1"><span class="v" id="edBv">0</span></div>
        <div class="sl"><label>Contrast</label><input type="range" id="edC" min="0.5" max="1.6" step="0.01" value="1"><span class="v" id="edCv">0</span></div>
        <div class="sl"><label>Saturation</label><input type="range" id="edS" min="0" max="2.2" step="0.01" value="1"><span class="v" id="edSv">0</span></div>
        <div class="sl"><label>Hue</label><input type="range" id="edH" min="-180" max="180" step="1" value="0"><span class="v" id="edHv">0°</span></div>
        <div class="sl" id="edCurlRow" style="display:none"><label>🗞 Curl</label><input type="range" id="edCurl" min="0" max="1" step="0.01" value="0.5"><span class="v" id="edCurlv">50%</span></div>
      </div>
    </div>
    <div class="ed-actions">
      <button class="ed-crop" id="edCrop">✂️ Crop</button>
      <button class="ed-reset" id="edReset">Reset</button>
      <button class="ed-cancel" id="edCancel">Cancel</button>
      <button class="ed-use" id="edUse">✓ Use Photo</button>
    </div>

<!-- crop overlay (Photoshop-style: drag handles, dim outside) -->
<div class="cropov" id="cropov" hidden>
  <div class="crop-presets" id="cropPresets">
    <button class="on" data-r="0">Free</button>
    <button data-r="1">1:1</button>
    <button data-r="1.3333">4:3</button>
    <button data-r="0.75">3:4</button>
    <button data-r="1.7778">16:9</button>
  </div>
  <div class="cropbox" id="cropbox"><i data-h="nw"></i><i data-h="ne"></i><i data-h="sw"></i><i data-h="se"></i></div>
  <div class="crop-bar"><button id="cropCancel">✕ Cancel</button><button id="cropApply">✓ Apply Crop</button></div>
</div>
  </div>
</div>

<!-- sample-request sticker popup: scan → who ordered this sample → print SKU / address sticker -->
<div class="samp" id="sampleModal" hidden>
  <div class="samp-card">
    <button class="samp-x" id="sampClose">✕</button>
    <div class="samp-head">
      <img id="sampImg" alt="" hidden>
      <div class="samp-id">
        <div class="samp-pat" id="sampPat">—</div>
        <div class="samp-meta" id="sampMeta"></div>
      </div>
    </div>
    <div class="samp-client">
      <div class="samp-row"><span>Client ordered</span><b id="sampClient">—</b></div>
      <div class="samp-row"><span>Client date</span><b id="sampOrdered">—</b></div>
      <div class="samp-row"><span>Vendor ordered</span><b id="sampVendorDate">—</b></div>
    </div>
    <div class="samp-btns">
      <button class="samp-print" id="sampPrintSku">🎫 Print SKU sticker</button>
      <button class="samp-print samp-addr" id="sampPrintAddr">✉️ Print address sticker</button>
    </div>
    <div class="samp-note" id="sampNote"></div>
  </div>
</div>

<!-- front + back identify: fuse back-label code + front-pattern visual match -->
<div class="samp" id="fbModal" hidden>
  <div class="samp-card">
    <button class="samp-x" id="fbClose">✕</button>
    <div class="samp-pat" style="margin-bottom:4px">Identify a sample</div>
    <div class="samp-meta" style="margin-bottom:12px">Photograph the BACK label, the FRONT pattern, or both — best ID uses both.</div>
    <div id="fbIncoming"></div>
    <div class="fb-tiles">
      <button class="fb-tile" id="fbBackTile"><div class="fb-thumb" id="fbBackThumb">📄</div><span>Back · label</span></button>
      <button class="fb-tile" id="fbFrontTile"><div class="fb-thumb" id="fbFrontThumb">🎨</div><span>Front · pattern</span></button>
    </div>
    <button class="samp-print" id="fbGo" disabled>🔎 Identify</button>
    <div class="fb-search"><input id="fbSearchInput" type="search" inputmode="search" autocomplete="off" placeholder="or type any part of a SKU / description — e.g. 21750"><button type="button" id="fbSearchBtn">Search</button></div>
    <div class="samp-note" id="fbNote"></div>
    <div id="fbResult"></div>
  </div>
</div>

<!-- add a NEW item to catalog + Shopify (draft), with vendor/vid dropdown -->
<div class="samp" id="addModal" hidden>
  <div class="samp-card">
    <button class="samp-x" id="addClose">✕</button>
    <div class="samp-pat" id="addTitle" style="margin-bottom:4px">Add new item</div>
    <div class="media-sub" id="addTarget"></div>
    <div class="media-grid" id="addMedia"></div>
    <div class="media-add">
      <button class="media-btn" id="addPhotoBtn">📷 Photo <span id="cPhoto">0/10</span></button>
      <button class="media-btn" id="addVideoBtn">🎥 Video <span id="cVideo">0/6</span></button>
    </div>
    <input type="file" accept="image/*" capture="environment" id="addPhotoInput" hidden>
    <input type="file" accept="video/*" capture="environment" id="addVideoInput" hidden>
    <button class="add-voice" id="addMeasure">📐 Measure real size (place a card by the goods)</button>
    <button class="add-voice" id="addVoice">🎤 Voice-fill everything that's missing</button>
    <div class="add-field"><label>Mfr # / SKU</label><div class="in-mic"><input id="addMfr" autocapitalize="characters" placeholder="e.g. TR2581"><button class="mic-btn" data-mic="addMfr" data-kind="sku" title="Speak the SKU / mfr number">🎤</button></div></div>
    <div class="add-field"><label>Vendor</label><div class="in-mic"><select id="addVendor"><option value="">Loading…</option></select><button class="mic-btn" data-mic="addVendor" data-kind="vendor" title="Speak the vendor name">🎤</button></div></div>
    <div class="add-row"><div class="add-field"><label>VID</label><div class="in-mic"><input id="addVid" placeholder="THI"><button class="mic-btn" data-mic="addVid" data-kind="text" title="Speak the VID">🎤</button></div></div><div class="add-field"><label>Color</label><div class="in-mic"><input id="addColor"><button class="mic-btn" data-mic="addColor" data-kind="text" title="Speak the color">🎤</button></div></div></div>
    <div class="add-row"><div class="add-field"><label>Pattern name</label><div class="in-mic"><input id="addName"><button class="mic-btn" data-mic="addName" data-kind="text" title="Speak the pattern name">🎤</button></div></div><div class="add-field"><label>Price</label><div class="in-mic"><input id="addPrice" type="number" inputmode="decimal"><button class="mic-btn" data-mic="addPrice" data-kind="num" title="Speak the price">🎤</button></div></div></div>
    <div id="addSpecs"></div>
    <button class="samp-print" id="addPreview">👁 Preview</button>
    <div class="samp-note" id="addNote"></div>
    <div id="addResult"></div>
  </div>
</div>

<!-- Live on-camera dimension measure: a credit card (3.375") in frame calibrates real inches -->
<div id="measureModal" hidden>
  <video id="mVideo" autoplay playsinline muted></video>
  <div class="mbox mcard" id="mCard"><span class="mlbl">💳 card</span><i class="mrz"></i></div>
  <div class="mbox mgoods" id="mSample"><span class="mlbl">📦 goods</span><i class="mrz"></i></div>
  <div class="mreadout" id="mReadout">Size 💳 to a credit card, 📦 to the goods</div>
  <div class="mbar">
    <button id="mSaveCal" hidden>✓ Save calibration</button>
    <button id="mUse" hidden>✓ Use W×L</button>
    <button id="mRecal" hidden>↺ Recalibrate</button>
    <button id="mClose">✕ Close</button>
  </div>
</div>
<script>
const $=s=>document.querySelector(s), grid=$('#grid');
let ITEMS=[], filter='twil', collapsed=false; // default landing = All TWIL
const LS=k=>localStorage.getItem('dwphoto_'+k), setLS=(k,v)=>localStorage.setItem('dwphoto_'+k,v);

// Density: set grid-template-columns DIRECTLY (older iOS Safari ignores var() as
// the count inside repeat() — writing the resolved repeat(N,1fr) works everywhere).
function applyDensity(n){
  const c=Math.max(1,Math.min(4,parseInt(n,10)||1));
  document.documentElement.style.setProperty('--cols',c);
  $('#grid').style.gridTemplateColumns='repeat('+c+',1fr)';
}

// restore prefs
if(LS('dens')){$('#dens').value=LS('dens');applyDensity(LS('dens'));}
else applyDensity($('#dens').value);
if(LS('sort'))$('#sort').value=LS('sort');
if(LS('filter')){filter=LS('filter');document.querySelectorAll('.chip').forEach(c=>c.classList.toggle('on',c.dataset.f===filter));}

function toast(m){const t=$('#toast');t.textContent=m;t.classList.add('show');clearTimeout(t._t);t._t=setTimeout(()=>t.classList.remove('show'),2200);}
function fmt(ts){if(!ts)return'';try{return new Date(ts).toLocaleString(undefined,{month:'short',day:'numeric',hour:'numeric',minute:'2-digit'})}catch(e){return''}}

async function load(){
  const r=await fetch('/api/queue'); const d=await r.json();
  ITEMS=d.items; render();
}
function priced(x){return x.price && parseFloat(x.price)>4.25;}
let LOOKUP=[], LOOKUP_INDEXED=0, _lkT=0, DISCO=null;
async function doLookup(){
  const q=($('#q').value||'').trim();
  DISCO=null;
  if(!q){ LOOKUP=[]; render(); return; }
  try{ const r=await fetch('/api/lookup?q='+encodeURIComponent(q)); const d=await r.json();
    LOOKUP=(d.items||[]).map(it=>({...it, keep_images: !it.needs_create && !!it.image})); // preserve existing room/lifestyle shots on live SKUs
    LOOKUP_INDEXED=d.indexed||0;
    // No active TWIL hit? Before escalating to a store-wide search, check whether this is a
    // DISCONTINUED / deleted SKU — if so, show the RED card pointing at its live successor.
    if(!LOOKUP.length && q){
      try{
        const dr=await(await fetch('/api/discontinued?q='+encodeURIComponent(q))).json();
        if(dr && dr.found && dr.discontinued){ DISCO=dr; render(); return; }
      }catch(e){}
      // otherwise auto-escalate to a store-wide search (mfr# + DW SKU across every vendor)
      toast('Not in TWIL — searching all Shopify…');
      document.querySelectorAll('.chip').forEach(z=>z.classList.remove('on')); const sc=document.querySelector('.chip[data-f="shop"]'); if(sc)sc.classList.add('on');
      filter='shop'; setLS('filter','shop'); $('#q').placeholder='🌐 Any Shopify SKU, name, vendor, or collection…';
      return doShopSearch();
    }
    render();
  }catch(e){ toast('Lookup failed'); }
}
function discoCardHtml(d){
  const succ = d.successor;
  const succUrl = succ && succ.handle ? ('https://www.designerwallcoverings.com/products/'+succ.handle) : '';
  const succHtml = succ
    ? `<div class="disco-succ">
         <span class="slabel">Now available as →</span>
         <span class="stitle">${(succ.title||'').replace(/</g,'&lt;')}</span>
         <span class="ssku">${(succ.sku||'').replace(/</g,'&lt;')}</span>
         ${succUrl?`<a class="go" href="${succUrl}" target="_blank" rel="noopener">View live product →</a>`:''}
       </div>`
    : `<div class="disco-nosucc">No live successor found — search the store for a comparable pattern.</div>`;
  return `<div class="disco-card">
      <div class="dhead">⛔ Discontinued <span class="dstatus">${(d.status||'').replace(/</g,'&lt;')}</span></div>
      <div>${(d.title||'').replace(/</g,'&lt;')} <span class="dsku">${(d.sku||'').replace(/</g,'&lt;')}</span></div>
      ${succHtml}
    </div>`;
}
// The not-yet-in-Shopify sheet items — photograph one to create + go live.
let NEWITEMS=[];
async function loadNew(){
  try{ const r=await fetch('/api/new'); const d=await r.json();
    NEWITEMS=(d.items||[]).map(it=>({...it, keep_images:false}));
    const ch=$('#chipNew'); if(ch) ch.textContent='✨ New ('+(d.total||0)+')';
    if(filter==='new') render();
  }catch(e){}
}
let TWILITEMS=[];
async function loadTwil(){
  try{ const r=await fetch('/api/twil'); const d=await r.json();
    TWILITEMS=d.items||[];
    const ch=$('#chipTwil'); if(ch) ch.textContent='🧵 All TWIL ('+(d.total||0)+')';
    if(filter==='twil') render();
  }catch(e){}
}
// Favorites (starred) + Recents (auto-logged on upload) — server-stored so the
// same lists show on phone AND office. Card-shaped, render straight into the grid.
let FAVORITES=[], RECENTS=[];
async function loadFav(){ try{ const d=await(await fetch('/api/favorites')).json(); FAVORITES=d.items||[]; if(filter==='fav')render(); }catch(e){} }
async function loadRecent(){ try{ const d=await(await fetch('/api/recents')).json(); RECENTS=d.items||[]; if(filter==='recent')render(); }catch(e){} }
// the display fields the server caches so recents/favorites render without a Shopify lookup
const cardMeta=x=>({title:x.title||'',mfr:x.mfr||'',price:x.price||null,image:(x.image&&!/^\/photos\//.test(x.image))?x.image:null,product_id:x.product_id||null,status:x.status||''});
async function toggleFav(x,btn){
  const on=!x.fav; x.fav=on;
  if(btn){ btn.textContent=on?'★':'☆'; btn.classList.toggle('on',on); }
  try{
    await fetch('/api/favorite',{method:'POST',headers:{'Content-Type':'application/json'},
      body:JSON.stringify({dw_sku:x.dw_sku,on,meta:cardMeta(x)})});
    toast(on?('⭐ Favorited '+x.dw_sku):('Removed '+x.dw_sku));
    loadFav(); if(filter==='fav')render(); // drop it from the Favorites view immediately on un-star
  }catch(e){ x.fav=!on; if(btn){btn.textContent=x.fav?'★':'☆';btn.classList.toggle('on',x.fav);} toast('Favorite failed'); }
}
// Live store-wide search — covers EVERY Shopify SKU, not just Fentucci.
let SHOPSEARCH=[];
async function doShopSearch(){
  const q=($('#q').value||'').trim();
  if(!q){ SHOPSEARCH=[]; render(); return; }
  $('#count').textContent='Searching all Shopify…';
  try{ const r=await fetch('/api/shopify-search?q='+encodeURIComponent(q)); const d=await r.json();
    SHOPSEARCH=d.items||[]; if(d.err) toast('Shopify: '+d.err); render();
  }catch(e){ toast('Shopify search failed'); }
}
// 🎨 Recognize a PATTERN (the swatch itself) → this + similar across the whole unified DB.
let SIMILAR=[], RECOGNIZED=null, QSIG=null, VRANK='', _rerankSeq=0;
const _SIGCACHE=new Map();
// One reusable 8×8 canvas for every fingerprint — imgSig runs synchronously (draw→read→return),
// so there's no reentrancy and ~49 transient canvas allocations per recognize collapse to one.
const _SIGN=8, _sigCtx=Object.assign(document.createElement('canvas'),{width:_SIGN,height:_SIGN}).getContext('2d',{willReadFrequently:true});
// Perceptual "thumbnail fingerprint": average the image down to an 8×8 RGB grid (192 nums,
// 0-1). Two patterns that look alike — similar colors in similar places — land close together.
function imgSig(src){
  try{ _sigCtx.drawImage(src,0,0,_SIGN,_SIGN); }catch(e){ return null; }
  let d; try{ d=_sigCtx.getImageData(0,0,_SIGN,_SIGN).data; }catch(e){ return null; }   // tainted canvas → null
  const sig=new Float32Array(_SIGN*_SIGN*3);
  for(let i=0;i<_SIGN*_SIGN;i++){ sig[i*3]=d[i*4]/255; sig[i*3+1]=d[i*4+1]/255; sig[i*3+2]=d[i*4+2]/255; }
  return sig;
}
function sigDist(a,b){ if(!a||!b)return 1e9; let s=0; for(let i=0;i<a.length;i++){const e=a[i]-b[i]; s+=e*e;} return s; }
function sigFromDataUrl(dataUrl){ return new Promise(res=>{ const im=new Image(); im.onload=()=>res(imgSig(im)); im.onerror=()=>res(null); im.src=dataUrl; }); }
// Fingerprint a candidate's ACTUAL catalog image (tiny 72px, fetched same-origin through the
// server proxy so Shopify's no-CORS image doesn't taint the canvas). Cached per URL.
function candSig(url){
  if(_SIGCACHE.has(url)) return Promise.resolve(_SIGCACHE.get(url));
  return new Promise(res=>{
    const small=url+(url.includes('?')?'&':'?')+'width=72';
    const im=new Image(); im.crossOrigin='anonymous';
    im.onload=()=>{ const s=imgSig(im); _SIGCACHE.set(url,s); res(s); };
    im.onerror=()=>{ _SIGCACHE.set(url,null); res(null); };
    im.src='/api/imgproxy?url='+encodeURIComponent(small);
  });
}
const _vd=x=>(x._vdist==null?1e9:x._vdist);
// Re-rank the tag-prefiltered candidates by VISUAL distance to the photographed swatch —
// "sort by the actual pattern image." Runs in the background, repainting as results land.
async function visualRerank(){
  if(!QSIG || !SIMILAR.length){ VRANK=''; return; }
  const seq=++_rerankSeq, items=SIMILAR.filter(x=>x.image);
  VRANK='running'; if(filter==='similar') render();
  let idx=0, done=0;
  async function worker(){
    while(idx<items.length){
      const x=items[idx++];
      const s=await candSig(x.image);
      if(seq!==_rerankSeq) return;                 // a newer recognize superseded us
      x._vdist=sigDist(QSIG,s); done++;
      if(done%8===0 && filter==='similar') render();   // progressive repaint
    }
  }
  await Promise.all(Array.from({length:6},()=>worker()));   // 6-way concurrency
  if(seq!==_rerankSeq) return;
  VRANK='done'; if(filter==='similar') render();
}
async function recognizeDataUrl(dataUrl){
  toast('🎨 Recognizing pattern…'); $('#count').textContent='🎨 Reading pattern…';
  QSIG=null; VRANK=''; const seq=++_rerankSeq;
  const sigP=sigFromDataUrl(dataUrl);            // fingerprint the photo in parallel with the VLM call
  try{
    const r=await fetch('/api/recognize',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({dataUrl})});
    const d=await r.json();
    RECOGNIZED=d.recognized||null; SIMILAR=d.items||[];
    collapsed=false; chipTo('similar'); $('#q').value=''; render();
    QSIG=await sigP;                               // already resolved in the common (VLM-slow) case
    if(QSIG && seq===_rerankSeq) visualRerank();   // sort by the actual pattern image
    if(d.err && !SIMILAR.length){ toast('Recognize failed: '+d.err); }
    else if(RECOGNIZED && RECOGNIZED.code){ toast('🏷 Code seen: '+RECOGNIZED.code+' · '+SIMILAR.length+' similar'); }
    else { toast('🎨 '+SIMILAR.length+' similar pattern'+(SIMILAR.length===1?'':'s')+' found'); }
  }catch(e){ toast('Recognize failed — is Ollama running?'); }
}
async function recognizePattern(file){
  if(!file)return;
  const dataUrl=await downscale(file,1024);   // honors never-upscale (shrink only); 1024 keeps the VLM fast
  return recognizeDataUrl(dataUrl);
}
// map a few common color NAMES → a hex chip so the recognized palette reads at a glance
const _CHEX={black:'#111',white:'#f4f1ea',cream:'#efe7d4',ivory:'#f3ecd8',beige:'#d9c9a8',taupe:'#b3a489',
  gray:'#9a9a9a',grey:'#9a9a9a',charcoal:'#3a3a3a',silver:'#c7c7c7',gold:'#c8a24a',brass:'#b08d2f',
  brown:'#6b4a2b',chocolate:'#3f2a1a',tan:'#c2a878',rust:'#9c4a23',terracotta:'#b5613c',
  red:'#a3322a',burgundy:'#5e2230',pink:'#d98aa0',blush:'#e3c3c5',coral:'#e0745a',orange:'#cc6a2c',
  yellow:'#d9b84a',mustard:'#b8902f',green:'#3f7a4a',olive:'#6b6a30',sage:'#9aa885',emerald:'#1f6b4a',
  teal:'#2f7b7b',blue:'#345f8a',navy:'#1f2f4a','slate blue':'#5a6b86',sky:'#7fa6c9',
  purple:'#6a4a82',lavender:'#b8a8cf',plum:'#5e3a55',mauve:'#9a7a86'};
function colorHex(name){const k=String(name||'').toLowerCase().trim();if(_CHEX[k])return _CHEX[k];
  for(const c in _CHEX){if(k.includes(c))return _CHEX[c];}return null;}
function recogBanner(){
  if(!RECOGNIZED)return'';
  const r=RECOGNIZED, parts=[];
  if(r.motif)parts.push(r.motif); if(r.style)parts.push(r.style); if(r.scale)parts.push(r.scale+' scale');
  const desc=r.description||(parts.length?parts.join(' · '):'pattern');
  const tags=[];
  if(r.material)tags.push(`<span>🧵 ${r.material}</span>`);
  if(r.motif)tags.push(`<span>${r.motif}</span>`);
  if(r.style)tags.push(`<span>${r.style}</span>`);
  (r.colors||[]).forEach(c=>{const h=colorHex(c);tags.push(h?`<span class="sw"><i style="background:${h}"></i>${c}</span>`:`<span>${c}</span>`);});
  if(r.background)tags.push(`<span class="sw"><i style="background:${colorHex(r.background)||'#444'}"></i>bg: ${r.background}</span>`);
  const codeLine=r.code?` <span class="rg-code" onclick="chipTo('shop');$('#q').value='${r.code}';doShopSearch();">· 🏷 ${r.code} ↗</span>`:'';
  return `<div class="recog"><div class="rg-d">🎨 Recognized: <b>${desc}</b>${codeLine}</div><div class="rg-tags">${tags.join('')}</div></div>`;
}
function render(){
  if(collapsed){
    const done=ITEMS.filter(x=>x.done).length, total=ITEMS.length;
    $('#prog').style.width=(total?done/total*100:0)+'%';
    $('#count').textContent=`${total} patterns · ${done} done`;
    $('#remain').textContent='pick a filter to begin';
    grid.innerHTML='<div class="empty">📸 Pick a filter, 🔎 search a SKU/collection, or tap 🔢 Scan to begin.'+(total?('<br><br>'+ (total-done) +' still need a photo.'):'')+'</div>';
    return;
  }
  // A resolved discontinued/deleted SKU → red successor card (takes over the grid for the 'any' lookup filter).
  if(DISCO && (filter==='any'||filter==='shop')){
    $('#prog').style.width='0%';
    $('#count').textContent='Discontinued SKU';
    $('#remain').textContent='→ see live successor';
    grid.innerHTML=discoCardHtml(DISCO);
    return;
  }
  const q=($('#q').value||'').toLowerCase(), sort=$('#sort').value;
  const src = filter==='any' ? LOOKUP : filter==='shop' ? SHOPSEARCH : filter==='new' ? NEWITEMS
            : filter==='twil' ? TWILITEMS : filter==='fav' ? FAVORITES : filter==='recent' ? RECENTS
            : filter==='similar' ? SIMILAR : ITEMS;
  const recog = filter==='similar'; // server returned a ranked similarity set
  const liveSearch = filter==='any'||filter==='shop'; // server already filtered these
  const curated = filter==='fav'||filter==='recent'; // server-stored lists, local q-filter only
  let list=src.filter(x=>{
    if(filter==='need'&&(x.done||x.skipped))return false;
    if(filter==='live'&&!x.live)return false;
    if(filter==='done'&&!x.done)return false;
    if(!liveSearch&&!recog&&q&&!((x.title||'').toLowerCase().includes(q)||(x.dw_sku||'').toLowerCase().includes(q)||(x.mfr||'').toLowerCase().includes(q)))return false;
    return true;
  });
  list.sort((a,b)=> sort==='title'?(a.title||'').localeCompare(b.title||'')
    : sort==='sku'?(a.dw_sku||'').localeCompare(b.dw_sku||'')
    : recog?(_vd(a)-_vd(b))||((b.score||0)-(a.score||0))   // visual (actual-image) distance, then tag score
    : (priced(b)-priced(a))||(a.title||'').localeCompare(b.title||''));
  if(liveSearch){
    const typed=($('#q').value||'').trim();
    $('#prog').style.width='0%';
    $('#count').textContent = typed ? `${list.length} match${list.length===1?'':'es'}`
      : (filter==='shop' ? 'Type any SKU, name, or vendor to search the whole store'
                         : 'Type a SKU, model #, or name to find any product');
    $('#remain').textContent = filter==='shop' ? '🌐 all Shopify' : (LOOKUP_INDEXED?`${LOOKUP_INDEXED} indexed`:'');
  } else if(filter==='new'){
    $('#prog').style.width='0%';
    $('#count').textContent = `${list.length} new SKU${list.length===1?'':'s'} to create`;
    $('#remain').textContent = '📷 shoot to create + go live';
  } else if(filter==='twil'){
    const live=list.filter(x=>x.status==='ACTIVE').length;
    $('#prog').style.width='0%';
    $('#count').textContent = `${list.length} TWIL items · ${live} live`;
    $('#remain').textContent = 'all grasscloth — photo or not';
  } else if(recog){
    $('#prog').style.width='0%';
    $('#count').textContent = RECOGNIZED ? `${list.length} similar match${list.length===1?'':'es'}` : '🎨 Find Similar';
    $('#remain').textContent = !RECOGNIZED ? '📷 photo a swatch to start'
      : VRANK==='running' ? '🎨 ranking by image…'
      : VRANK==='done' ? '✓ sorted by actual pattern image'
      : 'ranked by pattern · whole catalog';
  } else if(curated){
    $('#prog').style.width='0%';
    $('#count').textContent = filter==='fav' ? `${list.length} ⭐ favorite${list.length===1?'':'s'}` : `${list.length} recently updated`;
    $('#remain').textContent = filter==='fav' ? 'tap ☆ on any card to add' : 'newest first';
  } else {
    const done=ITEMS.filter(x=>x.done).length, total=ITEMS.length;
    $('#prog').style.width=(total?done/total*100:0)+'%';
    $('#count').textContent=`${done} / ${total} photographed`;
    $('#remain').textContent=`${total-done} remaining`;
  }
  const typedQ=($('#q').value||'').trim();
  const emptyMsg = filter==='shop' ? (typedQ?'No Shopify product matches that — try the SKU, pattern name, or vendor.':'🌐 Search EVERY Shopify SKU above — add or update a photo from your phone.')
    : filter==='any' ? (typedQ?'No Fentucci product matches that — try the model # or DW SKU.':'🔎 Search any TWIL/Fentucci SKU above to add or replace its photo.')
    : filter==='new' ? (typedQ?'No new SKU matches that.':'✅ All new SKUs created — none left to add.')
    : filter==='fav' ? (typedQ?'No favorite matches that.':'⭐ No favorites yet — tap the ☆ star on any card to pin SKUs you update often.')
    : filter==='recent' ? (typedQ?'No recent SKU matches that.':'🕘 Nothing updated yet — SKUs you photograph show up here, newest first.')
    : filter==='similar' ? (RECOGNIZED?'No similar items found — try a flatter, closer shot of just the pattern.':'🎨 Tap 🎨 Similar above and photograph a swatch — the local AI reads its colors, motif & material, then finds this + lookalikes across the whole catalog.')
    : 'Nothing here — switch filter or clear search.';
  grid.innerHTML = list.length? '' : `<div class="empty">${emptyMsg}</div>`;
  if(recog && RECOGNIZED) grid.insertAdjacentHTML('afterbegin', recogBanner());
  // MEMORY GUARD (iOS OOM-freeze fix): never build more than RENDER_CAP card nodes. The TWIL list is
  // ~88k items (36MB) — rendering them all drove the browser to 13GB and crashed the tab. Cap it and
  // tell the user to search/filter to narrow. (Search/filter shrink `list` before this point.)
  const RENDER_CAP=500;
  const shown = list.length>RENDER_CAP ? list.slice(0,RENDER_CAP) : list;
  for(const x of shown){
    const isNew = x.needs_create && !x.done;  // on the sheet but not yet created in Shopify
    const photographed = !isNew && (x.done || !!x.image || x.live); // already has a real photo
    const el=document.createElement('div'); el.className='card'+(photographed?' done':'');
    const badge=x.push_err?'<span class="badge b-err">PUSH ERR</span>':x.live?'<span class="badge b-live">● LIVE</span>':isNew?'<span class="badge b-new">✨ NEW</span>':photographed?'<span class="badge b-done">✓ PHOTO</span>':'<span class="badge b-need">NEEDS</span>';
    const thumbUrl=x.photo||(isNew?'':x.image)||'';
    const thumb=thumbUrl?`data-bg="${thumbUrl}"`:'';   // lazy: bg applied on-visible (see observer below)
    const adjustable=photographed; // anything with a real photo can be re-opened in the editor
    el.innerHTML=`
      <div class="thumb ${adjustable?'tap':''}" ${thumb} title="${adjustable?'click to adjust':''}">${thumbUrl?'':'▦'}${badge}${x.product_id?`<button class="fav-btn${x.fav?' on':''}" title="favorite">${x.fav?'★':'☆'}</button>`:''}</div>
      <div class="body">
        <div class="ttl">${(x.title||'').replace(/ \\| Fentucci$/,'')}</div>
        <div class="model" title="tap to copy model #" onclick="navigator.clipboard&&navigator.clipboard.writeText('${x.mfr||''}')"><small>Model&nbsp;#</small><b>${x.mfr||'—'}</b></div>
        <div class="sub">
          <span class="sku">${x.dw_sku||'—'}</span>
          <span class="price ${priced(x)?'':'no'}">${priced(x)?'$'+x.price:'no price'}</span>
          <span>${isNew?'NEW · not on Shopify':x.status}</span>
        </div>
        ${(x.collections&&x.collections.length)?`<div class="cols">${x.collections.slice(0,4).map(c=>'<span class="colchip">📚 '+c+'</span>').join('')}</div>`:''}
        ${x.ts?`<div class="when">🕓 ${fmt(x.ts)}${x.live?(x.created?' · 🟢 CREATED + LIVE':' · 🟢 LIVE on store'):x.shopify_pushed?(x.live_reason?(' · photo on Shopify ('+x.live_reason+')'):' · photo on Shopify'):''}</div>`:''}
        ${x.push_err?`<div class="when" style="color:var(--red)">${x.push_err}</div>`:''}
        ${(x.keep_images&&x.image)?`<label class="onlyimg${x._replaceAll?' armed':''}"><input type="checkbox" class="onlyimg-cb"${x._replaceAll?' checked':''}> Replace all — make this the only image</label>`:''}
        <div class="actions">
          ${adjustable?'<button class="enh">✨ Enhance</button>':''}
          ${x.product_id?'<button class="gal">🖼 Photos</button>':''}
          <label class="shoot">${isNew?'📷 Create + Go Live':photographed?'📷 Update Photo':'📷 Add Photo'}<input type="file" accept="image/*" capture="${camFacing==='user'?'user':'environment'}"></label>
          ${(x.done||x.live||x.image||isNew)?'':'<button class="skip">Skip</button>'}
        </div>
      </div>`;
    const editSrc=()=> (x.photo||('/api/current-image?pid='+x.product_id))+(x.photo?'?':'&')+'t='+Date.now();
    el.querySelector('.shoot input').addEventListener('change',e=>shoot(x,e.target.files[0]));
    const gal=el.querySelector('.gal'); if(gal) gal.addEventListener('click',()=>openGallery(x));
    const oi=el.querySelector('.onlyimg-cb'); if(oi) oi.addEventListener('change',e=>{ x._replaceAll=e.target.checked; el.querySelector('.onlyimg').classList.toggle('armed',e.target.checked); });
    const enh=el.querySelector('.enh'); if(enh) enh.addEventListener('click',()=>openEditor(x,editSrc()));
    const th=el.querySelector('.thumb.tap'); if(th) th.addEventListener('click',()=>openEditor(x,editSrc()));
    const fb=el.querySelector('.fav-btn'); if(fb) fb.addEventListener('click',ev=>{ev.stopPropagation();toggleFav(x,fb);});
    const sk=el.querySelector('.skip'); if(sk) sk.addEventListener('click',()=>skip(x));
    grid.appendChild(el);
  }
  if(list.length>shown.length){ const n=document.createElement('div'); n.className='empty'; n.style.gridColumn='1/-1';
    n.innerHTML=`Showing first <b>${shown.length}</b> of <b>${list.length.toLocaleString()}</b> — 🔎 search a SKU / name / vendor to narrow.`; grid.appendChild(n); }
  lazyThumbs();   // only load thumbnails as they scroll into view (huge mobile win vs 100s of images at once)
}
// CSS background-images can't use loading="lazy" — apply each thumb's bg only when it nears the viewport.
let _thumbObs=null;
function lazyThumbs(){
  if(_thumbObs) _thumbObs.disconnect();
  if(!('IntersectionObserver' in window)){ grid.querySelectorAll('.thumb[data-bg]').forEach(t=>{ t.style.backgroundImage=`url('${t.getAttribute('data-bg')}')`; t.removeAttribute('data-bg'); }); return; }
  _thumbObs=new IntersectionObserver((es,o)=>{ for(const e of es){ if(e.isIntersecting){ const t=e.target,u=t.getAttribute('data-bg'); if(u){ t.style.backgroundImage=`url('${u}')`; t.removeAttribute('data-bg'); } o.unobserve(t); } } },{rootMargin:'400px'});
  grid.querySelectorAll('.thumb[data-bg]').forEach(t=>_thumbObs.observe(t));
}
// iOS FREEZE FIX (DTD verdict B): NO client-side decode anywhere. v99 proved even createImageBitmap
// OOM-freezes iOS on a 12MP camera photo. downscale() now just reads the RAW file to a data URL via
// FileReader (no <img>/canvas/bitmap) — the SERVER (normalizeImage: ImageMagick → auto-orient → ≤1600px
// JPEG, handles HEIC) resizes at every ingest endpoint. `max` is ignored (kept for call-site compat).
async function downscale(file,max){
  const raw=await new Promise(r=>{ const fr=new FileReader(); fr.onload=()=>r(fr.result); fr.onerror=()=>r(null); fr.readAsDataURL(file); });
  if(raw) return raw;
  return new Promise((res)=>{
    const img=new Image(), url=URL.createObjectURL(file);
    let done=false; const finish=v=>{ if(done)return; done=true; try{URL.revokeObjectURL(url);}catch(e){} res(v); };
    img.onload=()=>{
      let{width:w,height:h}=img; const s=Math.min(1,(max||1200)/Math.max(w,h));
      const c=document.createElement('canvas'); c.width=Math.round(w*s); c.height=Math.round(h*s);
      c.getContext('2d').drawImage(img,0,0,c.width,c.height); finish(c.toDataURL('image/jpeg',0.82));
    };
    img.onerror=()=>finish(null);
    setTimeout(()=>finish(null),12000);
    img.src=url;
  });
}
// Capture → open the editor (review + enhance) instead of uploading immediately.
async function shoot(x,file){
  if(!file)return;
  toast('Loading editor…');
  const dataUrl=await downscale(file);   // honors never-upscale (shrink only)
  openEditor(x,dataUrl);
}

/* ===== Photo gallery: see every photo on a SKU, keep / remove / feature / add ===== */
let GAL_X=null;
async function openGallery(x){
  if(!x.product_id){ toast('Create the product first (take a photo)'); return; }
  GAL_X=x;
  $('#galName').textContent=(x.title||'').replace(/ \| Fentucci$/,'');
  $('#galSku').textContent=x.dw_sku||'';
  $('#galAnswer').hidden=true; $('#galAnswer').innerHTML='';   // clear any prior Q&A
  $('#gallery').hidden=false; document.body.style.overflow='hidden';
  await renderGallery();
}
function closeGallery(){ $('#gallery').hidden=true; document.body.style.overflow=''; GAL_X=null; if(filter!=='any'&&filter!=='shop'&&filter!=='new') load(); }
async function renderGallery(){
  const g=$('#galGrid'); g.innerHTML='<div class="gal-empty">Loading…</div>';
  try{
    const r=await fetch('/api/images?pid='+GAL_X.product_id); const d=await r.json();
    const imgs=d.images||[];
    if(!imgs.length){ g.innerHTML='<div class="gal-empty">No photos yet — tap 📷 Add for one, or ➕ Multiple to add several.</div>'; return; }
    g.innerHTML='';
    imgs.forEach((im,i)=>{
      const feat=im.position===1;
      const t=document.createElement('div'); t.className='gtile'+(feat?' feat':'');
      t.innerHTML=`<img src="${im.src}" loading="lazy">${feat?'<span class="gfeat-badge">★ FEATURED</span>':''}
        <div class="gtile-acts">${feat?'':'<button class="g-feat">★ Feature</button>'}<button class="g-del">✕ Remove</button></div>`;
      const f=t.querySelector('.g-feat'); if(f) f.addEventListener('click',()=>imgAction('feature',im.id));
      t.querySelector('.g-del').addEventListener('click',()=>{ if(confirm('Remove this photo?')) imgAction('delete',im.id); });
      g.appendChild(t);
    });
  }catch(e){ g.innerHTML='<div class="gal-empty">Couldn\'t load photos.</div>'; }
}
async function imgAction(act,image_id){
  toast(act==='delete'?'Removing…':'Setting featured…');
  try{ const r=await fetch('/api/image/'+act,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({pid:GAL_X.product_id,image_id})});
    const d=await r.json(); if(d.ok){ toast(act==='delete'?'Removed':'Featured ✓'); renderGallery(); } else toast('Failed'); }catch(e){ toast('Failed'); }
}
$('#galClose').addEventListener('click',closeGallery);
$('#galAsk').addEventListener('click',voiceAsk);
// Batch add: take several photos (in your camera roll), pick them all here, save at once.
$('#galAddMulti').addEventListener('change',async e=>{
  const files=[...e.target.files]; e.target.value=''; if(!files.length||!GAL_X)return;
  if(files.length>12){ toast('Max 12 at a time — adding first 12'); files.length=12; }
  toast('Adding '+files.length+' photo'+(files.length>1?'s':'')+'…');
  try{
    const dataUrls=[]; for(const f of files){ dataUrls.push(await downscale(f)); } // never-upscale each
    const r=await fetch('/api/photos',{method:'POST',headers:{'Content-Type':'application/json'},
      body:JSON.stringify({dw_sku:GAL_X.dw_sku,product_id:GAL_X.product_id,dataUrls,meta:cardMeta(GAL_X)})});
    const d=await r.json();
    if(d.ok){ toast('✓ Added '+d.added+' photo'+(d.added>1?'s':'')+((d.errors&&d.errors.length)?(' · '+d.errors.length+' failed'):'')+(d.live?' · 🟢 LIVE':'')); renderGallery(); }
    else toast('Add failed'+(d.err?': '+d.err:''));
  }catch(err){ toast('Upload failed — check connection'); }
});
$('#galAdd').addEventListener('change',e=>{ const f=e.target.files[0]; if(!f||!GAL_X)return;
  // add a photo to this SKU's gallery (keep existing), via the editor
  const gx={...GAL_X, keep_images:true, _replaceAll:false, _fromGallery:true};
  $('#gallery').hidden=true; document.body.style.overflow='';
  shoot(gx,f);
});
$('#galVid').addEventListener('change',e=>{ const f=e.target.files[0]; if(!f||!GAL_X)return; e.target.value='';
  if(!GAL_X.product_id){ toast('Create the product first'); return; }
  const mb=(f.size/1048576);
  if(mb>150){ toast('Video too large ('+mb.toFixed(0)+'MB) — keep under 150MB'); return; }
  toast('🎥 Uploading video ('+mb.toFixed(1)+'MB)…');
  const rd=new FileReader();
  rd.onload=async()=>{
    try{ const r=await fetch('/api/video',{method:'POST',headers:{'Content-Type':'application/json'},
      body:JSON.stringify({dw_sku:GAL_X.dw_sku,product_id:GAL_X.product_id,dataUrl:rd.result})});
      const d=await r.json();
      toast(d.ok?'🎥 Video added — Shopify is processing it':('Video failed: '+(d.err||'error')));
    }catch(err){ toast('Video upload failed — check connection'); }
  };
  rd.readAsDataURL(f);
});

// Send the (possibly edited) photo to the server → local save + Shopify push + activation gate.
async function uploadPhoto(x,dataUrl){
  toast((x.needs_create?'Creating ':'Uploading ')+x.dw_sku+'…');
  try{
    const r=await fetch('/api/photo',{method:'POST',headers:{'Content-Type':'application/json'},
      body:JSON.stringify({dw_sku:x.dw_sku,product_id:x.product_id,dataUrl,keep_images:!!x.keep_images&&!x._replaceAll,meta:cardMeta(x)})});
    const d=await r.json();
    if(d.ok){ Object.assign(x,{done:true,needs_create:false,created:d.created,photo:d.photo,ts:new Date().toISOString(),shopify_pushed:d.shopify_pushed,push_err:d.push_err,live:d.live,live_reason:d.live_reason});
      toast(d.created&&d.live?('🟢 Created + LIVE · '+x.dw_sku):d.created?('Created (draft) · '+x.dw_sku+(d.live_reason?' — '+d.live_reason:'')):d.live?('🟢 '+x.dw_sku+' is LIVE!'):d.shopify_pushed?('✓ Photo on Shopify · '+x.dw_sku+(d.live_reason?(' (needs '+d.live_reason.replace('needs ','')+')'):'')):('Saved '+x.dw_sku+(d.push_err?' (push failed)':''))); render();
      if(x._fromGallery&&x.product_id) setTimeout(()=>openGallery(x),400); // back to the gallery to see the added photo
    } else toast('Error: '+(d.err||'failed'));
  }catch(e){toast('Upload failed — check connection');}
}

/* ===================== Photo editor ===================== */
let ES=null, edRAF=0;
const WHEEL_R=64; // half of 128
function openEditor(x,dataUrl){
  const img=new Image();
  img.onload=()=>{
    const flat=document.createElement('canvas'); flat.width=img.width; flat.height=img.height;
    ES={x,img,flat,b:1,c:1,s:1,h:0,th:0,ts:0,rollMode:'off',curl:0.5};
    const cv=$('#edcanvas'); cv.width=img.width; cv.height=img.height;
    $('#ed-sku').textContent=x.dw_sku||'';
    // reset controls
    $('#edB').value=1; $('#edC').value=1; $('#edS').value=1; $('#edH').value=0;
    $('#edCurl').value=0.5; $('#edCurlv').textContent='50%'; $('#edCurlRow').style.display='none';
    setThumb(0,0); $('#wheelVal').textContent='neutral';
    $('#edRoll').classList.remove('on'); $('#edRoll').textContent='🗞 Roll';
    syncLabels(); edRedraw();
    $('#editor').hidden=false; document.body.style.overflow='hidden';
  };
  img.onerror=()=>toast('Could not load image to edit');
  img.src=dataUrl;
}
function closeEditor(){ $('#editor').hidden=true; document.body.style.overflow=''; ES=null; }

function syncLabels(){
  $('#edBv').textContent=signPct($('#edB').value);
  $('#edCv').textContent=signPct($('#edC').value);
  $('#edSv').textContent=signPct($('#edS').value);
  $('#edHv').textContent=Math.round(+$('#edH').value)+'°';
}
function signPct(v){const p=Math.round((+v-1)*100); return (p>0?'+':'')+p;}

function scheduleRedraw(){ if(edRAF)return; edRAF=requestAnimationFrame(()=>{edRAF=0;edRedraw();}); }
const _cl=v=>v<0?0:v>255?255:v;
function _hsl2rgb(h,s,l){ // h 0..1
  const q=l<.5?l*(1+s):l+s-l*s, p=2*l-q;
  const f=t=>{t=(t+1)%1; return t<1/6?p+(q-p)*6*t:t<1/2?q:t<2/3?p+(q-p)*(2/3-t)*6:p;};
  return [f(h+1/3)*255,f(h)*255,f(h-1/3)*255];
}
// Bake the slider adjustments into real pixels (reliable on every device — no ctx.filter).
function bakeAdjust(src,w,h){
  const c=document.createElement('canvas'); c.width=w; c.height=h;
  const ctx=c.getContext('2d'); ctx.drawImage(src,0,0,w,h);
  const id=ctx.getImageData(0,0,w,h), d=id.data;
  const b=+$('#edB').value, con=+$('#edC').value, sat=+$('#edS').value, hue=(+$('#edH').value)*Math.PI/180;
  const cos=Math.cos(hue), sn=Math.sin(hue);
  const m=[0.213+cos*0.787-sn*0.213,0.715-cos*0.715-sn*0.715,0.072-cos*0.072+sn*0.928,
           0.213-cos*0.213+sn*0.143,0.715+cos*0.285+sn*0.140,0.072-cos*0.072-sn*0.283,
           0.213-cos*0.213-sn*0.787,0.715-cos*0.715+sn*0.715,0.072+cos*0.928+sn*0.072];
  const castOn=ES&&ES.ts>0.04; let cr=0,cg=0,cb=0,ca=0;
  if(castOn){ const t=_hsl2rgb((ES.th||0)/360,1,.5); cr=t[0];cg=t[1];cb=t[2]; ca=ES.ts*0.6; }
  // Clamp after EACH stage to match CSS/SVG filter semantics — otherwise inter-stage
  // overshoot (>255) gets pulled back differently and the export drifts from the preview.
  for(let i=0;i<d.length;i+=4){
    let r=d[i],g=d[i+1],bl=d[i+2];
    r=_cl(r*b);g=_cl(g*b);bl=_cl(bl*b);                                          // brightness
    r=_cl((r-128)*con+128);g=_cl((g-128)*con+128);bl=_cl((bl-128)*con+128);      // contrast
    const L=0.2126*r+0.7152*g+0.0722*bl;                                         // saturation
    r=_cl(L+(r-L)*sat);g=_cl(L+(g-L)*sat);bl=_cl(L+(bl-L)*sat);
    const nr=_cl(r*m[0]+g*m[1]+bl*m[2]),ng=_cl(r*m[3]+g*m[4]+bl*m[5]),nb=_cl(r*m[6]+g*m[7]+bl*m[8]); // hue
    r=nr;g=ng;bl=nb;
    if(castOn){ r=r*(1-ca)+cr*ca;g=g*(1-ca)+cg*ca;bl=bl*(1-ca)+cb*ca; }          // color cast
    d[i]=_cl(r);d[i+1]=_cl(g);d[i+2]=_cl(bl);
  }
  ctx.putImageData(id,0,0); return c;
}
function edRedraw(){
  if(!ES)return;
  ES.b=+$('#edB').value; ES.c=+$('#edC').value; ES.s=+$('#edS').value; ES.h=+$('#edH').value;
  const cv=$('#edcanvas'), ctx=cv.getContext('2d');
  if(ES.rollMode==='tube'||ES.rollMode==='sheet'){
    cv.style.filter='none';
    const fc=bakeAdjust(ES.img,ES.img.width,ES.img.height);
    ctx.clearRect(0,0,cv.width,cv.height);
    (ES.rollMode==='tube'?renderRoll:renderSheet)(fc,ctx,cv.width,cv.height);
  } else {
    // FLAT: preview from the SAME baked pixels we export, so what you see IS what saves
    // (bakeAdjust is manual getImageData math — iOS-reliable, unlike ctx.filter).
    cv.style.filter='none';
    const fc=bakeAdjust(ES.img,ES.img.width,ES.img.height);
    ctx.clearRect(0,0,cv.width,cv.height);
    ctx.drawImage(fc,0,0);
  }
}

// Wrap the flat texture around a vertical cylinder ("roll around a tube,
// front-to-back") with Lambertian shading + sheen + contact shadow on a dark ground.
function renderRoll(src, o, W, H){
  o.fillStyle='#0c0b09'; o.fillRect(0,0,W,H);          // dark studio ground
  const t=(ES&&ES.curl!=null)?ES.curl:0.5;             // 0 = loose/gentle  →  1 = tight tube
  const cx=W/2, R=W*(0.52-t*0.18), cov=Math.PI*(0.62+t*0.52); // tighter ⇒ smaller radius, more wrap
  const shExp=0.55+t*0.55;                             // tighter ⇒ deeper edge shading
  const topPad=H*0.045, botPad=H*0.11, hh=H-topPad-botPad;
  // soft contact shadow beneath the roll
  o.save(); o.globalAlpha=.45; o.fillStyle='#000';
  o.beginPath(); o.ellipse(cx, H-botPad*0.55, R*0.96, botPad*0.5, 0, 0, 7); o.fill(); o.restore();
  const N=Math.min(W, 640);
  for(let i=0;i<N;i++){
    const sx=(i/(N-1)-0.5)*2*R;                         // screen offset from centre, −R..R
    const cl=Math.max(-0.999,Math.min(0.999,sx/R));
    const th=Math.asin(cl);                             // angle on the cylinder
    const u=th/cov+0.5; if(u<0||u>1) continue;          // map to source column
    const screenX=cx+sx, stripW=(2*R)/N+1.4;
    o.drawImage(src, u*(src.width-1),0,1,src.height, screenX, topPad, stripW, hh);
    const shade=Math.pow(Math.cos(th),shExp);           // bright at front → dark at the curl-away edges
    o.globalCompositeOperation='multiply';
    o.fillStyle='rgba(0,0,0,'+(1-shade).toFixed(3)+')';
    o.fillRect(screenX, topPad, stripW, hh);
    o.globalCompositeOperation='source-over';
  }
  // specular sheen down the front-left quarter
  const sg=o.createLinearGradient(cx-R,0,cx+R,0);
  sg.addColorStop(0,'rgba(255,255,255,0)'); sg.addColorStop(.40,'rgba(255,255,255,.18)');
  sg.addColorStop(.52,'rgba(255,255,255,.04)'); sg.addColorStop(1,'rgba(255,255,255,0)');
  o.globalCompositeOperation='soft-light'; o.fillStyle=sg; o.fillRect(cx-R,topPad,2*R,hh);
  o.globalCompositeOperation='source-over';
}

// Curled hanging SHEET (the PR roll-shot look): top faces the viewer, the sheet
// bows forward and the bottom edge curls UNDER, revealing the paper's cream back.
function renderSheet(src, o, W, H){
  o.fillStyle='#0c0b09'; o.fillRect(0,0,W,H);            // dark studio ground
  const t=(ES&&ES.curl!=null)?ES.curl:0.5;
  const margin=W*0.07, x0=margin, drawW=W-2*margin, top=H*0.05;
  const phiMax=Math.PI*(0.58+t*0.42);                   // how far it curls under (105°→180°)
  const ease=1.4+t*0.9;                                  // tighter ⇒ bend concentrated at the bottom
  const shExp=0.6+t*0.5;
  const steps=src.height, paperLen=H*0.94, ds=paperLen/steps;
  let y=top, minY=top;
  for(let i=0;i<steps;i++){
    const s=i/(steps-1), phi=Math.pow(s,ease)*phiMax;
    const dy=Math.cos(phi)*ds, bandH=Math.max(1,Math.abs(dy)+1.3);
    const yTop=dy>=0?y:y+dy;
    if(phi<=Math.PI/2){                                  // front face → texture + Lambert shade
      o.drawImage(src,0,i,src.width,1, x0,yTop,drawW,bandH);
      o.globalCompositeOperation='multiply';
      o.fillStyle='rgba(0,0,0,'+(1-Math.pow(Math.cos(phi),shExp)).toFixed(3)+')';
      o.fillRect(x0,yTop,drawW,bandH); o.globalCompositeOperation='source-over';
    } else {                                             // past vertical → cream UNDERSIDE of the paper
      const b=Math.pow(Math.max(0,Math.cos(Math.PI-phi)),0.8), lum=Math.round(150+72*b);
      o.fillStyle='rgb('+lum+','+(lum-10)+','+(lum-30)+')';
      o.fillRect(x0,yTop,drawW,bandH);
    }
    y+=dy; if(y<minY)minY=y;
  }
  // soft shadow line where the curl lifts off the front
  o.fillStyle='rgba(0,0,0,.18)'; o.fillRect(x0,minY-2,drawW,3);
}

// paint the color wheel once (white center → full hue at rim)
(function paintWheel(){
  const w=$('#wheel'), ctx=w.getContext('2d'), R=w.width/2;
  for(let a=0;a<360;a++){
    ctx.beginPath(); ctx.moveTo(R,R);
    ctx.arc(R,R,R,(a-1)*Math.PI/180,(a+1)*Math.PI/180); ctx.closePath();
    const g=ctx.createRadialGradient(R,R,0,R,R,R);
    g.addColorStop(0,'#fff'); g.addColorStop(1,`hsl(${a},100%,50%)`);
    ctx.fillStyle=g; ctx.fill();
  }
})();
function setThumb(dx,dy){ $('#wheelThumb').style.left=(WHEEL_R+dx)+'px'; $('#wheelThumb').style.top=(WHEEL_R+dy)+'px'; }
function wheelPick(ev){
  if(!ES)return; ev.preventDefault();
  const w=$('#wheel'), r=w.getBoundingClientRect();
  const t=ev.touches?ev.touches[0]:ev;
  let dx=(t.clientX-r.left)*(w.width/r.width)-WHEEL_R;
  let dy=(t.clientY-r.top)*(w.height/r.height)-WHEEL_R;
  let d=Math.hypot(dx,dy);
  if(d>WHEEL_R){dx*=WHEEL_R/d;dy*=WHEEL_R/d;d=WHEEL_R;}
  let hue=Math.atan2(dy,dx)*180/Math.PI; if(hue<0)hue+=360;
  ES.th=hue; ES.ts=d/WHEEL_R;
  setThumb(dx,dy);
  $('#wheelVal').textContent= ES.ts<0.04?'neutral':(Math.round(hue)+'° · '+Math.round(ES.ts*100)+'%');
  scheduleRedraw();
}
const wb=$('#wheelBox');
wb.addEventListener('pointerdown',e=>{wb.setPointerCapture(e.pointerId);wheelPick(e);});
wb.addEventListener('pointermove',e=>{if(e.buttons||e.pressure)wheelPick(e);});

['edB','edC','edS','edH'].forEach(id=>$('#'+id).addEventListener('input',()=>{syncLabels();scheduleRedraw();}));
$('#edCurl').addEventListener('input',e=>{
  if(ES)ES.curl=+e.target.value; $('#edCurlv').textContent=Math.round(+e.target.value*100)+'%'; scheduleRedraw();
});
$('#edRoll').addEventListener('click',()=>{
  if(!ES)return;
  ES.rollMode = ES.rollMode==='off'?'tube':ES.rollMode==='tube'?'sheet':'off';
  const on=ES.rollMode!=='off';
  $('#edRoll').classList.toggle('on',on);
  $('#edRoll').textContent = ES.rollMode==='tube'?'🗞 Tube':ES.rollMode==='sheet'?'🗞 Sheet':'🗞 Roll';
  $('#edCurlRow').style.display=on?'grid':'none';
  toast(ES.rollMode==='tube'?'Rolled around tube':ES.rollMode==='sheet'?'Curled sheet':'Flat view');
  edRedraw();
});
$('#edReset').addEventListener('click',()=>{
  $('#edB').value=1;$('#edC').value=1;$('#edS').value=1;$('#edH').value=0;
  $('#edCurl').value=0.5; $('#edCurlv').textContent='50%'; $('#edCurlRow').style.display='none';
  if(ES){ES.th=0;ES.ts=0;ES.rollMode='off';ES.curl=0.5;} setThumb(0,0); $('#wheelVal').textContent='neutral';
  $('#edRoll').classList.remove('on'); $('#edRoll').textContent='🗞 Roll'; syncLabels(); edRedraw();
});
$('#edCancel').addEventListener('click',()=>{toast('Edit cancelled');closeEditor();});

/* ===== Crop tool (drag corners / move box; dims outside) ===== */
let CROP=null;
function enterCrop(){
  if(!ES)return;
  const cv=$('#edcanvas'), r=cv.getBoundingClientRect();
  CROP={r, iw:ES.img.width, ih:ES.img.height};
  setCropBox(r.left+r.width*0.08, r.top+r.height*0.08, r.width*0.84, r.height*0.84);
  $('#cropov').hidden=false;
}
function exitCrop(){ $('#cropov').hidden=true; CROP=null; }
function setCropBox(x,y,w,h){
  const b=$('#cropbox'); b.style.left=x+'px'; b.style.top=y+'px'; b.style.width=Math.max(30,w)+'px'; b.style.height=Math.max(30,h)+'px';
}
let cropRatio=0; // 0 = free, else width/height
function setPreset(rat,btn){
  cropRatio=+rat||0;
  document.querySelectorAll('#cropPresets button').forEach(b=>b.classList.remove('on')); if(btn)btn.classList.add('on');
  if(!CROP||!cropRatio)return;
  const r=CROP.r, bb=$('#cropbox').getBoundingClientRect(), cx=bb.left+bb.width/2, cy=bb.top+bb.height/2;
  let w=bb.width, ht=w/cropRatio;
  if(ht>r.height*0.96){ ht=r.height*0.92; w=ht*cropRatio; }
  if(w>r.width*0.96){ w=r.width*0.92; ht=w/cropRatio; }
  const x=Math.max(r.left,Math.min(cx-w/2,r.right-w)), y=Math.max(r.top,Math.min(cy-ht/2,r.bottom-ht));
  setCropBox(x,y,w,ht);
}
(function cropDrag(){
  const box=$('#cropbox'); let mode=null,h=null,sx,sy,ox,oy,ow,oh;
  function down(e){ if(!CROP)return; e.preventDefault(); sx=e.clientX; sy=e.clientY;
    const bb=box.getBoundingClientRect(); ox=bb.left;oy=bb.top;ow=bb.width;oh=bb.height;
    h=e.target.dataset&&e.target.dataset.h; mode=h?'resize':'move';
    document.addEventListener('pointermove',move,{passive:false}); document.addEventListener('pointerup',up); }
  function move(e){ if(!mode||!CROP)return; e.preventDefault(); const r=CROP.r;
    if(mode==='move'){ const dx=e.clientX-sx, dy=e.clientY-sy;
      setCropBox(Math.max(r.left,Math.min(ox+dx,r.right-ow)), Math.max(r.top,Math.min(oy+dy,r.bottom-oh)), ow, oh); return; }
    // resize: opposite corner stays anchored
    const ax = h.includes('w') ? ox+ow : ox, ay = h.includes('n') ? oy+oh : oy;
    let px=Math.max(r.left,Math.min(e.clientX,r.right)), py=Math.max(r.top,Math.min(e.clientY,r.bottom));
    let w=Math.abs(px-ax), ht=Math.abs(py-ay);
    if(cropRatio){
      if(w/cropRatio>=ht) ht=w/cropRatio; else w=ht*cropRatio;
      if(h.includes('e')&&ax+w>r.right){ w=r.right-ax; ht=w/cropRatio; }
      if(h.includes('w')&&ax-w<r.left){ w=ax-r.left; ht=w/cropRatio; }
      if(h.includes('s')&&ay+ht>r.bottom){ ht=r.bottom-ay; w=ht*cropRatio; }
      if(h.includes('n')&&ay-ht<r.top){ ht=ay-r.top; w=ht*cropRatio; }
    }
    w=Math.max(30,w); ht=Math.max(30,ht);
    const x=h.includes('w')?ax-w:ax, y=h.includes('n')?ay-ht:ay;
    setCropBox(x,y,w,ht); }
  function up(){ mode=null; document.removeEventListener('pointermove',move); document.removeEventListener('pointerup',up); }
  box.addEventListener('pointerdown',down);
})();
document.querySelectorAll('#cropPresets button').forEach(b=>b.addEventListener('click',()=>setPreset(b.dataset.r,b)));
$('#edCrop').addEventListener('click',()=>{ enterCrop(); const f=document.querySelector('#cropPresets button[data-r="0"]'); setPreset(0,f); });
$('#cropCancel').addEventListener('click',exitCrop);
$('#cropApply').addEventListener('click',()=>{
  if(!CROP||!ES)return; const r=CROP.r, bb=$('#cropbox').getBoundingClientRect();
  const sx=(bb.left-r.left)/r.width*CROP.iw, sy=(bb.top-r.top)/r.height*CROP.ih;
  const sw=bb.width/r.width*CROP.iw, sh=bb.height/r.height*CROP.ih;
  const c=document.createElement('canvas'); c.width=Math.round(sw); c.height=Math.round(sh);
  c.getContext('2d').drawImage(ES.img, sx,sy,sw,sh, 0,0,c.width,c.height);
  const ni=new Image();
  ni.onload=()=>{ ES.img=ni; ES.flat=document.createElement('canvas'); ES.flat.width=ni.width; ES.flat.height=ni.height;
    const cv=$('#edcanvas'); cv.width=ni.width; cv.height=ni.height; edRedraw(); exitCrop(); toast('Cropped'); };
  ni.src=c.toDataURL('image/jpeg',0.95);
});
$('#edUse').addEventListener('click',()=>{
  if(!ES)return; const x=ES.x;
  // BAKE the adjustment into pixels (CSS preview filter does NOT survive toDataURL).
  let dataUrl;
  if(ES.rollMode==='tube'||ES.rollMode==='sheet'){ edRedraw(); dataUrl=$('#edcanvas').toDataURL('image/jpeg',0.9); }
  else { dataUrl=bakeAdjust(ES.img,ES.img.width,ES.img.height).toDataURL('image/jpeg',0.9); }
  closeEditor(); uploadPhoto(x,dataUrl);
});
async function skip(x){await fetch('/api/skip',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({dw_sku:x.dw_sku,skipped:true})});x.skipped=true;render();}

$('#q').addEventListener('input',()=>{
  collapsed=false;
  if(filter==='any'){ clearTimeout(_lkT); _lkT=setTimeout(doLookup,250); }
  else if(filter==='shop'){ clearTimeout(_lkT); _lkT=setTimeout(doShopSearch,350); } // a touch longer — each keystroke hits Shopify
  else render();
});
$('#sort').addEventListener('change',e=>{setLS('sort',e.target.value);render();});
['input','change'].forEach(ev=>$('#dens').addEventListener(ev,e=>{applyDensity(e.target.value);setLS('dens',e.target.value);}));
document.querySelectorAll('.chip').forEach(c=>c.addEventListener('click',()=>{
  collapsed=false;
  document.querySelectorAll('.chip').forEach(z=>z.classList.remove('on'));c.classList.add('on');filter=c.dataset.f;setLS('filter',filter);
  if(filter==='any'){ $('#q').placeholder='🔎 SKU, model #, name, or collection…'; $('#q').focus(); doLookup(); }
  else if(filter==='shop'){ $('#q').placeholder='🌐 Any Shopify SKU, name, vendor, or collection…'; $('#q').focus(); doShopSearch(); }
  else if(filter==='new'){ $('#q').placeholder='🔎 Filter the new SKUs…'; loadNew(); }
  else if(filter==='twil'){ $('#q').placeholder='🔎 Filter TWIL items…'; loadTwil(); }
  else if(filter==='fav'){ $('#q').placeholder='🔎 Filter favorites…'; render(); loadFav(); }
  else if(filter==='recent'){ $('#q').placeholder='🔎 Filter recent…'; render(); loadRecent(); }
  else if(filter==='similar'){ $('#q').placeholder='🎨 Photo a swatch to find similar'; if(!SIMILAR.length)$('#simInput').click(); else render(); }
  else { $('#q').placeholder='🔎 Search name or model #…'; render(); }
}));
// 🔢 Scan a printed mfr#/SKU with the camera → OCR (server Vision) → jump to that product.
/* ===== LIVE scanner: stream the camera, OCR each frame, LOCK IN when a SKU repeats ===== */
let scanStream=null,scanTimer=null,scanBusy=false,_lastTop=null,_lockHits=0,_frontHits=0,_scanStart=0,_scanSlow=false;
const SCAN_FAST=600, SCAN_CLOUD=1300, SCAN_IDLE_MS=60000;   // cloud OCR costs $ per frame → slow it + idle-pause
// shared camera choice (back/front) — drives BOTH the live scanner and every "Add Photo" capture input
let camFacing=LS('camFacing')||'environment';
function applyCamCapture(){   // point all <input capture> pickers at the chosen lens
  const v=(camFacing==='user')?'user':'environment';
  document.querySelectorAll('input[type=file][accept^="image"]').forEach(i=>{ if(i.hasAttribute('capture')) i.setAttribute('capture',v); });
}
async function startScanStream(){   // (re)acquire the live stream on the current facing
  if(scanStream){ scanStream.getTracks().forEach(t=>t.stop()); scanStream=null; }
  scanStream=await navigator.mediaDevices.getUserMedia({video:{facingMode:{ideal:camFacing},width:{ideal:1280}}});
  const v=$('#scanvideo'); v.srcObject=scanStream; try{await v.play();}catch(e){}
}
// ── lock-in "yoo-hoo" cue (Web Audio, zero asset; must be unlocked by a user gesture on iOS) ──
let _audio=null;
function unlockAudio(){ try{ if(!_audio) _audio=new (window.AudioContext||window.webkitAudioContext)(); if(_audio.state==='suspended')_audio.resume(); }catch(e){} }
function tone(seq,type,vol){               // seq = [[freq,startOffset,dur],…]; one-shot Web Audio blip
  try{
    if(!_audio) return;
    const ac=_audio, t=ac.currentTime, V=vol||0.3;
    seq.forEach(([f,off,dur])=>{
      const o=ac.createOscillator(), g=ac.createGain();
      o.type=type||'triangle'; o.frequency.setValueAtTime(f,t+off);
      g.gain.setValueAtTime(0.0001,t+off);
      g.gain.exponentialRampToValueAtTime(V,t+off+0.03);
      g.gain.exponentialRampToValueAtTime(0.0001,t+off+dur);
      o.connect(g).connect(ac.destination); o.start(t+off); o.stop(t+off+dur+0.03);
    });
  }catch(e){}
}
function playYoohoo(){ tone([[784,0.00,0.17],[659,0.15,0.34]],'triangle',0.30); }  // ✓ FOUND: bright falling "yoo-HOO"
function playUhoh(){   tone([[311,0.00,0.20],[233,0.20,0.36]],'square',0.22); }     // ✗ NOT RIGHT: low descending "uh-oh"

// ── Voice search: speak a vendor / SKU / "what to look for" → transcribe → run the search ──
let _recog=null, _listening=false;
function syncMicBtn(){ const b=$('#micBtn'); if(b){ b.textContent='🎤'; b.classList.remove('on'); } }
function voiceSearch(){
  const SR=window.SpeechRecognition||window.webkitSpeechRecognition;
  if(!SR){ toast('Voice not supported here — tap the search box and use the keyboard mic 🎙️'); $('#q').focus(); return; }
  if(_listening){ try{ _recog&&_recog.stop(); }catch(e){} return; }   // tap again = stop
  unlockAudio();
  _recog=new SR(); _recog.lang='en-US'; _recog.interimResults=false; _recog.maxAlternatives=1;
  _listening=true; const b=$('#micBtn'); if(b){ b.textContent='🔴'; b.classList.add('on'); }
  toast('🎤 Listening — say a vendor, SKU, or what to look for…');
  _recog.onresult=e=>{ const t=((e.results[0][0]||{}).transcript||'').trim(); if(t) runVoiceQuery(t); };
  _recog.onerror=e=>{ toast('Mic: '+(e.error||'error')); };
  _recog.onend=()=>{ _listening=false; syncMicBtn(); };
  try{ _recog.start(); }catch(e){ _listening=false; syncMicBtn(); toast('Mic busy — try again'); }
}
function runVoiceQuery(t){
  // strip spoken lead-ins ("show me…", "find…", "look for…") so the query is just the subject
  const q=t.replace(/^\s*(hey |ok |okay )?(show me|find( me)?|search( for)?|look( for)?|pull up|get me|i want|do you have|any)\s+/i,'').trim() || t.trim();
  $('#q').value=q;
  toast('🔎 “'+q+'”');
  // All-Shopify is the broadest lane — it resolves vendor names, product types, titles, SKUs,
  // and (via the mfr# index) printed vendor codes. Exactly what "look for a vendor" needs.
  chipTo('shop'); doShopSearch();
}

// ── Ask-the-SKU: speak a question about the OPEN product → local Ollama answers (spoken back) ──
function speak(text){ try{ const u=new SpeechSynthesisUtterance(text); u.rate=1.05; speechSynthesis.cancel(); speechSynthesis.speak(u); }catch(e){} }
function voiceAsk(){
  if(!GAL_X||!GAL_X.product_id){ toast('Open a product first'); return; }
  const SR=window.SpeechRecognition||window.webkitSpeechRecognition;
  if(!SR){ const q=prompt('Ask about '+(GAL_X.dw_sku||'this SKU')+':'); if(q) askSku(q); return; }
  if(_listening){ try{ _recog&&_recog.stop(); }catch(e){} return; }
  unlockAudio();
  _recog=new SR(); _recog.lang='en-US'; _recog.interimResults=false; _recog.maxAlternatives=1;
  _listening=true; const b=$('#galAsk'); if(b) b.textContent='🔴 …';
  toast('🎤 Ask about this SKU…');
  _recog.onresult=e=>{ const t=((e.results[0][0]||{}).transcript||'').trim(); if(t) askSku(t); };
  _recog.onerror=e=>{ toast('Mic: '+(e.error||'error')); };
  _recog.onend=()=>{ _listening=false; const b=$('#galAsk'); if(b) b.textContent='🎤 Ask'; };
  try{ _recog.start(); }catch(e){ _listening=false; toast('Mic busy — try again'); }
}
async function askSku(question){
  if(!GAL_X||!GAL_X.product_id) return;
  const a=$('#galAnswer'); a.hidden=false; a.innerHTML='<span class="qq">'+question+'</span>💭 thinking…';
  try{
    const r=await fetch('/api/ask',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({product_id:GAL_X.product_id,question})});
    const d=await r.json(); const ans=d.answer||'No answer.';
    a.innerHTML='<span class="qq">'+question+'</span>'+ans;
    speak(ans);
  }catch(e){ a.innerHTML='<span class="qq">'+question+'</span>⚠️ AI unreachable on the LAN.'; }
}
async function openLiveScan(){
  unlockAudio();                              // we're inside the user's tap → unlock audio for the lock-in cue
  try{ await startScanStream(); }
  catch(e){ toast('Camera blocked — using snap'); return $('#scanInput').click(); }
  _lastTop=null;_lockHits=0;_frontHits=0;_scanStart=Date.now();_scanSlow=false; resetScanFields(); $('#scanlive').classList.remove('locked'); $('#scanStatus').textContent='Point at the printed SKU…';
  $('#scanFlip').textContent=(camFacing==='user'?'🔄 Back camera':'🔄 Front camera');
  $('#scanlive').hidden=false; document.body.style.overflow='hidden';
  scanTimer=setInterval(scanTick,SCAN_FAST);
}
// tap the paused overlay to resume scanning (idle-pause caps cloud OCR cost)
function resumeScan(){ if(!$('#scanlive').hidden && !scanTimer){ _scanStart=Date.now(); _lastTop=null;_lockHits=0;_frontHits=0; $('#scanStatus').textContent='Point at the printed SKU…'; scanTimer=setInterval(scanTick,_scanSlow?SCAN_CLOUD:SCAN_FAST); } }
async function flipCam(){   // toggle back<->front, persist, re-acquire live + re-point capture inputs
  camFacing=(camFacing==='user')?'environment':'user'; setLS('camFacing',camFacing); applyCamCapture(); syncCamBtn();
  $('#scanFlip').textContent=(camFacing==='user'?'🔄 Back camera':'🔄 Front camera');
  if(!$('#scanlive').hidden){ try{ await startScanStream(); }catch(e){ toast('Could not switch camera'); } }
  else toast(camFacing==='user'?'📷 Front camera':'📷 Back camera');
}
function closeLiveScan(){ if(scanTimer)clearInterval(scanTimer); scanTimer=null; if(scanStream){scanStream.getTracks().forEach(t=>t.stop());scanStream=null;} _frontHits=0; resetScanFields(); $('#scanlive').hidden=true; $('#scanlive').classList.remove('locked'); document.body.style.overflow=''; }
// Live field readout: fill SKU#/Model#/Name/Color and flip each chip GREEN the moment it's detected.
// Sticky — once a field is read it stays green (a later blank frame won't wipe a good read).
let _sfSeen={};
function updateScanFields(f){
  f=f||{};
  document.querySelectorAll('#scanFields .sf-chip').forEach(ch=>{
    const k=ch.getAttribute('data-k'); const v=(f[k]!=null&&String(f[k]).trim())?String(f[k]).trim():(_sfSeen[k]||'');
    if(v){ _sfSeen[k]=v; ch.classList.add('on'); ch.querySelector('i').textContent=v; }
  });
}
function resetScanFields(){ _sfSeen={}; document.querySelectorAll('#scanFields .sf-chip').forEach(ch=>{ ch.classList.remove('on'); ch.querySelector('i').textContent='—'; }); }
async function ocrPost(dataUrl){ const r=await fetch('/api/ocr',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({dataUrl})}); return r.json(); }
function mirrorDataUrl(dataUrl){ return new Promise((res,rej)=>{ const im=new Image(); im.onload=()=>{ const c=document.createElement('canvas'); c.width=im.naturalWidth; c.height=im.naturalHeight; const x=c.getContext('2d'); x.translate(c.width,0); x.scale(-1,1); x.drawImage(im,0,0); res(c.toDataURL('image/jpeg',0.8)); }; im.onerror=rej; im.src=dataUrl; }); }
// front camera can deliver a MIRRORED frame (iOS) → backwards text reads as garbage. If a front-cam read finds nothing, retry flipped.
async function ocrSmart(dataUrl){
  let d=await ocrPost(dataUrl);
  if(camFacing==='user' && (!d||!d.candidates||!d.candidates.length)){
    try{ const d2=await ocrPost(await mirrorDataUrl(dataUrl)); if(d2&&d2.candidates&&d2.candidates.length) return d2; }catch(e){}
  }
  return d;
}
async function scanTick(){
  if(scanBusy)return; scanBusy=true;
  try{
    const v=$('#scanvideo'); if(!v.videoWidth){scanBusy=false;return;}
    const VW=v.videoWidth, VH=v.videoHeight;
    // full frame — kept for logo-ID + pattern-match (which want the whole swatch, not a text strip)
    const s=Math.min(1,1280/Math.max(VW,VH));
    const c=document.createElement('canvas'); c.width=Math.round(VW*s); c.height=Math.round(VH*s);
    c.getContext('2d').drawImage(v,0,0,c.width,c.height);
    // HONE IN ON THE TEXT: crop to the centered guide box (~86% wide, 5:2 like .scan-frame) and
    // UPSCALE it (up to 2x, cap 1600px) so small printed SKUs get more pixels for OCR and the
    // surrounding clutter is dropped — a sharper, tighter read than OCRing the whole frame.
    const roiW=Math.round(VW*0.86), roiH=Math.min(Math.round(roiW*2/5), Math.round(VH*0.92));
    const roiX=Math.round((VW-roiW)/2), roiY=Math.round((VH-roiH)/2);
    const TW=Math.min(1600,Math.round(roiW*2));
    const cRoi=document.createElement('canvas'); cRoi.width=TW; cRoi.height=Math.round(roiH*TW/roiW);
    cRoi.getContext('2d').drawImage(v, roiX,roiY,roiW,roiH, 0,0,cRoi.width,cRoi.height);
    const d=await ocrSmart(cRoi.toDataURL('image/jpeg',0.72));   // honed ROI; front-cam mirror-retry built in
    if(d&&d.fields) updateScanFields(d.fields);   // light SKU#/Model#/Name/Color chips green as they're read
    // cost throttle: slow ticks on a cloud (paid) engine, and idle-pause to cap spend on a forgotten scanner
    if(d&&d.available&&!d.available.includes('macvision')&&!_scanSlow){ _scanSlow=true; if(scanTimer){clearInterval(scanTimer);scanTimer=setInterval(scanTick,SCAN_CLOUD);} }
    if(_scanStart&&(Date.now()-_scanStart)>SCAN_IDLE_MS&&!_lockHits&&!_frontHits){ if(scanTimer){clearInterval(scanTimer);scanTimer=null;} $('#scanStatus').textContent='⏸ Paused to save cost — tap to resume'; scanBusy=false; return; }
    if(d&&d.top){
      _frontHits=0;   // a real code appeared → not the pattern face; cancel any front streak
      if(d.top===_lastTop) _lockHits++; else {_lastTop=d.top;_lockHits=1;}
      // FAST LOCK: a strong read (known vendor prefix, or the brand name is printed on the
      // swatch and this is the biggest code) locks on the FIRST sighting; anything weaker
      // waits for a 2nd matching frame so a shaky/garbled read can't false-lock.
      const locked=_lockHits>=2 || (d.topStrong && _lockHits>=1);
      const vtag=d.vendor ? d.vendor+' · ' : '';
      // a BARCODE read is exact (not OCR-guessed) → label it distinctly so the user trusts it.
      const isBarcode=d.barcode && d.barcode===d.top;
      const lockLabel=isBarcode ? '▍▍ BARCODE · ' : '🔒 LOCKED · ';
      // tentative cue: a non-strong read needs a 2nd matching frame to lock — coach the user
      // to hold steady (reduces the "why won't it lock" confusion); strong reads lock instantly.
      const tentative=d.topStrong ? '👀 ' : '👀 hold steady · ';
      $('#scanStatus').textContent=(locked?lockLabel:tentative)+vtag+d.top;
      if(locked){
        $('#scanlive').classList.add('locked'); // turn the frame + pill RED — "locked in"
        try{navigator.vibrate&&navigator.vibrate(70);}catch(e){}   // haptic on lock; audio waits for the resolve outcome
        if(scanTimer){clearInterval(scanTimer);scanTimer=null;}   // freeze ticks on the red frame
        const hit=d;
        const frame=c.toDataURL('image/jpeg',0.8);   // keep the locked frame for a background logo-ID if no vendor
        setTimeout(()=>{ closeLiveScan(); applyScan(hit, frame); },420);  // hold the red a beat, then GO
      }
    } else if(d && d.side==='front'){
      // Pattern face — no SKU on this side. Hold steady a few frames, then auto-MATCH the design
      // (reuse the recognize path) instead of dead-ending; user can still flip for the exact SKU.
      _lastTop=null; _lockHits=0; _frontHits++;
      if(_frontHits>=3){
        $('#scanStatus').textContent='🎨 Matching this design…';
        $('#scanlive').classList.add('locked');
        try{navigator.vibrate&&navigator.vibrate(70);}catch(e){}
        if(scanTimer){clearInterval(scanTimer);scanTimer=null;}
        const frame=c.toDataURL('image/jpeg',0.85);
        setTimeout(()=>{ closeLiveScan(); recognizeDataUrl(frame); },300);
      } else {
        $('#scanStatus').textContent='🎨 Front (pattern) · hold steady to match the design, or flip for the SKU';
      }
    }
    else if(_lockHits<2){ $('#scanStatus').textContent='📄 Point at the printed SKU…'; }
  }catch(e){} finally{ scanBusy=false; }
}
// multi-pass resolver: try EVERY OCR candidate against TWIL (dw_sku+mfr+name) then ALL-Shopify; first hit wins
function chipTo(f){
  document.querySelectorAll('.chip').forEach(z=>z.classList.remove('on'));
  const sc=document.querySelector('.chip[data-f="'+f+'"]'); if(sc)sc.classList.add('on');
  filter=f; setLS('filter',f); $('#q').placeholder='🔎 SKU, model #, name, or collection…';
}
async function scanResolve(cands){
  for(const c of (cands||[])){            // pass 1 — TWIL catalog (dw_sku + mfr# + name + collection in hay)
    try{ const d=await (await fetch('/api/lookup?q='+encodeURIComponent(c))).json();
      if(d&&d.items&&d.items.length) return {via:'any',q:c,n:d.items.length,vendor:(d.items[0]&&d.items[0].vendor)||''}; }catch(e){}
  }
  for(const c of (cands||[])){            // pass 2 — ALL Shopify (any vendor: dw sku / name / vendor)
    try{ const d=await (await fetch('/api/shopify-search?q='+encodeURIComponent(c))).json();
      if(d&&d.items&&d.items.length) return {via:'shop',q:c,n:d.items.length,vendor:(d.items[0]&&d.items[0].vendor)||''}; }catch(e){}
  }
  return null;
}
// Non-blocking logo-ID: when a code locked but OCR couldn't name the vendor, ask the VLM
// in the background (the resolve already ran — this never blocks it). If it finds a vendor,
// surface it and bind it to the locked SKU so the profile learns. See /api/identify.
function bgIdentify(imgDataUrl, sku){
  if(!imgDataUrl) return;
  fetch('/api/identify',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({dataUrl:imgDataUrl})})
    .then(r=>r.json()).then(v=>{
      if(v&&v.vendor){
        toast('🏷 Looks like '+v.vendor);
        if(sku) fetch('/api/learn',{method:'POST',headers:{'Content-Type':'application/json'},
          body:JSON.stringify({sku:sku,vendor:v.vendor})}).catch(()=>{});
      }
    }).catch(()=>{});
}
async function applyScan(d, imgDataUrl){
  const cands=(d&&d.candidates&&d.candidates.length)?d.candidates:(d&&d.top?[d.top]:[]);
  if(!cands.length){ playUhoh(); toast('No SKU/name found — try a closer, flatter shot'); return; }
  toast('🔎 Matching '+cands.length+' read'+(cands.length>1?'s':'')+'…');
  const hit=await scanResolve(cands);
  collapsed=false;
  if(!hit){                               // locked a code but nothing matched anywhere → "not right"
    playUhoh(); chipTo('shop'); $('#q').value=cands[0]; toast('✗ No match for '+cands[0]+' — searching all Shopify');
    if(!d.vendor) bgIdentify(imgDataUrl, cands[0]);   // no vendor from OCR → try the logo in the background
    return doShopSearch();
  }
  if(!hit.vendor && !d.vendor) bgIdentify(imgDataUrl, hit.q);   // resolved a product but no vendor named → logo-ID
  playYoohoo();                           // ✓ resolved to a real product — the happy lock
  // LEARN: this scan resolved to a known vendor — refine that vendor's sample profile
  // (fire-and-forget; server folds in the on-swatch code prefix). See /learn.
  if(hit.vendor){ const _f=(d&&d.fields)||{}; fetch('/api/learn',{method:'POST',headers:{'Content-Type':'application/json'},
    body:JSON.stringify({text:(d&&d.text)||'',sku:hit.q,vendor:hit.vendor,name:_f.name||null,color:_f.color||null})}).catch(()=>{}); }
  $('#q').value=hit.q;
  if(hit.via==='any'){ chipTo('any'); toast('✓ '+hit.q+' — '+hit.n+' on TWIL'); doLookup(); }
  else { chipTo('shop'); toast('✓ '+hit.q+' — '+hit.n+' in Shopify'); doShopSearch(); }
  // sample-request: who ordered this sample? (best id: barcode > matched sku > mfr code) → sticker popup
  checkSampleRequest((d&&d.fields&&d.fields.barcode)||hit.q, (d&&d.fields&&d.fields.model)||null);
}

// ── Sample-request sticker popup: scan → who ordered this sample → print SKU/address sticker (FileMaker) ──
let _sampReq=null;
function openSampleModal(data){
  _sampReq=data;
  const id=data.identity||{}, item=data.item||{}, cl=data.client||{}, dt=data.dates||{};
  const img=$('#sampImg');
  if(id.image){ img.src=id.image; img.hidden=false; } else { img.hidden=true; img.removeAttribute('src'); }
  $('#sampPat').textContent=item.mfr_pattern||id.pattern||id.mfr_sku||item.combo_sku||'Sample';
  $('#sampMeta').textContent=[id.vendor||item.vid, item.combo_sku].filter(Boolean).join(' · ');
  $('#sampClient').textContent=cl.name||'(no client on record)';
  $('#sampOrdered').textContent=dt.client_ordered||'—';
  $('#sampVendorDate').textContent=dt.vendor_ordered||'—';
  $('#sampNote').textContent=''; $('#sampPrintSku').disabled=false; $('#sampPrintAddr').disabled=false;
  $('#sampleModal').hidden=false; document.body.style.overflow='hidden';
}
function closeSampleModal(){ $('#sampleModal').hidden=true; document.body.style.overflow=''; _sampReq=null; }
async function sampPrint(type){
  if(!_sampReq||!_sampReq.recordId) return;
  $('#sampPrintSku').disabled=true; $('#sampPrintAddr').disabled=true; $('#sampNote').textContent='Sending to FileMaker…';
  try{
    const r=await(await fetch('/api/print-sticker',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({recordId:_sampReq.recordId,sticker:type})})).json();
    if(r.ok){ $('#sampNote').textContent=r.queued?('✓ '+type+' sticker sent to FileMaker to print'):('(preview) would flag '+type+' — FM_WRITE=1 to go live'); toast('🎫 '+type+' → FileMaker'); }
    else { $('#sampNote').textContent='✗ '+(r.err||'failed'); $('#sampPrintSku').disabled=false; $('#sampPrintAddr').disabled=false; }
  }catch(e){ $('#sampNote').textContent='✗ '+e.message; $('#sampPrintSku').disabled=false; $('#sampPrintAddr').disabled=false; }
}
async function checkSampleRequest(code,mfr){
  if(!code&&!mfr)return;
  try{ const q=new URLSearchParams(); if(code)q.set('code',code); if(mfr)q.set('mfr',mfr);
    const r=await(await fetch('/api/sample-request?'+q.toString())).json();
    if(r&&r.found) openSampleModal(r);
  }catch(e){}
}
$('#sampClose').addEventListener('click',closeSampleModal);
$('#sampPrintSku').addEventListener('click',()=>sampPrint('sku'));
$('#sampPrintAddr').addEventListener('click',()=>sampPrint('address'));

// ── Front + Back identify: fuse back-label code + vendor + QR (client-decoded) with front visual ──
let _fbBack=null,_fbFront=null,_fbQR=null,_jsqr=null,_fbLookup=false;
function loadJsQR(){ if(_jsqr) return Promise.resolve(_jsqr); return new Promise((res)=>{ const s=document.createElement('script'); s.src='https://cdn.jsdelivr.net/npm/jsqr@1.4.0/dist/jsQR.js'; s.onload=()=>{_jsqr=window.jsQR;res(_jsqr);}; s.onerror=()=>res(null); document.head.appendChild(s); }); }
async function decodeQR(dataUrl){ const jsQR=await loadJsQR(); if(!jsQR) return null;
  // iOS-safe: cap the QR canvas at ≤1000px (a full-12MP getImageData is a ~48MB array that freezes iOS).
  // createImageBitmap decodes off-main-thread + low-memory; QR reads fine at this size.
  try{
    const blob=await(await fetch(dataUrl)).blob();
    const bmp=await createImageBitmap(blob);
    const s=Math.min(1,1000/Math.max(bmp.width,bmp.height));
    const c=document.createElement('canvas'); c.width=Math.max(1,Math.round(bmp.width*s)); c.height=Math.max(1,Math.round(bmp.height*s));
    const x=c.getContext('2d'); x.drawImage(bmp,0,0,c.width,c.height); if(bmp.close)bmp.close();
    const d=x.getImageData(0,0,c.width,c.height); const q=jsQR(d.data,c.width,c.height); return q?q.data:null;
  }catch(e){ return null; }
}
async function fbRead(file,isBack){ if(!file)return;
  try{ $('#fbNote').textContent='📷 Processing photo…'; }catch(e){}
  // iOS FREEZE FIX (DTD verdict B): do ZERO client-side decode. FileReader reads the raw file to a
  // data URL without ever decoding the 12MP image into a bitmap/canvas — the server downscales+converts.
  const url=await new Promise(r=>{ const fr=new FileReader(); fr.onload=()=>r(fr.result); fr.onerror=()=>r(null); fr.readAsDataURL(file); });
  if(!url){ try{ $('#fbNote').textContent='Could not read that photo — try again.'; }catch(e){} return; }
  if(isBack){ _fbBack=url; const t=$('#fbBackThumb'); t.style.backgroundImage=`url('${url}')`; t.textContent=''; $('#fbBackTile').classList.add('set');
    $('#fbNote').textContent='Reading label + QR…'; _fbQR=await decodeQR(url).catch(()=>null); $('#fbNote').textContent=_fbQR?'🔗 QR code read':''; }
  else { _fbFront=url; const t=$('#fbFrontThumb'); t.style.backgroundImage=`url('${url}')`; t.textContent=''; $('#fbFrontTile').classList.add('set'); }
  $('#fbGo').disabled=!(_fbBack||_fbFront);
  // LOOK-UP SKU flow: front first → prompt/open the BACK (label) → then auto-identify
  if(_fbLookup){
    // NOTE: do NOT programmatically re-open the native camera here — iOS Safari LOCKS UP when a
    // capture-input .click() fires from a change-event (not a fresh user gesture). Prompt a tap instead.
    if(!isBack && !_fbBack){ $('#fbFrontTile').classList.remove('need'); $('#fbBackTile').classList.add('need');
      $('#fbNote').innerHTML='✓ Front captured — <b>tap “Back · label” below to shoot the label</b> (or 🔎 Identify to look up by pattern only)';
      $('#fbGo').disabled=false; }                            // allow front-only identify too
    else if(isBack){ $('#fbBackTile').classList.remove('need'); fbIdentify(); }   // both in → look it up
  }
}
function fbVisualHtml(vis){ if(!vis||!vis.length) return ''; return '<div class="samp-meta" style="margin-top:12px">Visual matches:</div>'+vis.slice(0,4).map(v=>`<div class="fb-hit">${v.image?`<img src="${v.image}">`:''}<div><b>${v.pattern||v.dw_sku||''}</b> <small>${Math.round((v.score||0)*100)}%</small><br><small>${[v.vendor,v.dw_sku].filter(Boolean).join(' · ')}</small></div></div>`).join(''); }
// POST JSON with real UPLOAD progress (fetch can't report it) — so a big raw-photo upload on cellular
// shows '⬆ Uploading… n%' instead of a dead-looking wait the user misreads as a freeze.
function postJSONup(url,obj,onUp){ return new Promise((resolve,reject)=>{
  const xhr=new XMLHttpRequest(); xhr.open('POST',url); xhr.setRequestHeader('Content-Type','application/json');
  if(onUp&&xhr.upload) xhr.upload.onprogress=e=>{ if(e.lengthComputable) onUp(Math.round(e.loaded/e.total*100)); };
  xhr.onload=()=>{ try{ resolve(JSON.parse(xhr.responseText)); }catch(e){ reject(e); } };
  xhr.onerror=()=>reject(new Error('network')); xhr.ontimeout=()=>reject(new Error('timeout')); xhr.timeout=90000;
  xhr.send(JSON.stringify(obj)); }); }
async function fbIdentify(){
  $('#fbGo').disabled=true; $('#fbNote').textContent='⬆ Uploading photo…'; $('#fbResult').innerHTML='';
  try{
    const r=await postJSONup('/api/identify-multi',{back:_fbBack,front:_fbFront,qr:_fbQR},
      pct=>{ $('#fbNote').textContent = pct<100 ? `⬆ Uploading photo… ${pct}%` : '🔎 Identifying…'; });
    // what the OCR actually read off the label (codes with a digit) — the model# is what runs down the SKU
    const ocrCands=(r.back_read&&r.back_read.candidates)||[];
    const ocrTop=r.code||(r.back_read&&r.back_read.top)||ocrCands[0]||'';
    const engLbl=(r.back_read&&r.back_read.engines&&r.back_read.engines.join('+'))||'';
    const readLine=ocrCands.length?`<div class="fb-read">📄 read${engLbl?` (${engLbl})`:''}: ${ocrCands.slice(0,6).join(' · ')}</div>`:'';
    if(!r.found){
      if(ocrTop){ $('#fbNote').innerHTML=`No exact match for <b>${ocrTop}</b> — searching the wallpaper file…`+readLine; wallpaperSearch(ocrTop); }
      else { $('#fbNote').innerHTML='No code read — try a clearer/closer photo of the label, or type part of the SKU below.'+readLine; $('#fbResult').innerHTML=fbVisualHtml(r.visual); }
      $('#fbGo').disabled=false; return; }
    const p=r.primary, conf=(r.confidence||'').replace('-','');
    const via=r.source==='qr'?'🔗 QR':r.source==='code'?'📄 label code':'🎨 visual';
    $('#fbNote').innerHTML=`Source: ${via}${r.corroborated?' · ✓ front+back agree':''}`+readLine;
    $('#fbResult').innerHTML=`<div class="fb-hit">${p.image?`<img src="${p.image}">`:''}<div><b>${p.pattern||p.mfr_sku||p.dw_sku||'—'}</b><span class="fb-conf ${conf}">${r.confidence}</span><br><small>${[p.vendor,p.dw_sku,p.mfr_code].filter(Boolean).join(' · ')}</small></div></div>`+`<div id="fbWpDetails"></div>`+fbVisualHtml(r.visual);
    // WALLPAPER lookup: trust a CODE/QR identify; but a VISUAL-only match doesn't beat the printed model# —
    // so prefer the OCR'd label code for the file lookup, falling back to the identified code.
    const byCode=(r.source==='code'||r.source==='qr');
    // Always key the FM lookup off the PRINTED code (ocrTop) first — that's the model# on the label —
    // plus the resolved dw_sku when we code-matched. The endpoint tries every dashed/dashless form.
    loadWallpaperDetails({ dw_sku: byCode?p.dw_sku:'', mfr_code: ocrTop||p.mfr_code||p.mfr_sku, _ocr: ocrTop });
    if(!_fbLookup){ matchIncoming(p); checkSampleRequest(p.dw_sku||p.mfr_code, p.mfr_code); }   // Check-Sample-In: flag the incoming match + print popup; pure Look-Up SKU skips both
  }catch(e){ $('#fbNote').textContent='Failed: '+e.message; }
  $('#fbGo').disabled=false;
}
// On any scan, pull the master product record from the WALLPAPER FileMaker file and show its details.
async function loadWallpaperDetails(p){ const box=$('#fbWpDetails')||$('#fbResult'); if(!box) return;
  const sku=encodeURIComponent(p.dw_sku||p.internal_sku||''), code=encodeURIComponent(p.mfr_code||p.mfr_sku||'');
  if(!sku && !code) return;
  box.innerHTML='<div class="wp-d-hd">Wallpaper file…</div>';
  let r; try{ r=await(await fetch(`/api/wallpaper-details?sku=${sku}&code=${code}`)).json(); }catch(e){ box.innerHTML=''; return; }
  if(!r||!r.ok||!r.found||!r.details){                         // exact miss → try a partial/fuzzy lookup
    box.innerHTML='<div class="wp-d-hd wp-d-empty">No exact wallpaper-file record — searching partial…</div>';
    wallpaperSearch(p.mfr_code||p.mfr_sku||p.dw_sku||p._ocr||'', box); return; }
  const d=r.details;
  const rows=[['Mfr #','mfr_pattern'],['Collection','series'],['Width','width'],['Repeat','repeat'],
    ['Retail','retail'],['Net','net'],['Cost','cost'],['Roll','roll_size'],['Minimum','minimum'],
    ['Border','border'],['Price code','five_ten'],['Line','line'],['SKU','combo_sku']]
    .filter(([,k])=>d[k]).map(([lbl,k])=>{
      let v=d[k]; if((k==='retail'||k==='net'||k==='cost')&&/^\d/.test(v)) v='$'+v;
      return `<div class="wp-d-row"><span class="wp-d-lbl">${lbl}</span><span class="wp-d-val">${v}</span></div>`; });
  const desc=d.internal_desc?`<div class="wp-d-desc">${d.internal_desc}</div>`:'';
  box.innerHTML=`<div class="wp-d-card"><div class="wp-d-hd">📒 Wallpaper file${r.matched>1?` · ${r.matched} records`:''}</div><div class="wp-d-grid">${rows.join('')}</div>${desc}</div>`;
}
// Partial / fuzzy search of the WALLPAPER file by any part of a SKU or description. Renders a candidate
// list into `box` (defaults to #fbResult for the manual search box).
let _wpSeq=0;
async function wallpaperSearch(q, box){ box=box||$('#fbResult'); if(!box) return;
  q=(q||'').trim(); if(q.replace(/[^A-Za-z0-9]/g,'').length<3){ box.innerHTML='<div class="wp-d-hd wp-d-empty">Type at least 3 characters.</div>'; return; }
  const seq=++_wpSeq;                                              // race guard: only the latest query renders
  box.innerHTML=`<div class="wp-d-hd">Searching the wallpaper file for “${q}”…</div>`;
  let r; try{ r=await(await fetch('/api/wallpaper-search?q='+encodeURIComponent(q))).json(); }catch(e){ if(seq===_wpSeq) box.innerHTML='<div class="wp-d-hd wp-d-empty">Search failed.</div>'; return; }
  if(seq!==_wpSeq) return;                                         // a newer keystroke already superseded this
  const cands=(r&&r.candidates)||[];
  if(!cands.length){ box.innerHTML=`<div class="wp-d-hd wp-d-empty">No wallpaper-file match for “${q}”.</div>`; return; }
  const rowHtml=c=>{ const bits=[c.width&&('W '+c.width),c.repeat&&('R '+c.repeat),c.retail&&('$'+c.retail),c.supplier,c.line].filter(Boolean).join(' · ');
    return `<div class="wp-c" onclick="loadWallpaperDetailsBySku('${(c.combo_sku||'').replace(/'/g,'')}','${(c.mfr_pattern||'').replace(/'/g,'')}')">
      <div class="wp-c-top"><b>${c.mfr_pattern||c.combo_sku||'—'}</b><span class="wp-c-sku">${c.combo_sku||''}</span></div>
      <div class="wp-c-sub">${[c.series,bits].filter(Boolean).join(' · ')||c.internal_desc||''}</div></div>`; };
  box.innerHTML=`<div class="wp-d-hd">📒 ${cands.length} partial match${cands.length>1?'es':''} for “${q}”</div><div class="wp-c-list">${cands.map(rowHtml).join('')}</div>`;
}
// tapping a candidate loads its full detail card
async function loadWallpaperDetailsBySku(sku,code){ await loadWallpaperDetails({dw_sku:sku,mfr_code:code}); }
// INCOMING SAMPLES (Check Sample In): show the ordered-not-received queue with image+spec; on a scan, flag the match.
let _incoming=[];
async function loadIncoming(){ const box=$('#fbIncoming'); if(!box) return; box.innerHTML='<div class="inc-hd">Loading samples coming in…</div>';
  let r; try{ r=await(await fetch('/api/incoming-samples')).json(); }catch(e){ box.innerHTML=''; return; }
  _incoming=(r&&r.samples)||[];
  if(!_incoming.length){ box.innerHTML=r&&r.ok?'<div class="inc-hd">No samples currently marked ordered-not-received.</div>':''; return; }
  box.innerHTML='<div class="inc-hd"><b>📦 '+_incoming.length+' coming in</b> — ordered, not yet received. Scan an arrival to check it in.</div>'
    +'<div class="inc-strip">'+_incoming.map((s,i)=>`<div class="inc-card" data-i="${i}" data-rid="${s.recordId||''}" data-sku="${(s.dw_sku||'').toUpperCase()}" data-mfr="${(s.mfr_sku||s.mfr_pattern||'').toUpperCase().replace(/[^A-Z0-9]/g,'')}"><div class="inc-thumb"${s.image?` style="background-image:url('${s.image}')"`:''}>${s.image?'':'📦'}</div><div class="inc-meta"><div class="inc-pat">${(s.pattern||s.mfr_pattern||s.combo_sku||'—')}</div><div class="inc-sub">${[s.vendor,s.vendor_ordered].filter(Boolean).join(' · ')}</div><button class="inc-recv" onclick="event.stopPropagation();markReceived('${s.recordId||''}',this)">✓ Received</button></div></div>`).join('')+'</div>';
  box.querySelectorAll('.inc-card').forEach(c=>c.addEventListener('click',()=>{ const s=_incoming[+c.dataset.i]; $('#fbNote').innerHTML=`📦 <b>${s.pattern||s.mfr_pattern||s.combo_sku}</b> — ${[s.vendor,s.vendor_ordered&&('ordered '+s.vendor_ordered),s.client&&('for '+s.client)].filter(Boolean).join(' · ')}`; }));
}
// Check a scanned/arrived sample IN → stamps Date WP Sample Sent=today in FileMaker, which also removes
// it from the incoming queue. `el` is the ✓ button (card-level) or the scan-match button (in fbNote).
async function markReceived(rid,el){ if(!rid){ return; } const card=el&&el.closest?el.closest('.inc-card'):null;
  if(el){ el.disabled=true; el.textContent='…'; }
  let r; try{ r=await(await fetch('/api/mark-received',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({recordId:rid})})).json(); }catch(e){ r={ok:false,err:'network'}; }
  if(r&&r.ok){ if(card){ card.style.transition='opacity .3s'; card.style.opacity='0'; setTimeout(()=>card.remove(),300); } _incoming=_incoming.filter(s=>String(s.recordId)!==String(rid));
    const hd=document.querySelector('#fbIncoming .inc-hd'); if(hd) hd.innerHTML='<b>📦 '+_incoming.length+' coming in</b> — ordered, not yet received. Scan an arrival to check it in.';
    $('#fbNote').innerHTML=`✅ <b style="color:#2bd06a">Checked in — received ${r.received}</b>`;
  } else if(el){ el.disabled=false; el.textContent='✓ Received'; $('#fbNote').innerHTML=`⚠️ Couldn't check in: ${(r&&r.err)||'error'}`; } }
function matchIncoming(p){ const pk=(p.dw_sku||'').toUpperCase(), pm=(p.mfr_sku||p.mfr_code||'').toUpperCase().replace(/[^A-Z0-9]/g,''); let hit=null;
  document.querySelectorAll('#fbIncoming .inc-card').forEach(c=>{ const m=(pk&&c.dataset.sku===pk)||(pm&&c.dataset.mfr&&(c.dataset.mfr===pm||c.dataset.mfr.startsWith(pm))); c.classList.toggle('hit',!!m); if(m){ hit=c; c.scrollIntoView({inline:'center',block:'nearest',behavior:'smooth'}); } });
  if(hit){ const rid=hit.dataset.rid||''; $('#fbNote').innerHTML+=' · <b style="color:#2bd06a">✓ this is an incoming sample</b>'
    +(rid?` <button class="inc-recv" style="margin-left:8px" onclick="markReceived('${rid}',this)">✓ Check in — received today</button>`:''); } }
function openFbModal(){ _fbBack=_fbFront=_fbQR=null; _fbLookup=false; $('#fbBackThumb').style.backgroundImage=''; $('#fbBackThumb').textContent='📄'; $('#fbFrontThumb').style.backgroundImage=''; $('#fbFrontThumb').textContent='🎨'; $('#fbBackTile').classList.remove('set','need'); $('#fbFrontTile').classList.remove('set','need'); $('#fbGo').disabled=true; $('#fbNote').textContent=''; $('#fbResult').innerHTML=''; $('#fbIncoming').innerHTML='';
  $('#fbModal').querySelector('.samp-pat').textContent='Identify a sample';
  $('#fbModal').querySelector('.samp-meta').textContent='Photograph the BACK label, the FRONT pattern, or both — best ID uses both.';
  $('#fbModal').hidden=false; document.body.style.overflow='hidden'; }
// LOOK UP SKU: camera-first — shoot the FRONT pattern, then it prompts/opens the BACK label, then IDs.
function openLookup(){ openFbModal(); _fbLookup=true;
  $('#fbModal').querySelector('.samp-pat').textContent='Look up SKU';
  $('#fbModal').querySelector('.samp-meta').textContent='Shoot the FRONT pattern first — then the BACK label (the text/SKU) — and it identifies the item.';
  $('#fbFrontTile').classList.add('need');
  setTimeout(()=>{ try{ $('#fbFrontInput').click(); }catch(e){} }, 250); }   // camera on, front first
$('#fbBtn').addEventListener('click',openFbModal);
$('#fbClose').addEventListener('click',()=>{ $('#fbModal').hidden=true; document.body.style.overflow=''; });
$('#fbBackTile').addEventListener('click',()=>$('#fbBackInput').click());
$('#fbFrontTile').addEventListener('click',()=>$('#fbFrontInput').click());
$('#fbBackInput').addEventListener('change',e=>fbRead(e.target.files[0],true));
$('#fbFrontInput').addEventListener('change',e=>fbRead(e.target.files[0],false));
$('#fbGo').addEventListener('click',fbIdentify);
$('#fbSearchBtn').addEventListener('click',()=>wallpaperSearch($('#fbSearchInput').value));
$('#fbSearchInput').addEventListener('keydown',e=>{ if(e.key==='Enter'){ e.preventDefault(); clearTimeout(_wpSearchT); wallpaperSearch(e.target.value); } });
// live search-as-you-type: debounced so we don't fire a FileMaker scan on every keystroke
let _wpSearchT=null;
$('#fbSearchInput').addEventListener('input',e=>{ const v=e.target.value; clearTimeout(_wpSearchT);
  if(v.replace(/[^A-Za-z0-9]/g,'').length<3){ if(!v) $('#fbResult').innerHTML=''; return; }
  _wpSearchT=setTimeout(()=>wallpaperSearch(v),300); });

// ── Add / Update item: capture up to 10 photos + 6 videos → Shopify (draft-create OR live-update) ──
const MAX_PHOTOS=10, MAX_VIDEOS=6;
let _addPhoto=null,_vendorsLoaded=false,_addExtracted={};
let _media=[];                                    // {type:'photo'|'video', dataUrl, name}
let _addMode='add', _updatePid=null, _updateSku=null, _extracted=false, _addSession=0, _updateNeedsConfirm=false;
function mediaCounts(){ return { p:_media.filter(m=>m.type==='photo').length, v:_media.filter(m=>m.type==='video').length }; }
function renderMedia(){ const c=mediaCounts();
  $('#cPhoto').textContent=c.p+'/'+MAX_PHOTOS; $('#cVideo').textContent=c.v+'/'+MAX_VIDEOS;
  $('#addPhotoBtn').disabled=c.p>=MAX_PHOTOS; $('#addVideoBtn').disabled=c.v>=MAX_VIDEOS;
  $('#addMedia').innerHTML=_media.map((m,i)=> m.type==='video'
    ? `<div class="media-cell"><video src="${m.dataUrl}" muted playsinline preload="metadata"></video><span class="badge">🎥</span><button class="rm" data-rm="${i}">✕</button></div>`
    : `<div class="media-cell" style="background-image:url('${m.dataUrl}')"><button class="rm" data-rm="${i}">✕</button></div>`).join('');
  $('#addMedia').querySelectorAll('.rm').forEach(b=>b.addEventListener('click',()=>{ _media.splice(+b.dataset.rm,1); renderMedia(); })); }
function addResetMedia(){ _media=[]; _addPhoto=null; _addExtracted={}; _extracted=false; _updatePid=null; _updateSku=null; _updateNeedsConfirm=false; $('#addSpecs').innerHTML=''; renderMedia(); }
function fileToDataUrl(f){ return new Promise(r=>{ const fr=new FileReader(); fr.onload=()=>r(fr.result); fr.readAsDataURL(f); }); }
async function addMediaAdd(type,file){ if(!file)return; const c=mediaCounts();
  if(type==='photo'){ if(c.p>=MAX_PHOTOS){ toast('Max '+MAX_PHOTOS+' photos'); return; }
    const url=await downscale(file,1400); if(!url){ toast('Could not read that photo — try again or use a JPEG'); return; }
    _media.push({type:'photo',dataUrl:url}); if(!_addPhoto)_addPhoto=url; renderMedia();
    if(!_extracted){ _extracted=true; addExtract(); }             // first photo = the scan → read specs
  } else { if(c.v>=MAX_VIDEOS){ toast('Max '+MAX_VIDEOS+' videos'); return; }
    if(file.size>150*1024*1024){ toast('Video too big (>150MB)'); return; }
    const dataUrl=await fileToDataUrl(file); _media.push({type:'video',dataUrl,name:file.name||'clip.mp4'}); renderMedia(); } }
async function loadVendors(){ if(_vendorsLoaded) return; try{ const r=await(await fetch('/api/vendors')).json(); $('#addVendor').innerHTML='<option value="">— pick vendor —</option>'+(r.vendors||[]).map(v=>`<option value="${v.vendor}" data-vid="${v.vid||''}">${v.vendor}${v.vid?(' ('+v.vid+')'):''}</option>`).join(''); _vendorsLoaded=true; }catch(e){} }
function openAddModal(pref,opts){ opts=opts||{}; _addMode=opts.mode||'add'; _addSession++; addResetMedia();
  ['addMfr','addVid','addName','addColor','addPrice'].forEach(id=>$('#'+id).value=''); $('#addNote').textContent=''; $('#addResult').innerHTML='';
  $('#addTitle').textContent = _addMode==='update' ? 'Update SKU on Shopify' : 'Add new item';
  $('#addTarget').textContent = _addMode==='update' ? 'Shoot the label to find the SKU, then add photos + videos.' : '';
  $('#addPreview').textContent = _addMode==='update' ? '⬆ Push media to SKU (LIVE)' : '👁 Preview';
  if(pref){ if(pref.mfr)$('#addMfr').value=pref.mfr; if(pref.color)$('#addColor').value=pref.color; if(pref.name)$('#addName').value=pref.name; }
  $('#addModal').hidden=false; document.body.style.overflow='hidden';
  loadVendors().then(()=>{ if(pref&&pref.vendor) $('#addVendor').value=pref.vendor; });
  if(opts.camera) setTimeout(()=>$('#addPhotoInput').click(),250);  // onload → go straight to camera
}
function addPhotosList(){ return _media.filter(m=>m.type==='photo').map(m=>m.dataUrl); }
function addVideosList(){ return _media.filter(m=>m.type==='video'); }
function addPayload(commit){ return Object.assign({}, _addExtracted, { mfr:$('#addMfr').value.trim(), vendor:$('#addVendor').value, vid:$('#addVid').value.trim(), name:$('#addName').value.trim(), color:$('#addColor').value.trim(), price:$('#addPrice').value.trim(), dataUrl:_addPhoto, photos:addPhotosList(), commit:!!commit }); }
async function uploadVideos(product_id, dwSku){ const vids=addVideosList(); let ok=0,failed=0;
  for(let i=0;i<vids.length;i++){ $('#addNote').textContent=`🎥 Uploading video ${i+1}/${vids.length}…`;
    try{ const r=await(await fetch('/api/video',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({product_id,dw_sku:dwSku,dataUrl:vids[i].dataUrl})})).json(); if(r.ok)ok++; else failed++; }catch(e){ failed++; } }
  return { ok, failed }; }
// Shopify transcodes video async — poll until every clip is READY (or a couple minutes pass).
async function pollVideoStatus(product_id, baseMsg, expected){ const started=Date.now(); expected=expected||1;
  while(Date.now()-started < 150000){
    await new Promise(r=>setTimeout(r,5000));
    let s; try{ s=await(await fetch('/api/media-status?product_id='+encodeURIComponent(product_id))).json(); }catch(e){ continue; }
    if(!s || !s.ok) continue;
    if(s.failed){ $('#addNote').innerHTML=`${baseMsg} · ⚠ ${s.failed} video(s) failed to process`; return; }
    // don't declare "ready" until ALL expected clips have registered AND finished (a 2nd clip can lag)
    if(s.all_ready && s.total>=expected){ $('#addNote').innerHTML=`${baseMsg} · 🎥 ${s.ready} video(s) ready ✓`; toast('🎥 Videos ready on Shopify'); return; }
    $('#addNote').innerHTML=`${baseMsg} · 🎥 transcoding ${s.ready}/${Math.max(s.total,expected)}…`;
  }
  $('#addNote').innerHTML=`${baseMsg} · 🎥 still transcoding — check Shopify shortly.`;
}
async function addPreview(){ if(_addMode==='update') return addUpdate();
  const p=addPayload(false); if(!p.mfr||!p.vendor){ $('#addNote').textContent='Mfr# and Vendor are required.'; return; }
  $('#addNote').textContent='Previewing…'; $('#addResult').innerHTML='';
  const r=await(await fetch('/api/create-item',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(p)})).json();
  if(r.duplicate){ $('#addNote').innerHTML='⚠ '+r.err+' — tap <b>⌂ → Update SKU</b> to add media to it live.'; return; }
  if(!r.ok){ $('#addNote').textContent='✗ '+(r.err||'failed'); return; }
  const pv=r.preview, c=mediaCounts(); $('#addNote').textContent='Preview — nothing created yet.';
  $('#addResult').innerHTML=`<div class="fb-hit"><div><b>${pv.title}</b><br><small>${[pv.vendor,pv.dw_sku,pv.mfr,pv.color,pv.price?('$'+pv.price):''].filter(Boolean).join(' · ')} · ${c.p} photo(s) · ${c.v} video(s) · <b>DRAFT</b></small></div></div><button class="samp-print" id="addCommit" style="margin-top:10px">✅ Create draft + upload media</button>`;
  $('#addCommit').addEventListener('click',addCommit);
}
async function addCommit(){ const b=$('#addCommit'); b.disabled=true; $('#addNote').textContent='Creating draft…';
  const r=await(await fetch('/api/create-item',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(addPayload(true))})).json();
  if(!r.ok){ $('#addNote').textContent='✗ '+(r.err||'failed'); b.disabled=false; return; }
  const hadVideos=addVideosList().length; let vmsg='', vup={ok:0,failed:0};
  if(hadVideos && r.product_id){ vup=await uploadVideos(r.product_id, r.dw_sku); vmsg=` + ${vup.ok} video(s)`+(vup.failed?` (${vup.failed} failed)`:''); }
  const baseMsg=`✅ Created DRAFT · ${r.dw_sku}${vmsg}`;
  $('#addNote').innerHTML=`${baseMsg} <small>— not live; publish in Shopify when ready</small>`; toast('✅ Draft created: '+r.dw_sku);
  if(vup.ok && r.product_id) pollVideoStatus(r.product_id, baseMsg, vup.ok);
}
// UPDATE mode: resolve the existing product from the scanned mfr#, then push media LIVE.
// resolve an mfr# / sku to a live Shopify product_id and bind it as the Update target
// confirmed=true only for EXPLICIT binds (label OCR match, or a user tapping a candidate).
// A visual auto-bind is confirmed=false → addUpdate demands a 2nd Push before writing LIVE.
async function bindProduct(mfr,sku,confirmed){ const qs=mfr?('mfr='+encodeURIComponent(mfr)):('sku='+encodeURIComponent(sku||''));
  try{ const r=await(await fetch('/api/resolve-product?'+qs)).json();
    if(r.found){ _updatePid=r.product_id; _updateSku=r.dw_sku; _updateNeedsConfirm=!confirmed; return r; } }catch(e){} return null; }
// ADD-MODE DEDUP: before creating a new SKU, check the pattern photo against the catalog (fine-tuned
// visual-ID). If it strongly matches an existing product, WARN (non-blocking) + offer "Update instead".
async function addDedupCheck(){ const mySession=_addSession;   // guard against a stale result landing in a reopened modal
  const photos=addPhotosList(); if(!photos.length || _addMode!=='add') return;
  let r; try{ r=await(await fetch('/api/identify-multi',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({fronts:photos})})).json(); }catch(e){ return; }
  const prim=r&&r.primary; if(!prim || !r.confidence || r.confidence==='low') return;   // only warn on a confident match
  if(_addMode!=='add' || _addSession!==mySession) return;   // modal switched/reopened mid-request — discard
  const label=prim.pattern || prim.dw_sku || prim.mfr_sku || 'an existing item';
  // WARN using the visual match itself (the pattern exists in the catalog even if it doesn't map to a live product_id)
  $('#addTarget').innerHTML=`⚠ This pattern may already exist as <b>${label}</b>${prim.dw_sku?(' <small>('+prim.dw_sku+')</small>'):''} <small>· ${r.confidence}</small>`;
  // resolve to a LIVE product (mfr then dw_sku) — REQUIRE a real product_id, else no "Update" shortcut
  let hit=null;
  for(const qs of [prim.mfr_sku&&('mfr='+encodeURIComponent(prim.mfr_sku)), prim.dw_sku&&('sku='+encodeURIComponent(prim.dw_sku))].filter(Boolean)){
    try{ const h=await(await fetch('/api/resolve-product?'+qs)).json(); if(h.found && h.product_id){ hit=h; break; } }catch(e){} }
  if(_addMode!=='add' || _addSession!==mySession) return;   // re-check after the resolve awaits
  if(hit){ $('#addTarget').innerHTML+=` <button class="vid-chip" id="addSwitchUpd">↻ Update it instead</button>`;
    const el=document.getElementById('addSwitchUpd'); if(el) el.addEventListener('click',()=>switchToUpdate(hit)); } }
// user chose to update the existing match instead of creating a dup — bind it (explicit → no extra confirm)
function switchToUpdate(hit){ _addMode='update'; _updatePid=hit.product_id; _updateSku=hit.dw_sku; _updateNeedsConfirm=false;
  $('#addTitle').textContent='Update SKU on Shopify'; $('#addPreview').textContent='⬆ Push media to SKU (LIVE)';
  $('#addTarget').innerHTML=`✓ Updating <b>${hit.title||hit.dw_sku}</b> <small>(${hit.dw_sku}) — your photos will attach here</small>`;
  $('#addResult').innerHTML=''; toast('Switched to Update — media attaches to '+hit.dw_sku); }
async function addResolveUpdate(){ const mfr=$('#addMfr').value.trim(); if(!mfr) return;
  const r=await bindProduct(mfr,null,true);   // label OCR match = explicit → no extra confirm needed
  if(r){ $('#addTarget').innerHTML=`✓ Updating <b>${r.title||r.dw_sku}</b> <small>(${r.status||''} · ${r.dw_sku})</small>`; }
  else { _updatePid=null; _updateSku=null; $('#addTarget').innerHTML='⚠ No SKU matched “'+mfr+'”. Try a photo of the pattern, fix the mfr#, or use Add New SKU.'; } }
// REALLY ID by DESIGN: front pattern (+ label) photo → /api/identify-multi (OCR+QR+vendor+CLIP visual)
// → bind the matched product. Lets Update mode find a SKU even when the label is unreadable.
async function visualIdentify(){ const photos=addPhotosList(); if(!photos.length) return;
  $('#addTarget').innerHTML='🔍 Identifying by pattern…';
  let r; try{ r=await(await fetch('/api/identify-multi',{method:'POST',headers:{'Content-Type':'application/json'},
    body:JSON.stringify({back:photos[0], fronts:photos})})).json(); }catch(e){ $('#addTarget').textContent='Visual ID failed.'; return; }
  const cands=(r.visual||[]).slice(0,4), prim=r.primary; let bound=null;
  // AUTO-bind ONLY a confident primary. Low-confidence or candidate matches require an explicit
  // tap — never silently bind a live customer product off an ambiguous pattern photo.
  if(prim && r.confidence && r.confidence!=='low') bound=await bindProduct(prim.mfr_sku, prim.dw_sku);
  if(bound){ if(prim.mfr_sku&&!$('#addMfr').value)$('#addMfr').value=prim.mfr_sku;
    $('#addTarget').innerHTML=`🔍 Visually matched <b>${bound.title||_updateSku}</b> <small>(${r.confidence} · ${_updateSku})</small>`; }
  else if(cands.length){ _vidCands=cands;
    $('#addTarget').innerHTML='🔍 Closest patterns — <b>tap to confirm</b>:<div class="vid-cands">'+cands.map((c,i)=>`<button class="vid-chip" data-i="${i}">${((c.pattern||c.mfr_sku||'?')+'').slice(0,20)}</button>`).join('')+'</div>';
    document.querySelectorAll('#addTarget .vid-chip').forEach(b=>b.addEventListener('click',async()=>{ const c=_vidCands[+b.dataset.i]; const bd=await bindProduct(c.mfr_sku,c.dw_sku,true); if(bd){ if(c.mfr_sku)$('#addMfr').value=c.mfr_sku; $('#addTarget').innerHTML=`🔍 Using <b>${bd.title||_updateSku}</b>`; } else $('#addTarget').innerHTML='That pattern has no live product — use Add New SKU.'; })); }
  else $('#addTarget').innerHTML='No confident match — fix the mfr# or use Add New SKU.'; }
let _vidCands=[];
async function addUpdate(){ if(!_updatePid) await addResolveUpdate();
  if(!_updatePid) await visualIdentify();   // last resort: match by the pattern itself
  if(!_updatePid){ $('#addNote').textContent='No product to update — scan the label/pattern or fix the mfr#.'; return; }
  // INVARIANT: never write LIVE to a product bound by an ambiguous visual match without an explicit
  // 2nd Push — covers BOTH the mid-Push bind AND the background bind set earlier during addExtract.
  if(_updateNeedsConfirm){ _updateNeedsConfirm=false;
    $('#addNote').innerHTML=`⚠ Matched <b>${_updateSku}</b> by pattern — tap Push again to confirm the LIVE update.`; return; }
  const photos=addPhotosList(); if(!photos.length && !addVideosList().length){ $('#addNote').textContent='Add at least one photo or video.'; return; }
  $('#addNote').textContent='⬆ Pushing to Shopify (LIVE)…'; let added=0;
  if(photos.length){ const r=await(await fetch('/api/photos',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({dw_sku:_updateSku,product_id:_updatePid,dataUrls:photos})})).json(); added=r.added||0; }
  const hadVideos=addVideosList().length;
  const vup=hadVideos ? await uploadVideos(_updatePid,_updateSku) : {ok:0,failed:0};
  const baseMsg=`✅ LIVE: ${added} photo(s) + ${vup.ok} video(s)`+(vup.failed?` (${vup.failed} failed)`:'')+` → ${_updateSku}`;
  $('#addNote').innerHTML=baseMsg; toast('✅ Updated '+_updateSku+' live');
  if(vup.ok) pollVideoStatus(_updatePid, baseMsg, vup.ok);
}
$('#addBtn').addEventListener('click',()=>openAddModal());
$('#addClose').addEventListener('click',()=>{ $('#addModal').hidden=true; document.body.style.overflow=''; });
$('#addVendor').addEventListener('change',e=>{ const o=e.target.selectedOptions[0]; if(o&&o.dataset.vid&&!$('#addVid').value) $('#addVid').value=o.dataset.vid; });
$('#addPhotoBtn').addEventListener('click',()=>$('#addPhotoInput').click());
$('#addVideoBtn').addEventListener('click',()=>$('#addVideoInput').click());
$('#addPhotoInput').addEventListener('change',e=>{ const files=[...e.target.files]; e.target.value=''; files.forEach(f=>addMediaAdd('photo',f)); });
$('#addVideoInput').addEventListener('change',e=>{ const files=[...e.target.files]; e.target.value=''; files.forEach(f=>addMediaAdd('video',f)); });
$('#addPreview').addEventListener('click',addPreview);
// import AS MUCH INFO AS POSSIBLE from the label photo → prefill every field, then VOICE ONLY as
// needed: if a required field is still blank, the app speaks the prompt + listens (dropdown stays).
async function addExtract(){ if(!_addPhoto)return; const mySession=_addSession; $('#addNote').textContent='📷 Reading label…';
  try{ const r=await(await fetch('/api/extract',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({dataUrl:_addPhoto})})).json();
    if(_addSession!==mySession) return;   // modal was closed/reopened mid-request — discard this stale result
    const f=r.fields||{}; _addExtracted=f; const setIf=(id,v)=>{ if(v&&!$('#'+id).value) $('#'+id).value=v; };
    setIf('addMfr',f.mfr_sku); setIf('addName',f.pattern_name); setIf('addColor',f.color); setIf('addPrice',String(f.price||'').replace(/[^0-9.]/g,''));
    if(r.vendor_matched){ await loadVendors(); const opt=[...$('#addVendor').options].find(o=>o.value.toLowerCase()===r.vendor_matched.toLowerCase()); if(opt){ $('#addVendor').value=opt.value; if(opt.dataset.vid&&!$('#addVid').value)$('#addVid').value=opt.dataset.vid; } }
    const specs=[['Material',f.material],['Collection',f.collection],['Width',f.width],['Roll length',f.roll_length],['Repeat',f.repeat],['Match',f.pattern_match],['Substrate / contents',f.substrate],['How sold',f.how_sold],['Price code',f.price_code]].filter(x=>x[1]);
    $('#addSpecs').innerHTML = specs.length? ('<div class="samp-meta" style="margin:10px 0 2px">Captured specs:</div>'+specs.map(s=>`<div class="add-spec"><span>${s[0]}</span><b>${s[1]}</b></div>`).join('')) : '';
    $('#addNote').textContent='✓ Imported from label';
    if(_addMode==='update'){ await addResolveUpdate(); if(_addSession!==mySession) return;   // discard if modal reset mid-resolve
      if(!_updatePid){ await visualIdentify(); if(_addSession!==mySession) return; } }        // label first, then match by design
    else { addPromptMissing(); addDedupCheck(); }   // warn (non-blocking) if this pattern already exists
  }catch(e){ $('#addNote').textContent=''; }
}
// speak a prompt out loud, then listen for the answer (the on-screen field stays usable too)
function speakThenListen(prompt, onResult){
  const go=()=>{ const SR=window.SpeechRecognition||window.webkitSpeechRecognition; if(!SR)return; const r=new SR(); r.lang='en-US'; r.interimResults=false; r.onresult=e=>onResult(((e.results[0][0]||{}).transcript||'').trim()); r.onerror=()=>{}; try{ r.start(); }catch(e){} };
  try{ const u=new SpeechSynthesisUtterance(prompt); u.rate=1.05; u.onend=go; speechSynthesis.cancel(); speechSynthesis.speak(u); setTimeout(()=>{ if(speechSynthesis.speaking)return; go(); }, 2500); }catch(e){ go(); }
}
// voice ONLY as needed — ask for the first missing REQUIRED field (mfr, then vendor)
function addPromptMissing(){
  if(!$('#addMfr').value.trim()){ $('#addMfr').classList.add('need'); $('#addNote').innerHTML='🔊 <b>Enter the SKU number</b> — say it or type it';
    return speakThenListen('Enter the S K U number', t=>{ const c=(t.toUpperCase().match(/[A-Z]{1,4}[- ]?\d{2,6}/)||t.match(/\d{3,6}/)||[])[0]; if(c){ $('#addMfr').value=c.replace(/\s/g,''); $('#addMfr').classList.remove('need'); } $('#addNote').textContent='🎤 “'+t+'”'; addPromptMissing(); }); }
  if(!$('#addVendor').value){ $('#addVendor').classList.add('need'); $('#addNote').innerHTML='🔊 <b>Enter vendor name</b> — say it or pick from the list';
    return speakThenListen('Enter vendor name', async t=>{ await loadVendors(); const up=t.toUpperCase(); const opt=[...$('#addVendor').options].find(o=>o.value&&(o.value.toUpperCase().includes(up.split(' ')[0])||up.includes(o.value.toUpperCase()))); if(opt){ $('#addVendor').value=opt.value; if(opt.dataset.vid&&!$('#addVid').value)$('#addVid').value=opt.dataset.vid; $('#addVendor').classList.remove('need'); } $('#addNote').textContent='🎤 “'+t+'”'; }); }
  $('#addNote').textContent='✓ Ready — Preview';
}
$('#addVoice').addEventListener('click',addPromptMissing);   // manual re-trigger of the voice fill

// ── Live dimension measure: a credit card (3.375" wide) in frame calibrates pixels→inches ──
const CARD_IN=3.375;   // ISO/IEC 7810 ID-1 (credit card) width
let _mStream=null,_measW=0,_measL=0,_ppi=0,_mMode='measure';
// CALIBRATE ONCE: a card sizes px→inches for THIS device; stored in localStorage, reused forever.
function calGet(){ try{ return JSON.parse(localStorage.getItem('dwMeasureCal')||'null'); }catch(e){ return null; } }
function calSet(o){ try{ localStorage.setItem('dwMeasureCal',JSON.stringify(o)); }catch(e){} }
function mSetMode(mode){ _mMode=mode;
  $('#mCard').hidden = mode!=='calibrate';   // card only shown while calibrating
  $('#mSample').hidden = mode==='calibrate';
  $('#mSaveCal').hidden = mode!=='calibrate';
  $('#mUse').hidden = mode==='calibrate';
  $('#mRecal').hidden = mode==='calibrate';
  mRecompute(); }
function mRecompute(){
  if(_mMode==='calibrate'){ _ppi=$('#mCard').offsetWidth/CARD_IN;
    $('#mReadout').innerHTML=`One-time setup: size 💳 to a real credit card, then Save. <small>(${_ppi.toFixed(0)} px/in)</small>`; return; }
  const s=$('#mSample'); if(!_ppi){ $('#mReadout').textContent='Tap ↺ Recalibrate first.'; return; }
  _measW=s.offsetWidth/_ppi; _measL=s.offsetHeight/_ppi;
  $('#mReadout').innerHTML=`W <b>${_measW.toFixed(1)}"</b> · L <b>${_measL.toFixed(1)}"</b> <small>· saved calibration · hold at your usual distance</small>`; }
function mMakeBox(el){ let mode=null,sx,sy,ox,oy,ow,oh; const rz=el.querySelector('.mrz');
  const start=(e,m)=>{ mode=m; sx=e.clientX; sy=e.clientY; ox=el.offsetLeft; oy=el.offsetTop; ow=el.offsetWidth; oh=el.offsetHeight; (m==='resize'?rz:el).setPointerCapture(e.pointerId); e.preventDefault(); e.stopPropagation(); };
  const move=e=>{ if(!mode)return; const dx=e.clientX-sx,dy=e.clientY-sy;
    if(mode==='move'){ el.style.left=(ox+dx)+'px'; el.style.top=(oy+dy)+'px'; }
    else { el.style.width=Math.max(34,ow+dx)+'px'; el.style.height=Math.max(34,oh+dy)+'px'; } mRecompute(); };
  const end=()=>{ mode=null; };
  el.addEventListener('pointerdown',e=>{ if(e.target!==rz) start(e,'move'); });
  rz.addEventListener('pointerdown',e=>start(e,'resize'));
  el.addEventListener('pointermove',move); rz.addEventListener('pointermove',move);
  el.addEventListener('pointerup',end); rz.addEventListener('pointerup',end); }
async function openMeasure(){
  if(!navigator.mediaDevices||!navigator.mediaDevices.getUserMedia){
    toast('📷 Live camera needs Safari on iPhone — Chrome/other apps block it. Open in Safari.'); return; }
  try{ _mStream=await navigator.mediaDevices.getUserMedia({video:{facingMode:{ideal:'environment'}}}); }
  catch(e){ toast('Camera blocked — allow camera access, or open in Safari.'); return; }
  // LOCK ZOOM to minimum so the focal length / field-of-view stays constant (scale depends on it)
  try{ const tr=_mStream.getVideoTracks()[0], caps=tr.getCapabilities?tr.getCapabilities():{};
    if(caps.zoom) await tr.applyConstraints({advanced:[{zoom:caps.zoom.min||1}]}); }catch(e){}
  $('#mVideo').srcObject=_mStream; $('#measureModal').hidden=false; document.body.style.overflow='hidden';
  Object.assign($('#mCard').style,{left:'26px',top:'96px',width:'135px',height:'85px'});
  Object.assign($('#mSample').style,{left:'56px',top:'200px',width:'210px',height:'270px'});
  const cal=calGet();
  // reuse the saved calibration only if the viewport matches — ppi is in CSS px, so a different
  // screen width (rotation / different device) would make the stored ppi wrong.
  if(cal&&cal.ppi&&(!cal.sw||Math.abs(cal.sw-window.innerWidth)<=2)){ _ppi=cal.ppi; mSetMode('measure'); }
  else { if(cal&&cal.ppi) toast('Screen changed — quick one-time recalibrate'); mSetMode('calibrate'); } }                             // first time on this device → calibrate
function closeMeasure(){ if(_mStream){ _mStream.getTracks().forEach(t=>t.stop()); _mStream=null; } $('#measureModal').hidden=true; document.body.style.overflow=''; }
mMakeBox($('#mCard')); mMakeBox($('#mSample'));
$('#addMeasure').addEventListener('click',openMeasure);
$('#mClose').addEventListener('click',closeMeasure);
$('#mRecal').addEventListener('click',()=>mSetMode('calibrate'));
$('#mSaveCal').addEventListener('click',()=>{ mRecompute();
  if(_ppi>0){ calSet({ppi:_ppi, sw:window.innerWidth, ts:Date.now()});
    toast('✓ Calibrated ('+_ppi.toFixed(0)+' px/in) — no card needed next time'); mSetMode('measure'); } });
$('#mUse').addEventListener('click',()=>{ const w=_measW.toFixed(1)+'"',l=_measL.toFixed(1)+'"';
  _addExtracted.width=w; _addExtracted.roll_length=l;
  $('#addSpecs').insertAdjacentHTML('afterbegin',`<div class="add-spec"><span>📐 Measured</span><b>${w} × ${l}</b></div>`);
  toast(`📐 ${w} × ${l} saved`); closeMeasure(); });
$('#addMfr').addEventListener('input',()=>{ $('#addMfr').classList.remove('need');
  // editing the mfr# unbinds any resolved Update target so we never push to a stale/wrong product
  if(_addMode==='update' && _updatePid){ _updatePid=null; _updateSku=null; $('#addTarget').textContent='mfr# changed — re-scan or press Push to re-find the SKU.'; } });
$('#addVendor').addEventListener('change',()=>$('#addVendor').classList.remove('need'));

// Per-field mic: tap the 🎤 on any field and speak straight into THAT field. kind tunes the parse.
let _micLive=null;
function dictateInto(fieldId, kind, btn){
  const SR=window.SpeechRecognition||window.webkitSpeechRecognition;
  if(!SR){ toast('Voice not supported here — use your keyboard’s mic 🎙️'); $('#'+fieldId).focus(); return; }
  if(_micLive){ try{_micLive.stop();}catch(e){} }   // only one mic at a time
  const r=new SR(); _micLive=r; r.lang='en-US'; r.interimResults=false;
  const off=()=>{ btn.classList.remove('on'); _micLive=null; };
  btn.classList.add('on'); toast('🎤 Listening…');
  r.onend=off; r.onerror=off;
  r.onresult=async e=>{ const t=((e.results[0][0]||{}).transcript||'').trim(); if(!t)return;
    const el=$('#'+fieldId);
    if(kind==='vendor'){ await loadVendors(); const up=t.toUpperCase(); const opt=[...el.options].find(o=>o.value&&(o.value.toUpperCase().includes(up.split(' ')[0])||up.includes(o.value.toUpperCase())));
      if(opt){ el.value=opt.value; if(opt.dataset.vid&&!$('#addVid').value)$('#addVid').value=opt.dataset.vid; el.classList.remove('need'); toast('🎤 “'+t+'” → '+opt.value); } else toast('No vendor match for “'+t+'”'); }
    else if(kind==='sku'){ const c=(t.toUpperCase().match(/[A-Z]{1,4}[- ]?\d{2,6}/)||t.match(/\d{3,6}/)||[])[0]; el.value=(c||t).toUpperCase().replace(/\s/g,''); el.classList.remove('need'); toast('🎤 “'+t+'”'); }
    else if(kind==='num'){ const n=(t.match(/[\d.]+/)||[])[0]; if(n)el.value=n; toast('🎤 “'+t+'”'); }
    else { el.value=t; el.classList.remove('need'); toast('🎤 “'+t+'”'); }
  };
  try{ r.start(); }catch(e){ off(); }
}
document.querySelectorAll('#addModal .mic-btn').forEach(b=>b.addEventListener('click',ev=>{ ev.preventDefault(); dictateInto(b.dataset.mic, b.dataset.kind, b); }));

// ── onload chooser: Add New SKU · Update SKU · Check Sample In ──
function routeHome(act){ $('#homeScreen').hidden=true;
  // onload → straight to the camera; details show after the first scan
  if(act==='add') openAddModal(null,{mode:'add',camera:true});
  else if(act==='update') openAddModal(null,{mode:'update',camera:true});
  else if(act==='lookup') openLookup();          // camera-first front→back → identify
  else if(act==='check'){ openFbModal(); loadIncoming(); }   // show the incoming (ordered-not-received) queue first
  else if(act==='skip'){ if(!TWILITEMS.length) loadTwil(); }   // lazy-load the big TWIL grid only when actually viewing it
}
document.querySelectorAll('#homeScreen .home-pill, #homeScreen .home-skip').forEach(b=>b.addEventListener('click',()=>routeHome(b.dataset.act)));
$('#homeBtn').addEventListener('click',()=>{ $('#homeScreen').hidden=false; });
$('#scanvideo').addEventListener('click',resumeScan);   // tap the paused overlay to resume scanning
$('#scanStatus').addEventListener('click',resumeScan);
$('#scanCancel').addEventListener('click',closeLiveScan);
$('#scanFlip').addEventListener('click',flipCam);
// 🏷 Identify the brand by its LOGO + typesetting via the local VLM (qwen2.5vl). Slower
// than OCR (a few seconds) so it's user-tapped — for swatches whose brand text won't OCR.
$('#scanLogo').addEventListener('click',async()=>{
  const v=$('#scanvideo'); if(!v.videoWidth){ toast('Camera not ready'); return; }
  const s=Math.min(1,768/Math.max(v.videoWidth,v.videoHeight));
  const c=document.createElement('canvas'); c.width=Math.round(v.videoWidth*s); c.height=Math.round(v.videoHeight*s);
  c.getContext('2d').drawImage(v,0,0,c.width,c.height);
  const prev=$('#scanStatus').textContent; $('#scanStatus').textContent='🏷 Reading logo…';
  try{
    const r=await fetch('/api/identify',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({dataUrl:c.toDataURL('image/jpeg',0.8)})});
    const d=await r.json();
    if(d&&d.brand){
      $('#scanStatus').textContent='🏷 '+d.brand+(d.typeface?(' · '+d.typeface):'');
      const tag=(d.vendor||d.brand);
      // if the logo also revealed a code, run the normal resolve on it
      if(d.code){ closeLiveScan(); $('#q').value=tag; applyScan({candidates:[d.code],top:d.code,text:tag,vendor:d.vendor||''}); }
      else { toast('🏷 '+tag+(d.code?(' · '+d.code):'')); }
    } else { $('#scanStatus').textContent=prev; toast(d&&d.err?('Logo read failed: '+d.err):'Brand not recognized'); }
  }catch(e){ $('#scanStatus').textContent=prev; toast('Logo read failed — is Ollama running?'); }
});
// 🎨 Recognize the PATTERN from the live camera frame → similar items (local VLM + DB).
$('#scanPat').addEventListener('click',async()=>{
  const v=$('#scanvideo'); if(!v.videoWidth){ toast('Camera not ready'); return; }
  const s=Math.min(1,1280/Math.max(v.videoWidth,v.videoHeight));
  const c=document.createElement('canvas'); c.width=Math.round(v.videoWidth*s); c.height=Math.round(v.videoHeight*s);
  c.getContext('2d').drawImage(v,0,0,c.width,c.height);
  closeLiveScan();
  await recognizeDataUrl(c.toDataURL('image/jpeg',0.85));
});
function syncCamBtn(){ $('#camBtn').textContent=(camFacing==='user'?'🤳 Front':'📷 Back'); }
$('#camBtn').addEventListener('click',flipCam);
$('#micBtn').addEventListener('click',voiceSearch);
$('#scanBtn').addEventListener('click',()=>{
  if(window.isSecureContext && navigator.mediaDevices && navigator.mediaDevices.getUserMedia) openLiveScan();
  else $('#scanInput').click();   // HTTP (no secure context) → fall back to single-snap
});
$('#scanInput').addEventListener('change',async e=>{
  const f=e.target.files[0]; if(!f)return;
  collapsed=false;
  toast('📷 Reading number…');
  const dataUrl=await downscale(f,1600);
  try{
    const d=await ocrSmart(dataUrl);   // front-cam mirror-retry + multi-candidate
    e.target.value='';
    // multi-pass resolve: every OCR candidate against TWIL sku/mfr/name → fall through to all-Shopify
    await applyScan(d, dataUrl);
  }catch(err){ toast('Scan failed — check connection'); }
});
// 🎨 Similar — photograph the PATTERN itself (not a label) → recognize + find lookalikes.
$('#simBtn').addEventListener('click',()=>$('#simInput').click());
$('#simInput').addEventListener('change',async e=>{
  const f=e.target.files[0]; e.target.value=''; if(!f)return;
  collapsed=false; await recognizePattern(f);
});
applyCamCapture(); syncCamBtn();   // apply saved camera choice to capture inputs + header toggle
load(); loadNew(); loadFav(); loadRecent();   // NOTE: loadTwil() (36MB/88k) is now lazy — fires on 'skip'/TWIL view, not page-load (iOS memory)
setInterval(()=>{ if(filter!=='any'&&filter!=='shop'&&filter!=='fav'&&filter!=='recent'&&filter!=='similar') load(); }, 60000);
</script>
</body>
</html>