← back to All Designerwallcoverings

public/index.html

1153 lines

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>All Products — Designer Wallcoverings</title>
<style>
  :root { --cols: 6; --fs: 1; --bg:#0d0d0f; --card:#17171b; --line:#2a2a30; --txt:#e8e8ea; --mut:#9a9aa2; --accent:#c8a96a; --rail:230px; }
  * { box-sizing: border-box; }
  body { margin:0; background:var(--bg); color:var(--txt); font:14px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; }

  /* ── top bar ── */
  header { position:sticky; top:0; z-index:10; background:rgba(13,13,15,.96); backdrop-filter:blur(8px);
    border-bottom:1px solid var(--line); padding:12px 20px; display:flex; flex-wrap:wrap; gap:10px 16px; align-items:center; }
  h1 { margin:0; font-size:17px; font-weight:600; letter-spacing:.02em; white-space:nowrap; }
  h1 small { color:var(--mut); font-weight:400; font-size:12px; margin-left:8px; }
  h1 a { color:var(--mut); font-size:11px; font-weight:400; margin-left:10px; text-decoration:none; border-bottom:1px dotted var(--mut); }
  .pill { background:var(--accent); color:#1a1407; border-radius:20px; padding:2px 10px; font-size:11.5px; font-weight:700; vertical-align:2px; }
  select, input[type=search], input[type=text] { background:var(--card); color:var(--txt); border:1px solid var(--line); border-radius:8px; padding:7px 10px; font-size:13px; }
  input[type=search] { flex:1 1 200px; min-width:180px; max-width:420px; }
  .bar { display:flex; align-items:center; gap:8px; color:var(--mut); font-size:12px; white-space:nowrap; }
  .dirbtn { background:var(--card); color:var(--accent); border:1px solid var(--line); border-radius:8px; padding:7px 10px; cursor:pointer; font-size:13px; }
  .viewseg { display:flex; border:1px solid var(--line); border-radius:8px; overflow:hidden; }
  .viewseg button { background:var(--card); color:var(--mut); border:none; padding:7px 13px; font-size:12px; cursor:pointer; }
  .viewseg button.on { background:var(--accent); color:#1a1407; font-weight:600; }
  #densWrap input { accent-color:var(--accent); width:110px; }
  /* FileMaker source-of-truth header cluster: status chip + verification toggles + bulk preview */
  #fmBar { gap:6px; border-left:1px solid var(--line); padding-left:10px; }
  .fmstat { color:#8aa6c9; font-size:11px; font-variant-numeric:tabular-nums; }
  .fmstat b { color:#e0b46a; }
  .fmtoggle { background:var(--card); color:var(--mut); border:1px solid var(--line); border-radius:7px;
    padding:5px 9px; font-size:11px; cursor:pointer; font-family:inherit; white-space:nowrap; }
  .fmtoggle:hover { border-color:var(--accent); color:var(--txt); }
  .fmtoggle.on { background:#2a1d0c; color:#e0b46a; border-color:#e0b46a; font-weight:600; }
  #fmBulk { color:#7fd0a0; border-color:#1a3a2a; }
  #fmBulk:hover { border-color:#7fd0a0; }
  /* bulk preview modal */
  #fmModal { position:fixed; inset:0; background:rgba(0,0,0,.6); display:none; align-items:center; justify-content:center; z-index:50; }
  #fmModal.on { display:flex; }
  #fmModal .box { background:#0f0f13; border:1px solid var(--line); border-radius:10px; max-width:560px; width:92%;
    max-height:80vh; overflow:auto; padding:16px 18px; font-size:12.5px; color:var(--txt); }
  #fmModal h3 { margin:0 0 8px; font-size:14px; color:var(--accent); }
  #fmModal .prow { border-top:1px solid var(--line); padding:6px 0; }
  #fmModal .prow .sk { color:#8aa6c9; font-weight:600; }
  #fmModal .prow .c { color:#7fd0a0; } #fmModal .prow .k { color:var(--mut); }
  #fmModal .close { float:right; cursor:pointer; color:var(--mut); border:1px solid var(--line); border-radius:6px; padding:2px 8px; }
  #railToggle { display:none; }

  /* ── layout: Amazon-style left rail + content ── */
  .wrap { display:flex; align-items:flex-start; }
  aside { width:var(--rail); flex:0 0 var(--rail); position:sticky; top:59px; max-height:calc(100vh - 59px);
    overflow-y:auto; padding:10px 10px 60px; border-right:1px solid var(--line); }
  /* Astek-style collapsed accordion sections per field */
  details.sec { border:1px solid var(--line); border-radius:7px; margin-bottom:7px; background:var(--card); }
  details.sec > summary { list-style:none; cursor:pointer; user-select:none; padding:8px 10px; font-size:10.5px;
    letter-spacing:.09em; text-transform:uppercase; color:var(--mut); display:flex; align-items:center; gap:7px; }
  details.sec > summary::-webkit-details-marker { display:none; }
  details.sec > summary::before { content:'▸'; color:var(--accent); font-size:10px; }
  details.sec[open] > summary::before { content:'▾'; }
  details.sec > summary .n { margin-left:auto; background:var(--accent); color:#1a1407; border-radius:20px;
    padding:0 7px; font-size:10px; font-weight:700; }
  .fbody { padding:2px 8px 9px; }
  .fitem { display:flex; align-items:center; gap:7px; padding:2.5px 2px; cursor:pointer; font-size:12.5px; color:var(--txt); border-radius:5px; }
  .fitem:hover { background:var(--card); }
  .fitem .box { width:13px; height:13px; flex:0 0 13px; border:1px solid var(--mut); border-radius:3px; display:inline-flex; align-items:center; justify-content:center; font-size:10px; }
  .fitem.on .box { background:var(--accent); border-color:var(--accent); color:#1a1407; font-weight:700; }
  .fitem.on { color:var(--accent); }
  .fitem .lbl { flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
  .fitem .ct { color:var(--mut); font-size:10.5px; }
  .fitem.zero { opacity:.35; }
  .more { color:var(--accent); font-size:11.5px; cursor:pointer; padding:3px 2px; display:inline-block; }
  .ffind { width:100%; padding:4px 8px !important; font-size:11.5px !important; border-radius:6px !important; margin:2px 0 5px; }
  .active { display:flex; flex-wrap:wrap; gap:6px; margin-bottom:4px; }
  .afilter { background:#23211a; border:1px solid var(--accent); color:var(--txt); border-radius:20px; padding:3px 9px; font-size:11px; cursor:pointer; }
  .afilter b { color:var(--accent); font-weight:600; }
  .clearall { background:none; border:1px solid var(--line); color:var(--mut); border-radius:20px; padding:3px 11px; font-size:11px; cursor:pointer; }

  main { flex:1; min-width:0; }

  /* ── grid view (tight 1px cards, type scale rides --fs from the density slider) ── */
  .grid { display:grid; grid-template-columns:repeat(var(--cols),1fr); gap:8px; padding:12px 14px 30px; }
  .card { background:var(--card); border:1px solid var(--line); border-radius:4px; overflow:hidden; display:flex; flex-direction:column; }
  .thumb { aspect-ratio:1/1; background:#000 center/cover no-repeat; position:relative; }
  .thumb.empty { display:flex; align-items:center; justify-content:center; color:#444; font-size:11px; }
  .badge { position:absolute; top:6px; left:6px; font-size:10px; font-weight:700; letter-spacing:.04em; padding:2px 7px; border-radius:20px; text-transform:uppercase; }
  .badge.life-archived { background:#5a3d3d; color:#e8c9c9; }
  .badge.life-staged { background:#3d4a5a; color:#c9dae8; }
  .badge.life-active { background:#2f4a34; color:#bfe6c6; }
  .meta { padding:calc(7px*var(--fs)) calc(9px*var(--fs)); }
  .sku { font-weight:700; font-size:calc(13px*var(--fs)); color:var(--accent); word-break:break-all; }
  .ttl { color:var(--txt); font-size:calc(11.5px*var(--fs)); margin-top:3px; max-height:2.8em; overflow:hidden; }
  .vt { color:var(--mut); font-size:calc(10.5px*var(--fs)); margin-top:3px; }
  /* manufacturer identity chips — mono number + muted supplier name (internal curation aid) */
  .mfrn { margin-top:3px; font-size:calc(10px*var(--fs)); color:var(--txt); font-variant-numeric:tabular-nums; }
  .mfrn b { color:var(--mut); font-weight:600; letter-spacing:.03em; }
  .mfrname { margin-top:2px; font-size:calc(10px*var(--fs)); color:var(--mut); }
  .mfrname b { letter-spacing:.03em; }
  /* FileMaker source-of-truth: the FM mfr# shown next to Shopify's, + mismatch flag.
     "· FM 12345" in green when it agrees, amber + ⚠ + tinted row when it disagrees. */
  .mfrn .fmv { color:#7fc98a; }
  .mfrn.mismatch { background:#2a1d0c; border-radius:4px; padding:2px 5px; margin-left:-4px; display:flex; flex-wrap:wrap; gap:3px 8px; }
  .mfrn.mismatch .fmv { color:#e0b46a; }
  .mfrn.mismatch .dwnum { color:var(--accent); }
  .mfrn.mismatch .shopv { color:var(--txt); }
  .mfrn.mismatch .dwnum b, .mfrn.mismatch .shopv b, .mfrn.mismatch .fmv b { color:var(--mut); font-weight:600; letter-spacing:.03em; margin-right:1px; }
  .mfrn .mmflag { color:#e0b46a; cursor:help; }
  /* List-view mfr cell: DW · Shop · FM breakout; amber tint + muted labels on a mismatch */
  td.tmfr { font-variant-numeric:tabular-nums; font-size:11px; white-space:nowrap; }
  td.tmfr .tfm { color:#7fc98a; }
  td.tmfr .tmm { background:#2a1d0c; border-radius:4px; padding:1px 5px; color:#e0b46a; }
  td.tmfr .tmm .k { color:var(--mut); font-weight:600; letter-spacing:.02em; }
  .fmspec { margin-top:3px; font-size:calc(9.5px*var(--fs)); color:var(--mut); line-height:1.5; }
  .fmspec b { color:#8aa6c9; font-weight:600; letter-spacing:.02em; }
  .badge.disco { background:#3a1616; color:#e0b3b3; }
  .imgcount { position:absolute; bottom:6px; right:6px; background:rgba(0,0,0,.62); color:#fff; font-size:10px;
    font-weight:600; padding:1px 6px; border-radius:20px; letter-spacing:.02em; pointer-events:none; }
  /* discontinued → red banner (search yields no live grid results) + red-card successor line */
  #discoBanner { display:none; margin:12px 14px 0; padding:14px 16px; border:1px solid #c0392b;
    border-left:5px solid #c0392b; border-radius:10px; background:#2a1414; color:#f0d6d6; }
  #discoBanner.show { display:block; }
  #discoBanner .dbhead { font-size:15px; font-weight:700; color:#e88; letter-spacing:.01em; }
  #discoBanner .dbsucc { margin-top:7px; font-size:14px; color:#f0d6d6; }
  #discoBanner .dbsucc a { color:#ff9b8a; font-weight:700; text-decoration:underline; }
  #discoBanner .dbsucc .arr { opacity:.7; margin:0 6px; }
  #discoBanner .dbnone { margin-top:6px; font-size:12.5px; color:#c99; }
  /* "Search the catalog for a similar pattern" → button + inline results grid */
  #discoBanner .dbfind { display:inline-block; margin-left:6px; padding:5px 14px; font:600 12.5px -apple-system,system-ui,sans-serif;
    color:#ffb3a3; background:#3a1a1a; border:1px solid #c0392b; border-radius:6px; cursor:pointer; letter-spacing:.01em; }
  #discoBanner .dbfind:hover { background:#4a2020; border-color:#e74c3c; color:#ffd0c5; }
  #discoBanner .dbfind:disabled { opacity:.55; cursor:wait; }
  #discoBanner .dbsimhead { margin-top:14px; font-size:12px; font-weight:700; color:#e8b3a8;
    text-transform:uppercase; letter-spacing:.08em; }
  #discoBanner .dbsimgrid { display:grid; grid-template-columns:repeat(auto-fill,minmax(180px,1fr));
    gap:10px; margin-top:8px; }
  #discoBanner .dbsimgrid .card { border-color:#4a2626; }
  .card.disco-red { outline:2px solid #c0392b; outline-offset:-2px; }
  .card.disco-red .disco-succ { display:block; margin-top:5px; font-size:11px; color:#ff9b8a; line-height:1.35; }
  .card.disco-red .disco-succ a { color:#ff9b8a; font-weight:700; text-decoration:underline; }
  /* FileMaker→Shopify push button + diff popover */
  .acts .im.pushbtn, td.acts-td .im.pushbtn { border-color:#1a3a2a; color:#7fd0a0; }
  .acts .im.pushbtn:hover, td.acts-td .im.pushbtn:hover { border-color:#7fd0a0; }
  .pushpop { font-size:calc(10px*var(--fs)); color:var(--txt); background:#0c0c10; border:1px solid var(--line);
    border-radius:5px; padding:6px; margin-top:2px; line-height:1.55; }
  .pushpop[hidden] { display:none; }
  .pushpop .chg { display:block; margin:2px 0; }
  .pushpop .chg .k { color:#8aa6c9; font-weight:600; }
  .pushpop .chg .from { color:#e0b3b3; text-decoration:line-through; opacity:.75; }
  .pushpop .chg .to { color:#7fd0a0; }
  .pushpop .none { color:var(--mut); }
  .pushpop .skip { color:var(--mut); font-style:italic; }
  .pushpop .commitbtn { margin-top:5px; width:100%; padding:4px; border-radius:5px; font-family:inherit; font-weight:600;
    border:1px solid #4a341a; background:transparent; color:#e0b46a; cursor:pointer; }
  .pushpop .commitbtn.gated { opacity:.55; cursor:not-allowed; }
  .fac { margin-top:4px; font-size:calc(10.5px*var(--fs)); color:var(--accent); }
  .price { color:#e8e8ea; font-size:calc(12.5px*var(--fs)); font-weight:600; margin-top:4px; display:block; }
  /* active-or-staged date chip — admin curation aid (Steve rule: admin cards show created date+time) */
  .when { margin-top:5px; font-size:calc(10px*var(--fs)); color:var(--mut); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
  .when b { font-weight:700; letter-spacing:.03em; }
  .when.life-active b { color:#7fc98a; } .when.life-staged b { color:#c9dae8; } .when.life-archived b { color:#e0b3b3; }
  /* per-card Website pill + grouped "Internal ▾" cluster (Admin / Line Viewer / Check Stock).
     Every target resolves to a real link/panel or the control dims — never a dead control. */
  .acts { display:flex; flex-wrap:wrap; gap:5px; padding:0 calc(9px*var(--fs)) calc(9px*var(--fs)); margin-top:auto; }
  .acts a.web, .acts .intbtn { flex:1; text-align:center; font-size:calc(10.5px*var(--fs)); font-weight:600; padding:4px 5px; border-radius:5px;
    text-decoration:none; border:1px solid var(--line); cursor:pointer; white-space:nowrap; letter-spacing:.02em; }
  .acts a.web { background:var(--accent); color:#1a1407; border-color:var(--accent); }
  .acts a.web:hover { filter:brightness(1.08); }
  .acts .intbtn { background:transparent; color:var(--accent); font-family:inherit; }
  .acts .intbtn:hover { border-color:var(--accent); }
  .acts a.web.disabled { opacity:.3; pointer-events:none; background:transparent; color:var(--mut); border-color:var(--line); }
  /* expanding internal panel — inline so nothing is clipped by the card's overflow */
  .intpanel { flex-basis:100%; width:100%; display:flex; flex-direction:column; gap:4px; margin-top:5px;
    padding:6px; border:1px solid var(--line); border-radius:6px; background:#0f0f13; }
  .intpanel[hidden] { display:none; }
  .im { display:block; text-align:left; font-size:calc(10.5px*var(--fs)); font-weight:600; padding:4px 7px; border-radius:5px;
    text-decoration:none; border:1px solid var(--line); background:var(--card); color:var(--accent); cursor:pointer; font-family:inherit; }
  .im:hover { border-color:var(--accent); }
  .im.disabled { opacity:.35; pointer-events:none; color:var(--mut); border-color:var(--line); }
  /* stored-stock popover ($0 local — never a --live portal scrape) */
  .stockpop { margin-top:2px; padding:6px 8px; border-radius:5px; background:#15170f; border:1px solid #34381f; font-size:calc(10.5px*var(--fs)); }
  .stockpop[hidden] { display:none; }
  .stockpop .sdot.in { color:#7fc98a; } .stockpop .sdot.out { color:#e08b8b; }
  .stockpop .sline { color:var(--txt); margin-top:2px; } .stockpop .sline.mut { color:var(--mut); }
  /* METERED live-check control + popover (amber = money, distinct from the free green stock) */
  .acts .im.livebtn, td.acts-td .im.livebtn { border-color:#4a341a; color:#e0b46a; }
  .acts .im.livebtn:hover, td.acts-td .im.livebtn:hover { border-color:#e0b46a; }
  .livepop { margin-top:2px; padding:6px 8px; border-radius:5px; background:#1c140c; border:1px solid #4a341a; color:var(--txt); font-size:calc(10.5px*var(--fs)); }
  .livepop[hidden] { display:none; }
  .livepop .sdot.in { color:#7fc98a; } .livepop .sdot.out { color:#e08b8b; }
  .livepop .sline { margin-top:2px; } .livepop .sline.mut { color:var(--mut); }
  .livepop .lcost { color:var(--accent); }
  .lspin { display:inline-block; animation:lspin 1s linear infinite; }
  @keyframes lspin { to { transform:rotate(360deg); } }
  /* Email Vendor — the stock & price inquiry EVERY vendor gets (blue = correspondence).
     Composes a Gmail DRAFT via George; never auto-sends. */
  .acts .im.mailbtn, td.acts-td .im.mailbtn { border-color:#1a2a3a; color:#8ab6e0; }
  .acts .im.mailbtn:hover, td.acts-td .im.mailbtn:hover { border-color:#8ab6e0; }
  .mailpop { margin-top:2px; padding:7px 8px; border-radius:5px; background:#0c1218; border:1px solid #1a2a3a;
    font-size:calc(10.5px*var(--fs)); display:flex; flex-direction:column; gap:4px; }
  .mailpop[hidden] { display:none; }
  .mailpop label { color:var(--mut); font-size:calc(9.5px*var(--fs)); letter-spacing:.04em; }
  .mailpop input, .mailpop textarea { width:100%; background:var(--card); color:var(--txt); border:1px solid var(--line);
    border-radius:5px; padding:5px 7px; font:inherit; font-size:calc(10.5px*var(--fs)); }
  .mailpop textarea { min-height:120px; resize:vertical; line-height:1.45; }
  .mailpop .mrow { display:flex; gap:5px; margin-top:2px; }
  .mailpop .mgo { flex:1; padding:5px; border-radius:5px; border:1px solid #1a2a3a; background:transparent; color:#8ab6e0;
    font-family:inherit; font-weight:600; cursor:pointer; font-size:calc(10.5px*var(--fs)); }
  .mailpop .mgo:hover { border-color:#8ab6e0; }
  .mailpop .mgo:disabled { opacity:.5; cursor:wait; }
  .mailpop a.mgo { text-align:center; text-decoration:none; }
  .mailpop .mstat { font-size:calc(10px*var(--fs)); line-height:1.4; }
  .mailpop .mstat.ok { color:#7fc98a; } .mailpop .mstat.err { color:#e08b8b; }
  /* smart FACE button — the ONE always-present per-card action, full-width row under Website.
     Inherits livebtn amber / mailbtn blue from the rules above; stock chip tints in/out. */
  .acts .im.face { flex-basis:100%; text-align:center; }
  .im.face.in { color:#7fd0a0; } .im.face.out { color:#e08a8a; }
  /* shared popovers (stock/live/mail) now sit at the END of .acts — one set per card,
     used by both the face button and the Internal-panel copies */
  .acts > .stockpop, .acts > .livepop, .acts > .mailpop { flex-basis:100%; width:100%; }
  /* list-view compact link cluster */
  td.acts-td { white-space:nowrap; }
  td.acts-td a, td.acts-td .intbtn { display:inline-block; font-size:11px; font-weight:600; padding:3px 8px; border-radius:5px; text-decoration:none; border:1px solid var(--line); margin:0 4px 3px 0; background:transparent; color:var(--accent); cursor:pointer; font-family:inherit; }
  td.acts-td a.web { background:var(--accent); color:#1a1407; border-color:var(--accent); }
  td.acts-td .im { display:inline-block; margin:0 4px 0 0; }
  td.acts-td a.disabled, td.acts-td .im.disabled { opacity:.35; pointer-events:none; color:var(--mut); }
  td.acts-td .intpanel { width:auto; margin-top:5px; }
  /* CARD FIELDS visibility toggles — all off = pure image-only texture wall */
  .grid.h-sku .sku, .grid.h-ttl .ttl, .grid.h-vt .vt, .grid.h-mfrn .mfrn, .grid.h-mfrname .mfrname, .grid.h-fac .fac, .grid.h-price .price, .grid.h-when .when, .grid.h-act .acts { display:none; }
  .grid.h-sku.h-ttl.h-vt.h-mfrn.h-mfrname.h-fac.h-price.h-when .meta { display:none; }

  /* ── list view ── */
  .listwrap { padding:0 16px 30px; overflow-x:auto; }
  table { width:100%; border-collapse:collapse; font-size:12.5px; }
  thead th { position:sticky; top:59px; background:#131317; z-index:5; text-align:left; padding:9px 10px; font-size:11px;
    letter-spacing:.05em; text-transform:uppercase; color:var(--mut); border-bottom:1px solid var(--line);
    cursor:pointer; user-select:none; white-space:nowrap; }
  thead th:hover { color:var(--accent); }
  thead th.sorted { color:var(--accent); }
  tbody td { padding:7px 10px; border-bottom:1px solid #1d1d22; vertical-align:middle; }
  tbody tr:hover { background:#141419; }
  tbody tr.linked { cursor:pointer; }
  td.timg { width:52px; } td.timg div { width:44px; height:44px; border-radius:6px; background:#000 center/cover no-repeat; }
  td .sku { font-size:12px; }
  td.num { text-align:right; font-variant-numeric:tabular-nums; }
  .tfac { color:var(--accent); font-size:11px; }

  #sentinel { height:1px; }
  #status { text-align:center; color:var(--mut); padding:16px; font-size:13px; }

  @media (max-width: 900px) {
    #railToggle { display:inline-block; }
    aside { position:fixed; left:0; top:59px; bottom:0; background:var(--bg); z-index:20; transform:translateX(-100%); transition:transform .2s; }
    aside.open { transform:none; box-shadow:6px 0 30px rgba(0,0,0,.5); }
  }
</style>
</head>
<body>
<header>
  <h1>All Products <span class="pill" id="pillN">…</span> <small id="sub">loading…</small><a href="/vendors.html">vendor directory →</a><a href="/microsites.html">microsites →</a></h1>
  <button class="dirbtn" id="railToggle">☰ Filters</button>
  <input type="search" id="q" placeholder="Search anything — red, damask, DWKR, Thibaut…">
  <span class="bar">Sort
    <select id="sort">
      <option value="newest">Newest</option>
      <option value="oldest">Oldest</option>
      <option value="updated">Recently updated</option>
      <option value="title">Title</option>
      <option value="vendor">Vendor</option>
      <option value="type">Type</option>
      <option value="series">Book / Series</option>
      <option value="sku">SKU</option>
      <option value="pattern">Pattern</option>
      <option value="color">Color (family)</option>
      <option value="style">Style</option>
      <option value="material">Material</option>
      <option value="price">Price</option>
      <option value="lifecycle">Lifecycle</option>
    </select>
    <button class="dirbtn" id="dir" title="Flip sort direction">▲</button>
  </span>
  <span class="viewseg"><button id="vGrid">Grid</button><button id="vList">List</button></span>
  <span class="bar" id="densWrap">Density <input type="range" id="dens" min="2" max="20" value="6"> <span id="densN"></span></span>
  <span class="bar" id="fmBar" title="FileMaker Pro source-of-truth">
    <span id="fmStat" class="fmstat">FM …</span>
    <button id="fmMismatch" class="fmtoggle" type="button" title="Show only rows where FileMaker and Shopify mfr numbers disagree">⚠ Mismatches</button>
    <button id="fmDisco" class="fmtoggle" type="button" title="Include rows FileMaker Pro marks discontinued (hidden by default)">Show FM-disco</button>
    <button id="fmBulk" class="fmtoggle" type="button" title="Dry-run the FileMaker→Shopify push for the loaded products (name / mfr / width / specs)">Preview sync ⇪</button>
  </span>
</header>

<div class="wrap">
  <aside id="rail">
    <div class="active" id="activeRow"></div>
    <details class="sec" data-k="fields"><summary>Card fields<span class="n" id="nFields"></span></summary><div class="fbody" id="fieldsRow"></div></details>
    <details class="sec" data-k="vendor"><summary>Vendor<span class="n" id="nVendor"></span></summary><div class="fbody" id="vendorRow"></div></details>
    <details class="sec" data-k="type"><summary>Type<span class="n" id="nType"></span></summary><div class="fbody" id="typeRow"></div></details>
    <details class="sec" data-k="series"><summary>Book / Series<span class="n" id="nSeries"></span></summary><div class="fbody" id="seriesRow"></div></details>
    <details class="sec" data-k="color"><summary>Color<span class="n" id="nColor"></span></summary><div class="fbody" id="colorRow"></div></details>
    <details class="sec" data-k="style"><summary>Style<span class="n" id="nStyle"></span></summary><div class="fbody" id="styleRow"></div></details>
    <details class="sec" data-k="material"><summary>Material<span class="n" id="nMaterial"></span></summary><div class="fbody" id="matRow"></div></details>
    <details class="sec" data-k="price"><summary>Price<span class="n" id="nPrice"></span></summary><div class="fbody" id="priceRow"></div></details>
    <details class="sec" data-k="lifecycle"><summary>Lifecycle<span class="n" id="nLifecycle"></span></summary><div class="fbody" id="lifecycleRow"></div></details>
    <details class="sec" data-k="image"><summary>Image<span class="n" id="nImage"></span></summary><div class="fbody" id="imgRow"></div></details>
  </aside>

  <main>
    <div id="discoBanner"></div>
    <div class="grid" id="grid"></div>
    <div class="listwrap" id="listwrap" style="display:none">
      <table>
        <thead><tr id="cols"></tr></thead>
        <tbody id="tbody"></tbody>
      </table>
    </div>
    <div id="sentinel"></div>
    <div id="status">loading…</div>
  </main>
</div>

<div id="fmModal"><div class="box"><span class="close" id="fmModalClose">✕ close</span><h3>FileMaker → Shopify — dry-run preview</h3><div id="fmModalBody">…</div></div></div>

<script>
const grid = document.getElementById('grid'), tbody = document.getElementById('tbody');
const listwrap = document.getElementById('listwrap');
const statusEl = document.getElementById('status'), subEl = document.getElementById('sub');
const LIMIT = 120;
const loadSet = (k) => { try { return new Set(JSON.parse(localStorage.getItem(k) || '[]')); } catch { return new Set(); } };

const urlq = new URLSearchParams(location.search);
let state = {
  sort: urlq.get('sort') || localStorage.getItem('all_sort') || 'newest',
  dir: urlq.get('dir') || localStorage.getItem('all_dir') || 'asc',
  view: urlq.get('view') || localStorage.getItem('all_view') || 'grid',
  q: urlq.get('q') || '',
  vendors: loadSet('all_vendors'), types: loadSet('all_types'), series: loadSet('all_series'), lifecycles: loadSet('all_lifecycles'),
  colors: loadSet('all_colors'), styles: loadSet('all_styles'), materials: loadSet('all_materials'),
  prices: loadSet('all_prices'), images: loadSet('all_images'),
  // FileMaker verification views: mismatch = only rows whose FM mfr# ≠ Shopify's; fmDisco =
  // include the rows FileMaker Pro marks discontinued (hidden by default).
  mismatch: urlq.get('mismatch') === '1', fmDisco: urlq.get('fm_disco') === '1',
  offset: 0, total: 0, loading: false, done: false,
};
// per-row UI state: expanded? + type-ahead needle (vendor/type rows have hundreds of values)
const rowUI = {
  vendorRow: { expand: false, find: '', typeahead: true }, typeRow: { expand: false, find: '', typeahead: true },
  seriesRow: { expand: false, find: '', typeahead: true },
  colorRow: { expand: false, find: '' }, styleRow: { expand: false, find: '' }, matRow: { expand: false, find: '' },
  priceRow: { expand: false, find: '' }, lifecycleRow: { expand: false, find: '' }, imgRow: { expand: false, find: '' },
};

/* ── top-bar wiring ── */
const dens = document.getElementById('dens'), densN = document.getElementById('densN');
dens.value = localStorage.getItem('all_cols') || 6;
const setCols = () => {
  document.documentElement.style.setProperty('--cols', dens.value); densN.textContent = dens.value;
  // type scale rides the density slider (Astek --fs pattern): 6-across = 1.0, shrinking toward .62 at 20
  document.documentElement.style.setProperty('--fs', Math.max(.62, Math.min(1.15, 6 / dens.value)).toFixed(2));
};
setCols();
dens.oninput = () => { setCols(); localStorage.setItem('all_cols', dens.value); };

/* ── FileMaker verification controls ── */
const fmMismatchBtn = document.getElementById('fmMismatch');
const fmDiscoBtn = document.getElementById('fmDisco');
const fmBulkBtn = document.getElementById('fmBulk');
const syncToggles = () => { fmMismatchBtn.classList.toggle('on', state.mismatch); fmDiscoBtn.classList.toggle('on', state.fmDisco); };
syncToggles();
fmMismatchBtn.onclick = () => { state.mismatch = !state.mismatch; syncToggles(); refreshFacets(); reset(); };
fmDiscoBtn.onclick = () => { state.fmDisco = !state.fmDisco; syncToggles(); refreshFacets(); reset(); };
fmBulkBtn.onclick = () => fmBulkPreview();

// Bulk dry-run: send the loaded FileMaker-joined SKUs (≤100 per call) to the push worker and
// summarize how many products have real changes. Review-only — no live write from here.
async function fmBulkPreview() {
  const modal = document.getElementById('fmModal'), body = document.getElementById('fmModalBody');
  const skus = [...new Set(FM_LOADED)].slice(0, 100);
  modal.classList.add('on');
  if (!skus.length) { body.innerHTML = '<span class="k">No FileMaker-joined products loaded. Scroll or filter to load some, or use ⚠ Mismatches.</span>'; return; }
  body.innerHTML = `<span class="k">◌ Comparing ${skus.length} product${skus.length > 1 ? 's' : ''} (of ${FM_LOADED.length} loaded) FileMaker → Shopify…</span>`;
  try {
    const res = await fetch('/api/shopify-push-bulk', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ skus }) });
    const d = await res.json();
    const changed = (d.results || []).filter((r) => r.ok && r.change_count > 0);
    let html = `<div class="prow"><b>${d.products_with_changes}</b> of ${d.evaluated} products have proposed changes · <b>${d.total_field_changes}</b> field updates total.${d.push_enabled ? '' : ' <span class="k">(live writes gated — dry-run only)</span>'}</div>`;
    if (!changed.length) html += `<div class="prow k">Everything loaded already matches FileMaker. ✓</div>`;
    for (const r of changed.slice(0, 40)) {
      const parts = [];
      if (r.title_change) parts.push('title');
      const mfs = (r.metafield_changes || []).map((c) => c.field);
      parts.push(...[...new Set(mfs)]);
      html += `<div class="prow"><span class="sk">${r.dw_sku}</span> — <span class="c">${r.change_count} change${r.change_count > 1 ? 's' : ''}</span> <span class="k">(${parts.join(', ')})</span></div>`;
    }
    if (changed.length > 40) html += `<div class="prow k">…and ${changed.length - 40} more.</div>`;
    body.innerHTML = html;
  } catch (e) { body.innerHTML = '<span class="k">Bulk preview failed — try again.</span>'; }
}
document.getElementById('fmModalClose').onclick = () => document.getElementById('fmModal').classList.remove('on');
document.getElementById('fmModal').onclick = (e) => { if (e.target.id === 'fmModal') e.currentTarget.classList.remove('on'); };

/* accordion sections: persist open/closed per section (collapsed by default, Astek-style) */
document.querySelectorAll('details.sec').forEach((d) => {
  d.open = localStorage.getItem('all_sec_' + d.dataset.k) === '1';
  d.ontoggle = () => localStorage.setItem('all_sec_' + d.dataset.k, d.open ? '1' : '0');
});

/* CARD FIELDS: toggle which card elements render — all off = image-only texture wall */
const FIELD_DEFS = [['sku', 'SKU'], ['ttl', 'Title'], ['vt', 'Vendor · Type'], ['mfrn', 'Mfr #'], ['mfrname', 'Mfr Name'], ['fac', 'Color / Style / Material'], ['price', 'Price'], ['when', 'Active / Staged date'], ['act', 'Website / Internal buttons']];
let hiddenFields = loadSet('all_hidden_fields');
function drawFieldToggles() {
  const row = document.getElementById('fieldsRow'); row.innerHTML = '';
  FIELD_DEFS.forEach(([k, label]) => {
    const on = !hiddenFields.has(k);
    const el = document.createElement('div');
    el.className = 'fitem' + (on ? ' on' : '');
    el.innerHTML = `<span class="box">${on ? '✓' : ''}</span><span class="lbl">${label}</span>`;
    el.onclick = () => {
      hiddenFields.has(k) ? hiddenFields.delete(k) : hiddenFields.add(k);
      localStorage.setItem('all_hidden_fields', JSON.stringify([...hiddenFields]));
      applyFieldClasses(); drawFieldToggles();
    };
    row.appendChild(el);
  });
  document.getElementById('nFields').textContent = hiddenFields.size ? `${FIELD_DEFS.length - hiddenFields.size}/${FIELD_DEFS.length}` : '';
  document.getElementById('nFields').style.display = hiddenFields.size ? '' : 'none';
}
function applyFieldClasses() {
  FIELD_DEFS.forEach(([k]) => grid.classList.toggle('h-' + k, hiddenFields.has(k)));
}
applyFieldClasses(); drawFieldToggles();

const sortSel = document.getElementById('sort'), dirBtn = document.getElementById('dir');
sortSel.value = ['price_asc','price_desc'].includes(state.sort) ? 'price' : state.sort;
state.sort = sortSel.value;
const drawDir = () => { dirBtn.textContent = state.dir === 'desc' ? '▼' : '▲'; };
drawDir();
sortSel.onchange = () => { state.sort = sortSel.value; persistSort(); reset(); drawCols(); };
dirBtn.onclick = () => { state.dir = state.dir === 'desc' ? 'asc' : 'desc'; persistSort(); drawDir(); reset(); drawCols(); };
function persistSort() { localStorage.setItem('all_sort', state.sort); localStorage.setItem('all_dir', state.dir); }

const vG = document.getElementById('vGrid'), vL = document.getElementById('vList');
function drawView() {
  vG.classList.toggle('on', state.view === 'grid'); vL.classList.toggle('on', state.view === 'list');
  grid.style.display = state.view === 'grid' ? '' : 'none';
  listwrap.style.display = state.view === 'list' ? '' : 'none';
  document.getElementById('densWrap').style.opacity = state.view === 'grid' ? 1 : .35;
}
vG.onclick = () => { state.view = 'grid'; localStorage.setItem('all_view', 'grid'); drawView(); reset(); };
vL.onclick = () => { state.view = 'list'; localStorage.setItem('all_view', 'list'); drawView(); reset(); };
drawView();

const rail = document.getElementById('rail');
document.getElementById('railToggle').onclick = () => rail.classList.toggle('open');

const qEl = document.getElementById('q'); qEl.value = state.q;
let qt; qEl.oninput = (e) => { clearTimeout(qt); qt = setTimeout(() => { state.q = e.target.value.trim(); applyFilterChange(); }, 250); };

/* ── list-view sortable columns: every column sorts, click toggles asc/desc ── */
const COLS = [
  { label: '', key: null }, // thumb
  { label: 'SKU', key: 'sku' }, { label: 'Mfr # (DW·Shop·FM)', key: null }, { label: 'Title', key: 'title' }, { label: 'Vendor', key: 'vendor' },
  { label: 'Type', key: 'type' }, { label: 'Pattern', key: 'pattern' }, { label: 'Color', key: 'color' },
  { label: 'Style', key: 'style' }, { label: 'Material', key: 'material' },
  { label: 'Price', key: 'price', num: true }, { label: 'Lifecycle', key: 'lifecycle' },
  { label: 'Shopify status', key: 'status' }, { label: 'Date', key: 'created' }, { label: 'Links', key: null },
];
function drawCols() {
  const tr = document.getElementById('cols'); tr.innerHTML = '';
  COLS.forEach((c) => {
    const th = document.createElement('th');
    if (c.key) {
      const on = state.sort === c.key;
      th.className = on ? 'sorted' : '';
      th.textContent = c.label + (on ? (state.dir === 'desc' ? ' ▼' : ' ▲') : '');
      th.onclick = () => {
        if (state.sort === c.key) state.dir = state.dir === 'desc' ? 'asc' : 'desc';
        else { state.sort = c.key; state.dir = 'asc'; }
        sortSel.value = c.key; persistSort(); drawDir(); drawCols(); reset();
      };
    } else th.textContent = c.label;
    tr.appendChild(th);
  });
}
drawCols();

/* ── filters (shared with facets API) ── */
const SETS = {
  vendors: 'all_vendors', types: 'all_types', series: 'all_series', lifecycles: 'all_lifecycles', colors: 'all_colors',
  styles: 'all_styles', materials: 'all_materials', prices: 'all_prices', images: 'all_images',
};
function persistFacets() { for (const [k, key] of Object.entries(SETS)) localStorage.setItem(key, JSON.stringify([...state[k]])); }
function facetParams(extra = {}) {
  return new URLSearchParams({
    sort: state.sort, dir: state.dir, q: state.q,
    vendors: [...state.vendors].join(','), types: [...state.types].join(','), series: [...state.series].join(','),
    lifecycles: [...state.lifecycles].join(','), colors: [...state.colors].join(','),
    styles: [...state.styles].join(','), materials: [...state.materials].join(','),
    prices: [...state.prices].join(','), images: [...state.images].join(','),
    ...(state.mismatch ? { mismatch: '1' } : {}), ...(state.fmDisco ? { fm_disco: '1' } : {}), ...extra,
  });
}

const FM_LOADED = [];   // SKUs (with FileMaker data) currently loaded — the bulk-preview scope
function reset() { window.scrollTo(0, 0); grid.innerHTML = ''; tbody.innerHTML = ''; FM_LOADED.length = 0; state.offset = 0; state.done = false; hideDiscoBanner(); statusEl.textContent = 'loading…'; load().then(() => { pump(); checkDiscontinued(); }); }

// ── discontinued-SKU resolver → red banner + red-card successor line ──────────────────
// When the current search resolves to a discontinued/deleted/archived SKU, /api/discontinued
// links its live successor. Two cases: (a) the dead SKU is NOT in the live grid → show a red
// banner above the grid; (b) it IS in the grid (archived lifecycle) → badge that card red + add
// the successor line. successor.url points at the live product page.
const discoBanner = document.getElementById('discoBanner');
let discoToken = 0;
function hideDiscoBanner() {
  discoBanner.className = ''; discoBanner.innerHTML = '';
  document.querySelectorAll('.card.disco-red').forEach((c) => { c.classList.remove('disco-red'); const s = c.querySelector('.disco-succ'); if (s) s.remove(); });
}
function succLineHTML(d) {
  const s = d.successor;
  if (!s || !s.sku) return `<div class="dbnone">No live successor identified —<button type="button" class="dbfind" id="dbFindBtn">Search the catalog for a similar pattern</button></div><div id="discoSim"></div>`;
  const label = esc(s.sku + (s.title ? ' · ' + s.title : ''));
  return s.url
    ? `<div class="dbsucc">Now available as <span class="arr">→</span> <a href="${esc(s.url)}" target="_blank" rel="noopener noreferrer">${label}</a></div>`
    : `<div class="dbsucc">Now available as <span class="arr">→</span> ${label}</div>`;
}
async function checkDiscontinued() {
  const q = (state.q || '').trim();
  const tok = ++discoToken;
  // only worth a lookup for a SKU-ish query (letters+digits, no spaces) — avoids firing on
  // "red"/"damask" free-text searches. Dash/-SAMPLE handled server-side.
  if (!q || /\s/.test(q) || !/[a-z]/i.test(q) || !/\d/.test(q)) return;
  try {
    const res = await fetch('/api/discontinued?sku=' + encodeURIComponent(q));
    const d = await res.json();
    if (tok !== discoToken) return;            // a newer search superseded this one
    if (!d.found || !d.discontinued) return;
    // is the dead SKU already rendered as a card? (archived lifecycle appears in the grid)
    const card = document.querySelector(`.card[data-sku="${(window.CSS && CSS.escape) ? CSS.escape(d.sku) : d.sku}"]`);
    if (card) {
      card.classList.add('disco-red');
      if (!card.querySelector('.disco-succ')) {
        const meta = card.querySelector('.meta') || card;
        const div = document.createElement('div'); div.className = 'disco-succ';
        const s = d.successor;
        div.innerHTML = s && s.sku
          ? (s.url ? `⛔ Discontinued · now → <a href="${esc(s.url)}" target="_blank" rel="noopener noreferrer">${esc(s.sku)}</a>` : `⛔ Discontinued · now → ${esc(s.sku)}`)
          : `⛔ Discontinued`;
        div.addEventListener('click', (e) => e.stopPropagation());
        meta.appendChild(div);
      }
    }
    // always show the banner when the query has no live grid results; also show it alongside a
    // badged archived card so the successor call-to-action is unmissable.
    if (state.total === 0 || card) {
      discoBanner.innerHTML = `<div class="dbhead">⛔ Discontinued — ${esc(d.title || '')} (${esc(d.sku)})</div>${succLineHTML(d)}`;
      discoBanner.className = 'show';
      const fb = discoBanner.querySelector('#dbFindBtn');
      if (fb) fb.onclick = () => loadSimilar(d.sku);
    }
  } catch (e) { /* endpoint degrades silently — grid still works */ }
}

// "Search the catalog" button → /api/similar scores live products against the dead SKU's
// attributes (material/style/color/type/vendor/title tokens) and the matches render below
// the banner with the SAME card renderer as the main grid (hover-gallery, actions, links).
async function loadSimilar(sku) {
  const box = discoBanner.querySelector('#discoSim');
  const btn = discoBanner.querySelector('#dbFindBtn');
  if (!box) return;
  if (btn) { btn.disabled = true; btn.textContent = 'searching…'; }
  try {
    const res = await fetch('/api/similar?sku=' + encodeURIComponent(sku) + '&limit=12');
    const d = await res.json();
    box.innerHTML = '';
    if (!d.rows || !d.rows.length) {
      box.innerHTML = `<div class="dbnone">No similar live patterns found.</div>`;
    } else {
      const h = document.createElement('div'); h.className = 'dbsimhead';
      h.textContent = `Similar live patterns (${d.rows.length})`; box.appendChild(h);
      const g = document.createElement('div'); g.className = 'dbsimgrid'; box.appendChild(g);
      for (const r of d.rows) g.appendChild(card(r));
    }
    if (btn) btn.remove();   // one-shot — results replace the call-to-action
  } catch (e) {
    if (btn) { btn.disabled = false; btn.textContent = 'Search the catalog for a similar pattern'; }
    box.innerHTML = `<div class="dbnone">Similarity search unavailable — try again.</div>`;
  }
}
function applyFilterChange() { persistFacets(); refreshFacets(); reset(); }
function toggleSet(set, val) { set.has(val) ? set.delete(val) : set.add(val); applyFilterChange(); }

/* Amazon-style vertical facet list: checkbox rows + counts, type-ahead on big rows,
   See-more expander, selected values always visible. */
function facetList(rowId, counts, set, opts = {}) {
  const row = document.getElementById(rowId); row.innerHTML = '';
  const ui = rowUI[rowId];
  if (ui.typeahead) {
    const find = document.createElement('input');
    find.type = 'text'; find.className = 'ffind'; find.placeholder = 'find…'; find.value = ui.find;
    find.oninput = () => { ui.find = find.value; renderFacets(LAST_FACETS); const f2 = document.querySelector(`#${rowId} .ffind`); if (f2) { f2.focus(); f2.setSelectionRange(f2.value.length, f2.value.length); } };
    row.appendChild(find);
  }
  let entries = Object.entries(counts || {});
  entries = opts.order
    ? entries.sort((a, b) => opts.order.indexOf(a[0]) - opts.order.indexOf(b[0]))
    : opts.byName
      ? entries.sort((a, b) => a[0].localeCompare(b[0], undefined, { sensitivity: 'base' }))
      : entries.sort((a, b) => b[1] - a[1]);
  if (ui.find) entries = entries.filter(([v]) => v.toLowerCase().includes(ui.find.toLowerCase()));
  const CAP = opts.cap || 8;
  let shown = entries, hidden = 0;
  if (!ui.expand && entries.length > CAP) { shown = entries.slice(0, CAP); hidden = entries.length - CAP; }
  const shownVals = new Set(shown.map(([v]) => v));
  for (const v of set) if (!shownVals.has(v)) { const e = entries.find(([x]) => x === v); shown.push(e || [v, 0]); }
  if (!shown.length) { const s = document.createElement('div'); s.style.cssText = 'font-size:10px;color:#6a6a72;padding:2px'; s.textContent = 'none'; row.appendChild(s); return; }
  shown.forEach(([val, ct]) => {
    const el = document.createElement('div');
    el.className = 'fitem' + (set.has(val) ? ' on' : '') + (ct ? '' : ' zero');
    el.innerHTML = `<span class="box">${set.has(val) ? '✓' : ''}</span><span class="lbl" title="${val.replace(/"/g, '&quot;')}">${val}</span><span class="ct">${ct.toLocaleString()}</span>`;
    el.onclick = () => toggleSet(set, val);
    row.appendChild(el);
  });
  if (hidden > 0) {
    const el = document.createElement('span'); el.className = 'more'; el.textContent = `See ${hidden.toLocaleString()} more ▾`;
    el.onclick = () => { ui.expand = true; renderFacets(LAST_FACETS); };
    row.appendChild(el);
  } else if (ui.expand && entries.length > CAP) {
    const el = document.createElement('span'); el.className = 'more'; el.textContent = 'See less ▴';
    el.onclick = () => { ui.expand = false; renderFacets(LAST_FACETS); };
    row.appendChild(el);
  }
}

let LAST_FACETS = {};
function renderFacets(f) {
  LAST_FACETS = f;
  facetList('vendorRow', f.vendor, state.vendors, { cap: 10, byName: true });
  facetList('typeRow', f.type, state.types, { cap: 10 });
  facetList('seriesRow', f.series, state.series, { cap: 12 });
  facetList('colorRow', f.color, state.colors, { order: f.family_order, cap: 12 });
  facetList('styleRow', f.style, state.styles, { cap: 10 });
  facetList('matRow', f.material, state.materials, { cap: 10 });
  facetList('priceRow', f.price_band, state.prices, { order: f.price_order, cap: 8 });
  facetList('lifecycleRow', f.lifecycle, state.lifecycles, { order: f.lifecycle_order, cap: 6 });
  facetList('imgRow', f.image_state, state.images, { cap: 4 });
  renderActive();
}

const SECN = { Vendor: 'nVendor', Type: 'nType', Book: 'nSeries', Color: 'nColor', Style: 'nStyle', Material: 'nMaterial', Price: 'nPrice', Lifecycle: 'nLifecycle', Image: 'nImage' };
function renderActive() {
  const row = document.getElementById('activeRow'); row.innerHTML = '';
  const dims = [['Vendor', state.vendors], ['Type', state.types], ['Book', state.series], ['Color', state.colors], ['Style', state.styles],
    ['Material', state.materials], ['Price', state.prices], ['Lifecycle', state.lifecycles], ['Image', state.images]];
  for (const [label, set] of dims) { // selection-count badge on each collapsed section header
    const b = document.getElementById(SECN[label]);
    if (b) { b.textContent = set.size || ''; b.style.display = set.size ? '' : 'none'; }
  }
  let n = 0;
  for (const [label, set] of dims) {
    set.forEach((v) => {
      n++;
      const el = document.createElement('span'); el.className = 'afilter';
      el.innerHTML = `<b>${label}:</b> ${v} ✕`;
      el.onclick = () => { set.delete(v); applyFilterChange(); };
      row.appendChild(el);
    });
  }
  if (n) {
    const c = document.createElement('button'); c.className = 'clearall'; c.textContent = 'Clear all';
    c.onclick = () => { dims.forEach(([, s]) => s.clear()); applyFilterChange(); };
    row.appendChild(c);
  }
}

async function refreshFacets() {
  try {
    const res = await fetch('/api/facets?' + facetParams());
    const fj = await res.json();
    renderFacets(fj);
    if (fj.filemaker) updateFmStat(fj.filemaker);
  } catch (e) { /* facets degrade silently — grid still works */ }
}
// Header FileMaker chip: how many loaded rows are joined to the WALLPAPER file + mfr# mismatches.
function updateFmStat(fm) {
  const el = document.getElementById('fmStat'); if (!el || !fm) return;
  el.innerHTML = `FM <b>${(fm.matched || 0).toLocaleString()}</b> joined · <b>${(fm.mismatch || 0).toLocaleString()}</b> mfr⚠`;
  el.title = `${(fm.rows || 0).toLocaleString()} FileMaker mirror rows · ${(fm.discontinued_hidden || 0).toLocaleString()} discontinued hidden · ${(fm.fm_only_live || 0).toLocaleString()} FileMaker-only live (not in Shopify)`;
}

/* ── renderers ── */
// Lifecycle label the surface lists: "Active on site" / "Staged" / "Archived".
// CSS-safe class from the first word → life-active / life-staged / life-archived.
const lifeClass = (r) => 'life-' + (r.lifecycle || '').split(' ')[0].toLowerCase();
function fmtDate(ms) {
  if (!ms) return '';
  return new Date(ms).toLocaleString(undefined, { year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
}
function whenChip(r) { // returns { html, textOnly } for grid card / list cell
  const label = r.lifecycle || '';
  const d = fmtDate(r.created);
  if (!d && !label) return { html: '', text: '' };
  const iso = r.created ? new Date(r.created).toISOString() : '';
  const upd = r.updated && r.updated !== r.created ? ` · updated ${fmtDate(r.updated)}` : '';
  return {
    html: `<div class="when ${lifeClass(r)}" title="${iso}${upd ? upd.replace(/"/g, '&quot;') : ''}">🕓 <b>${label}</b> ${d}</div>`,
    text: `${label} ${d}`.trim(),
  };
}

const esc = (s) => String(s == null ? '' : s).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));

// Stored-stock popover — the FREE /livestock snapshot ($0 local). Public-safe availability
// only (in stock / qty / lead / as-of); NEVER a metered --live portal scrape on render.
function stockPopHTML(st) {
  if (!st) return '';
  const dot = st.in_stock
    ? '<span class="sdot in">●</span> In stock'
    : '<span class="sdot out">●</span> Out of stock';
  const bits = [];
  if (st.quantity != null && st.quantity !== '') bits.push('Qty ' + esc(st.quantity));
  if (st.lead_time) bits.push('Lead ' + esc(st.lead_time));
  const when = st.as_of ? 'as of ' + esc(st.as_of) : 'stored snapshot';
  const src = st.source ? ' · ' + esc(st.source) : '';
  return `<div class="stockpop" hidden>${dot}${bits.length ? '<div class="sline">' + bits.join(' · ') + '</div>' : ''}<div class="sline mut">${when}${src}</div></div>`;
}

// Website pill (unchanged) + a compact grouped "Internal ▾" cluster: Admin, Line Viewer,
// Check Stock. Every target resolves to a real link/panel or dims — never a dead control.
function actButtonsHTML(r) {
  const web = r.url
    ? `<a class="web" href="${r.url}" target="_blank" rel="noopener noreferrer">Website</a>`
    : `<a class="web disabled" title="Not live on the storefront yet">Website</a>`;
  const admin = r.internal_url
    ? `<a class="im" href="${r.internal_url}" target="_blank" rel="noopener noreferrer">Shopify Admin ↗</a>`
    : `<a class="im disabled" title="No Shopify admin id for this SKU">Shopify Admin</a>`;
  const viewer = r.line_viewer_url
    ? `<a class="im" href="${r.line_viewer_url}" target="_blank" rel="noopener noreferrer">Line Viewer ↗</a>`
    : `<a class="im disabled" title="No internal line viewer for this line yet">Line Viewer</a>`;
  // This Item = the internal microsite deep-linked to THIS product's page (one item, not the whole line).
  const item = r.internal_product_url
    ? `<a class="im" href="${r.internal_product_url}" target="_blank" rel="noopener noreferrer">This Item ↗</a>`
    : `<a class="im disabled" title="No internal product page for this SKU yet">This Item</a>`;
  // Check Stock = FREE stored snapshot ($0, local). Check Live = METERED real-time portal
  // login+scrape (only enabled for vendors with a wired live adapter; dims otherwise).
  const stock = r.stock
    ? `<button class="im stockbtn" type="button">Check Stock</button>`
    : `<button class="im disabled" type="button" title="No stored stock snapshot — run /livestock ${esc(r.sku || '')}">Check Stock</button>`;
  const live = r.live_capable
    ? `<button class="im livebtn" type="button" data-sku="${esc(r.sku || '')}" title="Metered vendor-portal scrape (~$0.15–0.30 · cached 10 min)">Check Live 🔄</button>`
    : `<button class="im disabled" type="button" title="${esc(r.live_reason || 'live check unavailable for this vendor')}">Check Live 🔄</button>`;
  // Update Shopify = preview (dry-run) the FileMaker→Shopify push of name/mfr/width/all specs
  // for THIS product. Only offered when the SKU is in the WALLPAPER file (there's FM truth to push).
  const push = r.fm_present
    ? `<button class="im pushbtn" type="button" data-sku="${esc(r.sku || '')}" title="Preview the FileMaker→Shopify update (name / mfr / width / specs) — dry-run">Update Shopify ⇪</button><div class="pushpop" hidden></div>`
    : '';
  // Email Vendor = the stock & price path EVERY vendor gets (Steve 2026-07-15: no dead-end
  // buttons — live_capable vendors scrape, everyone can be emailed; also a confirmation path
  // for the live ones). Opens a compose panel; "Create Gmail draft" NEVER auto-sends.
  const mail = `<button class="im mailbtn" type="button" data-sku="${esc(r.sku || '')}" data-vendor="${esc(r.vendor || '')}" data-mfr="${esc(r.mfr_number || '')}" data-pat="${esc(r.title || r.pattern || '')}" title="Compose a stock & price inquiry to the vendor rep — saves a Gmail DRAFT, never auto-sends">Email Vendor ✉</button>`;
  // Smart FACE button — the ONE always-present per-card action (DTD verdict A, 2026-07-15).
  // Priority: live check (tier-labeled with its real cost) → stored-stock chip → Email Vendor.
  // Reuses livebtn / stockbtn / mailbtn classes so the delegated handleActsClick routes it
  // with NO new event wiring; the popovers below are SHARED with the Internal-panel copies.
  let face;
  if (r.live_capable) {
    const label = r.live_tier === 'DB_AUTHORITATIVE' ? 'Price ⚡ $0 · instant'
      : r.live_tier === 'PUBLIC_STOCK' ? 'Check Live · ~$0.03'
      : 'Check Live · ~$0.15–0.30';
    const hint = r.live_tier === 'DB_AUTHORITATIVE'
      ? 'Instant authoritative price from the vendor price file — price only, availability not checked'
      : (r.live_reason || 'Live vendor check');
    face = `<button class="im livebtn face" type="button" data-sku="${esc(r.sku || '')}" title="${esc(hint)} · result cached 10 min">${label}</button>`;
  } else if (r.stock) {
    const inS = !!r.stock.in_stock;
    face = `<button class="im stockbtn face ${inS ? 'in' : 'out'}" type="button" title="stored snapshot${r.stock.as_of ? ' · as of ' + esc(r.stock.as_of) : ''}">Stock ${inS ? '✓' : '✗'} (stored)</button>`;
  } else {
    face = `<button class="im mailbtn face" type="button" data-sku="${esc(r.sku || '')}" data-vendor="${esc(r.vendor || '')}" data-mfr="${esc(r.mfr_number || '')}" data-pat="${esc(r.title || r.pattern || '')}" title="Compose a stock & price inquiry to the vendor rep — saves a Gmail DRAFT, never auto-sends">Email Vendor ✉</button>`;
  }
  // Shared popovers (stock / live / mail) sit at the END of the .acts container — ONE set per
  // card, resolved by closest('.acts, td.acts-td') from face AND panel buttons alike.
  return `${web}${face}<button class="intbtn" type="button">Internal ▾</button><div class="intpanel" hidden>${admin}${viewer}${item}${stock}${live}${mail}${push}</div>${stockPopHTML(r.stock)}<div class="livepop" hidden></div><div class="mailpop" hidden></div>`;
}

// Delegated within an .acts container (which stops propagation so the card's own
// open-website click never fires): toggle the Internal panel + the stock popover.
function handleActsClick(e) {
  const intbtn = e.target.closest('.intbtn');
  if (intbtn) { const p = intbtn.parentElement.querySelector('.intpanel'); if (p) { p.hidden = !p.hidden; intbtn.textContent = p.hidden ? 'Internal ▾' : 'Internal ▴'; } return; }
  const sb = e.target.closest('.stockbtn');
  if (sb) { const pop = sb.closest('.acts, td.acts-td').querySelector('.stockpop'); if (pop) pop.hidden = !pop.hidden; return; }
  const lb = e.target.closest('.livebtn');
  if (lb) { fireLiveCheck(lb); return; }
  const mb = e.target.closest('.mailbtn');
  if (mb) { toggleMailPop(mb); return; }
  const cb = e.target.closest('.commitbtn');
  if (cb) { fireCommit(cb); return; }
  const pb = e.target.closest('.pushbtn');
  if (pb) { firePush(pb); return; }
}

// Preview (dry-run) the FileMaker→Shopify push for one SKU and render the diff. The Commit
// button appears only when the diff is non-empty AND the server reports push_enabled (the
// Steve-gated SHOPIFY_PUSH_ENABLED switch) — otherwise it renders gated + explains why.
function renderPush(pop, d) {
  if (!d || d.ok === false) { pop.innerHTML = `<span class="none">${esc((d && d.reason) || 'push preview failed')}</span>`; return; }
  const rows = [];
  if (d.title_change) rows.push(`<span class="chg"><span class="k">Title</span> <span class="from">${esc(d.title_change.from || '—')}</span> → <span class="to">${esc(d.title_change.to)}</span></span>`);
  for (const c of (d.metafield_changes || [])) rows.push(`<span class="chg"><span class="k">${esc(c.metafield)}</span> <span class="from">${esc(c.from || '—')}</span> → <span class="to">${esc(c.to)}</span></span>`);
  const skips = (d.skipped_richer || []).length;
  let html;
  if (!rows.length) {
    html = `<span class="none">✓ Shopify already matches FileMaker — nothing to push.</span>`;
  } else {
    const commit = d.push_enabled
      ? `<button class="commitbtn" type="button" data-sku="${esc(d.dw_sku)}">Commit ${rows.length} change${rows.length > 1 ? 's' : ''} to LIVE Shopify</button>`
      : `<button class="commitbtn gated" type="button" title="Live writes are gated. Steve enables with SHOPIFY_PUSH_ENABLED=1 on the server." disabled>Commit gated — dry-run only</button>`;
    html = rows.join('') + commit;
  }
  if (skips) html += `<span class="skip">+ ${skips} field${skips > 1 ? 's' : ''} left as-is (Shopify value already richer)</span>`;
  pop.innerHTML = html;
}
function firePush(pb) {
  if (pb.dataset.busy === '1') return;
  const pop = pb.parentElement.querySelector('.pushpop');
  const sku = pb.dataset.sku;
  pop.hidden = false; pop.innerHTML = '<span class="none">◌ Comparing FileMaker → Shopify…</span>';
  pb.dataset.busy = '1'; const orig = pb.textContent; pb.textContent = 'Comparing…';
  fetch('/api/shopify-push?sku=' + encodeURIComponent(sku), { method: 'POST' })
    .then((r) => r.json()).then((d) => renderPush(pop, d))
    .catch(() => { pop.innerHTML = '<span class="none">preview failed — try again</span>'; })
    .finally(() => { pb.dataset.busy = ''; pb.textContent = orig; });
}
// Commit is the GATED live write. Requires a confirm; server refuses unless SHOPIFY_PUSH_ENABLED=1.
function fireCommit(cb) {
  if (cb.disabled || cb.dataset.busy === '1') return;
  const sku = cb.dataset.sku;
  if (!confirm(`Write FileMaker's values to the LIVE Shopify product for ${sku}?\n\nThis updates the customer-facing store.`)) return;
  cb.dataset.busy = '1'; cb.textContent = 'Committing…';
  fetch('/api/shopify-push?sku=' + encodeURIComponent(sku) + '&commit=1', { method: 'POST' })
    .then((r) => r.json()).then((d) => {
      cb.textContent = d.committed ? '✓ Committed to live Shopify' : (d.blocked ? 'Blocked — writes gated' : 'Commit failed');
      cb.classList.add('gated');
    })
    .catch(() => { cb.textContent = 'Commit failed — try again'; })
    .finally(() => { cb.dataset.busy = ''; });
}

// One-click METERED live check. Fires immediately (no confirm — server rate-limits + caches).
// Shows a spinner, then the live result + the ACTUAL $ cost (or "cached · $0") inline.
const fmtWhen = (s) => { const t = Date.parse(s); return isNaN(t) ? String(s || '') : fmtDate(t); };
function renderLive(pop, d) {
  if (d.rate_limited) { pop.innerHTML = `<span class="sdot out">●</span> ${esc(d.reason || 'rate limited')}${d.retry_after_s ? ` · retry in ${d.retry_after_s}s` : ''}`; return; }
  if (d.live_available === false) { pop.innerHTML = `<span class="sline mut">🔒 ${esc(d.reason || 'live check unavailable for this vendor')}</span>`; return; }
  if (!d.ok) { pop.innerHTML = `<span class="sdot out">●</span> ${esc(d.reason || 'live scrape failed')}${d.cost_usd ? ` · <b class="lcost">$${Number(d.cost_usd).toFixed(2)} live</b>` : ''}`; return; }
  // DB_AUTHORITATIVE returns a price with no availability read — lead with the price,
  // never "Availability: unknown" (frontend iteration 2 of the Phase-1 plan).
  if (d.in_stock == null && d.price != null) {
    const pcost = d.cached ? 'cached · $0' : '$' + Number(d.cost_usd || 0).toFixed(2);
    // Price-file result, NOT an inventory read — neutral dot + explicit disclosure so the
    // user never mistakes "we have an authoritative price" for "it's in stock".
    pop.innerHTML = `<span class="sdot">●</span> Authoritative price <b>$${Number(d.price).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</b><div class="sline mut">availability unknown — price file only · use Email Vendor to confirm stock</div><div class="sline mut">as of ${esc(fmtWhen(d.as_of))} · <b class="lcost">${pcost}</b></div>`;
    return;
  }
  const dot = d.in_stock === true ? '<span class="sdot in">●</span> In stock (live)'
    : d.in_stock === false ? '<span class="sdot out">●</span> Out of stock (live)'
    : `<span class="sdot">●</span> Availability: ${esc(d.availability || 'unknown')}`;
  const bits = [];
  if (d.lead_time) bits.push('Lead ' + esc(d.lead_time));
  if (d.price != null) bits.push('$' + Number(d.price).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }));
  const cost = d.cached ? 'cached · $0' : ('$' + Number(d.cost_usd || 0).toFixed(2) + ' live');
  const when = (d.cached ? 'cached · as of ' : 'fresh · ') + esc(fmtWhen(d.as_of));
  pop.innerHTML = `${dot}${bits.length ? '<div class="sline">' + bits.join(' · ') + '</div>' : ''}<div class="sline mut">${when} · <b class="lcost">${cost}</b></div>`;
}
// Terminal responses render immediately (synchronous DB tier + rate-limit/cap/dim). A status:'pending'
// means the slow (Browserbase) scrape is running in the background — the initiating request no longer
// blocks on it (so it never 504s the proxy), so we poll /api/livestock…&poll=1 until status:'done'.
const LIVE_POLL_MAX = 30;                          // ~90s of polling at the default 3s cadence
function fireLiveCheck(lb) {
  if (lb.dataset.busy === '1') return;            // client debounce — no double-fire
  const pop = lb.closest('.acts, td.acts-td').querySelector('.livepop');
  const sku = lb.dataset.sku;
  pop.hidden = false; pop.className = 'livepop';
  pop.innerHTML = '<span class="lspin">◌</span> Checking live vendor portal…';
  lb.dataset.busy = '1'; const orig = lb.textContent; lb.textContent = 'Checking…';
  const done = () => { lb.dataset.busy = ''; lb.textContent = orig; };
  const tooLong = () => { pop.innerHTML = '<span class="sdot out">●</span> live check took too long — use Email Vendor'; done(); };

  const poll = (n, afterMs) => {
    if (n >= LIVE_POLL_MAX) return tooLong();
    setTimeout(() => {
      fetch('/api/livestock?sku=' + encodeURIComponent(sku) + '&live=1&poll=1')
        .then((r) => r.json()).then((d) => {
          if (d.status === 'pending') return poll(n + 1, d.poll_after_ms || 3000);
          if (d.status === 'gone') return tooLong();
          renderLive(pop, d); done();                 // status:'done' (or any terminal shape)
        })
        .catch(() => { pop.innerHTML = '<span class="sdot out">●</span> Live check failed — try again'; done(); });
    }, afterMs || 3000);
  };

  fetch('/api/livestock?sku=' + encodeURIComponent(sku) + '&live=1')
    .then((r) => r.json()).then((d) => {
      if (d.status === 'pending') return poll(0, d.poll_after_ms || 3000);
      renderLive(pop, d); done();                     // terminal on first hit (DB tier / rate-limit / cap / dim)
    })
    .catch(() => { pop.innerHTML = '<span class="sdot out">●</span> Live check failed — try again'; done(); });
}

// ── Email Vendor — stock & price inquiry compose (EVERY vendor; Gmail DRAFT only) ──────
// To: pre-fills from /api/vendor-contact (and is REMEMBERED on submit via the server upsert,
// so contact coverage grows as it's used). Professional trade-tone body asks the VENDOR for
// availability / lead time / THEIR current net-trade price — our cost data never appears.
// House signature block = the one dw-pitch-followup ships (address + 1-888-373-4564).
const INQ_SIGNATURE = 'Thank you,\n\nDesigner Wallcoverings\nsteve@designerwallcoverings.com · 1-888-373-4564\n15442 Ventura Blvd, #102, Sherman Oaks, CA 91403';
function inquiryDefaults(d) { // d = { vendor, sku, mfr, pat } (all strings, possibly empty)
  const ref = [d.mfr || d.sku, d.pat].filter(Boolean).join(' · ') || d.vendor || '';
  const subject = `Stock & price check — ${ref} (Designer Wallcoverings)`;
  const lines = ['Hello,', '',
    'This is Designer Wallcoverings (trade account) — could you check current stock and pricing on the following item for us?', ''];
  if (d.pat) lines.push(`  Pattern / colorway: ${d.pat}`);
  if (d.mfr) lines.push(`  Mfr SKU: ${d.mfr}`);
  if (d.sku && d.sku !== d.mfr) lines.push(`  Our ref: ${d.sku}`);
  if (d.vendor) lines.push(`  Line: ${d.vendor}`);
  lines.push('', 'Specifically:',
    '  • Current stock / availability',
    '  • Lead time if not in stock',
    '  • Current net / trade price',
    '', INQ_SIGNATURE);
  return { subject, body: lines.join('\n') };
}
const CONTACT_CACHE = {};   // vendor → {email} (session cache so repeat opens don't refetch)
function toggleMailPop(mb) {
  const pop = mb.closest('.acts, td.acts-td').querySelector('.mailpop');
  if (!pop) return;
  if (!pop.hidden) { pop.hidden = true; return; }
  pop.hidden = false;
  if (!pop.dataset.built) buildMailPop(pop, mb.dataset);
}
async function buildMailPop(pop, d) {
  pop.dataset.built = '1';
  const def = inquiryDefaults(d);
  pop.innerHTML = `
    <label>To${d.vendor ? ' — ' + esc(d.vendor) + ' rep' : ''}</label>
    <input type="text" class="mto" placeholder="vendor rep email — will be remembered">
    <label>Subject</label>
    <input type="text" class="msub">
    <label>Message</label>
    <textarea class="mbody"></textarea>
    <div class="mrow"><button type="button" class="mgo mdraft">Create Gmail draft</button><a class="mgo mmailto" target="_blank" rel="noopener noreferrer">Open in mail app</a></div>
    <div class="mstat" hidden></div>`;
  pop.querySelector('.msub').value = def.subject;
  pop.querySelector('.mbody').value = def.body;
  const syncMailto = () => {
    pop.querySelector('.mmailto').href = 'mailto:' + encodeURIComponent(pop.querySelector('.mto').value.trim())
      + '?subject=' + encodeURIComponent(pop.querySelector('.msub').value)
      + '&body=' + encodeURIComponent(pop.querySelector('.mbody').value);
  };
  pop.querySelectorAll('input,textarea').forEach((el) => el.addEventListener('input', syncMailto));
  syncMailto();
  pop.querySelector('.mdraft').addEventListener('click', () => fireInquiry(pop, d));
  // pre-fill the remembered rep email for this vendor (grows via the server upsert)
  if (d.vendor) {
    try {
      const c = CONTACT_CACHE[d.vendor]
        || (CONTACT_CACHE[d.vendor] = await (await fetch('/api/vendor-contact?vendor=' + encodeURIComponent(d.vendor))).json());
      const to = pop.querySelector('.mto');
      if (c && c.email && !to.value) { to.value = c.email; syncMailto(); }
    } catch (e) { /* unknown contact — placeholder stands */ }
  }
}
async function fireInquiry(pop, d) {
  const btn = pop.querySelector('.mdraft'), stat = pop.querySelector('.mstat');
  const to = pop.querySelector('.mto').value.trim();
  const subject = pop.querySelector('.msub').value.trim();
  const body = pop.querySelector('.mbody').value;
  stat.hidden = false; stat.className = 'mstat';
  if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(to)) { stat.className = 'mstat err'; stat.textContent = 'Enter the vendor rep’s email address first.'; return; }
  if (!subject.trim() || !body.trim()) { stat.className = 'mstat err'; stat.textContent = 'Subject and message are required.'; return; }
  btn.disabled = true; const orig = btn.textContent; btn.textContent = 'Drafting…';
  stat.textContent = '◌ Saving contact + creating Gmail draft…';
  try {
    const r = await fetch('/api/vendor-inquiry', {
      method: 'POST', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ vendor: d.vendor || '', to, subject, body, sku: d.sku || '' }),
    });
    const j = await r.json();
    if (j.drafted) {
      stat.className = 'mstat ok';
      stat.textContent = '✓ draft saved in Gmail (' + (j.account || 'steve-office') + ') — review + send from there. Contact remembered.';
      if (d.vendor) CONTACT_CACHE[d.vendor] = { email: to };
    } else if (j.ok) {
      stat.className = 'mstat err';
      stat.textContent = (j.contact_saved ? 'Contact saved, but the ' : 'The ') + 'Gmail draft failed: '
        + (j.reason || 'unknown') + ' — use “Open in mail app” instead.';
      if (j.contact_saved && d.vendor) CONTACT_CACHE[d.vendor] = { email: to };
    } else {
      stat.className = 'mstat err'; stat.textContent = j.reason || 'Request failed — try again.';
    }
  } catch (e) {
    stat.className = 'mstat err'; stat.textContent = 'Request failed — try again.';
  } finally { btn.disabled = false; btn.textContent = orig; }
}

// Mfr # chip — shows BOTH Shopify's stored number AND what FileMaker Pro shows, side by side,
// with a ⚠ + tinted row when they disagree (the mfr-number verification workflow). FM value in
// green when it agrees. When the SKU isn't in the WALLPAPER file, just the Shopify number shows.
function mfrHTML(r) {
  const shop = r.mfr_number ? esc(r.mfr_number) : '—';
  if (r.fm_present && r.fm_mfr_number) {
    const mm = r.mfr_mismatch;
    // On a mismatch, put all three identifiers on one line — DW SKU · Shopify mfr · FileMaker mfr —
    // so the mfr numbers can be cross-referenced against the DW number without looking back up.
    if (mm) {
      const dwNum = esc(String(r.sku || '').replace(/-sample$/i, ''));
      return `<div class="mfrn mismatch"><span class="dwnum" title="DW SKU number"><b>DW</b> ${dwNum}</span> <span class="shopv"><b>Shop</b> ${shop}</span> <span class="fmv" title="what FileMaker Pro shows as the mfr number"><b>FM</b> ${esc(r.fm_mfr_number)}</span> <span class="mmflag" title="FileMaker and Shopify mfr numbers disagree — verify">⚠</span></div>`;
    }
    return `<div class="mfrn"><b>Mfr #</b> ${shop} <span class="fmv" title="what FileMaker Pro shows as the mfr number">· FM ${esc(r.fm_mfr_number)}</span></div>`;
  }
  return r.mfr_number ? `<div class="mfrn"><b>Mfr #</b> ${shop}</div>` : '';
}
// Compact List-view mfr cell — same DW · Shop · FM breakout as the card, one line for the table.
function mfrCell(r) {
  const shop = r.mfr_number ? esc(r.mfr_number) : '—';
  if (r.fm_present && r.fm_mfr_number) {
    const fm = esc(r.fm_mfr_number);
    if (r.mfr_mismatch) {
      const dw = esc(String(r.sku || '').replace(/-sample$/i, ''));
      return `<span class="tmm"><span class="k">DW</span> ${dw} <span class="k">Shop</span> ${shop} <span class="k">FM</span> <b>${fm}</b> ⚠</span>`;
    }
    return `${shop} <span class="tfm">· FM ${fm}</span>`;
  }
  return shop;
}
// A compact line of FileMaker specs (width + up to 3 others) — the source-of-truth values
// available to push. Only rendered when the SKU is in the WALLPAPER file and has specs.
function fmSpecsHTML(r) {
  if (!r.fm_present) return '';
  const bits = [];
  if (r.fm_width) bits.push(`W: ${esc(r.fm_width)}`);
  const sp = r.fm_specs || {};
  for (const k of ['content', 'repeat', 'collection', 'length']) { if (sp[k] && bits.length < 4) bits.push(`${k[0].toUpperCase() + k.slice(1)}: ${esc(String(sp[k]))}`); }
  return bits.length ? `<div class="fmspec"><b>FM</b> ${bits.join(' · ')}</div>` : '';
}
function card(r) {
  const el = document.createElement('div'); el.className = 'card';
  if (r.sku) el.dataset.sku = String(r.sku);
  const stBadge = (r.lifecycle && r.lifecycle !== 'Active on site') ? `<span class="badge ${lifeClass(r)}">${r.lifecycle}</span>` : '';
  const discoBadge = r.fm_discontinued ? `<span class="badge disco" title="FileMaker Pro: discontinued ${esc(r.fm_disco_date || '')}">FM Disco</span>` : '';
  // gallery: hover the thumb to cycle every image (Shopify full image set)
  const gcount = (r.image_count || (r.images ? r.images.length : (r.image ? 1 : 0)));
  const galBadge = gcount > 1 ? `<span class="imgcount" title="${gcount} images — hover to cycle">⧉ ${gcount}</span>` : '';
  const thumb = r.image
    ? `<div class="thumb" style="background-image:url('${r.image}')">${stBadge}${discoBadge}${galBadge}</div>`
    : `<div class="thumb empty">${stBadge}${discoBadge}no image</div>`;
  const vt = [r.vendor, r.type].filter(Boolean).join(' · ');
  const mfrn = mfrHTML(r);
  const mfrname = r.mfr_name ? `<div class="mfrname"><b>Mfr</b> ${esc(r.mfr_name)}</div>` : '';
  const fmspec = fmSpecsHTML(r);
  const fac = [...(r.colors || []), ...(r.styles || []), ...(r.materials || [])].slice(0, 4).join(' · ');
  const price = r.price ? `<span class="price">$${r.price.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</span>` : '';
  el.innerHTML = `${thumb}<div class="meta"><div class="sku">${r.sku || '—'}</div><div class="ttl" title="${(r.title || '').replace(/"/g, '&quot;')}">${r.title || ''}</div>${vt ? `<div class="vt">${vt}</div>` : ''}${mfrn}${mfrname}${fmspec}${fac ? `<div class="fac">${fac}</div>` : ''}${price}${whenChip(r).html}</div><div class="acts">${actButtonsHTML(r)}</div>`;
  const acts = el.querySelector('.acts'); if (acts) acts.addEventListener('click', (e) => { e.stopPropagation(); handleActsClick(e); });
  if (r.url) { el.style.cursor = 'pointer'; el.onclick = () => window.open(r.url, '_blank', 'noopener,noreferrer'); }
  // hover-cycle the full Shopify gallery on the thumb (no extra requests — urls already in the row)
  if (gcount > 1 && r.images && r.images.length > 1) {
    const th = el.querySelector('.thumb'); let iv = null, i = 0;
    th.addEventListener('mouseenter', () => { iv = setInterval(() => { i = (i + 1) % r.images.length; th.style.backgroundImage = `url('${r.images[i]}')`; }, 700); });
    th.addEventListener('mouseleave', () => { clearInterval(iv); i = 0; th.style.backgroundImage = `url('${r.images[0]}')`; });
  }
  return el;
}

function listRow(r) {
  const tr = document.createElement('tr');
  const cell = (html, cls) => { const td = document.createElement('td'); if (cls) td.className = cls; td.innerHTML = html; return td; };
  tr.appendChild((() => { const td = document.createElement('td'); td.className = 'timg';
    td.innerHTML = r.image ? `<div style="background-image:url('${r.image}')"></div>` : '<div></div>'; return td; })());
  tr.appendChild(cell(`<span class="sku">${r.sku || '—'}</span>`));
  tr.appendChild(cell(mfrCell(r), 'tmfr'));
  tr.appendChild(cell((r.title || '').replace(/</g, '&lt;')));
  tr.appendChild(cell(r.vendor || ''));
  tr.appendChild(cell(r.type || ''));
  tr.appendChild(cell(r.pattern || ''));
  tr.appendChild(cell(`<span class="tfac">${(r.colors || []).join(', ')}</span>`));
  tr.appendChild(cell(`<span class="tfac">${(r.styles || []).join(', ')}</span>`));
  tr.appendChild(cell(`<span class="tfac">${(r.materials || []).join(', ')}</span>`));
  tr.appendChild(cell(r.price ? '$' + r.price.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }) : '', 'num'));
  tr.appendChild(cell(r.lifecycle ? `<span class="when ${lifeClass(r)}"><b>${r.lifecycle}</b></span>` : ''));
  tr.appendChild(cell(r.status || ''));
  tr.appendChild(cell(r.created ? `<span title="${new Date(r.created).toISOString()}">${fmtDate(r.created)}</span>` : ''));
  const actTd = cell(actButtonsHTML(r), 'acts-td');
  actTd.addEventListener('click', (e) => { e.stopPropagation(); handleActsClick(e); });
  tr.appendChild(actTd);
  if (r.url) { tr.className = 'linked'; tr.onclick = () => window.open(r.url, '_blank', 'noopener,noreferrer'); }
  return tr;
}

async function load() {
  if (state.loading || state.done) return;
  state.loading = true;
  try {
    const res = await fetch('/api/products?' + facetParams({ offset: state.offset, limit: LIMIT }));
    const data = await res.json();
    state.total = data.total;
    const frag = document.createDocumentFragment();
    data.rows.forEach((r) => { if (r.fm_present && r.sku) FM_LOADED.push(r.sku); frag.appendChild(state.view === 'list' ? listRow(r) : card(r)); });
    (state.view === 'list' ? tbody : grid).appendChild(frag);
    state.offset += data.rows.length;
    state.done = state.offset >= data.total || data.rows.length === 0;
    document.getElementById('pillN').textContent = data.total.toLocaleString();
    subEl.textContent = `of ${data.catalog.toLocaleString()} products · showing ${state.offset.toLocaleString()}`;
    statusEl.textContent = data.total === 0 ? '— no matching products —'
      : (state.done ? `— end · ${state.total.toLocaleString()} matching —` : 'scroll for more…');
  } catch (e) {
    statusEl.textContent = 'Load error — check server';
  } finally {
    state.loading = false;
  }
}

// Robust infinite scroll: a "pump" loop keeps loading WHILE the sentinel sits in (or near)
// the viewport. It re-measures the sentinel's geometry each iteration with getBoundingClientRect
// instead of trusting the IntersectionObserver's cached `sentVisible` flag — that flag only
// updates on an intersection TRANSITION, so after a reset() (filter/sort/view/search) that
// empties the grid and reloads a short first page, no transition fires and the stale flag
// (often false when the user changed a facet from near the top) left "scroll for more…"
// permanently stuck until the user manually scrolled. Geometry is always current, so the
// pump now self-sustains on initial load and after every reset regardless of observer timing.
const SENT_MARGIN = 600; // must match the observer rootMargin below
const sentEl = document.getElementById('sentinel');
function sentinelInView() {
  const r = sentEl.getBoundingClientRect();
  const vh = window.innerHeight || document.documentElement.clientHeight;
  return r.top <= vh + SENT_MARGIN && r.bottom >= -SENT_MARGIN;
}
let sentVisible = false, pumping = false;
async function pump() {
  if (pumping) return;
  pumping = true;
  try {
    while (!state.done && sentinelInView()) {
      const before = state.offset;
      await load();
      if (state.offset === before) break; // no progress (error/empty) — bail, don't spin
    }
  } finally { pumping = false; }
}
new IntersectionObserver((es) => { sentVisible = es[0].isIntersecting; if (sentVisible) pump(); }, { rootMargin: SENT_MARGIN + 'px' })
  .observe(sentEl);
// Re-pump on window resize too (a wider viewport can reveal the sentinel with no scroll event).
window.addEventListener('resize', () => { if (!state.done) pump(); });
refreshFacets(); load().then(() => pump());
</script>
</body>
</html>