← 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
M public/widget.jsM server.js
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 →