← 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
M public/index.htmlM server.js
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 & 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 →