← back to Claude Webdev Accelerator
Accelerator: real sort+density storefront starter (templates/) + preview-tunnel.sh (<slug>.agentabrams.com, DNS gated)
e5c33fa35d8755ce7b259041d5ae8ad0bcb0612d · 2026-07-13 15:43:46 -0700 · Steve
Files touched
A scripts/preview-tunnel.shM templates/README.mdA templates/data/products.jsonA templates/public/index.htmlA templates/server.js
Diff
commit e5c33fa35d8755ce7b259041d5ae8ad0bcb0612d
Author: Steve <steve@designerwallcoverings.com>
Date: Mon Jul 13 15:43:46 2026 -0700
Accelerator: real sort+density storefront starter (templates/) + preview-tunnel.sh (<slug>.agentabrams.com, DNS gated)
---
scripts/preview-tunnel.sh | 57 ++++++++++++++++++++++++++++++++++++++++++++
templates/README.md | 20 +++++++++++++++-
templates/data/products.json | 6 +++++
templates/public/index.html | 55 ++++++++++++++++++++++++++++++++++++++++++
templates/server.js | 41 +++++++++++++++++++++++++++++++
5 files changed, 178 insertions(+), 1 deletion(-)
diff --git a/scripts/preview-tunnel.sh b/scripts/preview-tunnel.sh
new file mode 100755
index 0000000..9858145
--- /dev/null
+++ b/scripts/preview-tunnel.sh
@@ -0,0 +1,57 @@
+#!/bin/bash
+# Stand up <slug>.agentabrams.com as a preview of a local app on <port>, through the
+# shared dw-followup cloudflared tunnel. Reversible local work runs automatically;
+# the DNS write (Cloudflare proxied CNAME) only runs with --dns (Steve-gated step).
+#
+# Usage: scripts/preview-tunnel.sh <slug> <port> [--dns]
+set -euo pipefail
+SLUG="${1:?usage: preview-tunnel.sh <slug> <port> [--dns]}"
+PORT="${2:?need a local port}"
+DO_DNS=""; [ "${3:-}" = "--dns" ] && DO_DNS=1
+HOST="${SLUG}.agentabrams.com"
+CFG="$HOME/.cloudflared/followup.yml"
+TUNNEL_ID="0e1edf5a-f5b4-48c9-af62-639645519983" # dw-followup
+ZONE_NAME="agentabrams.com"
+
+[ -f "$CFG" ] || { echo "no tunnel config at $CFG"; exit 1; }
+
+# 1. Idempotently add the ingress rule before the 404 catch-all.
+if grep -q "hostname: ${HOST}" "$CFG"; then
+ echo "ingress for ${HOST} already present"
+else
+ # insert two lines before the first "- service: http_status:404"
+ awk -v host="$HOST" -v port="$PORT" '
+ /- service: http_status:404/ && !done {
+ print " - hostname: " host; print " service: http://127.0.0.1:" port; done=1 }
+ { print }' "$CFG" > "$CFG.tmp" && mv "$CFG.tmp" "$CFG"
+ echo "added ingress ${HOST} -> 127.0.0.1:${PORT}"
+fi
+
+# 2. Restart the tunnel to pick up the new ingress.
+launchctl kickstart -k "gui/$(id -u)/com.steve.followup-tunnel" 2>/dev/null && echo "tunnel restarted" || echo "WARN: could not kickstart tunnel (start it manually)"
+
+# 3. DNS (gated). Repoint/point the CF record to a proxied CNAME on the tunnel.
+if [ -n "$DO_DNS" ]; then
+ set +u; source "$HOME/Projects/secrets-manager/.env" 2>/dev/null; set -u
+ : "${CLOUDFLARE_API_TOKEN:?CLOUDFLARE_API_TOKEN not set}"
+ ZONE=$(curl -s -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
+ "https://api.cloudflare.com/client/v4/zones?name=${ZONE_NAME}" | python3 -c "import sys,json;print(json.load(sys.stdin)['result'][0]['id'])")
+ REC=$(curl -s -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
+ "https://api.cloudflare.com/client/v4/zones/${ZONE}/dns_records?name=${HOST}" | python3 -c "import sys,json;r=json.load(sys.stdin)['result'];print(r[0]['id'] if r else '')")
+ BODY="{\"type\":\"CNAME\",\"name\":\"${HOST}\",\"content\":\"${TUNNEL_ID}.cfargotunnel.com\",\"proxied\":true,\"comment\":\"preview via dw-followup tunnel\"}"
+ if [ -n "$REC" ]; then
+ curl -s -X PUT "https://api.cloudflare.com/client/v4/zones/${ZONE}/dns_records/${REC}" \
+ -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" -H "Content-Type: application/json" -d "$BODY" >/dev/null
+ echo "updated existing DNS record → proxied CNAME to tunnel"
+ else
+ curl -s -X POST "https://api.cloudflare.com/client/v4/zones/${ZONE}/dns_records" \
+ -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" -H "Content-Type: application/json" -d "$BODY" >/dev/null
+ echo "created DNS record → proxied CNAME to tunnel"
+ fi
+ echo "live (after CF propagation): https://${HOST}"
+else
+ echo "DNS NOT changed (gated). To publish: re-run with --dns, or add a proxied CNAME"
+ echo " ${HOST} -> ${TUNNEL_ID}.cfargotunnel.com in Cloudflare (zone ${ZONE_NAME})."
+fi
+
+echo "REMINDER: gate the app — run it with BASIC_AUTH=\"admin:DW2024!\" since previews are public."
diff --git a/templates/README.md b/templates/README.md
index c321bef..9c6f5b4 100644
--- a/templates/README.md
+++ b/templates/README.md
@@ -1 +1,19 @@
-Starter templates copied into new projects by scripts/new-project.sh.
+# Project
+
+Scaffolded by the Claude web-dev accelerator. Storefront starter with a
+server-side-sorted product grid + density slider (both persist in localStorage).
+
+## Run
+```bash
+PORT=3900 node server.js # open http://localhost:3900
+BASIC_AUTH="admin:DW2024!" PORT=3900 node server.js # gated
+```
+
+## Fill it in
+- `data/products.json` — array of `{ title, sku, price, hex, image }`.
+- `server.js` `sortProducts()` — add sort modes as needed.
+- `public/index.html` — the grid + controls.
+
+## Preview publicly
+From the accelerator dir: `scripts/preview-tunnel.sh <slug> <port>` stands up
+`<slug>.agentabrams.com` (basic-auth gated). The DNS step is Steve-gated.
diff --git a/templates/data/products.json b/templates/data/products.json
new file mode 100644
index 0000000..8eee5f2
--- /dev/null
+++ b/templates/data/products.json
@@ -0,0 +1,6 @@
+[
+ { "title": "Sample One", "sku": "SMP-001", "price": 129.00, "hex": "#2b3a55", "image": "" },
+ { "title": "Sample Two", "sku": "SMP-002", "price": 89.00, "hex": "#d8c3a5", "image": "" },
+ { "title": "Sample Three", "sku": "SMP-003", "price": 210.00, "hex": "#8e8d8a", "image": "" },
+ { "title": "Sample Four", "sku": "SMP-004", "price": 54.00, "hex": "#e98074", "image": "" }
+]
diff --git a/templates/public/index.html b/templates/public/index.html
new file mode 100644
index 0000000..ceb7ab1
--- /dev/null
+++ b/templates/public/index.html
@@ -0,0 +1,55 @@
+<!doctype html><html lang="en"><head><meta charset="utf-8">
+<meta name="viewport" content="width=device-width,initial-scale=1"><title>Storefront starter</title>
+<style>
+ :root{--cols:4}
+ *{box-sizing:border-box} body{margin:0;font:15px/1.4 -apple-system,Segoe UI,Roboto,sans-serif;color:#1a1a1a;background:#fafafa}
+ header{display:flex;gap:14px;align-items:center;flex-wrap:wrap;padding:14px 20px;border-bottom:1px solid #e6e6e6;position:sticky;top:0;background:#fff;z-index:5}
+ h1{font-size:17px;margin:0;font-weight:700} .spacer{flex:1}
+ label{font-size:12px;color:#666;display:flex;gap:6px;align-items:center}
+ select,input[type=range]{font:inherit} .count{font-size:12px;color:#999}
+ .grid{display:grid;grid-template-columns:repeat(var(--cols),1fr);gap:14px;padding:20px}
+ .card{background:#fff;border:1px solid #e6e6e6;border-radius:8px;overflow:hidden}
+ .card img{width:100%;aspect-ratio:1/1;object-fit:cover;display:block;background:#f0f0f0}
+ .meta{padding:8px 10px} .t{font-size:13px;font-weight:600;margin:0 0 2px} .s{font-size:11px;color:#888}
+ .p{font-size:13px;margin-top:4px} .empty{padding:40px;color:#999;text-align:center}
+</style></head><body>
+<header>
+ <h1>Storefront starter</h1>
+ <label>Sort
+ <select id="sort">
+ <option value="newest">Newest</option>
+ <option value="title">Title A→Z</option>
+ <option value="sku">SKU A→Z</option>
+ <option value="price-asc">Price ↑</option>
+ <option value="price-desc">Price ↓</option>
+ <option value="light">Light → Dark</option>
+ <option value="dark">Dark → Light</option>
+ </select>
+ </label>
+ <label>Density <input id="density" type="range" min="2" max="8" value="4"></label>
+ <span class="spacer"></span><span class="count" id="count"></span>
+</header>
+<div class="grid" id="grid"><div class="empty">Loading…</div></div>
+<script>
+// House rule: sort + density both persist in localStorage so the choice survives reloads.
+const $=s=>document.querySelector(s), esc=s=>(s||'').replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));
+const sortSel=$('#sort'), dens=$('#density');
+sortSel.value=localStorage.getItem('sort')||'newest'; dens.value=localStorage.getItem('cols')||'4';
+function applyCols(){document.documentElement.style.setProperty('--cols',dens.value);localStorage.setItem('cols',dens.value);}
+applyCols();
+async function load(){
+ const r=await fetch('/api/products?sort='+encodeURIComponent(sortSel.value)); const d=await r.json();
+ $('#count').textContent=d.count+' items';
+ $('#grid').innerHTML = d.products.length ? d.products.map(p=>`
+ <div class="card">
+ <img src="${esc(p.image||'')}" alt="${esc(p.title||'')}" loading="lazy">
+ <div class="meta"><p class="t">${esc(p.title||'Untitled')}</p>
+ <div class="s">${esc(p.sku||p.handle||'')}</div>
+ ${p.price?`<div class="p">$${Number(p.price).toFixed(2)}</div>`:''}
+ </div>
+ </div>`).join('') : '<div class="empty">No products yet — fill data/products.json.</div>';
+}
+sortSel.onchange=()=>{localStorage.setItem('sort',sortSel.value);load();};
+dens.oninput=applyCols;
+load();
+</script></body></html>
diff --git a/templates/server.js b/templates/server.js
new file mode 100644
index 0000000..57f5df5
--- /dev/null
+++ b/templates/server.js
@@ -0,0 +1,41 @@
+// Storefront starter — zero-dep Node http. Serves data/products.json with SERVER-SIDE sort
+// (house rule: every product grid gets sort + a density slider). Optional basic auth via BASIC_AUTH.
+const http = require('http'), fs = require('fs'), path = require('path');
+const PORT = parseInt(process.env.PORT || '3900', 10);
+const BASIC_AUTH = process.env.BASIC_AUTH || ''; // "user:pass" to gate; empty = open
+const DIR = __dirname;
+
+function load() { try { return JSON.parse(fs.readFileSync(path.join(DIR, 'data', 'products.json'), 'utf8')); } catch { return []; } }
+function authed(req) {
+ if (!BASIC_AUTH) return true;
+ const m = (req.headers.authorization || '').match(/^Basic\s+(.+)$/i);
+ if (!m) return false; try { return Buffer.from(m[1], 'base64').toString() === BASIC_AUTH; } catch { return false; }
+}
+// Sort modes mirror the DW sort-skill: newest, title/sku A→Z, price ↑↓, light→dark (by dominant hex).
+function luminance(hex) { if (!/^#?[0-9a-f]{6}$/i.test(hex || '')) return 999; const h = hex.replace('#', '');
+ const r = parseInt(h.slice(0, 2), 16), g = parseInt(h.slice(2, 4), 16), b = parseInt(h.slice(4, 6), 16);
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b; }
+function sortProducts(items, mode) {
+ const a = items.slice();
+ switch (mode) {
+ case 'title': return a.sort((x, y) => (x.title || '').localeCompare(y.title || ''));
+ case 'sku': return a.sort((x, y) => (x.sku || x.handle || '').localeCompare(y.sku || y.handle || ''));
+ case 'price-asc': return a.sort((x, y) => (x.price || 0) - (y.price || 0));
+ case 'price-desc': return a.sort((x, y) => (y.price || 0) - (x.price || 0));
+ case 'light': return a.sort((x, y) => luminance(y.hex) - luminance(x.hex));
+ case 'dark': return a.sort((x, y) => luminance(x.hex) - luminance(y.hex));
+ default: return a; // 'newest' = natural order
+ }
+}
+http.createServer((req, res) => {
+ if (!authed(req)) { res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="preview"' }); return res.end('auth required'); }
+ const u = new URL(req.url, 'http://x');
+ if (u.pathname === '/api/products') {
+ const out = sortProducts(load(), u.searchParams.get('sort') || 'newest');
+ res.writeHead(200, { 'Content-Type': 'application/json' }); return res.end(JSON.stringify({ count: out.length, products: out }));
+ }
+ const f = u.pathname === '/' ? 'index.html' : u.pathname.replace(/^\//, '');
+ const fp = path.join(DIR, 'public', path.basename(f));
+ if (fs.existsSync(fp)) { res.writeHead(200, { 'Content-Type': f.endsWith('.html') ? 'text/html' : 'text/plain' }); return res.end(fs.readFileSync(fp)); }
+ res.writeHead(404); res.end('not found');
+}).listen(PORT, function () { console.log('[' + path.basename(DIR) + '] http://localhost:' + this.address().port); });
← b6a0cb0 Claude web-dev accelerator v0: playbook + scaffold script +
·
back to Claude Webdev Accelerator
·
preview-tunnel: read CF token via grep not source (set -e + ca78cd2 →