diff --git a/web/app.js b/web/app.js
index 1e62fe5..55d4ba5 100644
--- a/web/app.js
+++ b/web/app.js
@@ -58,7 +58,7 @@ const muteBtn = document.getElementById("muteBtn");
let micMuted = false;
const statusEl = document.getElementById("status");
const messagesEl = document.getElementById("messages");
-const orbEl = document.getElementById("orb");
+const ledWallEl = document.getElementById("ledWall");
const stateLabelEl = document.getElementById("stateLabel");
const thinkingChipEl = document.getElementById("thinkingChip");
const bargeHintEl = document.getElementById("bargeHint");
@@ -262,6 +262,84 @@ function teardownAudio() {
remoteCtx = remoteAnalyser = remoteFreqBuf = null;
}
+// ── LED wall — a grid of dots that pulses with the agent's audio ────────────
+const LED_COLS = 21;
+const LED_ROWS = 21;
+const LED_GROW = 1.4; // how much the dots grow toward the center
+let ledDots = [];
+let ledWallTime = 0;
+
+function buildLedWall() {
+ const inner = Math.floor(LED_COLS / 2);
+ const frag = document.createDocumentFragment();
+ for (let r = 0; r < LED_ROWS; r++) {
+ for (let c = 0; c < LED_COLS; c++) {
+ const b = document.createElement("button");
+ b.type = "button";
+ b.className = "led-dot";
+ b.disabled = true;
+ b.tabIndex = -1;
+ // Radial position: 0 at the center, 1 at the corner
+ const dx = (c - inner) / (LED_COLS / 2);
+ const dy = (r - inner) / (LED_ROWS / 2);
+ const rad = Math.sqrt(dx * dx + dy * dy);
+ // Push a radial cluster toward the center; scale its base brightness down
+ // as it moves outward so the wall reads as a soft round bloom.
+ b.dataset.rad = rad.toFixed(4);
+ // A per-dot phase so the wave ripples rather than flashing in unison.
+ b.dataset.phase = (Math.sin(r * 12.9898 + c * 78.233) * 43758.5453 % 1 * Math.PI * 2).toFixed(3);
+ ledDots.push(b);
+ frag.appendChild(b);
+ }
+ }
+ ledWallEl.appendChild(frag);
+}
+
+// Renders one frame of the LED wall. `level` is 0..1 (agent audio when speaking,
+// mic when listening). `mode` picks the palette/gradient by conversation state.
+function renderLedWall(level, mode) {
+ ledWallTime += 1;
+ ledWallEl.dataset.mode = mode;
+ const n = ledDots.length;
+ for (let i = 0; i < n; i++) {
+ const dot = ledDots[i];
+ const rad = parseFloat(dot.dataset.rad);
+ const phase = parseFloat(dot.dataset.phase || 0);
+
+ let vis = 0;
+ let size = 1;
+
+ if (mode === "idle") {
+ // A faint, static dot matrix — present but clearly dormant.
+ vis = 0.16 - rad * 0.08;
+ size = 1;
+ } else if (level > 0.005) {
+ // Centered breathing bloom that widens and brightens with the
+ // signal. A soft gaussian falloff keeps it circular; a gentle
+ // per-dot phase ripple adds life without banding.
+ const lvl = Math.min(1, level * 1.3);
+ const falloff = Math.exp(-rad * rad * (2.0 - lvl * 1.1));
+ const ripple = 0.62 + 0.38 * Math.sin(ledWallTime * 0.32 + phase);
+ vis = falloff * (0.35 + lvl) * ripple;
+ size = 1 + (1 - rad) * LED_GROW * lvl;
+ // Keep listening dimmer than speaking so the wall leans into the
+ // agent's output.
+ if (mode !== "speaking") vis *= 0.55;
+ } else {
+ // Powered state with no signal (e.g. thinking gap) — soft breathing.
+ const breathe = 0.5 + 0.5 * Math.sin(ledWallTime * 0.12 + phase);
+ vis = 0.14 - rad * 0.06 + 0.04 * breathe;
+ size = 1 + (1 - rad) * 0.3;
+ }
+
+ vis = Math.max(0.02, Math.min(1, vis));
+ // Dot grows toward the center and sits on a subtle 4px grid inset.
+ const px = (2 * size).toFixed(1);
+ dot.style.setProperty("--sz", `${px}px`);
+ dot.style.opacity = vis.toFixed(3);
+ }
+}
+
function readMicLevel() {
let level = 0;
if (micAnalyser && micBuf) {
@@ -308,7 +386,7 @@ const STATE_LABELS = {
function setState(next) {
uiState = next;
- orbEl.dataset.state = next;
+ ledWallEl.dataset.state = next;
stateLabelEl.textContent = STATE_LABELS[conversationMode][next] || next;
// Thinking timer: show the "thinking..." chip if it lingers past 2s
@@ -359,16 +437,14 @@ function startVisualLoop() {
// Mic meter (thin bar under the status line)
micMeterFillEl.style.width = `${Math.min(100, Math.round(micLevel * 250))}%`;
- // Orb pulse: scale/opacity from whichever stream is active
+ // LED wall: pulse on the agent's rendered audio when speaking, the mic
+ // while listening, and hold a faint matrix when idle.
if (uiState === "speaking") {
- orbEl.style.setProperty("--pulse-scale", (1 + agentAudioLevel * 0.35).toFixed(3));
- orbEl.style.setProperty("--pulse-opacity", Math.min(1, 0.55 + agentAudioLevel).toFixed(3));
+ renderLedWall(agentAudioLevel, "speaking");
} else if (uiState === "listening" && userSpeaking) {
- orbEl.style.setProperty("--pulse-scale", (1 + micLevel * 0.4).toFixed(3));
- orbEl.style.setProperty("--pulse-opacity", Math.min(1, 0.5 + micLevel).toFixed(3));
+ renderLedWall(micLevel, "listening");
} else {
- orbEl.style.setProperty("--pulse-scale", "1");
- orbEl.style.setProperty("--pulse-opacity", "");
+ renderLedWall(0, "idle");
}
// Agent audio visualizer bars on the latest agent bubble
@@ -393,8 +469,7 @@ function stopVisualLoop() {
if (visualTimer) clearInterval(visualTimer);
visualTimer = null;
micMeterFillEl.style.width = "0%";
- orbEl.style.setProperty("--pulse-scale", "1");
- orbEl.style.setProperty("--pulse-opacity", "");
+ renderLedWall(0, "idle");
}
// ── Conversation history (localStorage) ─────────────────────────────────────
@@ -1587,3 +1662,4 @@ closeMediaBtn.addEventListener("click", () => setMediaOpen(false));
startMediaPolling();
updateMediaControls();
+buildLedWall();
diff --git a/web/index.html b/web/index.html
index 4a361b3..217ce05 100644
--- a/web/index.html
+++ b/web/index.html
@@ -58,12 +58,7 @@
-
+
Ready when you are
Thinking…
diff --git a/web/style.css b/web/style.css
index 60cf3e8..d103b96 100644
--- a/web/style.css
+++ b/web/style.css
@@ -327,149 +327,68 @@ button {
padding-top: 0.75rem;
}
-.orb {
- --pulse-scale: 1;
- --pulse-opacity: 0.28;
+/* ── LED wall avatar ────────────────────────────────────────────────────── */
+.led-wall {
position: relative;
- width: 260px;
- height: 260px;
- display: grid;
- place-items: center;
- isolation: isolate;
-}
-
-.orb-core {
- position: relative;
- z-index: 3;
- width: 116px;
- height: 116px;
- overflow: hidden;
- border-radius: 50%;
- background:
- radial-gradient(circle at 32% 24%, rgba(244, 247, 251, 0.96) 0 3%, transparent 20%),
- radial-gradient(circle at 38% 30%, #b9c9ff, #6f8ff7 42%, #3d57ab 72%, #23305b 100%);
- box-shadow:
- 0 0 0 1px rgba(213, 222, 255, 0.24),
- 0 12px 28px rgba(2, 8, 20, 0.34),
- 0 0 52px rgba(111, 143, 247, 0.32),
- inset -12px -14px 26px rgba(10, 20, 50, 0.34);
- transform: scale(var(--pulse-scale));
- transition: filter 220ms ease, box-shadow 220ms ease, transform 90ms linear;
-}
-
-.orb-shimmer {
- position: absolute;
- width: 90px;
- height: 34px;
- left: 9px;
- top: 18px;
- border-radius: 50%;
- border-top: 1px solid rgba(255, 255, 255, 0.52);
- transform: rotate(-22deg);
- opacity: 0.7;
-}
-
-.orb-ring,
-.orb-orbit {
- position: absolute;
- border-radius: 50%;
- pointer-events: none;
-}
-
-.orb-ring {
- z-index: 2;
- width: 164px;
- height: 164px;
- border: 1px solid rgba(139, 167, 255, 0.2);
- opacity: var(--pulse-opacity);
- transform: scale(var(--pulse-scale));
- transition: opacity 180ms ease, transform 90ms linear;
-}
-
-.orb-orbit {
z-index: 1;
- border: 1px solid rgba(139, 167, 255, 0.15);
+ width: 320px;
+ height: 320px;
+ max-width: 100%;
+ display: grid;
+ grid-template-columns: repeat(21, 1fr);
+ grid-template-rows: repeat(21, 1fr);
+ gap: 2px;
+ place-items: center;
+ padding: 12px;
+ border-radius: 26px;
+ isolation: isolate;
+ transition: filter 220ms ease;
}
-.orbit-one {
- width: 214px;
- height: 140px;
- transform: rotate(-18deg);
-}
-
-.orbit-one::after {
+/* A soft glass panel that holds the matrix up off the page. */
+.led-wall::before {
content: "";
position: absolute;
- width: 7px;
- height: 7px;
- top: 12px;
- right: 37px;
- border-radius: 50%;
- background: var(--warm);
- box-shadow: 0 0 13px rgba(255, 179, 142, 0.65);
-}
-
-.orbit-two {
- width: 230px;
- height: 230px;
- border-color: rgba(139, 167, 255, 0.08);
-}
-
-.orb[data-state="idle"] .orb-core {
- filter: saturate(0.72) brightness(0.78);
- box-shadow:
- 0 0 0 1px rgba(213, 222, 255, 0.13),
- 0 12px 28px rgba(2, 8, 20, 0.3),
- 0 0 28px rgba(111, 143, 247, 0.16);
-}
-
-.orb[data-state="connecting"] .orbit-one,
-.orb[data-state="thinking"] .orbit-one {
- animation: orbit-turn 3.5s linear infinite;
-}
-
-.orb[data-state="connecting"] .orb-core {
- animation: presence-breathe 1.7s ease-in-out infinite;
-}
-
-.orb[data-state="listening"] .orb-core {
- box-shadow:
- 0 0 0 1px rgba(213, 222, 255, 0.4),
- 0 12px 28px rgba(2, 8, 20, 0.34),
- 0 0 68px rgba(111, 143, 247, 0.56),
- inset -12px -14px 26px rgba(10, 20, 50, 0.28);
-}
-
-.orb[data-state="listening"] .orb-ring {
- border-color: rgba(139, 167, 255, 0.5);
-}
-
-.orb[data-state="thinking"] .orb-core {
+ inset: 0;
+ border-radius: 26px;
+ border: 1px solid rgba(139, 167, 255, 0.14);
background:
- radial-gradient(circle at 32% 24%, rgba(255, 255, 255, 0.9) 0 3%, transparent 20%),
- radial-gradient(circle at 38% 30%, #f3c9b5, #a783ca 42%, #5d68bd 72%, #29365f 100%);
- box-shadow: 0 0 62px rgba(167, 131, 202, 0.48);
+ radial-gradient(circle at 50% 50%, rgba(111, 143, 247, 0.1), transparent 62%),
+ rgba(6, 12, 24, 0.4);
+ z-index: -1;
}
-.orb[data-state="speaking"] .orb-core {
- box-shadow:
- 0 0 0 1px rgba(213, 222, 255, 0.45),
- 0 12px 28px rgba(2, 8, 20, 0.34),
- 0 0 78px rgba(111, 143, 247, 0.64),
- 0 0 105px rgba(255, 179, 142, 0.11);
+.led-dot {
+ --sz: 5px;
+ width: var(--sz);
+ height: var(--sz);
+ min-width: 3px;
+ min-height: 3px;
+ border: 0;
+ border-radius: 50%;
+ background: currentColor;
+ opacity: 0.16;
+ transition: opacity 90ms linear, width 90ms linear, height 90ms linear;
}
-.orb[data-state="speaking"] .orbit-one::after {
- background: #ffd0b8;
+/* Palette shifts by state — a cool blue while it listens, warm toward the
+ center as the agent speaks, amber while thinking, dim when idle. */
+.led-wall[data-state="idle"] {
+ color: #56739e;
}
-@keyframes presence-breathe {
- 0%, 100% { transform: scale(calc(var(--pulse-scale) * 0.98)); }
- 50% { transform: scale(calc(var(--pulse-scale) * 1.045)); }
+.led-wall[data-state="connecting"],
+.led-wall[data-state="thinking"] {
+ color: #d99a6c;
}
-@keyframes orbit-turn {
- to { transform: rotate(342deg); }
+.led-wall[data-state="listening"] {
+ color: #6f8ff7;
+}
+
+.led-wall[data-state="speaking"] {
+ color: #9dc2ff;
+ filter: drop-shadow(0 0 14px rgba(111, 143, 247, 0.35));
}
.orb-labels {
@@ -964,10 +883,9 @@ body[data-mode="text"] .orb-stage {
min-height: 220px;
}
-body[data-mode="text"] .orb {
- width: 180px;
- height: 180px;
- transform: scale(0.72);
+body[data-mode="text"] .led-wall {
+ width: 260px;
+ height: 260px;
}
body[data-mode="text"] .controls {
@@ -1548,8 +1466,9 @@ body[data-mode="text"] .mic-meter {
margin: -1rem 0;
}
- .orb {
- transform: scale(0.78);
+ .led-wall {
+ width: 260px;
+ height: 260px;
}
.connection-strip,
@@ -1628,8 +1547,9 @@ body[data-mode="text"] .mic-meter {
margin: -0.75rem 0;
}
- body[data-mode="text"] .orb {
- transform: scale(0.48);
+ body[data-mode="text"] .led-wall {
+ width: 210px;
+ height: 210px;
}
.console-intro h2 {
@@ -1640,10 +1560,10 @@ body[data-mode="text"] .mic-meter {
min-height: 300px;
}
- .orb {
- width: 235px;
- height: 235px;
- transform: scale(0.94);
+ .led-wall {
+ width: 270px;
+ height: 270px;
+ padding: 9px;
}
.controls {