← 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
A public/embed.htmlM public/widget.js
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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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 →