[object Object]

← back to New Import Viewer

Panel item A: split details/actions into separate triggers — ⓘ details (read-only) vs ⚙ actions (write surface); browsing metadata never reveals a live button; actions chip reddens when armed

fe11c1fd53b7a606141a2390ea4825a5266b1a18 · 2026-06-09 18:59:42 -0700 · SteveStudio2

Files touched

Diff

commit fe11c1fd53b7a606141a2390ea4825a5266b1a18
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Tue Jun 9 18:59:42 2026 -0700

    Panel item A: split details/actions into separate triggers — ⓘ details (read-only) vs ⚙ actions (write surface); browsing metadata never reveals a live button; actions chip reddens when armed
---
 public/index.html | 23 ++++++++++++++++-------
 server.js         | 37 +++++++++++++++++++++++++++++++------
 2 files changed, 47 insertions(+), 13 deletions(-)

diff --git a/public/index.html b/public/index.html
index 7cb79ea..460c048 100644
--- a/public/index.html
+++ b/public/index.html
@@ -51,8 +51,14 @@ body.armed #livestrip{display:block}
 .chip.toggle{background:transparent;color:var(--mut);border-color:var(--line);cursor:pointer;user-select:none}
 .chip.toggle:hover{border-color:var(--acc);color:var(--ink)}
 /* ── collapsed detail drawer ── */
-.details{display:none;flex-direction:column;gap:7px;border-top:1px dashed var(--line);margin-top:2px;padding-top:8px}
-.card.open .details{display:flex}
+/* Two independent drawers: ⓘ details (read-only) and ⚙ actions (write surface).
+   Browsing metadata can never reveal a live-write button — separate triggers. */
+.info,.actpane{display:none;flex-direction:column;gap:7px;border-top:1px dashed var(--line);margin-top:2px;padding-top:8px}
+.card.info-open .info{display:flex}
+.card.acts-open .actpane{display:flex}
+/* the ⚙ actions chip — distinct from the safe info chip; turns red when armed */
+.chip.act-toggle{border-color:#3a4150}
+body.armed .chip.act-toggle{border-color:#fca5a5;color:#fecaca}
 .kv{display:grid;grid-template-columns:104px 1fr;gap:4px 10px;font-size:11.5px;align-items:baseline}
 .kv .k{color:var(--mut)}
 .kv .v{text-align:left;word-break:break-word;font-weight:500}
@@ -106,7 +112,7 @@ body.armed #livestrip{display:block}
 .grid.list .chips .chip.toggle{font-size:9.5px;padding:2px 7px}
 .grid.list .lsku{font-family:ui-monospace,Menlo,monospace;font-size:11px;color:var(--mut);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
 .grid.list .lcrawl{font-size:11px;color:var(--mut);white-space:nowrap;justify-self:start;text-align:left}
-.grid.list .details{grid-column:1 / -1;margin:2px 0 6px;padding:8px 4px 2px}
+.grid.list .info,.grid.list .actpane{grid-column:1 / -1;margin:2px 0 6px;padding:8px 4px 2px}
 /* list-only cells: hidden in the default card grid, shown only in list view */
 .lsku,.lcrawl{display:none}
 .grid.list .lsku,.grid.list .lcrawl{display:block}
@@ -210,7 +216,7 @@ function updateCardStatus(id,st){
 }
 function syncSel(){const n=sel.size;$('#seln').textContent=n.toLocaleString()+' selected';$('#selbar').classList.toggle('on',n>0);$('#bulkgo').disabled=!n;}
 function togglePick(id,cb){if(cb.checked)sel.add(id);else sel.delete(id);cb.closest('.card').classList.toggle('sel',cb.checked);syncSel();}
-function toggleCard(el){el.closest('.card').classList.toggle('open');}
+function togglePane(el,which){el.closest('.card').classList.toggle(which==='info'?'info-open':'acts-open');}
 // persisted prefs (CLAUDE.md: sort + density persist in localStorage)
 $('#status').value=localStorage.getItem('nip.status')||'new';
 $('#sort').value=localStorage.getItem('nip.sort')||'newest';
@@ -245,15 +251,18 @@ function card(p){
     <div class="ttl">${title}</div>
     <div class="chips">
       <span class="chip st-${st}">${STATUS_LABEL[st]||st}</span>
-      <span class="chip toggle" onclick="toggleCard(this)">⋯ details &amp; actions</span>
+      <span class="chip toggle" onclick="togglePane(this,'info')">ⓘ details</span>
+      <span class="chip toggle act-toggle" onclick="togglePane(this,'acts')">⚙ actions</span>
     </div>
     <span class="lsku" title="Vendor SKU">${esc(p.sku||p.dwSku||'')}</span>
     <span class="lcrawl" title="${esc(p.crawled||'')}">🕓 ${esc(p.crawled||'—')}</span>
-    <div class="details">
+    <div class="info">
       ${kv('Vendor SKU',p.sku)}${kv('DW SKU',p.dwSku)}${kv('Collection',p.collection)}
       ${kv('Type',p.type)}${kv('Sync status',p.syncStatus)}${kv('Shopify ID',p.shopifyId)}
       ${kv('First seen',p.firstSeen)}${kv('Last crawl',p.crawled)}${kv('Shopify synced',p.shopSynced)}
       <div class="chips">${vendLink}${shopLink}</div>
+    </div>
+    <div class="actpane">
       ${acts}
       <div class="actmsg" id="msg-${p.id}"></div>
     </div>
@@ -324,7 +333,7 @@ async function rowAction(id,spec,btn){
   }catch(e){if(msg)msg.textContent='✗ '+e.message;}
   btn.disabled=false;
 }
-window.rowAction=rowAction; window.togglePick=togglePick; window.toggleCard=toggleCard; window.toggleArm=toggleArm;
+window.rowAction=rowAction; window.togglePick=togglePick; window.togglePane=togglePane; window.toggleArm=toggleArm;
 $('#bulkgo').onclick=async()=>{
   const ids=[...sel]; if(!ids.length)return;
   const [action,mode]=$('#bulkact').value.split(':');
diff --git a/server.js b/server.js
index a6fd3da..8ec667c 100644
--- a/server.js
+++ b/server.js
@@ -244,12 +244,37 @@ const server = http.createServer((req, res) => {
           if (isLiveAction(action) && !stageOnly && sessionArmed) {
             // LIVE PATH — only reachable when SHOPIFY_LIVE_WRITES=1 AND the action
             // is in LIVE_ACTIONS AND a token is present AND it's a single-card act.
-            const rows = q(`SELECT vc.id, vc.shopify_product_id
-                            FROM vendor_catalog vc WHERE vc.id IN (${ids.join(',')})`);
-            const results = []; let ok = 0, skipped = 0, failed = 0;
+            // Fetch the row's identity + the LINKED product's identity so we can
+            // verify they're the same physical product before any live write.
+            const rows = q(`SELECT vc.id, vc.shopify_product_id, vc.mfr_sku, sp.mfr_sku, vc.pattern_name, sp.title
+                            FROM vendor_catalog vc
+                            LEFT JOIN shopify_products sp ON sp.shopify_id='gid://shopify/Product/'||vc.shopify_product_id::text
+                            WHERE vc.id IN (${ids.join(',')})`);
+            const results = []; let ok = 0, skipped = 0, failed = 0, blocked = 0;
+            // ≥1 significant (len≥4, non-noise) pattern token must appear in the title.
+            const NOISE = new Set(['wallcovering','wallcoverings','wallpaper','mural','grasscloth','vinyl','sample','collection','metallic','embossed','textile','fabric']);
+            const titleConfirms = (pat, title) => {
+              const toks = (pat || '').toLowerCase().replace(/[^a-z0-9 ]/g, ' ').split(/\s+/).filter(t => t.length >= 4 && !NOISE.has(t));
+              const t = (title || '').toLowerCase();
+              return toks.length > 0 && toks.some(tok => t.includes(tok));
+            };
             for (const r of rows) {
-              const vcId = +r[0], pid = r[1];
+              const vcId = +r[0], pid = r[1], vcMfr = (r[2] || '').trim(), prodMfr = (r[3] || '').trim(), pat = r[4], title = r[5];
               if (!pid) { skipped++; results.push({ id: vcId, skipped: 'not on Shopify' }); continue; }
+              // CROSS-LINK GUARD: a live write only fires when BOTH identity signals
+              // agree the linked product is the same one — mfr_sku matches AND the
+              // pattern actually appears in the linked title. Either disagreeing →
+              // refuse (stage for manual review). Catches the Swan-River bug (mfr
+              // corrupted-matches but title is a different pattern) AND fails safe on
+              // the multi-SKU-scheme cases. SKU data is unreliable, so we require
+              // positive confirmation, not absence-of-proof.
+              const mfrAgrees = !!vcMfr && !!prodMfr && vcMfr === prodMfr;
+              const titleAgrees = titleConfirms(pat, title);
+              if (!(mfrAgrees && titleAgrees)) {
+                blocked++;
+                results.push({ id: vcId, blocked: `unverified link — row "${pat}" (${vcMfr}) vs linked product "${(title||'').slice(0,40)}" (${prodMfr}); mfr_match=${mfrAgrees} title_match=${titleAgrees}. Refusing live ${action} (would risk wrong product). Verify + relink, then retry.` });
+                continue;
+              }
               const w = await shopifyWrite(action, pid);
               if (w.ok) {
                 ok++;
@@ -260,10 +285,10 @@ const server = http.createServer((req, res) => {
               } else { failed++; results.push({ id: vcId, error: w.error }); }
             }
             out.live = true;
-            out.applied = { ok, skipped, failed };
+            out.applied = { ok, skipped, failed, blocked };
             out.results = results;
             out.store = SHOP_STORE;
-            out.note = `LIVE ${action} on ${SHOP_STORE}: ${ok} ok, ${skipped} skipped (not on Shopify), ${failed} failed`;
+            out.note = `LIVE ${action} on ${SHOP_STORE}: ${ok} ok, ${skipped} skipped (not on Shopify), ${blocked} BLOCKED (mfr_sku cross-link), ${failed} failed`;
           } else if (isLiveAction(action) && stageOnly) {
             out.note = `staged only — bulk/${ids.length}-item ${action} never fires live (single-card only)`;
           } else if (isLiveAction(action) && !sessionArmed) {

← e4e0ced DTD-C: per-session arm toggle defaults OFF every load; live  ·  back to New Import Viewer  ·  Panel items B+D: No-image filter (12,156 rows) + structured b8c32b4 →