← back to Gated Queue Runner
gated-queue kid-notes viewer (categorized, sort+density, created chips)
e75c3c1415a3fdc1e11d22c28275a121b5d0433a · 2026-08-18 13:13:44 -0700 · steve
Files touched
A .gitignoreA .portA index.htmlA server.jsA tasks/TASK-A-ga4-deploy.mdA tasks/TASK-B-osborne-prices.md
Diff
commit e75c3c1415a3fdc1e11d22c28275a121b5d0433a
Author: steve <steve@designerwallcoverings.com>
Date: Tue Aug 18 13:13:44 2026 -0700
gated-queue kid-notes viewer (categorized, sort+density, created chips)
---
.gitignore | 5 ++
.port | 1 +
index.html | 116 +++++++++++++++++++++++++++++++++++++++++
server.js | 103 ++++++++++++++++++++++++++++++++++++
tasks/TASK-A-ga4-deploy.md | 32 ++++++++++++
tasks/TASK-B-osborne-prices.md | 33 ++++++++++++
6 files changed, 290 insertions(+)
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..ff2422c
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,5 @@
+node_modules/
+.env*
+tmp/
+*.log
+.DS_Store
diff --git a/.port b/.port
new file mode 100644
index 0000000..50e1196
--- /dev/null
+++ b/.port
@@ -0,0 +1 @@
+9801
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..0bf3922
--- /dev/null
+++ b/index.html
@@ -0,0 +1,116 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Things Waiting for Steve to Say Yes</title>
+<style>
+ :root { --cols: 3; --fs: 16px; }
+ * { box-sizing: border-box; }
+ body { margin: 0; font-family: -apple-system, "Segoe UI", system-ui, sans-serif; background: #f4f6fb; color: #1c2333; font-size: var(--fs); }
+ header { position: sticky; top: 0; z-index: 5; background: #fff; border-bottom: 2px solid #e6eaf2; padding: 14px 18px; }
+ h1 { margin: 0 0 4px; font-size: 1.5em; }
+ .sub { color: #667; font-size: .85em; }
+ .controls { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; margin-top: 10px; }
+ .controls label { font-size: .8em; color: #556; display: flex; align-items: center; gap: 5px; }
+ select, input[type=search] { padding: 6px 9px; border: 1px solid #ccd3e0; border-radius: 8px; font-size: .95em; }
+ input[type=range] { vertical-align: middle; }
+ .chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
+ .chip { border: 1px solid #d3dae8; background: #fff; border-radius: 999px; padding: 5px 12px; font-size: .82em; cursor: pointer; user-select: none; }
+ .chip.on { background: #2d5bff; color: #fff; border-color: #2d5bff; }
+ .chip .n { opacity: .7; margin-left: 4px; }
+ main { padding: 16px 18px; }
+ .grid { display: grid; grid-template-columns: repeat(var(--cols), 1fr); gap: 12px; }
+ .card { background: #fff; border: 1px solid #e6eaf2; border-radius: 14px; padding: 14px; display: flex; flex-direction: column; gap: 8px; box-shadow: 0 1px 3px rgba(20,30,60,.05); }
+ .card .top { display: flex; align-items: center; gap: 9px; }
+ .card .emoji { font-size: 1.8em; line-height: 1; }
+ .card .cat { font-size: .72em; font-weight: 700; text-transform: uppercase; letter-spacing: .04em; color: #2d5bff; }
+ .card .note { font-size: 1.05em; line-height: 1.45; color: #1c2333; }
+ .card .when { font-size: .72em; color: #8a93a8; display: flex; align-items: center; gap: 4px; }
+ .card .file { font-size: .68em; color: #aab; word-break: break-all; }
+ .card button { align-self: flex-start; margin-top: 2px; border: 1px solid #ccd3e0; background: #f7f9ff; border-radius: 8px; padding: 5px 10px; font-size: .8em; cursor: pointer; }
+ dialog { border: none; border-radius: 14px; max-width: 760px; width: 92vw; padding: 0; }
+ dialog .dh { padding: 12px 16px; border-bottom: 1px solid #eee; font-weight: 700; display: flex; justify-content: space-between; }
+ dialog pre { margin: 0; padding: 16px; white-space: pre-wrap; font-size: .82em; max-height: 70vh; overflow: auto; }
+ dialog .x { cursor: pointer; border: none; background: #eee; border-radius: 8px; padding: 3px 10px; }
+</style>
+</head>
+<body>
+<header>
+ <h1>🚦 Things waiting for you to say YES</h1>
+ <div class="sub" id="sub">loading…</div>
+ <div class="controls">
+ <input type="search" id="q" placeholder="🔎 search…">
+ <label>Sort
+ <select id="sort">
+ <option value="new">Newest first</option>
+ <option value="old">Oldest first</option>
+ <option value="cat">By kind</option>
+ <option value="az">A → Z</option>
+ </select>
+ </label>
+ <label>Size <input type="range" id="dens" min="1" max="5" value="3"></label>
+ </div>
+ <div class="chips" id="chips"></div>
+</header>
+<main><div class="grid" id="grid"></div></main>
+<dialog id="dlg"><div class="dh"><span id="dt"></span><button class="x" onclick="dlg.close()">close ✕</button></div><pre id="dp"></pre></dialog>
+<script>
+const KID = { spend:'💵', send:'✉️', dns:'🌐', shopify:'🛍️', catalog:'📦', deploy:'🚀', ga4:'📊', blocked:'🔒', other:'📄' };
+let DATA = { gates: [], counts: {}, total: 0 };
+let active = new Set(); // active category filters (empty = all)
+const $ = id => document.getElementById(id);
+
+function fmtWhen(ms){ return new Date(ms).toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}); }
+
+function load(){
+ fetch('/api/gates').then(r=>r.json()).then(d=>{
+ DATA=d; $('sub').textContent = `${d.total} things need your OK. Tap a card to read the grown-up version.`;
+ renderChips(); render();
+ });
+}
+function renderChips(){
+ const c=$('chips'); c.innerHTML='';
+ const cats = Object.entries(DATA.counts).sort((a,b)=>b[1]-a[1]);
+ cats.forEach(([k,n])=>{
+ const el=document.createElement('span');
+ el.className='chip'+(active.has(k)?' on':'');
+ el.innerHTML=`${KID[k]||'📄'} ${k}<span class="n">${n}</span>`;
+ el.onclick=()=>{ active.has(k)?active.delete(k):active.add(k); renderChips(); render(); };
+ c.appendChild(el);
+ });
+}
+function render(){
+ const q=$('q').value.toLowerCase(), sort=$('sort').value;
+ let g=DATA.gates.filter(x=> (active.size===0||active.has(x.category)) &&
+ (!q || (x.note+x.file+x.title).toLowerCase().includes(q)));
+ if(sort==='new') g.sort((a,b)=>b.created-a.created);
+ if(sort==='old') g.sort((a,b)=>a.created-b.created);
+ if(sort==='az') g.sort((a,b)=>a.title.localeCompare(b.title));
+ if(sort==='cat') g.sort((a,b)=>a.category.localeCompare(b.category)||b.created-a.created);
+ const grid=$('grid'); grid.innerHTML='';
+ g.forEach(x=>{
+ const d=document.createElement('div'); d.className='card';
+ d.innerHTML=`<div class="top"><span class="emoji">${x.emoji}</span><span class="cat">${x.catLabel}</span></div>
+ <div class="note">${esc(x.note)}</div>
+ <div class="when">🕓 ${fmtWhen(x.created)}</div>
+ <div class="file">${esc(x.file)}</div>
+ <button>read the grown-up version →</button>`;
+ d.querySelector('button').onclick=()=>openMemo(x.file,x.title);
+ grid.appendChild(d);
+ });
+ if(!g.length) grid.innerHTML='<p style="color:#889">Nothing matches.</p>';
+}
+function openMemo(f,t){ fetch('/api/memo?f='+encodeURIComponent(f)).then(r=>r.text()).then(txt=>{ $('dt').textContent=t; $('dp').textContent=txt; $('dlg').showModal(); }); }
+function esc(s){ return (s||'').replace(/[&<>]/g,c=>({'&':'&','<':'<','>':'>'}[c])); }
+
+// controls + persistence
+['q','sort'].forEach(id=>$(id).addEventListener('input',()=>{save();render();}));
+$('dens').addEventListener('input',e=>{ const v=+e.target.value; document.documentElement.style.setProperty('--cols', 6-v); document.documentElement.style.setProperty('--fs', (12+v*2)+'px'); localStorage.gqDens=v; });
+function save(){ localStorage.gqSort=$('sort').value; localStorage.gqQ=$('q').value; }
+(function restore(){ if(localStorage.gqSort)$('sort').value=localStorage.gqSort; if(localStorage.gqQ)$('q').value=localStorage.gqQ;
+ const v=localStorage.gqDens||3; $('dens').value=v; document.documentElement.style.setProperty('--cols',6-v); document.documentElement.style.setProperty('--fs',(12+v*2)+'px'); })();
+load();
+</script>
+</body>
+</html>
diff --git a/server.js b/server.js
new file mode 100644
index 0000000..2a930d9
--- /dev/null
+++ b/server.js
@@ -0,0 +1,103 @@
+#!/usr/bin/env node
+// Gated-queue viewer — reads ~/.claude/yolo-queue/pending-approval, explains each gate "like a 6-year-old".
+// Zero-dependency (built-in http/fs only). Basic auth admin/DW2024!.
+const http = require('http');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+
+const DIR = path.join(os.homedir(), '.claude/yolo-queue/pending-approval');
+const PORT = parseInt(fs.readFileSync(path.join(__dirname, '.port'), 'utf8').trim() || '9801', 10);
+const USER = 'admin', PASS = 'DW2024!';
+
+// ---- category + kid-note logic ----
+// order matters — first match wins; specific categories before the broad "spend" catch.
+const CATS = [
+ { key: 'blocked', emoji: '🔒', label: 'Waiting on you', rx: /blocked on (steve|scope|grant|write_themes|iam)|needs your (grant|one-time|login)|iam grant|only you can/i,
+ yes: 'This one is WAITING for you to unlock something first (a password or a permission).' },
+ { key: 'send', emoji: '✉️', label: 'Sends messages', rx: /send-to-list|mailer|email blast|\bsend\b .*(list|vendor|customer)|george.*send|\bsms\b|push notif|draft.*email/i,
+ yes: 'If you say YES, it sends a real email or message to real people.' },
+ { key: 'dns', emoji: '🌐', label: 'Website address', rx: /\bdns\b|a-?record|cloudflare zone|nameserver|repoint|subdomain|expose.*\.com/i,
+ yes: 'If you say YES, it changes where a website address sends people.' },
+ { key: 'catalog', emoji: '📦', label: 'Product list', rx: /catalog stage|_catalog\b|dw_unified.*write|stage → |price (list|refresh|change)|reprice|sku|metafield|variant/i,
+ yes: 'If you say YES, it changes our big list of products (like prices or names).' },
+ { key: 'shopify', emoji: '🛍️', label: 'Store change', rx: /shopify (write|publish|product)|customer-facing shopify|activate|draft-down|storefront|online store|collection/i,
+ yes: 'If you say YES, it changes what shoppers see in the store.' },
+ { key: 'ga4', emoji: '📊', label: 'Visitor counter', rx: /\bga4\b|analytics|gtag|pixel|\bgtm\b/i,
+ yes: 'If you say YES, it turns on the little counter that shows how many people visit.' },
+ { key: 'deploy', emoji: '🚀', label: 'Put on internet', rx: /deploy|kamatera|pm2 (restart|reload)|go-?live|rebuild|ship it|publish to|nginx/i,
+ yes: 'If you say YES, it puts a website change out on the internet for everyone to see.' },
+ { key: 'spend', emoji: '💵', label: 'Spends money', rx: /spend (real )?money|budget cap|will cost \$[1-9]|\$[1-9][0-9.]*\s*\/\s*mo|monthly \$|subscription|paid (api|plan|tier)|purchase order/i,
+ yes: 'If you say YES, it spends real money.' },
+];
+const OTHER = { key: 'other', emoji: '📄', label: 'Needs your OK', yes: 'If you say YES, it makes a change that needs your OK first.' };
+
+function categorize(body) {
+ for (const c of CATS) if (c.rx.test(body)) return c;
+ return OTHER;
+}
+function cleanTitle(firstLine, fname) {
+ let t = (firstLine || fname).replace(/^#+\s*/, '').trim();
+ // if the first line is not a real sentence/heading (looks like the filename), use filename cleanly
+ const looksLikeFname = /\.md$|\.csv$|^\d{4}[- ]\d\d[- ]\d\d/.test(t) || /GATED$|-GATED|_/.test(t);
+ if (looksLikeFname) t = fname.replace(/\.(md|csv)$/,'').replace(/[-_]/g,' ');
+ t = t.replace(/^(GATED|PENDING APPROVAL|APPROVAL|ESCALATION|FIX|DONE|READY|DRAFT|✅|🚨|🚦)\b[:\s—-]*/i, '');
+ t = t.replace(/\b20\d\d[ -]\d\d[ -]\d\d\b/g, '').replace(/\bTK[ -]?\d+\b/gi, '');
+ t = t.replace(/\b(GATED|READY|DRAFT|memo|publish held|verified)\b/gi, '');
+ t = t.replace(/[—:|].*$/, '').replace(/\(.*?\)/g, '').replace(/[_*`#>]/g, ' ').replace(/\s{2,}/g, ' ').trim();
+ if (!t) t = fname.replace(/\.(md|csv)$/, '').replace(/[-_]/g, ' ');
+ // simplify a few jargon words
+ const swap = { 'reconcile':'match up', 'remediation':'cleanup', 'canary':'watcher', 'metafield':'label',
+ 'orphan':'lost', 'backfill':'fill in', 'onboard':'add', 'scrape':'collect', 'reprice':'change prices',
+ 'redirect':'forward', 'provision':'set up', 'miswire':'wrong wiring', 'dedup':'remove doubles' };
+ for (const [k, v] of Object.entries(swap)) t = t.replace(new RegExp('\\b' + k + '\\w*', 'gi'), v);
+ return t.charAt(0).toUpperCase() + t.slice(1);
+}
+function kidNote(cat, title) {
+ return `This is about: ${title}. ${cat.yes}`;
+}
+
+function listGates() {
+ let files = [];
+ try { files = fs.readdirSync(DIR).filter(f => !f.startsWith('_') && (f.endsWith('.md') || f.endsWith('.csv'))); }
+ catch (e) { return []; }
+ return files.map(f => {
+ const p = path.join(DIR, f);
+ let body = '', first = '';
+ try { body = fs.readFileSync(p, 'utf8'); } catch {}
+ first = (body.split('\n').find(l => l.trim()) || '').trim();
+ const st = fs.statSync(p);
+ const cat = categorize(body);
+ const title = cleanTitle(first, f);
+ return {
+ file: f, title, category: cat.key, catLabel: cat.label, emoji: cat.emoji,
+ note: kidNote(cat, title), created: st.mtimeMs, size: st.size,
+ };
+ }).sort((a, b) => b.created - a.created);
+}
+
+const server = http.createServer((req, res) => {
+ // basic auth
+ const hdr = req.headers.authorization || '';
+ const [u, pw] = Buffer.from(hdr.split(' ')[1] || '', 'base64').toString().split(':');
+ if (u !== USER || pw !== PASS) {
+ res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="gates"' }); return res.end('auth');
+ }
+ if (req.url.startsWith('/api/gates')) {
+ const gates = listGates();
+ const counts = {};
+ gates.forEach(g => counts[g.category] = (counts[g.category] || 0) + 1);
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ return res.end(JSON.stringify({ total: gates.length, counts, gates }));
+ }
+ if (req.url.startsWith('/api/memo')) {
+ const f = decodeURIComponent((req.url.split('?f=')[1] || ''));
+ const p = path.join(DIR, path.basename(f));
+ if (!p.startsWith(DIR) || !fs.existsSync(p)) { res.writeHead(404); return res.end('no'); }
+ res.writeHead(200, { 'Content-Type': 'text/plain' });
+ return res.end(fs.readFileSync(p, 'utf8'));
+ }
+ res.writeHead(200, { 'Content-Type': 'text/html' });
+ res.end(fs.readFileSync(path.join(__dirname, 'index.html'), 'utf8'));
+});
+server.listen(PORT, () => console.log(`gated-queue viewer on http://127.0.0.1:${PORT} (admin/DW2024!)`));
diff --git a/tasks/TASK-A-ga4-deploy.md b/tasks/TASK-A-ga4-deploy.md
new file mode 100644
index 0000000..5e96b38
--- /dev/null
+++ b/tasks/TASK-A-ga4-deploy.md
@@ -0,0 +1,32 @@
+# TASK A — Deploy the GA4 / GTM sister-sites (Steve APPROVED this run)
+
+You are a dedicated Claude session. Steve selected this gated item to RUN — execute it end-to-end,
+verify-before-acting, show $ costs, ledger reversible deploys. Work under ticket TK-10631.
+
+## Already done (do NOT redo) — verified live 2026-08-18
+The original 8-site GA4 batch is deployed: designersalesreps, fabricfriday, patterndesignlab,
+wallpaperdictionary, wallpaperhistory, wallpaperwednesday = LIVE serving their G-XXXX ids.
+STRUCTURALLY BLOCKED (skip, note only): dsgntv.com (DNS-parked, nothing served) and
+designerssalesreps.com (301-redirects to designersalesreps.com → shows the redirect target's id).
+
+## Your actual scope — the remaining GA4/GTM deploy memos in ~/.claude/yolo-queue/pending-approval/
+1. `TK-10631-ga4-dw-fleet-two-sites-deploy.md`
+2. `TK-10631-gtm-commercialwallcovering-hollywoodwallcovering-deploy.md`
+3. `TK-10631-fix-hollywoodwallcovering-miswire.md`
+4. `GA4-rollout-gated-batch.md`
+5. `GA4-fleet-provision-APPROVED.md`
+
+For EACH: read the memo, identify the site(s) + the GA4/GTM id(s) + the deploy mechanism
+(usually `/deploy` from the project dir, or a pm2 restart on Kamatera). Deploy to Kamatera.
+Then VERIFY live: `curl -sL https://<domain> | grep -oE 'G-[A-Z0-9]{10}|GTM-[A-Z0-9]+'` must return
+the expected id. Fix the hollywoodwallcovering miswire (memo 3) — confirm it serves ITS id, not a neighbor's.
+
+## Rails
+- Deploy = external publish → this run is Steve-approved, so proceed, but verify each post-deploy.
+- Ledger each deploy to ~/.claude/yolo-queue/executed-reversible/ledger.jsonl via
+ `node ~/.claude/yolo-queue/executed-reversible/log-exec.mjs --agent ga4-deploy --ticket TK-10631 --action "..." --undo "git revert <sha> + redeploy" --blast <n> --verify "curl shows G-id" --apply`
+- Do NOT touch DNS (dsgntv parked stays parked). Do NOT create new GA4 properties (the 18-premature-properties
+ issue is a SEPARATE memo — leave it).
+- On completion, move each fully-deployed+verified memo to `_done/` and print a summary table
+ (site | id | deployed? | verified-live?).
+Show costs ($0 local deploy). Report back in this window when done.
diff --git a/tasks/TASK-B-osborne-prices.md b/tasks/TASK-B-osborne-prices.md
new file mode 100644
index 0000000..9f5de40
--- /dev/null
+++ b/tasks/TASK-B-osborne-prices.md
@@ -0,0 +1,33 @@
+# TASK B — Apply the 5 Osborne CHANGED price patterns (map Cobra colorways first) — Steve APPROVED
+
+You are a dedicated Claude session. Steve selected this gated item to RUN. This is a CANONICAL
+dw_unified write — reversible, bounded, internal (no externality). Snapshot old values BEFORE writing,
+ledger it. HOLD Shopify publish (that stays a separate gate). Work under ticket TK-10514.
+
+## Context (verified 2026-08-18)
+- Table `dw_unified.osborne_catalog` (Mac2 socket `-h /tmp`). Convention: `price_trade` = NET COST,
+ `price_retail` = round(cost/0.65/0.85, 2) = 1.81×cost. BASIS CONFIRMED (272 exact matches).
+- Source: `~/Projects/eur-recrawl/osborne-pricelists-2026-08/osborne_wallpapers_2026-08.csv`
+ (columns: base `W####`, pattern, cost/yd USD). Colorway rows in the table are `W####-##`.
+- Full finding: `~/.claude/yolo-queue/pending-approval/2026-08-18-TK-10514-osborne-STAGE-VERIFIED-publish-held.md`
+
+## The 5 CHANGED patterns (Apr-2026 list vs existing)
+| base | pattern | new cost | old trade | note |
+|---|---|---|---|---|
+| W7902 | Mayani | 216 | 333 | SINGLE cost → apply to all W7902-* colorways |
+| W7903 | Shalimar | 216 | 333 | SINGLE → apply to all W7903-* |
+| W7907 | Chellah | 326 | 435 | SINGLE → apply to all W7907-* |
+| W6302 | Cobra | **132 AND 242** | 233 | AMBIGUOUS → map colorways first |
+
+## Steps
+1. **Clean 3 (Mayani, Shalimar, Chellah):** snapshot current (id, mfr_sku, price_trade, price_retail)
+ for all `W7902-*`, `W7903-*`, `W7907-*` rows to `/tmp/osborne-rollback-B.csv`. Then UPDATE
+ `price_trade = <new cost>`, `price_retail = round(<new cost>/0.65/0.85, 2)`, `updated_at = now()`.
+2. **Cobra (W6302):** the Apr list has two costs — 132 (standard) and 242 (likely a wide-width / panel /
+ grasscloth variant). Read the source PDF text (`~/Projects/eur-recrawl/osborne-pricelists-2026-08/full.txt`)
+ around "Cobra" to see which colorway/width maps to 132 vs 242, and inspect the existing `W6302-*` rows
+ (color_name / width / material) to match. Apply per-colorway. If the mapping is NOT unambiguous from the
+ PDF + row attributes, DO NOT guess — leave Cobra unchanged and note it as still-needs-mapping in the memo.
+3. Ledger via `node ~/.claude/yolo-queue/executed-reversible/log-exec.mjs --agent osborne-prices --ticket TK-10514 --action "updated N Osborne colorway rows to Apr-2026 net cost (Mayani/Shalimar/Chellah [+Cobra if mapped])" --undo "restore from /tmp/osborne-rollback-B.csv" --blast <n> --verify "price_retail = 1.81x price_trade on updated rows" --apply`
+4. Update `...osborne-STAGE-VERIFIED-publish-held.md` with what was applied vs still-held. HOLD Shopify publish.
+Show costs ($0 local psql). Report back a table (pattern | rows updated | old→new trade | retail) in this window.
(oldest)
·
back to Gated Queue Runner
·
fix categorization balance + title cleanup 638e70c →