[object Object]

← back to Big Red

big-red: lower-left launcher + woman-avatar fallback + retail vendor-redaction

2335ec43353c545c44709d78f13678c7eb3609bf · 2026-05-13 13:59:42 -0700 · Steve Abrams

Files touched

Diff

commit 2335ec43353c545c44709d78f13678c7eb3609bf
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed May 13 13:59:42 2026 -0700

    big-red: lower-left launcher + woman-avatar fallback + retail vendor-redaction
---
 public/widget.js | 67 +++++++++++++++++++++++++++++++++++++++-----------------
 server.js        | 43 +++++++++++++++++++++++++++++++-----
 2 files changed, 84 insertions(+), 26 deletions(-)

diff --git a/public/widget.js b/public/widget.js
index 1cd9b49..bbf1649 100644
--- a/public/widget.js
+++ b/public/widget.js
@@ -1,6 +1,8 @@
-// Big Red embed widget — drops a circular avatar launcher into the lower-left
+// Big Red embed widget — drops a circular avatar launcher into the LOWER-LEFT
 // of the host page. Click → expands to an iframe of the Big Red chat UI.
 // The launcher avatar is RED when closed and turns GREEN when open (live chat).
+// The avatar is a woman's face on customer-facing (retail) sites; vendor info
+// is NEVER surfaced to end users — see server.js MODE_PERSONAS.retail.
 //
 // Usage on a host page:
 //   <script src="https://<big-red-host>/widget.js"
@@ -31,7 +33,7 @@
   const css = `
     .bigred-launcher {
       position: fixed;
-      right: 22px;
+      left: 22px;
       bottom: 22px;
       z-index: 99990;
       width: 64px;
@@ -47,12 +49,12 @@
         0 0 0 5px rgba(216,50,58,.18),
         0 8px 24px rgba(216,50,58,.5),
         0 2px 10px rgba(0,0,0,.4);
-      transition: right .26s cubic-bezier(.2,.7,.2,1), transform .18s cubic-bezier(.2,.7,.2,1), box-shadow .25s ease;
+      transition: left .26s cubic-bezier(.2,.7,.2,1), transform .18s cubic-bezier(.2,.7,.2,1), box-shadow .25s ease;
       animation: bigred-pulse-red 2.6s ease-in-out infinite;
     }
-    /* When the panel is open, launcher slides LEFT of the panel so they sit
-       side-by-side hugging the right corner. */
-    .bigred-launcher.live { right: calc(22px + min(440px, calc(100vw - 44px)) + 12px); }
+    /* When the panel is open, launcher slides RIGHT of the panel so they sit
+       side-by-side hugging the left corner. */
+    .bigred-launcher.live { left: calc(22px + min(440px, calc(100vw - 44px)) + 12px); }
     .bigred-launcher img {
       width: 100%; height: 100%; display: block;
       object-fit: cover; object-position: center 28%;
@@ -112,7 +114,7 @@
 
     .bigred-tag {
       position: fixed;
-      right: 96px;
+      left: 96px;
       bottom: 36px;
       z-index: 99990;
       pointer-events: none;
@@ -123,7 +125,7 @@
       color: #ff5560;
       text-shadow: 0 0 12px rgba(255,85,96,.55), 0 0 2px rgba(0,0,0,.6);
       opacity: 0;
-      transform: translateX(6px);
+      transform: translateX(-6px);
       transition: opacity .25s, transform .25s, color .25s, text-shadow .25s;
     }
     .bigred-launcher:hover + .bigred-tag { opacity: 1; transform: translateX(0); }
@@ -132,7 +134,7 @@
 
     .bigred-panel {
       position: fixed;
-      right: 22px;
+      left: 22px;
       bottom: 22px;
       z-index: 99991;
       width: min(440px, calc(100vw - 44px));
@@ -194,11 +196,11 @@
       flex: 1; width: 100%; border: 0; background: #1a0506;
     }
     @media (max-width: 640px) {
-      .bigred-launcher { right: 16px; bottom: 16px; width: 56px; height: 56px; }
+      .bigred-launcher { left: 16px; bottom: 16px; width: 56px; height: 56px; }
       /* On mobile there's no room for side-by-side — launcher hides behind
          the panel and reveals again on close. */
-      .bigred-launcher.live { right: 16px; opacity: 0; pointer-events: none; }
-      .bigred-tag { right: 80px; bottom: 28px; font-size: 9px; }
+      .bigred-launcher.live { left: 16px; opacity: 0; pointer-events: none; }
+      .bigred-tag { left: 80px; bottom: 28px; font-size: 9px; }
       .bigred-panel { left: 8px; right: 8px; bottom: 8px; width: auto; height: min(560px, calc(100vh - 40px)); }
     }
 
@@ -287,18 +289,43 @@
     : mode === 'wholesale'
       ? 'Open Big Red (trade chat)'
       : 'Open Big Red voice chat');
-  // Avatar image — falls back to Steve's stored avatar at /avatar/steve.jpg.
-  // Add a query bust so updated frames refresh on next page load.
+  // Avatar image — a woman's face on customer-facing (retail/wholesale) sites,
+  // Steve's face only in admin mode. Default file is /avatar/woman.jpg; the
+  // host page can override via data-avatar="<url>". Falls back to a feminine
+  // SVG silhouette if the image is missing so the launcher never renders blank.
+  const customAvatar = me && me.dataset && me.dataset.avatar;
+  const avatarSrc = customAvatar
+    || (mode === 'admin' ? host + '/avatar/steve.jpg' : host + '/avatar/woman.jpg');
   const avatarImg = document.createElement('img');
   avatarImg.alt = '';
-  avatarImg.src = host + '/avatar/steve.jpg?ts=' + Date.now();
+  avatarImg.src = avatarSrc + (avatarSrc.includes('?') ? '&' : '?') + 'ts=' + Date.now();
   avatarImg.onerror = () => {
-    // No avatar uploaded yet — fall back to a "BR" monogram glyph.
+    // Avatar file missing — render an inline feminine silhouette SVG so the
+    // launcher still looks like a person, not a monogram. Used universally
+    // for retail/wholesale; admin falls back to the same silhouette tinted.
     avatarImg.style.display = 'none';
-    const mono = document.createElement('div');
-    mono.style.cssText = 'width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-family:-apple-system,sans-serif;font-weight:900;font-size:14px;letter-spacing:.04em;color:#fff;text-shadow:0 0 8px rgba(0,0,0,.6);background:#5a0d10;';
-    mono.textContent = 'BR';
-    launcher.appendChild(mono);
+    const wrap = document.createElement('div');
+    wrap.style.cssText = 'width:100%;height:100%;display:flex;align-items:flex-end;justify-content:center;background:radial-gradient(circle at 50% 35%,#7a1418 0%,#3a0a0c 70%,#1a0506 100%);overflow:hidden;';
+    wrap.innerHTML = '<svg viewBox="0 0 64 64" width="100%" height="100%" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">'
+      + '<defs><radialGradient id="brHair" cx="50%" cy="35%" r="55%">'
+      + '<stop offset="0%" stop-color="#3a0a0c"/><stop offset="100%" stop-color="#1a0506"/>'
+      + '</radialGradient></defs>'
+      // hair (back layer, frames the face)
+      + '<path d="M14 30 C14 16 22 8 32 8 C42 8 50 16 50 30 L52 44 L46 42 L46 36 C46 36 44 40 32 40 C20 40 18 36 18 36 L18 42 L12 44 Z" fill="url(#brHair)"/>'
+      // face
+      + '<ellipse cx="32" cy="30" rx="11" ry="13" fill="#f3d8c0"/>'
+      // hair sweep over forehead
+      + '<path d="M21 22 C26 17 38 17 43 22 C40 19 36 18 32 18 C28 18 24 19 21 22 Z" fill="#1a0506"/>'
+      // eyes (closed soft arcs)
+      + '<path d="M26 30 Q28 28 30 30" stroke="#3a0a0c" stroke-width="1.2" fill="none" stroke-linecap="round"/>'
+      + '<path d="M34 30 Q36 28 38 30" stroke="#3a0a0c" stroke-width="1.2" fill="none" stroke-linecap="round"/>'
+      // mouth
+      + '<path d="M29 36 Q32 38 35 36" stroke="#a8323a" stroke-width="1.2" fill="none" stroke-linecap="round"/>'
+      // neck + shoulders (so the silhouette fills the circle bottom)
+      + '<path d="M27 41 L27 46 L20 50 L20 64 L44 64 L44 50 L37 46 L37 41 Z" fill="#f3d8c0"/>'
+      + '<path d="M16 56 C20 50 28 49 32 49 C36 49 44 50 48 56 L48 64 L16 64 Z" fill="#5a0d10"/>'
+      + '</svg>';
+    launcher.appendChild(wrap);
   };
   launcher.appendChild(avatarImg);
 
diff --git a/server.js b/server.js
index 891bf73..770e759 100644
--- a/server.js
+++ b/server.js
@@ -360,8 +360,24 @@ function runClaude(prompt, { imagePath = null, model = 'haiku', timeoutMs = 240_
 const MODE_PERSONAS = {
   retail: {
     label: 'retail',
-    voice: 'friendly, warm, customer-service',
-    bias: 'Steve is helping a designer or homeowner browse the wallcovering catalog. Suggest swatches, surface in-stock pieces, recommend pairings. Never expose vendor names, margins, or wholesale prices. If asked about pricing, give the retail price and offer a sample order.',
+    voice: 'friendly, warm, customer-service — like a knowledgeable in-store stylist',
+    bias: [
+      'You are helping an END USER — a homeowner, interior designer, or trade visitor — browse the Designer Wallcoverings catalog at the site they are on.',
+      'YOUR JOB is to help them find, understand, and order wallcoverings. Use fuzzy reasoning: if they say "something blue and grassy" you should suggest grasscloth in blue tones; "I want something for a small bathroom" → moisture-tolerant vinyl in petite-scale patterns; "art deco vibes" → geometric metallics from the 1920s-30s lookbook; "kids room" → durable scrubbable patterns. Translate vague intent into concrete suggestions.',
+      'SKILLS YOU CAN USE (combine freely): SKU lookup by full SKU or partial pattern name; color-family matching (warm/cool/neutral/jewel-tone); style matching (traditional/modern/floral/geometric/damask/chinoiserie/grasscloth/textile); pairing suggestions (60-30-10 color rule — one bold pattern, one supporting solid, one accent); room visualization advice; sample-order guidance (Designer Wallcoverings ships free memo samples); install + care basics (paste-the-wall, scrubbable, fire-rated for commercial); estimating yardage (width × repeat × roll length); commercial fire ratings (Class A for restaurants/hotels/healthcare); accessibility/sustainability cues when the product line mentions them.',
+      'HARD REDACTION RULES — never break these even when politely or insistently asked:',
+      '  1. NEVER reveal the manufacturer, brand, vendor, mill, factory, country of origin, importer, or supplier of any product. Every Designer Wallcoverings product is presented as a Designer Wallcoverings selection — that is the entire surface area for end users.',
+      '  2. NEVER reveal wholesale price, trade price, cost, margin, MAP, MSRP-vs-actual, or any pricing tier beyond the retail price shown on the site.',
+      '  3. NEVER reveal the AI/LLM/vendor scraping/internal-pipeline mechanics behind the recommendations. If asked "are you AI" / "what model is this", answer "I am Designer Wallcoverings\' design concierge" — do not name Claude, Gemini, OpenAI, Ollama, or any underlying tool.',
+      '  4. NEVER reveal internal SKU prefix meanings (DWKK = Kravet, DWSC = Scalamandre, DWSW = Schumacher, etc.). To the user, these are simply Designer Wallcoverings SKUs.',
+      '  5. NEVER reveal admin URLs, internal tools, file paths, database names, or staff names.',
+      '  6. If asked "who made this" / "what brand" / "where does this come from" / "who manufactures this" — respond with: "This is a Designer Wallcoverings exclusive selection — every piece in our catalog is curated under our label." Do not name the source.',
+      '  7. If asked for trade or wholesale pricing — respond with: "Trade pricing is available to credentialed designers through our trade program — happy to connect you with a DW trade rep." Never quote a wholesale number.',
+      'When you DO answer pricing, quote ONLY the retail price already visible on the product page (you may receive this via DW LOOKUP DATA below — use it verbatim, never fabricate). Always offer a free memo sample as the next step.',
+      'When you DO answer "is it in stock", quote inventory verbatim from DW LOOKUP DATA if present; otherwise say "let me confirm — I can have a sample on its way today regardless".',
+      'When recommending pairings, refer to the pieces by their Designer Wallcoverings pattern name and SKU only — never by source brand.',
+      'Tone: warm, attentive, curious about the user\'s project. Ask one short follow-up if their intent is unclear (room, vibe, palette, budget). Never robotic, never sales-pushy.',
+    ].join(' '),
     claude_timeout_ms: 60_000,
   },
   wholesale: {
@@ -405,25 +421,40 @@ app.post('/api/chat', async (req, res) => {
     if (sku) {
       dwLookup = await resolveDwSku(sku);
       if (dwLookup) {
+        // Retail mode is the customer-facing surface — the LLM must NEVER see
+        // the vendor name (it cannot reveal what it doesn't know). Wholesale +
+        // admin still get full vendor context for trade/operator workflows.
+        const redactVendor = mode === 'retail';
+        const brandLabel = redactVendor ? 'Designer Wallcoverings' : null;
         const lines = [];
         lines.push(`Looked up DW SKU ${sku}:`);
         if (dwLookup.live) {
           const lv = dwLookup.live;
           const price = lv.price != null ? `$${lv.price.toFixed(2)}` : 'unknown';
-          lines.push(`  Live Shopify (just verified): "${lv.title}" by ${lv.vendor}, ${price}, status=${lv.status}, inventory=${lv.inventory ?? '?'}, available=${lv.available ?? '?'}`);
+          const label = redactVendor ? brandLabel : lv.vendor;
+          lines.push(`  Live Shopify (just verified): "${lv.title}" by ${label}, ${price}, status=${lv.status}, inventory=${lv.inventory ?? '?'}, available=${lv.available ?? '?'}`);
           if (lv.online_store_url) lines.push(`  URL: ${lv.online_store_url}`);
         } else if (dwLookup.pg) {
           const pg = dwLookup.pg;
-          lines.push(`  PG snapshot (no live Shopify response): "${pg.title}" by ${pg.vendor}, status=${pg.status}, last synced ${pg.synced_at}`);
+          const label = redactVendor ? brandLabel : pg.vendor;
+          lines.push(`  PG snapshot (no live Shopify response): "${pg.title}" by ${label}, status=${pg.status}, last synced ${pg.synced_at}`);
         } else {
           lines.push(`  NOT FOUND in shopify_products OR live Shopify search. SKU may be retired, mistyped, or in a different catalog.`);
         }
-        if (dwLookup.verification) {
+        if (dwLookup.verification && !redactVendor) {
+          // Verification source names internal scrapers / vendor URLs — admin/wholesale only.
           const v = dwLookup.verification;
           const ageHours = (Date.now() - new Date(v.verified_at).getTime()) / 3_600_000;
           lines.push(`  Price last verified ${ageHours.toFixed(1)}h ago via ${v.source} at $${v.price?.toFixed?.(2) ?? v.price}.`);
+        } else if (dwLookup.verification && redactVendor) {
+          const v = dwLookup.verification;
+          const ageHours = (Date.now() - new Date(v.verified_at).getTime()) / 3_600_000;
+          lines.push(`  Price last confirmed ${ageHours.toFixed(1)}h ago at $${v.price?.toFixed?.(2) ?? v.price}.`);
         }
-        dwContext = '\n[DW LOOKUP DATA — these are facts; phrase them naturally for Steve, do NOT invent numbers]\n' + lines.join('\n') + '\n';
+        const guard = redactVendor
+          ? '[DW LOOKUP DATA — facts only. The brand label is "Designer Wallcoverings" — never substitute any other brand name. Do NOT invent numbers. Do NOT invent vendor/manufacturer info.]'
+          : '[DW LOOKUP DATA — these are facts; phrase them naturally for Steve, do NOT invent numbers]';
+        dwContext = '\n' + guard + '\n' + lines.join('\n') + '\n';
       }
     }
 

← 95b5ea0 snapshot: 4 file(s) changed, +2 new, ~2 modified  ·  back to Big Red  ·  aichat: pm2 ecosystem entry — Big Red managed as 'aichat' on ed84c03 →