[object Object]

← back to Wallco Ai

card-room hover: drop chip inset, show entire room setting on hover

b14db232d67013fbf20acaf01bd19c98fff104fd · 2026-05-28 13:03:01 -0700 · Steve Abrams

Steve directive 2026-05-28 round 2: 'onhover should show the entire room
setting/not just half'.

The earlier 'second position chip' directive (server.js:6300) had positioned
.card-room as a small bottom-right inset (right:8px; bottom:8px; width:42%;
aspect-ratio:1) — that's the half-card crop Steve was seeing on hover at
wallco.ai/designs.

Replaced the chip rule with full-card overlay (inset:0; width:100%;
height:100%; object-fit:cover). Card source is 1:1, room source is 1:1, so
cover produces no cropping (same-aspect → exact fill). Badge re-positioned
to a small bottom-right pill that doesn't obscure the room.

Shipped to prod (45.61.58.125:/root/public-projects/wallco-ai/server.js) via
surgical patch — pull-prod-copy + python find-replace + node -c +
scp-over-with-backup + pm2 reload wallco-ai. Honors the
feedback_wallco_ai_mac2_prod_serverjs_drift rule (no full server.js
rsync, surgical-port only).

Verified: prod /designs hover shows full room (wallpaper, sofa, console, TV,
lamp); /api/designs + homepage hero still 200.

Files touched

Diff

commit b14db232d67013fbf20acaf01bd19c98fff104fd
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu May 28 13:03:01 2026 -0700

    card-room hover: drop chip inset, show entire room setting on hover
    
    Steve directive 2026-05-28 round 2: 'onhover should show the entire room
    setting/not just half'.
    
    The earlier 'second position chip' directive (server.js:6300) had positioned
    .card-room as a small bottom-right inset (right:8px; bottom:8px; width:42%;
    aspect-ratio:1) — that's the half-card crop Steve was seeing on hover at
    wallco.ai/designs.
    
    Replaced the chip rule with full-card overlay (inset:0; width:100%;
    height:100%; object-fit:cover). Card source is 1:1, room source is 1:1, so
    cover produces no cropping (same-aspect → exact fill). Badge re-positioned
    to a small bottom-right pill that doesn't obscure the room.
    
    Shipped to prod (45.61.58.125:/root/public-projects/wallco-ai/server.js) via
    surgical patch — pull-prod-copy + python find-replace + node -c +
    scp-over-with-backup + pm2 reload wallco-ai. Honors the
    feedback_wallco_ai_mac2_prod_serverjs_drift rule (no full server.js
    rsync, surgical-port only).
    
    Verified: prod /designs hover shows full room (wallpaper, sofa, console, TV,
    lamp); /api/designs + homepage hero still 200.
---
 scripts/mass-push-etsy.js | 247 ++++++++++++++++++++++++++++++++++++++++++++++
 server.js                 |  22 ++---
 2 files changed, 257 insertions(+), 12 deletions(-)

diff --git a/scripts/mass-push-etsy.js b/scripts/mass-push-etsy.js
new file mode 100644
index 0000000..8dcbf0e
--- /dev/null
+++ b/scripts/mass-push-etsy.js
@@ -0,0 +1,247 @@
+#!/usr/bin/env node
+/**
+ * mass-push-etsy — drain wallco_etsy_bucket → Etsy draft listings in one
+ * command. Wraps the two existing scripts:
+ *
+ *   1. scripts/generate-etsy-bundles.js  — groups state='queued' bucket
+ *      items into themed bundles (default --by color, target 20/bundle) and
+ *      writes data/etsy-exports/<date>/bundles/<slug>/listing.json for each.
+ *
+ *   2. scripts/push-etsy-bundle.js --slug <slug> — for each bundle, builds
+ *      assets just-in-time, POSTs the draft listing + images + ZIP via Etsy
+ *      v3 API, updates wallco_etsy_bucket.state='listed' + etsy_listing_id,
+ *      cleans up local assets. Already handles OAuth auto-refresh on 401.
+ *
+ * Between pushes we sleep --rate-limit-sec (default 5) so Etsy's quotas
+ * (10 req/s app-wide, plus per-shop write throttling) get headroom. Per-
+ * bundle failure is captured and the run continues; 3 consecutive failures
+ * abort the run (something's structurally wrong — auth, network, quota).
+ *
+ * Usage:
+ *   node scripts/mass-push-etsy.js                            # full drain — bucket → drafts
+ *   node scripts/mass-push-etsy.js --dry-run                  # generate bundles + show payloads, don't upload
+ *   node scripts/mass-push-etsy.js --by category              # group by base category instead of color
+ *   node scripts/mass-push-etsy.js --skip-bundle-gen          # bundles already generated; just push them
+ *   node scripts/mass-push-etsy.js --max-bundles 5            # safety cap (default 0 = no cap)
+ *   node scripts/mass-push-etsy.js --rate-limit-sec 3         # tighter pacing (still well under Etsy's 10 req/s)
+ *   node scripts/mass-push-etsy.js --state active             # publish live; default 'draft' for you to review first
+ *   node scripts/mass-push-etsy.js --resume                   # skip bundles whose state is already 'listed'
+ *
+ * Exit codes:
+ *   0   one or more bundles pushed successfully (partial-success allowed)
+ *   1   bundle-gen step failed
+ *   2   no bundles to push
+ *   3   3-consecutive-failure circuit breaker tripped
+ *   4   missing Etsy credentials (when not --dry-run)
+ */
+const { execSync, spawn, spawnSync } = require('child_process');
+const fs = require('fs');
+const path = require('path');
+const os = require('os');
+
+const argv = process.argv.slice(2);
+const ARG = (k, d) => { const i = argv.indexOf(k); return i >= 0 ? argv[i + 1] : d; };
+const HAS = k => argv.includes(k);
+const opt = {
+  date:           ARG('--date', new Date().toISOString().slice(0, 10)),
+  by:             ARG('--by', 'color'),
+  targetSize:     parseInt(ARG('--target-size', '20'), 10),
+  rateLimitSec:   parseInt(ARG('--rate-limit-sec', '5'), 10),
+  maxBundles:     parseInt(ARG('--max-bundles', '0'), 10),
+  state:          ARG('--state', 'draft'),
+  skipBundleGen:  HAS('--skip-bundle-gen'),
+  dryRun:         HAS('--dry-run'),
+  resume:         HAS('--resume'),
+  noCleanup:      HAS('--no-cleanup'),
+};
+
+const HERE = __dirname;
+const OUT_DIR = path.join(HERE, '..', 'data', 'etsy-exports', opt.date);
+const BUNDLES_DIR = path.join(OUT_DIR, 'bundles');
+
+// ── creds preflight (only if we're actually going to upload) ──────────────
+function loadSecretsEnv() {
+  const p = path.join(os.homedir(), 'Projects', 'secrets-manager', '.env');
+  if (!fs.existsSync(p)) return;
+  for (const line of fs.readFileSync(p, 'utf8').split('\n')) {
+    const m = line.match(/^\s*([A-Z_][A-Z0-9_]*)\s*=\s*(.+?)\s*$/);
+    if (m && !process.env[m[1]]) process.env[m[1]] = m[2].replace(/^['"]|['"]$/g, '');
+  }
+}
+loadSecretsEnv();
+function requireCredsOrFail() {
+  const missing = ['ETSY_CLIENT_ID', 'ETSY_OAUTH_ACCESS_TOKEN', 'ETSY_SHOP_ID']
+    .filter(k => !process.env[k]);
+  if (missing.length) {
+    console.error('\n✗ Etsy credentials missing — required for live push:');
+    for (const k of missing) console.error(`     ${k}`);
+    console.error('\nRun first: node scripts/etsy-oauth-helper.js   (interactive auth)');
+    console.error('Or:        node scripts/mass-push-etsy.js --dry-run   (test without uploading)\n');
+    process.exit(4);
+  }
+}
+if (!opt.dryRun) requireCredsOrFail();
+
+// ── PG helpers ────────────────────────────────────────────────────────────
+const oneLine = s => s.replace(/\s+/g, ' ').trim();
+function psqlAt(sql) {
+  return execSync(`psql dw_unified -At -q -c ${JSON.stringify(oneLine(sql))}`,
+    { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 }).trim();
+}
+
+// ── pipeline ──────────────────────────────────────────────────────────────
+async function main() {
+  const t0 = Date.now();
+  console.log('\n══════════════════════════════════════════════════════');
+  console.log('  Mass-push Etsy — wallco_etsy_bucket → Etsy drafts');
+  console.log('══════════════════════════════════════════════════════');
+  console.log(`  date=${opt.date}  by=${opt.by}  target-size=${opt.targetSize}`);
+  console.log(`  rate-limit=${opt.rateLimitSec}s  max-bundles=${opt.maxBundles || 'all'}`);
+  console.log(`  state=${opt.state}  dry-run=${opt.dryRun}  resume=${opt.resume}`);
+  console.log('');
+
+  // Step 1: snapshot what's about to be drained
+  const queuedCount = parseInt(psqlAt(`SELECT count(*) FROM wallco_etsy_bucket WHERE state='queued';`), 10);
+  console.log(`Step 0 — Bucket inventory: ${queuedCount} items queued`);
+  if (!queuedCount && !opt.skipBundleGen) {
+    console.log('\n✗ No queued items in wallco_etsy_bucket — nothing to push.');
+    console.log('  Add items via the curator 🛒 Etsy buttons first.');
+    process.exit(2);
+  }
+
+  // Step 2: generate bundles (unless --skip-bundle-gen)
+  if (!opt.skipBundleGen) {
+    console.log(`\nStep 1 — Group ${queuedCount} bucket items into bundles (--by ${opt.by})`);
+    const genArgs = [
+      path.join(HERE, 'generate-etsy-bundles.js'),
+      '--from-bucket',
+      '--by', opt.by,
+      '--target-size', String(opt.targetSize),
+      '--commit',
+    ];
+    const r = spawnSync('node', genArgs, { stdio: 'inherit' });
+    if (r.status !== 0) { console.error('  ✗ bundle-gen failed (exit ' + r.status + ')'); process.exit(1); }
+  } else {
+    console.log('\nStep 1 — SKIPPED (--skip-bundle-gen)');
+  }
+
+  // Step 3: enumerate bundles for this date
+  if (!fs.existsSync(BUNDLES_DIR)) {
+    console.error(`\n✗ No bundles directory at ${BUNDLES_DIR}`);
+    console.error('  Either no items were grouped, or use --date <YYYY-MM-DD> to point at an older run.');
+    process.exit(2);
+  }
+  const slugs = fs.readdirSync(BUNDLES_DIR).filter(f => {
+    try { return fs.statSync(path.join(BUNDLES_DIR, f)).isDirectory() &&
+                  fs.existsSync(path.join(BUNDLES_DIR, f, 'listing.json')); }
+    catch { return false; }
+  }).sort();
+
+  // --resume: drop slugs whose design_ids are already 'listed' in PG
+  let pushable = slugs;
+  if (opt.resume) {
+    const before = slugs.length;
+    pushable = slugs.filter(slug => {
+      try {
+        const listing = JSON.parse(fs.readFileSync(path.join(BUNDLES_DIR, slug, 'listing.json'), 'utf8'));
+        const ids = listing.design_ids || [];
+        if (!ids.length) return true;
+        const listed = parseInt(psqlAt(
+          `SELECT count(*) FROM wallco_etsy_bucket WHERE design_id IN (${ids.join(',')}) AND state='listed';`
+        ), 10);
+        // If MAJORITY already listed, skip this bundle (already pushed)
+        return listed < (ids.length * 0.5);
+      } catch { return true; }
+    });
+    console.log(`\nStep 2 — Found ${before} bundles, ${pushable.length} pushable (--resume skipped ${before - pushable.length} already-listed)`);
+  } else {
+    console.log(`\nStep 2 — Found ${pushable.length} bundle(s) under ${path.relative(process.cwd(), BUNDLES_DIR)}/`);
+  }
+  if (opt.maxBundles > 0 && pushable.length > opt.maxBundles) {
+    console.log(`  Capping at ${opt.maxBundles} (--max-bundles)`);
+    pushable = pushable.slice(0, opt.maxBundles);
+  }
+  if (!pushable.length) { console.log('\n(nothing to push)'); process.exit(2); }
+
+  // Step 4: push each bundle via push-etsy-bundle.js (sequential — respects rate limits)
+  console.log(`\nStep 3 — Push ${pushable.length} bundles (rate-limit ${opt.rateLimitSec}s between)`);
+  console.log('');
+  const results = [];
+  let consecutiveFails = 0;
+
+  for (let i = 0; i < pushable.length; i++) {
+    const slug = pushable[i];
+    console.log(`──── (${i + 1}/${pushable.length}) ${slug} ────`);
+    const pushArgs = [
+      path.join(HERE, 'push-etsy-bundle.js'),
+      '--slug', slug,
+      '--date', opt.date,
+      '--state', opt.state,
+    ];
+    if (opt.dryRun)   pushArgs.push('--dry-run');
+    if (opt.noCleanup) pushArgs.push('--no-cleanup');
+
+    const t_b = Date.now();
+    const r = spawnSync('node', pushArgs, { stdio: 'inherit', timeout: 600000 });
+    const dur_ms = Date.now() - t_b;
+
+    const result = { slug, exit_code: r.status, duration_ms: dur_ms, ts: new Date().toISOString() };
+    results.push(result);
+
+    if (r.status === 0) {
+      consecutiveFails = 0;
+      console.log(`     ✓ ${slug} pushed in ${(dur_ms / 1000).toFixed(1)}s`);
+    } else {
+      consecutiveFails++;
+      console.log(`     ✗ ${slug} failed (exit ${r.status}) after ${(dur_ms / 1000).toFixed(1)}s`);
+      if (consecutiveFails >= 3) {
+        console.error(`\n✗ Circuit breaker — 3 consecutive failures. Aborting.`);
+        console.error(`  Common causes: Etsy access token expired (run scripts/etsy-oauth-helper.js --refresh),`);
+        console.error(`  rate limit (raise --rate-limit-sec), or upstream API outage.`);
+        results.push({ aborted_at: i + 1, reason: '3-consecutive-failures' });
+        break;
+      }
+    }
+
+    // Rate-limit between bundles (skip the sleep after the last one)
+    if (i < pushable.length - 1) {
+      process.stdout.write(`     · sleeping ${opt.rateLimitSec}s … `);
+      await new Promise(r => setTimeout(r, opt.rateLimitSec * 1000));
+      console.log('');
+    }
+  }
+
+  // Step 5: manifest + summary
+  const manifest = {
+    ts_start: new Date(t0).toISOString(),
+    ts_end:   new Date().toISOString(),
+    duration_min: ((Date.now() - t0) / 60000).toFixed(1),
+    opts: opt,
+    bundles_attempted: results.length,
+    bundles_succeeded: results.filter(r => r.exit_code === 0).length,
+    bundles_failed:    results.filter(r => r.exit_code != null && r.exit_code !== 0).length,
+    aborted: results.some(r => r.aborted_at),
+    results,
+  };
+  const manPath = path.join(OUT_DIR, `mass-push-manifest-${Date.now()}.json`);
+  fs.writeFileSync(manPath, JSON.stringify(manifest, null, 2));
+
+  console.log('\n══════════════════════════════════════════════════════');
+  console.log('  Summary');
+  console.log('══════════════════════════════════════════════════════');
+  console.log(`  Total bundles attempted: ${manifest.bundles_attempted}`);
+  console.log(`  ✓ Pushed:                ${manifest.bundles_succeeded}`);
+  console.log(`  ✗ Failed:                ${manifest.bundles_failed}`);
+  if (manifest.aborted) console.log(`  ⚠ Aborted (circuit breaker)`);
+  console.log(`  Wall clock:              ${manifest.duration_min} min`);
+  console.log(`  Manifest:                ${path.relative(process.cwd(), manPath)}`);
+  if (!opt.dryRun && manifest.bundles_succeeded > 0) {
+    console.log('\n  Next: review drafts at https://www.etsy.com/your/shops/me/tools/listings/drafts');
+    console.log(`        Bucket state: SELECT count(*) FROM wallco_etsy_bucket WHERE state='listed';`);
+  }
+  console.log('');
+
+  process.exit(manifest.aborted ? 3 : 0);
+}
+
+main().catch(e => { console.error('fatal:', e.message); process.exit(1); });
diff --git a/server.js b/server.js
index 62951d3..c7a08a7 100644
--- a/server.js
+++ b/server.js
@@ -6297,22 +6297,20 @@ ${(req.query.source === 'all') ? `
       /* Second-position room-setting image — when a design has a photoreal
          room render, layer it as a corner thumbnail INSIDE the card so the
          visitor sees the pattern AND a room scene without leaving the grid.
-         Steve directive 2026-05-28: "in second position, show the room
-         setting image on chips". Bottom-right inset (~42% wide, square),
-         rounded with a subtle shadow so it reads as a stacked secondary chip
-         rather than a watermark. Grows slightly on hover. */
+         Steve directive 2026-05-28 round 2: "onhover should show the entire
+         room setting/not just half". Retired the inset-chip (42% bottom-right)
+         pattern — on hover, the room overlay now covers the full card so the
+         entire room scene is visible. Card source is 1:1, room source is 1:1,
+         so object-fit:cover here means no cropping (same-aspect → exact fill).
+         This selector is more specific than site.css .card-room so it wins;
+         left here as a documentation anchor + room-badge styling. */
       .design-card .card-img .card-room {
-        position:absolute; right:8px; bottom:8px; width:42%; aspect-ratio:1;
-        object-fit:cover; border-radius:6px; z-index:3;
-        box-shadow:0 4px 14px rgba(0,0,0,.32), 0 0 0 1px rgba(255,255,255,.5);
-        transition:transform .2s ease, box-shadow .2s ease;
+        position:absolute; inset:0; width:100%; height:100%;
+        object-fit:cover; z-index:3;
         pointer-events:none;       /* clicks fall through to parent <a> */
       }
-      .design-card:hover .card-room {
-        transform:scale(1.06); box-shadow:0 6px 22px rgba(0,0,0,.45), 0 0 0 1px rgba(255,255,255,.7);
-      }
       .design-card .card-img .card-room-badge {
-        position:absolute; right:14px; bottom:calc(42% + 14px); z-index:4;
+        position:absolute; right:10px; bottom:10px; z-index:4;
         padding:3px 8px; border-radius:999px;
         background:rgba(15,12,8,.78); color:#fff;
         font:700 9px/1 var(--sans,system-ui); letter-spacing:.10em; text-transform:uppercase;

← f55cad1 scripts: push-shopify-digital-bundle.js — Shopify counterpar  ·  back to Wallco Ai  ·  scripts/mass-push-etsy.js — single-command drain wallco_etsy 050d10b →