[object Object]

← back to Big Red

feat(big-red/tick0): chat-only /embed.html (panel iframe replacement)

bd46b8fa9c14852e94f8b7eb7d9bf941a8f2e19b · 2026-05-12 00:14:53 -0700 · Steve Abrams

Replaces the full Big Red desktop UI in the widget panel with a lean
chat-only view: small avatar + mode pill + context line + msg history
+ Web Speech mic + ElevenLabs TTS playback. Reads mode/app/page/title
from query string, propagates to /api/chat. Esc inside the embed
postMessages bigred:close → widget closes the panel.

Tick 1 (T+30m via ScheduleWakeup) — per-mode launcher ring color so
admin/wholesale modes are visually distinct from retail in the host page.

Files touched

Diff

commit bd46b8fa9c14852e94f8b7eb7d9bf941a8f2e19b
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue May 12 00:14:53 2026 -0700

    feat(big-red/tick0): chat-only /embed.html (panel iframe replacement)
    
    Replaces the full Big Red desktop UI in the widget panel with a lean
    chat-only view: small avatar + mode pill + context line + msg history
    + Web Speech mic + ElevenLabs TTS playback. Reads mode/app/page/title
    from query string, propagates to /api/chat. Esc inside the embed
    postMessages bigred:close → widget closes the panel.
    
    Tick 1 (T+30m via ScheduleWakeup) — per-mode launcher ring color so
    admin/wholesale modes are visually distinct from retail in the host page.
---
 public/embed.html | 260 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
 public/widget.js  |   6 +-
 2 files changed, 265 insertions(+), 1 deletion(-)

diff --git a/public/embed.html b/public/embed.html
new file mode 100644
index 0000000..4defdcc
--- /dev/null
+++ b/public/embed.html
@@ -0,0 +1,260 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Big Red — chat</title>
+<style>
+  :root {
+    --bg-1: #1a0506;
+    --bg-2: #2a0506;
+    --red:  #d8323a;
+    --red-glow: #ff5560;
+    --green: #58e070;
+    --amber: #f0a93a;
+    --blue:  #5fa8ff;
+    --paper: #f7efe6;
+    --ink-soft: #c8b8a8;
+    --ink-faint: #8a7a72;
+    --line: #44090b;
+  }
+  * { box-sizing: border-box; }
+  html, body { margin: 0; padding: 0; height: 100%; background: var(--bg-1); color: var(--paper); font: 14px/1.5 -apple-system, BlinkMacSystemFont, "SF Pro Text", "Inter", sans-serif; overflow: hidden; }
+  body {
+    background:
+      radial-gradient(circle at 25% 0%, #44090b 0%, transparent 55%),
+      radial-gradient(circle at 80% 100%, #2a0506 0%, transparent 60%),
+      linear-gradient(160deg, var(--bg-1), var(--bg-2));
+  }
+  .head {
+    flex: 0 0 auto;
+    padding: 14px 16px 12px;
+    display: flex; align-items: center; gap: 12px;
+    border-bottom: 1px solid var(--line);
+  }
+  .av-wrap {
+    position: relative; width: 48px; height: 48px; flex: 0 0 48px;
+  }
+  .av {
+    width: 48px; height: 48px; border-radius: 50%;
+    background: #2a0506 center/cover no-repeat;
+    border: 2px solid var(--red);
+    box-shadow: 0 0 18px rgba(255,85,96,.5);
+    filter: saturate(.85);
+    transition: border-color .25s, box-shadow .25s, filter .25s;
+  }
+  .live .av { border-color: var(--green); box-shadow: 0 0 22px rgba(88,224,112,.55); filter: saturate(1); }
+  .live[data-mode="admin"] .av { border-color: var(--amber); box-shadow: 0 0 22px rgba(240,169,58,.55); }
+  .live[data-mode="wholesale"] .av { border-color: var(--blue); box-shadow: 0 0 22px rgba(95,168,255,.55); }
+  .head-info { flex: 1; min-width: 0; }
+  .name { font-weight: 800; letter-spacing: 0.12em; color: var(--red-glow); font-size: 12px; text-shadow: 0 0 8px rgba(255,85,96,.45); }
+  .ctx { font-size: 11px; color: var(--ink-faint); margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+  .mode-pill {
+    display: inline-block; padding: 2px 8px; border-radius: 999px;
+    font-size: 9.5px; font-weight: 800; letter-spacing: 0.12em; text-transform: uppercase;
+    margin-left: 6px;
+  }
+  .mode-pill.retail { background: rgba(216,50,58,.25); color: var(--red-glow); }
+  .mode-pill.wholesale { background: rgba(95,168,255,.22); color: var(--blue); }
+  .mode-pill.admin { background: rgba(240,169,58,.22); color: var(--amber); }
+  .msgs {
+    flex: 1; overflow-y: auto; padding: 16px;
+    display: flex; flex-direction: column; gap: 10px;
+  }
+  .msg { font-size: 13.5px; line-height: 1.5; padding: 9px 13px; border-radius: 13px; max-width: 86%; white-space: pre-wrap; word-wrap: break-word; }
+  .msg.user { align-self: flex-end; background: var(--red); color: #1a0506; border-bottom-right-radius: 4px; }
+  .msg.assistant { align-self: flex-start; background: rgba(255,239,230,.06); color: var(--paper); border: 1px solid rgba(255,239,230,.08); border-bottom-left-radius: 4px; }
+  .msg.pending { opacity: 0.65; font-style: italic; }
+  .placeholder {
+    color: var(--ink-faint); font-size: 12px; text-align: center;
+    padding: 60px 24px;
+    line-height: 1.6;
+  }
+  .placeholder code { font-family: ui-monospace, monospace; font-size: 11px; color: var(--red-glow); background: rgba(255,85,96,.08); padding: 1px 4px; border-radius: 3px; }
+  body.has-msgs .placeholder { display: none; }
+  .input-row {
+    flex: 0 0 auto; padding: 10px 12px;
+    border-top: 1px solid var(--line);
+    display: flex; gap: 6px; align-items: flex-end;
+  }
+  textarea {
+    flex: 1; resize: none; min-height: 38px; max-height: 120px;
+    background: rgba(255,239,230,.04); color: var(--paper);
+    border: 1px solid var(--line); border-radius: 10px;
+    padding: 9px 12px; font: inherit; font-size: 13px;
+    outline: none; transition: border-color .12s;
+  }
+  textarea:focus { border-color: var(--red); }
+  button {
+    background: var(--red); color: #1a0506; border: 0; border-radius: 10px;
+    padding: 9px 14px; font-weight: 700; font-size: 13px; cursor: pointer;
+    transition: background .12s, opacity .12s;
+    height: 38px;
+  }
+  button:hover:not(:disabled) { background: var(--red-glow); }
+  button:disabled { opacity: 0.5; cursor: not-allowed; }
+  .mic { background: rgba(255,239,230,.06); color: var(--paper); border: 1px solid var(--line); padding: 0 12px; width: 44px; }
+  .mic:hover:not(:disabled) { background: rgba(255,85,96,.18); }
+  .mic.listening { background: var(--red-glow); color: #1a0506; border-color: var(--red-glow); }
+  .layout { display: flex; flex-direction: column; height: 100%; }
+</style>
+</head>
+<body class="layout">
+  <div class="head">
+    <div class="av-wrap"><div class="av" id="av"></div></div>
+    <div class="head-info">
+      <div class="name">BIG RED <span class="mode-pill" id="modePill"></span></div>
+      <div class="ctx" id="ctx">connecting…</div>
+    </div>
+  </div>
+  <div class="msgs" id="msgs">
+    <div class="placeholder">
+      Ask about a DW SKU like <code>DWKK-150018</code> and I'll fetch the live Shopify price.
+      Mic for voice. Esc to close.
+    </div>
+  </div>
+  <div class="input-row">
+    <button class="mic" id="micBtn" type="button" title="Hold to speak" aria-label="Voice input">🎙</button>
+    <textarea id="txt" rows="1" placeholder="Talk to Big Red…"></textarea>
+    <button id="sendBtn" type="button">Send</button>
+  </div>
+
+<script>
+const params = new URLSearchParams(location.search);
+const MODE = (params.get('mode') || 'retail').toLowerCase();
+const APP  = params.get('app') || '';
+const PAGE = params.get('page') || '';
+const TITLE = params.get('title') || '';
+const HIST = [];
+
+document.body.dataset.mode = MODE;
+document.getElementById('modePill').textContent = MODE;
+document.getElementById('modePill').className = 'mode-pill ' + MODE;
+
+const ctxEl = document.getElementById('ctx');
+if (MODE === 'admin' && APP) ctxEl.textContent = `${APP}${PAGE ? ' · ' + PAGE : ''}`;
+else if (MODE === 'wholesale') ctxEl.textContent = 'Trade catalog · vendor + MOQ + lead time';
+else ctxEl.textContent = 'Retail · live Shopify price verify';
+
+// Avatar — use what /api/avatar returns; falls back to BR monogram.
+fetch('/api/avatar').then(r => r.json()).then(d => {
+  if (d.exists && d.url) document.getElementById('av').style.backgroundImage = `url('${d.url}')`;
+  else document.getElementById('av').textContent = 'BR';
+}).catch(() => {});
+
+document.body.classList.toggle('live', false);
+// "Live" state — pulse on mic active or first message sent.
+function setLive(on) { document.body.classList.toggle('live', !!on); }
+
+const msgsEl = document.getElementById('msgs');
+const txt = document.getElementById('txt');
+const sendBtn = document.getElementById('sendBtn');
+const micBtn = document.getElementById('micBtn');
+
+function esc(s) { return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); }
+function render() {
+  document.body.classList.toggle('has-msgs', HIST.length > 0);
+  msgsEl.innerHTML = HIST.map(m =>
+    `<div class="msg ${m.role}${m.pending?' pending':''}">${esc(m.content)}</div>`
+  ).join('') || msgsEl.innerHTML;
+  if (HIST.length === 0) {
+    msgsEl.innerHTML = '<div class="placeholder">Ask about a DW SKU like <code>DWKK-150018</code> and I\\'ll fetch the live Shopify price. Mic for voice. Esc to close.</div>';
+  }
+  msgsEl.scrollTop = msgsEl.scrollHeight;
+}
+
+async function send() {
+  const message = txt.value.trim();
+  if (!message) return;
+  txt.value = '';
+  HIST.push({ role: 'user', content: message });
+  HIST.push({ role: 'assistant', content: '…', pending: true });
+  render();
+  setLive(true);
+  sendBtn.disabled = true;
+  try {
+    const r = await fetch('/api/chat', {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({
+        message,
+        history: HIST.filter(m => !m.pending).slice(0, -1),
+        mode: MODE,
+        app: APP,
+        page: PAGE,
+        title: TITLE,
+      }),
+    });
+    const d = await r.json();
+    HIST.pop();
+    if (d.reply) {
+      HIST.push({ role: 'assistant', content: d.reply });
+      // Speak with TTS if available — fire and forget; doesn't block UI.
+      tryTts(d.reply).catch(() => {});
+    } else {
+      HIST.push({ role: 'assistant', content: '⚠ ' + (d.error || 'no reply') });
+    }
+  } catch (e) {
+    HIST.pop();
+    HIST.push({ role: 'assistant', content: '⚠ ' + e.message });
+  }
+  sendBtn.disabled = false;
+  render();
+  txt.focus();
+}
+
+async function tryTts(text) {
+  try {
+    const r = await fetch('/api/tts', {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ text }),
+    });
+    if (!r.ok) return;
+    const blob = await r.blob();
+    const url = URL.createObjectURL(blob);
+    const audio = new Audio(url);
+    audio.play().catch(() => {});
+    audio.addEventListener('ended', () => URL.revokeObjectURL(url));
+  } catch {}
+}
+
+sendBtn.addEventListener('click', send);
+txt.addEventListener('keydown', (e) => {
+  if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); }
+});
+txt.focus();
+
+// Voice input — Web Speech API (Chrome/Safari)
+const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
+let rec = null, listening = false;
+if (SR) {
+  rec = new SR();
+  rec.lang = 'en-US';
+  rec.interimResults = false;
+  rec.continuous = false;
+  rec.onresult = (e) => {
+    const result = Array.from(e.results).map(r => r[0].transcript).join(' ').trim();
+    if (result) {
+      txt.value = result;
+      send();
+    }
+  };
+  rec.onend = () => { listening = false; micBtn.classList.remove('listening'); };
+  micBtn.addEventListener('click', () => {
+    if (listening) { try { rec.stop(); } catch {} return; }
+    try { rec.start(); listening = true; micBtn.classList.add('listening'); } catch {}
+  });
+} else {
+  micBtn.style.display = 'none';
+}
+
+document.addEventListener('keydown', (e) => {
+  if (e.key === 'Escape') {
+    // Notify parent widget to close
+    if (window.parent !== window) window.parent.postMessage({ type: 'bigred:close' }, '*');
+  }
+});
+</script>
+</body>
+</html>
diff --git a/public/widget.js b/public/widget.js
index 1890a06..11291e1 100644
--- a/public/widget.js
+++ b/public/widget.js
@@ -265,7 +265,7 @@
         page: location.pathname + location.search,
         title: document.title.slice(0, 120),
       });
-      iframe.src = host + '/?' + params.toString();
+      iframe.src = host + '/embed.html?' + params.toString();
     }
     launcher.classList.add('live');
     launcher.setAttribute('aria-label', 'Close Big Red (currently live)');
@@ -288,6 +288,10 @@
   document.addEventListener('keydown', (e) => {
     if (e.key === 'Escape' && panel.classList.contains('open')) close();
   });
+  // The embed iframe posts {type:'bigred:close'} on Esc inside the chat.
+  window.addEventListener('message', (e) => {
+    if (e && e.data && e.data.type === 'bigred:close') close();
+  });
 
   // Public API
   window.BigRed = { open, close, toggle, host, mode, app: appSlug };

← 0f3f22b feat: per-mode personas (retail / wholesale / admin) + app c  ·  back to Big Red  ·  feat(big-red/tick1): per-mode launcher ring color (next: sho 56b06d5 →