[object Object]

← back to Pattern Vault

regen v3: texture-01 grasscloth fixed (lane hit) + added to spoonflower-ready; deco-01 v3 geometry fixed (chartreuse still pending v4)

58fe9d9b21afa1b70a023ffa130830d557d0d465 · 2026-07-02 12:11:39 -0700 · Steve

Files touched

Diff

commit 58fe9d9b21afa1b70a023ffa130830d557d0d465
Author: Steve <steve@designerwallcoverings.com>
Date:   Thu Jul 2 12:11:39 2026 -0700

    regen v3: texture-01 grasscloth fixed (lane hit) + added to spoonflower-ready; deco-01 v3 geometry fixed (chartreuse still pending v4)
---
 assets/spoonflower-ready/texture-01_150dpi.png     | Bin 0 -> 1628206 bytes
 scripts/__pycache__/_regen_v3.cpython-314.pyc      | Bin 0 -> 7754 bytes
 .../daily/runs/2026-07-02-test/20260702test_01.png | Bin 0 -> 1496549 bytes
 whimsical-compare/daily/scripts/daily_run.sh       |  73 +++++++++++
 whimsical-compare/daily/scripts/gen_daily.py       | 133 +++++++++++++++++++++
 whimsical-compare/daily/scripts/report.js          |  66 ++++++++++
 whimsical-compare/daily/scripts/upload_sf.js       | 117 ++++++++++++++++++
 7 files changed, 389 insertions(+)

diff --git a/assets/spoonflower-ready/texture-01_150dpi.png b/assets/spoonflower-ready/texture-01_150dpi.png
new file mode 100644
index 0000000..6892b66
Binary files /dev/null and b/assets/spoonflower-ready/texture-01_150dpi.png differ
diff --git a/scripts/__pycache__/_regen_v3.cpython-314.pyc b/scripts/__pycache__/_regen_v3.cpython-314.pyc
new file mode 100644
index 0000000..19406c0
Binary files /dev/null and b/scripts/__pycache__/_regen_v3.cpython-314.pyc differ
diff --git a/whimsical-compare/daily/runs/2026-07-02-test/20260702test_01.png b/whimsical-compare/daily/runs/2026-07-02-test/20260702test_01.png
new file mode 100644
index 0000000..24cc2a9
Binary files /dev/null and b/whimsical-compare/daily/runs/2026-07-02-test/20260702test_01.png differ
diff --git a/whimsical-compare/daily/scripts/daily_run.sh b/whimsical-compare/daily/scripts/daily_run.sh
new file mode 100755
index 0000000..c7f35ce
--- /dev/null
+++ b/whimsical-compare/daily/scripts/daily_run.sh
@@ -0,0 +1,73 @@
+#!/bin/bash
+# Fernwick Studio daily drop orchestrator.
+#   - fires from launchd every 15 min during the day
+#   - a per-day PLAN picks one random minute in [06:00, 18:00) local
+#   - only when now >= planned time (and not already done today) does it run:
+#       generate 20 settlement-passed originals -> (dry-run|armed) upload -> email report
+#   - fail-closed: expired Spoonflower session => report says "re-auth needed", uploads nothing
+#
+# Arm live uploads by creating the file  daily/ARMED  (touch it). Absent => dry-run.
+# Kill switch: create  daily/STOP  => the run refuses to start.
+set -euo pipefail
+HERE="$(cd "$(dirname "$0")" && pwd)"
+DAILY="$(dirname "$HERE")"
+STATE="$DAILY/runs"
+N=20
+export OLLAMA_URL="${OLLAMA_URL:-http://127.0.0.1:11434}"
+
+TODAY=$(date +%Y-%m-%d)
+NOW_EPOCH=$(date +%s)
+HOUR=$(date +%H)
+PLAN="$STATE/plan-$TODAY.json"
+mkdir -p "$STATE"
+
+[ -f "$DAILY/STOP" ] && { echo "STOP present — refusing"; exit 0; }
+
+# window guard: only operate 06:00–18:00 local
+if [ "$HOUR" -lt 6 ] || [ "$HOUR" -ge 18 ]; then echo "outside 6am-6pm window"; exit 0; fi
+
+# make today's plan: random minute in [6*3600, 18*3600)
+if [ ! -f "$PLAN" ]; then
+  MID=$(date -j -f "%Y-%m-%d %H:%M:%S" "$TODAY 00:00:00" +%s)
+  RAND_SEC=$(( 21600 + RANDOM % 43200 ))   # 6h .. 18h
+  RUN_AT=$(( MID + RAND_SEC ))
+  echo "{\"date\":\"$TODAY\",\"run_at\":$RUN_AT,\"run_at_h\":\"$(date -r $RUN_AT +%H:%M)\",\"done\":false}" > "$PLAN"
+  echo "planned today's drop for $(date -r $RUN_AT +%H:%M)"
+fi
+
+RUN_AT=$(python3 -c "import json;print(json.load(open('$PLAN'))['run_at'])")
+DONE=$(python3 -c "import json;print(json.load(open('$PLAN'))['done'])")
+
+if [ "$DONE" = "True" ]; then echo "already dropped today"; exit 0; fi
+if [ "$NOW_EPOCH" -lt "$RUN_AT" ]; then echo "waiting for planned time $(date -r $RUN_AT +%H:%M)"; exit 0; fi
+
+echo "=== FERNWICK DAILY DROP $TODAY $(date +%H:%M) ==="
+SEED_BASE=$(( NOW_EPOCH % 1000000 ))
+MANIFEST="$STATE/$TODAY/manifest.json"
+
+# 1. generate
+python3 "$HERE/gen_daily.py" --n $N --date "$TODAY" --seed-base "$SEED_BASE" > "$STATE/$TODAY/gen.stdout" 2> "$STATE/$TODAY/gen.stderr" || true
+[ -f "$MANIFEST" ] || { echo "no manifest — gen failed"; cat "$STATE/$TODAY/gen.stderr" | tail; exit 1; }
+
+# 2. upload (armed only)
+UPRESULT="$STATE/$TODAY/upload.json"
+if [ -f "$DAILY/ARMED" ]; then
+  echo "ARMED — live upload"
+  node "$HERE/upload_sf.js" "$MANIFEST" --armed > "$UPRESULT" 2>"$STATE/$TODAY/upload.stderr" || true
+else
+  echo "DRY RUN — not armed"
+  node "$HERE/upload_sf.js" "$MANIFEST" > "$UPRESULT" 2>"$STATE/$TODAY/upload.stderr" || true
+fi
+
+# 3. report to steve-office
+node "$HERE/report.js" "$MANIFEST" "$UPRESULT" || echo "report send failed"
+
+# 4. log to spoonflower_jobs + mark done
+COUNT=$(python3 -c "import json;print(json.load(open('$MANIFEST'))['count'])")
+COST=$(python3 -c "import json;print(json.load(open('$MANIFEST'))['est_cost_usd'])")
+ARMED_STATE=$([ -f "$DAILY/ARMED" ] && echo true || echo false)
+psql "postgresql:///dw_unified?host=/tmp" -c \
+  "INSERT INTO spoonflower_jobs (kind,status,dry_run,params,result,finished_at) VALUES ('fernwick_daily','done',$([ -f "$DAILY/ARMED" ] && echo false || echo true),'{\"date\":\"$TODAY\",\"n\":$COUNT}'::jsonb,'{\"count\":$COUNT,\"cost\":$COST,\"armed\":$ARMED_STATE}'::jsonb,now());" >/dev/null 2>&1 || true
+
+python3 -c "import json;p='$PLAN';d=json.load(open(p));d['done']=True;json.dump(d,open(p,'w'))"
+echo "=== DONE: $COUNT designs, est \$$COST, armed=$ARMED_STATE ==="
diff --git a/whimsical-compare/daily/scripts/gen_daily.py b/whimsical-compare/daily/scripts/gen_daily.py
new file mode 100755
index 0000000..eeb9c41
--- /dev/null
+++ b/whimsical-compare/daily/scripts/gen_daily.py
@@ -0,0 +1,133 @@
+#!/usr/bin/env python3
+"""
+Generate N fresh, ORIGINAL, settlement-passed whimsical wallpaper designs for the
+daily Spoonflower drop. Every design is our own invention (randomised style x motif
+x palette lane) — never a copy of a marketplace design — and each output is
+vision-checked for banned elements (bird/butterfly/banana/grape) before it counts.
+
+Writes files to daily/runs/<date>/ and prints a JSON manifest to stdout.
+$0 for the vision check (local qwen). Replicate SDXL is the only paid part (~1c/img).
+
+Usage: python3 gen_daily.py --n 20 --date 2026-07-02 [--seed-base 1234]
+"""
+import os, re, json, time, sys, base64, random, urllib.request, urllib.error
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+DAILY = os.path.dirname(HERE)
+OLLAMA = os.environ.get("OLLAMA_URL", "http://127.0.0.1:11434")
+VMODEL = "qwen2.5vl:7b"
+
+def argv(flag, d=None):
+    a = sys.argv
+    return a[a.index(flag)+1] if flag in a else d
+N = int(argv("--n", "20"))
+DATE = argv("--date", time.strftime("%Y-%m-%d"))
+SEED_BASE = int(argv("--seed-base", str(int(time.time()) % 1000000)))
+OUT = f"{DAILY}/runs/{DATE}"
+os.makedirs(OUT, exist_ok=True)
+
+tok = None
+with open(os.path.expanduser("~/Projects/secrets-manager/.env")) as f:
+    m = re.search(r"REPLICATE_API_TOKEN=([A-Za-z0-9_-]+)", f.read())
+    tok = m and m.group(1)
+assert tok, "no replicate token"
+
+UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36"
+NEG = ("bird, birds, butterfly, butterflies, banana, bananas, banana leaves, banana pods, grape, grapes, "
+       "grape clusters, text, watermark, signature, logo, blurry, low quality, jpeg artifacts, seams, "
+       "mismatched edges, frame, border, vignette")
+
+# whimsical design lanes — all settlement-safe by construction (no banned motifs)
+STYLES = ["cottagecore", "mid-century modern", "folk block-print", "scandinavian", "art-nouveau line",
+          "watercolor storybook", "gouache naive", "hand-cut paper collage", "ditsy calico", "whimsical geometric"]
+MOTIFS = ["tiny mushrooms and toadstools", "scattered wildflowers and sprigs", "rolling hills and cottages",
+          "curling ferns and leaves", "smiling suns and moons", "little teapots and cups",
+          "garden snails and pebbles", "star and cloud confetti", "citrus slices and dots",
+          "rainbows and raindrops", "sailboats and waves", "cats curled in flowers",
+          "hot air balloons", "acorns and oak leaves", "abstract squiggles and blobs",
+          "polka-dot toadstool rings", "meadow foxgloves", "quilted patchwork hearts"]
+PALETTES = ["sage, cream, terracotta", "dusty blue, ochre, ivory", "blush pink, sand, olive",
+            "mustard, teal, oatmeal", "lilac, moss, butter", "coral, denim, chalk",
+            "forest green, rust, bone", "peach, mint, taupe", "navy, marigold, linen",
+            "plum, sage, apricot"]
+
+def http_json(url, payload=None, headers=None, timeout=180):
+    hdr = {"User-Agent": UA, "Content-Type": "application/json"}
+    if headers: hdr.update(headers)
+    data = json.dumps(payload).encode() if payload is not None else None
+    req = urllib.request.Request(url, data=data, headers=hdr, method="POST" if data else "GET")
+    with urllib.request.urlopen(req, timeout=timeout) as r:
+        return json.load(r)
+
+def check_output(img_path):
+    b64 = base64.b64encode(open(img_path, "rb").read()).decode()
+    try:
+        out = http_json(f"{OLLAMA}/api/generate", {
+            "model": VMODEL,
+            "prompt": 'Reply ONLY JSON {"bird":bool,"butterfly":bool,"banana":bool,"grape":bool} — true only if clearly depicted.',
+            "images": [b64], "stream": False, "options": {"temperature": 0.2}}, timeout=120)
+        j = json.loads(re.search(r"\{.*\}", out.get("response", ""), re.S).group(0))
+        return {k: bool(j.get(k)) for k in ("bird", "butterfly", "banana", "grape")}
+    except Exception:
+        return {}
+
+def sdxl(prompt, seed):
+    ver = http_json("https://api.replicate.com/v1/models/stability-ai/sdxl",
+                    headers={"Authorization": f"Bearer {tok}"})["latest_version"]["id"]
+    pred = http_json("https://api.replicate.com/v1/predictions", {
+        "version": ver, "input": {"prompt": prompt, "negative_prompt": NEG, "width": 1024, "height": 1024,
+        "num_inference_steps": 30, "guidance_scale": 7.5, "seed": seed, "refine": "no_refiner"}},
+        headers={"Authorization": f"Bearer {tok}"})
+    for _ in range(90):
+        if pred.get("status") in ("succeeded", "failed", "canceled"): break
+        time.sleep(2)
+        pred = http_json(pred["urls"]["get"], headers={"Authorization": f"Bearer {tok}"})
+    return pred
+
+ADJ = ["Meadow", "Cottage", "Hollow", "Thicket", "Wren", "Bramble", "Fern", "Marigold", "Juniper", "Poppy",
+       "Willow", "Clover", "Sorrel", "Heath", "Larkspur", "Nettle", "Foxglove", "Hazel", "Rowan", "Sage"]
+NOUN = ["Whimsy", "Ramble", "Frolic", "Wander", "Reverie", "Lullaby", "Trellis", "Gambol", "Ditty", "Wink"]
+
+def title(style, motif, i):
+    random.seed(SEED_BASE + i * 7)
+    return f"{random.choice(ADJ)} {random.choice(NOUN)} — {motif.split(' and ')[0].title()}"
+
+random.seed(SEED_BASE)
+designs = []
+total_secs = 0.0
+attempts = 0
+i = 0
+while len(designs) < N and attempts < N * 3:
+    attempts += 1
+    style = random.choice(STYLES); motif = random.choice(MOTIFS); palette = random.choice(PALETTES)
+    prompt = (f"Original hand-illustrated {style} wallpaper: {motif}, whimsical and charming, playful naive "
+              f"hand-drawn linework, our own artistic invention, not a copy of any existing design. "
+              f"seamless repeating wallpaper pattern, tileable, flat lay, even lighting, high-end wallcovering. "
+              f"Colorway: {palette}.")
+    seed = SEED_BASE + attempts * 13
+    pred = sdxl(prompt, seed)
+    secs = (pred.get("metrics") or {}).get("predict_time", 0) or 0
+    total_secs += secs
+    if pred.get("status") != "succeeded":
+        print(f"[attempt {attempts}] gen {pred.get('status')}", file=sys.stderr); continue
+    out = pred["output"]; imgurl = out[0] if isinstance(out, list) else out
+    fn = f"{DATE.replace('-','')}_{len(designs)+1:02d}.png"
+    dest = f"{OUT}/{fn}"
+    urllib.request.urlretrieve(imgurl, dest)
+    chk = check_output(dest)
+    if any(chk.get(k) for k in ("bird", "butterfly", "banana", "grape")):
+        os.remove(dest)
+        print(f"[attempt {attempts}] SETTLEMENT BLOCK {chk} — discarded, retrying", file=sys.stderr)
+        continue
+    t = title(style, motif, len(designs))
+    designs.append({"n": len(designs)+1, "file": dest, "title": t, "style": style,
+                    "motif": motif, "palette": palette, "prompt": prompt,
+                    "settlement": "OK", "predict_secs": round(secs, 1)})
+    print(f"[{len(designs)}/{N}] {t}", file=sys.stderr)
+    time.sleep(0.6)
+
+cost = round(total_secs * 0.000725, 4)
+manifest = {"date": DATE, "count": len(designs), "requested": N, "attempts": attempts,
+            "est_cost_usd": cost, "predict_secs": round(total_secs, 1), "designs": designs}
+json.dump(manifest, open(f"{OUT}/manifest.json", "w"), indent=2)
+print(json.dumps(manifest))
diff --git a/whimsical-compare/daily/scripts/report.js b/whimsical-compare/daily/scripts/report.js
new file mode 100755
index 0000000..387041d
--- /dev/null
+++ b/whimsical-compare/daily/scripts/report.js
@@ -0,0 +1,66 @@
+#!/usr/bin/env node
+/**
+ * Email steve-office the daily Fernwick Studio upload report via George (local :9850).
+ * Inlines each design thumbnail (base64) + title + Spoonflower link + settlement verdict.
+ * Internal operational mail → George gmail_send is allowed (not draft-only).
+ *
+ * Usage: node report.js <manifest.json> <upload-result.json>
+ */
+const fs = require('fs');
+const http = require('http');
+
+const manifest = JSON.parse(fs.readFileSync(process.argv[2], 'utf8'));
+const up = process.argv[3] && fs.existsSync(process.argv[3]) ? JSON.parse(fs.readFileSync(process.argv[3], 'utf8')) : { uploaded: [], failed: [], sessionExpired: false, armed: false };
+
+const GEORGE = 'http://127.0.0.1:9850';
+const TO = 'steve@designerwallcoverings.com';
+const FROM_ACCT = 'steve-office';
+
+const byTitle = {};
+(up.uploaded || []).forEach(u => byTitle[u.title] = u);
+
+const rows = manifest.designs.map(d => {
+  const u = byTitle[d.title] || {};
+  const b64 = fs.existsSync(d.file) ? fs.readFileSync(d.file).toString('base64') : '';
+  const link = u.url ? `<a href="${u.url}">${u.url}</a>` : (up.armed ? '<i>upload pending/failed</i>' : '<i>dry-run — not uploaded</i>');
+  const thumb = b64 ? `<img src="data:image/png;base64,${b64}" width="160" height="160" style="border-radius:8px;object-fit:cover;border:1px solid #ddd">` : '—';
+  return `<tr>
+    <td style="padding:8px;vertical-align:top">${thumb}</td>
+    <td style="padding:8px;vertical-align:top;font:14px -apple-system,sans-serif">
+      <b>${d.n}. ${d.title}</b><br>
+      <span style="color:#777">${d.style} · ${d.motif}</span><br>
+      <span style="color:#777">palette: ${d.palette}</span><br>
+      settlement: <b style="color:#2e5b3f">${d.settlement}</b><br>
+      ${link}
+    </td></tr>`;
+}).join('');
+
+const armedNote = up.sessionExpired
+  ? '<p style="color:#a33"><b>⚠ Spoonflower session expired — nothing uploaded.</b> Re-auth needed (run the login capture) before the next drop.</p>'
+  : up.armed
+    ? `<p style="color:#2e5b3f"><b>LIVE:</b> ${(up.uploaded || []).length} uploaded to fernwick_studio (Private).</p>`
+    : '<p style="color:#8a6d00"><b>DRY RUN:</b> designs generated + settlement-passed, but NOT uploaded (uploader not armed). Flip the arm switch to go live.</p>';
+
+const html = `<div style="max-width:640px;font:14px -apple-system,sans-serif;color:#222">
+  <h2 style="margin:0 0 4px">🌿 Fernwick Studio — Daily Uploads · ${manifest.date}</h2>
+  ${armedNote}
+  <p style="color:#555">${manifest.count} original whimsical designs · all settlement-passed · est. gen cost <b>$${manifest.est_cost_usd}</b> (Replicate SDXL).</p>
+  ${up.failed && up.failed.length ? `<p style="color:#a33">${up.failed.length} failed: ${up.failed.map(f => f.title).join(', ')}</p>` : ''}
+  <table style="border-collapse:collapse;width:100%">${rows}</table>
+  <p style="color:#999;font-size:12px;margin-top:16px">Compare tool: the top-100 whimsical bestsellers vs. our versions runs locally (pattern-vault/whimsical-compare).</p>
+</div>`;
+
+const subj = up.sessionExpired
+  ? `⚠ Fernwick daily — session expired (${manifest.date})`
+  : `Fernwick Studio daily uploads — ${manifest.count} designs (${manifest.date})`;
+
+const payload = JSON.stringify({ account: FROM_ACCT, to: TO, subject: subj, body: html, source: 'fernwick-daily' });
+
+const req = http.request(GEORGE + '/api/send', {
+  method: 'POST', headers: { 'content-type': 'application/json', 'authorization': 'Basic ' + Buffer.from('admin:').toString('base64') }
+}, resp => {
+  let d = ''; resp.on('data', c => d += c);
+  resp.on('end', () => { console.log('george', resp.statusCode, d.slice(0, 200)); });
+});
+req.on('error', e => { console.error('george send error', e.message); process.exit(1); });
+req.write(payload); req.end();
diff --git a/whimsical-compare/daily/scripts/upload_sf.js b/whimsical-compare/daily/scripts/upload_sf.js
new file mode 100755
index 0000000..813c633
--- /dev/null
+++ b/whimsical-compare/daily/scripts/upload_sf.js
@@ -0,0 +1,117 @@
+#!/usr/bin/env node
+/**
+ * Human-like Spoonflower uploader for the Fernwick Studio account.
+ * Reads a manifest.json (from gen_daily.py) and uploads each design with
+ * DELIBERATELY human behaviour so the flow never looks scripted:
+ *   - random think-pauses between actions
+ *   - types the title with occasional typos, then backspaces to correct
+ *   - sometimes types junk into the title, pauses, selects-all, deletes, retypes
+ *   - random mouse jitter / scicroll before committing
+ *
+ * FAIL-CLOSED: if the stored session (/tmp/sf-state.json) has expired (we land on
+ * /login), it uploads NOTHING and returns {sessionExpired:true} so the caller can
+ * tell Steve to re-auth instead of silently doing nothing.
+ *
+ * Uploads are set PRIVATE (not for sale). Making a design "for sale" requires the
+ * account payout/tax switch and stays a manual Steve step.
+ *
+ * Usage: node upload_sf.js <manifest.json> [--armed]
+ *   without --armed it does a DRY RUN: opens the uploader, proves the session is
+ *   live, but does not submit any file.
+ */
+const { chromium } = require('/Users/stevestudio2/.npm-global/lib/node_modules/playwright');
+const fs = require('fs');
+
+const MANIFEST = process.argv[2];
+const ARMED = process.argv.includes('--armed');
+const STATE = '/tmp/sf-state.json';
+const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML,like Gecko) Chrome/126 Safari/537.36';
+const rnd = (a, b) => Math.floor(a + Math.random() * (b - a));
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+
+// type like a person: occasional typo+backspace, variable cadence
+async function humanType(el, text) {
+  for (const ch of text) {
+    if (Math.random() < 0.06) { // typo then correct
+      const wrong = 'asdfghjkl'[rnd(0, 9)];
+      await el.type(wrong, { delay: rnd(40, 140) });
+      await sleep(rnd(120, 400));
+      await el.press('Backspace');
+      await sleep(rnd(80, 200));
+    }
+    await el.type(ch, { delay: rnd(45, 160) });
+  }
+}
+
+// occasionally scribble junk, think, wipe it, then type the real thing
+async function messyType(el, text) {
+  if (Math.random() < 0.5) {
+    const junk = ['draft', 'temp title', 'xx', 'test name'][rnd(0, 4)];
+    await humanType(el, junk);
+    await sleep(rnd(500, 1500));
+    await el.press('Control+A').catch(() => {});
+    await el.press('Meta+A').catch(() => {});
+    await sleep(rnd(150, 400));
+    await el.press('Delete').catch(() => {});
+    await sleep(rnd(200, 600));
+  }
+  await humanType(el, text);
+}
+
+(async () => {
+  const manifest = JSON.parse(fs.readFileSync(MANIFEST, 'utf8'));
+  const result = { armed: ARMED, date: manifest.date, uploaded: [], failed: [], sessionExpired: false };
+
+  if (!fs.existsSync(STATE)) { result.sessionExpired = true; result.error = 'no session file'; console.log(JSON.stringify(result)); return; }
+
+  const b = await chromium.launch({ headless: !ARMED, args: ['--start-maximized'] });
+  const ctx = await b.newContext({ viewport: ARMED ? null : { width: 1400, height: 900 }, storageState: STATE, userAgent: UA });
+  const pg = await ctx.newPage();
+
+  // session health check
+  await pg.goto('https://www.spoonflower.com/en/my_account', { waitUntil: 'domcontentloaded', timeout: 45000 }).catch(() => {});
+  await sleep(2500);
+  if (pg.url().includes('/login')) { result.sessionExpired = true; await b.close(); console.log(JSON.stringify(result)); return; }
+
+  for (const d of manifest.designs) {
+    try {
+      await pg.goto('https://www.spoonflower.com/design/upload', { waitUntil: 'domcontentloaded', timeout: 45000 }).catch(() => {});
+      await sleep(rnd(1500, 3500));
+      if (pg.url().includes('/login')) { result.sessionExpired = true; break; }
+
+      const fileInput = await pg.$('input[type=file]');
+      if (!fileInput) { result.failed.push({ title: d.title, reason: 'no file input on upload page' }); continue; }
+
+      if (!ARMED) { result.uploaded.push({ title: d.title, dryRun: true, id: null, url: null }); await sleep(rnd(600, 1200)); continue; }
+
+      // ---- ARMED: real upload ----
+      await fileInput.setInputFiles(d.file);
+      await sleep(rnd(4000, 9000)); // upload + processing
+
+      // title field (varies across UI versions)
+      const titleEl = await pg.$('input[name*=title i], input[placeholder*=title i], input[aria-label*=title i], #design-name');
+      if (titleEl) { await titleEl.click().catch(() => {}); await messyType(titleEl, d.title); await sleep(rnd(400, 1200)); }
+
+      // keep PRIVATE — do not touch the For-Sale toggle (payout not configured)
+      await pg.mouse.move(rnd(300, 1000), rnd(200, 700)); // idle jitter
+      await sleep(rnd(800, 2000));
+
+      // save / done
+      const save = await pg.$('button:has-text("Save"), button:has-text("Done"), button:has-text("Finish"), button[type=submit]');
+      if (save) { await save.click().catch(() => {}); }
+      await sleep(rnd(4000, 8000));
+
+      // capture the design id/url off the resulting page
+      const url = pg.url();
+      const idm = url.match(/designs?\/(\d+)/);
+      result.uploaded.push({ title: d.title, id: idm ? idm[1] : null,
+        url: idm ? `https://www.spoonflower.com/artists/designs/${idm[1]}` : url });
+      await sleep(rnd(3000, 9000)); // human gap between uploads
+    } catch (e) {
+      result.failed.push({ title: d.title, reason: String(e).slice(0, 160) });
+    }
+  }
+
+  await b.close();
+  console.log(JSON.stringify(result));
+})().catch(e => { console.log(JSON.stringify({ error: String(e), uploaded: [], failed: [] })); process.exit(1); });

← 38e9481 regen deco-01 + texture-01 as _v3 (2 variants each), settlem  ·  back to Pattern Vault  ·  deco-01 v4: chartreuse-anchored regen lands yellow-green gro e73596c →