← back to Wild Wallcoverings
marketing approval viewer: reel lineup + caption + pin selection with GO gate; parametrized reel builder
7601c589ae2db98b0529094ddd6fdb867a215400 · 2026-07-23 12:00:52 -0700 · Steve Abrams
Files touched
A public/marketing.htmlM scripts/build_launch_reel.shM server.js
Diff
commit 7601c589ae2db98b0529094ddd6fdb867a215400
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Thu Jul 23 12:00:52 2026 -0700
marketing approval viewer: reel lineup + caption + pin selection with GO gate; parametrized reel builder
---
public/marketing.html | 180 +++++++++++++++++++++++++++++++++++++++++++
scripts/build_launch_reel.sh | 14 +++-
server.js | 42 ++++++++++
3 files changed, 234 insertions(+), 2 deletions(-)
diff --git a/public/marketing.html b/public/marketing.html
new file mode 100644
index 0000000..3dac411
--- /dev/null
+++ b/public/marketing.html
@@ -0,0 +1,180 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Whimsical Wallcoverings — Marketing Approval</title>
+<style>
+ * { box-sizing: border-box; }
+ body { margin: 0; font-family: -apple-system, 'Helvetica Neue', sans-serif;
+ background: #111; color: #eee; padding-bottom: 90px; }
+ header { padding: 18px 24px; border-bottom: 1px solid #2a2a2a; }
+ h1 { font-size: 20px; margin: 0; letter-spacing: .06em; font-weight: 600; }
+ h1 em { font-style: normal; color: #7ec8a9; }
+ .sub { color: #888; font-size: 13px; margin-top: 4px; }
+ h2 { font-size: 14px; letter-spacing: .1em; text-transform: uppercase; color: #9dc7b0;
+ padding: 0 24px; margin: 26px 0 10px; }
+ .hint { color: #777; font-size: 12px; padding: 0 24px; margin: -6px 0 12px; }
+ /* reel lineup */
+ #reel { padding: 0 24px; display: flex; flex-direction: column; gap: 8px; }
+ .row { display: flex; align-items: center; gap: 12px; background: #191919;
+ border: 1px solid #2a2a2a; border-radius: 10px; padding: 8px 12px; }
+ .row.in { border-color: #3c6; }
+ .row img { width: 54px; height: 56px; object-fit: cover; border-radius: 6px; background:#0b0b0b; }
+ .row .nm { flex: 1; }
+ .row .nm b { font-size: 14px; }
+ .row .nm span { color: #c9a; font-size: 12px; }
+ .row .ord { color: #7ec8a9; font-weight: 700; width: 22px; text-align: center; }
+ .row button { border: 1px solid #444; background: transparent; color: #ccc;
+ border-radius: 7px; padding: 4px 9px; cursor: pointer; font-size: 13px; }
+ .row .up, .row .dn { padding: 4px 8px; }
+ .row.in .tog { border-color: #3c6; color: #7ec8a9; }
+ /* captions */
+ .caps { padding: 0 24px; display: flex; flex-direction: column; gap: 10px; }
+ .cap { background: #191919; border: 1px solid #2a2a2a; border-radius: 10px; padding: 12px 14px; cursor: pointer; }
+ .cap.sel { border-color: #4caf7d; box-shadow: 0 0 0 1px #4caf7d55; }
+ .cap .t { font-size: 12px; letter-spacing: .08em; color: #7ec8a9; text-transform: uppercase; }
+ .cap .body { font-size: 13px; color: #ddd; margin-top: 6px; line-height: 1.5; }
+ /* pins */
+ #pins { padding: 0 24px; display: grid; grid-template-columns: repeat(auto-fill, minmax(120px,1fr)); gap: 12px; }
+ .pin { border: 1px solid #2c2c2c; border-radius: 10px; overflow: hidden; background: #1b1b1b; cursor: pointer; position: relative; }
+ .pin img { width: 100%; aspect-ratio: 2/3; object-fit: cover; display: block; }
+ .pin .lbl { padding: 6px 8px; font-size: 11px; color: #bbb; }
+ .pin.on { border-color: #4caf7d; box-shadow: 0 0 0 2px #4caf7d55; }
+ .pin .check { position: absolute; top: 6px; right: 6px; width: 22px; height: 22px; border-radius: 50%;
+ background: #0009; color: #4caf7d; display: flex; align-items: center; justify-content: center; font-size: 14px; }
+ #gobar { position: fixed; bottom: 0; left: 0; right: 0; background: #161f1a;
+ border-top: 1px solid #2e4638; padding: 12px 24px; display: flex; align-items: center; gap: 16px; }
+ #gobtn { background: #4caf7d; color: #08130d; border: none; border-radius: 10px;
+ padding: 12px 34px; font-size: 15px; font-weight: 700; letter-spacing: .06em; cursor: pointer; }
+ #gobtn:disabled { background: #2a3a30; color: #567; cursor: not-allowed; }
+ .info { color: #9dc7b0; font-size: 13px; }
+ #msg { color: #f0c060; font-size: 13px; }
+ #lightbox { position: fixed; inset: 0; background: #000d; display: none; align-items: center; justify-content: center; z-index: 9; cursor: zoom-out; }
+ #lightbox img { max-width: 92vw; max-height: 92vh; }
+</style>
+</head>
+<body>
+<header>
+ <h1>WHIMSICAL <em>WALLCOVERINGS</em> · Marketing Approval</h1>
+ <div class="sub">Nothing posts until you hit GO. Your picks only — never an original. <span id="count"></span></div>
+</header>
+
+<h2>Reel lineup <span style="color:#777;text-transform:none;letter-spacing:0">(<span id="reelcount">0</span>/10 in the reel)</span></h2>
+<div class="hint">Toggle designs in/out of the reel and reorder with ↑ ↓. In-order gets numbered.</div>
+<div id="reel"></div>
+
+<h2>Reel caption</h2>
+<div class="caps" id="caps"></div>
+
+<h2>Pins to publish <span style="color:#777;text-transform:none;letter-spacing:0">(<span id="pincount">0</span> selected)</span></h2>
+<div class="hint">Pinterest pins — click to include/exclude.</div>
+<div id="pins"></div>
+
+<div id="gobar">
+ <button id="gobtn" onclick="go()" disabled>GO</button>
+ <span class="info" id="goinfo">Pick a reel lineup + caption, then GO.</span>
+ <span id="msg"></span>
+</div>
+<div id="lightbox" onclick="this.style.display='none'"><img></div>
+
+<script>
+let designs = [], caps = [], state = { reel: {}, order: [], caption: 'A', pins: {} };
+
+function zoom(s){ const lb=document.getElementById('lightbox'); lb.querySelector('img').src=s; lb.style.display='flex'; }
+
+function renderReel(){
+ const box = document.getElementById('reel'); box.innerHTML='';
+ // in-reel first (in order), then the rest
+ const inList = state.order.filter(id => state.reel[id]);
+ const rest = designs.map(d=>d.id).filter(id => !state.reel[id]);
+ const ids = [...inList, ...rest];
+ ids.forEach(id => {
+ const d = designs.find(x=>x.id===id);
+ const on = !!state.reel[id];
+ const pos = on ? inList.indexOf(id)+1 : '';
+ const el = document.createElement('div');
+ el.className = 'row' + (on?' in':'');
+ el.innerHTML = `<div class="ord">${pos}</div>
+ <img src="/images/${d.file}" onclick="zoom('/images/${d.file}')">
+ <div class="nm"><b>${d.title}</b><br><span>${d.colorway}</span></div>
+ ${on?`<button class="up" onclick="move('${id}',-1)">↑</button><button class="dn" onclick="move('${id}',1)">↓</button>`:''}
+ <button class="tog" onclick="toggleReel('${id}')">${on?'Remove':'Add to reel'}</button>`;
+ box.appendChild(el);
+ });
+ const n = inList.length;
+ document.getElementById('reelcount').textContent = n;
+ updateGo();
+}
+function toggleReel(id){
+ if (state.reel[id]) { delete state.reel[id]; state.order = state.order.filter(x=>x!==id); }
+ else { if (Object.keys(state.reel).length>=10) { flash('Reel is 10 max — remove one first.'); return; } state.reel[id]=true; state.order.push(id); }
+ renderReel();
+}
+function move(id, dir){
+ const i = state.order.indexOf(id), j = i+dir;
+ if (j<0||j>=state.order.length) return;
+ [state.order[i], state.order[j]] = [state.order[j], state.order[i]];
+ renderReel();
+}
+function renderCaps(){
+ const box = document.getElementById('caps'); box.innerHTML='';
+ caps.forEach(c => {
+ const el = document.createElement('div');
+ el.className = 'cap' + (state.caption===c.key?' sel':'');
+ el.innerHTML = `<div class="t">Option ${c.key} — ${c.label}</div><div class="body">${c.body}</div>`;
+ el.onclick = () => { state.caption=c.key; renderCaps(); };
+ box.appendChild(el);
+ });
+}
+function renderPins(){
+ const box = document.getElementById('pins'); box.innerHTML='';
+ designs.forEach(d => {
+ const on = state.pins[d.id]!==false; // default on
+ const el = document.createElement('div');
+ el.className='pin'+(on?' on':'');
+ el.innerHTML = `<img src="/images/${d.file}"><div class="lbl">${d.title}</div>${on?'<div class="check">✓</div>':''}`;
+ el.onclick = () => { state.pins[d.id] = !on; renderPins(); };
+ box.appendChild(el);
+ });
+ document.getElementById('pincount').textContent = designs.filter(d=>state.pins[d.id]!==false).length;
+}
+function updateGo(){
+ const n = Object.keys(state.reel).length;
+ document.getElementById('gobtn').disabled = n===0;
+ document.getElementById('goinfo').textContent = n? `Ready: ${n}-design reel · caption ${state.caption} · ${designs.filter(d=>state.pins[d.id]!==false).length} pins` : 'Pick a reel lineup + caption, then GO.';
+}
+function flash(t){ const m=document.getElementById('msg'); m.textContent=t; setTimeout(()=>{if(m.textContent===t)m.textContent='';},4000); }
+
+async function go(){
+ const btn=document.getElementById('gobtn'); btn.disabled=true;
+ const spec = {
+ reel: state.order.map(id => { const d=designs.find(x=>x.id===id); return {id, file:d.file, title:d.title, colorway:d.colorway}; }),
+ caption: state.caption,
+ pins: designs.filter(d=>state.pins[d.id]!==false).map(d=>({id:d.id, file:d.file, title:d.title}))
+ };
+ flash('Approved — building reel + posting to your spec…');
+ const r = await fetch('/api/marketing-go', {method:'POST', body: JSON.stringify(spec)});
+ const res = await r.json();
+ flash(res.ok ? '✓ Approved & queued. Claude is building + posting — watch the terminal for permalinks.' : 'save failed');
+ btn.disabled=false;
+ poll();
+}
+async function poll(){
+ const s = await (await fetch('/api/marketing-status')).json();
+ if (s.status) document.getElementById('msg').textContent = s.status;
+ if (!s.done) setTimeout(poll, 5000);
+}
+
+async function load(){
+ const data = await (await fetch('/api/marketing-data')).json();
+ designs = data.designs; caps = data.captions;
+ // default reel = first 10
+ designs.slice(0,10).forEach(d => { state.reel[d.id]=true; state.order.push(d.id); });
+ document.getElementById('count').textContent = `${designs.length} approved designs`;
+ renderReel(); renderCaps(); renderPins();
+}
+load();
+</script>
+</body>
+</html>
diff --git a/scripts/build_launch_reel.sh b/scripts/build_launch_reel.sh
index b97e96d..a56b1b6 100755
--- a/scripts/build_launch_reel.sh
+++ b/scripts/build_launch_reel.sh
@@ -7,12 +7,21 @@
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
WORK="$(mktemp -d /tmp/whimsy-reel.XXXX)"
-OUT="$HOME/Videos/whimsical-wallcoverings-launch-reel-$(date +%Y%m%d).mp4"
+OUT="${REEL_OUT:-$HOME/Videos/whimsical-wallcoverings-launch-reel-$(date +%Y%m%d).mp4}"
DIDOT="/System/Library/Fonts/Supplemental/Didot.ttc"
AVENIR="/System/Library/Fonts/Avenir Next.ttc"
W=1080; H=1920; DUR=2.8; XF=0.5
-# file|Design Name|Colorway
+# Lineup: Steve's approved order from data/reel-lineup.json (file|Name|Colorway),
+# else the default 10 below. Written by the marketing approval viewer.
+SLIDES=()
+LINEUP="$ROOT/data/reel-lineup.json"
+if [ -f "$LINEUP" ]; then
+ while IFS='|' read -r line; do [ -n "$line" ] && SLIDES+=("$line"); done < <(
+ python3 -c "import json;[print('%s|%s|%s'%(s['file'],s['title'],s['colorway'])) for s in json.load(open('$LINEUP'))]"
+ )
+fi
+if [ ${#SLIDES[@]} -eq 0 ]; then
SLIDES=(
"astral-tiger-v1-nano.png|Tigris Nocturne|Onyx & Chalk"
"galaxy-bots-v1-nano.png|Cosmodrome Circus|Graphite Static"
@@ -25,6 +34,7 @@ SLIDES=(
"ray-shadow-v4-nano.png|Velvet Ray Ballet|Dusty Rose Wash"
"lagoon-legends-v4-nano.png|Serpent Lagoon|Sea Glass"
)
+fi
echo "workdir: $WORK"
diff --git a/server.js b/server.js
index 1f24250..eb5c753 100644
--- a/server.js
+++ b/server.js
@@ -60,6 +60,48 @@ const server = http.createServer((req, res) => {
return;
}
+ if (url.pathname === '/api/marketing-data') {
+ const designs = readJson(DESIGNS, []);
+ const choices = readJson(CHOICES, {});
+ const picks = [];
+ for (const [id, ch] of Object.entries(choices)) {
+ if (!ch.variant || ch.variant === 'none') continue;
+ const d = designs.find(x => x.id === id);
+ if (d) picks.push({ id, file: ch.variant, title: d.title, colorway: d.colorway || '' });
+ }
+ const captions = [
+ { key: 'A', label: 'Editorial launch', body: 'Introducing Whimsical Wallcoverings. 21 original designs from our in-house studio — each drawn flat in a screen-print hand and digitally printed to order. 24" × 25" repeat. $195/roll, samples $4.25. The full collection is live — link in bio.' },
+ { key: 'B', label: 'Playful hook', body: "Your walls called. They're bored. 🐅🤖🍄 21 brand-new original patterns just landed — all printed to order, all sampling at $4.25. Which one is your room?" },
+ { key: 'C', label: 'Trend-aware', body: 'POV: you asked for "fun but expensive-looking" and we drew 21 originals from scratch. Whimsical Wallcoverings — new house line, printed to order, samples for the price of a latte.' },
+ ];
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ return res.end(JSON.stringify({ designs: picks, captions }));
+ }
+
+ if (url.pathname === '/api/marketing-status') {
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ return res.end(JSON.stringify(readJson(path.join(ROOT, 'data', 'marketing-status.json'), { done: true, status: '' })));
+ }
+
+ if (url.pathname === '/api/marketing-go' && req.method === 'POST') {
+ let body = '';
+ req.on('data', c => body += c);
+ req.on('end', () => {
+ try {
+ const spec = JSON.parse(body);
+ fs.writeFileSync(path.join(ROOT, 'data', 'marketing-approved.json'),
+ JSON.stringify({ ...spec, approved_at: new Date().toISOString() }, null, 2));
+ // write the reel lineup the builder reads
+ fs.writeFileSync(path.join(ROOT, 'data', 'reel-lineup.json'), JSON.stringify(spec.reel, null, 2));
+ fs.writeFileSync(path.join(ROOT, 'data', 'marketing-status.json'),
+ JSON.stringify({ done: false, status: 'Approved — awaiting Claude to build + post.' }));
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ ok: true }));
+ } catch (e) { res.writeHead(400); res.end(String(e)); }
+ });
+ return;
+ }
+
if (url.pathname === '/api/choose-batch' && req.method === 'POST') {
let body = '';
req.on('data', c => body += c);
← f0bd296 auto-save: 2026-07-23T10:20:16 (1 files) — scripts/ig-grid-q
·
back to Wild Wallcoverings
·
Remove Pharmacy Follies (Aubergine Apothecary) design from t 1096823 →